-
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 @validateOn="blur/change"
#24
Changes from all commits
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,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,27 +40,42 @@ export interface HeadlessFormComponentSignature<DATA extends UserData> { | |
default: [ | ||
{ | ||
field: WithBoundArgs< | ||
typeof FieldComponent<FormData<DATA>>, | ||
typeof FieldComponent<DATA>, | ||
'data' | 'set' | 'errors' | 'registerField' | 'unregisterField' | ||
>; | ||
} | ||
]; | ||
}; | ||
} | ||
|
||
class FieldData< | ||
DATA extends FormData, | ||
KEY extends FormKey<DATA> = FormKey<DATA> | ||
> { | ||
constructor(fieldRegistration: FieldRegistrationData<DATA, KEY>) { | ||
this.validate = fieldRegistration.validate; | ||
} | ||
|
||
@tracked validationEnabled = false; | ||
|
||
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; | ||
|
||
internalData: DATA = new TrackedObject(this.args.data ?? {}) as DATA; | ||
|
||
fields = new Map<FormKey<FormData<DATA>>, FieldData<FormData<DATA>>>(); | ||
|
||
@tracked lastValidationResult?: ErrorRecord<FormData<DATA>>; | ||
@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<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; | ||
} | ||
|
||
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 +181,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 +201,21 @@ export default class HeadlessFormComponent< | |
set<KEY extends FormKey<FormData<DATA>>>(key: KEY, value: DATA[KEY]): void { | ||
this.internalData[key] = value; | ||
} | ||
|
||
@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. And yeah, that's the downside of that approach 😬 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. imo, "To be a custom control, it must behave like a control, including having 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, I agree. We definitely will want validation to trigger for controls like 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. Ok, but the question is still whether to do 1. or 3. then. For 1. to work, the event must have But what about the example with the 3-select date picker. It will receive For 3., 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. I think the caveat for 1 is fine. 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. Not sure tbh how the demo would relate to this addon, but I'll try it this way: The current form.field template would have a wrapping div like so: <div {{on "focusout (fn @triggerValidationFor @name)}} ...attributes>
{{! here is what is currently in the template }}
</div>
From the caller's site: <HeadlessForm as |form|>
<form.field @name="date" as |field|> <-- this would now be a div that captures the event, and *knows* what @name it belongs to!
<field.label>Date:</field.label>
<CustomDatePickerComponentConsistingOfThreeSelectsWithUnknownNames
@value={{field.value}}
@onUpdate={{field.setValue}}
/>
<field.errors/>
</form.field>
</HeadlessForm> So here, as long as the custom component triggers a Btw, 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. maybe this div is too much to assume. I imagine this may be something that a custom-control author would want to opt in to. as in: <HeadlessForm as |form|>
<form.field @name="date" as |field|>
<field.captureEvents @events={{"focusout"}}> or @events={{array "focusout" "change"}} ? idk
^-- this would now be a div that captures the event, and *knows* what @name it belongs to!
<field.label>Date:</field.label>
<CustomDatePickerComponentConsistingOfThreeSelectsWithUnknownNames
@value={{field.value}}
@onUpdate={{field.setValue}}
/>
</field.captureEvents>
<field.errors/>
</form
</HeadlessForm> But maybe that begins to defeat the purpose of the headless form?
would there be a way to not add those event bindings if no validator is passed? (conditionally apply the on modifier?)
it was a concern! this is good news.
most excellent. I think I've now changed my opinion from being all in on option 1, to at least being a bit 50/50 on options 1 and 3 now. I do have a concern though, so, what would happen if a field has multiple focusables? (this may sway me back to option 1? idk!) <HeadlessForm as |form|>
<form.field @name="date" as |field|>
<field.label>
Date:
<button {{on 'click' (fn this.helpAbout "date")}}>help</button>
^ - would we end up validating on events from here?
</field.label>
<CustomDatePickerComponentConsistingOfThreeSelectsWithUnknownNames
@value={{field.value}}
@onUpdate={{field.setValue}}
/>
<field.errors/>
</form
</HeadlessForm> |
||
} | ||
} | ||
} |
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.
Here is the name matching logic of variant 1 happening!