fix(forms): allow custom controls to require pending input

* Allow custom controls to make `pending` a required input
* Refactor test for `pending` input to be consistent with other control
  properties
* Test that `pending` inputs are reset when the field binding changes

(cherry picked from commit 1a4c3eb1d0)
This commit is contained in:
Leon Senft 2025-12-16 14:56:40 -08:00 committed by Kristiyan Kostadinov
parent 79d16919a5
commit ec110f170b
2 changed files with 92 additions and 43 deletions

View file

@ -46,6 +46,7 @@ const formControlInputFields = [
'hidden',
'invalid',
'name',
'pending',
'readonly',
'touched',
'max',

View file

@ -545,6 +545,97 @@ describe('field directive', () => {
});
});
describe('pending', () => {
it('should bind to custom control', async () => {
const {promise, resolve} = promiseWithResolvers<ValidationError[]>();
@Component({
selector: 'custom-control',
template: '<input #i [value]="value()" (input)="value.set(i.value)" />',
})
class CustomControl implements FormValueControl<string> {
readonly value = model.required<string>();
readonly pending = input.required<boolean>();
}
@Component({
template: ` <custom-control [field]="f" /> `,
imports: [CustomControl, Field],
})
class TestCmp {
readonly data = signal('test');
readonly f = form(this.data, (p) => {
validateAsync(p, {
params: () => [],
factory: (params) =>
resource({
params,
loader: () => promise,
}),
onSuccess: (results) => results,
onError: () => null,
});
});
readonly customControl = viewChild.required(CustomControl);
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const comp = fixture.componentInstance;
expect(comp.customControl().pending()).toBe(true);
resolve([]);
await promise;
await fixture.whenStable();
expect(comp.customControl().pending()).toBe(false);
});
it('should be reset when field changes on custom control', async () => {
const {promise, resolve} = promiseWithResolvers<ValidationError[]>();
@Component({selector: 'custom-control', template: ``})
class CustomControl implements FormValueControl<string> {
readonly value = model.required<string>();
readonly pending = input.required<boolean>();
}
@Component({
imports: [Field, CustomControl],
template: `<custom-control [field]="field()" />`,
})
class TestCmp {
readonly f = form(signal({x: '', y: ''}), (p) => {
validateAsync(p.x, {
params: () => [],
factory: (params) =>
resource({
params,
loader: () => promise,
}),
onSuccess: (results) => results,
onError: () => null,
});
});
readonly field = signal(this.f.x);
readonly customControl = viewChild.required(CustomControl);
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const component = fixture.componentInstance;
expect(component.customControl().pending()).toBe(true);
act(() => component.field.set(component.f.y));
expect(component.customControl().pending()).toBe(false);
resolve([]);
await promise;
await fixture.whenStable();
expect(component.customControl().pending()).toBe(false);
});
});
describe('readonly', () => {
it('should bind to native control', () => {
@Component({
@ -2184,49 +2275,6 @@ describe('field directive', () => {
});
});
it('should synchronize pending status', async () => {
const {promise, resolve} = promiseWithResolvers<ValidationError[]>();
@Component({
selector: 'my-input',
template: '<input #i [value]="value()" (input)="value.set(i.value)" />',
})
class CustomInput implements FormValueControl<string> {
value = model('');
pending = input(false);
}
@Component({
template: ` <my-input [field]="f" /> `,
imports: [CustomInput, Field],
})
class PendingTestCmp {
myInput = viewChild.required<CustomInput>(CustomInput);
data = signal('test');
f = form(this.data, (p) => {
validateAsync(p, {
params: () => [],
factory: (params) =>
resource({
params,
loader: () => promise,
}),
onSuccess: (results) => results,
onError: () => null,
});
});
}
const fix = act(() => TestBed.createComponent(PendingTestCmp));
expect(fix.componentInstance.myInput().pending()).toBe(true);
resolve([]);
await promise;
await fix.whenStable();
expect(fix.componentInstance.myInput().pending()).toBe(false);
});
it(`should mark field as touched on native control 'blur' event`, () => {
@Component({
imports: [Field],