added better rendering to account (#22297)
* added better rendering to account fixes: #21699 * merged in changes * re-added test
This commit is contained in:
parent
ecd9044a62
commit
6d85ceef25
13 changed files with 353 additions and 144 deletions
|
@ -79,7 +79,7 @@ export interface UserProfileAttributeMetadata {
|
|||
displayName: string;
|
||||
required: boolean;
|
||||
readOnly: boolean;
|
||||
annotations: { [index: string]: any };
|
||||
annotations?: { [index: string]: any };
|
||||
validators: { [index: string]: { [index: string]: any } };
|
||||
}
|
||||
|
||||
|
|
|
@ -1,130 +0,0 @@
|
|||
import {
|
||||
Button,
|
||||
FormGroup,
|
||||
InputGroup,
|
||||
Select,
|
||||
SelectOption,
|
||||
} from "@patternfly/react-core";
|
||||
import { ExternalLinkSquareAltIcon } from "@patternfly/react-icons";
|
||||
import { get } from "lodash-es";
|
||||
import { useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { KeycloakTextInput } from "ui-shared";
|
||||
import { UserProfileAttributeMetadata } from "../api/representations";
|
||||
import { environment } from "../environment";
|
||||
import { TFuncKey } from "../i18n";
|
||||
import { keycloak } from "../keycloak";
|
||||
import { LocaleSelector } from "./LocaleSelector";
|
||||
import { fieldName, isBundleKey, unWrap } from "./PersonalInfo";
|
||||
|
||||
type FormFieldProps = {
|
||||
attribute: UserProfileAttributeMetadata;
|
||||
};
|
||||
|
||||
export const FormField = ({ attribute }: FormFieldProps) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
formState: { errors },
|
||||
register,
|
||||
control,
|
||||
} = useFormContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const toggle = () => setOpen(!open);
|
||||
|
||||
const isSelect = (attribute: UserProfileAttributeMetadata) =>
|
||||
Object.hasOwn(attribute.validators, "options");
|
||||
|
||||
const {
|
||||
updateEmailFeatureEnabled,
|
||||
updateEmailActionEnabled,
|
||||
isRegistrationEmailAsUsername,
|
||||
isEditUserNameAllowed,
|
||||
} = environment.features;
|
||||
|
||||
if (attribute.name === "locale") return <LocaleSelector />;
|
||||
return (
|
||||
<FormGroup
|
||||
key={attribute.name}
|
||||
label={
|
||||
(isBundleKey(attribute.displayName)
|
||||
? t(unWrap(attribute.displayName) as TFuncKey)
|
||||
: attribute.displayName) || attribute.name
|
||||
}
|
||||
fieldId={attribute.name}
|
||||
isRequired={attribute.required}
|
||||
validated={get(errors, fieldName(attribute.name)) ? "error" : "default"}
|
||||
helperTextInvalid={
|
||||
get(errors, fieldName(attribute.name))?.message as string
|
||||
}
|
||||
>
|
||||
{isSelect(attribute) ? (
|
||||
<Controller
|
||||
name={fieldName(attribute.name)}
|
||||
defaultValue=""
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
data-testid={attribute.name}
|
||||
toggleId={attribute.name}
|
||||
onToggle={toggle}
|
||||
onSelect={(_, value) => {
|
||||
field.onChange(value.toString());
|
||||
toggle();
|
||||
}}
|
||||
selections={field.value}
|
||||
variant="single"
|
||||
aria-label={t("selectOne")}
|
||||
isOpen={open}
|
||||
>
|
||||
{[
|
||||
<SelectOption key="empty" value="">
|
||||
{t("choose")}
|
||||
</SelectOption>,
|
||||
...(
|
||||
attribute.validators.options as { options: string[] }
|
||||
).options.map((option) => (
|
||||
<SelectOption
|
||||
selected={field.value === option}
|
||||
key={option}
|
||||
value={option}
|
||||
>
|
||||
{option}
|
||||
</SelectOption>
|
||||
)),
|
||||
]}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<InputGroup>
|
||||
<KeycloakTextInput
|
||||
data-testid={attribute.name}
|
||||
id={attribute.name}
|
||||
isDisabled={
|
||||
attribute.readOnly ||
|
||||
(attribute.name === "email" && !updateEmailActionEnabled)
|
||||
}
|
||||
{...register(fieldName(attribute.name), {
|
||||
required: { value: attribute.required, message: t("required") },
|
||||
})}
|
||||
/>
|
||||
{attribute.name === "email" &&
|
||||
updateEmailFeatureEnabled &&
|
||||
updateEmailActionEnabled &&
|
||||
(!isRegistrationEmailAsUsername || isEditUserNameAllowed) && (
|
||||
<Button
|
||||
id="update-email-btn"
|
||||
variant="link"
|
||||
onClick={() => keycloak.login({ action: "UPDATE_EMAIL" })}
|
||||
icon={<ExternalLinkSquareAltIcon />}
|
||||
iconPosition="right"
|
||||
>
|
||||
{t("updateEmail")}
|
||||
</Button>
|
||||
)}
|
||||
</InputGroup>
|
||||
)}
|
||||
</FormGroup>
|
||||
);
|
||||
};
|
|
@ -4,6 +4,7 @@ import {
|
|||
Button,
|
||||
ExpandableSection,
|
||||
Form,
|
||||
Spinner,
|
||||
} from "@patternfly/react-core";
|
||||
import { useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
|
@ -19,7 +20,7 @@ import { environment } from "../environment";
|
|||
import { TFuncKey } from "../i18n";
|
||||
import { keycloak } from "../keycloak";
|
||||
import { usePromise } from "../utils/usePromise";
|
||||
import { FormField } from "./FormField";
|
||||
import { UserProfileFields } from "./UserProfileFields";
|
||||
|
||||
type FieldError = {
|
||||
field: string;
|
||||
|
@ -61,7 +62,7 @@ const PersonalInfo = () => {
|
|||
(error as FieldError[]).forEach((e) => {
|
||||
const params = Object.assign(
|
||||
{},
|
||||
e.params.map((p) => (isBundleKey(p) ? unWrap(p) : p)),
|
||||
e.params.map((p) => t((isBundleKey(p) ? unWrap(p) : p) as TFuncKey)),
|
||||
);
|
||||
setError(fieldName(e.field) as keyof UserRepresentation, {
|
||||
message: t(e.errorMessage as TFuncKey, {
|
||||
|
@ -74,13 +75,15 @@ const PersonalInfo = () => {
|
|||
}
|
||||
};
|
||||
|
||||
if (!userProfileMetadata) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page title={t("personalInfo")} description={t("personalInfoDescription")}>
|
||||
<Form isHorizontal onSubmit={handleSubmit(onSubmit)}>
|
||||
<FormProvider {...form}>
|
||||
{(userProfileMetadata?.attributes || []).map((attribute) => (
|
||||
<FormField key={attribute.name} attribute={attribute} />
|
||||
))}
|
||||
<UserProfileFields metaData={userProfileMetadata} />
|
||||
</FormProvider>
|
||||
<ActionGroup>
|
||||
<Button
|
||||
|
|
83
js/apps/account-ui/src/personal-info/UserProfileFields.tsx
Normal file
83
js/apps/account-ui/src/personal-info/UserProfileFields.tsx
Normal file
|
@ -0,0 +1,83 @@
|
|||
import { useFormContext } from "react-hook-form";
|
||||
import {
|
||||
UserProfileAttributeMetadata,
|
||||
UserProfileMetadata,
|
||||
} from "../api/representations";
|
||||
import { LocaleSelector } from "./LocaleSelector";
|
||||
import { OptionComponent } from "./components/OptionsComponent";
|
||||
import { SelectComponent } from "./components/SelectComponent";
|
||||
import { TextAreaComponent } from "./components/TextAreaComponent";
|
||||
import { TextComponent } from "./components/TextComponent";
|
||||
import { fieldName } from "./utils";
|
||||
|
||||
type UserProfileFieldsProps = {
|
||||
metaData: UserProfileMetadata;
|
||||
};
|
||||
|
||||
export type Options = {
|
||||
options: string[] | undefined;
|
||||
};
|
||||
|
||||
const FieldTypes = [
|
||||
"text",
|
||||
"textarea",
|
||||
"select",
|
||||
"select-radiobuttons",
|
||||
"multiselect",
|
||||
"multiselect-checkboxes",
|
||||
"html5-email",
|
||||
"html5-tel",
|
||||
"html5-url",
|
||||
"html5-number",
|
||||
"html5-range",
|
||||
"html5-datetime-local",
|
||||
"html5-date",
|
||||
"html5-month",
|
||||
"html5-time",
|
||||
] as const;
|
||||
|
||||
export type Field = (typeof FieldTypes)[number];
|
||||
|
||||
export const FIELDS: {
|
||||
[index in Field]: (props: any) => JSX.Element;
|
||||
} = {
|
||||
text: TextComponent,
|
||||
textarea: TextAreaComponent,
|
||||
select: SelectComponent,
|
||||
"select-radiobuttons": OptionComponent,
|
||||
multiselect: SelectComponent,
|
||||
"multiselect-checkboxes": OptionComponent,
|
||||
"html5-email": TextComponent,
|
||||
"html5-tel": TextComponent,
|
||||
"html5-url": TextComponent,
|
||||
"html5-number": TextComponent,
|
||||
"html5-range": TextComponent,
|
||||
"html5-datetime-local": TextComponent,
|
||||
"html5-date": TextComponent,
|
||||
"html5-month": TextComponent,
|
||||
"html5-time": TextComponent,
|
||||
} as const;
|
||||
|
||||
export const isValidComponentType = (value: string): value is Field =>
|
||||
value in FIELDS;
|
||||
|
||||
export const UserProfileFields = ({ metaData }: UserProfileFieldsProps) =>
|
||||
metaData.attributes.map((attribute) => (
|
||||
<FormField key={attribute.name} attribute={attribute} />
|
||||
));
|
||||
|
||||
type FormFieldProps = {
|
||||
attribute: UserProfileAttributeMetadata;
|
||||
};
|
||||
|
||||
const FormField = ({ attribute }: FormFieldProps) => {
|
||||
const { watch } = useFormContext();
|
||||
const value = watch(fieldName(attribute));
|
||||
|
||||
const componentType = (attribute.annotations?.["inputType"] ||
|
||||
(Array.isArray(value) ? "multiselect" : "text")) as Field;
|
||||
const Component = FIELDS[componentType];
|
||||
|
||||
if (attribute.name === "locale") return <LocaleSelector />;
|
||||
return <Component {...{ ...attribute }} />;
|
||||
};
|
|
@ -0,0 +1,52 @@
|
|||
import { Checkbox, Radio } from "@patternfly/react-core";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { UserProfileAttributeMetadata } from "../../api/representations";
|
||||
import { Options } from "../UserProfileFields";
|
||||
import { fieldName } from "../utils";
|
||||
import { UserProfileGroup } from "./UserProfileGroup";
|
||||
|
||||
export const OptionComponent = (attr: UserProfileAttributeMetadata) => {
|
||||
const { control } = useFormContext();
|
||||
const type = attr.annotations?.["inputType"] as string;
|
||||
const isMultiSelect = type.includes("multiselect");
|
||||
const Component = isMultiSelect ? Checkbox : Radio;
|
||||
|
||||
const options = (attr.validators.options as Options).options || [];
|
||||
|
||||
return (
|
||||
<UserProfileGroup {...attr}>
|
||||
<Controller
|
||||
name={fieldName(attr)}
|
||||
control={control}
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<>
|
||||
{options.map((option) => (
|
||||
<Component
|
||||
key={option}
|
||||
id={option}
|
||||
data-testid={option}
|
||||
label={option}
|
||||
value={option}
|
||||
isChecked={field.value.includes(option)}
|
||||
onChange={() => {
|
||||
if (isMultiSelect) {
|
||||
if (field.value.includes(option)) {
|
||||
field.onChange(
|
||||
field.value.filter((item: string) => item !== option),
|
||||
);
|
||||
} else {
|
||||
field.onChange([...field.value, option]);
|
||||
}
|
||||
} else {
|
||||
field.onChange([option]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</UserProfileGroup>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,61 @@
|
|||
import { Select, SelectOption } from "@patternfly/react-core";
|
||||
import { useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Options } from "../UserProfileFields";
|
||||
import { fieldName } from "../utils";
|
||||
import { UserProfileFieldsProps, UserProfileGroup } from "./UserProfileGroup";
|
||||
|
||||
export const SelectComponent = ({ ...attribute }: UserProfileFieldsProps) => {
|
||||
const { t } = useTranslation("translation");
|
||||
const { control } = useFormContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const options =
|
||||
(attribute.validators.options as Options | undefined)?.options || [];
|
||||
return (
|
||||
<UserProfileGroup {...attribute}>
|
||||
<Controller
|
||||
name={fieldName(attribute)}
|
||||
defaultValue=""
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
toggleId={attribute.name}
|
||||
onToggle={(b) => setOpen(b)}
|
||||
onSelect={(_, value) => {
|
||||
const option = value.toString();
|
||||
if (Array.isArray(field.value)) {
|
||||
if (field.value.includes(option)) {
|
||||
field.onChange(
|
||||
field.value.filter((item: string) => item !== option),
|
||||
);
|
||||
} else {
|
||||
field.onChange([...field.value, option]);
|
||||
}
|
||||
} else {
|
||||
field.onChange(option);
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
selections={field.value ? field.value : t("choose")}
|
||||
variant={Array.isArray(field.value) ? "typeaheadmulti" : "single"}
|
||||
aria-label={t("selectOne")}
|
||||
isOpen={open}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<SelectOption
|
||||
selected={field.value === option}
|
||||
key={option}
|
||||
value={option}
|
||||
>
|
||||
{option}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</UserProfileGroup>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,21 @@
|
|||
import { useFormContext } from "react-hook-form";
|
||||
import { UserProfileAttributeMetadata } from "../../api/representations";
|
||||
import { fieldName } from "../utils";
|
||||
import { UserProfileGroup } from "./UserProfileGroup";
|
||||
import { KeycloakTextArea } from "ui-shared";
|
||||
|
||||
export const TextAreaComponent = (attr: UserProfileAttributeMetadata) => {
|
||||
const { register } = useFormContext();
|
||||
|
||||
return (
|
||||
<UserProfileGroup {...attr}>
|
||||
<KeycloakTextArea
|
||||
id={attr.name}
|
||||
data-testid={attr.name}
|
||||
{...register(fieldName(attr))}
|
||||
cols={attr.annotations?.["inputTypeCols"] as number}
|
||||
rows={attr.annotations?.["inputTypeRows"] as number}
|
||||
/>
|
||||
</UserProfileGroup>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,25 @@
|
|||
import { useFormContext } from "react-hook-form";
|
||||
import { KeycloakTextInput } from "ui-shared";
|
||||
import { fieldName } from "../utils";
|
||||
import { UserProfileGroup } from "./UserProfileGroup";
|
||||
import { UserProfileAttributeMetadata } from "../../api/representations";
|
||||
|
||||
export const TextComponent = (attr: UserProfileAttributeMetadata) => {
|
||||
const { register } = useFormContext();
|
||||
const inputType = attr.annotations?.["inputType"] as string | undefined;
|
||||
const type: any = inputType?.startsWith("html")
|
||||
? inputType.substring("html".length + 2)
|
||||
: "text";
|
||||
|
||||
return (
|
||||
<UserProfileGroup {...attr}>
|
||||
<KeycloakTextInput
|
||||
id={attr.name}
|
||||
data-testid={attr.name}
|
||||
type={type}
|
||||
placeholder={attr.annotations?.["inputTypePlaceholder"] as string}
|
||||
{...register(fieldName(attr))}
|
||||
/>
|
||||
</UserProfileGroup>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,74 @@
|
|||
import { Button, FormGroup, InputGroup, Popover } from "@patternfly/react-core";
|
||||
import { ExternalLinkSquareAltIcon, HelpIcon } from "@patternfly/react-icons";
|
||||
import { get } from "lodash-es";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserProfileAttributeMetadata } from "../../api/representations";
|
||||
import { environment } from "../../environment";
|
||||
import { keycloak } from "../../keycloak";
|
||||
import { fieldName, label } from "../utils";
|
||||
import { TFuncKey } from "../../i18n";
|
||||
|
||||
export type UserProfileFieldsProps = UserProfileAttributeMetadata;
|
||||
|
||||
const isRequired = (attribute: UserProfileAttributeMetadata) =>
|
||||
Object.keys(attribute.required || {}).length !== 0 ||
|
||||
((attribute.validators?.length?.min as number) || 0) > 0;
|
||||
|
||||
export const UserProfileGroup = ({
|
||||
children,
|
||||
...attribute
|
||||
}: PropsWithChildren<UserProfileFieldsProps>) => {
|
||||
const { t } = useTranslation("translation");
|
||||
const helpText = attribute.annotations?.["inputHelperTextBefore"] as string;
|
||||
|
||||
const {
|
||||
formState: { errors },
|
||||
} = useFormContext();
|
||||
|
||||
const {
|
||||
updateEmailFeatureEnabled,
|
||||
updateEmailActionEnabled,
|
||||
isRegistrationEmailAsUsername,
|
||||
isEditUserNameAllowed,
|
||||
} = environment.features;
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
key={attribute.name}
|
||||
label={label(attribute, t) || ""}
|
||||
fieldId={attribute.name}
|
||||
isRequired={isRequired(attribute)}
|
||||
validated={get(errors, fieldName(attribute)) ? "error" : "default"}
|
||||
helperTextInvalid={t(
|
||||
get(errors, fieldName(attribute))?.message as TFuncKey,
|
||||
)}
|
||||
labelIcon={
|
||||
helpText ? (
|
||||
<Popover bodyContent={helpText}>
|
||||
<HelpIcon />
|
||||
</Popover>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<InputGroup>
|
||||
{children}
|
||||
{attribute.name === "email" &&
|
||||
updateEmailFeatureEnabled &&
|
||||
updateEmailActionEnabled &&
|
||||
(!isRegistrationEmailAsUsername || isEditUserNameAllowed) && (
|
||||
<Button
|
||||
id="update-email-btn"
|
||||
variant="link"
|
||||
onClick={() => keycloak.login({ action: "UPDATE_EMAIL" })}
|
||||
icon={<ExternalLinkSquareAltIcon />}
|
||||
iconPosition="right"
|
||||
>
|
||||
{t("updateEmail")}
|
||||
</Button>
|
||||
)}
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
);
|
||||
};
|
20
js/apps/account-ui/src/personal-info/utils.ts
Normal file
20
js/apps/account-ui/src/personal-info/utils.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { TFunction } from "i18next";
|
||||
import { TFuncKey } from "../i18n";
|
||||
import { UserProfileAttributeMetadata } from "../api/representations";
|
||||
|
||||
export const isBundleKey = (displayName?: string) =>
|
||||
displayName?.includes("${");
|
||||
export const unWrap = (key: string) => key.substring(2, key.length - 1);
|
||||
|
||||
export const label = (attribute: UserProfileAttributeMetadata, t: TFunction) =>
|
||||
(isBundleKey(attribute.displayName)
|
||||
? t(unWrap(attribute.displayName!) as TFuncKey)
|
||||
: attribute.displayName) || attribute.name;
|
||||
|
||||
const ROOT_ATTRIBUTES = ["username", "firstName", "lastName", "email"];
|
||||
|
||||
const isRootAttribute = (attr?: string) =>
|
||||
attr && ROOT_ATTRIBUTES.includes(attr);
|
||||
|
||||
export const fieldName = (attribute: UserProfileAttributeMetadata) =>
|
||||
`${isRootAttribute(attribute.name) ? "" : "attributes."}${attribute.name}`;
|
|
@ -1,13 +1,14 @@
|
|||
import { test } from "@playwright/test";
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Personal info page", () => {
|
||||
test("sets basic information", async ({ page }) => {
|
||||
await page.goto("./");
|
||||
await page.getByTestId("email").fill("edewit@somewhere.com");
|
||||
await page.getByTestId("firstName").fill("Erik");
|
||||
await page.getByTestId("lastName").fill("de Wit");
|
||||
await page.getByTestId("save").click();
|
||||
|
||||
// const alerts = page.getByTestId("alerts");
|
||||
// await expect(alerts).toHaveText("Your account has been updated.");
|
||||
const alerts = page.getByTestId("alerts");
|
||||
await expect(alerts).toHaveText("Your account has been updated.");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -116,11 +116,9 @@ const FormField = ({ attribute, roles }: FormFieldProps) => {
|
|||
const { watch } = useFormContext();
|
||||
const value = watch(fieldName(attribute));
|
||||
|
||||
const componentType = (
|
||||
attribute.annotations?.["inputType"] || Array.isArray(value)
|
||||
? "multiselect"
|
||||
: "text"
|
||||
) as Field;
|
||||
const componentType = (attribute.annotations?.["inputType"] ||
|
||||
(Array.isArray(value) ? "multiselect" : "text")) as Field;
|
||||
|
||||
const Component = FIELDS[componentType];
|
||||
|
||||
return <Component {...{ ...attribute, roles }} />;
|
||||
|
|
|
@ -7,6 +7,7 @@ export { TextAreaControl } from "./controls/TextAreaControl";
|
|||
export { HelpItem } from "./controls/HelpItem";
|
||||
export { useHelp, Help } from "./context/HelpContext";
|
||||
export { KeycloakTextInput } from "./keycloak-text-input/KeycloakTextInput";
|
||||
export { KeycloakTextArea } from "./controls/keycloak-text-area/KeycloakTextArea";
|
||||
export { AlertProvider, useAlerts } from "./alerts/Alerts";
|
||||
export { IconMapper } from "./icons/IconMapper";
|
||||
export { useStoredState } from "./utils/useStoredState";
|
||||
|
|
Loading…
Reference in a new issue