From afd99baba02a657667c970b43f12fc2941690103 Mon Sep 17 00:00:00 2001 From: "Manu Mtz.-Almeida" Date: Wed, 15 Mar 2017 23:30:08 +0100 Subject: [PATCH] fix(datetime): respect time limits in hours and minutes fixes #6850 --- src/components/datetime/datetime.ts | 322 +++++++++--------- src/components/datetime/test/datetime.spec.ts | 59 ++-- src/components/datetime/test/issues/main.html | 13 + src/util/datetime-util.ts | 6 +- src/util/util.ts | 20 +- 5 files changed, 220 insertions(+), 200 deletions(-) diff --git a/src/components/datetime/datetime.ts b/src/components/datetime/datetime.ts index f00ee4bb75..41e02ddda1 100644 --- a/src/components/datetime/datetime.ts +++ b/src/components/datetime/datetime.ts @@ -3,11 +3,11 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { Config } from '../../config/config'; import { Picker, PickerController } from '../picker/picker'; -import { PickerColumn, PickerColumnOption } from '../picker/picker-options'; +import { PickerColumn } from '../picker/picker-options'; import { Form } from '../../util/form'; import { Ion } from '../ion'; import { Item } from '../item/item'; -import { deepCopy, isBlank, isPresent, isTrueProperty, isArray, isString } from '../../util/util'; +import { deepCopy, isBlank, isPresent, isTrueProperty, isArray, isString, assert } from '../../util/util'; import { dateValueRange, renderDateTime, renderTextFormat, convertFormatToKey, getValueFromFormat, parseTemplate, parseDate, updateDate, DateTimeData, convertDataToISO, daysInMonth, dateSortValue, dateDataSortValue, LocaleData } from '../../util/datetime-util'; export const DATETIME_VALUE_ACCESSOR: any = { @@ -277,6 +277,7 @@ export class DateTime extends Ion implements AfterContentInit, ControlValueAcces _max: DateTimeData; _value: DateTimeData = {}; _locale: LocaleData = {}; + _picker: Picker; /** * @private @@ -475,39 +476,35 @@ export class DateTime extends Ion implements AfterContentInit, ControlValueAcces * @private */ open() { + assert(!this._isOpen, 'datetime is already open'); if (this._disabled) { return; } - console.debug('datetime, open picker'); // the user may have assigned some options specifically for the alert const pickerOptions = deepCopy(this.pickerOptions); - const picker = this._pickerCtrl.create(pickerOptions); - pickerOptions.buttons = [ - { - text: this.cancelText, - role: 'cancel', - handler: () => { - this.ionCancel.emit(null); - } - }, - { - text: this.doneText, - handler: (data: any) => { - console.debug('datetime, done', data); - this.onChange(data); - this.ionChange.emit(data); - } + const picker = this._picker = this._pickerCtrl.create(pickerOptions); + picker.addButton({ + text: this.cancelText, + role: 'cancel', + handler: () => this.ionCancel.emit(null) + }); + picker.addButton({ + text: this.doneText, + handler: (data: any) => { + console.debug('datetime, done', data); + this.onChange(data); + this.ionChange.emit(data); } - ]; + }); - this.generate(picker); - this.validate(picker); + this.generate(); + this.validate(); picker.ionChange.subscribe(() => { - this.validate(picker); + this.validate(); picker.refresh(); }); @@ -524,7 +521,8 @@ export class DateTime extends Ion implements AfterContentInit, ControlValueAcces /** * @private */ - generate(picker: Picker) { + generate() { + const picker = this._picker; // if a picker format wasn't provided, then fallback // to use the display format let template = this.pickerFormat || this.displayFormat || DEFAULT_FORMAT; @@ -586,127 +584,152 @@ export class DateTime extends Ion implements AfterContentInit, ControlValueAcces } }); - this.divyColumns(picker); + const min = this._min; + const max = this._max; + + // Normalize min/max + const columns = this._picker.getColumns(); + ['month', 'day', 'hour', 'minute'] + .filter(name => !columns.find(column => column.name === name)) + .forEach(name => { + min[name] = 0; + max[name] = 0; + }); + + this.divyColumns(); } } /** * @private */ - validate(picker: Picker) { - let today = new Date(); - let columns = picker.getColumns(); + getColumn(name: string): PickerColumn { + const columns = this._picker.getColumns(); + return columns.find(col => col.name === name); + } - // find the columns used - let yearCol = columns.find(col => col.name === 'year'); - let monthCol = columns.find(col => col.name === 'month'); - let dayCol = columns.find(col => col.name === 'day'); + /** + * @private + */ + validateColumn(name: string, index: number, min: number, max: number, lowerBounds: number[], upperBounds: number[]): number { + assert(lowerBounds.length === 5, 'lowerBounds length must be 5'); + assert(upperBounds.length === 5, 'upperBounds length must be 5'); - let yearOpt: PickerColumnOption; - let monthOpt: PickerColumnOption; - let dayOpt: PickerColumnOption; + const column = this.getColumn(name); + if (!column) { + return 0; + } + + const lb = lowerBounds.slice(); + const ub = upperBounds.slice(); + const options = column.options; + + for (var i = 0; i < options.length; i++) { + var opt = options[i]; + var value = opt.value; + lb[index] = opt.value; + ub[index] = opt.value; + + opt.disabled = ( + value < lowerBounds[index] || + value > upperBounds[index] || + dateSortValue(ub[0], ub[1], ub[2], ub[3], ub[4]) < min || + dateSortValue(lb[0], lb[1], lb[2], lb[3], lb[4]) > max + ); + } + + opt = column.options[column.selectedIndex]; + if (opt) { + return opt.value; + } + return 0; + } + + /** + * @private + */ + validate() { + const today = new Date(); + const minCompareVal = dateDataSortValue(this._min); + const maxCompareVal = dateDataSortValue(this._max); + const yearCol = this.getColumn('year'); + + assert(minCompareVal <= maxCompareVal, 'invalid min/max value'); - // default to the current year let selectedYear: number = today.getFullYear(); - if (yearCol) { // default to the first value if the current year doesn't exist in the options if (!yearCol.options.find(col => col.value === today.getFullYear())) { selectedYear = yearCol.options[0].value; } - yearOpt = yearCol.options[yearCol.selectedIndex]; + var yearOpt = yearCol.options[yearCol.selectedIndex]; if (yearOpt) { // they have a selected year value selectedYear = yearOpt.value; } } - // create sort values for the min/max datetimes - let minCompareVal = dateDataSortValue(this._min); - let maxCompareVal = dateDataSortValue(this._max); + const selectedMonth = this.validateColumn( + 'month', 1, + minCompareVal, maxCompareVal, + [selectedYear, 0, 0, 0, 0], + [selectedYear, 12, 31, 23, 59] + ); - if (monthCol) { - // enable/disable which months are valid - // to show within the min/max date range - for (var i = 0; i < monthCol.options.length; i++) { - monthOpt = monthCol.options[i]; + const numDaysInMonth = daysInMonth(selectedMonth, selectedYear); + const selectedDay = this.validateColumn( + 'day', 2, + minCompareVal, maxCompareVal, + [selectedYear, selectedMonth, 0, 0, 0], + [selectedYear, selectedMonth, numDaysInMonth, 23, 59] + ); - // loop through each month and see if it - // is within the min/max date range - monthOpt.disabled = (dateSortValue(selectedYear, monthOpt.value, 31) < minCompareVal || - dateSortValue(selectedYear, monthOpt.value, 1) > maxCompareVal); - } - } + const selectedHour = this.validateColumn( + 'hour', 3, + minCompareVal, maxCompareVal, + [selectedYear, selectedMonth, selectedDay, 0, 0], + [selectedYear, selectedMonth, selectedDay, 23, 59] + ); - // default to assuming this month has 31 days - let numDaysInMonth = 31; - let selectedMonth: number; - if (monthCol) { - monthOpt = monthCol.options[monthCol.selectedIndex]; - if (monthOpt) { - // they have a selected month value - selectedMonth = monthOpt.value; - - // calculate how many days are in this month - numDaysInMonth = daysInMonth(selectedMonth, selectedYear); - } - } - - if (dayCol) { - if (isPresent(selectedMonth)) { - // enable/disable which days are valid - // to show within the min/max date range - for (var i = 0; i < dayCol.options.length; i++) { - dayOpt = dayCol.options[i]; - - // loop through each day and see if it - // is within the min/max date range - var compareVal = dateSortValue(selectedYear, selectedMonth, dayOpt.value); - - dayOpt.disabled = (compareVal < minCompareVal || - compareVal > maxCompareVal || - numDaysInMonth < dayOpt.value); - } - - } else { - // enable/disable which numbers of days to show in this month - for (var i = 0; i < dayCol.options.length; i++) { - dayOpt = dayCol.options[i]; - dayOpt.disabled = (numDaysInMonth < dayOpt.value); - } - } - } + this.validateColumn( + 'minute', 4, + minCompareVal, maxCompareVal, + [selectedYear, selectedMonth, selectedDay, selectedHour, 0], + [selectedYear, selectedMonth, selectedDay, selectedHour, 59] + ); } /** * @private */ - divyColumns(picker: Picker) { - let pickerColumns = picker.getColumns(); - let columns: number[] = []; + divyColumns() { + const pickerColumns = this._picker.getColumns(); + let columnsWidth: number[] = []; + let col: PickerColumn; + let width: number; + for (var i = 0; i < pickerColumns.length; i++) { + col = pickerColumns[i]; + columnsWidth.push(0); - pickerColumns.forEach((col, i) => { - columns.push(0); - - col.options.forEach(opt => { - if (opt.text.length > columns[i]) { - columns[i] = opt.text.length; + for (var j = 0; j < col.options.length; j++) { + width = col.options[j].text.length; + if (width > columnsWidth[i]) { + columnsWidth[i] = width; } - }); + } + } - }); - - if (columns.length === 2) { - var width = Math.max(columns[0], columns[1]); + if (columnsWidth.length === 2) { + width = Math.max(columnsWidth[0], columnsWidth[1]); pickerColumns[0].align = 'right'; pickerColumns[1].align = 'left'; pickerColumns[0].optionsWidth = pickerColumns[1].optionsWidth = `${width * 17}px`; - } else if (columns.length === 3) { - var width = Math.max(columns[0], columns[2]); + } else if (columnsWidth.length === 3) { + width = Math.max(columnsWidth[0], columnsWidth[2]); pickerColumns[0].align = 'right'; - pickerColumns[1].columnWidth = `${columns[1] * 17}px`; + pickerColumns[1].columnWidth = `${columnsWidth[1] * 17}px`; pickerColumns[0].optionsWidth = pickerColumns[2].optionsWidth = `${width * 17}px`; pickerColumns[2].align = 'left'; } @@ -749,49 +772,53 @@ export class DateTime extends Ion implements AfterContentInit, ControlValueAcces */ calcMinMax(now?: Date) { const todaysYear = (now || new Date()).getFullYear(); - - if (isBlank(this.min)) { - if (isPresent(this.yearValues)) { - this.min = Math.min.apply(Math, convertToArrayOfNumbers(this.yearValues, 'year')); - - } else { + if (isPresent(this.yearValues)) { + var years = convertToArrayOfNumbers(this.yearValues, 'year'); + if (isBlank(this.min)) { + this.min = Math.min.apply(Math, years); + } + if (isBlank(this.max)) { + this.max = Math.max.apply(Math, years); + } + } else { + if (isBlank(this.min)) { this.min = (todaysYear - 100).toString(); } - } - - if (isBlank(this.max)) { - if (isPresent(this.yearValues)) { - this.max = Math.max.apply(Math, convertToArrayOfNumbers(this.yearValues, 'year')); - - } else { + if (isBlank(this.max)) { this.max = todaysYear.toString(); } } - const min = this._min = parseDate(this.min); const max = this._max = parseDate(this.max); + min.year = min.year || todaysYear; + max.year = max.year || todaysYear; + + min.month = min.month || 1; + max.month = max.month || 12; + min.day = min.day || 1; + max.day = max.day || 31; + min.hour = min.hour || 0; + max.hour = max.hour || 23; + min.minute = min.minute || 0; + max.minute = max.minute || 59; + min.second = min.second || 0; + max.second = max.second || 59; + + // Ensure min/max constraits if (min.year > max.year) { + console.error('min.year > max.year'); min.year = max.year - 100; - } else if (min.year === max.year) { + } + if (min.year === max.year) { if (min.month > max.month) { + console.error('min.month > max.month'); min.month = 1; } else if (min.month === max.month && min.day > max.day) { + console.error('min.day > max.day'); min.day = 1; } } - - min.month = min.month || 1; - min.day = min.day || 1; - min.hour = min.hour || 0; - min.minute = min.minute || 0; - min.second = min.second || 0; - - max.month = max.month || 12; - max.day = max.day || 31; - max.hour = max.hour || 23; - max.minute = max.minute || 59; - max.second = max.second || 59; } /** @@ -893,25 +920,21 @@ export class DateTime extends Ion implements AfterContentInit, ControlValueAcces * an array of numbers, and clean up any user input */ function convertToArrayOfNumbers(input: any, type: string): number[] { - var values: number[] = []; - if (isString(input)) { // convert the string to an array of strings // auto remove any whitespace and [] characters input = input.replace(/\[|\]|\s/g, '').split(','); } + let values: number[]; if (isArray(input)) { // ensure each value is an actual number in the returned array - input.forEach((num: any) => { - num = parseInt(num, 10); - if (!isNaN(num)) { - values.push(num); - } - }); + values = input + .map((num: any) => parseInt(num, 10)) + .filter(isFinite); } - if (!values.length) { + if (!values || !values.length) { console.warn(`Invalid "${type}Values". Must be an array of numbers, or a comma separated string of numbers.`); } @@ -925,25 +948,19 @@ function convertToArrayOfNumbers(input: any, type: string): number[] { */ function convertToArrayOfStrings(input: any, type: string): string[] { if (isPresent(input)) { - var values: string[] = []; - if (isString(input)) { // convert the string to an array of strings // auto remove any [] characters input = input.replace(/\[|\]/g, '').split(','); } + var values: string[]; if (isArray(input)) { // trim up each string value - input.forEach((val: any) => { - val = val.trim(); - if (val) { - values.push(val); - } - }); + values = input.map((val: string) => val.trim()); } - if (!values.length) { + if (!values || !values.length) { console.warn(`Invalid "${type}Names". Must be an array of strings, or a comma separated string.`); } @@ -951,4 +968,5 @@ function convertToArrayOfStrings(input: any, type: string): string[] { } } + const DEFAULT_FORMAT = 'MMM D, YYYY'; diff --git a/src/components/datetime/test/datetime.spec.ts b/src/components/datetime/test/datetime.spec.ts index 245c70a96f..81f8fd8658 100644 --- a/src/components/datetime/test/datetime.spec.ts +++ b/src/components/datetime/test/datetime.spec.ts @@ -14,15 +14,14 @@ describe('DateTime', () => { datetime.max = '2001-12-15'; datetime.min = '2000-01-15'; datetime.pickerFormat = 'MM DD YYYY'; - var picker = new Picker(mockApp()); - datetime.generate(picker); + datetime.generate(); var columns = picker.getColumns(); columns[0].selectedIndex = 0; // January columns[1].selectedIndex = 0; // January 1st columns[2].selectedIndex = 1; // January 1st, 2000 - datetime.validate(picker); + datetime.validate(); expect(columns[1].options[0].disabled).toEqual(true); expect(columns[1].options[13].disabled).toEqual(true); @@ -31,7 +30,7 @@ describe('DateTime', () => { columns[0].selectedIndex = 11; // December columns[2].selectedIndex = 0; // December 1st, 2001 - datetime.validate(picker); + datetime.validate(); expect(columns[0].options[11].disabled).toEqual(false); @@ -44,15 +43,14 @@ describe('DateTime', () => { datetime.max = '2010-11-15'; datetime.min = '2000-02-15'; datetime.pickerFormat = 'MM DD YYYY'; - var picker = new Picker(mockApp()); - datetime.generate(picker); + datetime.generate(); var columns = picker.getColumns(); columns[0].selectedIndex = 1; // February columns[1].selectedIndex = 0; // February 1st columns[2].selectedIndex = columns[2].options.length - 1; // February 1st, 2000 - datetime.validate(picker); + datetime.validate(); expect(columns[0].options[0].disabled).toEqual(true); expect(columns[0].options[1].disabled).toEqual(false); @@ -60,7 +58,7 @@ describe('DateTime', () => { columns[2].selectedIndex = 0; // December 1st, 2010 - datetime.validate(picker); + datetime.validate(); expect(columns[0].options[0].disabled).toEqual(false); expect(columns[0].options[10].disabled).toEqual(false); @@ -72,22 +70,21 @@ describe('DateTime', () => { datetime.min = '2000-01-01'; datetime.pickerFormat = 'MM DD YYYY'; - var picker = new Picker(mockApp()); - datetime.generate(picker); + datetime.generate(); var columns = picker.getColumns(); columns[0].selectedIndex = 0; // January columns[1].selectedIndex = 0; // January 1st columns[2].selectedIndex = 0; // January 1st, 2010 - datetime.validate(picker); + datetime.validate(); for (var i = 0; i < 31; i++) { expect(columns[1].options[i].disabled).toEqual(false); } columns[0].selectedIndex = 1; // February - datetime.validate(picker); + datetime.validate(); for (var i = 0; i < 28; i++) { expect(columns[1].options[i].disabled).toEqual(false); @@ -97,7 +94,7 @@ describe('DateTime', () => { expect(columns[1].options[30].disabled).toEqual(true); columns[0].selectedIndex = 3; // April - datetime.validate(picker); + datetime.validate(); for (var i = 0; i < 30; i++) { expect(columns[1].options[i].disabled).toEqual(false); @@ -112,8 +109,7 @@ describe('DateTime', () => { datetime.pickerFormat = 'MM DD YYYY'; - var picker = new Picker(mockApp()); - datetime.generate(picker); + datetime.generate(); var columns = picker.getColumns(); @@ -122,7 +118,7 @@ describe('DateTime', () => { expect(columns[2].options.length).toEqual(2); // years columns[0].selectedIndex = 1; // July - datetime.validate(picker); + datetime.validate(); // Months for (var i = 0; i < columns[0].options.length; i++) { @@ -135,7 +131,7 @@ describe('DateTime', () => { } columns[0].selectedIndex = 0; // June - datetime.validate(picker); + datetime.validate(); expect(columns[1].options[12].disabled).toEqual(true); }); @@ -175,8 +171,7 @@ describe('DateTime', () => { datetime.ngAfterContentInit(); datetime.setValue('1994-12-15T13:47:20.789Z'); - var picker = new Picker(mockApp()); - datetime.generate(picker); + datetime.generate(); var columns = picker.getColumns(); expect(columns.length).toEqual(3); @@ -191,8 +186,7 @@ describe('DateTime', () => { datetime.displayFormat = 'YYYY'; datetime.setValue('1994-12-15T13:47:20.789Z'); - var picker = new Picker(mockApp()); - datetime.generate(picker); + datetime.generate(); var columns = picker.getColumns(); expect(columns.length).toEqual(1); @@ -205,8 +199,7 @@ describe('DateTime', () => { datetime.pickerFormat = 'YYYY'; datetime.setValue('1994-12-15T13:47:20.789Z'); - var picker = new Picker(mockApp()); - datetime.generate(picker); + datetime.generate(); var columns = picker.getColumns(); expect(columns.length).toEqual(1); @@ -219,8 +212,7 @@ describe('DateTime', () => { datetime.pickerFormat = 'MMM YYYY'; datetime.setValue('1994-12-15T13:47:20.789Z'); - var picker = new Picker(mockApp()); - datetime.generate(picker); + datetime.generate(); var columns = picker.getColumns(); expect(columns.length).toEqual(2); @@ -235,8 +227,7 @@ describe('DateTime', () => { datetime.pickerFormat = 'MMMM YYYY'; datetime.setValue('1994-12-15T13:47:20.789Z'); - var picker = new Picker(mockApp()); - datetime.generate(picker); + datetime.generate(); var columns = picker.getColumns(); expect(columns.length).toEqual(2); @@ -249,8 +240,7 @@ describe('DateTime', () => { datetime.pickerFormat = 'DDDD D M YYYY'; datetime.setValue('1994-12-15T13:47:20.789Z'); - var picker = new Picker(mockApp()); - datetime.generate(picker); + datetime.generate(); var columns = picker.getColumns(); expect(columns.length).toEqual(3); @@ -263,8 +253,7 @@ describe('DateTime', () => { datetime.pickerFormat = 'DDDD M YYYY'; datetime.setValue('1994-12-15T13:47:20.789Z'); - var picker = new Picker(mockApp()); - datetime.generate(picker); + datetime.generate(); var columns = picker.getColumns(); expect(columns.length).toEqual(3); @@ -278,8 +267,7 @@ describe('DateTime', () => { datetime.min = '2000-01-01'; datetime.pickerFormat = 'MM DD YYYY'; - var picker = new Picker(mockApp()); - datetime.generate(picker); + datetime.generate(); var columns = picker.getColumns(); expect(columns.length).toEqual(3); @@ -301,8 +289,7 @@ describe('DateTime', () => { datetime.min = '2000-01-01'; datetime.pickerFormat = 'YYYY'; - var picker = new Picker(mockApp()); - datetime.generate(picker); + datetime.generate(); var columns = picker.getColumns(); expect(columns.length).toEqual(1); @@ -639,9 +626,11 @@ describe('DateTime', () => { }); var datetime: DateTime; + var picker: Picker; beforeEach(() => { datetime = new DateTime(new Form(), mockConfig(), mockElementRef(), mockRenderer(), null, {}); + datetime._picker = picker = new Picker(mockApp()); }); console.warn = function(){}; diff --git a/src/components/datetime/test/issues/main.html b/src/components/datetime/test/issues/main.html index fe4d4ceab2..2730633719 100644 --- a/src/components/datetime/test/issues/main.html +++ b/src/components/datetime/test/issues/main.html @@ -55,4 +55,17 @@ > + + + displayFormat="HH:mm" + min="01:20" + max="13:45" + + + + diff --git a/src/util/datetime-util.ts b/src/util/datetime-util.ts index c5e613085d..08f7dc41fe 100644 --- a/src/util/datetime-util.ts +++ b/src/util/datetime-util.ts @@ -150,13 +150,13 @@ export function dateValueRange(format: string, min: DateTimeData, max: DateTimeD return opts; } -export function dateSortValue(year: number, month: number, day: number): number { - return parseInt(`1${fourDigit(year)}${twoDigit(month)}${twoDigit(day)}`, 10); +export function dateSortValue(year: number, month: number, day: number, hour: number = 0, minute: number = 0): number { + return parseInt(`1${fourDigit(year)}${twoDigit(month)}${twoDigit(day)}${twoDigit(hour)}${twoDigit(minute)}`, 10); } export function dateDataSortValue(data: DateTimeData): number { if (data) { - return dateSortValue(data.year, data.month, data.day); + return dateSortValue(data.year, data.month, data.day, data.hour, data.minute); } return -1; } diff --git a/src/util/util.ts b/src/util/util.ts index b2fb6488e8..6a24028fb7 100644 --- a/src/util/util.ts +++ b/src/util/util.ts @@ -63,25 +63,25 @@ export function defaults(dest: any, ...args: any[]) { /** @private */ -export function isBoolean(val: any) { return typeof val === 'boolean'; } +export function isBoolean(val: any): val is boolean { return typeof val === 'boolean'; } /** @private */ -export function isString(val: any) { return typeof val === 'string'; } +export function isString(val: any): val is string { return typeof val === 'string'; } /** @private */ -export function isNumber(val: any) { return typeof val === 'number'; } +export function isNumber(val: any): val is number { return typeof val === 'number'; } /** @private */ -export function isFunction(val: any) { return typeof val === 'function'; } +export function isFunction(val: any): val is Function { return typeof val === 'function'; } /** @private */ -export function isDefined(val: any) { return typeof val !== 'undefined'; } +export function isDefined(val: any): boolean { return typeof val !== 'undefined'; } /** @private */ -export function isUndefined(val: any) { return typeof val === 'undefined'; } +export function isUndefined(val: any): val is undefined { return typeof val === 'undefined'; } /** @private */ -export function isPresent(val: any) { return val !== undefined && val !== null; } +export function isPresent(val: any): val is any { return val !== undefined && val !== null; } /** @private */ -export function isBlank(val: any) { return val === undefined || val === null; } +export function isBlank(val: any): val is null { return val === undefined || val === null; } /** @private */ -export function isObject(val: any) { return typeof val === 'object'; } +export function isObject(val: any): val is Object { return typeof val === 'object'; } /** @private */ -export function isArray(val: any) { return Array.isArray(val); }; +export function isArray(val: any): val is any[] { return Array.isArray(val); }; /** @private */