addon/components/ember-date-range-picker.js
/**
* @module Components
*
*/
import $ from 'jquery';
import Component from '@ember/component';
import EmberObject, { set, get, computed } from '@ember/object';
import { next, later } from '@ember/runloop';
import { underscore, classify } from '@ember/string';
import { on } from '@ember/object/evented';
import { isEmpty, isNone } from '@ember/utils';
import { loc } from '@ember/string';
import { assert } from '@ember/debug';
import _state from '@busy-web/ember-date-time/utils/state';
import _time, { i18n } from '@busy-web/ember-date-time/utils/time';
import keyEvent from '@busy-web/ember-date-time/utils/key-event';
import { longFormatDate } from '@busy-web/ember-date-time/utils/format';
import layout from '../templates/components/ember-date-range-picker';
import {
DAY_FLAG
} from '@busy-web/ember-date-time/utils/constant';
/**
* `Component/Busyweb/EmberDateTimePicker`
*
* @class EmberDateTimePicker
*/
export default Component.extend({
/**
* @private
* @property classNames
* @type String
*/
classNames: ['busyweb', 'emberdatetime', 'c-date-range-picker'],
classNameBindings: ['large', 'right'],
layout,
large: false,
right: false,
/**
* timestamp that is passed in as a milliseconds timestamp
*
* @private
* @property timestamp
* @type Number
*/
startTime: null,
endTime: null,
/**
* timestamp that is passed in as a seconds timestamp
*
* @public
* @property unix
* @type number
*/
startUnix: null,
endUnix: null,
/**
* can be passed in so a date after the maxDate cannot be selected
*
* @private
* @property maxDate
* @type Number
* @optional
*/
maxDate: null,
/**
* can be passed in so a date before the minDate cannot be selected
*
* @private
* @property minDate
* @type Number
* @optional
*/
minDate: null,
/**
* set to true if the values passed in should not be converted to local time
*
* @public
* @property utc
* @type boolean
*/
utc: false,
format: 'MMM DD, YYYY',
_start: null,
_end: null,
_min: null,
_max: null,
hideTime: true,
hideDate: false,
defaultAction: '',
stateManager: null,
calendar: null,
isStart: true,
isListOpen: false,
isCustom: false,
allowCustom: true,
activeDates: null,
weekStart: 0,
defaultDateOnChange: true,
restrictAfterNow: false,
inputDataName: computed('elementId', function() {
return `range-picker-${get(this, 'elementId')}`;
}),
_startDate: computed('_start', function() {
return _time(get(this, '_start'));
}),
_endDate: computed('_end', function() {
return _time(get(this, '_end'));
}),
disableNext: computed('selected', '_start', '_max', function() {
if (get(this, 'restrictAfterNow')) {
const today = _time();
const start = _time(getStart(this));
const end = _time(getEnd(this));
if (today.valueOf() >= start.valueOf() && today.valueOf() <= end.valueOf()) {
return true;
}
}
const { startTime } = this.getInterval(1);
return get(this, '_max') < startTime;
}),
disablePrev: computed('selected', '_end', '_min', function() {
const { end } = this.getInterval(-1);
return get(this, '_min') > end;
}),
isInRange(date, start, end) {
return (date.valueOf() >= start.valueOf() && date.valueOf() <= end.valueOf())
},
selectedDateRange: computed('selected', '_start', '_end', 'format', function() {
const { id } = get(this, 'selected');
const start = _time(getStart(this));
const end = _time(getEnd(this));
let isCurrent = false;
if (this.isInRange(_time(), start, end)) {
isCurrent = true;
}
if (id === 'monthly') {
if (isCurrent) {
return i18n('this_month');
} else if (this.isInRange(_time().add(1, 'months'), start, end)) {
return i18n('next_month');
} else if (this.isInRange(_time().subtract(1, 'months'), start, end)) {
return i18n('last_month');
} else if (_time().year() !== start.year()) {
return classify(start.format('MMMM')) + ' ' + start.year();
}
return classify(start.format('MMMM'));
} else if (id === 'weekly') {
if (isCurrent) {
return i18n('this_week');
} else if (this.isInRange(_time().add(1, 'weeks'), start, end)) {
return i18n('next_week');
} else if (this.isInRange(_time().subtract(1, 'weeks'), start, end)) {
return i18n('last_week');
} else if (_time().year() !== start.year() && start.year() !== end.year()) {
return `${start.format('MMM D YYYY')} - ${end.format('MMM D YYYY')}`;
} else if (_time().year() !== start.year()) {
return `${start.format('MMM D')} - ${end.format('MMM D YYYY')}`;
}
return `${start.format('MMM D')} - ${end.format('MMM D')}`;
} else if (id === 'daily') {
if (isCurrent) {
return i18n('this_day');
} else if (this.isInRange(_time().add(1, 'days'), start, end)) {
return i18n('next_day');
} else if (this.isInRange(_time().subtract(1, 'days'), start, end)) {
return i18n('last_day');
} else if (_time().year() === start.year() && _time().week() === start.week()) {
return start.format('dddd');
} else if (_time().year() !== start.year()) {
return `${start.format('MMM D')} - ${end.format('MMM D YYYY')}`;
}
return `${start.format('MMM D')}`;
} else if (start.year() !== end.year()) {
return `${start.format('ll')} - ${end.format('ll')}`;
} else {
return `${start.format('MMM D')} - ${end.format('MMM D')}`;
}
}),
getAttr(key) {
const attrs = get(this, 'attrs');
if (!isNone(get(attrs, key))) {
return get(this, key);
}
return null;
},
setup: on('init', function() {
const isUnix = !isNone(get(this, 'startUnix')) || !isNone(get(this, 'endUnix'));
set(this, '_isUnix', isUnix);
// get locale converted format str
let format = get(this, 'format');
format = longFormatDate(format);
assert(
`Moment format "${get(this, 'format')}" is not supported. All format strings must be a combination of "DD" "MM" "YYYY" with one of the following delimeters [ -./, ] and no spaces`,
/^(DD.MM.YYYY|DD.YYYY.MM|MM.DD.YYYY|MM.YYYY.DD|YYYY.MM.DD|YYYY.DD.MM)$/.test(format)
);
set(this, '__dayIndex', format.search(/D(D|o)/));
set(this, '__monthIndex', format.search(/M(M|o)/));
set(this, '__format', format);
if (isNone(get(this, 'activeDates'))) {
set(this, 'activeDates', []);
}
if (!get(this, 'changeFired') && (!isNone(this.getAttr('startTime')) || !isNone(this.getAttr('startUnix')))) {
setStart(this, setUserStart(this, (this.getAttr('startTime') || this.getAttr('startUnix'))));
} else if (isNone(getStart(this))) {
setStart(this, _time().timestamp());
}
if (!get(this, 'changeFired') && (!isNone(this.getAttr('endTime')) || !isNone(this.getAttr('endUnix')))) {
setEnd(this, setUserEnd(this, (this.getAttr('endTime') || this.getAttr('endUnix'))));
} else if (isNone(getEnd(this))) {
setEnd(this, _time().timestamp());
}
if (get(this, 'changeFired')) {
set(this, 'changeFired', false);
} else {
this.setState();
}
if (!isNone(this.getAttr('minDate'))) {
let min = this.getAttr('minDate');
if (isUnix) {
min = _time.unix(min).timestamp();
}
min = _time(min).startOf('day').timestamp();
if (get(this, '_min') !== min) {
set(this, '_min', min);
}
}
if (!isNone(get(this, 'maxDate'))) {
let max = get(this, 'maxDate');
if (isUnix) {
max = _time.unix(max).timestamp();
}
max = _time(max).endOf('day').valueOf();
if (get(this, '_max') !== max) {
set(this, '_max', max);
}
}
let actionList = get(this, '__actionList') || [];
if (isEmpty(actionList)) {
let tList = [];
let sortKey = 400;
(this.getAttr('actionList') || []).forEach(item => {
if (!item.get && !item.set) {
item = EmberObject.create(item);
}
let name = get(item, 'name');
assert("Action list items must contain a `name` property", !isEmpty(name));
if (isNone(item, 'id')) {
set(item, 'id', underscore(name));
}
if (isNone(get(item, 'sort'))) {
set(item, 'sort', sortKey);
sortKey += 1;
}
set(item, 'selected', false);
tList.push(item);
});
actionList = tList;
// action list is the list used in the select menu.
//
// id {string} - string id passed around for reference to a list item
// name {string} - the label to display in the list
// span {number|function} - the time span in time relational to {type} if function is provided it will be passed the current timestamp
// type {string} - the units used to calculate the time {span}
// sort {number} - a weighted number used to sort the list
// selected {boolean} a true if the item is currently the selected item
actionList.push(EmberObject.create({id: 'daily', name: loc('Daily'), span: 1, type: 'days', sort: 100, selected: false}));
actionList.push(EmberObject.create({id: 'weekly', name: loc('Weekly'), span: 1, type: 'weeks', sort: 200, selected: false}));
actionList.push(EmberObject.create({id: 'monthly', name: loc('Monthly'), span: 1, type: 'months', sort: 300, selected: false}));
actionList = actionList.sort((a, b) => get(a, 'sort') > get(b, 'sort') ? 1 : -1);
set(this, '__actionList', actionList);
}
if (isNone(get(this, 'selected'))) {
this.setSelected();
this.saveState();
}
let elementId = get(this, 'elementId');
$('body').off(`keydown.${elementId}`);
$('body').on(`keydown.${elementId}`, keyDownEventHandler(this));
$('body').off(`click.${elementId}`);
$('body').on(`click.${elementId}`, clickEventHandler(this));
}),
destroy: on('willDestroyElement', function() {
$('body').off(`keydown.${get(this, 'elementId')}`);
$('body').off(`click.${get(this, 'elementId')}`);
}),
getDefaultAction() {
if (get(this, 'isCustom')) {
const span = _time.daysApart(getStart(this), getEnd(this)) + 1;
return EmberObject.create({name: loc('Custom'), span, type: 'days'});
} else if (!isEmpty(get(this, 'defaultAction'))) {
return get(this, '__actionList').findBy('id', get(this, 'defaultAction'));
} else {
const start = getStart(this);
const end = getEnd(this);
const startDate = _time(start);
const endDate = _time(end);
let span = Math.abs(startDate.diff(endDate, 'days'));
let diff = Number.MAX_VALUE;
let selected;
get(this, '__actionList').forEach(item => {
if (typeof item.span !== 'function') {
const timeSpan = _time(start).add(item.span, item.type);
const itemSpan = Math.abs(startDate.diff(timeSpan, 'days'));
const nDiff = Math.abs(itemSpan - span);
if (diff > nDiff) {
selected = item;
diff = nDiff;
}
}
});
return selected;
}
},
setActiveState(isStart) {
if (get(this, 'allowCustom')) {
set(this, 'isStart', isStart);
} else {
set(this, 'isStart', true);
}
},
getInterval(direction=0) {
let { span, type } = get(this, 'selected');
let start, end;
if (!isEmpty(type) && !isNone(span)) {
start = _time(getStart(this)).valueOf();
end = _time(getEnd(this)).valueOf();
let currentSpanInSeconds = _time(end).unix() - _time(start).unix();
let selectedSpanInSeconds = _time(start).add(span, type).unix() - _time(start).unix();
if (typeof span === 'function') {
start = getUserStart(this);
end = getUserEnd(this);
// get range defined by span function
let range = span.call(get(this, 'selected'), start, end, direction);
start = setUserStart(this, range.start);
end = setUserEnd(this, range.end);
} else {
// if the current time span is greater than selected time span
// then set the new start to the end of the current period.
// this will keep the date more current.
if (currentSpanInSeconds > selectedSpanInSeconds) {
start = _time(getEnd(this)).startOf('day').valueOf();
if (!isNone(get(this, '_max')) && start > get(this, '_max')) {
start = _time(get(this, '_max')).subtract(span, type).startOf('day').valueOf();
}
}
if (type === 'weeks') {
if (_time(start).day() !== get(this, 'weekStart')) {
let __start = _time(start).day(get(this, 'weekStart')).valueOf();
if (start < __start) {
__start = _time(__start).subtract(7, 'days').valueOf();
}
start = __start;
}
} else if (type === 'months') {
// for months use startof month for proper month alignment
start = _time(start).startOf('month').valueOf();
}
if (direction === -1) {
start = _time(start).subtract(span, type).valueOf();
} else if (direction === 1) {
start = _time(start).add(span, type).valueOf();
} else {
start = _time(start).startOf('day').valueOf();
}
start = start.valueOf();
end = _time(start).add(span, type).subtract(1, 'days').endOf('day').valueOf();
}
}
return { start, end };
},
changeInterval(direction=0) {
let intervalWait = get(this, '__intervalWait');
if (!isNone(intervalWait)) {
clearTimeout(intervalWait);
intervalWait = null;
}
const { start, end } = this.getInterval(direction);
if (!isNone(start) && !isNone(end)) {
setStart(this, start);
setEnd(this, end);
intervalWait = setTimeout(() => {
this.triggerDateChange();
}, 500);
}
set(this, '__intervalWait', intervalWait);
},
/**
* sets the state object for
* the date-picker component to get date information
*
* @public
* @method setState
*/
setState() {
let start = getStart(this);
let end = getEnd(this);
let timestamp = start;
let calendarDate = get(this, 'calendarDate');
let minDate = get(this, '_min');
let maxDate = get(this, '_max');
let format = get(this, 'format');
if (!get(this, 'isStart')) {
timestamp = end;
}
const startRange = _time(start).startOf('day').valueOf();
const endRange = _time(end).startOf('day').valueOf();
if (isNone(get(this, 'stateManager'))) {
set(this, 'stateManager', _state({
isOpen: true, // isOpen should always be true
timestamp, calendarDate,
minDate, maxDate,
format,
range: [startRange, endRange]
}));
} else {
set(this, 'stateManager.timestamp', timestamp);
set(this, 'stateManager.calendarDate', calendarDate);
set(this, 'stateManager.minDate', minDate);
set(this, 'stateManager.maxDate', maxDate);
set(this, 'stateManager.format', format);
set(this, 'stateManager.range', [startRange, endRange]);
}
},
/**
* triggeres a date change event to send off
* to listeners of `onChange`
*
* @public
* @method triggerDateChange
*/
triggerDateChange() {
let start = getUserStart(this);
let end = getUserEnd(this);
this.setState();
set(this, 'changeFired', true);
let selectedType = get(this, 'isCustom') ? 'custom' : get((get(this, '__actionList').findBy('selected', true) || {id: 'weekly'}), 'id');
this.sendAction('onChange', start, end, get(this, 'isCustom'), selectedType);
},
/**
* sets the focus to on of the inputs based on the boolean passed in.
*
* true sets focus to the start time input
*
* @public
* @method focusActive
* @params isStart {boolean}
*/
focusActive(selection=0) {
let isStart = get(this, 'isStart');
set(this, '__saveFocus', { isStart, selection });
let input = (isStart) ? 'start' : 'end';
let el = this.$(`.state.${input} > input`);
if (el && el.data) {
el.data('selection', selection);
el.data('position', 0);
next(() => el.focus());
}
},
/**
* Update the start or end time date where the date will also set the other
* if it is incalid
*
* @public
* @method updateDates
* @params type {string} the type of setter day or month
* @params timestamp {number} milliseconds timestamp
* @params calendar {number} milliseconds timestamp
* @params singleSet {boolean} flag to only set start or end time unless a special case exists. This is for keyboards inputs
*/
updateDates(type, time, calendar, singleSet=false) {
let isStart = get(this, 'isStart');
if (!get(this, 'allowCustom')) {
isStart = true;
}
if (type === DAY_FLAG) {
if (!singleSet && !isStart && time < getStart(this)) {
isStart = true;
}
// set the start or end time
// based off the current active state
if (isStart) {
setStart(this, time);
} else {
setEnd(this, time);
}
if (!singleSet && isStart) {
// if active state is the start and its
// not a singleSet mode then set the end as well
setEnd(this, time);
} else if (isStart && time > getEnd(this)) {
// if active state is start and the time
// is greater than the end then set the end time
setEnd(this, time);
} else if (!isStart && time < getStart(this)) {
// if active state is end and the time
// is less than the start then set the start time
setStart(this, time);
}
// always set the calendar for start times and all
// single set times
if (isStart || singleSet) {
set(this, 'calendarDate', time);
}
// set the active state
this.setActiveState(get(this, 'isStart'));
// update the dates for the calendar
this.setState();
} else {
set(this, 'calendarDate', calendar);
}
return isStart;
},
/**
* set the select menu to the selected type by id
*
* @public
* @method setSelected
* @params id {string} the id of the menu type to set as the selected menu item
* @return {object} the selected item
*/
setSelected(id) {
// reset selected list
get(this, '__actionList').forEach(item => set(item, 'selected', false));
let selected;
if (isNone(id)) {
selected = this.getDefaultAction();
} else if (id === 'custom') {
set(this, 'isCustom', true);
const span = _time.daysApart(getStart(this), getEnd(this)) + 1;
selected = EmberObject.create({id: 'custom', name: loc('Custom'), span, type: 'days'});
} else {
set(this, 'isCustom', false);
selected = get(this, '__actionList').findBy('id', id);
if (get(this, 'defaultDateOnChange')) {
if (get(this, 'selected.id') !== get(selected, 'id')) {
setStart(this, _time().startOf('day').valueOf());
setEnd(this, _time().endOf('day').valueOf());
}
}
}
set(selected, 'selected', true);
set(this, 'selected', selected);
return selected;
},
/**
* Save the current state of the select menu and
* start end dates
*
* @public
* @method saveState
*/
saveState() {
let id = get(this, 'selected.id');
set(this, '__saveState', {
isCustom: id === 'custom',
selectedId: id,
start: getStart(this),
end: getEnd(this)
});
},
/**
* restore the state to the previous state of the select menu
* and start end dates
*
* @public
* @method restoreState
*/
restoreState() {
if (!isNone(get(this, '__saveState'))) {
setStart(this, get(this, '__saveState.start'));
setEnd(this, get(this, '__saveState.end'));
this.setSelected(get(this, '__saveState.selectedId'));
}
},
closeMenu() {
let focus = this.$('.date-range-picker-dropdown > .focus');
if (focus.length) {
focus.removeClass('focus');
}
set(this, 'isListOpen', false);
},
openMenu() {
set(this, 'isListOpen', true);
this.focusActive();
},
click(event) {
if (get(this, 'isListOpen')) {
let el = $(event.target);
if (el.parents('.date-range-picker-menu').length || el.hasClass('date-range-picker-menu')) {
let focus = get(this, '__saveFocus');
this.setActiveState(focus.isStart);
this.focusActive(focus.selected);
}
}
},
actions: {
intervalBack() {
if (!get(this, 'disablePrev')) {
this.changeInterval(-1);
}
},
intervalForward() {
if (!get(this, 'disableNext')) {
this.changeInterval(1);
}
},
toggleList() {
if (!get(this, 'isListOpen')) {
this.openMenu();
} else {
this.closeMenu();
}
},
setFocus(val, event) {
let index = event.target.selectionStart;
if (val === 'start') {
this.setActiveState(true);
set(this, '__saveFocus', { isStart: true, selected: index });
} else if (val === 'end') {
this.setActiveState(false);
set(this, '__saveFocus', { isStart: false, selected: index });
}
},
selectItem(id) {
if (!get(this, 'selected') !== id) {
this.saveState();
this.setSelected(id);
}
this.closeMenu();
this.changeInterval();
},
selectCustom() {
this.saveState();
this.setSelected('custom');
later(() => {
this.focusActive();
}, 100);
},
applyRange() {
this.saveState();
if (get(this, 'allowCustom')) {
this.setSelected('custom');
}
this.closeMenu();
this.setActiveState(true);
this.changeInterval();
},
cancelRange() {
this.restoreState();
this.closeMenu();
this.setActiveState(true);
},
dateChange(time) {
this.updateDates(DAY_FLAG, time, null, true);
},
updateTime(state, time) {
let isStart = this.updateDates(DAY_FLAG, time);
this.setActiveState(!isStart);
// resets the focus after the user clicks a day
this.focusActive(get(this, '__dayIndex'));
},
tabAction() {
this.setActiveState(!get(this, 'isStart'));
// change focus to next input
this.focusActive();
},
/**
* update the clicked value for days and months
* and set the focus back to the input when done
*
*/
calendarChange(type, time, calendar) {
let isStart;
if (type === DAY_FLAG) {
isStart = this.updateDates(type, time, calendar);
this.setActiveState(!isStart);
this.focusActive(get(this, '__dayIndex'));
} else {
this.updateDates(type, time, calendar);
this.focusActive(get(this, '__monthIndex'));
}
}
}
});
function getUserStart(target) {
let time = getStart(target);
if (get(target, 'utc')) {
time = _time.utcFromLocal(time).timestamp();
}
if (get(target, '_isUnix')) {
time = _time(time).unix();
}
return time;
}
function getUserEnd(target) {
let time = getEnd(target);
if (get(target, 'utc')) {
time = _time.utcFromLocal(time).timestamp();
}
if (get(target, '_isUnix')) {
time = _time(time).unix();
}
return time;
}
function setUserStart(target, time) {
if (get(target, '_isUnix')) {
time = _time.unix(time).timestamp();
}
if (get(target, 'utc')) {
time = _time.utcToLocal(time).timestamp();
}
return time;
}
function setUserEnd(target, time) {
if (get(target, '_isUnix')) {
time = _time.unix(time).timestamp();
}
if (get(target, 'utc')) {
time = _time.utcToLocal(time).timestamp();
}
return time;
}
function getStart(target) {
return get(target, '_start');
}
function getEnd(target) {
return get(target, '_end');
}
function setStart(target, time) {
time = _time(time).startOf('day').valueOf();
if (getStart(target) !== time) {
set(target, '_start', time);
}
}
function setEnd(target, time) {
time = _time(time).endOf('day').valueOf();
if (getEnd(target) !== time) {
set(target, '_end', time);
}
}
function findAction(target, key) {
let actions = get(target, '__actionList').map(i => {
let id = i.id;
let regx = new RegExp('^' + id.charAt(0).toLowerCase() + '$');
return { id, regx };
});
actions.push({ id: 'custom', regx: /^C$/});
let res = actions.find(i => i.regx.test(key));
if (isNone(res)) {
res = { id: null, regx: null };
}
return res;
}
function handleAltKeys(target, keyName, isOpen) {
if (keyName === '/') {
target.send('toggleList');
} else {
let selectedId = get(target, 'selected.id');
let { id } = findAction(target, keyName);
if (!isNone(id)) {
if (selectedId !== id) {
if (id === 'custom') {
if (!isOpen) {
target.send('selectCustom');
}
target.send('toggleList');
} else {
target.send('selectItem', id);
}
}
}
}
}
function keyDownEventHandler(target) {
return function(event) {
let isOpen = get(target, 'isListOpen');
let handler = keyEvent({ event });
if (event.altKey) {
if (handler.type === 'letter' || handler.type === 'math') {
handleAltKeys(target, handler.keyName, isOpen);
} else if (handler.keyName === 'ArrowLeft') {
if (!isOpen) {
target.send('intervalBack');
}
} else if (handler.keyName === 'ArrowRight') {
if (!isOpen) {
target.send('intervalForward');
}
}
} else {
if (handler.keyName === 'Tab') {
if (isOpen) {
target.send('tabAction', event);
}
} else if (handler.keyName === 'Escape') {
if (isOpen) {
target.send('toggleList');
}
} else if (handler.keyName === 'ArrowDown') {
if (isOpen) {
nextListItem(target);
}
} else if (handler.keyName === 'ArrowUp') {
if (isOpen) {
prevListItem(target);
}
} else if (handler.keyName === 'Enter') {
if (isOpen) {
let selected = target.$('.date-range-picker-dropdown > .focus');
if (selected.length) {
selected.click();
}
}
}
}
return true;
}
}
function nextListItem(target) {
let element = target.$('.date-range-picker-dropdown');
let active = element.find('.focus');
if (!active.length) {
active = element.find('.active');
}
let next = active.next('.item');
if (!next.length) {
next = element.children().first();
}
next.addClass('focus');
active.removeClass('focus');
}
function prevListItem(target) {
let element = target.$('.date-range-picker-dropdown');
let active = element.find('.focus');
if (!active.length) {
active = element.find('.active');
}
let next = active.prev('.item');
if (!next.length) {
next = element.children('.item').last();
}
next.addClass('focus');
active.removeClass('focus');
}
function clickEventHandler(target) {
return function(evt) {
let el = $(evt.target);
let isOpen = get(target, 'isListOpen');
if (isOpen) {
if (el.parents('.c-date-range-picker').length) { // is in date picker
if (!el.parents('.date-range-picker-dropdown').length && !el.hasClass('select') && !el.hasClass('date-range-picker-dropdown')) {
target.send('toggleList');
}
} else {
target.send('toggleList');
}
}
}
}