added better rendering to account (#22297)

* added better rendering to account

fixes: #21699

* merged in changes

* re-added test
This commit is contained in:
Erik Jan de Wit 2023-08-15 15:22:13 +02:00 committed by GitHub
parent ecd9044a62
commit 6d85ceef25
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 353 additions and 144 deletions

View file

@ -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 } };
}

View file

@ -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>
);
};

View file

@ -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

View 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 }} />;
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View 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}`;

View file

@ -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.");
});
});

View file

@ -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 }} />;

View file

@ -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";