From 82ffa801d6dde1da35181eab6f86a73b562aed32 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 21 Apr 2024 08:12:25 +0200 Subject: [PATCH] Refactor useFormValidation --- src/login/lib/useFormValidation.tsx | 858 ++++++++++++++++------------ 1 file changed, 479 insertions(+), 379 deletions(-) diff --git a/src/login/lib/useFormValidation.tsx b/src/login/lib/useFormValidation.tsx index 89b7380e..7aa4bc42 100644 --- a/src/login/lib/useFormValidation.tsx +++ b/src/login/lib/useFormValidation.tsx @@ -1,5 +1,5 @@ import "keycloakify/tools/Array.prototype.every"; -import { useMemo, useReducer, Fragment } from "react"; +import { useMemo, useReducer, Fragment, type Dispatch } from "react"; import { id } from "tsafe/id"; import type { MessageKey } from "keycloakify/login/i18n/i18n"; import type { Attribute, Validators } from "keycloakify/login/kcContext/KcContext"; @@ -7,12 +7,46 @@ import { useConstCallback } from "keycloakify/tools/useConstCallback"; import { emailRegexp } from "keycloakify/tools/emailRegExp"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; +import type { Param0 } from "tsafe"; +import { assert, type Equals } from "tsafe/assert"; -/** - * NOTE: The attributesWithPassword returned is actually augmented with - * artificial password related attributes only if kcContext.passwordRequired === true - */ -export function useFormValidation(params: { +export type FormFieldError = { + errorMessage: JSX.Element; + errorMessageStr: string; + validatorName: keyof Validators | undefined; +}; + +export type FormFieldState = { + name: string; + /** The index is always 0 for non multi-valued fields */ + index: number; + value: string; + displayableError: FormFieldError[]; +}; + +export type FormState = { + isFormSubmittable: boolean; + formFieldStates: FormFieldState[]; +}; + +export type FormAction = + | { + action: "update value"; + name: string; + index: number; + newValue: string; + } + | { + action: "focus lost"; + name: string; + index: number; + } + | { + action: "add value to multi-valued attribute"; + name: string; + }; + +export type ParamsOfUseFromValidation = { kcContext: { messagesPerField: Pick; profile: { @@ -22,159 +56,194 @@ export function useFormValidation(params: { realm: { registrationEmailAsUsername: boolean }; }; passwordValidators?: Validators; - //TODO: Add a param that enable not to use password confirmation + requirePasswordConfirmation?: boolean; i18n: I18n; -}) { - const { kcContext, passwordValidators = {}, i18n } = params; +}; - const attributesWithPassword = useMemo( - () => - !kcContext.passwordRequired - ? kcContext.profile.attributes - : (() => { - const name = kcContext.realm.registrationEmailAsUsername ? "email" : "username"; +export type ReturnTypeOfUseFormValidation = { + formState: FormState; + dispatchFormAction: Dispatch; + attributesWithPassword: Attribute[]; +}; - return kcContext.profile.attributes.reduce( - (prev, curr) => [ - ...prev, - ...(curr.name !== name - ? [curr] - : [ - curr, - id({ - "name": "password", - "displayName": id<`\${${MessageKey}}`>("${password}"), - "required": true, - "readOnly": false, - "validators": passwordValidators, - "annotations": {}, - "autocomplete": "new-password", - "html5DataAnnotations": {}, - // NOTE: Compat with Keycloak version prior to 24 - ...({ "groupAnnotations": {} } as {}) - }), - id({ - "name": "password-confirm", - "displayName": id<`\${${MessageKey}}`>("${passwordConfirm}"), - "required": true, - "readOnly": false, - "validators": { - "_compareToOther": { - "name": "password", - "ignore.empty.value": true, - "shouldBe": "equal", - "error-message": id<`\${${MessageKey}}`>("${invalidPasswordConfirmMessage}") - } - }, - "annotations": {}, - "html5DataAnnotations": {}, - "autocomplete": "new-password", - // NOTE: Compat with Keycloak version prior to 24 - ...({ "groupAnnotations": {} } as {}) - }) - ]) - ], - [] - ); - })(), - [kcContext, JSON.stringify(passwordValidators)] - ); +/** + * NOTE: The attributesWithPassword returned is actually augmented with + * artificial password related attributes only if kcContext.passwordRequired === true + */ +export function useFormValidation(params: ParamsOfUseFromValidation): ReturnTypeOfUseFormValidation { + const { kcContext, passwordValidators = {}, requirePasswordConfirmation = true, i18n } = params; + + const attributesWithPassword = useMemo(() => { + const attributesWithPassword: Attribute[] = []; + + for (const attribute of kcContext.profile.attributes) { + attributesWithPassword.push(attribute); + + add_password_and_password_confirm: { + if (attribute.name !== (kcContext.realm.registrationEmailAsUsername ? "email" : "username")) { + // NOTE: We want to add password and password-confirm after the field that identifies the user. + // It's either email or username. + break add_password_and_password_confirm; + } + + attributesWithPassword.push( + { + "name": "password", + "displayName": id<`\${${MessageKey}}`>("${password}"), + "required": true, + "readOnly": false, + "validators": passwordValidators, + "annotations": {}, + "autocomplete": "new-password", + "html5DataAnnotations": {}, + // NOTE: Compat with Keycloak version prior to 24 + ...({ "groupAnnotations": {} } as {}) + }, + { + "name": "password-confirm", + "displayName": id<`\${${MessageKey}}`>("${passwordConfirm}"), + "required": true, + "readOnly": false, + "validators": { + "_compareToOther": { + "name": "password", + "ignore.empty.value": true, + "shouldBe": "equal", + "error-message": id<`\${${MessageKey}}`>("${invalidPasswordConfirmMessage}") + } + }, + "annotations": {}, + "html5DataAnnotations": {}, + "autocomplete": "new-password", + "hidden": !requirePasswordConfirmation, + // NOTE: Compat with Keycloak version prior to 24 + ...({ "groupAnnotations": {} } as {}) + } + ); + } + } + + return attributesWithPassword; + }, []); const { getErrors } = useGetErrors({ - "kcContext": { - "messagesPerField": kcContext.messagesPerField, - "profile": { - "attributes": attributesWithPassword - } - }, + kcContext, + "attributes": attributesWithPassword, i18n }); - const initialInternalState = useMemo( - () => - Object.fromEntries( - attributesWithPassword - .map(attribute => ({ - attribute, - "errors": getErrors({ - "name": attribute.name, - "fieldValueByAttributeName": Object.fromEntries( - attributesWithPassword.map(({ name, value }) => [name, { "value": value ?? "" }]) - ) - }) - })) - .map(({ attribute, errors }) => [ - attribute.name, - { - "value": attribute.value ?? "", - errors, - "doDisplayPotentialErrorMessages": errors.length !== 0 - } - ]) - ), - [attributesWithPassword] - ); + type FormFieldState_internal = Omit & { + errors: FormFieldError[]; + hasLostFocusAtLeastOnce: boolean; + }; - type InternalState = typeof initialInternalState; + type State = FormFieldState_internal[]; - const [formValidationInternalState, formValidationDispatch] = useReducer( - ( - state: InternalState, - params: - | { - action: "update value"; - name: string; - newValue: string; - } - | { - action: "focus lost"; - name: string; - } - ): InternalState => ({ - ...state, - [params.name]: { - ...state[params.name], - ...(() => { - switch (params.action) { - case "focus lost": - return { "doDisplayPotentialErrorMessages": true }; - case "update value": - return { - "value": params.newValue, - "errors": getErrors({ - "name": params.name, - "fieldValueByAttributeName": { - ...state, - [params.name]: { "value": params.newValue } - } - }) - }; - } - })() + const [state, dispatchFormAction] = useReducer( + (state: State, params: FormAction): State => { + if (params.action === "add value to multi-valued attribute") { + const formFieldStates = state.filter(({ name }) => name === params.name); + + state.splice(state.indexOf(formFieldStates[formFieldStates.length - 1]) + 1, 0, { + "index": formFieldStates.length, + "name": params.name, + "value": "", + "errors": getErrors({ + "name": params.name, + "index": formFieldStates.length, + "fieldValues": state + }), + "hasLostFocusAtLeastOnce": false + }); + + return state; } - }), - initialInternalState + + const formFieldState = state.find(({ name, index }) => name === params.name && index === params.index); + + assert(formFieldState !== undefined); + + switch (params.action) { + case "focus lost": + formFieldState.hasLostFocusAtLeastOnce = true; + return state; + case "update value": + formFieldState.value = params.newValue; + formFieldState.errors = getErrors({ + "name": params.name, + "index": params.index, + "fieldValues": state + }); + return state; + } + + assert>(false); + }, + useMemo(function getInitialState(): State { + const initialFormFieldValues = (() => { + const initialFormFieldValues: Param0["fieldValues"] = []; + + for (const attribute of attributesWithPassword) { + handle_multi_valued_attribute: { + if (!attribute.multivalued) { + break handle_multi_valued_attribute; + } + + const values = attribute.values ?? [attribute.value ?? ""]; + + for (let index = 0; index < values.length; index++) { + initialFormFieldValues.push({ + "name": attribute.name, + index, + "value": values[index] + }); + } + + continue; + } + + initialFormFieldValues.push({ + "name": attribute.name, + "index": 0, + "value": attribute.value ?? "" + }); + } + + return initialFormFieldValues; + })(); + + const initialState: State = initialFormFieldValues.map(({ name, index, value }) => ({ + name, + index, + value, + "errors": getErrors({ + "name": name, + index, + "fieldValues": initialFormFieldValues + }), + "hasLostFocusAtLeastOnce": false + })); + + return initialState; + }, []) ); - const formValidationState = useMemo( + const formState: FormState = useMemo( () => ({ - "fieldStateByAttributeName": Object.fromEntries( - Object.entries(formValidationInternalState).map(([name, { value, errors, doDisplayPotentialErrorMessages }]) => [ - name, - { value, "displayableErrors": doDisplayPotentialErrorMessages ? errors : [] } - ]) - ), - "isFormSubmittable": Object.entries(formValidationInternalState).every( - ([name, { value, errors }]) => - errors.length === 0 && (value !== "" || !attributesWithPassword.find(attribute => attribute.name === name)!.required) - ) + "formFieldStates": state.map(({ name, index, value, errors, hasLostFocusAtLeastOnce }) => ({ + name, + index, + value, + "displayableError": hasLostFocusAtLeastOnce ? errors : [] + })), + "isFormSubmittable": state.every(({ errors }) => errors.length === 0) }), - [formValidationInternalState, attributesWithPassword] + [state] ); return { - formValidationState, - formValidationDispatch, + formState, + dispatchFormAction, attributesWithPassword }; } @@ -183,296 +252,327 @@ export function useFormValidation(params: { function useGetErrors(params: { kcContext: { messagesPerField: Pick; - profile: { - attributes: { name: string; value?: string; validators: Validators }[]; - }; }; + attributes: { + name: string; + validators: Validators; + value?: string; + values?: string[]; + required?: boolean; + }[]; i18n: I18n; }) { - const { kcContext, i18n } = params; + const { kcContext, attributes, i18n } = params; - const { - messagesPerField, - profile: { attributes } - } = kcContext; + const { messagesPerField } = kcContext; const { msg, msgStr, advancedMsg, advancedMsgStr } = i18n; - const getErrors = useConstCallback((params: { name: string; fieldValueByAttributeName: Record }) => { - const { name, fieldValueByAttributeName } = params; + const getErrors = useConstCallback( + (params: { name: string; index: number; fieldValues: { name: string; index: number; value: string }[] }): FormFieldError[] => { + const { name, index, fieldValues } = params; - const { value } = fieldValueByAttributeName[name]; + const value = (() => { + const fieldValue = fieldValues.find(fieldValue => fieldValue.name === name && fieldValue.index === index); - const { value: defaultValue, validators } = attributes.find(attribute => attribute.name === name)!; + assert(fieldValue !== undefined); - block: { - if ((defaultValue ?? "") !== value) { - break block; - } - - let doesErrorExist: boolean; - - try { - doesErrorExist = messagesPerField.existsError(name); - } catch { - break block; - } - - if (!doesErrorExist) { - break block; - } - - const errorMessageStr = messagesPerField.get(name); - - return [ - { - "validatorName": undefined, - errorMessageStr, - "errorMessage": {errorMessageStr} - } - ]; - } - - const errors: { - errorMessage: JSX.Element; - errorMessageStr: string; - validatorName: keyof Validators | undefined; - }[] = []; - - scope: { - const validatorName = "length"; - - const validator = validators[validatorName]; - - if (validator === undefined) { - break scope; - } - - const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator; - - if (ignoreEmptyValue && value === "") { - break scope; - } - - if (max !== undefined && value.length > parseInt(max)) { - const msgArgs = ["error-invalid-length-too-long", max] as const; - - errors.push({ - "errorMessage": {msg(...msgArgs)}, - "errorMessageStr": msgStr(...msgArgs), - validatorName - }); - } - - if (min !== undefined && value.length < parseInt(min)) { - const msgArgs = ["error-invalid-length-too-short", min] as const; - - errors.push({ - "errorMessage": {msg(...msgArgs)}, - "errorMessageStr": msgStr(...msgArgs), - validatorName - }); - } - } - - scope: { - const validatorName = "_compareToOther"; - - const validator = validators[validatorName]; - - if (validator === undefined) { - break scope; - } - - const { "ignore.empty.value": ignoreEmptyValue = false, name: otherName, shouldBe, "error-message": errorMessageKey } = validator; - - if (ignoreEmptyValue && value === "") { - break scope; - } - - const { value: otherValue } = fieldValueByAttributeName[otherName]; - - const isValid = (() => { - switch (shouldBe) { - case "different": - return otherValue !== value; - case "equal": - return otherValue === value; - } + return fieldValue.value; })(); - if (isValid) { - break scope; + const attribute = attributes.find(attribute => attribute.name === name); + + assert(attribute !== undefined); + + server_side_error: { + const defaultValue = (attribute.values !== undefined ? attribute.values[index] : attribute.value) ?? ""; + + if (defaultValue !== value) { + break server_side_error; + } + + let doesErrorExist: boolean; + + try { + doesErrorExist = messagesPerField.existsError(name); + } catch { + break server_side_error; + } + + if (!doesErrorExist) { + break server_side_error; + } + + const errorMessageStr = messagesPerField.get(name); + + return [ + { + "validatorName": undefined, + errorMessageStr, + "errorMessage": {errorMessageStr} + } + ]; } - const msgArg = [ - errorMessageKey ?? - id( - (() => { - switch (shouldBe) { - case "equal": - return "shouldBeEqual"; - case "different": - return "shouldBeDifferent"; - } - })() - ), - otherName, - name, - shouldBe - ] as const; + const errors: FormFieldError[] = []; - errors.push({ - validatorName, - "errorMessage": {advancedMsg(...msgArg)}, - "errorMessageStr": advancedMsgStr(...msgArg) - }); - } + const { validators } = attribute; - scope: { - const validatorName = "pattern"; + required_field: { + if (!attribute.required) { + break required_field; + } - const validator = validators[validatorName]; + if (value !== "") { + break required_field; + } - if (validator === undefined) { - break scope; + const msgArgs = ["error-user-attribute-required"] as const; + + errors.push({ + "validatorName": undefined, + "errorMessage": {msg(...msgArgs)}, + "errorMessageStr": msgStr(...msgArgs) + }); } - const { "ignore.empty.value": ignoreEmptyValue = false, pattern, "error-message": errorMessageKey } = validator; + validator_x: { + const validatorName = "length"; - if (ignoreEmptyValue && value === "") { - break scope; + const validator = validators[validatorName]; + + if (validator === undefined) { + break validator_x; + } + + const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator; + + if (ignoreEmptyValue && value === "") { + break validator_x; + } + + if (max !== undefined && value.length > parseInt(max)) { + const msgArgs = ["error-invalid-length-too-long", max] as const; + + errors.push({ + "errorMessage": {msg(...msgArgs)}, + "errorMessageStr": msgStr(...msgArgs), + validatorName + }); + } + + if (min !== undefined && value.length < parseInt(min)) { + const msgArgs = ["error-invalid-length-too-short", min] as const; + + errors.push({ + "errorMessage": {msg(...msgArgs)}, + "errorMessageStr": msgStr(...msgArgs), + validatorName + }); + } } - if (new RegExp(pattern).test(value)) { - break scope; + validator_x: { + const validatorName = "_compareToOther"; + + const validator = validators[validatorName]; + + if (validator === undefined) { + break validator_x; + } + + const { "ignore.empty.value": ignoreEmptyValue = false, name: otherName, shouldBe, "error-message": errorMessageKey } = validator; + + if (ignoreEmptyValue && value === "") { + break validator_x; + } + + const otherFieldValue = fieldValues.find(fieldValue => fieldValue.name === otherName); + + assert(otherFieldValue !== undefined); + + const isValid = (() => { + switch (shouldBe) { + case "different": + return otherFieldValue.value !== value; + case "equal": + return otherFieldValue.value === value; + } + })(); + + if (isValid) { + break validator_x; + } + + const msgArg = [ + errorMessageKey ?? + id( + (() => { + switch (shouldBe) { + case "equal": + return "shouldBeEqual"; + case "different": + return "shouldBeDifferent"; + } + })() + ), + otherName, + name, + shouldBe + ] as const; + + errors.push({ + validatorName, + "errorMessage": {advancedMsg(...msgArg)}, + "errorMessageStr": advancedMsgStr(...msgArg) + }); } - const msgArgs = [errorMessageKey ?? id("shouldMatchPattern"), pattern] as const; + validator_x: { + const validatorName = "pattern"; - errors.push({ - validatorName, - "errorMessage": {advancedMsg(...msgArgs)}, - "errorMessageStr": advancedMsgStr(...msgArgs) - }); - } + const validator = validators[validatorName]; - scope: { - if ([...errors].reverse()[0]?.validatorName === "pattern") { - break scope; + if (validator === undefined) { + break validator_x; + } + + const { "ignore.empty.value": ignoreEmptyValue = false, pattern, "error-message": errorMessageKey } = validator; + + if (ignoreEmptyValue && value === "") { + break validator_x; + } + + if (new RegExp(pattern).test(value)) { + break validator_x; + } + + const msgArgs = [errorMessageKey ?? id("shouldMatchPattern"), pattern] as const; + + errors.push({ + validatorName, + "errorMessage": {advancedMsg(...msgArgs)}, + "errorMessageStr": advancedMsgStr(...msgArgs) + }); } - const validatorName = "email"; + validator_x: { + if ([...errors].reverse()[0]?.validatorName === "pattern") { + break validator_x; + } - const validator = validators[validatorName]; + const validatorName = "email"; - if (validator === undefined) { - break scope; - } + const validator = validators[validatorName]; - const { "ignore.empty.value": ignoreEmptyValue = false } = validator; + if (validator === undefined) { + break validator_x; + } - if (ignoreEmptyValue && value === "") { - break scope; - } + const { "ignore.empty.value": ignoreEmptyValue = false } = validator; - if (emailRegexp.test(value)) { - break scope; - } + if (ignoreEmptyValue && value === "") { + break validator_x; + } - const msgArgs = [id("invalidEmailMessage")] as const; + if (emailRegexp.test(value)) { + break validator_x; + } - errors.push({ - validatorName, - "errorMessage": {msg(...msgArgs)}, - "errorMessageStr": msgStr(...msgArgs) - }); - } - - scope: { - const validatorName = "integer"; - - const validator = validators[validatorName]; - - if (validator === undefined) { - break scope; - } - - const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator; - - if (ignoreEmptyValue && value === "") { - break scope; - } - - const intValue = parseInt(value); - - if (isNaN(intValue)) { - const msgArgs = ["mustBeAnInteger"] as const; + const msgArgs = [id("invalidEmailMessage")] as const; errors.push({ validatorName, "errorMessage": {msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs) }); - - break scope; } - if (max !== undefined && intValue > parseInt(max)) { - const msgArgs = ["error-number-out-of-range-too-big", max] as const; + validator_x: { + const validatorName = "integer"; + + const validator = validators[validatorName]; + + if (validator === undefined) { + break validator_x; + } + + const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator; + + if (ignoreEmptyValue && value === "") { + break validator_x; + } + + const intValue = parseInt(value); + + if (isNaN(intValue)) { + const msgArgs = ["mustBeAnInteger"] as const; + + errors.push({ + validatorName, + "errorMessage": {msg(...msgArgs)}, + "errorMessageStr": msgStr(...msgArgs) + }); + + break validator_x; + } + + if (max !== undefined && intValue > parseInt(max)) { + const msgArgs = ["error-number-out-of-range-too-big", max] as const; + + errors.push({ + validatorName, + "errorMessage": {msg(...msgArgs)}, + "errorMessageStr": msgStr(...msgArgs) + }); + + break validator_x; + } + + if (min !== undefined && intValue < parseInt(min)) { + const msgArgs = ["error-number-out-of-range-too-small", min] as const; + + errors.push({ + validatorName, + "errorMessage": {msg(...msgArgs)}, + "errorMessageStr": msgStr(...msgArgs) + }); + + break validator_x; + } + } + + validator_x: { + const validatorName = "options"; + + const validator = validators[validatorName]; + + if (validator === undefined) { + break validator_x; + } + + if (value === "") { + break validator_x; + } + + if (validator.options.indexOf(value) >= 0) { + break validator_x; + } + + const msgArgs = [id("notAValidOption")] as const; errors.push({ validatorName, - "errorMessage": {msg(...msgArgs)}, - "errorMessageStr": msgStr(...msgArgs) + "errorMessage": {advancedMsg(...msgArgs)}, + "errorMessageStr": advancedMsgStr(...msgArgs) }); - - break scope; } - if (min !== undefined && intValue < parseInt(min)) { - const msgArgs = ["error-number-out-of-range-too-small", min] as const; + //TODO: Implement missing validators. - errors.push({ - validatorName, - "errorMessage": {msg(...msgArgs)}, - "errorMessageStr": msgStr(...msgArgs) - }); - - break scope; - } + return errors; } - - scope: { - const validatorName = "options"; - - const validator = validators[validatorName]; - - if (validator === undefined) { - break scope; - } - - if (value === "") { - break scope; - } - - if (validator.options.indexOf(value) >= 0) { - break scope; - } - - const msgArgs = [id("notAValidOption")] as const; - - errors.push({ - validatorName, - "errorMessage": {advancedMsg(...msgArgs)}, - "errorMessageStr": advancedMsgStr(...msgArgs) - }); - } - - //TODO: Implement missing validators. - - return errors; - }); + ); return { getErrors }; }