Adding translations when a new attribute is created (#27313)

* reimplemented attribute translations

Signed-off-by: Agnieszka Gancarczyk <agancarc@redhat.com>

* added translations refresh

Signed-off-by: Agnieszka Gancarczyk <agancarc@redhat.com>

* improvement

Signed-off-by: Agnieszka Gancarczyk <agancarc@redhat.com>

* feedback

Signed-off-by: Agnieszka Gancarczyk <agancarc@redhat.com>

* feedback

Signed-off-by: Agnieszka Gancarczyk <agancarc@redhat.com>

* refactor

Signed-off-by: Agnieszka Gancarczyk <agancarc@redhat.com>

* refactor

Signed-off-by: Agnieszka Gancarczyk <agancarc@redhat.com>

* added type

Signed-off-by: Agnieszka Gancarczyk <agancarc@redhat.com>

---------

Signed-off-by: Agnieszka Gancarczyk <agancarc@redhat.com>
Co-authored-by: Agnieszka Gancarczyk <agancarc@redhat.com>
This commit is contained in:
agagancarczyk 2024-03-04 11:39:20 +00:00 committed by GitHub
parent 2c750c8ffb
commit f49efd0a51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 735 additions and 61 deletions

View file

@ -1181,7 +1181,7 @@ deleteClientSuccess=Client profile deleted
emptyClientScopesPrimaryAction=Add client scopes
addStepTo=Add step to {{name}}
eventTypes.AUTHREQID_TO_TOKEN_ERROR.description=Authreqid to token error
deleteAttributeConfirm=Are you sure you want to permanently delete the attribute {{attributeName}}?
deleteAttributeConfirm=Are you sure you want to permanently delete the attribute {{attributeName}} and its corresponding translations?
chooseResources=Choose the resources you want to import
selectOne=Select an option
emailTheme=Email theme
@ -1592,7 +1592,7 @@ exportFail=Could not export realm\: '{{error}}'
flowTypeHelp=What kind of form is it
targetHelp=Destination field for the mapper. LOCAL (default) means that the changes are applied to the username stored in local database upon user import. BROKER_ID and BROKER_USERNAME means that the changes are stored into the ID or username used for federation user lookup, respectively.
setPasswordConfirm=Set password?
attributeDisplayNameHelp=Display name for the attribute. Supports keys for localized values as well. For example\: ${profile.attribute.phoneNumber}.
attributeDisplayNameHelp=Display name for the attribute. Supports keys for localized values as well. For example\: ${profile.attribute.phoneNumber}. Hit the 'Globe Route' icon to add translations.
assignedType=Assigned type
modeHelp=LDAP_ONLY means that all group mappings of users are retrieved from LDAP and saved into LDAP. READ_ONLY is Read-only LDAP mode where group mappings are retrieved from both LDAP and DB and merged together. New group joins are not saved to LDAP but to DB. IMPORT is Read-only LDAP mode where group mappings are retrieved from LDAP just at the time when user is imported from LDAP and then they are saved to local keycloak DB.
identityProvider=Identity provider
@ -3075,4 +3075,25 @@ to the attribute. For that, make sure to use any of the built-in validators to p
sendIdTokenOnLogout=Send 'id_token_hint' in logout requests
sendIdTokenOnLogoutHelp=If the 'id_token_hint' parameter should be sent in logout requests.
sendClientIdOnLogout=Send 'client_id' in logout requests
sendClientIdOnLogoutHelp=If the 'client_id' parameter should be sent in logout requests.
sendClientIdOnLogoutHelp=If the 'client_id' parameter should be sent in logout requests.
searchClientAuthorizationPermission=Search permission
addAttributeTranslationBtn=Add translation button
addAttributeTranslationTooltip=Add translations for this field value.
addTranslationsModalTitle=Add translations
addTranslationsModalSubTitle=You are able to translate the "Display name" based on your locale or preferred languages. In addition, you are also able to create or edit the "Display name" translations in the
addTranslationsModalSubTitleBolded=Realm settings > Localization > Realm overrides.
translationKey=Key
translationValue=Value
translationsTableHeading=Translations
searchForLanguage=Search for language
supportedLanguagesTableColumnName=Supported languages
translationTableColumnName=Translation
defaultLanguage=Default
translationValue=Translation value
noLanguages=No languages
noLanguagesInstructions=Add a language in Realm settings > Localization > Locales to get started.
addTranslationsDialogRowsTable=Add a translations dialog rows table
addTranslationDialogHelperText=The translation based on the default language is required.
noLanguagesSearchResultsInstructions=Click on the search bar above to search for languages
addTranslationDialogOkBtn=Ok
translationError=Please add translations before saving

View file

@ -40,7 +40,7 @@ export const AddTranslationModal = ({
return (
<Modal
variant={ModalVariant.small}
title={t("AddTranslation")}
title={t("addTranslation")}
isOpen
onClose={handleModalToggle}
actions={[

View file

@ -1,3 +1,4 @@
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import type {
UserProfileAttribute,
UserProfileConfig,
@ -31,6 +32,16 @@ import { AttributeValidations } from "./user-profile/attribute/AttributeValidati
import "./realm-settings-section.css";
type TranslationForm = {
locale: string;
value: string;
};
type Translations = {
key: string;
translations: TranslationForm[];
};
type IndexedAnnotations = {
key: string;
value?: Record<string, unknown>;
@ -81,21 +92,41 @@ 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 /> },
{
title: t("generalSettings"),
panel: (
<AttributeGeneralSettings
onHandlingTranslationData={handleTranslationsData}
onHandlingGeneratedDisplayName={handleGeneratedDisplayName}
/>
),
},
{ title: t("permission"), panel: <AttributePermission /> },
{ title: t("validations"), panel: <AttributeValidations /> },
{ title: t("annotations"), panel: <AttributeAnnotations /> },
@ -124,13 +155,30 @@ const CreateAttributeFormContent = ({
};
export default function NewAttributeSettings() {
const { realm, attributeName } = useParams<AttributeParams>();
const { realm: realmName, attributeName } = useParams<AttributeParams>();
const form = useForm<UserProfileAttributeFormFields>();
const { t } = useTranslation();
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>("");
const [realm, setRealm] = useState<RealmRepresentation>();
useFetch(
() => adminClient.realms.findOne({ realm: realmName }),
(realm) => {
if (!realm) {
throw new Error(t("notFound"));
}
setRealm(realm);
},
[],
);
useFetch(
() => adminClient.users.getProfile(),
@ -178,6 +226,30 @@ 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) {
console.error(`Error saving translation for ${translation.locale}`);
}
});
await Promise.all(nonEmptyTranslations);
} catch (error) {
console.error(`Error saving translations: ${error}`);
}
};
const save = async ({
hasSelector,
hasRequiredScopes,
@ -206,7 +278,7 @@ export default function NewAttributeSettings() {
);
const patchAttributes = () =>
config?.attributes!.map((attribute) => {
(config?.attributes || []).map((attribute) => {
if (attribute.name !== attributeName) {
return attribute;
}
@ -229,11 +301,11 @@ export default function NewAttributeSettings() {
});
const addAttribute = () =>
config?.attributes!.concat([
(config?.attributes || []).concat([
Object.assign(
{
name: formFields.name,
displayName: formFields.displayName!,
displayName: formFields.displayName! || generatedDisplayName,
required: formFields.isRequired ? formFields.required : undefined,
selector: formFields.selector,
permissions: formFields.permissions!,
@ -246,16 +318,28 @@ export default function NewAttributeSettings() {
),
] as UserProfileAttribute);
const updatedAttributes = editMode ? patchAttributes() : addAttribute();
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();
await adminClient.users.updateProfile({
...config,
attributes: updatedAttributes as UserProfileAttribute[],
realm,
realm: realmName,
});
navigate(toUserProfile({ realm, tab: "attributes" }));
await saveTranslations();
navigate(toUserProfile({ realm: realmName, tab: "attributes" }));
addAlert(t("createAttributeSuccess"), AlertVariant.success);
} catch (error) {
@ -270,7 +354,11 @@ export default function NewAttributeSettings() {
subKey={editMode ? "" : t("createAttributeSubTitle")}
/>
<PageSection variant="light">
<CreateAttributeFormContent save={() => form.handleSubmit(save)()} />
<CreateAttributeFormContent
save={() => form.handleSubmit(save)()}
onHandlingTranslationsData={setTranslationsData}
onHandlingGeneratedDisplayName={setGeneratedDisplayName}
/>
</PageSection>
</FormProvider>
);

View file

@ -9,7 +9,7 @@ import {
TabTitleText,
Tooltip,
} from "@patternfly/react-core";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@ -53,6 +53,7 @@ import { ClientPoliciesTab, toClientPolicies } from "./routes/ClientPolicies";
import { RealmSettingsTab, toRealmSettings } from "./routes/RealmSettings";
import { SecurityDefenses } from "./security-defences/SecurityDefenses";
import { UserProfileTab } from "./user-profile/UserProfileTab";
import { DEFAULT_LOCALE } from "../i18n/i18n";
export interface UIRealmRepresentation extends RealmRepresentation {
upConfig?: UserProfileConfig;
@ -174,6 +175,7 @@ const RealmSettingsHeader = ({
type RealmSettingsTabsProps = {
realm: UIRealmRepresentation;
refresh: () => void;
tableData?: Record<string, string>[];
};
export const RealmSettingsTabs = ({
@ -186,6 +188,9 @@ export const RealmSettingsTabs = ({
const { refresh: refreshRealms } = useRealms();
const navigate = useNavigate();
const isFeatureEnabled = useIsFeatureEnabled();
const [tableData, setTableData] = useState<
Record<string, string>[] | undefined
>(undefined);
const { control, setValue, getValues } = useForm({
mode: "onChange",
@ -200,7 +205,47 @@ export const RealmSettingsTabs = ({
convertToFormValues(r, setValue);
};
useEffect(setupForm, [setValue, realm]);
const defaultSupportedLocales = useMemo(() => {
return realm.supportedLocales?.length
? realm.supportedLocales
: [DEFAULT_LOCALE];
}, [realm]);
const defaultLocales = useMemo(() => {
return realm.defaultLocale?.length ? [realm.defaultLocale] : [];
}, [realm]);
const combinedLocales = useMemo(() => {
return Array.from(new Set([...defaultLocales, ...defaultSupportedLocales]));
}, [defaultLocales, defaultSupportedLocales]);
useEffect(() => {
setupForm();
const fetchLocalizationTexts = async () => {
try {
await Promise.all(
combinedLocales.map(async (locale) => {
try {
const response =
await adminClient.realms.getRealmLocalizationTexts({
realm: realmName,
selectedLocale: locale,
});
if (response) {
setTableData([response]);
}
} catch (error) {
return [];
}
}),
);
} catch (error) {
return [];
}
};
fetchLocalizationTexts();
}, [setValue, realm]);
const save = async (r: UIRealmRepresentation) => {
r = convertFormValuesToObject(r);
@ -354,7 +399,12 @@ export const RealmSettingsTabs = ({
data-testid="rs-localization-tab"
{...localizationTab}
>
<LocalizationTab key={key} save={save} realm={realm} />
<LocalizationTab
key={key}
save={save}
realm={realm}
tableData={tableData}
/>
</Tab>
<Tab
title={<TabTitleText>{t("securityDefences")}</TabTitleText>}
@ -422,7 +472,7 @@ export const RealmSettingsTabs = ({
data-testid="rs-user-profile-tab"
{...userProfileTab}
>
<UserProfileTab />
<UserProfileTab setTableData={setTableData as any} />
</Tab>
<Tab
title={<TabTitleText>{t("userRegistration")}</TabTitleText>}

View file

@ -26,9 +26,14 @@ import { RealmOverrides } from "./RealmOverrides";
type LocalizationTabProps = {
save: (realm: RealmRepresentation) => void;
realm: RealmRepresentation;
tableData: Record<string, string>[] | undefined;
};
export const LocalizationTab = ({ save, realm }: LocalizationTabProps) => {
export const LocalizationTab = ({
save,
realm,
tableData,
}: LocalizationTabProps) => {
const { t } = useTranslation();
const { whoAmI } = useWhoAmI();
@ -244,6 +249,7 @@ export const LocalizationTab = ({ save, realm }: LocalizationTabProps) => {
internationalizationEnabled={internationalizationEnabled}
watchSupportedLocales={watchSupportedLocales}
realm={realm}
tableData={tableData}
/>
</Tab>
<Tab

View file

@ -34,7 +34,7 @@ import {
Thead,
Tr,
} from "@patternfly/react-table";
import RealmRepresentation from "libs/keycloak-admin-client/lib/defs/realmRepresentation";
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import { cloneDeep, isEqual, uniqWith } from "lodash-es";
import { ChangeEvent, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@ -56,6 +56,7 @@ type RealmOverridesProps = {
internationalizationEnabled: boolean;
watchSupportedLocales: string[];
realm: RealmRepresentation;
tableData: Record<string, string>[] | undefined;
};
type EditStatesType = { [key: number]: boolean };
@ -77,6 +78,7 @@ export const RealmOverrides = ({
internationalizationEnabled,
watchSupportedLocales,
realm,
tableData,
}: RealmOverridesProps) => {
const { t } = useTranslation();
const [addTranslationModalOpen, setAddTranslationModalOpen] = useState(false);
@ -173,7 +175,7 @@ export const RealmOverrides = ({
setTableRows(updatedRows);
});
}, [tableKey, first, max, filter]);
}, [tableKey, tableData, first, max, filter]);
const handleModalToggle = () => {
setAddTranslationModalOpen(!addTranslationModalOpen);

View file

@ -293,3 +293,7 @@ input#kc-scopes {
.kc--attributes-validations--action-cell {
text-align: right;
}
.kc-attribute-display-name-iconBtn:hover {
color: var(--pf-c-button--m-primary--active--BackgroundColor);
}

View file

@ -1,3 +1,4 @@
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
import {
Button,
@ -12,7 +13,7 @@ import {
} from "@patternfly/react-core";
import { FilterIcon } from "@patternfly/react-icons";
import { uniqBy } from "lodash-es";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { DraggableTable } from "../../authentication/components/DraggableTable";
@ -23,12 +24,21 @@ import useToggle from "../../utils/useToggle";
import { toAddAttribute } from "../routes/AddAttribute";
import { toAttribute } from "../routes/Attribute";
import { useUserProfile } from "./UserProfileContext";
import { useFetch } from "../../utils/useFetch";
import { adminClient } from "../../admin-client";
import { DEFAULT_LOCALE } from "../../i18n/i18n";
const RESTRICTED_ATTRIBUTES = ["username", "email"];
type movedAttributeType = UserProfileAttribute;
export const AttributesTab = () => {
type AttributesTabProps = {
setTableData: React.Dispatch<
React.SetStateAction<Record<string, string>[] | undefined>
>;
};
export const AttributesTab = ({ setTableData }: AttributesTabProps) => {
const { config, save } = useUserProfile();
const { realm: realmName } = useRealm();
const { t } = useTranslation();
@ -38,6 +48,32 @@ export const AttributesTab = () => {
useToggle();
const [data, setData] = useState(config?.attributes);
const [attributeToDelete, setAttributeToDelete] = useState("");
const [realm, setRealm] = useState<RealmRepresentation>();
useFetch(
() => adminClient.realms.findOne({ realm: realmName }),
(realm) => {
if (!realm) {
throw new Error(t("notFound"));
}
setRealm(realm);
},
[],
);
const defaultSupportedLocales = useMemo(() => {
return realm?.supportedLocales?.length
? realm.supportedLocales
: [DEFAULT_LOCALE];
}, [realm]);
const defaultLocales = useMemo(() => {
return realm?.defaultLocale?.length ? [realm.defaultLocale] : [];
}, [realm]);
const combinedLocales = useMemo(() => {
return Array.from(new Set([...defaultLocales, ...defaultSupportedLocales]));
}, [defaultLocales, defaultSupportedLocales]);
const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: t("deleteAttributeConfirmTitle"),
@ -49,18 +85,58 @@ export const AttributesTab = () => {
onConfirm: async () => {
if (!config?.attributes) return;
const updatedAttributes = config.attributes.filter(
(attribute) => attribute.name !== attributeToDelete,
);
const translationsToDelete = config.attributes.find(
(attribute) => attribute.name === attributeToDelete,
)?.displayName;
save(
{ ...config, attributes: updatedAttributes!, groups: config.groups },
{
successMessageKey: "deleteAttributeSuccess",
errorMessageKey: "deleteAttributeError",
},
);
setAttributeToDelete("");
try {
await Promise.all(
combinedLocales.map(async (locale) => {
try {
const response =
await adminClient.realms.getRealmLocalizationTexts({
realm: realmName,
selectedLocale: locale,
});
if (response) {
await adminClient.realms.deleteRealmLocalizationTexts({
realm: realmName,
selectedLocale: locale,
key: translationsToDelete,
});
const updatedData =
await adminClient.realms.getRealmLocalizationTexts({
realm: realmName,
selectedLocale: locale,
});
setTableData([updatedData]);
}
} catch (error) {
console.error(`Error removing translations for ${locale}`);
}
}),
);
const updatedAttributes = config.attributes.filter(
(attribute) => attribute.name !== attributeToDelete,
);
save(
{ ...config, attributes: updatedAttributes, groups: config.groups },
{
successMessageKey: "deleteAttributeSuccess",
errorMessageKey: "deleteAttributeError",
},
);
setAttributeToDelete("");
} catch (error) {
console.error(
`Error removing translations or updating attributes: ${error}`,
);
}
},
});

View file

@ -15,7 +15,13 @@ import { AttributesTab } from "./AttributesTab";
import { JsonEditorTab } from "./JsonEditorTab";
import { UserProfileProvider } from "./UserProfileContext";
export const UserProfileTab = () => {
type UserProfileTabProps = {
setTableData: React.Dispatch<
React.SetStateAction<Record<string, string>[] | undefined>
>;
};
export const UserProfileTab = ({ setTableData }: UserProfileTabProps) => {
const { realm } = useRealm();
const { t } = useTranslation();
@ -37,7 +43,7 @@ export const UserProfileTab = () => {
data-testid="attributesTab"
{...attributesTab}
>
<AttributesTab />
<AttributesTab setTableData={setTableData} />
</Tab>
<Tab
title={<TabTitleText>{t("attributesGroup")}</TabTitleText>}

View file

@ -0,0 +1,290 @@
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import {
Button,
Flex,
FlexItem,
Form,
FormGroup,
Label,
Modal,
ModalVariant,
Text,
TextContent,
TextVariants,
} from "@patternfly/react-core";
import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table";
import { SearchIcon } from "@patternfly/react-icons";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FormProvider, useForm } from "react-hook-form";
import { useRealm } from "../../../context/realm-context/RealmContext";
import { useWhoAmI } from "../../../context/whoami/WhoAmI";
import { adminClient } from "../../../admin-client";
import { PaginatingTableToolbar } from "../../../components/table-toolbar/PaginatingTableToolbar";
import { ListEmptyState } from "../../../components/list-empty-state/ListEmptyState";
import { useFetch } from "../../../utils/useFetch";
import { localeToDisplayName } from "../../../util";
import { DEFAULT_LOCALE } from "../../../i18n/i18n";
import { TextControl } from "ui-shared";
type TranslationForm = {
locale: string;
value: string;
};
type Translations = {
key: string;
translations: TranslationForm[];
};
export type AddTranslationsDialogProps = {
translationKey: string;
onCancel: () => void;
toggleDialog: () => void;
onTranslationsAdded: (translations: Translations) => void;
};
export const AddTranslationsDialog = ({
translationKey,
onCancel,
toggleDialog,
onTranslationsAdded,
}: AddTranslationsDialogProps) => {
const { t } = useTranslation();
const { realm: realmName } = useRealm();
const [realm, setRealm] = useState<RealmRepresentation>();
const { whoAmI } = useWhoAmI();
const [max, setMax] = useState(10);
const [first, setFirst] = useState(0);
const [filter, setFilter] = useState("");
const form = useForm<{
key: string;
translations: TranslationForm[];
}>({
mode: "onChange",
});
const {
getValues,
handleSubmit,
setValue,
formState: { isValid },
} = form;
useFetch(
() => adminClient.realms.findOne({ realm: realmName }),
(realm) => {
if (!realm) {
throw new Error(t("notFound"));
}
setRealm(realm);
},
[],
);
const defaultSupportedLocales = useMemo(() => {
return realm?.supportedLocales!.length
? realm.supportedLocales
: [DEFAULT_LOCALE];
}, [realm]);
const defaultLocales = useMemo(() => {
return realm?.defaultLocale!.length ? [realm.defaultLocale] : [];
}, [realm]);
const combinedLocales = useMemo(() => {
return Array.from(new Set([...defaultLocales, ...defaultSupportedLocales]));
}, [defaultLocales, defaultSupportedLocales]);
const filteredLocales = useMemo(() => {
return combinedLocales.filter((locale) =>
localeToDisplayName(locale, whoAmI.getLocale())!
.toLowerCase()
.includes(filter.toLowerCase()),
);
}, [combinedLocales, filter, whoAmI]);
useEffect(() => {
combinedLocales.forEach((locale, rowIndex) => {
setValue(`translations.${rowIndex}`, {
locale,
value: "",
});
setValue("key", translationKey);
});
}, [combinedLocales, translationKey, setValue]);
const handleOk = () => {
const formData = getValues();
onTranslationsAdded(formData);
toggleDialog();
};
return (
<Modal
variant={ModalVariant.medium}
title={t("addTranslationsModalTitle")}
isOpen
onClose={toggleDialog}
actions={[
<Button
key="ok"
data-testid="okTranslationBtn"
variant="primary"
type="submit"
form="add-translation"
isDisabled={!isValid}
>
{t("addTranslationDialogOkBtn")}
</Button>,
<Button
key="cancel"
data-testid="cancelTranslationBtn"
variant="link"
onClick={onCancel}
>
{t("cancel")}
</Button>,
]}
>
<Flex
direction={{ default: "column" }}
spaceItems={{ default: "spaceItemsNone" }}
>
<FlexItem>
<TextContent>
<Text component={TextVariants.p}>
{t("addTranslationsModalSubTitle")}{" "}
<strong>{t("addTranslationsModalSubTitleBolded")}</strong>
</Text>
</TextContent>
</FlexItem>
<FlexItem>
<FormProvider {...form}>
<Form
id="add-translation"
data-testid="addTranslationForm"
onSubmit={handleSubmit(handleOk)}
>
<TextControl
name="key"
label={t("translationKey")}
className="pf-u-mt-md"
data-testid="translation-key"
isDisabled
/>
<FlexItem>
<TextContent>
<Text
className="pf-u-font-size-sm pf-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")}
>
{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-u-py-lg">
{t("supportedLanguagesTableColumnName")}
</Th>
<Th className="pf-u-py-lg">
{t("translationTableColumnName")}
</Th>
<Th aria-hidden="true" />
</Tr>
</Thead>
<Tbody>
{filteredLocales.map((locale, rowIndex) => (
<Tr key={rowIndex}>
<Td
className="pf-m-sm pf-u-px-sm"
dataLabel={t("supportedLanguage")}
>
<FormGroup fieldId="kc-supportedLanguage">
{localeToDisplayName(
locale,
whoAmI.getLocale(),
)}
{locale === defaultLocales.toString() && (
<Label className="pf-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"
rules={{
required: {
value: true,
message: t("required"),
},
}}
/>
)}
{locale !== defaultLocales.toString() && (
<TextControl
name={`translations.${rowIndex}.value`}
label={t("translationValue")}
data-testid="translation-value"
/>
)}
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</PaginatingTableToolbar>
</FlexItem>
</Form>
</FormProvider>
</FlexItem>
</Flex>
</Modal>
);
};

View file

@ -1,36 +1,38 @@
import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation";
import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import {
Button,
Divider,
FormGroup,
Grid,
GridItem,
Radio,
Select,
SelectOption,
SelectVariant,
Switch,
Tooltip,
} from "@patternfly/react-core";
import { isEqual } from "lodash-es";
import { useState } from "react";
import {
Controller,
FormProvider,
useFormContext,
useWatch,
} from "react-hook-form";
import { useEffect, useRef, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { HelpItem } from "ui-shared";
import { adminClient } from "../../../admin-client";
import { DefaultSwitchControl } from "../../../components/SwitchControl";
import { FormAccess } from "../../../components/form/FormAccess";
import { KeycloakSpinner } from "../../../components/keycloak-spinner/KeycloakSpinner";
import { KeycloakTextInput } from "../../../components/keycloak-text-input/KeycloakTextInput";
import { useFetch } from "../../../utils/useFetch";
import { useParams } from "../../../utils/useParams";
import { USERNAME_EMAIL } from "../../NewAttributeSettings";
import type { AttributeParams } from "../../routes/Attribute";
import "../../realm-settings-section.css";
import { GlobeRouteIcon } from "@patternfly/react-icons";
import { AddTranslationsDialog } from "./AddTranslationsDialog";
import useToggle from "../../../utils/useToggle";
import { AttributeParams } from "../../routes/Attribute";
import { useRealm } from "../../../context/realm-context/RealmContext";
const REQUIRED_FOR = [
{ label: "requiredForLabel.both", value: ["admin", "user"] },
@ -38,9 +40,29 @@ const REQUIRED_FOR = [
{ label: "requiredForLabel.admins", value: ["admin"] },
] as const;
export const AttributeGeneralSettings = () => {
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) => {
const { t } = useTranslation();
const { realm: realmName } = useRealm();
const form = useFormContext();
const tooltipRef = useRef();
const [clientScopes, setClientScopes] =
useState<ClientScopeRepresentation[]>();
const [config, setConfig] = useState<UserProfileConfig>();
@ -48,8 +70,31 @@ export const AttributeGeneralSettings = () => {
const [selectRequiredForOpen, setSelectRequiredForOpen] = useState(false);
const [isAttributeGroupDropdownOpen, setIsAttributeGroupDropdownOpen] =
useState(false);
const [addTranslationsModalOpen, toggleModal] = useToggle();
const { attributeName } = useParams<AttributeParams>();
const editMode = attributeName ? true : false;
const [realm, setRealm] = useState<RealmRepresentation>();
const [newAttributeName, setNewAttributeName] = useState("");
const [generatedDisplayName, setGeneratedDisplayName] = useState("");
const [translationsData, setTranslationsData] = useState<Translations>({
key: "",
translations: [],
});
const displayNameRegex = /\$\{([^}]+)\}/;
const handleAttributeNameChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const newAttributeName = event.target.value;
setNewAttributeName(newAttributeName);
const newDisplayName =
newAttributeName !== "" && realm?.internationalizationEnabled
? "${profile.attributes." + `${newAttributeName}}`
: "";
setGeneratedDisplayName(newDisplayName);
};
const hasSelector = useWatch({
control: form.control,
@ -67,9 +112,40 @@ export const AttributeGeneralSettings = () => {
defaultValue: false,
});
const attributeDisplayName = useWatch({
control: form.control,
name: "displayName",
});
const displayNamePatternMatch = displayNameRegex.test(attributeDisplayName);
useFetch(
() => adminClient.realms.findOne({ realm: realmName }),
(realm) => {
if (!realm) {
throw new Error(t("notFound"));
}
setRealm(realm);
},
[],
);
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 />;
}
@ -82,9 +158,33 @@ export const AttributeGeneralSettings = () => {
form.setValue("hasRequiredScopes", hasRequiredScopes);
}
const handleTranslationsAdded = (translationsData: Translations) => {
setTranslationsData(translationsData);
};
const handleToggleDialog = () => {
toggleModal();
handleTranslationsData(translationsData);
handleGeneratedDisplayName(generatedDisplayName);
};
return (
<FormAccess role="manage-realm" isHorizontal>
<FormProvider {...form}>
<>
{addTranslationsModalOpen && (
<AddTranslationsDialog
translationKey={
editMode
? attributeDisplayName
: "${profile.attributes." + `${newAttributeName}}`
}
onTranslationsAdded={handleTranslationsAdded}
toggleDialog={handleToggleDialog}
onCancel={() => {
toggleModal();
}}
/>
)}
<FormAccess role="manage-realm" isHorizontal>
<FormGroup
label={t("attributeName")}
labelIcon={
@ -106,6 +206,7 @@ export const AttributeGeneralSettings = () => {
isDisabled={editMode}
validated={form.formState.errors.name ? "error" : "default"}
{...form.register("name", { required: true })}
onChange={handleAttributeNameChange}
/>
</FormGroup>
<FormGroup
@ -118,18 +219,48 @@ export const AttributeGeneralSettings = () => {
}
fieldId="kc-attribute-display-name"
>
<KeycloakTextInput
id="kc-attribute-display-name"
defaultValue=""
data-testid="attribute-display-name"
{...form.register("displayName")}
/>
<Grid hasGutter>
<GridItem span={realm?.internationalizationEnabled ? 11 : 12}>
<KeycloakTextInput
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")}
/>
</GridItem>
{realm?.internationalizationEnabled && (
<GridItem span={1}>
<Button
ref={tooltipRef}
variant="link"
className="pf-m-plain kc-attribute-display-name-iconBtn"
data-testid="addAttributeTranslationBtn"
aria-label={t("addAttributeTranslationBtn")}
isDisabled={!newAttributeName && !editMode}
onClick={() => {
toggleModal();
}}
icon={<GlobeRouteIcon />}
/>
<Tooltip
content={t("addAttributeTranslationTooltip")}
reference={tooltipRef}
/>
</GridItem>
)}
</Grid>
</FormGroup>
<DefaultSwitchControl
name="multivalued"
label={t("multivalued")}
labelIcon={t("multivaluedHelp")}
/>
<FormGroup
label={t("attributeGroup")}
labelIcon={
@ -407,7 +538,7 @@ export const AttributeGeneralSettings = () => {
)}
</>
)}
</FormProvider>
</FormAccess>
</FormAccess>
</>
);
};