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-untouched`
* `.ng-touched`
* `.ng-submitted` (enclosing form element only)
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.

View File

@ -314,6 +314,8 @@ Angular sets special CSS classes on the control element to reflect the state, as
</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.
### Observe control states

View File

@ -460,7 +460,7 @@ export declare class RadioControlValueAccessor extends ɵangular_packages_forms_
name: string;
onChange: () => void;
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;
ngOnDestroy(): void;
ngOnInit(): void;

View File

@ -12,7 +12,8 @@ import {AbstractControlDirective} from './abstract_control_directive';
import {ControlContainer} from './control_container';
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 {
private _cd: AbstractControlDirective|null;
@ -22,6 +23,17 @@ export class AbstractControlStatus {
}
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];
}
}
@ -36,6 +48,17 @@ export const ngControlStatusHost = {
'[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
* Directive automatically applied to Angular form controls that sets CSS classes
@ -69,7 +92,8 @@ export class NgControlStatus extends AbstractControlStatus {
/**
* @description
* 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`
*
@ -80,7 +104,7 @@ export class NgControlStatus extends AbstractControlStatus {
@Directive({
selector:
'[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]',
host: ngControlStatusHost
host: ngGroupStatusHost
})
export class NgControlStatusGroup extends AbstractControlStatus {
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', () => {
const fixture = initTest(NestedFormGroupComp);
const fixture = initTest(NestedFormGroupNameComp);
fixture.componentInstance.form = new FormGroup(
{'signin': new FormGroup({'login': new FormControl(), 'password': new FormControl()})});
fixture.detectChanges();
@ -242,7 +242,7 @@ const ValueAccessorB = createControlValueAccessor('[cva-b]');
});
it('should pick up dir validators from nested form groups', () => {
const fixture = initTest(NestedFormGroupComp, LoginIsEmptyValidator);
const fixture = initTest(NestedFormGroupNameComp, LoginIsEmptyValidator);
const form = new FormGroup({
'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', () => {
const fixture = initTest(NestedFormGroupComp, LoginIsEmptyValidator);
const fixture = initTest(NestedFormGroupNameComp, LoginIsEmptyValidator);
const form = new FormGroup({
'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', () => {
const fixture = initTest(NestedFormGroupComp, LoginIsEmptyValidator);
const fixture = initTest(NestedFormGroupNameComp, LoginIsEmptyValidator);
const form = new FormGroup({
signin: new FormGroup(
{login: new FormControl('oldLogin'), password: new FormControl('oldPassword')})
@ -1087,6 +1087,8 @@ const ValueAccessorB = createControlValueAccessor('[cva-b]');
fixture.detectChanges();
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']);
dispatchEvent(input, 'blur');
@ -1099,6 +1101,19 @@ const ValueAccessorB = createControlValueAccessor('[cva-b]');
fixture.detectChanges();
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', () => {
@ -1122,6 +1137,126 @@ const ValueAccessorB = createControlValueAccessor('[cva-b]');
fixture.detectChanges();
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', () => {
const fixture = initTest(NestedFormGroupComp);
const fixture = initTest(NestedFormGroupNameComp);
const loginControl =
new FormControl('', {validators: Validators.required, updateOn: 'change'});
const passwordControl = new FormControl('', Validators.required);
@ -1810,7 +1945,7 @@ const ValueAccessorB = createControlValueAccessor('[cva-b]');
const validatorSpy = jasmine.createSpy('validator');
const groupValidatorSpy = jasmine.createSpy('groupValidatorSpy');
const fixture = initTest(NestedFormGroupComp);
const fixture = initTest(NestedFormGroupNameComp);
const formGroup = new FormGroup({
signin: new FormGroup({login: new FormControl(), password: new FormControl()}),
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', () => {
const fixture = initTest(NestedFormGroupComp);
const fixture = initTest(NestedFormGroupNameComp);
const loginControl =
new FormControl('', {validators: Validators.required, updateOn: 'change'});
const passwordControl = new FormControl('', Validators.required);
@ -4807,7 +4942,7 @@ class FormGroupComp {
}
@Component({
selector: 'nested-form-group-comp',
selector: 'nested-form-group-name-comp',
template: `
<form [formGroup]="form">
<div formGroupName="signin" login-is-empty-validator>
@ -4817,7 +4952,7 @@ class FormGroupComp {
<input *ngIf="form.contains('email')" formControlName="email">
</form>`
})
class NestedFormGroupComp {
class NestedFormGroupNameComp {
// TODO(issue/24571): remove '!'.
form!: FormGroup;
}
@ -4840,6 +4975,20 @@ class FormArrayComp {
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({
selector: 'form-array-nested-group',
template: `

View File

@ -187,6 +187,21 @@ import {NgModelCustomComp, NgModelCustomWrapper} from './value_accessor_integrat
dispatchEvent(input, 'input');
fixture.detectChanges();
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(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', () => {
const fixture = initTest(NgNoFormComp);
fixture.detectChanges();
@ -2367,6 +2425,24 @@ class NgModelNgIfForm {
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({
selector: 'ng-no-form',
template: `