feat(validator): Adding error, warn, info categories support. (#204)

This commit is contained in:
Lalit 2022-11-26 23:04:14 +05:30 committed by GitHub
parent 2f2c28b13f
commit 965a16aaec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 141 additions and 60 deletions

View file

@ -11,12 +11,20 @@ const form = new FormGroup([
{ {
name: "username", name: "username",
label: "Username", label: "Username",
validators: [Validators.required], validators: [
{
validator: Validators.required,
category: "info",
},
],
}, },
{ {
name: "email", name: "email",
label: "Email", label: "Email",
validators: [Validators.email, Validators.required], validators: [
{ validator: Validators.required },
{ validator: Validators.email, category: "warn" },
],
}, },
{ {
name: "password", name: "password",
@ -52,7 +60,7 @@ const form = new FormGroup([
name: "comment", name: "comment",
label: "Feedback", label: "Feedback",
type: "textarea", type: "textarea",
value: "Nice!" value: "Nice!",
}, },
]); ]);

View file

@ -1,3 +1,5 @@
import type { ValidatorRules } from "./validator.types";
/** /**
* `ControlType` determines the type of form control * `ControlType` determines the type of form control
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#input_types * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#input_types
@ -34,7 +36,7 @@ export interface ControlBase {
value?: string | number | string[]; value?: string | number | string[];
label?: string; label?: string;
placeholder?: string; placeholder?: string;
validators?: string[]; // TODO: implement validator type validators?: ValidatorRules;
} }
export interface Checkbox extends ControlBase { export interface Checkbox extends ControlBase {

View file

@ -1,9 +1,16 @@
export type HookType = "onSubmit" | "onControlBlur" | "all"; export type HookType = "onSubmit" | "onControlBlur" | "all";
export type CategoryType = "error" | "warn" | "info";
export type ValidatorRules =
| string[]
| { validator: string; category?: CategoryType }[];
export type ValidationResult = true | ValidationError; export type ValidationResult = true | ValidationError;
export type ValidationError = { export type ValidationError = {
error: string; error: string;
value: string; value: string;
limit?: number; limit?: number;
category?: string;
}; };

View file

@ -20,19 +20,24 @@ export interface Props {
const { control, showValidationHints, showErrors = false, readOnly = false } = Astro.props; const { control, showValidationHints, showErrors = false, readOnly = false } = Astro.props;
const hasErrors: boolean | null = !!control.errors?.length; const hasError: boolean = control.errors?.length ? control.errors[0]?.category === 'error' : false;
const hasWarn: boolean = control.errors?.length ? control.errors[0]?.category === 'warn' : false;
const hasInfo: boolean = control.errors?.length ? control.errors[0]?.category === 'info' : false;
--- ---
<div class="field" data-validator-haserrors={hasErrors ? hasErrors.toString() : null}> <div
class="field"
data-validator-error={hasError.toString()}
data-validator-warn={hasWarn.toString()}
data-validator-info={hasInfo.toString()}
>
<Label control={control} showValidationHints={showValidationHints}> <Label control={control} showValidationHints={showValidationHints}>
{ {
control.type === 'radio' ? ( control.type === 'radio' ? (
<RadioGroup control={control as Radio} readOnly={readOnly} /> <RadioGroup control={control as Radio} readOnly={readOnly} />
) : ) : control.type === 'dropdown' ? (
control.type === 'dropdown' ? (
<DropdownControl control={control as Dropdown} readOnly={readOnly} /> <DropdownControl control={control as Dropdown} readOnly={readOnly} />
) : ) : control.type === 'textarea' ? (
control.type === 'textarea' ? (
<TextAreaControl control={control as TextArea} readOnly={readOnly} /> <TextAreaControl control={control as TextArea} readOnly={readOnly} />
) : ( ) : (
<Input control={control} readOnly={readOnly} /> <Input control={control} readOnly={readOnly} />

View file

@ -60,9 +60,19 @@ const formId = Array.isArray(formGroups) ? uid() : formGroups?.id || null;
</style> </style>
<style is:global> <style is:global>
[data-validator-hints='true'][data-validator-haserrors='true'], [data-validator-hints='true'][data-validator-error='true'],
[data-validator-hints='true'] [data-validator-haserrors='true'] { [data-validator-hints='true'] [data-validator-error='true'] {
color: red; color: red;
border-color: red; border-color: red;
} }
[data-validator-hints='true'][data-validator-warn='true'],
[data-validator-hints='true'] [data-validator-warn='true'] {
color: orange;
border-color: orange;
}
[data-validator-hints='true'][data-validator-info='true'],
[data-validator-hints='true'] [data-validator-info='true'] {
color: blue;
border-color: blue;
}
</style> </style>

View file

@ -11,12 +11,18 @@ const { control, showValidationHints } = Astro.props;
const { validators = [] } = control; const { validators = [] } = control;
const isRequired: boolean = showValidationHints && validators.includes('validator-required'); const isRequired: boolean =
showValidationHints &&
validators.some((validator) => {
if (typeof validator === 'string' && validator == 'validator-required') return true;
if (typeof validator === 'object' && validator.validator == 'validator-required') return true;
return false;
});
--- ---
{ {
control.label && control.type !== "checkbox" && ( control.label && control.type !== 'checkbox' && (
<label for={control.id} data-validation-required={isRequired ? "true" : null}> <label for={control.id} data-validation-required={isRequired ? 'true' : null}>
{control.label} {control.label}
</label> </label>
) )
@ -25,15 +31,15 @@ const isRequired: boolean = showValidationHints && validators.includes('validato
<slot /> <slot />
{ {
control.label && control.type === "checkbox" && ( control.label && control.type === 'checkbox' && (
<label for={control.id} data-validation-required={isRequired ? "true" : null}> <label for={control.id} data-validation-required={isRequired ? 'true' : null}>
{control.label} {control.label}
</label> </label>
) )
} }
<style> <style>
label[data-validation-required="true"]::before { label[data-validation-required='true']::before {
content: "*"; content: '*';
} }
</style> </style>

View file

@ -7,23 +7,30 @@ import type { FormControl } from '../../core/form-control';
export interface Props { export interface Props {
control: FormControl; control: FormControl;
readOnly?: boolean; readOnly?: boolean;
} }
const { control, readOnly } = Astro.props; const { control, readOnly } = Astro.props;
const { validators = [] } = control; const { validators = [] } = control;
const hasErrors: boolean = !!control.errors?.length; const hasError: boolean = control.errors?.length ? control.errors[0]?.category === 'error' : false;
const hasWarn: boolean = control.errors?.length ? control.errors[0]?.category === 'warn' : false;
const hasInfo: boolean = control.errors?.length ? control.errors[0]?.category === 'info' : false;
const validatorAttributes: Record<string, string> = validators?.reduce((prev, validator) => { // @ts-ignore
const validatorAttributes: Record<string, string> = validators.reduce((prev, val) => {
const validator = typeof val === 'string' ? val : val.validator;
const split: string[] = validator.split(':'); const split: string[] = validator.split(':');
const label: string = `data-${split[0]}` || 'invalid'; const label: string = `data-${split[0]}` || 'invalid';
const value: string | null = split.length > 1 ? split[1] ?? null : 'true'; const value: string | null = split.length > 1 ? split[1] ?? null : 'true';
const category = typeof val === 'string' ? 'error' : val.category || 'error';
const categoryLabel: string = `data-${split[0]}-category`;
return { return {
...prev, ...prev,
[label]: value, [label]: value,
[categoryLabel]: category,
}; };
}, {}); }, {});
--- ---
@ -36,8 +43,10 @@ const validatorAttributes: Record<string, string> = validators?.reduce((prev, va
checked={control.value === 'checked'} checked={control.value === 'checked'}
placeholder={control.placeholder} placeholder={control.placeholder}
data-label={control.label} data-label={control.label}
data-validator-haserrors={hasErrors.toString()} data-validator-error={hasError.toString()}
data-validator-warn={hasWarn.toString()}
data-validator-info={hasInfo.toString()}
readonly={readOnly || null} readonly={readOnly || null}
disabled={(readOnly || null) && control.type === 'checkbox'} disabled={(readOnly || null) && control.type === 'checkbox'}
{...validatorAttributes} {...validatorAttributes}
/> />

View file

@ -13,14 +13,19 @@ const { control, readOnly = false } = Astro.props;
const { validators = [] } = control; const { validators = [] } = control;
const validatorAttributes: Record<string, string> = validators?.reduce((prev, validator) => { // @ts-ignore
const validatorAttributes: Record<string, string> = validators.reduce((prev, val) => {
const validator = typeof val === 'string' ? val : val.validator;
const split: string[] = validator.split(':'); const split: string[] = validator.split(':');
const label: string = `data-${split[0]}` || 'invalid'; const label: string = `data-${split[0]}` || 'invalid';
const value: string | null = split.length > 1 ? split[1] ?? null : 'true'; const value: string | null = split.length > 1 ? split[1] ?? null : 'true';
const category = typeof val === 'string' ? 'error' : val.category || 'error';
const categoryLabel: string = `data-${split[0]}-category`;
return { return {
...prev, ...prev,
[label]: value, [label]: value,
[categoryLabel]: category,
}; };
}, {}); }, {});
--- ---
@ -35,5 +40,5 @@ const validatorAttributes: Record<string, string> = validators?.reduce((prev, va
readonly={readOnly || null} readonly={readOnly || null}
{...validatorAttributes} {...validatorAttributes}
> >
{ control.value } {control.value}
</textarea> </textarea>

View file

@ -9,6 +9,7 @@ import type {
ValidationError, ValidationError,
TextArea, TextArea,
ControlBase, ControlBase,
ValidatorRules,
} from '@astro-reactive/common'; } from '@astro-reactive/common';
import ShortUniqueId from 'short-unique-id'; import ShortUniqueId from 'short-unique-id';
@ -23,15 +24,15 @@ export class FormControl {
private _isValid = true; private _isValid = true;
private _isPristine = true; private _isPristine = true;
private _placeholder: string | null = null; private _placeholder: string | null = null;
private _validators: string[] = []; private _validators: ValidatorRules = [];
private _errors: ValidationError[] = []; private _errors: ValidationError[] = [];
private _options: string[] | ControlOption[] = []; private _options: string[] | ControlOption[] = [];
private _rows: number | null = null; private _rows: number | null = null;
private _cols: number | null = null; private _cols: number | null = null;
private validate: (value: string, validators: string[]) => ValidationError[] = ( private validate: (value: string, validators: ValidatorRules) => ValidationError[] = (
value: string, value: string,
validators: string[] validators: ValidatorRules
) => { ) => {
value; value;
validators; validators;

View file

@ -52,7 +52,7 @@ describe('Field.astro test', () => {
it('Should server-render validation error attributes', async () => { it('Should server-render validation error attributes', async () => {
// arrange // arrange
const expectedResult = 'data-validator-haserrors="true"'; const expectedResult = 'data-validator-error="true"';
const props = { const props = {
control: { control: {
label: 'FAKE LABEL', label: 'FAKE LABEL',
@ -61,6 +61,7 @@ describe('Field.astro test', () => {
errors: [ errors: [
{ {
error: 'required', error: 'required',
category: 'error',
}, },
], ],
value: '', value: '',

View file

@ -24,6 +24,7 @@ const { hook = 'all', displayErrorMessages = false } = Astro.props;
// TODO: handle hooks / when to attach event listeners // TODO: handle hooks / when to attach event listeners
// const form = document.querySelector('form'); // const form = document.querySelector('form');
import type { ValidatorRules } from '@astro-reactive/common';
import { clearErrors, validate } from './core'; import { clearErrors, validate } from './core';
// const hook: HookType = (document.getElementById('hook') as HTMLInputElement).value as HookType; // const hook: HookType = (document.getElementById('hook') as HTMLInputElement).value as HookType;
@ -38,22 +39,27 @@ const { hook = 'all', displayErrorMessages = false } = Astro.props;
.filter((attribute) => attribute.includes('data-validator-')) .filter((attribute) => attribute.includes('data-validator-'))
.map((attribute) => { .map((attribute) => {
const limit = element.getAttribute(attribute); const limit = element.getAttribute(attribute);
return `${attribute}:${limit}`; return {
}); validator: `${attribute}:${limit}`,
category: element.getAttribute(`${attribute}-category`),
};
})
.filter((val) => val.category !== null); // Filter out validators without a category
const value = const value =
element.type === 'checkbox' ? (element.checked ? 'checked' : '') : element.value; element.type === 'checkbox' ? (element.checked ? 'checked' : '') : element.value;
const errors = validate(value, validators); const errors = validate(value, validators as ValidatorRules);
// set element hasErrors // set element hasErrors
if (errors.length) { if (errors.length) {
element.parentElement?.setAttribute('data-validator-haserrors', 'true'); element.parentElement?.setAttribute(`data-validator-${errors[0]?.category}`, 'true');
element.setAttribute('data-validator-haserrors', 'true'); element.setAttribute(`data-validator-${errors[0]?.category}`, 'true');
element.classList.add('has-errors'); element.classList.add(`has-errors`);
// TODO: display error messages // TODO: display error messages
} else { } else {
element.parentElement?.removeAttribute('data-validator-haserrors'); element.parentElement?.removeAttribute(`data-validator-${errors[0]?.category}`);
element.removeAttribute('data-validator-haserrors'); element.removeAttribute(`data-validator-${errors[0]?.category}`);
element.classList.remove('has-errors'); element.classList.remove(`has-${errors[0]?.category}`);
} }
}); });

View file

@ -1,4 +1,4 @@
import type { ValidationError } from '@astro-reactive/common'; import type { ValidationError, ValidatorRules } from '@astro-reactive/common';
import { Validators } from './validator-names'; import { Validators } from './validator-names';
/** /**
@ -7,10 +7,19 @@ import { Validators } from './validator-names';
* @param validators - names of validation logic to be applied * @param validators - names of validation logic to be applied
* @returns errors - array of errors `ValidationError` * @returns errors - array of errors `ValidationError`
*/ */
export function validate(value: string, validators: string[]): ValidationError[] { export function validate(value: string, validators: ValidatorRules): ValidationError[] {
return validators return validators
.map((validator) => validator.replace('data-', '')) .map((validator) => {
.map((attribute): ValidationError | null => { if (typeof validator === 'string') {
return { attribute: validator.replace('data-', ''), category: 'error' };
}
return {
attribute: validator.validator.replace('data-', ''),
category: validator.category || 'error',
};
})
.map(({ attribute, category }): ValidationError | null => {
// TODO: implement dynamic import of function depending on validators // TODO: implement dynamic import of function depending on validators
const split = attribute.split(':'); const split = attribute.split(':');
const validator = split[0]; const validator = split[0];
@ -18,31 +27,31 @@ export function validate(value: string, validators: string[]): ValidationError[]
const limit = parseInt(limitStr || '0', 10); const limit = parseInt(limitStr || '0', 10);
if (validator === Validators.min()) { if (validator === Validators.min()) {
return validateMin(value, limit); return validateMin(value, limit, category);
} }
if (validator === Validators.max()) { if (validator === Validators.max()) {
return validateMax(value, limit); return validateMax(value, limit, category);
} }
if (validator === Validators.required) { if (validator === Validators.required) {
return validateRequired(value); return validateRequired(value, category);
} }
if (validator === Validators.requiredChecked) { if (validator === Validators.requiredChecked) {
return validateRequiredChecked(value); return validateRequiredChecked(value, category);
} }
if (validator === Validators.email) { if (validator === Validators.email) {
return validateEmail(value); return validateEmail(value, category);
} }
if (validator === Validators.minLength()) { if (validator === Validators.minLength()) {
return validateMinLength(value, limit); return validateMinLength(value, limit, category);
} }
if (validator === Validators.maxLength()) { if (validator === Validators.maxLength()) {
return validateMaxLength(value, limit); return validateMaxLength(value, limit, category);
} }
return null; return null;
@ -51,12 +60,17 @@ export function validate(value: string, validators: string[]): ValidationError[]
} }
export function clearErrors(event: Event) { export function clearErrors(event: Event) {
const categories = ['error', 'warn', 'info'];
const element = event.target as HTMLInputElement; const element = event.target as HTMLInputElement;
element.parentElement?.setAttribute('data-validator-haserrors', 'false'); const parent = element.parentElement as HTMLElement;
element.setAttribute('data-validator-haserrors', 'false');
categories.forEach((category) => {
parent.setAttribute(`data-validator-${category}`, 'false');
element.setAttribute(`data-validator-${category}`, 'false');
});
} }
function validateMin(value: string, limit: number): ValidationError | null { function validateMin(value: string, limit: number, category: string): ValidationError | null {
const isValid = parseInt(value, 10) >= limit; const isValid = parseInt(value, 10) >= limit;
if (!isValid) { if (!isValid) {
@ -64,13 +78,14 @@ function validateMin(value: string, limit: number): ValidationError | null {
value, value,
error: 'min', error: 'min',
limit: limit, limit: limit,
category,
}; };
} }
return null; return null;
} }
function validateMax(value: string, limit: number): ValidationError | null { function validateMax(value: string, limit: number, category: string): ValidationError | null {
const isValid = parseInt(value, 10) <= limit; const isValid = parseInt(value, 10) <= limit;
if (!isValid) { if (!isValid) {
@ -78,32 +93,35 @@ function validateMax(value: string, limit: number): ValidationError | null {
value, value,
error: 'max', error: 'max',
limit: limit, limit: limit,
category,
}; };
} }
return null; return null;
} }
function validateRequired(value: string): ValidationError | null { function validateRequired(value: string, category: string): ValidationError | null {
const isValid = !!value; const isValid = !!value;
if (!isValid) { if (!isValid) {
return { return {
value, value,
error: 'required', error: 'required',
category,
}; };
} }
return null; return null;
} }
function validateRequiredChecked(value: string): ValidationError | null { function validateRequiredChecked(value: string, category: string): ValidationError | null {
const isValid = value === 'checked'; const isValid = value === 'checked';
if (!isValid) { if (!isValid) {
return { return {
value, value,
error: 'requiredChecked', error: 'requiredChecked',
category,
}; };
} }
@ -111,7 +129,7 @@ function validateRequiredChecked(value: string): ValidationError | null {
} }
// TODO: review regexp vulnerability // TODO: review regexp vulnerability
function validateEmail(value: string): ValidationError | null { function validateEmail(value: string, category: string): ValidationError | null {
const isValid = String(value) const isValid = String(value)
.toLowerCase() .toLowerCase()
.match( .match(
@ -122,13 +140,14 @@ function validateEmail(value: string): ValidationError | null {
return { return {
value, value,
error: 'email', error: 'email',
category,
}; };
} }
return null; return null;
} }
function validateMinLength(value: string, limit: number): ValidationError | null { function validateMinLength(value: string, limit: number, category: string): ValidationError | null {
const isValid = value.length >= limit; const isValid = value.length >= limit;
if (!isValid) { if (!isValid) {
@ -136,13 +155,14 @@ function validateMinLength(value: string, limit: number): ValidationError | null
value, value,
error: 'minLength', error: 'minLength',
limit: limit, limit: limit,
category,
}; };
} }
return null; return null;
} }
function validateMaxLength(value: string, limit: number): ValidationError | null { function validateMaxLength(value: string, limit: number, category: string): ValidationError | null {
const isValid = value.length <= limit; const isValid = value.length <= limit;
if (!isValid) { if (!isValid) {
@ -150,6 +170,7 @@ function validateMaxLength(value: string, limit: number): ValidationError | null
value, value,
error: 'minLength', error: 'minLength',
limit: limit, limit: limit,
category,
}; };
} }