complete decoupling of user profile form validation logic

This commit is contained in:
Joseph Garrone 2024-10-19 10:18:22 +02:00
parent 5892cf2ba7
commit 36dd324139
6 changed files with 1673 additions and 1537 deletions

View File

@ -1,133 +0,0 @@
import "keycloakify/tools/Array.prototype.every";
import { assert } from "tsafe/assert";
import type {
PasswordPolicies,
Attribute,
Validators
} from "keycloakify/login/KcContext";
import type { KcContext } from "../KcContext";
import type { KcContextLike as KcContextLike_i18n } from "keycloakify/login/i18n";
export type FormFieldError = {
advancedMsgArgs: [string, ...string[]];
source: FormFieldError.Source;
fieldIndex: number | undefined;
};
export namespace FormFieldError {
export type Source =
| Source.Validator
| Source.PasswordPolicy
| Source.Server
| Source.Other;
export namespace Source {
export type Validator = {
type: "validator";
name: keyof Validators;
};
export type PasswordPolicy = {
type: "passwordPolicy";
name: keyof PasswordPolicies;
};
export type Server = {
type: "server";
};
export type Other = {
type: "other";
rule: "passwordConfirmMatchesPassword" | "requiredField";
};
}
}
export type FormFieldState = {
attribute: Attribute;
displayableErrors: FormFieldError[];
valueOrValues: string | string[];
};
export type FormState = {
isFormSubmittable: boolean;
formFieldStates: FormFieldState[];
};
export type FormAction =
| {
action: "update";
name: string;
valueOrValues: string | string[];
/** Default false */
displayErrorsImmediately?: boolean;
}
| {
action: "focus lost";
name: string;
fieldIndex: number | undefined;
};
export type KcContextLike = KcContextLike_i18n &
KcContextLike_useGetErrors & {
profile: {
attributesByName: Record<string, Attribute>;
html5DataAnnotations?: Record<string, string>;
};
passwordRequired?: boolean;
realm: { registrationEmailAsUsername: boolean };
url: {
resourcesPath: string;
};
};
type KcContextLike_useGetErrors = KcContextLike_i18n & {
messagesPerField: Pick<KcContext["messagesPerField"], "existsError" | "get">;
passwordPolicies?: PasswordPolicies;
};
assert<
Extract<
Extract<KcContext, { profile: unknown }>,
{ pageId: "register.ftl" }
> extends KcContextLike
? true
: false
>();
export type UserProfileApi = {
getFormState: () => FormState;
subscribeToFormState: (callback: () => void) => { unsubscribe: () => void };
dispatchFormAction: (action: FormAction) => void;
};
const cachedUserProfileApiByKcContext = new WeakMap<KcContextLike, UserProfileApi>();
export type ParamsOfGetUserProfileApi = {
kcContext: KcContextLike;
doMakeUserConfirmPassword: boolean;
};
export function getUserProfileApi(params: ParamsOfGetUserProfileApi): UserProfileApi {
const { kcContext } = params;
use_cache: {
const userProfileApi_cache = cachedUserProfileApiByKcContext.get(kcContext);
if (userProfileApi_cache === undefined) {
break use_cache;
}
return userProfileApi_cache;
}
const userProfileApi = getUserProfileApi_noCache(params);
cachedUserProfileApiByKcContext.set(kcContext, userProfileApi);
return userProfileApi;
}
export function getUserProfileApi_noCache(
params: ParamsOfGetUserProfileApi
): UserProfileApi {
return null as any;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
export * from "./getUserProfileApi";

View File

@ -0,0 +1,109 @@
import { assert } from "keycloakify/tools/assert";
let cleanup: (() => void) | undefined;
const handledElements = new WeakSet<HTMLElement>();
const KC_NUMBER_UNFORMAT = "kcNumberUnFormat";
const SELECTOR = `input[data-${KC_NUMBER_UNFORMAT}]`;
export function unFormatNumberOnSubmit() {
cleanup?.();
const handleElement = (element: HTMLInputElement) => {
if (handledElements.has(element)) {
return;
}
const form = element.closest("form");
if (form === null) {
return;
}
form.addEventListener("submit", () => {
const rawFormat = element.getAttribute(`data-${KC_NUMBER_UNFORMAT}`);
if (rawFormat) {
element.value = formatNumber(element.value, rawFormat);
}
});
handledElements.add(element);
};
document.querySelectorAll(SELECTOR).forEach(element => {
assert(element instanceof HTMLInputElement);
handleElement(element);
});
const observer = new MutationObserver(mutationsList => {
for (const mutation of mutationsList) {
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = (node as HTMLElement).querySelector(SELECTOR);
if (element !== null) {
assert(element instanceof HTMLInputElement);
handleElement(element);
}
}
});
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
cleanup = () => observer.disconnect();
}
// NOTE: Keycloak code
const formatNumber = (input: string, format: string) => {
if (!input) {
return "";
}
// array holding the patterns for the number of expected digits in each part
const digitPattern = format.match(/{\d+}/g);
if (!digitPattern) {
return "";
}
// calculate the maximum size of the given pattern based on the sum of the expected digits
const maxSize = digitPattern.reduce(
(total, p) => total + parseInt(p.replace("{", "").replace("}", "")),
0
);
// keep only digits
let rawValue = input.replace(/\D+/g, "");
// make sure the value is a number
//@ts-expect-error
if (parseInt(rawValue) != rawValue) {
return "";
}
// make sure the number of digits does not exceed the maximum size
if (rawValue.length > maxSize) {
rawValue = rawValue.substring(0, maxSize);
}
// build the regex based based on the expected digits in each part
const formatter = digitPattern.reduce((result, p) => result + `(\\d${p})`, "^");
// if the current digits match the pattern we have each group of digits in an array
let digits = new RegExp(formatter).exec(rawValue);
// no match, return the raw value without any format
if (!digits) {
return input;
}
let result = format;
// finally format the current digits accordingly to the given format
for (let i = 0; i < digitPattern.length; i++) {
result = result.replace(digitPattern[i], digits[i + 1]);
}
return result;
};

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,9 @@
import * as reactlessApi from "./getUserProfileApi";
import * as reactlessApi from "./getUserProfileApi/index";
import type { PasswordPolicies, Attribute, Validators } from "keycloakify/login/KcContext";
import { useEffect, useState, useMemo, Fragment } from "react";
import { assert, type Equals } from "tsafe/assert";
import type { I18n } from "../i18n";
export { getButtonToDisplayForMultivaluedAttributeField } from "./getUserProfileApi/index";
export type FormFieldError = {
errorMessage: JSX.Element;