Progress on form reactivity

This commit is contained in:
Joseph Garrone 2024-04-27 19:09:22 +02:00
parent 423d031210
commit 4909928d3a
3 changed files with 326 additions and 63 deletions

View File

@ -212,7 +212,9 @@ const keycloakifyExtraMessages = {
"shouldMatchPattern": "Pattern should match: `/{0}/`", "shouldMatchPattern": "Pattern should match: `/{0}/`",
"mustBeAnInteger": "Must be an integer", "mustBeAnInteger": "Must be an integer",
"notAValidOption": "Not a valid option", "notAValidOption": "Not a valid option",
"selectAnOption": "Select an option" "selectAnOption": "Select an option",
"remove": "Remove",
"add value": "Add value"
}, },
"fr": { "fr": {
/* spell-checker: disable */ /* spell-checker: disable */
@ -225,7 +227,9 @@ const keycloakifyExtraMessages = {
"logoutConfirmTitle": "Déconnexion", "logoutConfirmTitle": "Déconnexion",
"logoutConfirmHeader": "Êtes-vous sûr(e) de vouloir vous déconnecter ?", "logoutConfirmHeader": "Êtes-vous sûr(e) de vouloir vous déconnecter ?",
"doLogout": "Se 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 */ /* spell-checker: enable */
} }
}; };

View File

@ -20,7 +20,7 @@ export type FormFieldState = {
/** The index is always 0 for non multi-valued fields */ /** The index is always 0 for non multi-valued fields */
index: number; index: number;
value: string; value: string;
displayableError: FormFieldError[]; displayableErrors: FormFieldError[];
attribute: Attribute; attribute: Attribute;
}; };
@ -44,6 +44,11 @@ export type FormAction =
| { | {
action: "add value to multi-valued attribute"; action: "add value to multi-valued attribute";
name: string; name: string;
}
| {
action: "remove value from multi-valued attribute";
name: string;
index: number;
}; };
export type KcContextLike = { export type KcContextLike = {
@ -59,7 +64,7 @@ export type KcContextLike = {
export type ParamsOfUseUserProfileForm = { export type ParamsOfUseUserProfileForm = {
kcContext: KcContextLike; kcContext: KcContextLike;
i18n: I18n; i18n: I18n;
passwordConfirmationDisabled?: boolean; doMakeUserConfirmPassword: boolean;
}; };
export type ReturnTypeOfUseUserProfileForm = { export type ReturnTypeOfUseUserProfileForm = {
@ -72,7 +77,7 @@ export type ReturnTypeOfUseUserProfileForm = {
* artificial password related attributes only if kcContext.passwordRequired === true * artificial password related attributes only if kcContext.passwordRequired === true
*/ */
export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTypeOfUseUserProfileForm { export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTypeOfUseUserProfileForm {
const { kcContext, i18n, passwordConfirmationDisabled = false } = params; const { kcContext, i18n, doMakeUserConfirmPassword } = params;
const attributesWithPassword = useMemo(() => { const attributesWithPassword = useMemo(() => {
const attributesWithPassword: Attribute[] = []; const attributesWithPassword: Attribute[] = [];
@ -125,26 +130,28 @@ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTy
i18n i18n
}); });
type FormFieldState_internal = Omit<FormFieldState, "displayableError"> & { type FormFieldState_internal = Omit<FormFieldState, "displayableErrors"> & {
errors: FormFieldError[]; errors: FormFieldError[];
hasLostFocusAtLeastOnce: boolean; hasLostFocusAtLeastOnce: boolean;
}; };
type State = FormFieldState_internal[]; type State = {
formFieldStates: FormFieldState_internal[];
};
const [state, dispatchFormAction] = useReducer( const [state, dispatchFormAction] = useReducer(
function reducer(state: State, params: FormAction): State { function reducer(state: State, params: FormAction): State {
if (params.action === "add value to multi-valued attribute") { 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, "index": formFieldStates.length,
"name": params.name, "name": params.name,
"value": "", "value": "",
"errors": getErrors({ "errors": getErrors({
"name": params.name, "name": params.name,
"index": formFieldStates.length, "index": formFieldStates.length,
"fieldValues": state "fieldValues": state.formFieldStates
}), }),
"hasLostFocusAtLeastOnce": false, "hasLostFocusAtLeastOnce": false,
"attribute": formFieldStates[0].attribute "attribute": formFieldStates[0].attribute
@ -153,7 +160,7 @@ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTy
return state; 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); assert(formFieldState !== undefined);
@ -167,7 +174,7 @@ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTy
break update_password_confirm; break update_password_confirm;
} }
if (!passwordConfirmationDisabled) { if (doMakeUserConfirmPassword) {
break update_password_confirm; break update_password_confirm;
} }
@ -183,9 +190,12 @@ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTy
formFieldState.errors = getErrors({ formFieldState.errors = getErrors({
"name": params.name, "name": params.name,
"index": params.index, "index": params.index,
"fieldValues": state "fieldValues": state.formFieldStates
}); });
return state; return state;
case "remove value from multi-valued attribute":
state.formFieldStates.splice(state.formFieldStates.indexOf(formFieldState), 1);
return state;
} }
assert<Equals<typeof params, never>>(false); assert<Equals<typeof params, never>>(false);
@ -245,18 +255,20 @@ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTy
return initialFormFieldValues; return initialFormFieldValues;
})(); })();
const initialState: State = initialFormFieldValues.map(({ name, index, value, attribute }) => ({ const initialState: State = {
name, "formFieldStates": initialFormFieldValues.map(({ name, index, value, attribute }) => ({
index,
value,
"errors": getErrors({
name, name,
index, index,
"fieldValues": initialFormFieldValues value,
}), "errors": getErrors({
"hasLostFocusAtLeastOnce": false, name,
attribute index,
})); "fieldValues": initialFormFieldValues
}),
"hasLostFocusAtLeastOnce": false,
attribute
}))
};
return initialState; return initialState;
}, []) }, [])
@ -264,14 +276,14 @@ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTy
const formState: FormState = useMemo( const formState: FormState = useMemo(
() => ({ () => ({
"formFieldStates": state.map(({ name, index, value, errors, hasLostFocusAtLeastOnce, attribute }) => ({ "formFieldStates": state.formFieldStates.map(({ name, index, value, errors, hasLostFocusAtLeastOnce, attribute }) => ({
name, name,
index, index,
value, value,
"displayableError": hasLostFocusAtLeastOnce ? errors : [], "displayableErrors": hasLostFocusAtLeastOnce ? errors : [],
attribute attribute
})), })),
"isFormSubmittable": state.every(({ errors }) => errors.length === 0) "isFormSubmittable": state.formFieldStates.every(({ errors }) => errors.length === 0)
}), }),
[state] [state]
); );

View File

@ -1,7 +1,13 @@
import { useEffect, Fragment } from "react"; import { useEffect, Fragment } from "react";
import type { ClassKey } from "keycloakify/login/TemplateProps"; import type { ClassKey } from "keycloakify/login/TemplateProps";
import { clsx } from "keycloakify/tools/clsx"; 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 { Attribute, LegacyAttribute } from "keycloakify/login/kcContext/KcContext";
import type { I18n } from "../../i18n"; import type { I18n } from "../../i18n";
import { assert } from "tsafe/assert"; import { assert } from "tsafe/assert";
@ -11,24 +17,34 @@ export type UserProfileFormFieldsProps = {
i18n: I18n; i18n: I18n;
getClassName: (classKey: ClassKey) => string; getClassName: (classKey: ClassKey) => string;
onIsFormSubmittableValueChange: (isFormSubmittable: boolean) => void; onIsFormSubmittableValueChange: (isFormSubmittable: boolean) => void;
BeforeField?: (props: { attribute: Attribute }) => JSX.Element | null; BeforeField?: (props: BeforeAfterFieldProps) => JSX.Element | null;
AfterField?: (props: { attribute: Attribute }) => JSX.Element | null; AfterField?: (props: BeforeAfterFieldProps) => JSX.Element | null;
}; };
type BeforeAfterFieldProps = {
attribute: Attribute;
index: number;
value: string;
dispatchFormAction: React.Dispatch<FormAction>;
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) { export function UserProfileFormFields(props: UserProfileFormFieldsProps) {
const { kcContext, onIsFormSubmittableValueChange, i18n, getClassName, BeforeField, AfterField } = props; const { kcContext, onIsFormSubmittableValueChange, i18n, getClassName, BeforeField, AfterField } = props;
const { advancedMsg, msg } = i18n; const { advancedMsg, msg } = i18n;
const { const {
formValidationState: { fieldStateByAttributeName, isFormSubmittable }, formState: { formFieldStates, isFormSubmittable },
formValidationDispatch, dispatchFormAction
attributesWithPassword } = useUserProfileForm({
} = useProfileAttributeForm({
kcContext, kcContext,
i18n i18n,
// NOTE: Uncomment the following line if you don't want for force the user to enter the password twice. doMakeUserConfirmPassword
//"requirePasswordConfirmation": false
}); });
useEffect(() => { useEffect(() => {
@ -39,16 +55,14 @@ export function UserProfileFormFields(props: UserProfileFormFieldsProps) {
return ( return (
<> <>
{attributesWithPassword.map((attribute, i) => { {formFieldStates.map(({ index, value, attribute, displayableErrors }) => {
const { displayableErrors, value } = fieldStateByAttributeName[attribute.name];
const formGroupClassName = clsx( const formGroupClassName = clsx(
getClassName("kcFormGroupClass"), getClassName("kcFormGroupClass"),
displayableErrors.length !== 0 && getClassName("kcFormGroupErrorClass") displayableErrors.length !== 0 && getClassName("kcFormGroupErrorClass")
); );
return ( return (
<Fragment key={i}> <Fragment key={`${attribute.name}-${index}`}>
{(() => { {(() => {
keycloak_prior_to_24: { keycloak_prior_to_24: {
if (attribute.html5DataAnnotations !== undefined) { if (attribute.html5DataAnnotations !== undefined) {
@ -132,9 +146,23 @@ export function UserProfileFormFields(props: UserProfileFormFieldsProps) {
return null; return null;
})()} })()}
{BeforeField && <BeforeField attribute={attribute} />} {BeforeField && (
<BeforeField
attribute={attribute}
index={index}
value={value}
dispatchFormAction={dispatchFormAction}
formFieldErrors={displayableErrors}
i18n={i18n}
/>
)}
<div className={formGroupClassName}> <div
className={formGroupClassName}
style={{
"display": attribute.name === "password-confirm" && !doMakeUserConfirmPassword ? "none" : undefined
}}
>
<div className={getClassName("kcLabelWrapperClass")}> <div className={getClassName("kcLabelWrapperClass")}>
<label htmlFor={attribute.name} className={getClassName("kcLabelClass")}> <label htmlFor={attribute.name} className={getClassName("kcLabelClass")}>
{advancedMsg(attribute.displayName ?? "")} {advancedMsg(attribute.displayName ?? "")}
@ -142,7 +170,80 @@ export function UserProfileFormFields(props: UserProfileFormFieldsProps) {
{attribute.required && <>*</>} {attribute.required && <>*</>}
</div> </div>
<div className={getClassName("kcInputWrapperClass")}> <div className={getClassName("kcInputWrapperClass")}>
{attribute.annotations.inputHelperTextBefore !== undefined && index === 0 && (
<div
className={getClassName("kcInputHelperTextBeforeClass")}
id={`form-help-text-before-${attribute.name}`}
aria-live="polite"
>
{advancedMsg(attribute.annotations.inputHelperTextBefore)}
</div>
)}
<InputFiledByType
attribute={attribute}
index={index}
value={value}
formValidationDispatch={dispatchFormAction}
getClassName={getClassName}
i18n={i18n}
/>
{attribute.multivalued && (
<AddRemoveButtonsMultiValuedAttribute
formFieldStates={formFieldStates}
attribute={attribute}
index={index}
dispatchFormAction={dispatchFormAction}
i18n={i18n}
/>
)}
{displayableErrors.length !== 0 && (
<span
id={`input-error-${attribute.name}${index === 0 ? "" : `-${index + 1}`}`}
className={getClassName("kcInputErrorMessageClass")}
style={{
"position": displayableErrors.length === 1 ? "absolute" : undefined
}}
aria-live="polite"
>
{displayableErrors.map(({ errorMessage }, i, arr) => (
<>
<span key={i}>{errorMessage}</span>
{arr.length - 1 !== i && <br />}
</>
))}
</span>
)}
{attribute.annotations.inputHelperTextAfter !== undefined && index === 0 && (
<div
className={getClassName("kcInputHelperTextAfterClass")}
id={`form-help-text-before-${attribute.name}`}
aria-live="polite"
>
{advancedMsg(attribute.annotations.inputHelperTextAfter)}
</div>
)}
{AfterField && (
<AfterField
attribute={attribute}
index={index}
value={value}
dispatchFormAction={dispatchFormAction}
formFieldErrors={displayableErrors}
i18n={i18n}
/>
)}
{/*
TODO:
<#list profile.html5DataAnnotations?keys as key>
<script type="module" src="${url.resourcesPath}/js/${key}.js"></script>
</#list>
*/}
{(() => { {(() => {
/*
const { options } = attribute.validators; const { options } = attribute.validators;
if (options !== undefined) { if (options !== undefined) {
@ -212,33 +313,179 @@ export function UserProfileFormFields(props: UserProfileFormFieldsProps) {
autoComplete={attribute.autocomplete} autoComplete={attribute.autocomplete}
/> />
); );
*/
})()} })()}
{displayableErrors.length !== 0 &&
(() => {
const divId = `input-error-${attribute.name}`;
return (
<>
<style>{`#${divId} > span: { display: block; }`}</style>
<span
id={divId}
className={getClassName("kcInputErrorMessageClass")}
style={{
"position": displayableErrors.length === 1 ? "absolute" : undefined
}}
aria-live="polite"
>
{displayableErrors.map(({ errorMessage }) => errorMessage)}
</span>
</>
);
})()}
</div> </div>
</div> </div>
{AfterField && <AfterField attribute={attribute} />}
</Fragment> </Fragment>
); );
})} })}
</> </>
); );
} }
function AddRemoveButtonsMultiValuedAttribute(props: {
formFieldStates: FormFieldState[];
attribute: Attribute;
index: number;
dispatchFormAction: React.Dispatch<
Extract<FormAction, { action: "add value to multi-valued attribute" | "remove value from multi-valued attribute" }>
>;
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 && (
<button
id={`kc-remove-${attribute.name}-${index + 1}`}
type="button"
className="pf-c-button pf-m-inline pf-m-link"
onClick={() =>
dispatchFormAction({
"action": "remove value from multi-valued attribute",
"name": attribute.name,
index
})
}
>
{msg("remove")}
{hasRemove ? <>&nbsp;|&nbsp;</> : null}
</button>
)}
{hasAdd && (
<button
id="kc-add-titles-1"
type="button"
className="pf-c-button pf-m-inline pf-m-link"
onClick={() =>
dispatchFormAction({
"action": "add value to multi-valued attribute",
"name": attribute.name
})
}
>
{msg("add value")}
</button>
)}
</>
);
}
function InputFiledByType(props: {
attribute: Attribute;
index: number;
value: string;
formValidationDispatch: React.Dispatch<FormAction>;
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!''/>
</#list>
<#else>
<@inputTag attribute=attribute value=attribute.value!''/>
</#if>
</#switch>
</#macro>
*/
switch (attribute.annotations.inputType) {
case "textarea":
return <textareaTag {...props} />;
case "select":
case "multiselect":
return <selectTag {...props} />;
case "select-radiobuttons":
case "multiselect-checkboxes":
return <inputTagSelects {...props} />;
default:
}
}