Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ All notable changes for each version of this project will be documented in this
- `igx-checkbox`, `igx-switch` and `igx-radio-group` now report `required` and `aria-required` for `Validators.requiredTrue`, as `igxInput` already did.
- `igx-radio-group` implements `setDisabledState`, so `control.disable()` / `enable()` and the Signal Forms `disabled` rule reach the radio buttons. Buttons disabled in the template stay disabled after `enable()`.

### Bug Fixes

- **Forms**
- `igxInput`, `igx-select`, `igx-combo`, `igx-simple-combo`, `igx-date-picker`, `igx-time-picker` and `igx-date-range-picker` no longer paint the invalid style while an async validator is pending. A control that has not answered yet renders in its initial state and only turns invalid once the validator resolves (#17621).
Comment thread
rkaraivanov marked this conversation as resolved.

## 22.2.0

### New Features
Expand Down
9 changes: 3 additions & 6 deletions projects/igniteui-angular/combo/src/combo/combo.common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import {
} from 'igniteui-angular/core';
import { IForOfState, IgxForOfDirective } from 'igniteui-angular/directives';
import { IgxIconService } from 'igniteui-angular/icon';
import { IGX_INPUT_GROUP_TYPE, IgxInputDirective, IgxInputGroupComponent, IgxInputGroupType, IgxInputState, IgxHintDirective, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group';
import { IGX_INPUT_GROUP_TYPE, IgxInputDirective, IgxInputGroupComponent, IgxInputGroupType, IgxInputState, toInputState, IgxHintDirective, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group';
import { IgxComboDropDownComponent } from './combo-dropdown.component';
import { IgxComboAPIService } from './combo.api';
import {
Expand Down Expand Up @@ -1328,11 +1328,8 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh

protected onStatusChanged = () => {
if (this.control && this.control.touchedOrDirty && !this.control.disabled) {
if (this.control.hasValidators && (!this.collapsed || this.inputGroup.isFocused)) {
this.valid = this.control.valid ? IgxInputState.VALID : IgxInputState.INVALID;
} else {
this.valid = this.control.valid ? IgxInputState.INITIAL : IgxInputState.INVALID;
}
const showSuccess = this.control.hasValidators && (!this.collapsed || this.inputGroup.isFocused);
this.valid = toInputState(this.control.status, showSuccess ? 'allowed' : 'suppressed');
} else {
// B.P. 18 May 2021: IgxDatePicker does not reset its state upon resetForm #9526
this.valid = IgxInputState.INITIAL;
Expand Down
48 changes: 36 additions & 12 deletions projects/igniteui-angular/core/src/core/ng-control-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export type NgControlBackend = 'observable' | 'signal';
/** Whether a control took a value written through `setValue`. */
export type ValueWriteResult = 'accepted' | 'ignored';

/** Validation outcome of a control, mirroring `FormControlStatus`. */
export type ControlStatus = 'valid' | 'invalid' | 'pending' | 'disabled';

/**
* Uniform access to the `NgControl` bound to a form control.
*
Expand Down Expand Up @@ -46,6 +49,23 @@ export class NgControlAdapter {
return !!this.ngControl.invalid;
}

public get pending(): boolean {
return !!this.ngControl.pending;
}

/** Derived, not read from `status`: the Signal Forms interop throws on a status it does not know. */
public get status(): ControlStatus {
if (this.disabled) {
return 'disabled';
}

if (this.invalid) {
return 'invalid';
}

return this.pending ? 'pending' : 'valid';
}

public get touchedOrDirty(): boolean {
const control = this.ngControl.control;
return !!(control?.touched || control?.dirty);
Expand All @@ -58,7 +78,7 @@ export class NgControlAdapter {
*/
public get hasValidators(): boolean {
if (this.backend === 'signal') {
return this.required || this.noteErrors();
return this.required || this.sawErrors;
}

const control = this.ngControl.control;
Expand Down Expand Up @@ -93,10 +113,14 @@ export class NgControlAdapter {
*/
public get statusChanges(): Observable<unknown> {
if (this.backend === 'signal') {
return this.watch(() => [
this.ngControl.valid, this.noteErrors(), this.ngControl.pending, this.required,
this.ngControl.disabled, this.ngControl.dirty, this.ngControl.touched
]);
return this.watch(() => {
this.observeErrors();

return [
this.ngControl.valid, this.pending, this.required,
this.ngControl.disabled, this.ngControl.dirty, this.ngControl.touched
];
});
}

return this.ngControl.statusChanges!;
Expand Down Expand Up @@ -137,19 +161,19 @@ export class NgControlAdapter {
return 'accepted';
}

/** Remembers that the field had rules. Returns the current invalid or pending state. */
private noteErrors(): boolean {
const hasErrors = this.invalid || !!this.ngControl.pending;
this.sawErrors ||= hasErrors;

return hasErrors || this.sawErrors;
/**
* Records that the field has rules. A `[formField]` switch reuses the same interop
* `NgControl`, so an untouched, pristine control opens a new observation window.
*/
private observeErrors(): void {
this.sawErrors = this.touchedOrDirty && (this.sawErrors || this.invalid || this.pending);
}

// Signal-backed getters are reactive, so an effect over them replaces the missing observables.
// A root effect runs before change detection, like an observable would; a view effect would
// run after the host bindings were checked. `untracked` allows subscribing from within another
// effect. `toObservable` is not used: it replays and lives until the environment is destroyed.
private watch(read: () => unknown[]): Observable<void> {
private watch(read: () => unknown): Observable<void> {
return new Observable<void>(subscriber => {
const ref = untracked(() => effect(() => {
read();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -898,7 +898,11 @@ describe('IgxDatePicker', () => {
set control(val: any) {
this._control = val;
},
valid: true
valid: true,
// A real control keeps the two in sync; the adapter reads `invalid`.
get invalid() {
return !this.valid;
}
};
mockInjector = jasmine.createSpyObj('Injector', {
get: mockNgControl
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
IFormattingViews, IFormattingOptions
} from 'igniteui-angular/calendar';
import {
IgxLabelDirective, IgxInputState, IgxInputGroupComponent, IgxPrefixDirective, IgxInputDirective, IgxSuffixDirective,
IgxLabelDirective, IgxInputState, toInputState, IgxInputGroupComponent, IgxPrefixDirective, IgxInputDirective, IgxSuffixDirective,
IgxReadOnlyInputDirective
} from 'igniteui-angular/input-group';
import { fromEvent, Subscription, noop, MonoTypeOperatorFunction } from 'rxjs';
Expand Down Expand Up @@ -836,11 +836,8 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr
private updateValidity() {
// B.P. 18 May 2021: IgxDatePicker does not reset its state upon resetForm #9526
if (this._control && !this.disabled && this._control.touchedOrDirty) {
if (this._control.hasValidators && this.inputGroup.isFocused) {
this.inputDirective.valid = this._control.valid ? IgxInputState.VALID : IgxInputState.INVALID;
} else {
this.inputDirective.valid = this._control.valid ? IgxInputState.INITIAL : IgxInputState.INVALID;
}
const showSuccess = this._control.hasValidators && this.inputGroup.isFocused;
this.inputDirective.valid = toInputState(this._control.status, showSuccess ? 'allowed' : 'suppressed');
} else {
this.inputDirective.valid = IgxInputState.INITIAL;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { IgxDateTimeEditorDirective } from '../../../directives/src/directives/d
import { DateRangeType } from 'igniteui-angular/core';
import { IgxDateRangePickerComponent, IgxDateRangeEndComponent } from './public_api';
import { AutoPositionStrategy, IgxOverlayService } from 'igniteui-angular/core';
import { Subject } from 'rxjs';
import { map, Subject, timer } from 'rxjs';
import { AsyncPipe } from '@angular/common';
import { IgxAngularAnimationService } from 'igniteui-angular/core';
import { IgxPickerClearComponent, IgxPickerToggleComponent } from '../../../core/src/date-common/picker-icons.common';
Expand Down Expand Up @@ -50,6 +50,7 @@ const CSS_CLASS_CALENDAR_HEADER_TITLE = '.igx-calendar__header-year';
const CSS_CLASS_CALENDAR_SUBHEADER = '.igx-calendar-picker__dates';
const CSS_CLASS_CALENDAR_HEADER = '.igx-calendar__header';
const CSS_CLASS_CALENDAR_WRAPPER_VERTICAL = 'igx-calendar__wrapper--vertical';
const ASYNC_VALIDATION_DELAY = 2000;

describe('IgxDateRangePicker', () => {
describe('Unit tests: ', () => {
Expand Down Expand Up @@ -2344,6 +2345,48 @@ describe('IgxDateRangePicker', () => {
});
});

describe('IgxDateRangePicker - async validation', () => {
let fixture: ComponentFixture<DateRangeAsyncValidatedComponent>;

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [NoopAnimationsModule, DateRangeAsyncValidatedComponent]
}).compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(DateRangeAsyncValidatedComponent);
fixture.detectChanges();
});

const blur = (input: DebugElement) => {
input.nativeElement.focus();
input.nativeElement.blur();
fixture.detectChanges();
};

it('should not paint the invalid state on blur while an async validator is pending', fakeAsync(() => {
const { single, twoInputs, singleControl, twoInputsControl } = fixture.componentInstance;
const inputs = fixture.debugElement.queryAll(By.css(CSS_CLASS_INPUT));
const range = { start: new Date(2020, 0, 1), end: new Date(2020, 0, 5) };

singleControl.setValue(range);
twoInputsControl.setValue(range);
inputs.forEach(blur);

expect(single.inputDirective.valid).toBe(IgxInputState.INITIAL);
expect(twoInputs.projectedInputs.first.inputDirective.valid).toBe(IgxInputState.INITIAL);
expect(twoInputs.projectedInputs.last.inputDirective.valid).toBe(IgxInputState.INITIAL);

tick(ASYNC_VALIDATION_DELAY);
inputs.forEach(blur);

expect(single.inputDirective.valid).toBe(IgxInputState.INVALID);
expect(twoInputs.projectedInputs.first.inputDirective.valid).toBe(IgxInputState.INVALID);
expect(twoInputs.projectedInputs.last.inputDirective.valid).toBe(IgxInputState.INVALID);
}));
});

describe('IgxDateRangePicker - Signal Forms', () => {
let fixture: ComponentFixture<DateRangeSignalFormComponent>;
let single: IgxDateRangePickerComponent;
Expand Down Expand Up @@ -2763,3 +2806,36 @@ export class DateRangeSignalFormComponent {
disabled(path.trip, { when: () => this.isDisabled() });
});
}

@Component({
template: `
<igx-date-range-picker #single [formControl]="singleControl"></igx-date-range-picker>
<igx-date-range-picker #twoInputs [formControl]="twoInputsControl">
<igx-date-range-start>
<input igxInput igxDateTimeEditor>
</igx-date-range-start>
<igx-date-range-end>
<input igxInput igxDateTimeEditor>
</igx-date-range-end>
</igx-date-range-picker>`,
changeDetection: ChangeDetectionStrategy.Eager,
imports: [
IgxDateRangePickerComponent,
IgxDateRangeStartComponent,
IgxDateRangeEndComponent,
IgxInputDirective,
IgxDateTimeEditorDirective,
ReactiveFormsModule
]
})
export class DateRangeAsyncValidatedComponent {
@ViewChild('single', { read: IgxDateRangePickerComponent }) public single: IgxDateRangePickerComponent;
@ViewChild('twoInputs', { read: IgxDateRangePickerComponent }) public twoInputs: IgxDateRangePickerComponent;

public singleControl = new UntypedFormControl(null, { asyncValidators: [this.pending] });
public twoInputsControl = new UntypedFormControl(null, { asyncValidators: [this.pending] });

private pending() {
return timer(ASYNC_VALIDATION_DELAY).pipe(map(() => ({ taken: true })));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
IgxInputDirective,
IgxInputGroupComponent,
IgxInputState,
toInputState,
IgxLabelDirective,
IgxSuffixDirective,
IgxPrefixDirective,
Expand Down Expand Up @@ -940,11 +941,8 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective

private setValidityState(inputDirective: IgxInputDirective, isFocused: boolean) {
if (this._control && !this._control.disabled && this._control.touchedOrDirty) {
if (this._control.hasValidators && isFocused) {
inputDirective.valid = this._control.valid ? IgxInputState.VALID : IgxInputState.INVALID;
} else {
inputDirective.valid = this._control.valid ? IgxInputState.INITIAL : IgxInputState.INVALID;
}
const showSuccess = this._control.hasValidators && isFocused;
inputDirective.valid = toInputState(this._control.status, showSuccess ? 'allowed' : 'suppressed');
Comment thread
rkaraivanov marked this conversation as resolved.
} else {
inputDirective.valid = IgxInputState.INITIAL;
}
Expand Down Expand Up @@ -1044,24 +1042,19 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective
private updateValidityOnBlur() {
this._focusedInput = null!;
this.onTouchCallback();
if (this._ngControl) {
if (this.hasProjectedInputs) {
this.projectedInputs.forEach(i => {
if (!this._ngControl.valid) {
i.updateInputValidity(IgxInputState.INVALID);
} else {
i.updateInputValidity(IgxInputState.INITIAL);
}
});
}
if (!this._control) {
return;
}

if (this.inputDirective) {
if (!this._ngControl.valid) {
this.inputDirective.valid = IgxInputState.INVALID;
} else {
this.inputDirective.valid = IgxInputState.INITIAL;
}
}
// Blur never shows success, only the error.
const state = toInputState(this._control.status, 'suppressed');

if (this.hasProjectedInputs) {
this.projectedInputs.forEach(i => i.updateInputValidity(state));
}

if (this.inputDirective) {
this.inputDirective.valid = state;
}
}

Expand Down
Loading
Loading