feat(validator): server-side rendered validation errors (#123)
* feat(validator): server-side rendered validation errors * test(form): field should render error * refactor: remove comment * fix: incorrect imports * chore: update deps * chore: update build commands
This commit is contained in:
parent
82c121a85d
commit
d128747240
26 changed files with 1138 additions and 1129 deletions
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
@ -3,6 +3,6 @@
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll": true
|
"source.fixAll": true
|
||||||
},
|
},
|
||||||
"cSpell.words": ["Astro"],
|
"cSpell.words": ["Astro", "maxlength"],
|
||||||
"prettier.documentSelectors": ["**/*.astro"]
|
"prettier.documentSelectors": ["**/*.astro"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
|
|
||||||
|
|
||||||
const form = document.querySelector('form');
|
|
||||||
form.addEventListener('submit', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
console.log('build script');
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,100 +0,0 @@
|
||||||
---
|
|
||||||
import Form, {
|
|
||||||
ControlConfig,
|
|
||||||
FormGroup,
|
|
||||||
FormControl,
|
|
||||||
} from "@astro-reactive/form";
|
|
||||||
|
|
||||||
const baseForm = new FormGroup([
|
|
||||||
{
|
|
||||||
name: "crust",
|
|
||||||
label: "Crust",
|
|
||||||
type: "radio",
|
|
||||||
value: [{ label: "Garlic", value: "garlic" },]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "size",
|
|
||||||
label: "Size",
|
|
||||||
type: "radio",
|
|
||||||
value: ["Small", "Medium", "Large"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "sauce",
|
|
||||||
label: "Sauce",
|
|
||||||
type: "radio",
|
|
||||||
value: ["Tomato", "Barbeque"]
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
const toppingsForm = new FormGroup([
|
|
||||||
{
|
|
||||||
name: "mushrooms",
|
|
||||||
label: "Mushrooms",
|
|
||||||
type: "checkbox"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "extraCheese",
|
|
||||||
label: "Extra Cheese",
|
|
||||||
type: "checkbox"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "onions",
|
|
||||||
label: "Onions",
|
|
||||||
type: "checkbox"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "peppers",
|
|
||||||
label: "Peppers",
|
|
||||||
type: "checkbox"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "pepperoni",
|
|
||||||
label: "Pepperoni",
|
|
||||||
type: "checkbox"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "sausage",
|
|
||||||
label: "Sausage",
|
|
||||||
type: "checkbox"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "chicken",
|
|
||||||
label: "Chicken",
|
|
||||||
type: "checkbox"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "pineapple",
|
|
||||||
label: "Pineapple",
|
|
||||||
type: "checkbox"
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
baseForm.name = "Base"
|
|
||||||
toppingsForm.name = "Toppings";
|
|
||||||
|
|
||||||
// const config: ControlConfig = {
|
|
||||||
// type: "checkbox",
|
|
||||||
// name: "is-awesome",
|
|
||||||
// label: "is Awesome?",
|
|
||||||
// labelPosition: "right",
|
|
||||||
// };
|
|
||||||
|
|
||||||
// insert a control
|
|
||||||
// form.controls.push(new FormControl(config));
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width" />
|
|
||||||
<meta name="generator" content={Astro.generator} />
|
|
||||||
<title>Astro</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Pizza Form Demo</h1>
|
|
||||||
<Form showValidationHints={true} formGroups={[baseForm, toppingsForm]} />
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -27,7 +27,7 @@ const form = new FormGroup([
|
||||||
name: "rating",
|
name: "rating",
|
||||||
label: "Rating",
|
label: "Rating",
|
||||||
type: "radio",
|
type: "radio",
|
||||||
value: ["1", "2", "3", "4", "5"]
|
value: ["1", "2", "3", "4", "5"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "agreement",
|
name: "agreement",
|
||||||
|
@ -35,9 +35,9 @@ const form = new FormGroup([
|
||||||
type: "radio",
|
type: "radio",
|
||||||
value: [
|
value: [
|
||||||
{ label: "Agree", value: "yes" },
|
{ label: "Agree", value: "yes" },
|
||||||
{ label: "Disagree", value: "no", labelPosition: "right" }
|
{ label: "Disagree", value: "no", labelPosition: "right" },
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
form.name = "Simple Form";
|
form.name = "Simple Form";
|
||||||
|
@ -58,6 +58,9 @@ const userNameControl = form.get("username");
|
||||||
// set values dynamically
|
// set values dynamically
|
||||||
userNameControl?.setValue("RAMOOOON");
|
userNameControl?.setValue("RAMOOOON");
|
||||||
form.get("is-awesome")?.setValue("checked");
|
form.get("is-awesome")?.setValue("checked");
|
||||||
|
|
||||||
|
// setting an invalid value will cause errors as server-rendered
|
||||||
|
form.get("email")?.setValue("invalid-email");
|
||||||
---
|
---
|
||||||
|
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
|
@ -1,9 +1,5 @@
|
||||||
---
|
---
|
||||||
import Form, {
|
import Form, { FormGroup } from "@astro-reactive/form";
|
||||||
ControlConfig,
|
|
||||||
FormGroup,
|
|
||||||
FormControl,
|
|
||||||
} from "@astro-reactive/form";
|
|
||||||
import { Validators } from "@astro-reactive/validator";
|
import { Validators } from "@astro-reactive/validator";
|
||||||
|
|
||||||
const baseForm = new FormGroup([
|
const baseForm = new FormGroup([
|
||||||
|
@ -11,62 +7,62 @@ const baseForm = new FormGroup([
|
||||||
name: "crust",
|
name: "crust",
|
||||||
label: "Crust",
|
label: "Crust",
|
||||||
type: "radio",
|
type: "radio",
|
||||||
value: [{ label: "Garlic", value: "garlic" },]
|
value: [{ label: "Garlic", value: "garlic" }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "size",
|
name: "size",
|
||||||
label: "Size",
|
label: "Size",
|
||||||
type: "radio",
|
type: "radio",
|
||||||
value: ["Small", "Medium", "Large"]
|
value: ["Small", "Medium", "Large"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "sauce",
|
name: "sauce",
|
||||||
label: "Sauce",
|
label: "Sauce",
|
||||||
type: "radio",
|
type: "radio",
|
||||||
value: ["Tomato", "Barbeque"]
|
value: ["Tomato", "Barbeque"],
|
||||||
}
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const toppingsForm = new FormGroup([
|
const toppingsForm = new FormGroup([
|
||||||
{
|
{
|
||||||
name: "mushrooms",
|
name: "mushrooms",
|
||||||
label: "Mushrooms",
|
label: "Mushrooms",
|
||||||
type: "checkbox"
|
type: "checkbox",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "extraCheese",
|
name: "extraCheese",
|
||||||
label: "Extra Cheese",
|
label: "Extra Cheese",
|
||||||
type: "checkbox"
|
type: "checkbox",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "onions",
|
name: "onions",
|
||||||
label: "Onions",
|
label: "Onions",
|
||||||
type: "checkbox"
|
type: "checkbox",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "peppers",
|
name: "peppers",
|
||||||
label: "Peppers",
|
label: "Peppers",
|
||||||
type: "checkbox"
|
type: "checkbox",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "pepperoni",
|
name: "pepperoni",
|
||||||
label: "Pepperoni",
|
label: "Pepperoni",
|
||||||
type: "checkbox"
|
type: "checkbox",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "sausage",
|
name: "sausage",
|
||||||
label: "Sausage",
|
label: "Sausage",
|
||||||
type: "checkbox"
|
type: "checkbox",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "chicken",
|
name: "chicken",
|
||||||
label: "Chicken",
|
label: "Chicken",
|
||||||
type: "checkbox"
|
type: "checkbox",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "pineapple",
|
name: "pineapple",
|
||||||
label: "Pineapple",
|
label: "Pineapple",
|
||||||
type: "checkbox"
|
type: "checkbox",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
@ -88,21 +84,9 @@ const infoForm = new FormGroup([
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
baseForm.name = "Base"
|
baseForm.name = "Base";
|
||||||
toppingsForm.name = "Toppings";
|
toppingsForm.name = "Toppings";
|
||||||
infoForm.name = "Customer Info";
|
infoForm.name = "Customer Info";
|
||||||
|
|
||||||
// const config: ControlConfig = {
|
|
||||||
// type: "checkbox",
|
|
||||||
// name: "is-awesome",
|
|
||||||
// label: "is Awesome?",
|
|
||||||
// labelPosition: "right",
|
|
||||||
// };
|
|
||||||
|
|
||||||
// insert a control
|
|
||||||
// form.controls.push(new FormControl(config));
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
@ -115,6 +99,9 @@ infoForm.name = "Customer Info";
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Pizza Form Demo</h1>
|
<h1>Pizza Form Demo</h1>
|
||||||
<Form showValidationHints={true} formGroups={[baseForm, toppingsForm, infoForm]} />
|
<Form
|
||||||
|
showValidationHints={true}
|
||||||
|
formGroups={[baseForm, toppingsForm, infoForm]}
|
||||||
|
/>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
"astro-eleventy-img": "^0.5.0",
|
"astro-eleventy-img": "^0.5.0",
|
||||||
"astro-icon": "^0.7.3",
|
"astro-icon": "^0.7.3",
|
||||||
"prettier": "2.7.1",
|
"prettier": "2.7.1",
|
||||||
"prettier-plugin-astro": "^0.5.5",
|
"prettier-plugin-astro": "^0.6.0",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"tailwindcss-fluid-type": "^1.3.3"
|
"tailwindcss-fluid-type": "^1.3.3"
|
||||||
},
|
},
|
||||||
|
|
1710
package-lock.json
generated
1710
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -37,5 +37,8 @@
|
||||||
"apps/docs",
|
"apps/docs",
|
||||||
"apps/landing-page",
|
"apps/landing-page",
|
||||||
"packages/common"
|
"packages/common"
|
||||||
]
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"prettier-plugin-astro": "^0.6.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,29 +3,27 @@
|
||||||
* 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
|
||||||
*/
|
*/
|
||||||
export type ControlType =
|
export type ControlType =
|
||||||
| 'text'
|
| "text"
|
||||||
| 'checkbox'
|
| "checkbox"
|
||||||
| 'radio'
|
| "radio"
|
||||||
| 'password'
|
| "password"
|
||||||
| 'button'
|
| "button"
|
||||||
| 'color'
|
| "color"
|
||||||
| 'date'
|
| "date"
|
||||||
| 'datetime-local'
|
| "datetime-local"
|
||||||
| 'email'
|
| "email"
|
||||||
| 'file'
|
| "file"
|
||||||
| 'hidden'
|
| "hidden"
|
||||||
| 'image'
|
| "image"
|
||||||
| 'month'
|
| "month"
|
||||||
| 'number'
|
| "number"
|
||||||
| 'range'
|
| "range"
|
||||||
| 'search'
|
| "search"
|
||||||
| 'submit'
|
| "submit"
|
||||||
| 'tel'
|
| "tel"
|
||||||
| 'time'
|
| "time"
|
||||||
| 'url'
|
| "url"
|
||||||
| 'week';
|
| "week";
|
||||||
|
|
||||||
export type ControlConfig = ControlBase | Checkbox | Radio | Submit | Button;
|
|
||||||
|
|
||||||
export interface ControlBase {
|
export interface ControlBase {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -33,33 +31,33 @@ export interface ControlBase {
|
||||||
type?: ControlType;
|
type?: ControlType;
|
||||||
value?: string | number | string[];
|
value?: string | number | string[];
|
||||||
label?: string;
|
label?: string;
|
||||||
labelPosition?: 'right' | 'left';
|
labelPosition?: "right" | "left";
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
validators?: string[]; // TODO: implement validator type
|
validators?: string[]; // TODO: implement validator type
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Checkbox extends ControlBase {
|
export interface Checkbox extends ControlBase {
|
||||||
type: 'checkbox';
|
type: "checkbox";
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Radio extends Omit<ControlBase, 'value'> {
|
export interface Radio extends Omit<ControlBase, "value"> {
|
||||||
type: 'radio';
|
type: "radio";
|
||||||
value: string[] | RadioOption[];
|
value: string[] | RadioOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RadioOption extends Omit<ControlBase, 'name'> {
|
export interface RadioOption extends Omit<ControlBase, "name"> {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Submit extends ControlBase {
|
export interface Submit extends ControlBase {
|
||||||
type: 'submit';
|
type: "submit";
|
||||||
callBack?: () => void;
|
callBack?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Button extends ControlBase {
|
export interface Button extends ControlBase {
|
||||||
type: 'button';
|
type: "button";
|
||||||
callBack?: () => void;
|
callBack?: () => void;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
export * from "./control.types";
|
export * from "./control.types";
|
||||||
|
export * from "./validator.types";
|
||||||
|
|
9
packages/common/types/validator.types.ts
Normal file
9
packages/common/types/validator.types.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
export type HookType = "onSubmit" | "onControlBlur" | "all";
|
||||||
|
|
||||||
|
export type ValidationResult = true | ValidationError;
|
||||||
|
|
||||||
|
export type ValidationError = {
|
||||||
|
error: string;
|
||||||
|
value: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
|
@ -4,16 +4,19 @@ import type { FormControl } from '../core/form-control';
|
||||||
export interface Props {
|
export interface Props {
|
||||||
control: FormControl;
|
control: FormControl;
|
||||||
showValidationHints: boolean;
|
showValidationHints: boolean;
|
||||||
|
showErrors?: boolean;
|
||||||
showOnlyLabel?: boolean;
|
showOnlyLabel?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { control, showValidationHints, showOnlyLabel = false } = Astro.props;
|
const { control, showValidationHints, showErrors = false, showOnlyLabel = false } = Astro.props;
|
||||||
|
|
||||||
const { validators = [] } = control;
|
const { validators = [] } = control;
|
||||||
|
|
||||||
const isRequired = showValidationHints && validators.includes('validator-required');
|
const isRequired: boolean = showValidationHints && validators.includes('validator-required');
|
||||||
|
|
||||||
const validatorAttirbutes: Record<string, string> = validators?.reduce(
|
const hasErrors: boolean = showValidationHints && !!control.errors?.length;
|
||||||
|
|
||||||
|
const validatorAttributes: Record<string, string> = validators?.reduce(
|
||||||
(prev, validator) => {
|
(prev, 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';
|
||||||
|
@ -29,7 +32,11 @@ const validatorAttirbutes: Record<string, string> = validators?.reduce(
|
||||||
);
|
);
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="field" data-validator-hints={showValidationHints.toString()}>
|
<div
|
||||||
|
class="field"
|
||||||
|
data-validator-hints={showValidationHints?.toString()}
|
||||||
|
data-validator-haserrors={hasErrors ? hasErrors?.toString() : null}
|
||||||
|
>
|
||||||
{
|
{
|
||||||
control.label && control.labelPosition === 'left' && (
|
control.label && control.labelPosition === 'left' && (
|
||||||
<label for={control?.id ?? control.name}>
|
<label for={control?.id ?? control.name}>
|
||||||
|
@ -50,11 +57,23 @@ const validatorAttirbutes: Record<string, string> = validators?.reduce(
|
||||||
placeholder={control.placeholder}
|
placeholder={control.placeholder}
|
||||||
data-label={control.label}
|
data-label={control.label}
|
||||||
data-label-position={control.labelPosition}
|
data-label-position={control.labelPosition}
|
||||||
{...validatorAttirbutes}
|
data-validator-haserrors={hasErrors ? hasErrors?.toString() : null}
|
||||||
|
class:list={[{ 'has-errors': hasErrors }]}
|
||||||
|
{...validatorAttributes}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
showErrors &&
|
||||||
|
hasErrors &&
|
||||||
|
control.errors.map((error) => (
|
||||||
|
<li>
|
||||||
|
{error.error} {error.limit}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
control.label && control.labelPosition === 'right' && (
|
control.label && control.labelPosition === 'right' && (
|
||||||
<label for={control?.id ?? control.name}>
|
<label for={control?.id ?? control.name}>
|
||||||
|
@ -66,8 +85,13 @@ const validatorAttirbutes: Record<string, string> = validators?.reduce(
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/**
|
||||||
|
TODO: remove usage of data-validator-haserrors,
|
||||||
|
class has-errors is sufficient
|
||||||
|
*/
|
||||||
[data-validator-hints='true'][data-validator-haserrors='true'],
|
[data-validator-hints='true'][data-validator-haserrors='true'],
|
||||||
[data-validator-hints='true'] [data-validator-haserrors='true'] {
|
[data-validator-hints='true'] [data-validator-haserrors='true'],
|
||||||
|
.has-errors {
|
||||||
color: red;
|
color: red;
|
||||||
border-color: red;
|
border-color: red;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,15 @@
|
||||||
import type { ControlConfig, ControlType, RadioOption } from 'common/types';
|
import type {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
ControlBase,
|
||||||
|
ControlType,
|
||||||
|
Radio,
|
||||||
|
RadioOption,
|
||||||
|
Submit,
|
||||||
|
ValidationError,
|
||||||
|
} from 'common/types';
|
||||||
|
|
||||||
|
export type ControlConfig = ControlBase | Checkbox | Radio | Submit | Button;
|
||||||
|
|
||||||
export class FormControl {
|
export class FormControl {
|
||||||
private _name = '';
|
private _name = '';
|
||||||
|
@ -11,9 +22,19 @@ export class FormControl {
|
||||||
private _isPristine = true;
|
private _isPristine = true;
|
||||||
private _placeholder?: string;
|
private _placeholder?: string;
|
||||||
private _validators?: string[];
|
private _validators?: string[];
|
||||||
|
private _errors: ValidationError[] = [];
|
||||||
|
|
||||||
constructor(config: ControlConfig) {
|
private validate: (value: string, validators: string[]) => ValidationError[] = (
|
||||||
const { name, id, type, value, label, labelPosition, placeholder, validators } = config;
|
value: string,
|
||||||
|
validators: string[]
|
||||||
|
) => {
|
||||||
|
value;
|
||||||
|
validators;
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(private config: ControlConfig) {
|
||||||
|
const { name, id, type, value, label, labelPosition, placeholder, validators = [] } = config;
|
||||||
this._name = name;
|
this._name = name;
|
||||||
this._id = id ?? name;
|
this._id = id ?? name;
|
||||||
this._type = type ?? 'text';
|
this._type = type ?? 'text';
|
||||||
|
@ -21,7 +42,20 @@ export class FormControl {
|
||||||
this._label = label ?? '';
|
this._label = label ?? '';
|
||||||
this._labelPosition = labelPosition ?? 'left';
|
this._labelPosition = labelPosition ?? 'left';
|
||||||
this._placeholder = placeholder ?? '';
|
this._placeholder = placeholder ?? '';
|
||||||
this._validators = validators ?? [];
|
this._validators = validators;
|
||||||
|
|
||||||
|
// dynamic import of the validator package
|
||||||
|
// if user did not install the validator, then errors should be empty
|
||||||
|
import('@astro-reactive/validator').then((validator) => {
|
||||||
|
if (validator) {
|
||||||
|
this.validate = validator.validate;
|
||||||
|
|
||||||
|
const valueStr: string = this._value?.toString() || '';
|
||||||
|
this._errors = this.validate(valueStr, validators);
|
||||||
|
} else {
|
||||||
|
this._errors = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() {
|
get name() {
|
||||||
|
@ -56,10 +90,6 @@ export class FormControl {
|
||||||
return this._isPristine;
|
return this._isPristine;
|
||||||
}
|
}
|
||||||
|
|
||||||
set isPristine(value: boolean) {
|
|
||||||
this._isPristine = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isValid() {
|
get isValid() {
|
||||||
return this._isValid;
|
return this._isValid;
|
||||||
}
|
}
|
||||||
|
@ -68,12 +98,21 @@ export class FormControl {
|
||||||
return this._validators;
|
return this._validators;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get errors() {
|
||||||
|
return this._errors;
|
||||||
|
}
|
||||||
|
|
||||||
setValue(value: string) {
|
setValue(value: string) {
|
||||||
this._value = value;
|
this._value = value;
|
||||||
this._isPristine = false;
|
this._isPristine = false;
|
||||||
|
this._errors = this.validate(this._value, this.config.validators || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsPristine(value: boolean) {
|
clearErrors() {
|
||||||
this._isPristine = value;
|
this._errors = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(error: ValidationError) {
|
||||||
|
this._errors = [...(this._errors || []), error];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import type { ControlConfig } from 'common/types';
|
import { ControlConfig, FormControl } from './form-control';
|
||||||
import { FormControl } from './form-control';
|
|
||||||
|
|
||||||
export class FormGroup {
|
export class FormGroup {
|
||||||
controls: FormControl[];
|
controls: FormControl[];
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
import Form from './Form.astro';
|
import Form from './Form.astro';
|
||||||
export default Form;
|
export default Form;
|
||||||
export * from './core';
|
export * from './core';
|
||||||
|
|
|
@ -30,7 +30,7 @@
|
||||||
"format": "prettier -w .",
|
"format": "prettier -w .",
|
||||||
"lint": "eslint . --ext .ts,.js",
|
"lint": "eslint . --ext .ts,.js",
|
||||||
"lint:fix": "eslint --fix . --ext .ts,.js",
|
"lint:fix": "eslint --fix . --ext .ts,.js",
|
||||||
"build": "tsc --noEmit"
|
"build": "astro check && tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chai": "^4.3.3",
|
"@types/chai": "^4.3.3",
|
||||||
|
@ -43,19 +43,17 @@
|
||||||
"astro": "^1.5.0",
|
"astro": "^1.5.0",
|
||||||
"astro-component-tester": "^0.6.0",
|
"astro-component-tester": "^0.6.0",
|
||||||
"chai": "^4.3.6",
|
"chai": "^4.3.6",
|
||||||
|
"common": "file:packages/common",
|
||||||
"eslint": "^8.23.1",
|
"eslint": "^8.23.1",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"eslint-config-prettier": "^8.5.0",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"mocha": "^10.0.0",
|
"mocha": "^10.0.0",
|
||||||
"prettier": "^2.7.1",
|
"prettier": "^2.7.1",
|
||||||
"prettier-plugin-astro": "^0.5.4",
|
"prettier-plugin-astro": "^0.6.0",
|
||||||
"typescript": "^4.8.3"
|
"typescript": "^4.8.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"astro": "^1.5.0"
|
"astro": "^1.5.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
|
||||||
"common": "file:packages/common"
|
|
||||||
},
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|
|
@ -72,6 +72,33 @@ describe('Field.astro test', () => {
|
||||||
expect(actualResult).to.contain(expectedLabel);
|
expect(actualResult).to.contain(expectedLabel);
|
||||||
expect(actualResult).to.contain('*');
|
expect(actualResult).to.contain('*');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Should server-render validation error attributes', async () => {
|
||||||
|
// arrange
|
||||||
|
const expectedClass = 'has-errors';
|
||||||
|
const props = {
|
||||||
|
control: {
|
||||||
|
label: 'FAKE LABEL',
|
||||||
|
name: 'username',
|
||||||
|
labelPosition: 'left',
|
||||||
|
validators: ['validator-required'],
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
error: 'required',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
showValidationHints: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// act
|
||||||
|
component = await getComponentOutput('./components/Field.astro', props);
|
||||||
|
const actualResult = cleanString(component.html);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(actualResult).to.contain(expectedClass);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function cleanString(str) {
|
function cleanString(str) {
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/strictest",
|
"extends": "astro/tsconfigs/strictest",
|
||||||
"exclude": ["index.ts"]
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
import type { HookType } from './core';
|
import type { HookType } from 'common/types';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
hook?: HookType;
|
hook?: HookType;
|
||||||
|
@ -28,8 +28,34 @@ const { hook = 'all', displayErrorMessages = false } = Astro.props;
|
||||||
|
|
||||||
// const hook: HookType = (document.getElementById('hook') as HTMLInputElement).value as HookType;
|
// const hook: HookType = (document.getElementById('hook') as HTMLInputElement).value as HookType;
|
||||||
const inputs = [...document.querySelectorAll('form input')] as HTMLInputElement[];
|
const inputs = [...document.querySelectorAll('form input')] as HTMLInputElement[];
|
||||||
|
|
||||||
inputs?.forEach((input) => {
|
inputs?.forEach((input) => {
|
||||||
input.addEventListener('blur', validate);
|
input.addEventListener('blur', (e: Event) => {
|
||||||
|
// NOTE: event target attribute names are converted to lowercase
|
||||||
|
const element = e.target as HTMLInputElement;
|
||||||
|
const attributeNames = element?.getAttributeNames() || [];
|
||||||
|
const validators = attributeNames
|
||||||
|
.filter((attribute) => attribute.includes('data-validator-'))
|
||||||
|
.map((attribute) => {
|
||||||
|
const limit = element.getAttribute(attribute);
|
||||||
|
return `${attribute}:${limit}`;
|
||||||
|
});
|
||||||
|
const value = element.value;
|
||||||
|
const errors = validate(value, validators);
|
||||||
|
|
||||||
|
// set element hasErrors
|
||||||
|
if (errors.length) {
|
||||||
|
element.parentElement?.setAttribute('data-validator-haserrors', 'true');
|
||||||
|
element.setAttribute('data-validator-haserrors', 'true');
|
||||||
|
element.classList.add('has-errors');
|
||||||
|
// TODO: display error messages
|
||||||
|
} else {
|
||||||
|
element.parentElement?.removeAttribute('data-validator-haserrors');
|
||||||
|
element.removeAttribute('data-validator-haserrors');
|
||||||
|
element.classList.remove('has-errors');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
input.addEventListener('input', clearErrors);
|
input.addEventListener('input', clearErrors);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,3 +1,2 @@
|
||||||
export * from './validator.functions';
|
export * from './validator-functions';
|
||||||
export * from './validator.types';
|
export * from './validator-names';
|
||||||
export * from './validators';
|
|
||||||
|
|
|
@ -1,28 +1,27 @@
|
||||||
import type { ValidationResult } from './validator.types';
|
import type { ValidationError } from 'common/types';
|
||||||
import { Validators } from './validators';
|
import { Validators } from './validator-names';
|
||||||
|
|
||||||
export function validate(event: FocusEvent) {
|
/**
|
||||||
// NOTE: event target attribute names are converted to lowercase
|
* given the value and validators, `validate()` returns an array of errors
|
||||||
const element = event.target as HTMLInputElement;
|
* @param value - value to be validated
|
||||||
const attributeNames = element?.getAttributeNames() || [];
|
* @param validators - names of validation logic to be applied
|
||||||
const validatorAttirbutes = attributeNames.filter((attribute) =>
|
* @returns errors - array of errors `ValidationError`
|
||||||
attribute.includes('data-validator-')
|
*/
|
||||||
);
|
export function validate(value: string, validators: string[]): ValidationError[] {
|
||||||
const value = element.value;
|
return validators
|
||||||
|
|
||||||
const validityArray: ValidationResult[] = validatorAttirbutes
|
|
||||||
.map((validator) => validator.replace('data-', ''))
|
.map((validator) => validator.replace('data-', ''))
|
||||||
.map((validator): ValidationResult => {
|
.map((attribute): ValidationError | null => {
|
||||||
// insert logic for each validator
|
// TODO: implement dynamic import of function depending on validators
|
||||||
// TODO: implement a map of functions,validator
|
const split = attribute.split(':');
|
||||||
|
const validator = split[0];
|
||||||
|
const limitStr = split[1];
|
||||||
|
const limit = parseInt(limitStr || '0', 10);
|
||||||
|
|
||||||
if (validator === Validators.min()) {
|
if (validator === Validators.min()) {
|
||||||
const limit = parseInt(element.getAttribute('data-validator-min') || '0', 10);
|
|
||||||
return validateMin(value, limit);
|
return validateMin(value, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validator === Validators.max()) {
|
if (validator === Validators.max()) {
|
||||||
const limit = parseInt(element.getAttribute('data-validator-min') || '0', 10);
|
|
||||||
return validateMax(value, limit);
|
return validateMax(value, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -39,26 +38,16 @@ export function validate(event: FocusEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validator === Validators.minLength()) {
|
if (validator === Validators.minLength()) {
|
||||||
const limit = parseInt(element.getAttribute('data-validator-minlength') || '0', 10);
|
|
||||||
return validateMinLength(value, limit);
|
return validateMinLength(value, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validator === Validators.maxLength()) {
|
if (validator === Validators.maxLength()) {
|
||||||
const limit = parseInt(element.getAttribute('data-validator-maxlength') || '0', 10);
|
|
||||||
return validateMaxLength(value, limit);
|
return validateMaxLength(value, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return null;
|
||||||
});
|
})
|
||||||
|
.filter((result) => result !== null) as ValidationError[];
|
||||||
const errors = validityArray.filter((result) => result !== true);
|
|
||||||
|
|
||||||
// set element hasErrors
|
|
||||||
if (errors.length) {
|
|
||||||
element.parentElement?.setAttribute('data-validator-haserrors', 'true');
|
|
||||||
element.setAttribute('data-validator-haserrors', 'true');
|
|
||||||
// TODO: display error messages
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearErrors(event: Event) {
|
export function clearErrors(event: Event) {
|
||||||
|
@ -67,52 +56,62 @@ export function clearErrors(event: Event) {
|
||||||
element.setAttribute('data-validator-haserrors', 'false');
|
element.setAttribute('data-validator-haserrors', 'false');
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateMin(value: string, limit: number): ValidationResult {
|
function validateMin(value: string, limit: number): ValidationError | null {
|
||||||
const isValid = parseInt(value, 10) >= limit;
|
const isValid = parseInt(value, 10) >= limit;
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return {
|
return {
|
||||||
|
value,
|
||||||
error: 'min',
|
error: 'min',
|
||||||
limit: limit,
|
limit: limit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateMax(value: string, limit: number): ValidationResult {
|
function validateMax(value: string, limit: number): ValidationError | null {
|
||||||
const isValid = parseInt(value, 10) <= limit;
|
const isValid = parseInt(value, 10) <= limit;
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return {
|
return {
|
||||||
|
value,
|
||||||
error: 'max',
|
error: 'max',
|
||||||
limit: limit,
|
limit: limit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateRequired(value: string): ValidationResult {
|
function validateRequired(value: string): ValidationError | null {
|
||||||
const isValid = !!value;
|
const isValid = !!value;
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return {
|
return {
|
||||||
|
value,
|
||||||
error: 'required',
|
error: 'required',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateRequiredChecked(value: string): ValidationResult {
|
function validateRequiredChecked(value: string): ValidationError | null {
|
||||||
const isValid = value === 'checked';
|
const isValid = value === 'checked';
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return {
|
return {
|
||||||
|
value,
|
||||||
error: 'requiredChecked',
|
error: 'requiredChecked',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: review regexp vulnerability
|
// TODO: review regexp vulnerability
|
||||||
function validateEmail(value: string): ValidationResult {
|
function validateEmail(value: string): ValidationError | null {
|
||||||
const isValid = String(value)
|
const isValid = String(value)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.match(
|
.match(
|
||||||
|
@ -121,32 +120,38 @@ function validateEmail(value: string): ValidationResult {
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return {
|
return {
|
||||||
|
value,
|
||||||
error: 'email',
|
error: 'email',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateMinLength(value: string, limit: number): ValidationResult {
|
function validateMinLength(value: string, limit: number): ValidationError | null {
|
||||||
const isValid = value.length >= limit;
|
const isValid = value.length >= limit;
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return {
|
return {
|
||||||
|
value,
|
||||||
error: 'minLength',
|
error: 'minLength',
|
||||||
limit: limit,
|
limit: limit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateMaxLength(value: string, limit: number): ValidationResult {
|
function validateMaxLength(value: string, limit: number): ValidationError | null {
|
||||||
const isValid = value.length <= limit;
|
const isValid = value.length <= limit;
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return {
|
return {
|
||||||
|
value,
|
||||||
error: 'minLength',
|
error: 'minLength',
|
||||||
limit: limit,
|
limit: limit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
|
return null;
|
||||||
}
|
}
|
|
@ -1,8 +0,0 @@
|
||||||
export type HookType = 'onSubmit' | 'onControlBlur' | 'all';
|
|
||||||
|
|
||||||
export type ValidationResult = true | ValidationError;
|
|
||||||
|
|
||||||
export type ValidationError = {
|
|
||||||
error: string;
|
|
||||||
limit?: number;
|
|
||||||
};
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
import Validator from './Validator.astro';
|
import Validator from './Validator.astro';
|
||||||
export default Validator;
|
export default Validator;
|
||||||
export * from './core';
|
export * from './core';
|
||||||
|
|
|
@ -27,7 +27,7 @@
|
||||||
"format": "prettier -w .",
|
"format": "prettier -w .",
|
||||||
"lint": "eslint . --ext .ts,.js",
|
"lint": "eslint . --ext .ts,.js",
|
||||||
"lint:fix": "eslint --fix . --ext .ts,.js",
|
"lint:fix": "eslint --fix . --ext .ts,.js",
|
||||||
"build": "tsc --noEmit"
|
"build": "astro check && tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chai": "^4.3.3",
|
"@types/chai": "^4.3.3",
|
||||||
|
@ -40,12 +40,13 @@
|
||||||
"astro": "^1.5.0",
|
"astro": "^1.5.0",
|
||||||
"astro-component-tester": "^0.6.0",
|
"astro-component-tester": "^0.6.0",
|
||||||
"chai": "^4.3.6",
|
"chai": "^4.3.6",
|
||||||
|
"common": "file:packages/common",
|
||||||
"eslint": "^8.23.1",
|
"eslint": "^8.23.1",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"eslint-config-prettier": "^8.5.0",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"mocha": "^10.0.0",
|
"mocha": "^10.0.0",
|
||||||
"prettier": "^2.7.1",
|
"prettier": "^2.7.1",
|
||||||
"prettier-plugin-astro": "^0.5.4",
|
"prettier-plugin-astro": "^0.6.0",
|
||||||
"typescript": "^4.8.3"
|
"typescript": "^4.8.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/strictest",
|
"extends": "astro/tsconfigs/strictest",
|
||||||
"exclude": ["index.ts"]
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue