From 4909928d3a7b4b06b8bbde18d0aa23a8f70f9195 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 27 Apr 2024 19:09:22 +0200 Subject: [PATCH] Progress on form reactivity --- src/login/i18n/i18n.tsx | 8 +- src/login/lib/useUserProfileForm.tsx | 60 ++-- .../pages/shared/UserProfileFormFields.tsx | 321 ++++++++++++++++-- 3 files changed, 326 insertions(+), 63 deletions(-) diff --git a/src/login/i18n/i18n.tsx b/src/login/i18n/i18n.tsx index c65dba20..bc6efaf5 100644 --- a/src/login/i18n/i18n.tsx +++ b/src/login/i18n/i18n.tsx @@ -212,7 +212,9 @@ const keycloakifyExtraMessages = { "shouldMatchPattern": "Pattern should match: `/{0}/`", "mustBeAnInteger": "Must be an integer", "notAValidOption": "Not a valid option", - "selectAnOption": "Select an option" + "selectAnOption": "Select an option", + "remove": "Remove", + "add value": "Add value" }, "fr": { /* spell-checker: disable */ @@ -225,7 +227,9 @@ const keycloakifyExtraMessages = { "logoutConfirmTitle": "Déconnexion", "logoutConfirmHeader": "Êtes-vous sûr(e) de vouloir vous déconnecter ?", "doLogout": "Se déconnecter", - "selectAnOption": "Sélectionner une option" + "selectAnOption": "Sélectionner une option", + "remove": "Supprimer", + "add value": "Ajouter une valeur" /* spell-checker: enable */ } }; diff --git a/src/login/lib/useUserProfileForm.tsx b/src/login/lib/useUserProfileForm.tsx index f997f84e..cfa55f5f 100644 --- a/src/login/lib/useUserProfileForm.tsx +++ b/src/login/lib/useUserProfileForm.tsx @@ -20,7 +20,7 @@ export type FormFieldState = { /** The index is always 0 for non multi-valued fields */ index: number; value: string; - displayableError: FormFieldError[]; + displayableErrors: FormFieldError[]; attribute: Attribute; }; @@ -44,6 +44,11 @@ export type FormAction = | { action: "add value to multi-valued attribute"; name: string; + } + | { + action: "remove value from multi-valued attribute"; + name: string; + index: number; }; export type KcContextLike = { @@ -59,7 +64,7 @@ export type KcContextLike = { export type ParamsOfUseUserProfileForm = { kcContext: KcContextLike; i18n: I18n; - passwordConfirmationDisabled?: boolean; + doMakeUserConfirmPassword: boolean; }; export type ReturnTypeOfUseUserProfileForm = { @@ -72,7 +77,7 @@ export type ReturnTypeOfUseUserProfileForm = { * artificial password related attributes only if kcContext.passwordRequired === true */ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTypeOfUseUserProfileForm { - const { kcContext, i18n, passwordConfirmationDisabled = false } = params; + const { kcContext, i18n, doMakeUserConfirmPassword } = params; const attributesWithPassword = useMemo(() => { const attributesWithPassword: Attribute[] = []; @@ -125,26 +130,28 @@ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTy i18n }); - type FormFieldState_internal = Omit & { + type FormFieldState_internal = Omit & { errors: FormFieldError[]; hasLostFocusAtLeastOnce: boolean; }; - type State = FormFieldState_internal[]; + type State = { + formFieldStates: FormFieldState_internal[]; + }; const [state, dispatchFormAction] = useReducer( function reducer(state: State, params: FormAction): State { if (params.action === "add value to multi-valued attribute") { - const formFieldStates = state.filter(({ name }) => name === params.name); + const formFieldStates = state.formFieldStates.filter(({ name }) => name === params.name); - state.splice(state.indexOf(formFieldStates[formFieldStates.length - 1]) + 1, 0, { + state.formFieldStates.splice(state.formFieldStates.indexOf(formFieldStates[formFieldStates.length - 1]) + 1, 0, { "index": formFieldStates.length, "name": params.name, "value": "", "errors": getErrors({ "name": params.name, "index": formFieldStates.length, - "fieldValues": state + "fieldValues": state.formFieldStates }), "hasLostFocusAtLeastOnce": false, "attribute": formFieldStates[0].attribute @@ -153,7 +160,7 @@ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTy return state; } - const formFieldState = state.find(({ name, index }) => name === params.name && index === params.index); + const formFieldState = state.formFieldStates.find(({ name, index }) => name === params.name && index === params.index); assert(formFieldState !== undefined); @@ -167,7 +174,7 @@ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTy break update_password_confirm; } - if (!passwordConfirmationDisabled) { + if (doMakeUserConfirmPassword) { break update_password_confirm; } @@ -183,9 +190,12 @@ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTy formFieldState.errors = getErrors({ "name": params.name, "index": params.index, - "fieldValues": state + "fieldValues": state.formFieldStates }); return state; + case "remove value from multi-valued attribute": + state.formFieldStates.splice(state.formFieldStates.indexOf(formFieldState), 1); + return state; } assert>(false); @@ -245,18 +255,20 @@ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTy return initialFormFieldValues; })(); - const initialState: State = initialFormFieldValues.map(({ name, index, value, attribute }) => ({ - name, - index, - value, - "errors": getErrors({ + const initialState: State = { + "formFieldStates": initialFormFieldValues.map(({ name, index, value, attribute }) => ({ name, index, - "fieldValues": initialFormFieldValues - }), - "hasLostFocusAtLeastOnce": false, - attribute - })); + value, + "errors": getErrors({ + name, + index, + "fieldValues": initialFormFieldValues + }), + "hasLostFocusAtLeastOnce": false, + attribute + })) + }; return initialState; }, []) @@ -264,14 +276,14 @@ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTy const formState: FormState = useMemo( () => ({ - "formFieldStates": state.map(({ name, index, value, errors, hasLostFocusAtLeastOnce, attribute }) => ({ + "formFieldStates": state.formFieldStates.map(({ name, index, value, errors, hasLostFocusAtLeastOnce, attribute }) => ({ name, index, value, - "displayableError": hasLostFocusAtLeastOnce ? errors : [], + "displayableErrors": hasLostFocusAtLeastOnce ? errors : [], attribute })), - "isFormSubmittable": state.every(({ errors }) => errors.length === 0) + "isFormSubmittable": state.formFieldStates.every(({ errors }) => errors.length === 0) }), [state] ); diff --git a/src/login/pages/shared/UserProfileFormFields.tsx b/src/login/pages/shared/UserProfileFormFields.tsx index 659b6f88..459bbb58 100644 --- a/src/login/pages/shared/UserProfileFormFields.tsx +++ b/src/login/pages/shared/UserProfileFormFields.tsx @@ -1,7 +1,13 @@ import { useEffect, Fragment } from "react"; import type { ClassKey } from "keycloakify/login/TemplateProps"; import { clsx } from "keycloakify/tools/clsx"; -import { useProfileAttributeForm, type KcContextLike } from "keycloakify/login/lib/useProfileAttributeForm"; +import { + useUserProfileForm, + type KcContextLike, + type FormAction, + type FormFieldError, + FormFieldState +} from "keycloakify/login/lib/useUserProfileForm"; import type { Attribute, LegacyAttribute } from "keycloakify/login/kcContext/KcContext"; import type { I18n } from "../../i18n"; import { assert } from "tsafe/assert"; @@ -11,24 +17,34 @@ export type UserProfileFormFieldsProps = { i18n: I18n; getClassName: (classKey: ClassKey) => string; onIsFormSubmittableValueChange: (isFormSubmittable: boolean) => void; - BeforeField?: (props: { attribute: Attribute }) => JSX.Element | null; - AfterField?: (props: { attribute: Attribute }) => JSX.Element | null; + BeforeField?: (props: BeforeAfterFieldProps) => JSX.Element | null; + AfterField?: (props: BeforeAfterFieldProps) => JSX.Element | null; }; +type BeforeAfterFieldProps = { + attribute: Attribute; + index: number; + value: string; + dispatchFormAction: React.Dispatch; + formFieldErrors: FormFieldError[]; + i18n: I18n; +}; + +// NOTE: Enabled by default but it's a UX best practice to set it to false. +const doMakeUserConfirmPassword = true; + export function UserProfileFormFields(props: UserProfileFormFieldsProps) { const { kcContext, onIsFormSubmittableValueChange, i18n, getClassName, BeforeField, AfterField } = props; const { advancedMsg, msg } = i18n; const { - formValidationState: { fieldStateByAttributeName, isFormSubmittable }, - formValidationDispatch, - attributesWithPassword - } = useProfileAttributeForm({ + formState: { formFieldStates, isFormSubmittable }, + dispatchFormAction + } = useUserProfileForm({ kcContext, - i18n - // NOTE: Uncomment the following line if you don't want for force the user to enter the password twice. - //"requirePasswordConfirmation": false + i18n, + doMakeUserConfirmPassword }); useEffect(() => { @@ -39,16 +55,14 @@ export function UserProfileFormFields(props: UserProfileFormFieldsProps) { return ( <> - {attributesWithPassword.map((attribute, i) => { - const { displayableErrors, value } = fieldStateByAttributeName[attribute.name]; - + {formFieldStates.map(({ index, value, attribute, displayableErrors }) => { const formGroupClassName = clsx( getClassName("kcFormGroupClass"), displayableErrors.length !== 0 && getClassName("kcFormGroupErrorClass") ); return ( - + {(() => { keycloak_prior_to_24: { if (attribute.html5DataAnnotations !== undefined) { @@ -132,9 +146,23 @@ export function UserProfileFormFields(props: UserProfileFormFieldsProps) { return null; })()} - {BeforeField && } + {BeforeField && ( + + )} -
+
+ {attribute.annotations.inputHelperTextBefore !== undefined && index === 0 && ( +
+ {advancedMsg(attribute.annotations.inputHelperTextBefore)} +
+ )} + + {attribute.multivalued && ( + + )} + {displayableErrors.length !== 0 && ( + + {displayableErrors.map(({ errorMessage }, i, arr) => ( + <> + {errorMessage} + {arr.length - 1 !== i &&
} + + ))} +
+ )} + {attribute.annotations.inputHelperTextAfter !== undefined && index === 0 && ( +
+ {advancedMsg(attribute.annotations.inputHelperTextAfter)} +
+ )} + + {AfterField && ( + + )} + {/* + TODO: + + <#list profile.html5DataAnnotations?keys as key> + + + + */} + {(() => { + /* const { options } = attribute.validators; if (options !== undefined) { @@ -212,33 +313,179 @@ export function UserProfileFormFields(props: UserProfileFormFieldsProps) { autoComplete={attribute.autocomplete} /> ); + */ })()} - {displayableErrors.length !== 0 && - (() => { - const divId = `input-error-${attribute.name}`; - - return ( - <> - - - {displayableErrors.map(({ errorMessage }) => errorMessage)} - - - ); - })()}
- {AfterField && } ); })} ); } + +function AddRemoveButtonsMultiValuedAttribute(props: { + formFieldStates: FormFieldState[]; + attribute: Attribute; + index: number; + dispatchFormAction: React.Dispatch< + Extract + >; + i18n: I18n; +}) { + const { formFieldStates, attribute, index, dispatchFormAction, i18n } = props; + + const { msg } = i18n; + + const currentCount = formFieldStates.filter(({ attribute: attribute_i }) => attribute_i.name === attribute.name).length; + + const hasRemove = (() => { + if (currentCount === 1) { + return false; + } + + const minCount = (() => { + const { multivalued } = attribute.validators; + + if (multivalued === undefined) { + return undefined; + } + + const minStr = multivalued.min; + + if (minStr === undefined) { + return undefined; + } + + return parseInt(minStr); + })(); + + if (minCount === undefined) { + return true; + } + + if (currentCount === minCount) { + return false; + } + + return true; + })(); + + const hasAdd = (() => { + if (index + 1 !== currentCount) { + return false; + } + + const maxCount = (() => { + const { multivalued } = attribute.validators; + + if (multivalued === undefined) { + return undefined; + } + + const maxStr = multivalued.max; + + if (maxStr === undefined) { + return undefined; + } + + return parseInt(maxStr); + })(); + + if (maxCount === undefined) { + return false; + } + + if (currentCount === maxCount) { + return false; + } + + return true; + })(); + + return ( + <> + {hasRemove && ( + + )} + {hasAdd && ( + + )} + + ); +} + +function InputFiledByType(props: { + attribute: Attribute; + index: number; + value: string; + formValidationDispatch: React.Dispatch; + getClassName: UserProfileFormFieldsProps["getClassName"]; + i18n: I18n; +}) { + const { attribute, formValidationDispatch, getClassName, i18n } = props; + + /* + <#macro inputFieldByType attribute> + <#switch attribute.annotations.inputType!''> + <#case 'textarea'> + <@textareaTag attribute=attribute/> + <#break> + <#case 'select'> + <#case 'multiselect'> + <@selectTag attribute=attribute/> + <#break> + <#case 'select-radiobuttons'> + <#case 'multiselect-checkboxes'> + <@inputTagSelects attribute=attribute/> + <#break> + <#default> + <#if attribute.multivalued && attribute.values?has_content> + <#list attribute.values as value> + <@inputTag attribute=attribute value=value!''/> + + <#else> + <@inputTag attribute=attribute value=attribute.value!''/> + + + + */ + + switch (attribute.annotations.inputType) { + case "textarea": + return ; + case "select": + case "multiselect": + return ; + case "select-radiobuttons": + case "multiselect-checkboxes": + return ; + default: + } +}