-
Notifications
You must be signed in to change notification settings - Fork 9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support for @validateOn
/@revalidateOn
options
#25
Changes from all commits
2ee44c1
f7a640c
89ed939
f25e8a2
7aca25f
cf739d7
b69caff
d1a632a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,9 @@ | ||
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 { waitFor } from '@ember/test-waiters'; | ||
|
||
import { TrackedObject } from 'tracked-built-ins'; | ||
|
||
|
@@ -10,11 +12,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,28 +41,62 @@ export interface HeadlessFormComponentSignature<DATA extends UserData> { | |
default: [ | ||
{ | ||
field: WithBoundArgs< | ||
typeof FieldComponent<FormData<DATA>>, | ||
typeof FieldComponent<DATA>, | ||
'data' | 'set' | 'errors' | 'registerField' | 'unregisterField' | ||
>; | ||
} | ||
]; | ||
}; | ||
} | ||
|
||
/** | ||
* This internal data structure maintains information about each field that is registered to the form by `registerField`. | ||
*/ | ||
class FieldData< | ||
DATA extends FormData, | ||
KEY extends FormKey<DATA> = FormKey<DATA> | ||
> { | ||
constructor(fieldRegistration: FieldRegistrationData<DATA, KEY>) { | ||
this.validate = fieldRegistration.validate; | ||
} | ||
|
||
/** | ||
* tracked state that enabled a dynamic validation of a field *before* the whole form is submitted, e.g. by `@validateOn="blur" and the blur event being triggered for that particular field. | ||
*/ | ||
@tracked validationEnabled = false; | ||
|
||
/** | ||
* The *field* level validation callback passed to the field as in `<form.field @name="foo" @validate={{this.validateCallback}}>` | ||
*/ | ||
validate?: FieldValidateCallback<DATA, KEY>; | ||
} | ||
|
||
export default class HeadlessFormComponent< | ||
DATA extends UserData | ||
> extends Component<HeadlessFormComponentSignature<DATA>> { | ||
FieldComponent: ComponentLike<HeadlessFormFieldComponentSignature<DATA>> = | ||
FieldComponent; | ||
|
||
internalData: FormData<DATA> = new TrackedObject( | ||
this.args.data ?? {} | ||
) as FormData<DATA>; | ||
// we cannot use (modifier "on") directly in the template due to https://github.com/emberjs/ember.js/issues/19869 | ||
on = on; | ||
|
||
/** | ||
* A copy of the passed `@data` stored internally, which is only passed back to the component consumer after a (successful) form submission. | ||
*/ | ||
internalData: DATA = new TrackedObject(this.args.data ?? {}) as DATA; | ||
|
||
fields = new Map<FormKey<FormData<DATA>>, FieldData<FormData<DATA>>>(); | ||
|
||
/** | ||
* The last result of calling `this.validate()`. | ||
*/ | ||
@tracked lastValidationResult?: ErrorRecord<FormData<DATA>>; | ||
|
||
/** | ||
* When this is set to true by submitting the form, eventual validation errors are show for *all* field, regardless of their individual dynamic validation status in `FieldData#validationEnabled` | ||
*/ | ||
@tracked showAllValidations = false; | ||
|
||
get validateOn(): ValidateOn { | ||
return this.args.validateOn ?? 'submit'; | ||
} | ||
|
@@ -67,6 +105,43 @@ export default class HeadlessFormComponent< | |
return this.args.revalidateOn ?? 'change'; | ||
} | ||
|
||
/** | ||
* Return the event type that will be listened on for dynamic validation (i.e. *before* submitting) | ||
*/ | ||
get fieldValidationEvent(): 'focusout' | 'change' | undefined { | ||
const { validateOn } = this; | ||
|
||
return validateOn === 'submit' | ||
? // no need for dynamic validation, as validation always happens on submit | ||
undefined | ||
: // our component API expects "blur", but the actual blur event does not bubble up, so we use focusout internally instead | ||
simonihmig marked this conversation as resolved.
Show resolved
Hide resolved
|
||
validateOn === 'blur' | ||
? 'focusout' | ||
: validateOn; | ||
} | ||
|
||
/** | ||
* Return the event type that will be listened on for dynamic *re*validation, i.e. updating the validation status of a field that has been previously marked as invalid | ||
*/ | ||
get fieldRevalidationEvent(): 'focusout' | 'change' | undefined { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: I could use some JSDOC here to remind us when this getter gets triggered and why this gets triggered instead of triggering There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added, and a bunch of more JSDoc annotations along that! This was good to call out, thank you! Indeed there are different things playing together here, which were clear to me as I was working on the things directly for many hours, but which might not be obvious on a review or when working in the codebase and not being super familiar with it. So, yeah, I must remind myself to explain things better here! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These comments are super helpful, thank you! |
||
const { validateOn, revalidateOn } = this; | ||
|
||
return revalidateOn === 'submit' | ||
? // no need for dynamic validation, as validation always happens on submit | ||
undefined | ||
: // when validation happens more frequently than revalidation, then we can ignore revalidation, because the validation handler will already cover us | ||
validateOn === 'change' || | ||
(validateOn === 'blur' && revalidateOn === 'blur') | ||
? undefined | ||
: // our component API expects "blur", but the actual blur event does not bubble up, so we use focusout internally instead | ||
revalidateOn === 'blur' | ||
? 'focusout' | ||
: revalidateOn; | ||
} | ||
|
||
/** | ||
* Return true if validation has happened (by submitting or by an `@validateOn` event being triggered) and at least one field is invalid | ||
*/ | ||
get hasValidationErrors(): boolean { | ||
// Only consider validation errors for which we actually have a field rendered | ||
return this.lastValidationResult | ||
|
@@ -76,6 +151,10 @@ export default class HeadlessFormComponent< | |
: false; | ||
} | ||
|
||
/** | ||
* Call the passed validation callbacks, defined both on the whole form as well as on field level, and return the merged result for all fields. | ||
*/ | ||
@waitFor | ||
async validate(): Promise<ErrorRecord<FormData<DATA>> | undefined> { | ||
let errors: ErrorRecord<FormData<DATA>> | undefined = undefined; | ||
|
||
|
@@ -106,11 +185,46 @@ export default class HeadlessFormComponent< | |
return Object.keys(errors).length > 0 ? errors : undefined; | ||
} | ||
|
||
/** | ||
* Return a mapping of field to validation errors, for all fields that are invalid *and* for which validation errors should be visible. | ||
* Validation errors will be visible for a certain field, if validation errors for *all* fields are visible, which is the case when trying to submit the form, | ||
* or when that field has triggered the event given by `@validateOn` for showing validation errors before submitting, e.g. on blur. | ||
*/ | ||
get visibleErrors(): ErrorRecord<FormData<DATA>> | undefined { | ||
if (!this.lastValidationResult) { | ||
return undefined; | ||
} | ||
|
||
const visibleErrors: ErrorRecord<FormData<DATA>> = {}; | ||
|
||
for (const [field, errors] of Object.entries(this.lastValidationResult) as [ | ||
FormKey<FormData<DATA>>, | ||
ValidationError<FormData<DATA>[FormKey<FormData<DATA>>]>[] | ||
][]) { | ||
if (this.showErrorsFor(field)) { | ||
visibleErrors[field] = errors; | ||
} | ||
} | ||
|
||
return visibleErrors; | ||
} | ||
|
||
/** | ||
* Given a field name, return if eventual errors for the field should be visible. See `visibleErrors` for further details. | ||
*/ | ||
showErrorsFor(field: FormKey<FormData<DATA>>): boolean { | ||
return ( | ||
this.showAllValidations || | ||
(this.fields.get(field)?.validationEnabled ?? false) | ||
); | ||
} | ||
|
||
@action | ||
async onSubmit(e: Event): Promise<void> { | ||
e.preventDefault(); | ||
|
||
this.lastValidationResult = await this.validate(); | ||
this.showAllValidations = true; | ||
|
||
if (!this.hasValidationErrors) { | ||
this.args.onSubmit?.(this.internalData); | ||
|
@@ -126,15 +240,15 @@ export default class HeadlessFormComponent< | |
@action | ||
registerField( | ||
name: FormKey<FormData<DATA>>, | ||
field: FieldData<FormData<DATA>> | ||
field: FieldRegistrationData<FormData<DATA>> | ||
): void { | ||
assert( | ||
`You passed @name="${String( | ||
name | ||
)}" 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 +260,47 @@ export default class HeadlessFormComponent< | |
set<KEY extends FormKey<FormData<DATA>>>(key: KEY, value: DATA[KEY]): void { | ||
this.internalData[key] = value; | ||
} | ||
|
||
/** | ||
* Handle the `@validateOn` event for a certain field, e.g. "blur". | ||
* Associating the event with a field is done by looking at the event target's `name` attribute, which must match one of the `<form.field @name="...">` invocations by the user's template. | ||
* Validation will be triggered, and the particular field will be marked to show eventual validation errors. | ||
*/ | ||
@action | ||
async handleFieldValidation(e: Event): Promise<void> { | ||
const { target } = e; | ||
const { name } = target as HTMLInputElement; | ||
|
||
if (name) { | ||
const field = this.fields.get(name as FormKey<FormData<DATA>>); | ||
|
||
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? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The DX should be improved here by emitting a warning, by a future PR (see PR description) that provides additional primitives for better custom controls support, and mentions these primitives in the warning. See keeping this as-is for now... |
||
} | ||
} | ||
|
||
/** | ||
* Handle the `@revalidateOn` event for a certain field, e.g. "blur". | ||
* Associating the event with a field is done by looking at the event target's `name` attribute, which must match one of the `<form.field @name="...">` invocations by the user's template. | ||
* When a field has been already marked to show validation errors by `@validateOn`, then for revalidation another validation will be triggered. | ||
* | ||
* The use case here is to allow this to happen more frequently than the initial validation, e.g. `@validateOn="blur" @revalidateOn="change"`. | ||
*/ | ||
@action | ||
async handleFieldRevalidation(e: Event): Promise<void> { | ||
const { target } = e; | ||
const { name } = target as HTMLInputElement; | ||
|
||
if (name) { | ||
if (this.showErrorsFor(name as FormKey<FormData<DATA>>)) { | ||
this.lastValidationResult = await this.validate(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. interacting with I keep meaning to work on this lint but... priorities 🙃 be sure to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch! So I can certainly add But I'd first like to understand why this could be a problem here, i.e. what is unsafe about it? So when the async action still has a pointer to So it exists, but it could be in a "destroyed" state (as far as Ember is concerned). In classic Ember, a call to Compare that to this comment here on the linting issue... Loosely related: should we wrap the async methods (that call potentially async validators) with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
yeah, that's a good way to solve this whole class of problem.
you raise some good points, and I'm going to have to do some concrete research with shareable examples before continuing on with the lint 😅 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Oh right, that can be problematic indeed! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So I added Regarding the In other words: is this good to merge? @NullVoxPopuli @nicolechung There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we're good to merge. isDetroy{ing,ed}, I think I want to iterate on the lint, so we can have a definite error case to match on, and then provide an autofix as well |
||
} | ||
} else { | ||
// @todo how to handle custom controls that don't emit focusout/change events from native form controls? | ||
} | ||
} | ||
} |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What IS
FieldRegistrationData
...like why did this get renamed? I think I don't have the clearest mental model yet of everything going on and how it fits together.Maybe I could use some JSDoc for this interface and the one below.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was splitting this up into two concerns, that's why I renamed this.
So there is information passed form the field component to the parent form component via the
registerField
action. Basically the field register itself with its parent form, so the form has data available that the dev passed to the field. Currently this is just the field level validation callback, which the form needs to have available. This is what theFieldRegistrationData
contains.Then in this PR there is a new
FieldData
class being added, which gets created when registering the field, so it usesFieldRegistrationData
, but it also contains more than that (thevalidationEnabled
tracked property), which the field does not need to care about.So that's why we have these two different but related concepts now.
As mentioned earlier, I added a bunch of JSDoc comments, also here, so hopefully that becomes more clear now!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for this!