From 2ee44c1b05f527999bf3be28d79cda10364f390c Mon Sep 17 00:00:00 2001 From: Simon Ihmig Date: Fri, 27 Jan 2023 11:04:07 +0100 Subject: [PATCH 1/8] Validate fields on blur/change --- ember-headless-form/package.json | 1 + .../src/components/-private/types.ts | 8 +- .../src/components/headless-form.hbs | 11 +- .../src/components/headless-form.ts | 86 +++++- pnpm-lock.yaml | 22 +- test-app/app/templates/index.hbs | 7 +- .../headless-form-validation-test.gts | 252 ++++++++++++++---- 7 files changed, 317 insertions(+), 70 deletions(-) diff --git a/ember-headless-form/package.json b/ember-headless-form/package.json index 5a7456f9..adf28dad 100644 --- a/ember-headless-form/package.json +++ b/ember-headless-form/package.json @@ -57,6 +57,7 @@ "@types/ember__debug": "^4.0.0", "@types/ember__engine": "^4.0.0", "@types/ember__error": "^4.0.0", + "@types/ember__modifier": "^4.0.3", "@types/ember__object": "^4.0.0", "@types/ember__polyfills": "^4.0.0", "@types/ember__routing": "^4.0.0", diff --git a/ember-headless-form/src/components/-private/types.ts b/ember-headless-form/src/components/-private/types.ts index 32ac2266..5de01b16 100644 --- a/ember-headless-form/src/components/-private/types.ts +++ b/ember-headless-form/src/components/-private/types.ts @@ -55,7 +55,7 @@ export type FieldValidateCallback< * Internal structure to track used fields * @private */ -export interface FieldData< +export interface FieldRegistrationData< DATA extends FormData, KEY extends FormKey = FormKey > { @@ -69,7 +69,7 @@ export interface FieldData< export type RegisterFieldCallback< DATA extends FormData, KEY extends FormKey = FormKey -> = (name: KEY, field: FieldData) => void; +> = (name: KEY, field: FieldRegistrationData) => void; export type UnregisterFieldCallback< DATA extends FormData, @@ -79,6 +79,4 @@ export type UnregisterFieldCallback< /** * Mapper type to construct subset of objects, whose keys are only strings (and not number or symbol) */ -export type OnlyStringKeys = { - [P in Extract]: T[P]; -}; +export type OnlyStringKeys = Pick; diff --git a/ember-headless-form/src/components/headless-form.hbs b/ember-headless-form/src/components/headless-form.hbs index b7462ef5..2b9a8ddd 100644 --- a/ember-headless-form/src/components/headless-form.hbs +++ b/ember-headless-form/src/components/headless-form.hbs @@ -1,11 +1,18 @@ -
+{{! ignoring prettier here is need to *not* wrap the modifier usage below into a new line, making @glint-expect-error fail to work 🙈 }} +{{! prettier-ignore }} + {{yield (hash field=(component (ensure-safe-component this.FieldComponent) data=this.internalData set=this.set - errors=this.lastValidationResult + errors=this.visibleErrors registerField=this.registerField unregisterField=this.unregisterField ) diff --git a/ember-headless-form/src/components/headless-form.ts b/ember-headless-form/src/components/headless-form.ts index 324e4bcb..c3bcbad7 100644 --- a/ember-headless-form/src/components/headless-form.ts +++ b/ember-headless-form/src/components/headless-form.ts @@ -1,6 +1,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { assert } from '@ember/debug'; +import { on } from '@ember/modifier'; import { action } from '@ember/object'; import { TrackedObject } from 'tracked-built-ins'; @@ -10,11 +11,13 @@ import FieldComponent from './-private/field'; import type { HeadlessFormFieldComponentSignature } from './-private/field'; import type { ErrorRecord, - FieldData, + FieldRegistrationData, + FieldValidateCallback, FormData, FormKey, FormValidateCallback, UserData, + ValidationError, } from './-private/types'; import type { ComponentLike, WithBoundArgs } from '@glint/template'; @@ -37,7 +40,7 @@ export interface HeadlessFormComponentSignature { default: [ { field: WithBoundArgs< - typeof FieldComponent>, + typeof FieldComponent, 'data' | 'set' | 'errors' | 'registerField' | 'unregisterField' >; } @@ -45,19 +48,34 @@ export interface HeadlessFormComponentSignature { }; } +class FieldData< + DATA extends FormData, + KEY extends FormKey = FormKey +> { + constructor(fieldRegistration: FieldRegistrationData) { + this.validate = fieldRegistration.validate; + } + + @tracked validationEnabled = false; + + validate?: FieldValidateCallback; +} + export default class HeadlessFormComponent< DATA extends UserData > extends Component> { FieldComponent: ComponentLike> = FieldComponent; - internalData: FormData = new TrackedObject( - this.args.data ?? {} - ) as FormData; + // we cannot use (modifier "on") directly in the template due to https://github.com/emberjs/ember.js/issues/19869 + on = on; + + internalData: DATA = new TrackedObject(this.args.data ?? {}) as DATA; fields = new Map>, FieldData>>(); @tracked lastValidationResult?: ErrorRecord>; + @tracked showAllValidations = false; get validateOn(): ValidateOn { return this.args.validateOn ?? 'submit'; @@ -67,6 +85,16 @@ export default class HeadlessFormComponent< return this.args.revalidateOn ?? 'change'; } + get fieldValidationEvent(): 'focusout' | 'change' | undefined { + const { validateOn } = this; + + return validateOn === 'submit' + ? undefined + : validateOn === 'blur' + ? 'focusout' + : validateOn; + } + get hasValidationErrors(): boolean { // Only consider validation errors for which we actually have a field rendered return this.lastValidationResult @@ -106,11 +134,38 @@ export default class HeadlessFormComponent< return Object.keys(errors).length > 0 ? errors : undefined; } + get visibleErrors(): ErrorRecord> | undefined { + if (!this.lastValidationResult) { + return undefined; + } + + const visibleErrors: ErrorRecord> = {}; + + for (const [field, errors] of Object.entries(this.lastValidationResult) as [ + FormKey>, + ValidationError[FormKey>]>[] + ][]) { + if (this.showErrorsFor(field)) { + visibleErrors[field] = errors; + } + } + + return visibleErrors; + } + + showErrorsFor(field: FormKey>): boolean { + return ( + this.showAllValidations || + (this.fields.get(field)?.validationEnabled ?? false) + ); + } + @action async onSubmit(e: Event): Promise { e.preventDefault(); this.lastValidationResult = await this.validate(); + this.showAllValidations = true; if (!this.hasValidationErrors) { this.args.onSubmit?.(this.internalData); @@ -126,7 +181,7 @@ export default class HeadlessFormComponent< @action registerField( name: FormKey>, - field: FieldData> + field: FieldRegistrationData> ): void { assert( `You passed @name="${String( @@ -134,7 +189,7 @@ export default class HeadlessFormComponent< )}" to the form field, but this is already in use. Names of form fields must be unique!`, !this.fields.has(name) ); - this.fields.set(name, field); + this.fields.set(name, new FieldData(field)); } @action @@ -146,4 +201,21 @@ export default class HeadlessFormComponent< set>>(key: KEY, value: DATA[KEY]): void { this.internalData[key] = value; } + + @action + async handleFieldValidation(e: Event): Promise { + const { target } = e; + const { name } = target as HTMLInputElement; + + if (name) { + const field = this.fields.get(name as FormKey>); + + if (field) { + this.lastValidationResult = await this.validate(); + field.validationEnabled = true; + } + } else { + // @todo how to handle custom controls that don't emit focusout/change events from native form controls? + } + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d86fe8c3..a7e44acb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,7 @@ importers: '@types/ember__debug': ^4.0.0 '@types/ember__engine': ^4.0.0 '@types/ember__error': ^4.0.0 + '@types/ember__modifier': ^4.0.3 '@types/ember__object': ^4.0.0 '@types/ember__polyfills': ^4.0.0 '@types/ember__routing': ^4.0.0 @@ -90,6 +91,7 @@ importers: '@types/ember__debug': 4.0.3_@babel+core@7.20.12 '@types/ember__engine': 4.0.4_@babel+core@7.20.12 '@types/ember__error': 4.0.2 + '@types/ember__modifier': 4.0.3_@babel+core@7.20.12 '@types/ember__object': 4.0.5_@babel+core@7.20.12 '@types/ember__polyfills': 4.0.1 '@types/ember__routing': 4.0.12_@babel+core@7.20.12 @@ -2975,10 +2977,7 @@ packages: /@types/ember__controller/4.0.4: resolution: {integrity: sha512-+f0knTIJJkRX5xijeSI/n4FvLfhMFFxIxODyFFFFB483EryYuts3QzpTwU5D66WQ5rAbZvpPRXRMPTTCNJoUhg==} dependencies: - '@types/ember__object': 4.0.5 - transitivePeerDependencies: - - '@babel/core' - - supports-color + '@types/ember__object': 4.0.5_@babel+core@7.20.12 dev: true /@types/ember__controller/4.0.4_@babel+core@7.20.12: @@ -3051,16 +3050,23 @@ packages: - supports-color dev: true - /@types/ember__object/4.0.5: - resolution: {integrity: sha512-gXrywWBwoW7J9y9yJqoZ0m1qtiyMdrEi29cJdF1xI2qOnMqaZeuSCMYaPQMsyq52/YnVIG2EnGzo6eUD57J4Nw==} + /@types/ember__modifier/4.0.3_@babel+core@7.20.12: + resolution: {integrity: sha512-2Z4ty8OZNVO/UkypnVMoqdp57OxRd48dko3wYzYMbjhR7Wi2Gtd374dLlhoA6WXcWnEyrz7SUCot8zyBpBZwfg==} dependencies: - '@types/ember': 4.0.3 - '@types/rsvp': 4.0.4 + '@types/ember': 4.0.3_@babel+core@7.20.12 + '@types/ember__owner': 4.0.3 transitivePeerDependencies: - '@babel/core' - supports-color dev: true + /@types/ember__object/4.0.5: + resolution: {integrity: sha512-gXrywWBwoW7J9y9yJqoZ0m1qtiyMdrEi29cJdF1xI2qOnMqaZeuSCMYaPQMsyq52/YnVIG2EnGzo6eUD57J4Nw==} + dependencies: + '@types/ember': 4.0.3_@babel+core@7.20.12 + '@types/rsvp': 4.0.4 + dev: true + /@types/ember__object/4.0.5_@babel+core@7.20.12: resolution: {integrity: sha512-gXrywWBwoW7J9y9yJqoZ0m1qtiyMdrEi29cJdF1xI2qOnMqaZeuSCMYaPQMsyq52/YnVIG2EnGzo6eUD57J4Nw==} dependencies: diff --git a/test-app/app/templates/index.hbs b/test-app/app/templates/index.hbs index bc0e5162..192f9032 100644 --- a/test-app/app/templates/index.hbs +++ b/test-app/app/templates/index.hbs @@ -1,4 +1,9 @@ - + Name diff --git a/test-app/tests/integration/components/headless-form-validation-test.gts b/test-app/tests/integration/components/headless-form-validation-test.gts index 46fc3d71..381c0430 100644 --- a/test-app/tests/integration/components/headless-form-validation-test.gts +++ b/test-app/tests/integration/components/headless-form-validation-test.gts @@ -2,7 +2,7 @@ /* eslint-disable simple-import-sort/imports,padding-line-between-statements,decorator-position/decorator-position -- Can't fix these manually, without --fix working in .gts */ import { tracked } from '@glimmer/tracking'; -import { click, fillIn, render, rerender } from '@ember/test-helpers'; +import { blur, click, fillIn, render, rerender } from '@ember/test-helpers'; import { module, test } from 'qunit'; import HeadlessForm from 'ember-headless-form/components/headless-form'; @@ -10,53 +10,69 @@ import sinon from 'sinon'; import { setupRenderingTest } from 'test-app/tests/helpers'; import type { RenderingTestContext } from '@ember/test-helpers'; +import type { + FormValidateCallback, + FieldValidateCallback, + ErrorRecord, + ValidationError, +} from 'ember-headless-form/components/-private/types'; module('Integration Component HeadlessForm > Validation', function (hooks) { setupRenderingTest(hooks); - interface FormData { + interface TestFormData { firstName?: string; lastName?: string; } - const validateFormCallbackSync = ({ firstName }: FormData) => { - const firstNameErrors = []; - if (firstName == undefined) { - firstNameErrors.push({ - type: 'required', - value: firstName, - message: 'firstName is required!', - }); - } else { - if (firstName.charAt(0).toUpperCase() !== firstName.charAt(0)) { - firstNameErrors.push({ - type: 'uppercase', - value: firstName, - message: 'firstName must be upper case!', + const validateFormCallbackSync: FormValidateCallback = ( + data + ) => { + const errorRecord: ErrorRecord = {}; + + for (const [field, value] of Object.entries(data)) { + const errors: ValidationError[] = []; + if (value == undefined) { + errors.push({ + type: 'required', + value, + message: `${field} is required!`, }); + } else { + if (value.charAt(0).toUpperCase() !== value.charAt(0)) { + errors.push({ + type: 'uppercase', + value, + message: `${field} must be upper case!`, + }); + } + + if (value.toLowerCase() === 'foo') { + errors.push({ + type: 'notFoo', + value, + message: `Foo is an invalid ${field}!`, + }); + } } - if (firstName.toLowerCase() === 'foo') { - firstNameErrors.push({ - type: 'notFoo', - value: firstName, - message: 'Foo is an invalid firstName!', - }); + if (errors.length > 0) { + errorRecord[field as keyof TestFormData] = errors; } } - return firstNameErrors.length > 0 - ? { firstName: firstNameErrors } - : undefined; + return errorRecord; }; - const validateFormCallbackAsync = async (data: FormData) => { + const validateFormCallbackAsync: FormValidateCallback = async ( + data + ) => { return await validateFormCallbackSync(data); }; - const validateFieldCallbackSync = ( - value: string | undefined, - field: string + const validateFieldCallbackSync: FieldValidateCallback = ( + value, + field ) => { const errors = []; if (value == undefined) { @@ -86,11 +102,10 @@ module('Integration Component HeadlessForm > Validation', function (hooks) { return errors.length > 0 ? errors : undefined; }; - const validateFieldCallbackAsync = async ( - value: string | undefined, - field: string - ) => { - return await validateFieldCallbackSync(value, field); + const validateFieldCallbackAsync: FieldValidateCallback< + TestFormData + > = async (value, field, data) => { + return await validateFieldCallbackSync(value, field, data); }; module('form @validation callback', function () { @@ -100,7 +115,7 @@ module('Integration Component HeadlessForm > Validation', function (hooks) { ].forEach(({ testType, validateCallback }) => module(testType, function () { test('validation callback is called on submit', async function (assert) { - const data: FormData = { firstName: 'Tony', lastName: 'Ward' }; + const data: TestFormData = { firstName: 'Tony', lastName: 'Ward' }; const validateCallback = sinon.spy(); await render(