Skip to content

Commit f4d1017

Browse files
leonsenftAndrewKushnir
authored andcommitted
fix(forms): test that common field states are propagated to controls (angular#63884)
Fix several typos caught by the added test cases: * `disabled` attribute for native controls * `readonly` property for custom controls Note that the `name` test cases have been marked `pending()` due to angular#63882. PR Close angular#63884
1 parent e873c22 commit f4d1017

File tree

2 files changed

+249
-2
lines changed

2 files changed

+249
-2
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ function updateCustomControl(
336336
maybeWriteToDirectiveInput(componentDef, component, 'minLength', state.minLength);
337337
maybeWriteToDirectiveInput(componentDef, component, 'name', state.name);
338338
maybeWriteToDirectiveInput(componentDef, component, 'pattern', state.pattern);
339-
maybeWriteToDirectiveInput(componentDef, component, 'readOnly', state.readonly);
339+
maybeWriteToDirectiveInput(componentDef, component, 'readonly', state.readonly);
340340
maybeWriteToDirectiveInput(componentDef, component, 'required', state.required);
341341
maybeWriteToDirectiveInput(componentDef, component, 'touched', state.touched);
342342
}
@@ -376,7 +376,7 @@ function updateNativeControl(tNode: TNode, lView: LView, control: ɵControl<unkn
376376
// * check if bindings changed before writing.
377377
setNativeControlValue(input, state.value());
378378
renderer.setAttribute(input, 'name', state.name());
379-
setBooleanAttribute(renderer, input, 'disable', state.disabled());
379+
setBooleanAttribute(renderer, input, 'disabled', state.disabled());
380380
setBooleanAttribute(renderer, input, 'readonly', state.readonly());
381381
setBooleanAttribute(renderer, input, 'required', state.required());
382382

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

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
provideZonelessChangeDetection,
1717
signal,
1818
viewChild,
19+
viewChildren,
1920
} from '@angular/core';
2021
import {TestBed} from '@angular/core/testing';
2122
import {
@@ -54,6 +55,252 @@ describe('control directive', () => {
5455
});
5556
});
5657

58+
describe('properties', () => {
59+
describe('disabled', () => {
60+
it('native control', () => {
61+
@Component({
62+
imports: [Control],
63+
template: `<input [control]="f">`,
64+
})
65+
class TestCmp {
66+
readonly disabled = signal(false);
67+
readonly f = form(signal(false), (p) => {
68+
disabled(p, this.disabled);
69+
});
70+
}
71+
72+
const fixture = act(() => TestBed.createComponent(TestCmp));
73+
const input = fixture.nativeElement.firstChild;
74+
expect(input.disabled).toBe(false);
75+
76+
act(() => fixture.componentInstance.disabled.set(true));
77+
expect(input.disabled).toBe(true);
78+
});
79+
80+
it('custom control', () => {
81+
@Component({selector: 'custom-control', template: ``})
82+
class CustomControl {
83+
readonly value = model(false);
84+
readonly disabled = input(false);
85+
}
86+
87+
@Component({
88+
imports: [Control, CustomControl],
89+
template: `<custom-control [control]="f" />`,
90+
})
91+
class TestCmp {
92+
readonly disabled = signal(false);
93+
readonly f = form(signal(false), (p) => {
94+
disabled(p, this.disabled);
95+
});
96+
readonly customControl = viewChild.required(CustomControl);
97+
}
98+
99+
const fixture = act(() => TestBed.createComponent(TestCmp));
100+
const component = fixture.componentInstance;
101+
expect(component.customControl().disabled()).toBe(false);
102+
103+
act(() => component.disabled.set(true));
104+
expect(component.customControl().disabled()).toBe(true);
105+
});
106+
});
107+
108+
describe('name', () => {
109+
it('native control', () => {
110+
@Component({
111+
imports: [Control],
112+
template: `
113+
@for (item of f; track item) {
114+
<input #control [control]="item">
115+
<span>{{item().value()}}</span>
116+
}
117+
`,
118+
})
119+
class TestCmp {
120+
readonly f = form(signal(['a', 'b']), {name: 'root'});
121+
readonly controls = viewChildren<ElementRef<HTMLInputElement>>('control');
122+
}
123+
124+
const fixture = act(() => TestBed.createComponent(TestCmp));
125+
const component = fixture.componentInstance;
126+
const controlA = component.controls()[0].nativeElement;
127+
const controlB = component.controls()[1].nativeElement;
128+
expect(controlA.value).toBe('a');
129+
expect(controlB.value).toBe('b');
130+
expect(controlA.name).toBe('root.0');
131+
expect(controlB.name).toBe('root.1');
132+
expect(fixture.nativeElement.innerText).toBe('ab');
133+
134+
act(() => component.f().value.update((items) => [items[1], items[0]]));
135+
136+
// @for should not recreate views when swapped.
137+
expect(controlA.isConnected).toBeTrue();
138+
expect(controlB.isConnected).toBeTrue();
139+
140+
pending('TODO: https://github.com/angular/angular/issues/63882');
141+
142+
// Controls should retain their value.
143+
expect(controlA.value).toBe('a');
144+
expect(controlB.value).toBe('b');
145+
146+
// Controls should have new names to reflect their new position.
147+
expect(controlA.name).toBe('root.1');
148+
expect(controlB.name).toBe('root.0');
149+
150+
// DOM order of controls should be swapped.
151+
expect(fixture.nativeElement.innerText).toBe('ba');
152+
});
153+
154+
it('custom control', () => {
155+
@Component({selector: 'custom-control', template: `{{value()}}`})
156+
class CustomControl {
157+
readonly value = model('');
158+
readonly name = input('');
159+
}
160+
161+
@Component({
162+
imports: [Control, CustomControl],
163+
template: `
164+
@for (item of f; track item) {
165+
<custom-control [control]="item" />
166+
}
167+
`,
168+
})
169+
class TestCmp {
170+
readonly f = form(signal(['a', 'b']), {name: 'root'});
171+
readonly controls = viewChildren(CustomControl);
172+
}
173+
174+
const fixture = act(() => TestBed.createComponent(TestCmp));
175+
const component = fixture.componentInstance;
176+
const controlA = component.controls()[0];
177+
const controlB = component.controls()[1];
178+
expect(controlA.value()).toBe('a');
179+
expect(controlB.value()).toBe('b');
180+
expect(controlA.name()).toBe('root.0');
181+
expect(controlB.name()).toBe('root.1');
182+
expect(fixture.nativeElement.innerText).toBe('ab');
183+
184+
act(() => component.f().value.update((items) => [items[1], items[0]]));
185+
186+
// @for should not recreate views when swapped.
187+
expect(component.controls()).toContain(controlA);
188+
expect(component.controls()).toContain(controlB);
189+
190+
pending('TODO: https://github.com/angular/angular/issues/63882');
191+
192+
// Controls should retain their values.
193+
expect(controlA.value()).toBe('a');
194+
expect(controlB.value()).toBe('b');
195+
196+
// Controls should have new names to reflect their new position.
197+
expect(controlA.name()).toBe('root.1');
198+
expect(controlB.name()).toBe('root.0');
199+
200+
// DOM order of controls should be swapped.
201+
expect(fixture.nativeElement.innerText).toBe('ba');
202+
});
203+
});
204+
205+
describe('readonly', () => {
206+
it('native control', () => {
207+
@Component({
208+
imports: [Control],
209+
template: `<input [control]="f">`,
210+
})
211+
class TestCmp {
212+
readonly readonly = signal(true);
213+
readonly f = form(signal(''), (p) => {
214+
readonly(p, this.readonly);
215+
});
216+
}
217+
218+
const fixture = act(() => TestBed.createComponent(TestCmp));
219+
const element = fixture.nativeElement.firstChild as HTMLInputElement;
220+
expect(element.readOnly).toBe(true);
221+
222+
act(() => fixture.componentInstance.readonly.set(false));
223+
expect(element.readOnly).toBe(false);
224+
});
225+
226+
it('custom control', () => {
227+
@Component({selector: 'custom-control', template: ``})
228+
class CustomControl {
229+
readonly value = model('');
230+
readonly readonly = input(false);
231+
}
232+
233+
@Component({
234+
imports: [Control, CustomControl],
235+
template: `<custom-control [control]="f" />`,
236+
})
237+
class TestCmp {
238+
readonly readonly = signal(false);
239+
readonly f = form(signal(''), (p) => {
240+
readonly(p, this.readonly);
241+
});
242+
readonly child = viewChild.required(CustomControl);
243+
}
244+
245+
const fixture = act(() => TestBed.createComponent(TestCmp));
246+
const component = fixture.componentInstance;
247+
expect(component.child().readonly()).toBe(false);
248+
249+
act(() => component.readonly.set(true));
250+
expect(component.child().readonly()).toBe(true);
251+
});
252+
});
253+
254+
describe('required', () => {
255+
it('native control', () => {
256+
@Component({
257+
imports: [Control],
258+
template: `<input [control]="f">`,
259+
})
260+
class TestCmp {
261+
readonly required = signal(false);
262+
readonly f = form(signal(''), (p) => {
263+
required(p, {when: this.required});
264+
});
265+
}
266+
267+
const fixture = act(() => TestBed.createComponent(TestCmp));
268+
const element = fixture.nativeElement.firstChild;
269+
expect(element.required).toBe(false);
270+
271+
act(() => fixture.componentInstance.required.set(true));
272+
expect(element.required).toBe(true);
273+
});
274+
275+
it('custom control', () => {
276+
@Component({selector: 'custom-control', template: ``})
277+
class CustomControl {
278+
readonly value = model('');
279+
readonly required = input(false);
280+
}
281+
282+
@Component({
283+
imports: [Control, CustomControl],
284+
template: `<custom-control [control]="f" />`,
285+
})
286+
class TestCmp {
287+
readonly required = signal(false);
288+
readonly f = form(signal(''), (p) => {
289+
required(p, {when: this.required});
290+
});
291+
readonly customControl = viewChild.required(CustomControl);
292+
}
293+
294+
const fixture = act(() => TestBed.createComponent(TestCmp));
295+
const component = fixture.componentInstance;
296+
expect(component.customControl().required()).toBe(false);
297+
298+
act(() => component.required.set(true));
299+
expect(component.customControl().required()).toBe(true);
300+
});
301+
});
302+
});
303+
57304
it('synchronizes a basic form with a custom control', () => {
58305
@Component({
59306
imports: [Control],

0 commit comments

Comments
 (0)