render components based on the annotations (#21180)
* render components based on the annotations * merge fix
This commit is contained in:
parent
d2cdd78655
commit
0481ea6bda
9 changed files with 289 additions and 104 deletions
|
@ -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(
|
||||
|
|
|
@ -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 }} />;
|
||||
};
|
||||
|
|
52
js/apps/admin-ui/src/user/components/OptionsComponent.tsx
Normal file
52
js/apps/admin-ui/src/user/components/OptionsComponent.tsx
Normal 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>
|
||||
);
|
||||
};
|
69
js/apps/admin-ui/src/user/components/SelectComponent.tsx
Normal file
69
js/apps/admin-ui/src/user/components/SelectComponent.tsx
Normal 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>
|
||||
);
|
||||
};
|
21
js/apps/admin-ui/src/user/components/TextAreaComponent.tsx
Normal file
21
js/apps/admin-ui/src/user/components/TextAreaComponent.tsx
Normal 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>
|
||||
);
|
||||
};
|
25
js/apps/admin-ui/src/user/components/TextComponent.tsx
Normal file
25
js/apps/admin-ui/src/user/components/TextComponent.tsx
Normal 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>
|
||||
);
|
||||
};
|
46
js/apps/admin-ui/src/user/components/UserProfileGroup.tsx
Normal file
46
js/apps/admin-ui/src/user/components/UserProfileGroup.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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}`;
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue