Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
159 changes: 57 additions & 102 deletions js/apps/admin-ui/src/user/UserProfileFields.tsx
Original file line number Diff line number Diff line change
@@ -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[];
Expand All @@ -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;
}
Expand All @@ -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) => {
Expand Down Expand Up @@ -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 changes: 52 additions & 0 deletions js/apps/admin-ui/src/user/components/OptionsComponent.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 69 additions & 0 deletions js/apps/admin-ui/src/user/components/SelectComponent.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 21 additions & 0 deletions js/apps/admin-ui/src/user/components/TextAreaComponent.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 25 additions & 0 deletions js/apps/admin-ui/src/user/components/TextComponent.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
Loading