refactor: form package cleanup (#135)

* refactor: form package cleanup

* refactor: organize components folder
This commit is contained in:
Ayo Ayco 2022-10-23 23:04:40 +02:00 committed by GitHub
parent b9995b8a63
commit 3d361fcb5b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 287 additions and 234 deletions

View file

@ -34,8 +34,8 @@ const form = new FormGroup([
label: "Agreement",
type: "radio",
value: [
{ label: "Agree", value: "yes" },
{ label: "Disagree", value: "no", labelPosition: "right" },
{ label: "Agree", value: "yes", checked: true },
{ label: "Disagree", value: "no" },
],
},
]);
@ -61,9 +61,12 @@ form.get("is-awesome")?.setValue("checked");
// setting an invalid value will cause errors as server-rendered
form.get("email")?.setValue("invalid-email");
// switch between light and dark mode
const theme = "dark";
---
<html lang="en">
<html lang="en" class={theme}>
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
@ -71,8 +74,15 @@ form.get("email")?.setValue("invalid-email");
<meta name="generator" content={Astro.generator} />
<title>Astro</title>
</head>
<body>
<body class={theme}>
<nav>Examples: <a href="/pizza-delivery">Pizza Delivery</a></nav>
<h1>Astro Reactive Form</h1>
<Form showValidationHints={true} formGroups={form} />
<Form showValidationHints={true} formGroups={form} theme={theme} />
<style>
html.dark,
body.dark {
color-scheme: dark;
}
</style>
</body>
</html>

View file

@ -98,6 +98,7 @@ infoForm.name = "Customer Info";
<title>Astro</title>
</head>
<body>
<nav><a href="/">Home</a></nav>
<h1>Pizza Form Demo</h1>
<Form
showValidationHints={true}

View file

@ -46,7 +46,7 @@ export interface Radio extends Omit<ControlBase, "value"> {
value: string[] | RadioOption[];
}
export interface RadioOption extends Omit<ControlBase, "name"> {
export interface RadioOption {
label: string;
value: string;
checked?: boolean;

View file

@ -1,84 +0,0 @@
---
import type { Submit, Radio, RadioOption } from 'common/types';
import { FormGroup, FormControl } from './core';
import FieldSet from './components/FieldSet.astro';
import Field from './components/Field.astro';
export interface Props {
formGroups: FormGroup | FormGroup[];
submitControl?: Submit;
theme?: 'light' | 'dark';
showValidationHints?: boolean;
}
const { submitControl, formGroups: form, theme, showValidationHints = false } = Astro.props;
const formTheme = theme ?? 'light';
const formName = Array.isArray(form) ? null : form?.name || null;
---
<form class={formTheme} name={formName} id={formName}>
{
Array.isArray(form)
? form?.map((group) => <FieldSet showValidationHints={showValidationHints} group={group} />)
: form?.controls.map((control) =>
control.type === 'radio' ? (
[
<Field
showValidationHints={showValidationHints}
control={control}
showOnlyLabel={true}
/>,
...(control as Radio)?.value?.map((v: string | RadioOption) => (
<Field
showValidationHints={showValidationHints}
control={
typeof v === 'string'
? new FormControl({
name: control.name,
type: 'radio',
id: control.name + v,
label: v,
value: v,
})
: new FormControl({
name: control.name,
type: 'radio',
id: control.name + v.label,
...(v as RadioOption),
})
}
/>
)),
]
) : (
<Field showValidationHints={showValidationHints} control={control} />
)
)
}
{
submitControl && (
<Field showValidationHints={showValidationHints} control={new FormControl(submitControl)} />
)
}
</form>
<style>
.light {
/**
* run dev server with: "npm start",
* then open browser to "localhost:3000"
* add a class="light" to the <form> element above to test changes
* INSERT STYLES FOR LIGHT MODE BELOW: */
}
.dark {
/**
* run dev server with: "npm start",
* then open browser to "localhost:3000"
* add a class="dark" to the <form> element above to test changes
* INSERT STYLES FOR DARK MODE BELOW: */
background-color: #333;
color: white;
}
</style>

View file

@ -0,0 +1,17 @@
---
import type { ValidationError } from 'common/types';
export interface Props {
errors: ValidationError[];
}
const { errors = [] } = Astro.props;
---
{
errors.map((error) => (
<li>
{error.error} {error.limit}
</li>
))
}

View file

@ -1,98 +1,34 @@
---
/**
* DEFAULT CONTROL COMPONENT
*/
import type { Radio } from 'common/types';
import type { FormControl } from '../core/form-control';
import Input from './controls/Input.astro';
import RadioGroup from './controls/RadioGroup.astro';
import Errors from './Errors.astro';
import Label from './Label.astro';
export interface Props {
control: FormControl;
showValidationHints: boolean;
showErrors?: boolean;
showOnlyLabel?: boolean;
showErrors?: boolean; // feature flag for showing validation errors
}
const { control, showValidationHints, showErrors = false, showOnlyLabel = false } = Astro.props;
const { control, showValidationHints, showErrors = false } = Astro.props;
const { validators = [] } = control;
const isRequired: boolean = showValidationHints && validators.includes('validator-required');
const hasErrors: boolean = showValidationHints && !!control.errors?.length;
const validatorAttributes: Record<string, string> = validators?.reduce(
(prev, validator) => {
const split: string[] = validator.split(':');
const label: string = `data-${split[0]}` || 'invalid';
const value: string | null = split.length > 1 ? split[1] ?? null : 'true';
return {
...prev,
[label]: value,
};
},
{}
);
const hasErrors: boolean | null = !!control.errors?.length;
---
<div
class="field"
data-validator-hints={showValidationHints?.toString()}
data-validator-haserrors={hasErrors ? hasErrors?.toString() : null}
>
{
control.label && control.labelPosition === 'left' && (
<label for={control?.id ?? control.name}>
{isRequired && <span>*</span>}
{control.label}
</label>
)
}
{
!showOnlyLabel && (
<input
name={control.name}
id={control?.id ?? control.name}
type={control.type}
value={control.value?.toString()}
checked={control.value === 'checked'}
placeholder={control.placeholder}
data-label={control.label}
data-label-position={control.labelPosition}
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' && (
<label for={control?.id ?? control.name}>
{isRequired && <span>*</span>}
{control.label}
</label>
)
}
<div class="field" data-validator-haserrors={hasErrors ? hasErrors.toString() : null}>
<Label control={control} showValidationHints={showValidationHints}>
{
control.type === 'radio' ? (
<RadioGroup control={control as Radio} />
) : (
<Input control={control} />
)
}
{showErrors && <Errors errors={control.errors} />}
</Label>
</div>
<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'],
.has-errors {
color: red;
border-color: red;
}
</style>

View file

@ -0,0 +1,58 @@
---
import type { Submit } from 'common/types';
import { FormGroup, FormControl } from '../core';
import FieldSet from './FieldSet.astro';
import Field from './Field.astro';
export interface Props {
formGroups: FormGroup | FormGroup[];
submitControl?: Submit;
theme?: 'light' | 'dark';
showValidationHints?: boolean;
}
const { submitControl, formGroups = [], theme, showValidationHints = false } = Astro.props;
const formTheme = theme ?? 'light';
const formName = Array.isArray(formGroups) ? null : formGroups?.name || null;
---
<form
class={formTheme}
name={formName}
id={formName}
data-validator-hints={showValidationHints.toString()}
>
{
Array.isArray(formGroups)
? formGroups?.map((group) => (
<FieldSet showValidationHints={showValidationHints} group={group} />
))
: formGroups?.controls.map((control) => (
<Field showValidationHints={showValidationHints} control={control} />
))
}
{
submitControl && (
<Field showValidationHints={showValidationHints} control={new FormControl(submitControl)} />
)
}
</form>
<style>
.light {
color-scheme: light;
}
.dark {
color-scheme: dark;
}
</style>
<style is:global>
[data-validator-hints='true'][data-validator-haserrors='true'],
[data-validator-hints='true'] [data-validator-haserrors='true'] {
color: red;
border-color: red;
}
</style>

View file

@ -0,0 +1,36 @@
---
import type { FormControl } from '../core';
export interface Props {
control: FormControl;
showValidationHints: boolean;
showErrors?: boolean; // feature flag for showing validation errors
}
const { control, showValidationHints } = Astro.props;
const { validators = [] } = control;
const isRequired: boolean = showValidationHints && validators.includes('validator-required');
---
{
control.label && control.labelPosition === 'left' && (
<label for={control.name}>
{isRequired && <span>*</span>}
{control.label}
</label>
)
}
<slot />
{
control.label && control.labelPosition === 'right' && (
<label for={control.name}>
{isRequired && <span>*</span>}
{control.label}
</label>
)
}

View file

@ -0,0 +1,40 @@
---
/**
* DEFAULT INPUT COMPONENT
*/
import type { FormControl } from '../../core/form-control';
export interface Props {
control: FormControl;
}
const { control } = Astro.props;
const { validators = [] } = control;
const hasErrors: boolean = !!control.errors?.length;
const validatorAttributes: Record<string, string> = validators?.reduce((prev, validator) => {
const split: string[] = validator.split(':');
const label: string = `data-${split[0]}` || 'invalid';
const value: string | null = split.length > 1 ? split[1] ?? null : 'true';
return {
...prev,
[label]: value,
};
}, {});
---
<input
name={control.name}
id={control.name}
type={control.type}
value={control.value?.toString()}
checked={control.value === 'checked'}
placeholder={control.placeholder}
data-label={control.label}
data-label-position={control.labelPosition}
data-validator-haserrors={hasErrors.toString()}
{...validatorAttributes}
/>

View file

@ -0,0 +1,31 @@
---
/**
* RADIO GROUP COMPONENT
*/
import type { Radio } from 'common/types';
export interface Props {
control: Radio;
}
const { control } = Astro.props;
const options = control.value.map((option) => {
if (typeof option === 'string') {
return {
label: option,
value: option,
};
}
return option;
});
---
{
options.map((option) => (
<div class="radio-option">
<input type="radio" name={control.name} value={option.value} checked={option.checked} />{' '}
{option.label}
</div>
))
}

View file

@ -13,15 +13,14 @@ export type ControlConfig = ControlBase | Checkbox | Radio | Submit | Button;
export class FormControl {
private _name = '';
private _id = '';
private _type: ControlType = 'text';
private _value?: string | number | null | string[] | RadioOption[];
private _label?: string;
private _label = '';
private _labelPosition?: 'right' | 'left' = 'left';
private _isValid = true;
private _isPristine = true;
private _placeholder?: string;
private _validators?: string[];
private _placeholder: string | null = null;
private _validators: string[] = [];
private _errors: ValidationError[] = [];
private validate: (value: string, validators: string[]) => ValidationError[] = (
@ -34,18 +33,25 @@ export class FormControl {
};
constructor(private config: ControlConfig) {
const { name, id, type, value, label, labelPosition, placeholder, validators = [] } = config;
const {
name,
type = 'text',
value = null,
label = '',
labelPosition = 'left',
placeholder = null,
validators = [],
} = config;
this._name = name;
this._id = id ?? name;
this._type = type ?? 'text';
this._value = value ?? null;
this._label = label ?? '';
this._labelPosition = labelPosition ?? 'left';
this._placeholder = placeholder ?? '';
this._type = type;
this._value = value;
this._label = label;
this._labelPosition = labelPosition;
this._placeholder = placeholder;
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;
@ -53,6 +59,7 @@ export class FormControl {
const valueStr: string = this._value?.toString() || '';
this._errors = this.validate(valueStr, validators);
} else {
// if user did not install the validator, then errors should be empty
this._errors = [];
}
});
@ -62,10 +69,6 @@ export class FormControl {
return this._name;
}
get id() {
return this._id;
}
get type() {
return this._type;
}

View file

@ -1,5 +1,5 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import Form from './Form.astro';
import Form from './components/Form.astro';
export default Form;
export * from './core';

View file

@ -16,7 +16,6 @@
"files": [
"core/",
"components/",
"Form.astro",
"index.ts"
],
"keywords": [

View file

@ -1,6 +1,7 @@
import { expect } from 'chai';
import { beforeEach, describe, it } from 'mocha';
import { getComponentOutput } from 'astro-component-tester';
import { cleanString } from './utils/index.js';
describe('Field.astro test', () => {
let component;
@ -29,28 +30,6 @@ describe('Field.astro test', () => {
expect(actualResult).to.contain(expectedLabel);
});
it('Should render only label', async () => {
// arrange
const expectedLabel = 'TestLabel';
const props = {
control: {
label: expectedLabel,
name: 'username',
labelPosition: 'left',
},
showValidationHints: false,
showOnlyLabel: true,
};
// act
component = await getComponentOutput('./components/Field.astro', props);
const actualResult = cleanString(component.html);
// assert
expect(actualResult).to.contain(expectedLabel);
expect(actualResult).to.not.contain('input');
});
it('Should render required fields with asterisk in label when showValidationHints is true', async () => {
// arrange
const expectedLabel = 'TestLabel';
@ -75,7 +54,7 @@ describe('Field.astro test', () => {
it('Should server-render validation error attributes', async () => {
// arrange
const expectedClass = 'has-errors';
const expectedResult = 'data-validator-haserrors="true"';
const props = {
control: {
label: 'FAKE LABEL',
@ -97,10 +76,6 @@ describe('Field.astro test', () => {
const actualResult = cleanString(component.html);
// assert
expect(actualResult).to.contain(expectedClass);
expect(actualResult).to.contain(expectedResult);
});
});
function cleanString(str) {
return str.replace(/\s/g, '');
}

View file

@ -1,6 +1,7 @@
import { expect } from 'chai';
import { beforeEach, describe, it } from 'mocha';
import { getComponentOutput } from 'astro-component-tester';
import { cleanString } from './utils/index.js';
describe('FieldSet.astro test', () => {
let component;
@ -36,7 +37,3 @@ describe('FieldSet.astro test', () => {
expect(actualResult).to.contain(`<legend>${expectedName}</legend>`);
});
});
function cleanString(str) {
return str.replace(/\s/g, '');
}

View file

@ -1,6 +1,7 @@
import { expect } from 'chai';
import { describe, beforeEach, it } from 'mocha';
import { getComponentOutput } from 'astro-component-tester';
import { cleanString } from './utils/index.js';
describe('Form.astro test', () => {
let component;
@ -15,7 +16,7 @@ describe('Form.astro test', () => {
const expectedCount = 0;
const element = /<fieldset>/g;
const props = { formGroups: undefined };
component = await getComponentOutput('./Form.astro', props);
component = await getComponentOutput('./components/Form.astro', props);
// act
const actualResult = cleanString(component.html);
@ -30,7 +31,7 @@ describe('Form.astro test', () => {
const expectedCount = 0;
const element = /<fieldset>/g;
const props = { formGroups: [] };
component = await getComponentOutput('./Form.astro', props);
component = await getComponentOutput('./components/Form.astro', props);
// act
const actualResult = cleanString(component.html);
@ -54,7 +55,7 @@ describe('Form.astro test', () => {
],
};
const props = { formGroups: Array(expectedCount).fill(fakeFormGroup) };
component = await getComponentOutput('./Form.astro', props);
component = await getComponentOutput('./components/Form.astro', props);
// act
const actualResult = cleanString(component.html);
@ -78,7 +79,7 @@ describe('Form.astro test', () => {
],
};
const props = { formGroups: fakeFormGroup };
component = await getComponentOutput('./Form.astro', props);
component = await getComponentOutput('./components/Form.astro', props);
// act
const actualResult = cleanString(component.html);
@ -89,7 +90,3 @@ describe('Form.astro test', () => {
});
});
});
function cleanString(str) {
return str.replace(/\s/g, '');
}

View file

@ -0,0 +1,34 @@
import { expect } from 'chai';
import { describe, beforeEach, it } from 'mocha';
import { getComponentOutput } from 'astro-component-tester';
import { cleanString } from './utils/index.js';
describe('RadioGroup.astro test', () => {
let component;
beforeEach(() => {
component = undefined;
});
it('Should render all radio options', async () => {
// arrange
const expectedOptions = 3;
const element = /radio-option/g;
const props = {
control: {
label: 'FAKE LABEL',
name: 'FAKE NAME',
type: 'radio',
value: ['one', 'two', 'three'],
},
showValidationHints: true,
};
// act
component = await getComponentOutput('./components/controls/RadioGroup.astro', props);
const actualResult = cleanString(component.html);
const matches = actualResult.match(element) || [];
// assert
expect(matches.length).to.equal(expectedOptions);
});
});

View file

@ -0,0 +1,3 @@
export function cleanString(str) {
return str.replace(/\s/g, '');
}