diff --git a/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx b/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx index 8a411b289c..e2ac0011ee 100644 --- a/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx +++ b/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx @@ -151,7 +151,7 @@ export default function NewAttributeSettings() { "annotations", Object.entries(annotations || {}).map(([key, value]) => ({ key, - value, + value: value as Record, })), ); form.setValue( diff --git a/js/apps/admin-ui/src/user/UserProfileFields.tsx b/js/apps/admin-ui/src/user/UserProfileFields.tsx index 1a417d6743..73d7940dd6 100644 --- a/js/apps/admin-ui/src/user/UserProfileFields.tsx +++ b/js/apps/admin-ui/src/user/UserProfileFields.tsx @@ -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 ( - - {isSelect(attribute) ? ( - ( - - )} - /> - ) : ( - - roles.includes(r), - ) - } - {...register(fieldName(attribute))} - /> - )} - - ); + return ; }; diff --git a/js/apps/admin-ui/src/user/components/OptionsComponent.tsx b/js/apps/admin-ui/src/user/components/OptionsComponent.tsx new file mode 100644 index 0000000000..79f6456c70 --- /dev/null +++ b/js/apps/admin-ui/src/user/components/OptionsComponent.tsx @@ -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 ( + + ( + <> + {options.map((option) => ( + { + 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]); + } + }} + /> + ))} + + )} + /> + + ); +}; diff --git a/js/apps/admin-ui/src/user/components/SelectComponent.tsx b/js/apps/admin-ui/src/user/components/SelectComponent.tsx new file mode 100644 index 0000000000..44f5de8d17 --- /dev/null +++ b/js/apps/admin-ui/src/user/components/SelectComponent.tsx @@ -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 ( + + ( + + )} + /> + + ); +}; diff --git a/js/apps/admin-ui/src/user/components/TextAreaComponent.tsx b/js/apps/admin-ui/src/user/components/TextAreaComponent.tsx new file mode 100644 index 0000000000..c8d584912c --- /dev/null +++ b/js/apps/admin-ui/src/user/components/TextAreaComponent.tsx @@ -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 ( + + + + ); +}; diff --git a/js/apps/admin-ui/src/user/components/TextComponent.tsx b/js/apps/admin-ui/src/user/components/TextComponent.tsx new file mode 100644 index 0000000000..da74389f5a --- /dev/null +++ b/js/apps/admin-ui/src/user/components/TextComponent.tsx @@ -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 ( + + + + ); +}; diff --git a/js/apps/admin-ui/src/user/components/UserProfileGroup.tsx b/js/apps/admin-ui/src/user/components/UserProfileGroup.tsx new file mode 100644 index 0000000000..7b40e6bf5a --- /dev/null +++ b/js/apps/admin-ui/src/user/components/UserProfileGroup.tsx @@ -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) => { + const { t } = useTranslation("users"); + const helpText = attribute.annotations?.["inputHelperTextBefore"] as string; + + const { + formState: { errors }, + } = useFormContext(); + + return ( + + ) : undefined + } + > + {children} + + ); +}; diff --git a/js/apps/admin-ui/src/user/utils.ts b/js/apps/admin-ui/src/user/utils.ts index 55ac4f3110..061633cf73 100644 --- a/js/apps/admin-ui/src/user/utils.ts +++ b/js/apps/admin-ui/src/user/utils.ts @@ -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}`; diff --git a/js/libs/keycloak-admin-client/src/defs/userProfileConfig.ts b/js/libs/keycloak-admin-client/src/defs/userProfileConfig.ts index 47ad4c22af..0af67d28c1 100644 --- a/js/libs/keycloak-admin-client/src/defs/userProfileConfig.ts +++ b/js/libs/keycloak-admin-client/src/defs/userProfileConfig.ts @@ -8,7 +8,7 @@ export default interface UserProfileConfig { export interface UserProfileAttribute { name?: string; validations?: Record>; - annotations?: Record[]; + annotations?: Record; required?: UserProfileAttributeRequired; permissions?: UserProfileAttributePermissions; selector?: UserProfileAttributeSelector;