@keycloakify/keycloak-ui-shared 26.0.6001

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +6 -0
  3. package/keycloak-theme/shared/keycloak-ui-shared/alerts/AlertPanel.tsx +43 -0
  4. package/keycloak-theme/shared/keycloak-ui-shared/alerts/Alerts.tsx +82 -0
  5. package/keycloak-theme/shared/keycloak-ui-shared/buttons/FormSubmitButton.tsx +47 -0
  6. package/keycloak-theme/shared/keycloak-ui-shared/context/ErrorPage.tsx +60 -0
  7. package/keycloak-theme/shared/keycloak-ui-shared/context/HelpContext.tsx +30 -0
  8. package/keycloak-theme/shared/keycloak-ui-shared/context/KeycloakContext.tsx +97 -0
  9. package/keycloak-theme/shared/keycloak-ui-shared/context/environment.ts +50 -0
  10. package/keycloak-theme/shared/keycloak-ui-shared/continue-cancel/ContinueCancelModal.tsx +75 -0
  11. package/keycloak-theme/shared/keycloak-ui-shared/controls/FormErrorText.tsx +23 -0
  12. package/keycloak-theme/shared/keycloak-ui-shared/controls/FormLabel.tsx +40 -0
  13. package/keycloak-theme/shared/keycloak-ui-shared/controls/HelpItem.tsx +43 -0
  14. package/keycloak-theme/shared/keycloak-ui-shared/controls/KeycloakSpinner.tsx +12 -0
  15. package/keycloak-theme/shared/keycloak-ui-shared/controls/NumberControl.tsx +93 -0
  16. package/keycloak-theme/shared/keycloak-ui-shared/controls/OrganizationTable.tsx +122 -0
  17. package/keycloak-theme/shared/keycloak-ui-shared/controls/PasswordControl.tsx +71 -0
  18. package/keycloak-theme/shared/keycloak-ui-shared/controls/PasswordInput.tsx +50 -0
  19. package/keycloak-theme/shared/keycloak-ui-shared/controls/SwitchControl.tsx +67 -0
  20. package/keycloak-theme/shared/keycloak-ui-shared/controls/TextAreaControl.tsx +60 -0
  21. package/keycloak-theme/shared/keycloak-ui-shared/controls/TextControl.tsx +75 -0
  22. package/keycloak-theme/shared/keycloak-ui-shared/controls/keycloak-text-area/KeycloakTextArea.tsx +23 -0
  23. package/keycloak-theme/shared/keycloak-ui-shared/controls/select-control/SelectControl.tsx +75 -0
  24. package/keycloak-theme/shared/keycloak-ui-shared/controls/select-control/SingleSelectControl.tsx +109 -0
  25. package/keycloak-theme/shared/keycloak-ui-shared/controls/select-control/TypeaheadSelectControl.tsx +285 -0
  26. package/keycloak-theme/shared/keycloak-ui-shared/controls/table/KeycloakDataTable.tsx +597 -0
  27. package/keycloak-theme/shared/keycloak-ui-shared/controls/table/ListEmptyState.tsx +86 -0
  28. package/keycloak-theme/shared/keycloak-ui-shared/controls/table/PaginatingTableToolbar.tsx +106 -0
  29. package/keycloak-theme/shared/keycloak-ui-shared/controls/table/TableToolbar.tsx +92 -0
  30. package/keycloak-theme/shared/keycloak-ui-shared/icons/IconMapper.tsx +63 -0
  31. package/keycloak-theme/shared/keycloak-ui-shared/index.ts +1 -0
  32. package/keycloak-theme/shared/keycloak-ui-shared/main.ts +96 -0
  33. package/keycloak-theme/shared/keycloak-ui-shared/masthead/DefaultAvatar.tsx +109 -0
  34. package/keycloak-theme/shared/keycloak-ui-shared/masthead/KeycloakDropdown.tsx +48 -0
  35. package/keycloak-theme/shared/keycloak-ui-shared/masthead/Masthead.tsx +161 -0
  36. package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/FormPanel.tsx +29 -0
  37. package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/FormTitle.tsx +28 -0
  38. package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/ScrollForm.tsx +98 -0
  39. package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/ScrollPanel.tsx +21 -0
  40. package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/form-title.module.css +4 -0
  41. package/keycloak-theme/shared/keycloak-ui-shared/scroll-form/scroll-form.module.css +8 -0
  42. package/keycloak-theme/shared/keycloak-ui-shared/select/KeycloakSelect.tsx +49 -0
  43. package/keycloak-theme/shared/keycloak-ui-shared/select/SingleSelect.tsx +89 -0
  44. package/keycloak-theme/shared/keycloak-ui-shared/select/TypeaheadSelect.tsx +198 -0
  45. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/LocaleSelector.tsx +51 -0
  46. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/MultiInputComponent.tsx +146 -0
  47. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/OptionsComponent.tsx +63 -0
  48. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/SelectComponent.tsx +109 -0
  49. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/TextAreaComponent.tsx +23 -0
  50. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/TextComponent.tsx +32 -0
  51. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/UserProfileFields.tsx +243 -0
  52. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/UserProfileGroup.tsx +71 -0
  53. package/keycloak-theme/shared/keycloak-ui-shared/user-profile/utils.ts +170 -0
  54. package/keycloak-theme/shared/keycloak-ui-shared/utils/ErrorBoundary.tsx +77 -0
  55. package/keycloak-theme/shared/keycloak-ui-shared/utils/createNamedContext.ts +11 -0
  56. package/keycloak-theme/shared/keycloak-ui-shared/utils/darkMode.ts +19 -0
  57. package/keycloak-theme/shared/keycloak-ui-shared/utils/errors.ts +55 -0
  58. package/keycloak-theme/shared/keycloak-ui-shared/utils/generateId.ts +1 -0
  59. package/keycloak-theme/shared/keycloak-ui-shared/utils/getRuleValue.ts +17 -0
  60. package/keycloak-theme/shared/keycloak-ui-shared/utils/isDefined.ts +3 -0
  61. package/keycloak-theme/shared/keycloak-ui-shared/utils/useFetch.ts +44 -0
  62. package/keycloak-theme/shared/keycloak-ui-shared/utils/useRequiredContext.ts +24 -0
  63. package/keycloak-theme/shared/keycloak-ui-shared/utils/useSetTimeout.ts +40 -0
  64. package/keycloak-theme/shared/keycloak-ui-shared/utils/useStorageItem.ts +51 -0
  65. package/keycloak-theme/shared/keycloak-ui-shared/utils/useStoredState.ts +38 -0
  66. package/package.json +31 -0
@@ -0,0 +1,146 @@
1
+ import {
2
+ Button,
3
+ ButtonVariant,
4
+ InputGroup,
5
+ TextInput,
6
+ TextInputProps,
7
+ TextInputTypes,
8
+ InputGroupItem,
9
+ } from "@patternfly/react-core";
10
+ import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons";
11
+ import { type TFunction } from "i18next";
12
+ import { Fragment, useEffect, useMemo } from "react";
13
+ import { FieldPath, UseFormReturn, useWatch } from "react-hook-form";
14
+
15
+ import { InputType, UserProfileFieldProps } from "./UserProfileFields";
16
+ import { UserProfileGroup } from "./UserProfileGroup";
17
+ import { UserFormFields, fieldName, labelAttribute } from "./utils";
18
+
19
+ export const MultiInputComponent = ({
20
+ t,
21
+ form,
22
+ attribute,
23
+ renderer,
24
+ ...rest
25
+ }: UserProfileFieldProps) => (
26
+ <UserProfileGroup t={t} form={form} attribute={attribute} renderer={renderer}>
27
+ <MultiLineInput
28
+ t={t}
29
+ form={form}
30
+ aria-label={labelAttribute(t, attribute)}
31
+ name={fieldName(attribute.name)!}
32
+ addButtonLabel={t("addMultivaluedLabel", {
33
+ fieldLabel: labelAttribute(t, attribute),
34
+ })}
35
+ {...rest}
36
+ />
37
+ </UserProfileGroup>
38
+ );
39
+
40
+ export type MultiLineInputProps = Omit<TextInputProps, "form"> & {
41
+ t: TFunction;
42
+ name: FieldPath<UserFormFields>;
43
+ form: UseFormReturn<UserFormFields>;
44
+ addButtonLabel?: string;
45
+ isDisabled?: boolean;
46
+ defaultValue?: string[];
47
+ inputType: InputType;
48
+ };
49
+
50
+ const MultiLineInput = ({
51
+ t,
52
+ name,
53
+ inputType,
54
+ form,
55
+ addButtonLabel,
56
+ isDisabled = false,
57
+ defaultValue,
58
+ id,
59
+ ...rest
60
+ }: MultiLineInputProps) => {
61
+ const { register, setValue, control } = form;
62
+ const value = useWatch({
63
+ name,
64
+ control,
65
+ defaultValue: defaultValue || "",
66
+ });
67
+
68
+ const fields = useMemo<string[]>(() => {
69
+ return Array.isArray(value) && value.length !== 0
70
+ ? value
71
+ : defaultValue || [""];
72
+ }, [value]);
73
+
74
+ const remove = (index: number) => {
75
+ update([...fields.slice(0, index), ...fields.slice(index + 1)]);
76
+ };
77
+
78
+ const append = () => {
79
+ update([...fields, ""]);
80
+ };
81
+
82
+ const updateValue = (index: number, value: string) => {
83
+ update([...fields.slice(0, index), value, ...fields.slice(index + 1)]);
84
+ };
85
+
86
+ const update = (values: string[]) => {
87
+ const fieldValue = values.flatMap((field) => field);
88
+ setValue(name, fieldValue, {
89
+ shouldDirty: true,
90
+ });
91
+ };
92
+
93
+ const type = inputType.startsWith("html")
94
+ ? (inputType.substring("html".length + 2) as TextInputTypes)
95
+ : "text";
96
+
97
+ useEffect(() => {
98
+ register(name);
99
+ }, [register]);
100
+
101
+ return (
102
+ <div id={id}>
103
+ {fields.map((value, index) => (
104
+ <Fragment key={index}>
105
+ <InputGroup>
106
+ <InputGroupItem isFill>
107
+ <TextInput
108
+ data-testid={name + index}
109
+ onChange={(_event, value) => updateValue(index, value)}
110
+ name={`${name}.${index}.value`}
111
+ value={value}
112
+ isDisabled={isDisabled}
113
+ type={type}
114
+ {...rest}
115
+ />
116
+ </InputGroupItem>
117
+ <InputGroupItem>
118
+ <Button
119
+ data-testid={"remove" + index}
120
+ variant={ButtonVariant.link}
121
+ onClick={() => remove(index)}
122
+ tabIndex={-1}
123
+ aria-label={t("remove")}
124
+ isDisabled={fields.length === 1 || isDisabled}
125
+ >
126
+ <MinusCircleIcon />
127
+ </Button>
128
+ </InputGroupItem>
129
+ </InputGroup>
130
+ {index === fields.length - 1 && (
131
+ <Button
132
+ variant={ButtonVariant.link}
133
+ onClick={append}
134
+ tabIndex={-1}
135
+ aria-label={t("add")}
136
+ data-testid="addValue"
137
+ isDisabled={!value || isDisabled}
138
+ >
139
+ <PlusCircleIcon /> {t(addButtonLabel || "add")}
140
+ </Button>
141
+ )}
142
+ </Fragment>
143
+ ))}
144
+ </div>
145
+ );
146
+ };
@@ -0,0 +1,63 @@
1
+ import { Checkbox, Radio } from "@patternfly/react-core";
2
+ import { Controller } from "react-hook-form";
3
+ import {
4
+ OptionLabel,
5
+ Options,
6
+ UserProfileFieldProps,
7
+ } from "./UserProfileFields";
8
+ import { UserProfileGroup } from "./UserProfileGroup";
9
+ import { fieldName, isRequiredAttribute, label } from "./utils";
10
+
11
+ export const OptionComponent = (props: UserProfileFieldProps) => {
12
+ const { form, inputType, attribute } = props;
13
+ const isRequired = isRequiredAttribute(attribute);
14
+ const isMultiSelect = inputType.startsWith("multiselect");
15
+ const Component = isMultiSelect ? Checkbox : Radio;
16
+ const options =
17
+ (attribute.validators?.options as Options | undefined)?.options || [];
18
+
19
+ const optionLabel =
20
+ (attribute.annotations?.["inputOptionLabels"] as OptionLabel) || {};
21
+ const prefix = attribute.annotations?.[
22
+ "inputOptionLabelsI18nPrefix"
23
+ ] as string;
24
+
25
+ return (
26
+ <UserProfileGroup {...props}>
27
+ <Controller
28
+ name={fieldName(attribute.name)}
29
+ control={form.control}
30
+ defaultValue=""
31
+ render={({ field }) => (
32
+ <>
33
+ {options.map((option) => (
34
+ <Component
35
+ key={option}
36
+ id={option}
37
+ data-testid={option}
38
+ label={label(props.t, optionLabel[option], option, prefix)}
39
+ value={option}
40
+ isChecked={field.value.includes(option)}
41
+ onChange={() => {
42
+ if (isMultiSelect) {
43
+ if (field.value.includes(option)) {
44
+ field.onChange(
45
+ field.value.filter((item: string) => item !== option),
46
+ );
47
+ } else {
48
+ field.onChange([...field.value, option]);
49
+ }
50
+ } else {
51
+ field.onChange([option]);
52
+ }
53
+ }}
54
+ readOnly={attribute.readOnly}
55
+ isRequired={isRequired}
56
+ />
57
+ ))}
58
+ </>
59
+ )}
60
+ />
61
+ </UserProfileGroup>
62
+ );
63
+ };
@@ -0,0 +1,109 @@
1
+ import { SelectOption } from "@patternfly/react-core";
2
+ import { useState } from "react";
3
+ import { Controller, ControllerRenderProps } from "react-hook-form";
4
+ import { KeycloakSelect, SelectVariant } from "../select/KeycloakSelect";
5
+ import {
6
+ OptionLabel,
7
+ Options,
8
+ UserProfileFieldProps,
9
+ } from "./UserProfileFields";
10
+ import { UserProfileGroup } from "./UserProfileGroup";
11
+ import { UserFormFields, fieldName, label } from "./utils";
12
+
13
+ export const SelectComponent = (props: UserProfileFieldProps) => {
14
+ const { t, form, inputType, attribute } = props;
15
+ const [open, setOpen] = useState(false);
16
+ const [filter, setFilter] = useState("");
17
+ const isMultiValue = inputType === "multiselect";
18
+
19
+ const setValue = (
20
+ value: string,
21
+ field: ControllerRenderProps<UserFormFields>,
22
+ ) => {
23
+ if (isMultiValue) {
24
+ if (field.value.includes(value)) {
25
+ field.onChange(field.value.filter((item: string) => item !== value));
26
+ } else {
27
+ if (Array.isArray(field.value)) {
28
+ field.onChange([...field.value, value]);
29
+ } else {
30
+ field.onChange([value]);
31
+ }
32
+ }
33
+ } else {
34
+ field.onChange(value === field.value ? "" : value);
35
+ }
36
+ };
37
+
38
+ const options =
39
+ (attribute.validators?.options as Options | undefined)?.options || [];
40
+
41
+ const optionLabel =
42
+ (attribute.annotations?.["inputOptionLabels"] as OptionLabel) || {};
43
+ const prefix = attribute.annotations?.[
44
+ "inputOptionLabelsI18nPrefix"
45
+ ] as string;
46
+
47
+ const fetchLabel = (option: string) =>
48
+ label(props.t, optionLabel[option], option, prefix);
49
+
50
+ const convertOptions = (selected: string) =>
51
+ options
52
+ .filter((o) =>
53
+ fetchLabel(o)!.toLowerCase().includes(filter.toLowerCase()),
54
+ )
55
+ .map((option) => (
56
+ <SelectOption
57
+ selected={selected === option}
58
+ key={option}
59
+ value={option}
60
+ >
61
+ {fetchLabel(option)}
62
+ </SelectOption>
63
+ ));
64
+
65
+ return (
66
+ <UserProfileGroup {...props}>
67
+ <Controller
68
+ name={fieldName(attribute.name)}
69
+ defaultValue=""
70
+ control={form.control}
71
+ render={({ field }) => (
72
+ <KeycloakSelect
73
+ toggleId={attribute.name}
74
+ onToggle={(b) => setOpen(b)}
75
+ onClear={() => setValue("", field)}
76
+ onSelect={(value) => {
77
+ const option = value.toString();
78
+ setValue(option, field);
79
+ if (!Array.isArray(field.value)) {
80
+ setOpen(false);
81
+ }
82
+ }}
83
+ selections={
84
+ isMultiValue && Array.isArray(field.value)
85
+ ? field.value
86
+ : fetchLabel(field.value)
87
+ }
88
+ variant={
89
+ isMultiValue
90
+ ? SelectVariant.typeaheadMulti
91
+ : options.length >= 10
92
+ ? SelectVariant.typeahead
93
+ : SelectVariant.single
94
+ }
95
+ aria-label={t("selectOne")}
96
+ isOpen={open}
97
+ isDisabled={attribute.readOnly}
98
+ onFilter={(value) => {
99
+ setFilter(value);
100
+ return convertOptions(field.value);
101
+ }}
102
+ >
103
+ {convertOptions(field.value)}
104
+ </KeycloakSelect>
105
+ )}
106
+ />
107
+ </UserProfileGroup>
108
+ );
109
+ };
@@ -0,0 +1,23 @@
1
+ import { KeycloakTextArea } from "../controls/keycloak-text-area/KeycloakTextArea";
2
+ import { UserProfileFieldProps } from "./UserProfileFields";
3
+ import { UserProfileGroup } from "./UserProfileGroup";
4
+ import { fieldName, isRequiredAttribute } from "./utils";
5
+
6
+ export const TextAreaComponent = (props: UserProfileFieldProps) => {
7
+ const { form, attribute } = props;
8
+ const isRequired = isRequiredAttribute(attribute);
9
+
10
+ return (
11
+ <UserProfileGroup {...props}>
12
+ <KeycloakTextArea
13
+ id={attribute.name}
14
+ data-testid={attribute.name}
15
+ {...form.register(fieldName(attribute.name))}
16
+ cols={attribute.annotations?.["inputTypeCols"] as number}
17
+ rows={attribute.annotations?.["inputTypeRows"] as number}
18
+ readOnly={attribute.readOnly}
19
+ isRequired={isRequired}
20
+ />
21
+ </UserProfileGroup>
22
+ );
23
+ };
@@ -0,0 +1,32 @@
1
+ import { TextInput, TextInputTypes } from "@patternfly/react-core";
2
+
3
+ import { UserProfileFieldProps } from "./UserProfileFields";
4
+ import { UserProfileGroup } from "./UserProfileGroup";
5
+ import { fieldName, isRequiredAttribute, label } from "./utils";
6
+
7
+ export const TextComponent = (props: UserProfileFieldProps) => {
8
+ const { form, inputType, attribute } = props;
9
+ const isRequired = isRequiredAttribute(attribute);
10
+ const type = inputType.startsWith("html")
11
+ ? (inputType.substring("html".length + 2) as TextInputTypes)
12
+ : "text";
13
+
14
+ return (
15
+ <UserProfileGroup {...props}>
16
+ <TextInput
17
+ id={attribute.name}
18
+ data-testid={attribute.name}
19
+ type={type}
20
+ placeholder={label(
21
+ props.t,
22
+ attribute.annotations?.["inputTypePlaceholder"] as string,
23
+ attribute.name,
24
+ attribute.annotations?.["inputOptionLabelsI18nPrefix"] as string,
25
+ )}
26
+ readOnly={attribute.readOnly}
27
+ isRequired={isRequired}
28
+ {...form.register(fieldName(attribute.name))}
29
+ />
30
+ </UserProfileGroup>
31
+ );
32
+ };
@@ -0,0 +1,243 @@
1
+ import {
2
+ UserProfileAttributeGroupMetadata,
3
+ UserProfileAttributeMetadata,
4
+ UserProfileMetadata,
5
+ } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
6
+ import { Text } from "@patternfly/react-core";
7
+ import { TFunction } from "i18next";
8
+ import { ReactNode, useMemo, type JSX } from "react";
9
+ import { FieldPath, UseFormReturn } from "react-hook-form";
10
+
11
+ import { ScrollForm } from "../main";
12
+ import { LocaleSelector } from "./LocaleSelector";
13
+ import { MultiInputComponent } from "./MultiInputComponent";
14
+ import { OptionComponent } from "./OptionsComponent";
15
+ import { SelectComponent } from "./SelectComponent";
16
+ import { TextAreaComponent } from "./TextAreaComponent";
17
+ import { TextComponent } from "./TextComponent";
18
+ import { UserFormFields, fieldName, isRootAttribute, label } from "./utils";
19
+
20
+ export type UserProfileError = {
21
+ responseData: { errors?: { errorMessage: string }[] };
22
+ };
23
+
24
+ export type Options = {
25
+ options?: string[];
26
+ };
27
+
28
+ export type InputType =
29
+ | "text"
30
+ | "textarea"
31
+ | "select"
32
+ | "select-radiobuttons"
33
+ | "multiselect"
34
+ | "multiselect-checkboxes"
35
+ | "html5-email"
36
+ | "html5-tel"
37
+ | "html5-url"
38
+ | "html5-number"
39
+ | "html5-range"
40
+ | "html5-datetime-local"
41
+ | "html5-date"
42
+ | "html5-month"
43
+ | "html5-time"
44
+ | "multi-input";
45
+
46
+ export type UserProfileFieldProps = {
47
+ t: TFunction;
48
+ form: UseFormReturn<UserFormFields>;
49
+ inputType: InputType;
50
+ attribute: UserProfileAttributeMetadata;
51
+ renderer?: (attribute: UserProfileAttributeMetadata) => ReactNode;
52
+ };
53
+
54
+ export type OptionLabel = Record<string, string> | undefined;
55
+
56
+ export const FIELDS: {
57
+ [type in InputType]: (props: UserProfileFieldProps) => JSX.Element;
58
+ } = {
59
+ text: TextComponent,
60
+ textarea: TextAreaComponent,
61
+ select: SelectComponent,
62
+ "select-radiobuttons": OptionComponent,
63
+ multiselect: SelectComponent,
64
+ "multiselect-checkboxes": OptionComponent,
65
+ "html5-email": TextComponent,
66
+ "html5-tel": TextComponent,
67
+ "html5-url": TextComponent,
68
+ "html5-number": TextComponent,
69
+ "html5-range": TextComponent,
70
+ "html5-datetime-local": TextComponent,
71
+ "html5-date": TextComponent,
72
+ "html5-month": TextComponent,
73
+ "html5-time": TextComponent,
74
+ "multi-input": MultiInputComponent,
75
+ } as const;
76
+
77
+ export type UserProfileFieldsProps = {
78
+ t: TFunction;
79
+ form: UseFormReturn<UserFormFields>;
80
+ userProfileMetadata: UserProfileMetadata;
81
+ supportedLocales: string[];
82
+ currentLocale: string;
83
+ hideReadOnly?: boolean;
84
+ renderer?: (
85
+ attribute: UserProfileAttributeMetadata,
86
+ ) => JSX.Element | undefined;
87
+ };
88
+
89
+ type GroupWithAttributes = {
90
+ group: UserProfileAttributeGroupMetadata;
91
+ attributes: UserProfileAttributeMetadata[];
92
+ };
93
+
94
+ export const UserProfileFields = ({
95
+ t,
96
+ form,
97
+ userProfileMetadata,
98
+ supportedLocales,
99
+ currentLocale,
100
+ hideReadOnly = false,
101
+ renderer,
102
+ }: UserProfileFieldsProps) => {
103
+ // Group attributes by group, for easier rendering.
104
+ const groupsWithAttributes = useMemo(() => {
105
+ // If there are no attributes, there is no need to group them.
106
+ if (!userProfileMetadata.attributes) {
107
+ return [];
108
+ }
109
+
110
+ // Hide read-only attributes if 'hideReadOnly' is enabled.
111
+ const attributes = hideReadOnly
112
+ ? userProfileMetadata.attributes.filter(({ readOnly }) => !readOnly)
113
+ : userProfileMetadata.attributes;
114
+
115
+ return [
116
+ // Insert an empty group for attributes without a group.
117
+ { name: undefined },
118
+ ...(userProfileMetadata.groups ?? []),
119
+ ].map<GroupWithAttributes>((group) => ({
120
+ group,
121
+ attributes: attributes.filter(
122
+ (attribute) => attribute.group === group.name,
123
+ ),
124
+ }));
125
+ }, [
126
+ hideReadOnly,
127
+ userProfileMetadata.groups,
128
+ userProfileMetadata.attributes,
129
+ ]);
130
+
131
+ if (groupsWithAttributes.length === 0) {
132
+ return null;
133
+ }
134
+
135
+ return (
136
+ <ScrollForm
137
+ label={t("jumpToSection")}
138
+ sections={groupsWithAttributes
139
+ .filter((group) => group.attributes.length > 0)
140
+ .map(({ group, attributes }) => ({
141
+ title: label(t, group.displayHeader, group.name) || t("general"),
142
+ panel: (
143
+ <div className="pf-v5-c-form">
144
+ {group.displayDescription && (
145
+ <Text className="pf-v5-u-pb-lg">
146
+ {label(t, group.displayDescription, "")}
147
+ </Text>
148
+ )}
149
+ {attributes.map((attribute) => (
150
+ <FormField
151
+ key={attribute.name}
152
+ t={t}
153
+ form={form}
154
+ supportedLocales={supportedLocales}
155
+ currentLocale={currentLocale}
156
+ renderer={renderer}
157
+ attribute={attribute}
158
+ />
159
+ ))}
160
+ </div>
161
+ ),
162
+ }))}
163
+ />
164
+ );
165
+ };
166
+
167
+ type FormFieldProps = {
168
+ t: TFunction;
169
+ form: UseFormReturn<UserFormFields>;
170
+ supportedLocales: string[];
171
+ currentLocale: string;
172
+ attribute: UserProfileAttributeMetadata;
173
+ renderer?: (
174
+ attribute: UserProfileAttributeMetadata,
175
+ ) => JSX.Element | undefined;
176
+ };
177
+
178
+ const FormField = ({
179
+ t,
180
+ form,
181
+ renderer,
182
+ supportedLocales,
183
+ currentLocale,
184
+ attribute,
185
+ }: FormFieldProps) => {
186
+ const value = form.watch(
187
+ fieldName(attribute.name) as FieldPath<UserFormFields>,
188
+ );
189
+ const inputType = useMemo(() => determineInputType(attribute), [attribute]);
190
+
191
+ const Component =
192
+ attribute.multivalued ||
193
+ (isMultiValue(value) && attribute.annotations?.inputType === undefined)
194
+ ? FIELDS["multi-input"]
195
+ : FIELDS[inputType];
196
+
197
+ if (attribute.name === "locale")
198
+ return (
199
+ <LocaleSelector
200
+ form={form}
201
+ supportedLocales={supportedLocales}
202
+ currentLocale={currentLocale}
203
+ t={t}
204
+ attribute={attribute}
205
+ />
206
+ );
207
+ return (
208
+ <Component
209
+ t={t}
210
+ form={form}
211
+ inputType={inputType}
212
+ attribute={attribute}
213
+ renderer={renderer}
214
+ />
215
+ );
216
+ };
217
+
218
+ const DEFAULT_INPUT_TYPE = "text" satisfies InputType;
219
+
220
+ function determineInputType(
221
+ attribute: UserProfileAttributeMetadata,
222
+ ): InputType {
223
+ // Always treat the root attributes as a text field.
224
+ if (isRootAttribute(attribute.name)) {
225
+ return "text";
226
+ }
227
+
228
+ const inputType = attribute.annotations?.inputType;
229
+
230
+ // if we have an valid input type use that to render
231
+ if (isValidInputType(inputType)) {
232
+ return inputType;
233
+ }
234
+
235
+ // In all other cases use the default
236
+ return DEFAULT_INPUT_TYPE;
237
+ }
238
+
239
+ const isValidInputType = (value: unknown): value is InputType =>
240
+ typeof value === "string" && value in FIELDS;
241
+
242
+ const isMultiValue = (value: unknown): boolean =>
243
+ Array.isArray(value) && value.length > 1;
@@ -0,0 +1,71 @@
1
+ import { UserProfileAttributeMetadata } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
2
+ import { FormGroup, InputGroup } from "@patternfly/react-core";
3
+ import { TFunction } from "i18next";
4
+ import { get } from "lodash-es";
5
+ import { PropsWithChildren, ReactNode } from "react";
6
+ import { UseFormReturn, type FieldError } from "react-hook-form";
7
+
8
+ import { FormErrorText } from "../controls/FormErrorText";
9
+ import { HelpItem } from "../controls/HelpItem";
10
+ import {
11
+ UserFormFields,
12
+ fieldName,
13
+ isRequiredAttribute,
14
+ label,
15
+ labelAttribute,
16
+ } from "./utils";
17
+
18
+ export type UserProfileGroupProps = {
19
+ t: TFunction;
20
+ form: UseFormReturn<UserFormFields>;
21
+ attribute: UserProfileAttributeMetadata;
22
+ renderer?: (attribute: UserProfileAttributeMetadata) => ReactNode;
23
+ };
24
+
25
+ export const UserProfileGroup = ({
26
+ t,
27
+ form,
28
+ attribute,
29
+ renderer,
30
+ children,
31
+ }: PropsWithChildren<UserProfileGroupProps>) => {
32
+ const helpText = label(
33
+ t,
34
+ attribute.annotations?.["inputHelperTextBefore"] as string,
35
+ );
36
+ const {
37
+ formState: { errors },
38
+ } = form;
39
+
40
+ const component = renderer?.(attribute);
41
+ const error = get(errors, fieldName(attribute.name)) as FieldError;
42
+
43
+ return (
44
+ <FormGroup
45
+ key={attribute.name}
46
+ label={labelAttribute(t, attribute) || ""}
47
+ fieldId={attribute.name}
48
+ isRequired={isRequiredAttribute(attribute)}
49
+ labelIcon={
50
+ helpText ? (
51
+ <HelpItem helpText={helpText} fieldLabelId={attribute.name!} />
52
+ ) : undefined
53
+ }
54
+ >
55
+ {component ? (
56
+ <InputGroup>
57
+ {children}
58
+ {component}
59
+ </InputGroup>
60
+ ) : (
61
+ children
62
+ )}
63
+ {error && (
64
+ <FormErrorText
65
+ data-testid={`${attribute.name}-helper`}
66
+ message={error.message as string}
67
+ />
68
+ )}
69
+ </FormGroup>
70
+ );
71
+ };