render components based on the annotations (#21180)

* render components based on the annotations

* merge fix
This commit is contained in:
Erik Jan de Wit 2023-07-18 13:40:53 +02:00 committed by GitHub
parent d2cdd78655
commit 0481ea6bda
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 289 additions and 104 deletions

View file

@ -151,7 +151,7 @@ export default function NewAttributeSettings() {
"annotations",
Object.entries(annotations || {}).map(([key, value]) => ({
key,
value,
value: value as Record<string, unknown>,
})),
);
form.setValue(

View file

@ -1,23 +1,15 @@
import type { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import {
Form,
FormGroup,
Select,
SelectOption,
Text,
} from "@patternfly/react-core";
import { Form, Text } from "@patternfly/react-core";
import { Fragment } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { KeycloakTextInput } from "../components/keycloak-text-input/KeycloakTextInput";
import { ScrollForm } from "../components/scroll-form/ScrollForm";
import { useUserProfile } from "../realm-settings/user-profile/UserProfileContext";
import { isBundleKey, unWrap } from "./utils";
import useToggle from "../utils/useToggle";
const ROOT_ATTRIBUTES = ["username", "firstName", "lastName", "email"];
const DEFAULT_ROLES = ["admin", "user"];
import { OptionComponent } from "./components/OptionsComponent";
import { SelectComponent } from "./components/SelectComponent";
import { TextAreaComponent } from "./components/TextAreaComponent";
import { TextComponent } from "./components/TextComponent";
import { DEFAULT_ROLES } from "./utils";
type UserProfileFieldsProps = {
roles?: string[];
@ -27,6 +19,10 @@ export type UserProfileError = {
responseData: { errors?: { errorMessage: string }[] };
};
export type Options = {
options: string[] | undefined;
};
export function isUserProfileError(error: unknown): error is UserProfileError {
return !!(error as UserProfileError).responseData.errors;
}
@ -37,6 +33,49 @@ export function userProfileErrorToString(error: UserProfileError) {
);
}
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 = ({
roles = ["admin"],
}: UserProfileFieldsProps) => {
@ -73,93 +112,9 @@ type FormFieldProps = {
};
const FormField = ({ attribute, roles }: FormFieldProps) => {
const { t } = useTranslation("users");
const {
formState: { errors },
register,
control,
} = useFormContext();
const [open, toggle] = useToggle();
const componentType = (attribute.annotations?.["inputType"] ||
"text") as Field;
const Component = FIELDS[componentType];
const isSelect = (attribute: UserProfileAttribute) =>
Object.hasOwn(attribute.validations || {}, "options");
const isRootAttribute = (attr?: string) =>
attr && ROOT_ATTRIBUTES.includes(attr);
const isRequired = (attribute: UserProfileAttribute) =>
Object.keys(attribute.required || {}).length !== 0 ||
((attribute.validations?.length?.min as number) || 0) > 0;
const fieldName = (attribute: UserProfileAttribute) =>
`${isRootAttribute(attribute.name) ? "" : "attributes."}${attribute.name}`;
return (
<FormGroup
key={attribute.name}
label={
(isBundleKey(attribute.displayName)
? t(unWrap(attribute.displayName!))
: attribute.displayName) || attribute.name
}
fieldId={attribute.name}
isRequired={isRequired(attribute)}
validated={errors.username ? "error" : "default"}
helperTextInvalid={t("common:required")}
>
{isSelect(attribute) ? (
<Controller
name={fieldName(attribute)}
defaultValue=""
control={control}
render={({ field }) => (
<Select
toggleId={attribute.name}
onToggle={toggle}
onSelect={(_, value) => {
field.onChange(value.toString());
toggle();
}}
selections={field.value}
variant="single"
aria-label={t("common:selectOne")}
isOpen={open}
isDisabled={
!(attribute.permissions?.edit || DEFAULT_ROLES).some((r) =>
roles.includes(r),
)
}
>
{[
<SelectOption key="empty" value="">
{t("common:choose")}
</SelectOption>,
...(
attribute.validations?.options as { options: string[] }
).options.map((option) => (
<SelectOption
selected={field.value === option}
key={option}
value={option}
>
{option}
</SelectOption>
)),
]}
</Select>
)}
/>
) : (
<KeycloakTextInput
id={attribute.name}
isDisabled={
!(attribute.permissions?.edit || DEFAULT_ROLES).some((r) =>
roles.includes(r),
)
}
{...register(fieldName(attribute))}
/>
)}
</FormGroup>
);
return <Component {...{ ...attribute, roles }} />;
};

View file

@ -0,0 +1,52 @@
import { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import { Checkbox, Radio } from "@patternfly/react-core";
import { Controller, useFormContext } from "react-hook-form";
import { UserProfileGroup } from "./UserProfileGroup";
import { Options } from "../UserProfileFields";
import { fieldName } from "../utils";
export const OptionComponent = (attr: UserProfileAttribute) => {
const { control } = useFormContext();
const type = attr.annotations?.["inputType"] as string;
const isMultiSelect = type.includes("multiselect");
const Component = isMultiSelect ? Checkbox : Radio;
const options = (attr.validations?.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,69 @@
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 { DEFAULT_ROLES, fieldName } from "../utils";
import { UserProfileFieldsProps, UserProfileGroup } from "./UserProfileGroup";
export const SelectComponent = ({
roles = [],
...attribute
}: UserProfileFieldsProps) => {
const { t } = useTranslation("users");
const { control } = useFormContext();
const [open, setOpen] = useState(false);
const isMultiSelect = attribute.annotations?.["inputType"] === "multiselect";
const options = (attribute.validations?.options as Options).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 (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);
setOpen(false);
}
}}
selections={field.value ? field.value : t("common:choose")}
variant={isMultiSelect ? "typeaheadmulti" : "single"}
aria-label={t("common:selectOne")}
isOpen={open}
isDisabled={
!(attribute.permissions?.edit || DEFAULT_ROLES).some((r) =>
roles.includes(r),
)
}
>
{options.map((option) => (
<SelectOption
selected={field.value === option}
key={option}
value={option}
>
{option}
</SelectOption>
))}
</Select>
)}
/>
</UserProfileGroup>
);
};

View file

@ -0,0 +1,21 @@
import { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import { useFormContext } from "react-hook-form";
import { KeycloakTextArea } from "../../components/keycloak-text-area/KeycloakTextArea";
import { UserProfileGroup } from "./UserProfileGroup";
import { fieldName } from "../utils";
export const TextAreaComponent = (attr: UserProfileAttribute) => {
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 { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import { useFormContext } from "react-hook-form";
import { KeycloakTextInput } from "ui-shared";
import { fieldName } from "../utils";
import { UserProfileGroup } from "./UserProfileGroup";
export const TextComponent = (attr: UserProfileAttribute) => {
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,46 @@
import { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import { FormGroup } from "@patternfly/react-core";
import { PropsWithChildren } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { HelpItem } from "ui-shared";
import { label } from "../utils";
export type UserProfileFieldsProps = UserProfileAttribute & {
roles?: string[];
};
const isRequired = (attribute: UserProfileAttribute) =>
Object.keys(attribute.required || {}).length !== 0 ||
((attribute.validations?.length?.min as number) || 0) > 0;
export const UserProfileGroup = ({
children,
...attribute
}: PropsWithChildren<UserProfileFieldsProps>) => {
const { t } = useTranslation("users");
const helpText = attribute.annotations?.["inputHelperTextBefore"] as string;
const {
formState: { errors },
} = useFormContext();
return (
<FormGroup
key={attribute.name}
label={label(attribute, t) || ""}
fieldId={attribute.name}
isRequired={isRequired(attribute)}
validated={errors.username ? "error" : "default"}
helperTextInvalid={t("common:required")}
labelIcon={
helpText ? (
<HelpItem helpText={helpText} fieldLabelId={attribute.name!} />
) : undefined
}
>
{children}
</FormGroup>
);
};

View file

@ -1,3 +1,20 @@
import { UserProfileAttribute } from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import { TFunction } from "i18next";
export const isBundleKey = (displayName?: string) =>
displayName?.includes("${");
export const unWrap = (key: string) => key.substring(2, key.length - 1);
export const label = (attribute: UserProfileAttribute, t: TFunction) =>
(isBundleKey(attribute.displayName)
? t(unWrap(attribute.displayName!))
: attribute.displayName) || attribute.name;
const ROOT_ATTRIBUTES = ["username", "firstName", "lastName", "email"];
export const DEFAULT_ROLES = ["admin", "user"];
const isRootAttribute = (attr?: string) =>
attr && ROOT_ATTRIBUTES.includes(attr);
export const fieldName = (attribute: UserProfileAttribute) =>
`${isRootAttribute(attribute.name) ? "" : "attributes."}${attribute.name}`;

View file

@ -8,7 +8,7 @@ export default interface UserProfileConfig {
export interface UserProfileAttribute {
name?: string;
validations?: Record<string, Record<string, unknown>>;
annotations?: Record<string, unknown>[];
annotations?: Record<string, unknown>;
required?: UserProfileAttributeRequired;
permissions?: UserProfileAttributePermissions;
selector?: UserProfileAttributeSelector;