feat(forms): undo revert and add ng-submitted class to forms that have been submitted. (#42132) (#42132)

As previously discussed in pull/31070 and issues/30486, this would be useful because it is often desirable to apply styles to fields that are both `ng-invalid` and `ng-pristine` after the first attempt at form submission, but Angular does not provide any simple way to do this (although evidently Angularjs did). This will now be possible with a descendant selector such as `.ng-submitted .ng-invalid`.

In this implementation, the directive that sets control status classes on forms and formGroups has its set of statuses widened to include `ng-submitted`. Then, in the event that `is('submitted')` is invoked, the `submitted` property of the control container is returned iff it exists. This is preferred over checking whether the container is a `Form` or `FormGroup` directly to avoid reflecting on those classes.

Closes #30486.

PR Close #42132.

This reverts commit 00b1444d12, undoing the rollback of this change.

PR Close #42132
This commit is contained in:
Dylan Hunn 2021-06-07 10:07:49 -07:00 committed by Jessica Janiuk
parent 0777faccfb
commit 34ce635e3a
6 changed files with 265 additions and 13 deletions

View File

@ -195,6 +195,7 @@ The following classes are currently supported.
* `.ng-dirty` * `.ng-dirty`
* `.ng-untouched` * `.ng-untouched`
* `.ng-touched` * `.ng-touched`
* `.ng-submitted` (enclosing form element only)
In the following example, the hero form uses the `.ng-valid` and `.ng-invalid` classes to In the following example, the hero form uses the `.ng-valid` and `.ng-invalid` classes to
set the color of each form control's border. set the color of each form control's border.

View File

@ -314,6 +314,8 @@ Angular sets special CSS classes on the control element to reflect the state, as
</table> </table>
Additionally, Angular applies the `ng-submitted` class to `<form>` elements upon submission. This class does *not* apply to inner controls.
You use these CSS classes to define the styles for your control based on its status. You use these CSS classes to define the styles for your control based on its status.
### Observe control states ### Observe control states

View File

@ -460,7 +460,7 @@ export declare class RadioControlValueAccessor extends ɵangular_packages_forms_
name: string; name: string;
onChange: () => void; onChange: () => void;
value: any; value: any;
constructor(renderer: Renderer2, elementRef: ElementRef, _registry: ɵangular_packages_forms_forms_q, _injector: Injector); constructor(renderer: Renderer2, elementRef: ElementRef, _registry: ɵangular_packages_forms_forms_r, _injector: Injector);
fireUncheck(value: any): void; fireUncheck(value: any): void;
ngOnDestroy(): void; ngOnDestroy(): void;
ngOnInit(): void; ngOnInit(): void;

View File

@ -12,7 +12,8 @@ import {AbstractControlDirective} from './abstract_control_directive';
import {ControlContainer} from './control_container'; import {ControlContainer} from './control_container';
import {NgControl} from './ng_control'; import {NgControl} from './ng_control';
type AnyControlStatus = 'untouched'|'touched'|'pristine'|'dirty'|'valid'|'invalid'|'pending'; type AnyControlStatus =
'untouched'|'touched'|'pristine'|'dirty'|'valid'|'invalid'|'pending'|'submitted';
export class AbstractControlStatus { export class AbstractControlStatus {
private _cd: AbstractControlDirective|null; private _cd: AbstractControlDirective|null;
@ -22,6 +23,17 @@ export class AbstractControlStatus {
} }
is(status: AnyControlStatus): boolean { is(status: AnyControlStatus): boolean {
// Currently with ViewEngine (in AOT mode) it's not possible to use private methods in host
// bindings.
// TODO: once ViewEngine is removed, this function should be refactored:
// - make the `is` method `protected`, so it's not accessible publicly
// - move the `submitted` status logic to the `NgControlStatusGroup` class
// and make it `private` or `protected` too.
if (status === 'submitted') {
// We check for the `submitted` field from `NgForm` and `FormGroupDirective` classes, but
// we avoid instanceof checks to prevent non-tree-shakable references to those types.
return !!(this._cd as unknown as {submitted: boolean} | null)?.submitted;
}
return !!this._cd?.control?.[status]; return !!this._cd?.control?.[status];
} }
} }
@ -36,6 +48,17 @@ export const ngControlStatusHost = {
'[class.ng-pending]': 'is("pending")', '[class.ng-pending]': 'is("pending")',
}; };
export const ngGroupStatusHost = {
'[class.ng-untouched]': 'is("untouched")',
'[class.ng-touched]': 'is("touched")',
'[class.ng-pristine]': 'is("pristine")',
'[class.ng-dirty]': 'is("dirty")',
'[class.ng-valid]': 'is("valid")',
'[class.ng-invalid]': 'is("invalid")',
'[class.ng-pending]': 'is("pending")',
'[class.ng-submitted]': 'is("submitted")',
};
/** /**
* @description * @description
* Directive automatically applied to Angular form controls that sets CSS classes * Directive automatically applied to Angular form controls that sets CSS classes
@ -69,7 +92,8 @@ export class NgControlStatus extends AbstractControlStatus {
/** /**
* @description * @description
* Directive automatically applied to Angular form groups that sets CSS classes * Directive automatically applied to Angular form groups that sets CSS classes
* based on control status (valid/invalid/dirty/etc). * based on control status (valid/invalid/dirty/etc). On groups, this includes the additional
* class ng-submitted.
* *
* @see `NgControlStatus` * @see `NgControlStatus`
* *
@ -80,7 +104,7 @@ export class NgControlStatus extends AbstractControlStatus {
@Directive({ @Directive({
selector: selector:
'[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]', '[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]',
host: ngControlStatusHost host: ngGroupStatusHost
}) })
export class NgControlStatusGroup extends AbstractControlStatus { export class NgControlStatusGroup extends AbstractControlStatus {
constructor(@Optional() @Self() cd: ControlContainer) { constructor(@Optional() @Self() cd: ControlContainer) {

View File

@ -190,7 +190,7 @@ const ValueAccessorB = createControlValueAccessor('[cva-b]');
}); });
it('should update nested form group model when UI changes', () => { it('should update nested form group model when UI changes', () => {
const fixture = initTest(NestedFormGroupComp); const fixture = initTest(NestedFormGroupNameComp);
fixture.componentInstance.form = new FormGroup( fixture.componentInstance.form = new FormGroup(
{'signin': new FormGroup({'login': new FormControl(), 'password': new FormControl()})}); {'signin': new FormGroup({'login': new FormControl(), 'password': new FormControl()})});
fixture.detectChanges(); fixture.detectChanges();
@ -242,7 +242,7 @@ const ValueAccessorB = createControlValueAccessor('[cva-b]');
}); });
it('should pick up dir validators from nested form groups', () => { it('should pick up dir validators from nested form groups', () => {
const fixture = initTest(NestedFormGroupComp, LoginIsEmptyValidator); const fixture = initTest(NestedFormGroupNameComp, LoginIsEmptyValidator);
const form = new FormGroup({ const form = new FormGroup({
'signin': new FormGroup({'login': new FormControl(''), 'password': new FormControl('')}) 'signin': new FormGroup({'login': new FormControl(''), 'password': new FormControl('')})
}); });
@ -260,7 +260,7 @@ const ValueAccessorB = createControlValueAccessor('[cva-b]');
}); });
it('should strip named controls that are not found', () => { it('should strip named controls that are not found', () => {
const fixture = initTest(NestedFormGroupComp, LoginIsEmptyValidator); const fixture = initTest(NestedFormGroupNameComp, LoginIsEmptyValidator);
const form = new FormGroup({ const form = new FormGroup({
'signin': new FormGroup({'login': new FormControl(''), 'password': new FormControl('')}) 'signin': new FormGroup({'login': new FormControl(''), 'password': new FormControl('')})
}); });
@ -335,7 +335,7 @@ const ValueAccessorB = createControlValueAccessor('[cva-b]');
}); });
it('should attach dirs to all child controls when group control changes', () => { it('should attach dirs to all child controls when group control changes', () => {
const fixture = initTest(NestedFormGroupComp, LoginIsEmptyValidator); const fixture = initTest(NestedFormGroupNameComp, LoginIsEmptyValidator);
const form = new FormGroup({ const form = new FormGroup({
signin: new FormGroup( signin: new FormGroup(
{login: new FormControl('oldLogin'), password: new FormControl('oldPassword')}) {login: new FormControl('oldLogin'), password: new FormControl('oldPassword')})
@ -1087,6 +1087,8 @@ const ValueAccessorB = createControlValueAccessor('[cva-b]');
fixture.detectChanges(); fixture.detectChanges();
const input = fixture.debugElement.query(By.css('input')).nativeElement; const input = fixture.debugElement.query(By.css('input')).nativeElement;
const formEl = fixture.debugElement.query(By.css('form')).nativeElement;
expect(sortedClassList(input)).toEqual(['ng-invalid', 'ng-pristine', 'ng-untouched']); expect(sortedClassList(input)).toEqual(['ng-invalid', 'ng-pristine', 'ng-untouched']);
dispatchEvent(input, 'blur'); dispatchEvent(input, 'blur');
@ -1099,6 +1101,19 @@ const ValueAccessorB = createControlValueAccessor('[cva-b]');
fixture.detectChanges(); fixture.detectChanges();
expect(sortedClassList(input)).toEqual(['ng-dirty', 'ng-touched', 'ng-valid']); expect(sortedClassList(input)).toEqual(['ng-dirty', 'ng-touched', 'ng-valid']);
expect(sortedClassList(formEl)).not.toContain('ng-submitted');
dispatchEvent(formEl, 'submit');
fixture.detectChanges();
expect(sortedClassList(input)).not.toContain('ng-submitted');
expect(sortedClassList(formEl)).toContain('ng-submitted');
dispatchEvent(formEl, 'reset');
fixture.detectChanges();
expect(sortedClassList(input)).not.toContain('ng-submitted');
expect(sortedClassList(formEl)).not.toContain('ng-submitted');
}); });
it('should work with formGroup', () => { it('should work with formGroup', () => {
@ -1122,6 +1137,126 @@ const ValueAccessorB = createControlValueAccessor('[cva-b]');
fixture.detectChanges(); fixture.detectChanges();
expect(sortedClassList(formEl)).toEqual(['ng-dirty', 'ng-touched', 'ng-valid']); expect(sortedClassList(formEl)).toEqual(['ng-dirty', 'ng-touched', 'ng-valid']);
dispatchEvent(formEl, 'submit');
fixture.detectChanges();
expect(sortedClassList(formEl)).toContain('ng-submitted');
dispatchEvent(formEl, 'reset');
fixture.detectChanges();
expect(sortedClassList(input)).not.toContain('ng-submitted');
expect(sortedClassList(formEl)).not.toContain('ng-submitted');
});
it('should not assign `ng-submitted` class to elements with `formArrayName`', () => {
// Since element with the `formArrayName` can not represent top-level forms (can only be
// inside other elements), this test verifies that these elements never receive
// `ng-submitted` CSS class even when they are located inside submitted form.
const fixture = initTest(FormArrayComp);
const cityArray = new FormArray([new FormControl('SF'), new FormControl('NY')]);
const form = new FormGroup({cities: cityArray});
fixture.componentInstance.form = form;
fixture.componentInstance.cityArray = cityArray;
fixture.detectChanges();
const [loginInput, passwordInput] =
fixture.debugElement.queryAll(By.css('input')).map(el => el.nativeElement);
const arrEl = fixture.debugElement.query(By.css('div')).nativeElement;
const formEl = fixture.debugElement.query(By.css('form')).nativeElement;
expect(passwordInput).toBeDefined();
expect(sortedClassList(loginInput)).not.toContain('ng-submitted');
expect(sortedClassList(arrEl)).not.toContain('ng-submitted');
expect(sortedClassList(formEl)).not.toContain('ng-submitted');
dispatchEvent(formEl, 'submit');
fixture.detectChanges();
expect(sortedClassList(loginInput)).not.toContain('ng-submitted');
expect(sortedClassList(arrEl)).not.toContain('ng-submitted');
expect(sortedClassList(formEl)).toContain('ng-submitted');
dispatchEvent(formEl, 'reset');
fixture.detectChanges();
expect(sortedClassList(loginInput)).not.toContain('ng-submitted');
expect(sortedClassList(arrEl)).not.toContain('ng-submitted');
expect(sortedClassList(formEl)).not.toContain('ng-submitted');
});
it('should apply submitted status with nested formArrayName', () => {
const fixture = initTest(NestedFormArrayNameComp);
const ic = new FormControl('foo');
const arr = new FormArray([ic]);
const form = new FormGroup({arr});
fixture.componentInstance.form = form;
fixture.detectChanges();
const input = fixture.debugElement.query(By.css('input')).nativeElement;
const arrEl = fixture.debugElement.query(By.css('div')).nativeElement;
const formEl = fixture.debugElement.query(By.css('form')).nativeElement;
expect(sortedClassList(input)).not.toContain('ng-submitted');
expect(sortedClassList(arrEl)).not.toContain('ng-submitted');
expect(sortedClassList(formEl)).not.toContain('ng-submitted');
dispatchEvent(formEl, 'submit');
fixture.detectChanges();
expect(sortedClassList(input)).not.toContain('ng-submitted');
expect(sortedClassList(arrEl)).not.toContain('ng-submitted');
expect(sortedClassList(formEl)).toContain('ng-submitted');
dispatchEvent(formEl, 'reset');
fixture.detectChanges();
expect(sortedClassList(input)).not.toContain('ng-submitted');
expect(sortedClassList(arrEl)).not.toContain('ng-submitted');
expect(sortedClassList(formEl)).not.toContain('ng-submitted');
});
it('should apply submitted status with nested formGroupName', () => {
const fixture = initTest(NestedFormGroupNameComp);
const loginControl =
new FormControl('', {validators: Validators.required, updateOn: 'change'});
const passwordControl = new FormControl('', Validators.required);
const formGroup = new FormGroup(
{signin: new FormGroup({login: loginControl, password: passwordControl})},
{updateOn: 'blur'});
fixture.componentInstance.form = formGroup;
fixture.detectChanges();
const [loginInput, passwordInput] =
fixture.debugElement.queryAll(By.css('input')).map(el => el.nativeElement);
const formEl = fixture.debugElement.query(By.css('form')).nativeElement;
const groupEl = fixture.debugElement.query(By.css('div')).nativeElement;
loginInput.value = 'Nancy';
// Input and blur events, as in a real interaction, cause the form to be touched and
// dirtied.
dispatchEvent(loginInput, 'input');
dispatchEvent(loginInput, 'blur');
fixture.detectChanges();
expect(sortedClassList(loginInput)).not.toContain('ng-submitted');
expect(sortedClassList(groupEl)).not.toContain('ng-submitted');
expect(sortedClassList(formEl)).not.toContain('ng-submitted');
dispatchEvent(formEl, 'submit');
fixture.detectChanges();
expect(sortedClassList(loginInput)).not.toContain('ng-submitted');
expect(sortedClassList(groupEl)).not.toContain('ng-submitted');
expect(sortedClassList(formEl)).toContain('ng-submitted');
dispatchEvent(formEl, 'reset');
fixture.detectChanges();
expect(sortedClassList(loginInput)).not.toContain('ng-submitted');
expect(sortedClassList(groupEl)).not.toContain('ng-submitted');
expect(sortedClassList(formEl)).not.toContain('ng-submitted');
}); });
}); });
@ -1501,7 +1636,7 @@ const ValueAccessorB = createControlValueAccessor('[cva-b]');
it('should allow child control updateOn blur to override group updateOn', () => { it('should allow child control updateOn blur to override group updateOn', () => {
const fixture = initTest(NestedFormGroupComp); const fixture = initTest(NestedFormGroupNameComp);
const loginControl = const loginControl =
new FormControl('', {validators: Validators.required, updateOn: 'change'}); new FormControl('', {validators: Validators.required, updateOn: 'change'});
const passwordControl = new FormControl('', Validators.required); const passwordControl = new FormControl('', Validators.required);
@ -1810,7 +1945,7 @@ const ValueAccessorB = createControlValueAccessor('[cva-b]');
const validatorSpy = jasmine.createSpy('validator'); const validatorSpy = jasmine.createSpy('validator');
const groupValidatorSpy = jasmine.createSpy('groupValidatorSpy'); const groupValidatorSpy = jasmine.createSpy('groupValidatorSpy');
const fixture = initTest(NestedFormGroupComp); const fixture = initTest(NestedFormGroupNameComp);
const formGroup = new FormGroup({ const formGroup = new FormGroup({
signin: new FormGroup({login: new FormControl(), password: new FormControl()}), signin: new FormGroup({login: new FormControl(), password: new FormControl()}),
email: new FormControl('', {updateOn: 'submit'}) email: new FormControl('', {updateOn: 'submit'})
@ -1907,7 +2042,7 @@ const ValueAccessorB = createControlValueAccessor('[cva-b]');
}); });
it('should allow child control updateOn submit to override group updateOn', () => { it('should allow child control updateOn submit to override group updateOn', () => {
const fixture = initTest(NestedFormGroupComp); const fixture = initTest(NestedFormGroupNameComp);
const loginControl = const loginControl =
new FormControl('', {validators: Validators.required, updateOn: 'change'}); new FormControl('', {validators: Validators.required, updateOn: 'change'});
const passwordControl = new FormControl('', Validators.required); const passwordControl = new FormControl('', Validators.required);
@ -4807,7 +4942,7 @@ class FormGroupComp {
} }
@Component({ @Component({
selector: 'nested-form-group-comp', selector: 'nested-form-group-name-comp',
template: ` template: `
<form [formGroup]="form"> <form [formGroup]="form">
<div formGroupName="signin" login-is-empty-validator> <div formGroupName="signin" login-is-empty-validator>
@ -4817,7 +4952,7 @@ class FormGroupComp {
<input *ngIf="form.contains('email')" formControlName="email"> <input *ngIf="form.contains('email')" formControlName="email">
</form>` </form>`
}) })
class NestedFormGroupComp { class NestedFormGroupNameComp {
// TODO(issue/24571): remove '!'. // TODO(issue/24571): remove '!'.
form!: FormGroup; form!: FormGroup;
} }
@ -4840,6 +4975,20 @@ class FormArrayComp {
cityArray!: FormArray; cityArray!: FormArray;
} }
@Component({
selector: 'nested-form-array-name-comp',
template: `
<form [formGroup]="form">
<div formArrayName="arr">
<input formControlName="0">
</div>
</form>
`
})
class NestedFormArrayNameComp {
form!: FormGroup;
}
@Component({ @Component({
selector: 'form-array-nested-group', selector: 'form-array-nested-group',
template: ` template: `

View File

@ -187,6 +187,21 @@ import {NgModelCustomComp, NgModelCustomWrapper} from './value_accessor_integrat
dispatchEvent(input, 'input'); dispatchEvent(input, 'input');
fixture.detectChanges(); fixture.detectChanges();
expect(sortedClassList(input)).toEqual(['ng-dirty', 'ng-touched', 'ng-valid']); expect(sortedClassList(input)).toEqual(['ng-dirty', 'ng-touched', 'ng-valid']);
const formEl = fixture.debugElement.query(By.css('form')).nativeElement;
dispatchEvent(formEl, 'submit');
fixture.detectChanges();
expect(sortedClassList(formEl)).toEqual([
'ng-dirty', 'ng-submitted', 'ng-touched', 'ng-valid'
]);
expect(sortedClassList(input)).not.toContain('ng-submitted');
dispatchEvent(formEl, 'reset');
fixture.detectChanges();
expect(sortedClassList(formEl)).toEqual(['ng-pristine', 'ng-untouched', 'ng-valid']);
expect(sortedClassList(input)).not.toContain('ng-submitted');
}); });
})); }));
@ -244,9 +259,52 @@ import {NgModelCustomComp, NgModelCustomWrapper} from './value_accessor_integrat
expect(sortedClassList(modelGroup)).toEqual(['ng-dirty', 'ng-touched', 'ng-valid']); expect(sortedClassList(modelGroup)).toEqual(['ng-dirty', 'ng-touched', 'ng-valid']);
expect(sortedClassList(form)).toEqual(['ng-dirty', 'ng-touched', 'ng-valid']); expect(sortedClassList(form)).toEqual(['ng-dirty', 'ng-touched', 'ng-valid']);
const formEl = fixture.debugElement.query(By.css('form')).nativeElement;
dispatchEvent(formEl, 'submit');
fixture.detectChanges();
expect(sortedClassList(formEl)).toEqual([
'ng-dirty', 'ng-submitted', 'ng-touched', 'ng-valid'
]);
}); });
})); }));
it('should set status classes involving nested FormGroups', () => {
const fixture = initTest(NgModelNestedForm);
fixture.componentInstance.first = '';
fixture.componentInstance.other = '';
fixture.detectChanges();
const form = fixture.debugElement.query(By.css('form')).nativeElement;
const modelGroup = fixture.debugElement.query(By.css('[ngModelGroup]')).nativeElement;
const input = fixture.debugElement.query(By.css('input')).nativeElement;
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(sortedClassList(modelGroup)).toEqual(['ng-pristine', 'ng-untouched', 'ng-valid']);
expect(sortedClassList(form)).toEqual(['ng-pristine', 'ng-untouched', 'ng-valid']);
const formEl = fixture.debugElement.query(By.css('form')).nativeElement;
dispatchEvent(formEl, 'submit');
fixture.detectChanges();
expect(sortedClassList(modelGroup)).toEqual(['ng-pristine', 'ng-untouched', 'ng-valid']);
expect(sortedClassList(form)).toEqual([
'ng-pristine', 'ng-submitted', 'ng-untouched', 'ng-valid'
]);
expect(sortedClassList(input)).not.toContain('ng-submitted');
dispatchEvent(formEl, 'reset');
fixture.detectChanges();
expect(sortedClassList(modelGroup)).toEqual(['ng-pristine', 'ng-untouched', 'ng-valid']);
expect(sortedClassList(form)).toEqual(['ng-pristine', 'ng-untouched', 'ng-valid']);
expect(sortedClassList(input)).not.toContain('ng-submitted');
});
});
it('should not create a template-driven form when ngNoForm is used', () => { it('should not create a template-driven form when ngNoForm is used', () => {
const fixture = initTest(NgNoFormComp); const fixture = initTest(NgNoFormComp);
fixture.detectChanges(); fixture.detectChanges();
@ -2367,6 +2425,24 @@ class NgModelNgIfForm {
email!: string; email!: string;
} }
@Component({
selector: 'ng-model-nested',
template: `
<form>
<div ngModelGroup="contact-info">
<input name="first" [(ngModel)]="first">
<div ngModelGroup="other-names">
<input name="other-names" [(ngModel)]="other">
</div>
</div>
</form>
`
})
class NgModelNestedForm {
first!: string;
other!: string;
}
@Component({ @Component({
selector: 'ng-no-form', selector: 'ng-no-form',
template: ` template: `