Refactor useFormValidation

This commit is contained in:
Joseph Garrone 2024-04-21 08:12:25 +02:00
parent b42bf24935
commit 82ffa801d6

View File

@ -1,5 +1,5 @@
import "keycloakify/tools/Array.prototype.every"; 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 { id } from "tsafe/id";
import type { MessageKey } from "keycloakify/login/i18n/i18n"; import type { MessageKey } from "keycloakify/login/i18n/i18n";
import type { Attribute, Validators } from "keycloakify/login/kcContext/KcContext"; 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 { emailRegexp } from "keycloakify/tools/emailRegExp";
import type { KcContext } from "../kcContext"; import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
import type { Param0 } from "tsafe";
import { assert, type Equals } from "tsafe/assert";
/** export type FormFieldError = {
* NOTE: The attributesWithPassword returned is actually augmented with errorMessage: JSX.Element;
* artificial password related attributes only if kcContext.passwordRequired === true errorMessageStr: string;
*/ validatorName: keyof Validators | undefined;
export function useFormValidation(params: { };
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: { kcContext: {
messagesPerField: Pick<KcContext.Common["messagesPerField"], "existsError" | "get">; messagesPerField: Pick<KcContext.Common["messagesPerField"], "existsError" | "get">;
profile: { profile: {
@ -22,159 +56,194 @@ export function useFormValidation(params: {
realm: { registrationEmailAsUsername: boolean }; realm: { registrationEmailAsUsername: boolean };
}; };
passwordValidators?: Validators; passwordValidators?: Validators;
//TODO: Add a param that enable not to use password confirmation requirePasswordConfirmation?: boolean;
i18n: I18n; i18n: I18n;
}) { };
const { kcContext, passwordValidators = {}, i18n } = params;
const attributesWithPassword = useMemo( export type ReturnTypeOfUseFormValidation = {
() => formState: FormState;
!kcContext.passwordRequired dispatchFormAction: Dispatch<FormAction>;
? kcContext.profile.attributes attributesWithPassword: Attribute[];
: (() => { };
const name = kcContext.realm.registrationEmailAsUsername ? "email" : "username";
return kcContext.profile.attributes.reduce<Attribute[]>( /**
(prev, curr) => [ * NOTE: The attributesWithPassword returned is actually augmented with
...prev, * artificial password related attributes only if kcContext.passwordRequired === true
...(curr.name !== name */
? [curr] export function useFormValidation(params: ParamsOfUseFromValidation): ReturnTypeOfUseFormValidation {
: [ const { kcContext, passwordValidators = {}, requirePasswordConfirmation = true, i18n } = params;
curr,
id<Attribute>({ const attributesWithPassword = useMemo(() => {
"name": "password", const attributesWithPassword: Attribute[] = [];
"displayName": id<`\${${MessageKey}}`>("${password}"),
"required": true, for (const attribute of kcContext.profile.attributes) {
"readOnly": false, attributesWithPassword.push(attribute);
"validators": passwordValidators,
"annotations": {}, add_password_and_password_confirm: {
"autocomplete": "new-password", if (attribute.name !== (kcContext.realm.registrationEmailAsUsername ? "email" : "username")) {
"html5DataAnnotations": {}, // NOTE: We want to add password and password-confirm after the field that identifies the user.
// NOTE: Compat with Keycloak version prior to 24 // It's either email or username.
...({ "groupAnnotations": {} } as {}) break add_password_and_password_confirm;
}), }
id<Attribute>({
"name": "password-confirm", attributesWithPassword.push(
"displayName": id<`\${${MessageKey}}`>("${passwordConfirm}"), {
"required": true, "name": "password",
"readOnly": false, "displayName": id<`\${${MessageKey}}`>("${password}"),
"validators": { "required": true,
"_compareToOther": { "readOnly": false,
"name": "password", "validators": passwordValidators,
"ignore.empty.value": true, "annotations": {},
"shouldBe": "equal", "autocomplete": "new-password",
"error-message": id<`\${${MessageKey}}`>("${invalidPasswordConfirmMessage}") "html5DataAnnotations": {},
} // NOTE: Compat with Keycloak version prior to 24
}, ...({ "groupAnnotations": {} } as {})
"annotations": {}, },
"html5DataAnnotations": {}, {
"autocomplete": "new-password", "name": "password-confirm",
// NOTE: Compat with Keycloak version prior to 24 "displayName": id<`\${${MessageKey}}`>("${passwordConfirm}"),
...({ "groupAnnotations": {} } as {}) "required": true,
}) "readOnly": false,
]) "validators": {
], "_compareToOther": {
[] "name": "password",
); "ignore.empty.value": true,
})(), "shouldBe": "equal",
[kcContext, JSON.stringify(passwordValidators)] "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({ const { getErrors } = useGetErrors({
"kcContext": { kcContext,
"messagesPerField": kcContext.messagesPerField, "attributes": attributesWithPassword,
"profile": {
"attributes": attributesWithPassword
}
},
i18n i18n
}); });
const initialInternalState = useMemo( type FormFieldState_internal = Omit<FormFieldState, "displayableError"> & {
() => errors: FormFieldError[];
Object.fromEntries( hasLostFocusAtLeastOnce: boolean;
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 InternalState = typeof initialInternalState; type State = FormFieldState_internal[];
const [formValidationInternalState, formValidationDispatch] = useReducer( const [state, dispatchFormAction] = useReducer(
( (state: State, params: FormAction): State => {
state: InternalState, if (params.action === "add value to multi-valued attribute") {
params: const formFieldStates = state.filter(({ name }) => name === params.name);
| {
action: "update value"; state.splice(state.indexOf(formFieldStates[formFieldStates.length - 1]) + 1, 0, {
name: string; "index": formFieldStates.length,
newValue: string; "name": params.name,
} "value": "",
| { "errors": getErrors({
action: "focus lost"; "name": params.name,
name: string; "index": formFieldStates.length,
} "fieldValues": state
): InternalState => ({ }),
...state, "hasLostFocusAtLeastOnce": false
[params.name]: { });
...state[params.name],
...(() => { return state;
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 }
}
})
};
}
})()
} }
}),
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<Equals<typeof params, never>>(false);
},
useMemo(function getInitialState(): State {
const initialFormFieldValues = (() => {
const initialFormFieldValues: Param0<typeof getErrors>["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( "formFieldStates": state.map(({ name, index, value, errors, hasLostFocusAtLeastOnce }) => ({
Object.entries(formValidationInternalState).map(([name, { value, errors, doDisplayPotentialErrorMessages }]) => [ name,
name, index,
{ value, "displayableErrors": doDisplayPotentialErrorMessages ? errors : [] } value,
]) "displayableError": hasLostFocusAtLeastOnce ? errors : []
), })),
"isFormSubmittable": Object.entries(formValidationInternalState).every( "isFormSubmittable": state.every(({ errors }) => errors.length === 0)
([name, { value, errors }]) =>
errors.length === 0 && (value !== "" || !attributesWithPassword.find(attribute => attribute.name === name)!.required)
)
}), }),
[formValidationInternalState, attributesWithPassword] [state]
); );
return { return {
formValidationState, formState,
formValidationDispatch, dispatchFormAction,
attributesWithPassword attributesWithPassword
}; };
} }
@ -183,296 +252,327 @@ export function useFormValidation(params: {
function useGetErrors(params: { function useGetErrors(params: {
kcContext: { kcContext: {
messagesPerField: Pick<KcContext.Common["messagesPerField"], "existsError" | "get">; messagesPerField: Pick<KcContext.Common["messagesPerField"], "existsError" | "get">;
profile: {
attributes: { name: string; value?: string; validators: Validators }[];
};
}; };
attributes: {
name: string;
validators: Validators;
value?: string;
values?: string[];
required?: boolean;
}[];
i18n: I18n; i18n: I18n;
}) { }) {
const { kcContext, i18n } = params; const { kcContext, attributes, i18n } = params;
const { const { messagesPerField } = kcContext;
messagesPerField,
profile: { attributes }
} = kcContext;
const { msg, msgStr, advancedMsg, advancedMsgStr } = i18n; const { msg, msgStr, advancedMsg, advancedMsgStr } = i18n;
const getErrors = useConstCallback((params: { name: string; fieldValueByAttributeName: Record<string, { value: string }> }) => { const getErrors = useConstCallback(
const { name, fieldValueByAttributeName } = params; (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: { return fieldValue.value;
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": <span key={0}>{errorMessageStr}</span>
}
];
}
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": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"errorMessageStr": msgStr(...msgArgs),
validatorName
});
}
if (min !== undefined && value.length < parseInt(min)) {
const msgArgs = ["error-invalid-length-too-short", min] as const;
errors.push({
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"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;
}
})(); })();
if (isValid) { const attribute = attributes.find(attribute => attribute.name === name);
break scope;
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": <span key={0}>{errorMessageStr}</span>
}
];
} }
const msgArg = [ const errors: FormFieldError[] = [];
errorMessageKey ??
id<MessageKey>(
(() => {
switch (shouldBe) {
case "equal":
return "shouldBeEqual";
case "different":
return "shouldBeDifferent";
}
})()
),
otherName,
name,
shouldBe
] as const;
errors.push({ const { validators } = attribute;
validatorName,
"errorMessage": <Fragment key={errors.length}>{advancedMsg(...msgArg)}</Fragment>,
"errorMessageStr": advancedMsgStr(...msgArg)
});
}
scope: { required_field: {
const validatorName = "pattern"; if (!attribute.required) {
break required_field;
}
const validator = validators[validatorName]; if (value !== "") {
break required_field;
}
if (validator === undefined) { const msgArgs = ["error-user-attribute-required"] as const;
break scope;
errors.push({
"validatorName": undefined,
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"errorMessageStr": msgStr(...msgArgs)
});
} }
const { "ignore.empty.value": ignoreEmptyValue = false, pattern, "error-message": errorMessageKey } = validator; validator_x: {
const validatorName = "length";
if (ignoreEmptyValue && value === "") { const validator = validators[validatorName];
break scope;
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": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"errorMessageStr": msgStr(...msgArgs),
validatorName
});
}
if (min !== undefined && value.length < parseInt(min)) {
const msgArgs = ["error-invalid-length-too-short", min] as const;
errors.push({
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"errorMessageStr": msgStr(...msgArgs),
validatorName
});
}
} }
if (new RegExp(pattern).test(value)) { validator_x: {
break scope; 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<MessageKey>(
(() => {
switch (shouldBe) {
case "equal":
return "shouldBeEqual";
case "different":
return "shouldBeDifferent";
}
})()
),
otherName,
name,
shouldBe
] as const;
errors.push({
validatorName,
"errorMessage": <Fragment key={errors.length}>{advancedMsg(...msgArg)}</Fragment>,
"errorMessageStr": advancedMsgStr(...msgArg)
});
} }
const msgArgs = [errorMessageKey ?? id<MessageKey>("shouldMatchPattern"), pattern] as const; validator_x: {
const validatorName = "pattern";
errors.push({ const validator = validators[validatorName];
validatorName,
"errorMessage": <Fragment key={errors.length}>{advancedMsg(...msgArgs)}</Fragment>,
"errorMessageStr": advancedMsgStr(...msgArgs)
});
}
scope: { if (validator === undefined) {
if ([...errors].reverse()[0]?.validatorName === "pattern") { break validator_x;
break scope; }
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<MessageKey>("shouldMatchPattern"), pattern] as const;
errors.push({
validatorName,
"errorMessage": <Fragment key={errors.length}>{advancedMsg(...msgArgs)}</Fragment>,
"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) { const validator = validators[validatorName];
break scope;
}
const { "ignore.empty.value": ignoreEmptyValue = false } = validator; if (validator === undefined) {
break validator_x;
}
if (ignoreEmptyValue && value === "") { const { "ignore.empty.value": ignoreEmptyValue = false } = validator;
break scope;
}
if (emailRegexp.test(value)) { if (ignoreEmptyValue && value === "") {
break scope; break validator_x;
} }
const msgArgs = [id<MessageKey>("invalidEmailMessage")] as const; if (emailRegexp.test(value)) {
break validator_x;
}
errors.push({ const msgArgs = [id<MessageKey>("invalidEmailMessage")] as const;
validatorName,
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"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;
errors.push({ errors.push({
validatorName, validatorName,
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>, "errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"errorMessageStr": msgStr(...msgArgs) "errorMessageStr": msgStr(...msgArgs)
}); });
break scope;
} }
if (max !== undefined && intValue > parseInt(max)) { validator_x: {
const msgArgs = ["error-number-out-of-range-too-big", max] as const; 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": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"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": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"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": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"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<MessageKey>("notAValidOption")] as const;
errors.push({ errors.push({
validatorName, validatorName,
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>, "errorMessage": <Fragment key={errors.length}>{advancedMsg(...msgArgs)}</Fragment>,
"errorMessageStr": msgStr(...msgArgs) "errorMessageStr": advancedMsgStr(...msgArgs)
}); });
break scope;
} }
if (min !== undefined && intValue < parseInt(min)) { //TODO: Implement missing validators.
const msgArgs = ["error-number-out-of-range-too-small", min] as const;
errors.push({ return errors;
validatorName,
"errorMessage": <Fragment key={errors.length}>{msg(...msgArgs)}</Fragment>,
"errorMessageStr": msgStr(...msgArgs)
});
break scope;
}
} }
);
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<MessageKey>("notAValidOption")] as const;
errors.push({
validatorName,
"errorMessage": <Fragment key={errors.length}>{advancedMsg(...msgArgs)}</Fragment>,
"errorMessageStr": advancedMsgStr(...msgArgs)
});
}
//TODO: Implement missing validators.
return errors;
});
return { getErrors }; return { getErrors };
} }