Skip to content

Commit 9fe9566

Browse files
cexbrayatalxhub
authored andcommitted
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 angular#65575
1 parent 96b79fc commit 9fe9566

3 files changed

Lines changed: 123 additions & 0 deletions

File tree

packages/core/src/render3/instructions/control.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,14 +930,17 @@ type ControlBindings = {
930930
const CONTROL_BINDING_NAMES = {
931931
disabled: 'disabled',
932932
disabledReasons: 'disabledReasons',
933+
dirty: 'dirty',
933934
errors: 'errors',
935+
hidden: 'hidden',
934936
invalid: 'invalid',
935937
max: 'max',
936938
maxLength: 'maxLength',
937939
min: 'min',
938940
minLength: 'minLength',
939941
name: 'name',
940942
pattern: 'pattern',
943+
pending: 'pending',
941944
readonly: 'readonly',
942945
required: 'required',
943946
touched: 'touched',

packages/core/src/render3/interfaces/control.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,21 @@ export interface ɵFieldState<T> {
143143
*/
144144
readonly touched: Signal<boolean>;
145145

146+
/**
147+
* A signal indicating whether the field value has been changed by user.
148+
*/
149+
readonly dirty: Signal<boolean>;
150+
151+
/**
152+
* A signal indicating whether the field is hidden.
153+
*/
154+
readonly hidden: Signal<boolean>;
155+
156+
/**
157+
* A signal indicating whether there are any validators still pending for this field.
158+
*/
159+
readonly pending: Signal<boolean>;
160+
146161
/**
147162
* A writable signal containing the value for this field.
148163
*

packages/forms/signals/test/web/field_directive.spec.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
inputBinding,
1919
model,
2020
numberAttribute,
21+
resource,
2122
signal,
2223
viewChild,
2324
viewChildren,
@@ -39,6 +40,7 @@ import {
3940
provideSignalFormsConfig,
4041
readonly,
4142
required,
43+
validateAsync,
4244
type DisabledReason,
4345
type FieldTree,
4446
type FormCheckboxControl,
@@ -1886,6 +1888,109 @@ describe('field directive', () => {
18861888
expect(comp.myInput().invalid()).toBe(false);
18871889
});
18881890

1891+
it('should synchronize hidden status', () => {
1892+
@Component({
1893+
selector: 'my-input',
1894+
template: '<input #i [value]="value()" (input)="value.set(i.value)" />',
1895+
})
1896+
class CustomInput implements FormValueControl<string> {
1897+
value = model('');
1898+
hidden = input(false);
1899+
}
1900+
1901+
@Component({
1902+
template: `
1903+
<my-input [field]="f" />
1904+
`,
1905+
imports: [CustomInput, Field],
1906+
})
1907+
class HiddenTestCmp {
1908+
myInput = viewChild.required<CustomInput>(CustomInput);
1909+
data = signal('');
1910+
f = form(this.data, (p) => {
1911+
hidden(p, ({value}) => value() === '');
1912+
});
1913+
}
1914+
1915+
const comp = act(() => TestBed.createComponent(HiddenTestCmp)).componentInstance;
1916+
expect(comp.myInput().hidden()).toBe(true);
1917+
act(() => comp.f().value.set('visible'));
1918+
expect(comp.myInput().hidden()).toBe(false);
1919+
});
1920+
1921+
it('should synchronize dirty status', () => {
1922+
@Component({
1923+
selector: 'my-input',
1924+
template: '<input #i [value]="value()" (input)="value.set(i.value)" />',
1925+
})
1926+
class CustomInput implements FormValueControl<string> {
1927+
value = model('');
1928+
dirty = input(false);
1929+
}
1930+
1931+
@Component({
1932+
template: `
1933+
<my-input [field]="f" />
1934+
`,
1935+
imports: [CustomInput, Field],
1936+
})
1937+
class DirtyTestCmp {
1938+
myInput = viewChild.required<CustomInput>(CustomInput);
1939+
data = signal('');
1940+
f = form(this.data);
1941+
}
1942+
1943+
const comp = act(() => TestBed.createComponent(DirtyTestCmp)).componentInstance;
1944+
expect(comp.myInput().dirty()).toBe(false);
1945+
act(() => comp.f().markAsDirty());
1946+
expect(comp.myInput().dirty()).toBe(true);
1947+
});
1948+
1949+
it('should synchronize pending status', async () => {
1950+
const {promise, resolve} = promiseWithResolvers<ValidationError[]>();
1951+
1952+
@Component({
1953+
selector: 'my-input',
1954+
template: '<input #i [value]="value()" (input)="value.set(i.value)" />',
1955+
})
1956+
class CustomInput implements FormValueControl<string> {
1957+
value = model('');
1958+
pending = input(false);
1959+
}
1960+
1961+
@Component({
1962+
template: `
1963+
<my-input [field]="f" />
1964+
`,
1965+
imports: [CustomInput, Field],
1966+
})
1967+
class PendingTestCmp {
1968+
myInput = viewChild.required<CustomInput>(CustomInput);
1969+
data = signal('test');
1970+
f = form(this.data, (p) => {
1971+
validateAsync(p, {
1972+
params: () => [],
1973+
factory: (params) =>
1974+
resource({
1975+
params,
1976+
loader: () => promise,
1977+
}),
1978+
onSuccess: (results) => results,
1979+
onError: () => null,
1980+
});
1981+
});
1982+
}
1983+
1984+
const fix = act(() => TestBed.createComponent(PendingTestCmp));
1985+
1986+
expect(fix.componentInstance.myInput().pending()).toBe(true);
1987+
1988+
resolve([]);
1989+
await promise;
1990+
await fix.whenStable();
1991+
expect(fix.componentInstance.myInput().pending()).toBe(false);
1992+
});
1993+
18891994
it(`should mark field as touched on native control 'blur' event`, () => {
18901995
@Component({
18911996
imports: [Field],

0 commit comments

Comments
 (0)