Skip to content

fix(material/datepicker): range input controls dirty on init #21223

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 8, 2021
Merged
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
27 changes: 11 additions & 16 deletions src/material/datepicker/date-range-input-parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,15 +159,20 @@ abstract class MatDateRangeInputPartBase<D>
return this._rangeInput.dateFilter;
}

protected _outsideValueChanged = () => {
// Whenever the value changes outside the input we need to revalidate, because
// the validation state of each of the inputs depends on the other one.
this._validatorOnChange();
}

protected _parentDisabled() {
return this._rangeInput._groupDisabled;
}

protected _shouldHandleChangeEvent({source}: DateSelectionModelChange<DateRange<D>>): boolean {
return source !== this._rangeInput._startInput && source !== this._rangeInput._endInput;
}

protected _assignValueProgrammatically(value: D | null) {
super._assignValueProgrammatically(value);
const opposite = (this === this._rangeInput._startInput ? this._rangeInput._endInput :
this._rangeInput._startInput) as MatDateRangeInputPartBase<D> | undefined;
opposite?._validatorOnChange();
}
}

const _MatDateRangeInputBase:
Expand Down Expand Up @@ -259,14 +264,9 @@ export class MatStartDate<D> extends _MatDateRangeInputBase<D> implements
if (this._model) {
const range = new DateRange(value, this._model.selection.end);
this._model.updateSelection(range, this);
this._cvaOnChange(value);
}
}

protected _canEmitChangeEvent = (event: DateSelectionModelChange<DateRange<D>>): boolean => {
return event.source !== this._rangeInput._endInput;
}

protected _formatValue(value: D | null) {
super._formatValue(value);

Expand Down Expand Up @@ -367,14 +367,9 @@ export class MatEndDate<D> extends _MatDateRangeInputBase<D> implements
if (this._model) {
const range = new DateRange(this._model.selection.start, value);
this._model.updateSelection(range, this);
this._cvaOnChange(value);
}
}

protected _canEmitChangeEvent = (event: DateSelectionModelChange<DateRange<D>>): boolean => {
return event.source !== this._rangeInput._startInput;
}

_onKeydown(event: KeyboardEvent) {
// If the user is pressing backspace on an empty end input, move focus back to the start.
if (event.keyCode === BACKSPACE && !this._elementRef.nativeElement.value) {
Expand Down
73 changes: 72 additions & 1 deletion src/material/datepicker/date-range-input.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import {Type, Component, ViewChild, ElementRef, Directive} from '@angular/core';
import {ComponentFixture, TestBed, inject, fakeAsync, tick} from '@angular/core/testing';
import {ComponentFixture, TestBed, inject, fakeAsync, tick, flush} from '@angular/core/testing';
import {
FormsModule,
ReactiveFormsModule,
FormGroup,
FormControl,
NG_VALIDATORS,
Validator,
NgModel,
} from '@angular/forms';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {OverlayContainer} from '@angular/cdk/overlay';
Expand Down Expand Up @@ -251,6 +252,9 @@ describe('MatDateRangeInput', () => {
tick();
const {start, end} = fixture.componentInstance.range.controls;

// The default error state matcher only checks if the controls have been touched.
// Set it manually here so we can assert `rangeInput.errorState` correctly.
fixture.componentInstance.range.markAllAsTouched();
expect(fixture.componentInstance.rangeInput.errorState).toBe(false);
expect(start.errors?.matStartDateInvalid).toBeFalsy();
expect(end.errors?.matEndDateInvalid).toBeFalsy();
Expand All @@ -262,6 +266,13 @@ describe('MatDateRangeInput', () => {
expect(fixture.componentInstance.rangeInput.errorState).toBe(true);
expect(start.errors?.matStartDateInvalid).toBeTruthy();
expect(end.errors?.matEndDateInvalid).toBeTruthy();

end.setValue(new Date(2020, 3, 2));
fixture.detectChanges();

expect(fixture.componentInstance.rangeInput.errorState).toBe(false);
expect(start.errors?.matStartDateInvalid).toBeFalsy();
expect(end.errors?.matEndDateInvalid).toBeFalsy();
}));

it('should pass the minimum date from the range input to the inner inputs', () => {
Expand Down Expand Up @@ -569,6 +580,62 @@ describe('MatDateRangeInput', () => {
assignAndAssert(new Date(2020, 2, 2), new Date(2020, 2, 5));
}));

it('should not be dirty on init when there is no value', fakeAsync(() => {
const fixture = createComponent(RangePickerNgModel);
fixture.detectChanges();
flush();
const {startModel, endModel} = fixture.componentInstance;

expect(startModel.dirty).toBe(false);
expect(startModel.touched).toBe(false);
expect(endModel.dirty).toBe(false);
expect(endModel.touched).toBe(false);
}));

it('should not be dirty on init when there is a value', fakeAsync(() => {
const fixture = createComponent(RangePickerNgModel);
fixture.componentInstance.start = new Date(2020, 1, 2);
fixture.componentInstance.end = new Date(2020, 2, 2);
fixture.detectChanges();
flush();
const {startModel, endModel} = fixture.componentInstance;

expect(startModel.dirty).toBe(false);
expect(startModel.touched).toBe(false);
expect(endModel.dirty).toBe(false);
expect(endModel.touched).toBe(false);
}));

it('should mark the input as dirty once the user types in it', fakeAsync(() => {
const fixture = createComponent(RangePickerNgModel);
fixture.componentInstance.start = new Date(2020, 1, 2);
fixture.componentInstance.end = new Date(2020, 2, 2);
fixture.detectChanges();
flush();
const {startModel, endModel, startInput, endInput} = fixture.componentInstance;

expect(startModel.dirty).toBe(false);
expect(endModel.dirty).toBe(false);

endInput.nativeElement.value = '30/12/2020';
dispatchFakeEvent(endInput.nativeElement, 'input');
fixture.detectChanges();
flush();
fixture.detectChanges();

expect(startModel.dirty).toBe(false);
expect(endModel.dirty).toBe(true);

startInput.nativeElement.value = '12/12/2020';
dispatchFakeEvent(startInput.nativeElement, 'input');
fixture.detectChanges();
flush();
fixture.detectChanges();

expect(startModel.dirty).toBe(true);
expect(endModel.dirty).toBe(true);
}));

it('should move focus to the start input when pressing backspace on an empty end input', () => {
const fixture = createComponent(StandardRangePicker);
fixture.detectChanges();
Expand Down Expand Up @@ -848,6 +915,10 @@ class RangePickerNoEnd {}
`
})
class RangePickerNgModel {
@ViewChild(MatStartDate, {read: NgModel}) startModel: NgModel;
@ViewChild(MatEndDate, {read: NgModel}) endModel: NgModel;
@ViewChild(MatStartDate, {read: ElementRef}) startInput: ElementRef<HTMLInputElement>;
@ViewChild(MatEndDate, {read: ElementRef}) endInput: ElementRef<HTMLInputElement>;
start: Date | null = null;
end: Date | null = null;
}
Expand Down
57 changes: 18 additions & 39 deletions src/material/datepicker/datepicker-input-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,16 +76,7 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
return this._model ? this._getValueFromModel(this._model.selection) : this._pendingValue;
}
set value(value: D | null) {
value = this._dateAdapter.deserialize(value);
this._lastValueValid = this._isValidValue(value);
value = this._dateAdapter.getValidDateOrNull(value);
const oldDate = this.value;
this._assignValue(value);
this._formatValue(value);

if (!this._dateAdapter.sameDate(oldDate, value)) {
this._valueChange.emit(value);
}
this._assignValueProgrammatically(value);
}
protected _model: MatDateSelectionModel<S, D> | undefined;

Expand Down Expand Up @@ -122,16 +113,13 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
@Output() readonly dateInput: EventEmitter<MatDatepickerInputEvent<D, S>> =
new EventEmitter<MatDatepickerInputEvent<D, S>>();

/** Emits when the value changes (either due to user input or programmatic change). */
_valueChange = new EventEmitter<D | null>();

/** Emits when the internal state has changed */
stateChanges = new Subject<void>();

_onTouched = () => {};
_validatorOnChange = () => {};

protected _cvaOnChange: (value: any) => void = () => {};
private _cvaOnChange: (value: any) => void = () => {};
private _valueChangesSubscription = Subscription.EMPTY;
private _localeSubscription = Subscription.EMPTY;

Expand Down Expand Up @@ -200,24 +188,14 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
}

this._valueChangesSubscription = this._model.selectionChanged.subscribe(event => {
if (event.source !== this) {
if (this._shouldHandleChangeEvent(event)) {
const value = this._getValueFromModel(event.selection);
this._lastValueValid = this._isValidValue(value);
this._cvaOnChange(value);
this._onTouched();
this._formatValue(value);

// Note that we can't wrap the entire block with this logic, because for the range inputs
// we want to revalidate whenever either one of the inputs changes and we don't have a
// good way of distinguishing it at the moment.
if (this._canEmitChangeEvent(event)) {
this.dateInput.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement));
this.dateChange.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement));
}

if (this._outsideValueChanged) {
this._outsideValueChanged();
}
this.dateInput.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement));
this.dateChange.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement));
}
});
}
Expand All @@ -234,14 +212,8 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
/** Combined form control validator for this input. */
protected abstract _validator: ValidatorFn | null;

/**
* Callback that'll be invoked when the selection model is changed
* from somewhere that's not the current datepicker input.
*/
protected abstract _outsideValueChanged?: () => void;

/** Predicate that determines whether we're allowed to emit a particular change event. */
protected abstract _canEmitChangeEvent(event: DateSelectionModelChange<S>): boolean;
/** Predicate that determines whether the input should handle a particular change event. */
protected abstract _shouldHandleChangeEvent(event: DateSelectionModelChange<S>): boolean;

/** Whether the last value set on the input was valid. */
protected _lastValueValid = false;
Expand All @@ -262,7 +234,7 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection

// Update the displayed date when the locale changes.
this._localeSubscription = _dateAdapter.localeChanges.subscribe(() => {
this.value = this.value;
this._assignValueProgrammatically(this.value);
});
}

Expand All @@ -279,7 +251,6 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
ngOnDestroy() {
this._valueChangesSubscription.unsubscribe();
this._localeSubscription.unsubscribe();
this._valueChange.complete();
this.stateChanges.complete();
}

Expand All @@ -295,7 +266,7 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection

// Implemented as part of ControlValueAccessor.
writeValue(value: D): void {
this.value = value;
this._assignValueProgrammatically(value);
}

// Implemented as part of ControlValueAccessor.
Expand Down Expand Up @@ -331,7 +302,6 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
if (!this._dateAdapter.sameDate(date, this.value)) {
this._assignValue(date);
this._cvaOnChange(date);
this._valueChange.emit(date);
this.dateInput.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement));
} else {
// Call the CVA change handler for invalid values
Expand Down Expand Up @@ -391,6 +361,15 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
return false;
}

/** Programmatically assigns a value to the input. */
protected _assignValueProgrammatically(value: D | null) {
value = this._dateAdapter.deserialize(value);
this._lastValueValid = this._isValidValue(value);
value = this._dateAdapter.getValidDateOrNull(value);
this._assignValue(value);
this._formatValue(value);
}

/** Gets whether a value matches the current date filter. */
_matchesFilter(value: D | null): boolean {
const filter = this._getDateFilter();
Expand Down
8 changes: 3 additions & 5 deletions src/material/datepicker/datepicker-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {MatFormField, MAT_FORM_FIELD} from '@angular/material/form-field';
import {MAT_INPUT_VALUE_ACCESSOR} from '@angular/material/input';
import {MatDatepickerInputBase, DateFilterFn} from './datepicker-input-base';
import {MatDatepickerControl, MatDatepickerPanel} from './datepicker-base';
import {DateSelectionModelChange} from './date-selection-model';

/** @docs-private */
export const MAT_DATEPICKER_VALUE_ACCESSOR: any = {
Expand Down Expand Up @@ -183,13 +184,10 @@ export class MatDatepickerInput<D> extends MatDatepickerInputBase<D | null, D>
return this._dateFilter;
}

protected _canEmitChangeEvent() {
return true;
protected _shouldHandleChangeEvent(event: DateSelectionModelChange<D>) {
return event.source !== this;
}

// Unnecessary when selecting a single date.
protected _outsideValueChanged: undefined;

// Accept `any` to avoid conflicts with other directives on `<input>` that
// may accept different types.
static ngAcceptInputType_value: any;
Expand Down
5 changes: 1 addition & 4 deletions tools/public_api_guard/material/datepicker.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,6 @@ export declare class MatDatepickerContent<S, D = ExtractDateTypeFromSelection<S>

export declare class MatDatepickerInput<D> extends MatDatepickerInputBase<D | null, D> implements MatDatepickerControl<D | null> {
_datepicker: MatDatepickerPanel<MatDatepickerControl<D>, D | null, D>;
protected _outsideValueChanged: undefined;
protected _validator: ValidatorFn | null;
get dateFilter(): DateFilterFn<D | null>;
set dateFilter(value: DateFilterFn<D | null>);
Expand All @@ -222,12 +221,12 @@ export declare class MatDatepickerInput<D> extends MatDatepickerInputBase<D | nu
set min(value: D | null);
constructor(elementRef: ElementRef<HTMLInputElement>, dateAdapter: DateAdapter<D>, dateFormats: MatDateFormats, _formField: MatFormField);
protected _assignValueToModel(value: D | null): void;
protected _canEmitChangeEvent(): boolean;
protected _getDateFilter(): DateFilterFn<D | null>;
_getMaxDate(): D | null;
_getMinDate(): D | null;
protected _getValueFromModel(modelValue: D | null): D | null;
protected _openPopup(): void;
protected _shouldHandleChangeEvent(event: DateSelectionModelChange<D>): boolean;
getConnectedOverlayOrigin(): ElementRef;
getStartValue(): D | null;
getThemePalette(): ThemePalette;
Expand Down Expand Up @@ -372,7 +371,6 @@ export declare abstract class MatDateSelectionModel<S, D = ExtractDateTypeFromSe
}

export declare class MatEndDate<D> extends _MatDateRangeInputBase<D> implements CanUpdateErrorState, DoCheck, OnInit {
protected _canEmitChangeEvent: (event: DateSelectionModelChange<DateRange<D>>) => boolean;
protected _validator: ValidatorFn | null;
constructor(rangeInput: MatDateRangeInputParent<D>, elementRef: ElementRef<HTMLInputElement>, defaultErrorStateMatcher: ErrorStateMatcher, injector: Injector, parentForm: NgForm, parentFormGroup: FormGroupDirective, dateAdapter: DateAdapter<D>, dateFormats: MatDateFormats);
protected _assignValueToModel(value: D | null): void;
Expand Down Expand Up @@ -482,7 +480,6 @@ export declare class MatSingleDateSelectionModel<D> extends MatDateSelectionMode
}

export declare class MatStartDate<D> extends _MatDateRangeInputBase<D> implements CanUpdateErrorState, DoCheck, OnInit {
protected _canEmitChangeEvent: (event: DateSelectionModelChange<DateRange<D>>) => boolean;
protected _validator: ValidatorFn | null;
constructor(rangeInput: MatDateRangeInputParent<D>, elementRef: ElementRef<HTMLInputElement>, defaultErrorStateMatcher: ErrorStateMatcher, injector: Injector, parentForm: NgForm, parentFormGroup: FormGroupDirective, dateAdapter: DateAdapter<D>, dateFormats: MatDateFormats);
protected _assignValueToModel(value: D | null): void;
Expand Down