diff --git a/js/apps/account-ui/src/personal-info/LocaleSelector.tsx b/js/apps/account-ui/src/personal-info/LocaleSelector.tsx deleted file mode 100644 index 27832c362ff3..000000000000 --- a/js/apps/account-ui/src/personal-info/LocaleSelector.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import type { SelectControlOption } from "ui-shared"; -import { SelectControl } from "ui-shared"; -import { getSupportedLocales } from "../api/methods"; -import { usePromise } from "../utils/usePromise"; - -const localeToDisplayName = (locale: string) => { - try { - return new Intl.DisplayNames([locale], { type: "language" }).of(locale); - } catch (error) { - return locale; - } -}; - -export const LocaleSelector = () => { - const { t } = useTranslation(); - const [locales, setLocales] = useState([]); - - usePromise( - (signal) => getSupportedLocales({ signal }), - (locales) => - setLocales( - locales.map((locale) => ({ - key: locale, - value: localeToDisplayName(locale) || "", - })), - ), - ); - - return ( - - ); -}; diff --git a/js/apps/account-ui/src/personal-info/PersonalInfo.tsx b/js/apps/account-ui/src/personal-info/PersonalInfo.tsx index f8b0beceec07..0f2eb0af4156 100644 --- a/js/apps/account-ui/src/personal-info/PersonalInfo.tsx +++ b/js/apps/account-ui/src/personal-info/PersonalInfo.tsx @@ -10,8 +10,12 @@ import { useKeycloak } from "keycloak-masthead"; import { useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { useAlerts } from "ui-shared"; -import { getPersonalInfo, savePersonalInfo } from "../api/methods"; +import { UserProfileFields, useAlerts } from "ui-shared"; +import { + getPersonalInfo, + getSupportedLocales, + savePersonalInfo, +} from "../api/methods"; import { UserProfileMetadata, UserRepresentation, @@ -20,7 +24,6 @@ import { Page } from "../components/page/Page"; import { environment } from "../environment"; import { TFuncKey } from "../i18n"; import { usePromise } from "../utils/usePromise"; -import { UserProfileFields } from "./UserProfileFields"; type FieldError = { field: string; @@ -41,14 +44,20 @@ const PersonalInfo = () => { const keycloak = useKeycloak(); const [userProfileMetadata, setUserProfileMetadata] = useState(); + const [supportedLocales, setSupportedLocales] = useState([]); const form = useForm({ mode: "onChange" }); const { handleSubmit, reset, setError } = form; const { addAlert, addError } = useAlerts(); usePromise( - (signal) => getPersonalInfo({ signal }), - (personalInfo) => { + (signal) => + Promise.all([ + getPersonalInfo({ signal }), + getSupportedLocales({ signal }), + ]), + ([personalInfo, supportedLocales]) => { setUserProfileMetadata(personalInfo.userProfileMetadata); + setSupportedLocales(supportedLocales); reset(personalInfo); }, ); @@ -85,7 +94,11 @@ const PersonalInfo = () => {
- + t(key)} + /> - )} - - - ); -}; diff --git a/js/libs/ui-shared/src/main.ts b/js/libs/ui-shared/src/main.ts index 4018a6a4801f..62fa21df5625 100644 --- a/js/libs/ui-shared/src/main.ts +++ b/js/libs/ui-shared/src/main.ts @@ -14,3 +14,4 @@ export { useStoredState } from "./utils/useStoredState"; export { isDefined } from "./utils/isDefined"; export { createNamedContext } from "./utils/createNamedContext"; export { useRequiredContext } from "./utils/useRequiredContext"; +export { UserProfileFields } from "./user-profile/UserProfileFields"; diff --git a/js/libs/ui-shared/src/user-profile/LocaleSelector.tsx b/js/libs/ui-shared/src/user-profile/LocaleSelector.tsx new file mode 100644 index 000000000000..221ed8cc0417 --- /dev/null +++ b/js/libs/ui-shared/src/user-profile/LocaleSelector.tsx @@ -0,0 +1,34 @@ +import { SelectControl } from "../controls/SelectControl"; +import { UserProfileFieldsProps } from "./UserProfileGroup"; + +const localeToDisplayName = (locale: string) => { + try { + return new Intl.DisplayNames([locale], { type: "language" }).of(locale); + } catch (error) { + return locale; + } +}; + +type LocaleSelectorProps = UserProfileFieldsProps & { + supportedLocales: string[]; +}; + +export const LocaleSelector = ({ + t, + supportedLocales, +}: LocaleSelectorProps) => { + const locales = supportedLocales.map((locale) => ({ + key: locale, + value: localeToDisplayName(locale) || "", + })); + + return ( + + ); +}; diff --git a/js/apps/account-ui/src/personal-info/components/OptionsComponent.tsx b/js/libs/ui-shared/src/user-profile/OptionsComponent.tsx similarity index 79% rename from js/apps/account-ui/src/personal-info/components/OptionsComponent.tsx rename to js/libs/ui-shared/src/user-profile/OptionsComponent.tsx index 4bb84e8c214b..a13373360fa0 100644 --- a/js/apps/account-ui/src/personal-info/components/OptionsComponent.tsx +++ b/js/libs/ui-shared/src/user-profile/OptionsComponent.tsx @@ -1,17 +1,16 @@ import { Checkbox, Radio } from "@patternfly/react-core"; import { Controller, useFormContext } from "react-hook-form"; -import { UserProfileAttributeMetadata } from "../../api/representations"; -import { Options } from "../UserProfileFields"; -import { fieldName } from "../utils"; -import { UserProfileGroup } from "./UserProfileGroup"; +import { Options } from "./UserProfileFields"; +import { UserProfileFieldsProps, UserProfileGroup } from "./UserProfileGroup"; +import { fieldName } from "./utils"; -export const OptionComponent = (attr: UserProfileAttributeMetadata) => { +export const OptionComponent = (attr: UserProfileFieldsProps) => { const { control } = useFormContext(); const type = attr.annotations?.["inputType"] as string; const isMultiSelect = type.includes("multiselect"); const Component = isMultiSelect ? Checkbox : Radio; - const options = (attr.validators.options as Options).options || []; + const options = (attr.validators?.options as Options).options || []; return ( @@ -42,6 +41,7 @@ export const OptionComponent = (attr: UserProfileAttributeMetadata) => { field.onChange([option]); } }} + readOnly={attr.readOnly} /> ))} diff --git a/js/libs/ui-shared/src/user-profile/SelectComponent.tsx b/js/libs/ui-shared/src/user-profile/SelectComponent.tsx new file mode 100644 index 000000000000..3b7ca8207bdc --- /dev/null +++ b/js/libs/ui-shared/src/user-profile/SelectComponent.tsx @@ -0,0 +1,92 @@ +import { Select, SelectOption } from "@patternfly/react-core"; +import { useState } from "react"; +import { + Controller, + useFormContext, + ControllerRenderProps, + FieldValues, +} from "react-hook-form"; + +import { Options } from "./UserProfileFields"; +import { fieldName, unWrap } from "./utils"; +import { UserProfileFieldsProps, UserProfileGroup } from "./UserProfileGroup"; + +type OptionLabel = Record | undefined; +export const SelectComponent = (attr: UserProfileFieldsProps) => { + const { t, ...attribute } = attr; + const { control } = useFormContext(); + const [open, setOpen] = useState(false); + + const isMultiValue = (field: ControllerRenderProps) => { + return ( + attribute.annotations?.["inputType"] === "multiselect" || + Array.isArray(field.value) + ); + }; + + const setValue = ( + value: string, + field: ControllerRenderProps, + ) => { + if (isMultiValue(field)) { + if (field.value.includes(value)) { + field.onChange(field.value.filter((item: string) => item !== value)); + } else { + field.onChange([...field.value, value]); + } + } else { + field.onChange(value); + } + }; + + const options = + (attribute.validators?.options as Options | undefined)?.options || []; + + const optionLabel = attribute.annotations?.[ + "inputOptionLabels" + ] as OptionLabel; + const label = (label: string) => + optionLabel ? t(unWrap(optionLabel[label])) : label; + + return ( + + ( + + )} + /> + + ); +}; diff --git a/js/apps/account-ui/src/personal-info/components/TextAreaComponent.tsx b/js/libs/ui-shared/src/user-profile/TextAreaComponent.tsx similarity index 58% rename from js/apps/account-ui/src/personal-info/components/TextAreaComponent.tsx rename to js/libs/ui-shared/src/user-profile/TextAreaComponent.tsx index d13d1ed284ec..2fa7c72f0a7e 100644 --- a/js/apps/account-ui/src/personal-info/components/TextAreaComponent.tsx +++ b/js/libs/ui-shared/src/user-profile/TextAreaComponent.tsx @@ -1,10 +1,9 @@ import { useFormContext } from "react-hook-form"; -import { UserProfileAttributeMetadata } from "../../api/representations"; -import { fieldName } from "../utils"; -import { UserProfileGroup } from "./UserProfileGroup"; -import { KeycloakTextArea } from "ui-shared"; +import { KeycloakTextArea } from "../controls/keycloak-text-area/KeycloakTextArea"; +import { UserProfileFieldsProps, UserProfileGroup } from "./UserProfileGroup"; +import { fieldName } from "./utils"; -export const TextAreaComponent = (attr: UserProfileAttributeMetadata) => { +export const TextAreaComponent = (attr: UserProfileFieldsProps) => { const { register } = useFormContext(); return ( @@ -15,6 +14,7 @@ export const TextAreaComponent = (attr: UserProfileAttributeMetadata) => { {...register(fieldName(attr))} cols={attr.annotations?.["inputTypeCols"] as number} rows={attr.annotations?.["inputTypeRows"] as number} + readOnly={attr.readOnly} /> ); diff --git a/js/apps/account-ui/src/personal-info/components/TextComponent.tsx b/js/libs/ui-shared/src/user-profile/TextComponent.tsx similarity index 66% rename from js/apps/account-ui/src/personal-info/components/TextComponent.tsx rename to js/libs/ui-shared/src/user-profile/TextComponent.tsx index 81f14604dc38..da52bedf4152 100644 --- a/js/apps/account-ui/src/personal-info/components/TextComponent.tsx +++ b/js/libs/ui-shared/src/user-profile/TextComponent.tsx @@ -1,10 +1,9 @@ import { useFormContext } from "react-hook-form"; -import { KeycloakTextInput } from "ui-shared"; -import { fieldName } from "../utils"; -import { UserProfileGroup } from "./UserProfileGroup"; -import { UserProfileAttributeMetadata } from "../../api/representations"; +import { KeycloakTextInput } from "../keycloak-text-input/KeycloakTextInput"; +import { UserProfileFieldsProps, UserProfileGroup } from "./UserProfileGroup"; +import { fieldName } from "./utils"; -export const TextComponent = (attr: UserProfileAttributeMetadata) => { +export const TextComponent = (attr: UserProfileFieldsProps) => { const { register } = useFormContext(); const inputType = attr.annotations?.["inputType"] as string | undefined; const type: any = inputType?.startsWith("html") @@ -18,6 +17,7 @@ export const TextComponent = (attr: UserProfileAttributeMetadata) => { data-testid={attr.name} type={type} placeholder={attr.annotations?.["inputTypePlaceholder"] as string} + readOnly={attr.readOnly} {...register(fieldName(attr))} /> diff --git a/js/apps/account-ui/src/personal-info/UserProfileFields.tsx b/js/libs/ui-shared/src/user-profile/UserProfileFields.tsx similarity index 63% rename from js/apps/account-ui/src/personal-info/UserProfileFields.tsx rename to js/libs/ui-shared/src/user-profile/UserProfileFields.tsx index de028d1d21e9..410dafb69a6b 100644 --- a/js/apps/account-ui/src/personal-info/UserProfileFields.tsx +++ b/js/libs/ui-shared/src/user-profile/UserProfileFields.tsx @@ -1,17 +1,19 @@ import { useFormContext } from "react-hook-form"; +import { LocaleSelector } from "./LocaleSelector"; +import { OptionComponent } from "./OptionsComponent"; +import { SelectComponent } from "./SelectComponent"; +import { TextAreaComponent } from "./TextAreaComponent"; +import { TextComponent } from "./TextComponent"; import { - UserProfileAttributeMetadata, UserProfileMetadata, -} from "../api/representations"; -import { LocaleSelector } from "./LocaleSelector"; -import { OptionComponent } from "./components/OptionsComponent"; -import { SelectComponent } from "./components/SelectComponent"; -import { TextAreaComponent } from "./components/TextAreaComponent"; -import { TextComponent } from "./components/TextComponent"; -import { fieldName } from "./utils"; + UserProfileAttributeMetadata, +} from "./userProfileConfig"; +import { TranslationFunction, fieldName } from "./utils"; type UserProfileFieldsProps = { + t: TranslationFunction; metaData: UserProfileMetadata; + supportedLocales: string[]; }; export type Options = { @@ -61,23 +63,36 @@ export const FIELDS: { export const isValidComponentType = (value: string): value is Field => value in FIELDS; -export const UserProfileFields = ({ metaData }: UserProfileFieldsProps) => +export const UserProfileFields = ({ + metaData, + ...rest +}: UserProfileFieldsProps) => metaData.attributes.map((attribute) => ( - + )); type FormFieldProps = { + t: TranslationFunction; + supportedLocales: string[]; attribute: UserProfileAttributeMetadata; }; -const FormField = ({ attribute }: FormFieldProps) => { +const FormField = (attr: FormFieldProps) => { + const { attribute, supportedLocales, t } = attr; const { watch } = useFormContext(); - const value = watch(fieldName(attribute)); + const value = watch(fieldName(attr)); const componentType = (attribute.annotations?.["inputType"] || (Array.isArray(value) ? "multiselect" : "text")) as Field; const Component = FIELDS[componentType]; - if (attribute.name === "locale") return ; - return ; + if (attribute.name === "locale") + return ; + return ( + + ); }; diff --git a/js/libs/ui-shared/src/user-profile/UserProfileGroup.tsx b/js/libs/ui-shared/src/user-profile/UserProfileGroup.tsx new file mode 100644 index 000000000000..8eff7bdb4f5e --- /dev/null +++ b/js/libs/ui-shared/src/user-profile/UserProfileGroup.tsx @@ -0,0 +1,51 @@ +import { FormGroup } from "@patternfly/react-core"; +import { PropsWithChildren } from "react"; +import { useFormContext } from "react-hook-form"; +import { HelpItem } from "../controls/HelpItem"; +import { UserProfileAttribute } from "./userProfileConfig"; +import { TranslationFunction, label } from "./utils"; + +export type UserProfileFieldsProps = UserProfileAttribute & { + t: TranslationFunction; +}; + +type LengthValidator = + | { + min: number; + } + | undefined; + +const isRequired = (attribute: UserProfileAttribute) => + Object.keys(attribute.required || {}).length !== 0 || + (((attribute.validators?.length as LengthValidator)?.min as number) || 0) > 0; + +export const UserProfileGroup = ({ + children, + ...attribute +}: PropsWithChildren) => { + const { t } = attribute; + const helpText = attribute.annotations?.["inputHelperTextBefore"] as string; + + const { + formState: { errors }, + } = useFormContext(); + + console.log("label", label(attribute)); + return ( + + ) : undefined + } + > + {children} + + ); +}; diff --git a/js/libs/ui-shared/src/user-profile/userProfileConfig.d.ts b/js/libs/ui-shared/src/user-profile/userProfileConfig.d.ts new file mode 100644 index 000000000000..f2db7502e871 --- /dev/null +++ b/js/libs/ui-shared/src/user-profile/userProfileConfig.d.ts @@ -0,0 +1,57 @@ +export default interface UserProfileConfig { + attributes?: UserProfileAttribute[]; + groups?: UserProfileGroup[]; +} +export interface UserProfileAttribute { + name?: string; + validations?: Record; + validators?: Record; + annotations?: Record; + required?: UserProfileAttributeRequired; + readOnly?: boolean; + permissions?: UserProfileAttributePermissions; + selector?: UserProfileAttributeSelector; + displayName?: string; + group?: string; +} +export interface UserProfileAttributeRequired { + roles?: string[]; + scopes?: string[]; +} +export interface UserProfileAttributePermissions { + view?: string[]; + edit?: string[]; +} +export interface UserProfileAttributeSelector { + scopes?: string[]; +} +export interface UserProfileGroup { + name?: string; + displayHeader?: string; + displayDescription?: string; + annotations?: Record; +} + +export interface UserProfileAttributeMetadata { + name: string; + displayName: string; + required: boolean; + readOnly: boolean; + annotations?: { [index: string]: any }; + validators: { [index: string]: { [index: string]: any } }; +} + +export interface UserProfileMetadata { + attributes: UserProfileAttributeMetadata[]; +} + +export interface UserRepresentation { + id: string; + username: string; + firstName: string; + lastName: string; + email: string; + emailVerified: boolean; + userProfileMetadata: UserProfileMetadata; + attributes: { [index: string]: string[] }; +} diff --git a/js/libs/ui-shared/src/user-profile/utils.ts b/js/libs/ui-shared/src/user-profile/utils.ts new file mode 100644 index 000000000000..924a78c884e5 --- /dev/null +++ b/js/libs/ui-shared/src/user-profile/utils.ts @@ -0,0 +1,20 @@ +import { UserProfileFieldsProps } from "./UserProfileGroup"; + +export const isBundleKey = (displayName?: string) => + displayName?.includes("${"); +export const unWrap = (key: string) => key.substring(2, key.length - 1); + +export const label = ({ t, ...attribute }: UserProfileFieldsProps) => + (isBundleKey(attribute.displayName) + ? t(unWrap(attribute.displayName!) as string) + : attribute.displayName) || attribute.name; + +const ROOT_ATTRIBUTES = ["username", "firstName", "lastName", "email"]; + +const isRootAttribute = (attr?: string) => + attr && ROOT_ATTRIBUTES.includes(attr); + +export const fieldName = (attribute: UserProfileFieldsProps) => + `${isRootAttribute(attribute.name) ? "" : "attributes."}${attribute.name}`; + +export type TranslationFunction = (key: unknown) => string;