Added localization for User Profile attribute groups (#29374)

* resolved conflicts

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

* added localization feature to up attributes groups

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

* refactor

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

* fix linting

Signed-off-by: Jon Koops <jonkoops@gmail.com>

* fixed attribute groups test

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

* fixed another failing test

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

* reverted the test change

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

---------

Signed-off-by: Agnieszka Gancarczyk <agancarc@redhat.com>
Signed-off-by: Jon Koops <jonkoops@gmail.com>
Co-authored-by: Agnieszka Gancarczyk <agancarc@redhat.com>
Co-authored-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
agagancarczyk 2024-05-13 14:50:00 +01:00 committed by GitHub
parent e200ccfa53
commit b01e47feec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 674 additions and 135 deletions

View file

@ -571,7 +571,9 @@ describe("Clients test", () => {
cy.findByTestId("importClient").click();
cy.findByTestId("realm-file").selectFile(
"cypress/fixtures/partial-import-test-data/import-identical-client.json",
{ action: "drag-drop" },
{
action: "drag-drop",
},
);
cy.wait(1000);

View file

@ -60,9 +60,10 @@ export default class ListingPage extends CommonElements {
#tableNameColumnPrefix = "name-column-";
#rowGroup = "table:visible tbody[role='rowgroup']";
#tableHeaderCheckboxItemAllRows = "input[aria-label='Select all rows']";
#searchBtnInModal =
".pf-v5-c-modal-box .pf-v5-c-toolbar__content-section button.pf-m-control:visible";
#menuContent = ".pf-v5-c-menu__content";
#menuItemText = ".pf-v5-c-menu__item-text";
#getSearchInput() {
return cy.findAllByTestId("table-search-input").last().find("input");
@ -189,6 +190,14 @@ export default class ListingPage extends CommonElements {
return this;
}
clickMenuDelete() {
cy.get(this.#menuContent)
.find(this.#menuItemText)
.contains("Delete")
.click({ force: true });
return this;
}
clickItemCheckbox(itemName: string) {
cy.get(this.#itemsRows)
.contains(itemName)
@ -247,7 +256,7 @@ export default class ListingPage extends CommonElements {
deleteItem(itemName: string) {
this.clickRowDetails(itemName);
this.clickDetailMenu("Delete");
this.clickMenuDelete();
return this;
}

View file

@ -3085,9 +3085,13 @@ 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
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
addTranslationsModalSubTitleBolded=Realm settings > Localization > Realm overrides.
addTranslationsModalSubTitleDescription=You are able to translate the "Display description" based on your locale or preferred languages. In addition, you are also able to create or edit the "Display description" translations in the
addAttributesGroupTranslationInfo=Add translations for this field using the icon next to the "Display name" field.
translationKey=Key
translationsTableHeading=Translations
searchForLanguage=Search for language

View file

@ -10,7 +10,7 @@ import {
PageSection,
} from "@patternfly/react-core";
import { flatten } from "flat";
import { useMemo, useState } from "react";
import { useState } from "react";
import { FormProvider, useForm, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
@ -29,8 +29,7 @@ import { AttributeAnnotations } from "./user-profile/attribute/AttributeAnnotati
import { AttributeGeneralSettings } from "./user-profile/attribute/AttributeGeneralSettings";
import { AttributePermission } from "./user-profile/attribute/AttributePermission";
import { AttributeValidations } from "./user-profile/attribute/AttributeValidations";
import { DEFAULT_LOCALE } from "../i18n/i18n";
import useLocale from "../utils/useLocale";
import "./realm-settings-section.css";
type TranslationForm = {
@ -157,10 +156,10 @@ const CreateAttributeFormContent = ({
export default function NewAttributeSettings() {
const { adminClient } = useAdminClient();
const { realm: realmName, attributeName } = useParams<AttributeParams>();
const form = useForm<UserProfileAttributeFormFields>();
const { t } = useTranslation();
const combinedLocales = useLocale();
const navigate = useNavigate();
const { addAlert, addError } = useAlerts();
const [config, setConfig] = useState<UserProfileConfig | null>(null);
@ -172,20 +171,6 @@ export default function NewAttributeSettings() {
const [generatedDisplayName, setGeneratedDisplayName] = useState<string>("");
const [realm, setRealm] = useState<RealmRepresentation>();
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]);
useFetch(
() => adminClient.realms.findOne({ realm: realmName }),
(realm) => {

View file

@ -1,7 +1,7 @@
import { fetchWithError } from "@keycloak/keycloak-admin-client";
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
import { AdminEnvironment, useEnvironment } from "@keycloak/keycloak-ui-shared";
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import {
AlertVariant,
ButtonVariant,
@ -14,11 +14,10 @@ import {
DropdownItem,
DropdownSeparator,
} from "@patternfly/react-core/deprecated";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useAdminClient } from "../admin-client";
import { useAlerts } from "../components/alert/Alerts";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import type { KeyValueType } from "../components/key-value-form/key-value-convert";
@ -28,17 +27,16 @@ import {
} from "../components/routable-tabs/RoutableTabs";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useRealms } from "../context/RealmsContext";
import { useAccess } from "../context/access/Access";
import { useRealm } from "../context/realm-context/RealmContext";
import { toDashboard } from "../dashboard/routes/Dashboard";
import helpUrls from "../help-urls";
import { DEFAULT_LOCALE } from "../i18n/i18n";
import { convertFormValuesToObject, convertToFormValues } from "../util";
import { getAuthorizationHeaders } from "../utils/getAuthorizationHeaders";
import { joinPath } from "../utils/joinPath";
import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled";
import { RealmSettingsEmailTab } from "./EmailTab";
import { RealmSettingsGeneralTab } from "./GeneralTab";
import { LocalizationTab } from "./localization/LocalizationTab";
import { RealmSettingsLoginTab } from "./LoginTab";
import { PartialExportDialog } from "./PartialExport";
import { PartialImportDialog } from "./PartialImport";
@ -50,11 +48,13 @@ import { RealmSettingsTokensTab } from "./TokensTab";
import { UserRegistration } from "./UserRegistration";
import { EventsTab } from "./event-config/EventsTab";
import { KeysTab } from "./keys/KeysTab";
import { LocalizationTab } from "./localization/LocalizationTab";
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 useLocale from "../utils/useLocale";
import { useAdminClient } from "../admin-client";
import { useAccess } from "../context/access/Access";
export interface UIRealmRepresentation extends RealmRepresentation {
upConfig?: UserProfileConfig;
@ -77,14 +77,12 @@ const RealmSettingsHeader = ({
}: RealmSettingsHeaderProps) => {
const { adminClient } = useAdminClient();
const { environment } = useEnvironment<AdminEnvironment>();
const { t } = useTranslation();
const { refresh: refreshRealms } = useRealms();
const { addAlert, addError } = useAlerts();
const navigate = useNavigate();
const [partialImportOpen, setPartialImportOpen] = useState(false);
const [partialExportOpen, setPartialExportOpen] = useState(false);
const { hasAccess } = useAccess();
const canManageRealm = hasAccess("manage-realm");
@ -187,22 +185,20 @@ export const RealmSettingsTabs = ({
refresh,
}: RealmSettingsTabsProps) => {
const { adminClient } = useAdminClient();
const { t } = useTranslation();
const { addAlert, addError } = useAlerts();
const { realm: realmName } = useRealm();
const { refresh: refreshRealms } = useRealms();
const combinedLocales = useLocale();
const navigate = useNavigate();
const isFeatureEnabled = useIsFeatureEnabled();
const [tableData, setTableData] = useState<
Record<string, string>[] | undefined
>(undefined);
const { control, setValue, getValues } = useForm({
mode: "onChange",
});
const [key, setKey] = useState(0);
const refreshHeader = () => {
setKey(key + 1);
};
@ -211,20 +207,6 @@ export const RealmSettingsTabs = ({
convertToFormValues(r, setValue);
};
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 () => {

View file

@ -1,18 +1,28 @@
import type { UserProfileGroup } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import {
ActionGroup,
Alert,
Button,
FormGroup,
Grid,
GridItem,
PageSection,
Text,
TextContent,
TextInput,
} from "@patternfly/react-core";
import { useEffect, useMemo } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { useEffect, useMemo, useState } from "react";
import {
FormProvider,
SubmitHandler,
useForm,
useWatch,
} from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Link, useNavigate, useParams } from "react-router-dom";
import { TextControl } from "@keycloak/keycloak-ui-shared";
import { HelpItem, TextControl } from "@keycloak/keycloak-ui-shared";
import { useAlerts } from "../../components/alert/Alerts";
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";
@ -21,8 +31,16 @@ import { useRealm } from "../../context/realm-context/RealmContext";
import type { EditAttributesGroupParams } from "../routes/EditAttributesGroup";
import { toUserProfile } from "../routes/UserProfile";
import { useUserProfile } from "./UserProfileContext";
import { useFetch } from "../../utils/useFetch";
import { GlobeRouteIcon } from "@patternfly/react-icons";
import useToggle from "../../utils/useToggle";
import useLocale from "../../utils/useLocale";
import {
AddTranslationsDialog,
TranslationsType,
} from "./attribute/AddTranslationsDialog";
import "../realm-settings-section.css";
import { useAdminClient } from "../../admin-client";
function parseAnnotations(input: Record<string, unknown>): KeyValueType[] {
return Object.entries(input).reduce((p, [key, value]) => {
@ -46,6 +64,21 @@ type FormFields = Required<Omit<UserProfileGroup, "annotations">> & {
annotations: KeyValueType[];
};
type TranslationForm = {
locale: string;
value: string;
};
type Translations = {
key: string;
translations: TranslationForm[];
};
type TranslationsSets = {
displayHeader: Translations;
displayDescription: Translations;
};
const defaultValues: FormFields = {
annotations: [],
displayDescription: "",
@ -54,12 +87,39 @@ const defaultValues: FormFields = {
};
export default function AttributesGroupForm() {
const { adminClient } = useAdminClient();
const { t } = useTranslation();
const { realm } = useRealm();
const { realm: realmName } = useRealm();
const { config, save } = useUserProfile();
const navigate = useNavigate();
const combinedLocales = useLocale();
const params = useParams<EditAttributesGroupParams>();
const form = useForm<FormFields>({ defaultValues });
const [realm, setRealm] = useState<RealmRepresentation>();
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<TranslationsSets>({
displayHeader: {
key: "",
translations: [],
},
displayDescription: {
key: "",
translations: [],
},
});
const matchingGroup = useMemo(
() => config?.groups?.find(({ name }) => name === params.name),
@ -78,6 +138,185 @@ export default function AttributesGroupForm() {
form.reset({ ...defaultValues, ...matchingGroup, annotations });
}, [matchingGroup]);
useEffect(() => {
form.setValue(
"displayHeader",
matchingGroup
? matchingGroup.displayHeader!
: generatedAttributesGroupDisplayName,
);
form.setValue(
"displayDescription",
matchingGroup
? matchingGroup.displayDescription!
: generatedAttributesGroupDisplayDescription,
);
}, [
generatedAttributesGroupDisplayName,
generatedAttributesGroupDisplayDescription,
]);
useFetch(
() => adminClient.realms.findOne({ realm: realmName }),
(realm) => {
if (!realm) {
throw new Error(t("notFound"));
}
setRealm(realm);
},
[],
);
useFetch(
async () => {
const translationsToSaveDisplayHeader: Translations[] = [];
const translationsToSaveDisplayDescription: Translations[] = [];
const formData = form.getValues();
const translationsResults = await Promise.all(
combinedLocales.map(async (selectedLocale) => {
try {
const translations =
await adminClient.realms.getRealmLocalizationTexts({
realm: realmName,
selectedLocale,
});
const formattedDisplayHeaderKey = formData.displayHeader?.substring(
2,
formData.displayHeader.length - 1,
);
const formattedDisplayDescriptionKey =
formData.displayDescription?.substring(
2,
formData.displayDescription.length - 1,
);
return {
locale: selectedLocale,
headerTranslation: translations[formattedDisplayHeaderKey] ?? "",
descriptionTranslation:
translations[formattedDisplayDescriptionKey] ?? "",
};
} catch (error) {
console.error(
`Error fetching translations for ${selectedLocale}:`,
error,
);
return null;
}
}),
);
translationsResults.forEach((translationsResult) => {
if (translationsResult) {
const { locale, headerTranslation, descriptionTranslation } =
translationsResult;
translationsToSaveDisplayHeader.push({
key: formData.displayHeader?.substring(
2,
formData.displayHeader.length - 1,
),
translations: [
{
locale,
value: headerTranslation,
},
],
});
translationsToSaveDisplayDescription.push({
key: formData.displayDescription?.substring(
2,
formData.displayDescription.length - 1,
),
translations: [
{
locale,
value: descriptionTranslation,
},
],
});
}
});
return {
translationsToSaveDisplayHeader,
translationsToSaveDisplayDescription,
};
},
(data) => {
setTranslationsData({
displayHeader: {
key: data.translationsToSaveDisplayHeader[0].key,
translations: data.translationsToSaveDisplayHeader.flatMap(
(translationData) => translationData.translations,
),
},
displayDescription: {
key: data.translationsToSaveDisplayDescription[0].key,
translations: data.translationsToSaveDisplayDescription.flatMap(
(translationData) => translationData.translations,
),
},
});
},
[combinedLocales],
);
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.displayHeader &&
translationsData.displayHeader.translations.length > 0
) {
for (const translation of translationsData.displayHeader.translations) {
await addLocalization(
translationsData.displayHeader.key,
translation.locale,
translation.value,
);
}
}
if (
translationsData.displayDescription &&
translationsData.displayDescription.translations.length > 0
) {
for (const translation of translationsData.displayDescription
.translations) {
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;
@ -96,15 +335,133 @@ 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) {
navigate(toUserProfile({ realm, tab: "attributes-group" }));
await saveTranslations();
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();
}}
/>
)}
<ViewHeader
titleKey={matchingGroup ? "editGroupText" : "createGroupText"}
divider
@ -116,22 +473,136 @@ export default function AttributesGroupForm() {
name="name"
label={t("nameField")}
labelIcon={t("nameHintHelp")}
rules={{ required: t("required") }}
readOnly={!!matchingGroup}
isDisabled={!!matchingGroup || editMode}
rules={{
required: {
value: true,
message: t("required"),
},
onChange: (event) => {
handleAttributesGroupNameChange(event, event.target.value);
},
}}
/>
{!!matchingGroup && (
<input type="hidden" {...form.register("name")} />
)}
<TextControl
name="displayHeader"
<FormGroup
label={t("displayHeaderField")}
labelIcon={t("displayHeaderHintHelp")}
labelIcon={
<HelpItem
helpText={t("displayHeaderHintHelp")}
fieldLabelId="displayHeaderField"
/>
<TextControl
name="displayDescription"
}
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={t("displayDescriptionHintHelp")}
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>
@ -151,7 +622,10 @@ export default function AttributesGroupForm() {
component={(props) => (
<Link
{...props}
to={toUserProfile({ realm, tab: "attributes-group" })}
to={toUserProfile({
realm: realmName,
tab: "attributes-group",
})}
/>
)}
>

View file

@ -18,10 +18,22 @@ import { useRealm } from "../../context/realm-context/RealmContext";
import { toEditAttributesGroup } from "../routes/EditAttributesGroup";
import { toNewAttributesGroup } from "../routes/NewAttributesGroup";
import { useUserProfile } from "./UserProfileContext";
import useLocale from "../../utils/useLocale";
import { useAdminClient } from "../../admin-client";
export const AttributesGroupTab = () => {
type AttributesGroupTabProps = {
setTableData: React.Dispatch<
React.SetStateAction<Record<string, string>[] | undefined>
>;
};
export const AttributesGroupTab = ({
setTableData,
}: AttributesGroupTabProps) => {
const { adminClient } = useAdminClient();
const { config, save } = useUserProfile();
const { t } = useTranslation();
const combinedLocales = useLocale();
const navigate = useNavigate();
const { realm } = useRealm();
const [key, setKey] = useState(0);
@ -44,10 +56,56 @@ export const AttributesGroupTab = () => {
),
continueButtonLabel: "delete",
continueButtonVariant: ButtonVariant.danger,
onConfirm() {
onConfirm: async () => {
const groups = (config?.groups ?? []).filter(
(group) => group !== groupToDelete,
);
const translationsForDisplayHeaderToDelete =
groupToDelete?.displayHeader?.substring(
2,
groupToDelete?.displayHeader.length - 1,
);
const translationsForDisplayDescriptionToDelete =
groupToDelete?.displayDescription?.substring(
2,
groupToDelete?.displayDescription.length - 1,
);
try {
await Promise.all(
combinedLocales.map(async (locale) => {
try {
const response =
await adminClient.realms.getRealmLocalizationTexts({
realm,
selectedLocale: locale,
});
if (response) {
await adminClient.realms.deleteRealmLocalizationTexts({
realm,
selectedLocale: locale,
key: translationsForDisplayHeaderToDelete,
});
await adminClient.realms.deleteRealmLocalizationTexts({
realm,
selectedLocale: locale,
key: translationsForDisplayDescriptionToDelete,
});
const updatedData =
await adminClient.realms.getRealmLocalizationTexts({
realm,
selectedLocale: locale,
});
setTableData([updatedData]);
}
} catch (error) {
console.error(`Error removing translations for ${locale}`);
}
}),
);
save(
{ ...config, groups },
@ -56,6 +114,11 @@ export const AttributesGroupTab = () => {
errorMessageKey: "deleteAttributeGroupError",
},
);
} catch (error) {
console.error(
`Error removing translations or updating attributes group: ${error}`,
);
}
},
});
@ -91,7 +154,12 @@ export const AttributesGroupTab = () => {
name: "name",
displayKey: "columnName",
cellRenderer: (group) => (
<Link to={toEditAttributesGroup({ realm, name: group.name! })}>
<Link
to={toEditAttributesGroup({
realm,
name: group.name!,
})}
>
{group.name}
</Link>
),

View file

@ -1,4 +1,3 @@
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
import {
Button,
@ -15,20 +14,19 @@ import {
} from "@patternfly/react-core/deprecated";
import { FilterIcon } from "@patternfly/react-icons";
import { uniqBy } from "lodash-es";
import { useMemo, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { useAdminClient } from "../../admin-client";
import { DraggableTable } from "../../authentication/components/DraggableTable";
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner";
import { useRealm } from "../../context/realm-context/RealmContext";
import { DEFAULT_LOCALE } from "../../i18n/i18n";
import { useFetch } from "../../utils/useFetch";
import useToggle from "../../utils/useToggle";
import { toAddAttribute } from "../routes/AddAttribute";
import { toAttribute } from "../routes/Attribute";
import { useUserProfile } from "./UserProfileContext";
import useLocale from "../../utils/useLocale";
import { useAdminClient } from "../../admin-client";
const RESTRICTED_ATTRIBUTES = ["username", "email"];
@ -42,42 +40,16 @@ type AttributesTabProps = {
export const AttributesTab = ({ setTableData }: AttributesTabProps) => {
const { adminClient } = useAdminClient();
const { config, save } = useUserProfile();
const { realm: realmName } = useRealm();
const { realm } = useRealm();
const { t } = useTranslation();
const combinedLocales = useLocale();
const navigate = useNavigate();
const [filter, setFilter] = useState("allGroups");
const [isFilterTypeDropdownOpen, toggleIsFilterTypeDropdownOpen] =
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"),
@ -105,20 +77,20 @@ export const AttributesTab = ({ setTableData }: AttributesTabProps) => {
try {
const response =
await adminClient.realms.getRealmLocalizationTexts({
realm: realmName,
realm,
selectedLocale: locale,
});
if (response) {
await adminClient.realms.deleteRealmLocalizationTexts({
realm: realmName,
realm,
selectedLocale: locale,
key: formattedTranslationsToDelete,
});
const updatedData =
await adminClient.realms.getRealmLocalizationTexts({
realm: realmName,
realm,
selectedLocale: locale,
});
setTableData([updatedData]);
@ -182,7 +154,7 @@ export const AttributesTab = ({ setTableData }: AttributesTabProps) => {
const cellFormatter = (row: UserProfileAttribute) => (
<Link
to={toAttribute({
realm: realmName,
realm,
attributeName: row.name!,
})}
key={row.name}
@ -241,7 +213,7 @@ export const AttributesTab = ({ setTableData }: AttributesTabProps) => {
data-testid="createAttributeBtn"
variant="primary"
component={(props) => (
<Link {...props} to={toAddAttribute({ realm: realmName })} />
<Link {...props} to={toAddAttribute({ realm })} />
)}
>
{t("createAttribute")}
@ -268,7 +240,7 @@ export const AttributesTab = ({ setTableData }: AttributesTabProps) => {
onClick: (_key, _idx, component) => {
navigate(
toAttribute({
realm: realmName,
realm,
attributeName: component.name,
}),
);

View file

@ -50,7 +50,7 @@ export const UserProfileTab = ({ setTableData }: UserProfileTabProps) => {
data-testid="attributesGroupTab"
{...attributesGroupTab}
>
<AttributesGroupTab />
<AttributesGroupTab setTableData={setTableData} />
</Tab>
<Tab
title={<TabTitleText>{t("jsonEditor")}</TabTitleText>}

View file

@ -1,5 +1,4 @@
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import { TextControl } from "@keycloak/keycloak-ui-shared";
import {
Button,
Flex,
@ -13,19 +12,25 @@ import {
TextContent,
TextVariants,
} from "@patternfly/react-core";
import { SearchIcon } from "@patternfly/react-icons";
import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table";
import { SearchIcon } from "@patternfly/react-icons";
import { useEffect, useMemo, useState } from "react";
import { FormProvider, useForm, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useAdminClient } from "../../../admin-client";
import { ListEmptyState } from "../../../components/list-empty-state/ListEmptyState";
import { PaginatingTableToolbar } from "../../../components/table-toolbar/PaginatingTableToolbar";
import { FormProvider, useForm, useWatch } from "react-hook-form";
import { useRealm } from "../../../context/realm-context/RealmContext";
import { useWhoAmI } from "../../../context/whoami/WhoAmI";
import { DEFAULT_LOCALE } from "../../../i18n/i18n";
import { localeToDisplayName } from "../../../util";
import { useFetch } from "../../../utils/useFetch";
import { localeToDisplayName } from "../../../util";
import useLocale from "../../../utils/useLocale";
import { TextControl } from "@keycloak/keycloak-ui-shared";
export type TranslationsType =
| "displayName"
| "displayHeader"
| "displayDescription";
type TranslationForm = {
locale: string;
@ -40,6 +45,7 @@ type Translations = {
export type AddTranslationsDialogProps = {
translationKey: string;
translations: Translations;
type: TranslationsType;
onCancel: () => void;
toggleDialog: () => void;
onTranslationsAdded: (translations: Translations) => void;
@ -48,14 +54,15 @@ export type AddTranslationsDialogProps = {
export const AddTranslationsDialog = ({
translationKey,
translations,
type,
onCancel,
toggleDialog,
onTranslationsAdded,
}: AddTranslationsDialogProps) => {
const { adminClient } = useAdminClient();
const { t } = useTranslation();
const { realm: realmName } = useRealm();
const combinedLocales = useLocale();
const [realm, setRealm] = useState<RealmRepresentation>();
const { whoAmI } = useWhoAmI();
const [max, setMax] = useState(10);
@ -90,20 +97,10 @@ export const AddTranslationsDialog = ({
[],
);
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())!
@ -233,7 +230,9 @@ export const AddTranslationsDialog = ({
<FlexItem>
<TextContent>
<Text component={TextVariants.p}>
{t("addTranslationsModalSubTitle")}{" "}
{type !== "displayHeader"
? t("addTranslationsModalSubTitleDescription")
: t("addTranslationsModalSubTitle")}{" "}
<strong>{t("addTranslationsModalSubTitleBolded")}</strong>
</Text>
</TextContent>

View file

@ -23,7 +23,6 @@ import { useEffect, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { FormErrorText, HelpItem } from "@keycloak/keycloak-ui-shared";
import { useAdminClient } from "../../../admin-client";
import { FormAccess } from "../../../components/form/FormAccess";
import { KeycloakSpinner } from "../../../components/keycloak-spinner/KeycloakSpinner";
import { useRealm } from "../../../context/realm-context/RealmContext";
@ -32,8 +31,11 @@ import { useParams } from "../../../utils/useParams";
import useToggle from "../../../utils/useToggle";
import { USERNAME_EMAIL } from "../../NewAttributeSettings";
import { AttributeParams } from "../../routes/Attribute";
import { AddTranslationsDialog } from "./AddTranslationsDialog";
import {
AddTranslationsDialog,
TranslationsType,
} from "./AddTranslationsDialog";
import { useAdminClient } from "../../../admin-client";
import "../../realm-settings-section.css";
const REQUIRED_FOR = [
@ -62,7 +64,6 @@ export const AttributeGeneralSettings = ({
onHandlingGeneratedDisplayName,
}: AttributeGeneralSettingsProps) => {
const { adminClient } = useAdminClient();
const { t } = useTranslation();
const { realm: realmName } = useRealm();
const form = useFormContext();
@ -79,6 +80,7 @@ export const AttributeGeneralSettings = ({
const [realm, setRealm] = useState<RealmRepresentation>();
const [newAttributeName, setNewAttributeName] = useState("");
const [generatedDisplayName, setGeneratedDisplayName] = useState("");
const [type, setType] = useState<TranslationsType>();
const [translationsData, setTranslationsData] = useState<Translations>({
key: "",
translations: [],
@ -184,6 +186,7 @@ export const AttributeGeneralSettings = ({
: `profile.attributes.${newAttributeName}`
}
translations={translationsData}
type={type ?? "displayName"}
onTranslationsAdded={handleTranslationsAdded}
toggleDialog={handleToggleDialog}
onCancel={() => {
@ -265,6 +268,7 @@ export const AttributeGeneralSettings = ({
aria-label={t("addAttributeTranslationBtn")}
isDisabled={!newAttributeName && !editMode}
onClick={() => {
setType("displayName");
toggleModal();
}}
icon={<GlobeRouteIcon />}

View file

@ -0,0 +1,40 @@
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import { useMemo, useState } from "react";
import { DEFAULT_LOCALE } from "../i18n/i18n";
import { useFetch } from "./useFetch";
import { useRealm } from "../context/realm-context/RealmContext";
import { useTranslation } from "react-i18next";
import { useAdminClient } from "../admin-client";
export default function useLocale() {
const { adminClient } = useAdminClient();
const { t } = useTranslation();
const { realm: realmName } = useRealm();
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]);
return combinedLocales;
}