From 55fc677cef4409302bc474ff316d392097a034e7 Mon Sep 17 00:00:00 2001 From: cexbrayat Date: Wed, 3 Dec 2025 17:18:44 +0100 Subject: [PATCH] fix(forms): add signals for dirty, hidden, and pending states in custom controls FormUiControl states that hidden, pending and dirty will be bind in custom controls, but this is currently not the case. Fixes #65575 (cherry picked from commit 9fe95665813e24544d00dcf463658067da745ac2) --- .../core/src/render3/instructions/control.ts | 3 + .../core/src/render3/interfaces/control.ts | 15 +++ .../signals/test/web/field_directive.spec.ts | 105 ++++++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/packages/core/src/render3/instructions/control.ts b/packages/core/src/render3/instructions/control.ts index 29993d2c46b..4bdf764397d 100644 --- a/packages/core/src/render3/instructions/control.ts +++ b/packages/core/src/render3/instructions/control.ts @@ -930,7 +930,9 @@ type ControlBindings = { const CONTROL_BINDING_NAMES = { disabled: 'disabled', disabledReasons: 'disabledReasons', + dirty: 'dirty', errors: 'errors', + hidden: 'hidden', invalid: 'invalid', max: 'max', maxLength: 'maxLength', @@ -938,6 +940,7 @@ const CONTROL_BINDING_NAMES = { minLength: 'minLength', name: 'name', pattern: 'pattern', + pending: 'pending', readonly: 'readonly', required: 'required', touched: 'touched', diff --git a/packages/core/src/render3/interfaces/control.ts b/packages/core/src/render3/interfaces/control.ts index e5afd496557..3f94682b446 100644 --- a/packages/core/src/render3/interfaces/control.ts +++ b/packages/core/src/render3/interfaces/control.ts @@ -143,6 +143,21 @@ export interface ɵFieldState { */ readonly touched: Signal; + /** + * A signal indicating whether the field value has been changed by user. + */ + readonly dirty: Signal; + + /** + * A signal indicating whether the field is hidden. + */ + readonly hidden: Signal; + + /** + * A signal indicating whether there are any validators still pending for this field. + */ + readonly pending: Signal; + /** * A writable signal containing the value for this field. * diff --git a/packages/forms/signals/test/web/field_directive.spec.ts b/packages/forms/signals/test/web/field_directive.spec.ts index 9da08567ddd..e57b1e04bd4 100644 --- a/packages/forms/signals/test/web/field_directive.spec.ts +++ b/packages/forms/signals/test/web/field_directive.spec.ts @@ -18,6 +18,7 @@ import { inputBinding, model, numberAttribute, + resource, signal, viewChild, viewChildren, @@ -39,6 +40,7 @@ import { provideSignalFormsConfig, readonly, required, + validateAsync, type DisabledReason, type FieldTree, type FormCheckboxControl, @@ -1886,6 +1888,109 @@ describe('field directive', () => { expect(comp.myInput().invalid()).toBe(false); }); + it('should synchronize hidden status', () => { + @Component({ + selector: 'my-input', + template: '', + }) + class CustomInput implements FormValueControl { + value = model(''); + hidden = input(false); + } + + @Component({ + template: ` + + `, + imports: [CustomInput, Field], + }) + class HiddenTestCmp { + myInput = viewChild.required(CustomInput); + data = signal(''); + f = form(this.data, (p) => { + hidden(p, ({value}) => value() === ''); + }); + } + + const comp = act(() => TestBed.createComponent(HiddenTestCmp)).componentInstance; + expect(comp.myInput().hidden()).toBe(true); + act(() => comp.f().value.set('visible')); + expect(comp.myInput().hidden()).toBe(false); + }); + + it('should synchronize dirty status', () => { + @Component({ + selector: 'my-input', + template: '', + }) + class CustomInput implements FormValueControl { + value = model(''); + dirty = input(false); + } + + @Component({ + template: ` + + `, + imports: [CustomInput, Field], + }) + class DirtyTestCmp { + myInput = viewChild.required(CustomInput); + data = signal(''); + f = form(this.data); + } + + const comp = act(() => TestBed.createComponent(DirtyTestCmp)).componentInstance; + expect(comp.myInput().dirty()).toBe(false); + act(() => comp.f().markAsDirty()); + expect(comp.myInput().dirty()).toBe(true); + }); + + it('should synchronize pending status', async () => { + const {promise, resolve} = promiseWithResolvers(); + + @Component({ + selector: 'my-input', + template: '', + }) + class CustomInput implements FormValueControl { + value = model(''); + pending = input(false); + } + + @Component({ + template: ` + + `, + imports: [CustomInput, Field], + }) + class PendingTestCmp { + myInput = viewChild.required(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],