Skip to content

Commit

Permalink
Support per-field @validate
Browse files Browse the repository at this point in the history
  • Loading branch information
simonihmig committed Jan 26, 2023
1 parent fae49bc commit 2138e58
Show file tree
Hide file tree
Showing 5 changed files with 198 additions and 22 deletions.
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"glint.libraryPath": "test-app"
}
34 changes: 22 additions & 12 deletions ember-headless-form/src/components/-private/field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import LabelComponent from './label';

import type {
ErrorRecord,
FieldValidateCallback,
HeadlessFormData,
RegisterFieldCallback,
UnregisterFieldCallback,
ValidationError,
} from '../headless-form';
import type { HeadlessFormControlCheckboxComponentSignature } from './control/checkbox';
Expand All @@ -20,18 +23,6 @@ import type { HeadlessFormControlTextareaComponentSignature } from './control/te
import type { HeadlessFormLabelComponentSignature } from './label';
import type { ComponentLike, WithBoundArgs } from '@glint/template';

export type FieldValidateCallback<
DATA extends HeadlessFormData,
KEY extends keyof DATA = keyof DATA
> = (
fieldValue: DATA[KEY],
fieldName: KEY,
formData: DATA
) =>
| true
| ValidationError<DATA[KEY]>[]
| Promise<true | ValidationError<DATA[KEY]>[]>;

export interface HeadlessFormFieldComponentSignature<
DATA extends HeadlessFormData,
KEY extends keyof DATA = keyof DATA
Expand All @@ -42,6 +33,8 @@ export interface HeadlessFormFieldComponentSignature<
set: (key: KEY, value: DATA[KEY]) => void;
validate?: FieldValidateCallback<DATA, KEY>;
errors?: ErrorRecord<DATA, KEY>;
registerField: RegisterFieldCallback<DATA, KEY>;
unregisterField: UnregisterFieldCallback<DATA, KEY>;
};
Blocks: {
default: [
Expand Down Expand Up @@ -84,6 +77,23 @@ export default class HeadlessFormFieldComponent<
RadioComponent: ComponentLike<HeadlessFormControlRadioComponentSignature> =
RadioComponent;

constructor(
owner: unknown,
args: HeadlessFormFieldComponentSignature<DATA, KEY>['Args']
) {
super(owner, args);

this.args.registerField(this.args.name, {
validate: this.args.validate,
});
}

willDestroy(): void {
this.args.unregisterField(this.args.name);

super.willDestroy();
}

get value(): DATA[KEY] {
return this.args.data[this.args.name];
}
Expand Down
2 changes: 2 additions & 0 deletions ember-headless-form/src/components/headless-form.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
data=this.internalData
set=this.set
errors=this.errors
registerField=this.registerField
unregisterField=this.unregisterField
)
)
}}
Expand Down
82 changes: 74 additions & 8 deletions ember-headless-form/src/components/headless-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

import { TrackedObject } from 'tracked-built-ins';
import { TrackedMap, TrackedObject } from 'tracked-built-ins';

import FieldComponent from './-private/field';

Expand All @@ -25,7 +25,36 @@ export type ErrorRecord<

export type FormValidateCallback<DATA extends HeadlessFormData> = (
formData: DATA
) => true | ErrorRecord<DATA> | Promise<true | ErrorRecord<DATA>>;
) => undefined | ErrorRecord<DATA> | Promise<undefined | ErrorRecord<DATA>>;

export type FieldValidateCallback<
DATA extends HeadlessFormData,
KEY extends keyof DATA = keyof DATA
> = (
fieldValue: DATA[KEY],
fieldName: KEY,
formData: DATA
) =>
| undefined
| ValidationError<DATA[KEY]>[]
| Promise<undefined | ValidationError<DATA[KEY]>[]>;

export interface FieldData<
DATA extends HeadlessFormData,
KEY extends keyof DATA = keyof DATA
> {
validate?: FieldValidateCallback<DATA, KEY>;
}

export type RegisterFieldCallback<
DATA extends HeadlessFormData,
KEY extends keyof DATA = keyof DATA
> = (name: KEY, field: FieldData<DATA, KEY>) => void;

export type UnregisterFieldCallback<
DATA extends HeadlessFormData,
KEY extends keyof DATA = keyof DATA
> = (name: KEY) => void;

export interface HeadlessFormComponentSignature<DATA extends HeadlessFormData> {
Element: HTMLFormElement;
Expand All @@ -41,7 +70,7 @@ export interface HeadlessFormComponentSignature<DATA extends HeadlessFormData> {
{
field: WithBoundArgs<
typeof FieldComponent<DATA>,
'data' | 'set' | 'errors'
'data' | 'set' | 'errors' | 'registerField' | 'unregisterField'
>;
}
];
Expand All @@ -56,6 +85,8 @@ export default class HeadlessFormComponent<

internalData: DATA = new TrackedObject(this.args.data ?? {}) as DATA;

fields = new TrackedMap<keyof DATA, FieldData<DATA>>();

@tracked errors?: ErrorRecord<DATA>;

get validateOn(): ValidateOn {
Expand All @@ -66,21 +97,56 @@ export default class HeadlessFormComponent<
return this.args.revalidateOn ?? 'change';
}

async validate(): Promise<ErrorRecord<DATA> | undefined> {
let errors: ErrorRecord<DATA> | undefined = undefined;

if (this.args.validate) {
errors = await this.args.validate(this.internalData);
}

if (!errors) {
errors = {};
}

for (const [name, field] of this.fields) {
const fieldValidation = await field.validate?.(
this.internalData[name],
name,
this.internalData
);

if (fieldValidation) {
errors[name] = fieldValidation;
}
}

return Object.keys(errors).length > 0 ? errors : undefined;
}

@action
async onSubmit(e: Event): Promise<void> {
e.preventDefault();

if (this.args.validate) {
const validationResult = await this.args.validate(this.internalData);
const validationResult = await this.validate();

if (validationResult !== true) {
this.errors = validationResult;
}
if (validationResult) {
this.errors = validationResult;
}

// @todo only when valid
this.args.onSubmit?.(this.internalData);
}

@action
registerField(name: keyof DATA, field: FieldData<DATA>): void {
this.fields.set(name, field);
}

@action
unregisterField(name: keyof DATA): void {
this.fields.delete(name);
}

@action
set<KEY extends keyof DATA>(key: KEY, value: DATA[KEY]): void {
this.internalData[key] = value;
Expand Down
99 changes: 97 additions & 2 deletions test-app/tests/integration/components/headless-form-test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -686,7 +686,7 @@ module('Integration Component headless-form', function (hooks) {
);
});

test('form validation errors are exposed as field.errors on submit', async function (assert) {
test('validation errors are exposed as field.errors on submit', async function (assert) {
const data = { firstName: 'Foo', lastName: 'Smith' };
const validateCallback = ({ firstName }: { firstName: string }) =>
firstName === 'Foo'
Expand All @@ -699,7 +699,7 @@ module('Integration Component headless-form', function (hooks) {
},
],
}
: true;
: undefined;

await render(<template>
<HeadlessForm @data={{data}} @validate={{validateCallback}} as |form|>
Expand Down Expand Up @@ -747,6 +747,101 @@ module('Integration Component headless-form', function (hooks) {
assert.dom('[data-test-last-name-errors]').doesNotExist();
});
});

module('form.field @validation callback', function () {
test('validation callback is called on submit', async function (assert) {
const data = { firstName: 'Tony', lastName: 'Ward' };
const validateCallback = sinon.spy();

await render(<template>
<HeadlessForm @data={{data}} as |form|>
<form.field
@name="firstName"
@validate={{validateCallback}}
as |field|
>
<field.label>First Name</field.label>
<field.input data-test-first-name />
</form.field>
<form.field @name="lastName" as |field|>
<field.label>Last Name</field.label>
<field.input data-test-last-name />
</form.field>
<button type="submit" data-test-submit>Submit</button>
</HeadlessForm>
</template>);

await click('[data-test-submit]');

assert.true(
validateCallback.calledWith(data.firstName, 'firstName', data),
'@validate is called with form data'
);
});

test('validation errors are exposed as field.errors on submit', async function (assert) {
const data = { firstName: 'Foo', lastName: 'Smith' };
const validateCallback = (firstName: string) =>
firstName === 'Foo'
? [
{
type: 'custom',
value: firstName,
message: 'Foo is an invalid first name!',
},
]
: undefined;

await render(<template>
<HeadlessForm @data={{data}} as |form|>
<form.field
@name="firstName"
@validate={{validateCallback}}
as |field|
>
<field.label>First Name</field.label>
<field.input data-test-first-name />
{{#each field.errors as |e|}}
<div data-test-first-name-error>
<div data-test-error-type>
{{e.type}}
</div>
<div data-test-error-value>
{{e.value}}
</div>
<div data-test-error-message>
{{e.message}}
</div>
</div>
{{/each}}
</form.field>
<form.field @name="lastName" as |field|>
<field.label>Last Name</field.label>
<field.input data-test-last-name />
{{#if field.errors}}
<div data-test-last-name-errors />
{{/if}}
</form.field>
<button type="submit" data-test-submit>Submit</button>
</HeadlessForm>
</template>);

await click('[data-test-submit]');

assert.dom('[data-test-first-name-error]').exists({ count: 1 });
assert
.dom('[data-test-first-name-error] [data-test-error-type]')
.hasText('custom');
assert
.dom('[data-test-first-name-error] [data-test-error-value]')
.hasText('Foo');
assert
.dom('[data-test-first-name-error] [data-test-error-message]')
.hasText('Foo is an invalid first name!');

assert.dom('[data-test-last-name-errors]').doesNotExist();
});
});
});

module('Glint', function () {
Expand Down

0 comments on commit 2138e58

Please sign in to comment.