Skip to content

Commit

Permalink
feat(datepicker): optional min/max dates for infinite navigation
Browse files Browse the repository at this point in the history
Fixes #1732

Closes #2219
  • Loading branch information
maxokorokov authored and pkozlowski-opensource committed Mar 16, 2018
1 parent c6af446 commit 3a1a341
Show file tree
Hide file tree
Showing 6 changed files with 334 additions and 98 deletions.
133 changes: 121 additions & 12 deletions src/datepicker/datepicker-service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,48 +53,48 @@ describe('ngb-datepicker-service', () => {

describe(`min/max dates`, () => {

it(`should emit only undefined and valid 'minDate' values`, () => {
it(`should emit undefined, null and valid 'minDate' values`, () => {
// valid
const minDate = new NgbDate(2017, 5, 1);
service.minDate = minDate;
service.focus(new NgbDate(2017, 5, 1));
expect(model.minDate).toEqual(minDate);

// null
service.minDate = null;
expect(model.minDate).toBeNull();

// undefined
service.minDate = undefined;
expect(model.minDate).toBeUndefined();

// null -> ignore
service.minDate = null;
expect(model.minDate).toBeUndefined();

// invalid -> ignore
service.minDate = new NgbDate(-2, 0, null);
expect(model.minDate).toBeUndefined();

expect(mock.onNext).toHaveBeenCalledTimes(2);
expect(mock.onNext).toHaveBeenCalledTimes(3);
});

it(`should emit only undefined and valid 'maxDate' values`, () => {
it(`should emit undefined, null and valid 'maxDate' values`, () => {
// valid
const maxDate = new NgbDate(2017, 5, 1);
service.maxDate = maxDate;
service.focus(new NgbDate(2017, 5, 1));
expect(model.maxDate).toEqual(maxDate);

// null
service.maxDate = null;
expect(model.maxDate).toBeNull();

// undefined
service.maxDate = undefined;
expect(model.maxDate).toBeUndefined();

// null -> ignore
service.maxDate = null;
expect(model.maxDate).toBeUndefined();

// invalid -> ignore
service.maxDate = new NgbDate(-2, 0, null);
expect(model.maxDate).toBeUndefined();

expect(mock.onNext).toHaveBeenCalledTimes(2);
expect(mock.onNext).toHaveBeenCalledTimes(3);
});

it(`should not emit the same 'minDate' value twice`, () => {
Expand Down Expand Up @@ -445,6 +445,8 @@ describe('ngb-datepicker-service', () => {

describe(`select`, () => {

const range = (start, end) => Array.from({length: end - start + 1}, (e, i) => start + i);

it(`should not generate 'months' and 'years' for non-select navigations`, () => {
service.minDate = new NgbDate(2010, 5, 1);
service.maxDate = new NgbDate(2012, 5, 1);
Expand Down Expand Up @@ -494,6 +496,105 @@ describe('ngb-datepicker-service', () => {
expect(model.selectBoxes.years).toEqual([2011]);
expect(model.selectBoxes.months).toEqual([2, 3, 4, 5, 6, 7, 8]);
});

it(`should generate [-10, +10] 'years' when min/max dates are missing`, () => {
const year = calendar.getToday().year;
service.open(null);
expect(model.selectBoxes.years).toEqual(range(year - 10, year + 10));

service.focus(new NgbDate(2011, 1, 1));
expect(model.selectBoxes.years).toEqual(range(2001, 2021));

service.focus(new NgbDate(2020, 1, 1));
expect(model.selectBoxes.years).toEqual(range(2010, 2030));
});

it(`should generate [min, +10] 'years' when max date is missing`, () => {
service.minDate = new NgbDate(2010, 1, 1);
service.open(new NgbDate(2011, 1, 1));
expect(model.selectBoxes.years).toEqual(range(2010, 2021));

service.minDate = new NgbDate(2015, 1, 1);
expect(model.selectBoxes.years).toEqual(range(2015, 2025));

service.minDate = new NgbDate(1000, 1, 1);
expect(model.selectBoxes.years).toEqual(range(1000, 2025));
});

it(`should generate [min, +10] 'years' when min date is missing`, () => {
service.maxDate = new NgbDate(2010, 1, 1);
service.open(new NgbDate(2009, 1, 1));
expect(model.selectBoxes.years).toEqual(range(1999, 2010));

service.maxDate = new NgbDate(2005, 1, 1);
expect(model.selectBoxes.years).toEqual(range(1995, 2005));

service.maxDate = new NgbDate(3000, 1, 1);
expect(model.selectBoxes.years).toEqual(range(1995, 3000));
});

it(`should generate 'months' when min/max dates are missing`, () => {
service.open(null);
expect(model.selectBoxes.months).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);

service.focus(new NgbDate(2010, 1, 1));
expect(model.selectBoxes.months).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
});

it(`should generate 'months' and 'years' when resetting min/max dates`, () => {
service.minDate = new NgbDate(2010, 3, 1);
service.maxDate = new NgbDate(2010, 8, 1);
service.open(new NgbDate(2010, 5, 10));
expect(model.selectBoxes.months).toEqual([3, 4, 5, 6, 7, 8]);
expect(model.selectBoxes.years).toEqual([2010]);

service.minDate = null;
expect(model.selectBoxes.months).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
expect(model.selectBoxes.years).toEqual(range(2000, 2010));

service.maxDate = null;
expect(model.selectBoxes.months).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
expect(model.selectBoxes.years).toEqual(range(2000, 2020));
});

it(`should generate 'months' when max date is missing`, () => {
service.minDate = new NgbDate(2010, 1, 1);
service.open(new NgbDate(2010, 5, 1));
expect(model.selectBoxes.months).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);

service.minDate = new NgbDate(2010, 4, 1);
expect(model.selectBoxes.months).toEqual([4, 5, 6, 7, 8, 9, 10, 11, 12]);
});

it(`should generate 'months' when min date is missing`, () => {
service.maxDate = new NgbDate(2010, 12, 1);
service.open(new NgbDate(2010, 5, 1));
expect(model.selectBoxes.months).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);

service.maxDate = new NgbDate(2010, 7, 1);
expect(model.selectBoxes.months).toEqual([1, 2, 3, 4, 5, 6, 7]);
});

it(`should rebuild 'months' and 'years' only when year change`, () => {
service.focus(new NgbDate(2010, 5, 1));
let months = model.selectBoxes.months;
let years = model.selectBoxes.years;

// focusing -> nothing
service.focus(new NgbDate(2010, 5, 10));
expect(model.selectBoxes.months).toBe(months);
expect(model.selectBoxes.years).toBe(years);

// month changes -> nothing
service.focus(new NgbDate(2010, 6, 1));
expect(model.selectBoxes.months).toBe(months);
expect(model.selectBoxes.years).toBe(years);

// year changes -> rebuilding both
service.focus(new NgbDate(2011, 6, 1));
expect(model.selectBoxes.months).not.toBe(months);
expect(model.selectBoxes.years).not.toBe(years);
});
});

describe(`arrows`, () => {
Expand Down Expand Up @@ -729,6 +830,14 @@ describe('ngb-datepicker-service', () => {

describe(`view change handling`, () => {

it(`should open current month if nothing is provided`, () => {
const today = calendar.getToday();
service.open(null);
expect(model.months.length).toBe(1);
expect(model.firstDate).toEqual(new NgbDate(today.year, today.month, 1));
expect(model.focusDate).toEqual(today);
});

it(`should open month and set up focus correctly`, () => {
service.open(new NgbDate(2017, 5, 5));
expect(model.months.length).toBe(1);
Expand Down
30 changes: 14 additions & 16 deletions src/datepicker/datepicker-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class NgbDatepickerService {
}

set maxDate(date: NgbDate) {
if (date === undefined || this._calendar.isValid(date) && isChangedDate(this._state.maxDate, date)) {
if (date == null || this._calendar.isValid(date) && isChangedDate(this._state.maxDate, date)) {
this._nextState({maxDate: date});
}
}
Expand All @@ -81,7 +81,7 @@ export class NgbDatepickerService {
}

set minDate(date: NgbDate) {
if (date === undefined || this._calendar.isValid(date) && isChangedDate(this._state.minDate, date)) {
if (date == null || this._calendar.isValid(date) && isChangedDate(this._state.minDate, date)) {
this._nextState({minDate: date});
}
}
Expand Down Expand Up @@ -113,8 +113,9 @@ export class NgbDatepickerService {
}

open(date: NgbDate) {
if (!this._state.disabled && this._calendar.isValid(date)) {
this._nextState({firstDate: date});
const validDate = this.toValidDate(date, this._calendar.getToday());
if (!this._state.disabled) {
this._nextState({firstDate: validDate});
}
}

Expand Down Expand Up @@ -244,15 +245,16 @@ export class NgbDatepickerService {
}

// adjusting months/years for the select box navigation
const yearChanged = !this._state.firstDate || this._state.firstDate.year !== state.firstDate.year;
const monthChanged = !this._state.firstDate || this._state.firstDate.month !== state.firstDate.month;
if (state.navigation === 'select') {
// years -> boundaries (min/max were changed)
if ('minDate' in patch || 'maxDate' in patch || state.selectBoxes.years.length === 0) {
state.selectBoxes.years = generateSelectBoxYears(state.minDate, state.maxDate);
if ('minDate' in patch || 'maxDate' in patch || state.selectBoxes.years.length === 0 || yearChanged) {
state.selectBoxes.years = generateSelectBoxYears(state.focusDate, state.minDate, state.maxDate);
}

// months -> when current year or boundaries change
if ('minDate' in patch || 'maxDate' in patch || state.selectBoxes.months.length === 0 ||
this._state.firstDate.year !== state.firstDate.year) {
if ('minDate' in patch || 'maxDate' in patch || state.selectBoxes.months.length === 0 || yearChanged) {
state.selectBoxes.months =
generateSelectBoxMonths(this._calendar, state.focusDate, state.minDate, state.maxDate);
}
Expand All @@ -261,14 +263,10 @@ export class NgbDatepickerService {
}

// updating navigation arrows -> boundaries change (min/max) or month/year changes
if (state.navigation === 'arrows' || state.navigation === 'select') {
const monthChanged = !this._state.firstDate || this._state.firstDate.month !== state.firstDate.month;
const yearChanged = !this._state.firstDate || this._state.firstDate.year !== state.firstDate.year;

if (monthChanged || yearChanged || 'minDate' in patch || 'maxDate' in patch || 'disabled' in patch) {
state.prevDisabled = state.disabled || prevMonthDisabled(this._calendar, state.firstDate, state.minDate);
state.nextDisabled = state.disabled || nextMonthDisabled(this._calendar, state.lastDate, state.maxDate);
}
if ((state.navigation === 'arrows' || state.navigation === 'select') &&
(monthChanged || yearChanged || 'minDate' in patch || 'maxDate' in patch || 'disabled' in patch)) {
state.prevDisabled = state.disabled || prevMonthDisabled(this._calendar, state.firstDate, state.minDate);
state.nextDisabled = state.disabled || nextMonthDisabled(this._calendar, state.lastDate, state.maxDate);
}
}

Expand Down
84 changes: 83 additions & 1 deletion src/datepicker/datepicker-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import {
buildMonths,
checkDateInRange,
dateComparator,
generateSelectBoxMonths,
getFirstViewDate,
isDateSelectable
isDateSelectable,
generateSelectBoxYears
} from './datepicker-tools';
import {NgbDate} from './ngb-date';
import {NgbCalendar, NgbCalendarGregorian} from './ngb-calendar';
Expand Down Expand Up @@ -375,4 +377,84 @@ describe(`datepicker-tools`, () => {
});
});

describe(`generateSelectBoxMonths`, () => {

let calendar: NgbCalendar;

beforeAll(() => {
TestBed.configureTestingModule({providers: [{provide: NgbCalendar, useClass: NgbCalendarGregorian}]});
calendar = TestBed.get(NgbCalendar);
});

const test = (minDate, date, maxDate, result) => {
expect(generateSelectBoxMonths(calendar, date, minDate, maxDate)).toEqual(result);
};

it(`should handle edge cases`, () => {
test(new NgbDate(2018, 6, 1), null, new NgbDate(2018, 6, 10), []);
test(null, null, null, []);
});

it(`should generate months correctly`, () => {
// clang-format off
// different years
test(new NgbDate(2017, 1, 1), new NgbDate(2018, 1, 1), new NgbDate(2019, 1, 1), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
test(null, new NgbDate(2018, 6, 10), null, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
test(null, new NgbDate(2018, 1, 1), new NgbDate(2019, 1, 1), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
test(new NgbDate(2017, 1, 1), new NgbDate(2018, 1, 1), null, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);

// same 'min year'
test(new NgbDate(2018, 1, 1), new NgbDate(2018, 6, 10), new NgbDate(2020, 1, 2), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
test(new NgbDate(2018, 6, 1), new NgbDate(2018, 6, 10), new NgbDate(2020, 1, 2), [6, 7, 8, 9, 10, 11, 12]);
test(new NgbDate(2018, 6, 1), new NgbDate(2018, 6, 10), null, [6, 7, 8, 9, 10, 11, 12]);

// same 'max' year
test(new NgbDate(2017, 1, 1), new NgbDate(2018, 6, 10), new NgbDate(2018, 12, 1), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
test(new NgbDate(2017, 1, 1), new NgbDate(2018, 6, 10), new NgbDate(2018, 6, 10), [1, 2, 3, 4, 5, 6]);
test(null, new NgbDate(2018, 6, 10), new NgbDate(2018, 6, 10), [1, 2, 3, 4, 5, 6]);

// same 'min' and 'max years'
test(new NgbDate(2018, 1, 1), new NgbDate(2018, 6, 10), new NgbDate(2018, 12, 1), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
test(new NgbDate(2018, 3, 1), new NgbDate(2018, 6, 10), new NgbDate(2018, 12, 1), [3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
test(new NgbDate(2018, 3, 1), new NgbDate(2018, 6, 10), null, [3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
test(null, new NgbDate(2018, 6, 10), new NgbDate(2018, 8, 1), [1, 2, 3, 4, 5, 6, 7, 8]);
test(new NgbDate(2018, 3, 1), new NgbDate(2018, 6, 10), new NgbDate(2018, 8, 1), [3, 4, 5, 6, 7, 8] );
test(new NgbDate(2018, 6, 1), new NgbDate(2018, 6, 10), new NgbDate(2018, 6, 10), [6]);
// clang-format on
});
});

describe(`generateSelectBoxYears`, () => {

const test =
(minDate, date, maxDate, result) => { expect(generateSelectBoxYears(date, minDate, maxDate)).toEqual(result); };
const range = (start, end) => Array.from({length: end - start + 1}, (e, i) => start + i);

it(`should handle edge cases`, () => {
test(new NgbDate(2018, 6, 1), null, new NgbDate(2018, 6, 10), []);
test(null, null, null, []);
});

it(`should generate years correctly`, () => {
// both 'min' and 'max' are set
test(new NgbDate(2017, 1, 1), new NgbDate(2018, 1, 1), new NgbDate(2019, 1, 1), range(2017, 2019));
test(new NgbDate(2000, 1, 1), new NgbDate(2018, 1, 1), new NgbDate(3000, 1, 1), range(2000, 3000));
test(new NgbDate(2018, 1, 1), new NgbDate(2018, 1, 1), new NgbDate(2018, 1, 1), [2018]);

// 'min' is not set
test(null, new NgbDate(2018, 1, 1), new NgbDate(2019, 1, 1), range(2008, 2019));
test(null, new NgbDate(2018, 1, 1), new NgbDate(3000, 1, 1), range(2008, 3000));
test(null, new NgbDate(2018, 1, 1), new NgbDate(2018, 1, 1), range(2008, 2018));

// 'max' is not set
test(new NgbDate(2017, 1, 1), new NgbDate(2018, 1, 1), null, range(2017, 2028));
test(new NgbDate(2000, 1, 1), new NgbDate(2018, 1, 1), null, range(2000, 2028));
test(new NgbDate(2018, 1, 1), new NgbDate(2018, 1, 1), null, range(2018, 2028));

// both are not set
test(null, new NgbDate(2018, 1, 1), null, range(2008, 2028));
test(null, new NgbDate(2000, 1, 1), null, range(1990, 2010));
});
});

});
17 changes: 12 additions & 5 deletions src/datepicker/datepicker-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,27 +42,34 @@ export function isDateSelectable(
}

export function generateSelectBoxMonths(calendar: NgbCalendar, date: NgbDate, minDate: NgbDate, maxDate: NgbDate) {
if (!date || !minDate || !maxDate) {
if (!date) {
return [];
}

let months = calendar.getMonths();

if (date.year === minDate.year) {
if (minDate && date.year === minDate.year) {
const index = months.findIndex(month => month === minDate.month);
months = months.slice(index);
}

if (date.year === maxDate.year) {
if (maxDate && date.year === maxDate.year) {
const index = months.findIndex(month => month === maxDate.month);
months = months.slice(0, index + 1);
}

return months;
}

export function generateSelectBoxYears(minDate: NgbDate, maxDate: NgbDate): number[] {
return (minDate && maxDate) ? Array.from({length: maxDate.year - minDate.year + 1}, (e, i) => minDate.year + i) : [];
export function generateSelectBoxYears(date: NgbDate, minDate: NgbDate, maxDate: NgbDate) {
if (!date) {
return [];
}

const start = minDate && minDate.year || date.year - 10;
const end = maxDate && maxDate.year || date.year + 10;

return Array.from({length: end - start + 1}, (e, i) => start + i);
}

export function nextMonthDisabled(calendar: NgbCalendar, date: NgbDate, maxDate: NgbDate) {
Expand Down

0 comments on commit 3a1a341

Please sign in to comment.