tui-calendar
Version:
TOAST UI Calendar
804 lines (704 loc) • 25.2 kB
JavaScript
/**
* @fileoverview Floating layer for writing new schedules
* @author NHN FE Development Lab <dl_javascript@nhn.com>
*/
'use strict';
var View = require('../../view/view');
var FloatingLayer = require('../../common/floatingLayer');
var util = require('tui-code-snippet');
var DatePicker = require('tui-date-picker');
var timezone = require('../../common/timezone');
var config = require('../../config');
var domevent = require('../../common/domevent');
var domutil = require('../../common/domutil');
var common = require('../../common/common');
var datetime = require('../../common/datetime');
var tmpl = require('../template/popup/scheduleCreationPopup.hbs');
var TZDate = timezone.Date;
var MAX_WEEK_OF_MONTH = 6;
/**
* @constructor
* @extends {View}
* @param {HTMLElement} container - container element
* @param {Array.<Calendar>} calendars - calendar list used to create new schedule
* @param {boolean} usageStatistics - GA tracking options in Calendar
*/
function ScheduleCreationPopup(container, calendars, usageStatistics) {
View.call(this, container);
/**
* @type {FloatingLayer}
*/
this.layer = new FloatingLayer(null, container);
/**
* cached view model
* @type {object}
*/
this._viewModel = null;
this._selectedCal = null;
this._schedule = null;
this.calendars = calendars;
this._focusedDropdown = null;
this._usageStatistics = usageStatistics;
this._onClickListeners = [
this._selectDropdownMenuItem.bind(this),
this._toggleDropdownMenuView.bind(this),
this._closeDropdownMenuView.bind(this, null),
this._closePopup.bind(this),
this._toggleIsAllday.bind(this),
this._toggleIsPrivate.bind(this),
this._onClickSaveSchedule.bind(this)
];
this._datepickerState = {
start: null,
end: null,
isAllDay: false
};
domevent.on(container, 'click', this._onClick, this);
}
util.inherit(ScheduleCreationPopup, View);
/**
* Mousedown event handler for hiding popup layer when user mousedown outside of
* layer
* @param {MouseEvent} mouseDownEvent - mouse event object
*/
ScheduleCreationPopup.prototype._onMouseDown = function(mouseDownEvent) {
var target = domevent.getEventTarget(mouseDownEvent),
popupLayer = domutil.closest(target, config.classname('.floating-layer'));
if (popupLayer) {
return;
}
this.hide();
};
/**
* @override
*/
ScheduleCreationPopup.prototype.destroy = function() {
this.layer.destroy();
this.layer = null;
if (this.rangePicker) {
this.rangePicker.destroy();
this.rangePicker = null;
}
domevent.off(this.container, 'click', this._onClick, this);
domevent.off(document.body, 'mousedown', this._onMouseDown, this);
View.prototype.destroy.call(this);
};
/**
* @override
* Click event handler for close button
* @param {MouseEvent} clickEvent - mouse event object
*/
ScheduleCreationPopup.prototype._onClick = function(clickEvent) {
var target = domevent.getEventTarget(clickEvent);
util.forEach(this._onClickListeners, function(listener) {
return !listener(target);
});
};
/**
* Test click event target is close button, and return layer is closed(hidden)
* @param {HTMLElement} target click event target
* @returns {boolean} whether popup layer is closed or not
*/
ScheduleCreationPopup.prototype._closePopup = function(target) {
var className = config.classname('popup-close');
if (domutil.hasClass(target, className) || domutil.closest(target, '.' + className)) {
this.hide();
return true;
}
return false;
};
/**
* Toggle dropdown menu view, when user clicks dropdown button
* @param {HTMLElement} target click event target
* @returns {boolean} whether user clicked dropdown button or not
*/
ScheduleCreationPopup.prototype._toggleDropdownMenuView = function(target) {
var className = config.classname('dropdown-button');
var dropdownBtn = domutil.hasClass(target, className) ? target : domutil.closest(target, '.' + className);
if (!dropdownBtn) {
return false;
}
if (domutil.hasClass(dropdownBtn.parentNode, config.classname('open'))) {
this._closeDropdownMenuView(dropdownBtn.parentNode);
} else {
this._openDropdownMenuView(dropdownBtn.parentNode);
}
return true;
};
/**
* Close drop down menu
* @param {HTMLElement} dropdown - dropdown element that has a opened dropdown menu
*/
ScheduleCreationPopup.prototype._closeDropdownMenuView = function(dropdown) {
dropdown = dropdown || this._focusedDropdown;
if (dropdown) {
domutil.removeClass(dropdown, config.classname('open'));
this._focusedDropdown = null;
}
};
/**
* Open drop down menu
* @param {HTMLElement} dropdown - dropdown element that has a closed dropdown menu
*/
ScheduleCreationPopup.prototype._openDropdownMenuView = function(dropdown) {
domutil.addClass(dropdown, config.classname('open'));
this._focusedDropdown = dropdown;
};
/**
* If click dropdown menu item, close dropdown menu
* @param {HTMLElement} target click event target
* @returns {boolean} whether
*/
ScheduleCreationPopup.prototype._selectDropdownMenuItem = function(target) {
var itemClassName = config.classname('dropdown-menu-item');
var iconClassName = config.classname('icon');
var contentClassName = config.classname('content');
var selectedItem = domutil.hasClass(target, itemClassName) ? target : domutil.closest(target, '.' + itemClassName);
var bgColor, title, dropdown, dropdownBtn;
if (!selectedItem) {
return false;
}
bgColor = domutil.find('.' + iconClassName, selectedItem).style.backgroundColor || 'transparent';
title = domutil.find('.' + contentClassName, selectedItem).innerHTML;
dropdown = domutil.closest(selectedItem, config.classname('.dropdown'));
dropdownBtn = domutil.find(config.classname('.dropdown-button'), dropdown);
domutil.find('.' + contentClassName, dropdownBtn).innerText = title;
if (domutil.hasClass(dropdown, config.classname('section-calendar'))) {
domutil.find('.' + iconClassName, dropdownBtn).style.backgroundColor = bgColor;
this._selectedCal = common.find(this.calendars, function(cal) {
return String(cal.id) === domutil.getData(selectedItem, 'calendarId');
});
}
domutil.removeClass(dropdown, config.classname('open'));
return true;
};
/**
* Toggle allday checkbox state
* @param {HTMLElement} target click event target
* @returns {boolean} whether event target is allday section or not
*/
ScheduleCreationPopup.prototype._toggleIsAllday = function(target) {
var className = config.classname('section-allday');
var alldaySection = domutil.hasClass(target, className) ? target : domutil.closest(target, '.' + className);
var checkbox;
if (alldaySection) {
checkbox = domutil.find(config.classname('.checkbox-square'), alldaySection);
checkbox.checked = !checkbox.checked;
this.rangePicker.destroy();
this.rangePicker = null;
this._setDatepickerState({isAllDay: checkbox.checked});
this._createDatepicker();
return true;
}
return false;
};
/**
* Toggle private button
* @param {HTMLElement} target click event target
* @returns {boolean} whether event target is private section or not
*/
ScheduleCreationPopup.prototype._toggleIsPrivate = function(target) {
var className = config.classname('section-private');
var privateSection = domutil.hasClass(target, className) ? target : domutil.closest(target, '.' + className);
if (privateSection) {
if (domutil.hasClass(privateSection, config.classname('public'))) {
domutil.removeClass(privateSection, config.classname('public'));
} else {
domutil.addClass(privateSection, config.classname('public'));
}
return true;
}
return false;
};
/**
* Save new schedule if user clicked save button
* @emits ScheduleCreationPopup#saveSchedule
* @param {HTMLElement} target click event target
* @returns {boolean} whether save button is clicked or not
*/
// eslint-disable-next-line complexity
ScheduleCreationPopup.prototype._onClickSaveSchedule = function(target) {
var className = config.classname('popup-save');
var cssPrefix = config.cssPrefix;
var title;
var startDate;
var endDate;
var rangeDate;
var form;
var isAllDay;
if (!domutil.hasClass(target, className) && !domutil.closest(target, '.' + className)) {
return false;
}
title = domutil.get(cssPrefix + 'schedule-title');
startDate = new TZDate(this.rangePicker.getStartDate());
endDate = new TZDate(this.rangePicker.getEndDate());
if (!this._validateForm(title, startDate, endDate)) {
if (!title.value) {
title.focus();
}
return false;
}
isAllDay = !!domutil.get(cssPrefix + 'schedule-allday').checked;
rangeDate = this._getRangeDate(startDate, endDate, isAllDay);
form = {
calendarId: this._selectedCal ? this._selectedCal.id : null,
title: title,
location: domutil.get(cssPrefix + 'schedule-location'),
start: rangeDate.start,
end: rangeDate.end,
isAllDay: isAllDay,
state: domutil.get(cssPrefix + 'schedule-state').innerText,
isPrivate: !domutil.hasClass(domutil.get(cssPrefix + 'schedule-private'), config.classname('public'))
};
if (this._isEditMode) {
this._onClickUpdateSchedule(form);
} else {
this._onClickCreateSchedule(form);
}
this.hide();
return true;
};
/**
* @override
* @param {object} viewModel - view model from factory/monthView
*/
ScheduleCreationPopup.prototype.render = function(viewModel) {
var calendars = this.calendars;
var layer = this.layer;
var boxElement, guideElements, defaultStartDate, defaultEndDate;
viewModel.zIndex = this.layer.zIndex + 5;
viewModel.calendars = calendars;
if (calendars.length) {
viewModel.selectedCal = this._selectedCal = calendars[0];
}
this._isEditMode = viewModel.schedule && viewModel.schedule.id;
if (this._isEditMode) {
boxElement = viewModel.target;
viewModel = this._makeEditModeData(viewModel);
} else {
this.guide = viewModel.guide;
guideElements = this._getGuideElements(this.guide);
boxElement = guideElements.length ? guideElements[0] : null;
}
layer.setContent(tmpl(viewModel));
defaultStartDate = new TZDate(viewModel.start);
defaultEndDate = new TZDate(viewModel.end);
// NOTE: Setting default start/end time when editing all-day schedule first time.
// This logic refers to Apple calendar's behavior.
if (viewModel.isAllDay) {
defaultStartDate.setHours(12, 0, 0);
defaultEndDate.setHours(13, 0, 0);
}
this._setDatepickerState({
start: defaultStartDate,
end: defaultEndDate,
isAllDay: viewModel.isAllDay
});
this._createDatepicker();
layer.show();
if (boxElement) {
this._setPopupPositionAndArrowDirection(boxElement.getBoundingClientRect());
}
util.debounce(function() {
domevent.on(document.body, 'mousedown', this._onMouseDown, this);
}.bind(this))();
};
/**
* Make view model for edit mode
* @param {object} viewModel - original view model from 'beforeCreateEditPopup'
* @returns {object} - edit mode view model
*/
ScheduleCreationPopup.prototype._makeEditModeData = function(viewModel) {
var schedule = viewModel.schedule;
var title, isPrivate, location, startDate, endDate, isAllDay, state;
var calendars = this.calendars;
var id = schedule.id;
title = schedule.title;
isPrivate = schedule.isPrivate;
location = schedule.location;
startDate = schedule.start;
endDate = schedule.end;
isAllDay = schedule.isAllDay;
state = schedule.state;
viewModel.selectedCal = this._selectedCal = common.find(this.calendars, function(cal) {
return cal.id === viewModel.schedule.calendarId;
});
this._schedule = schedule;
return {
id: id,
selectedCal: this._selectedCal,
calendars: calendars,
title: title,
isPrivate: isPrivate,
location: location,
isAllDay: isAllDay,
state: state,
start: startDate,
end: endDate,
zIndex: this.layer.zIndex + 5,
isEditMode: this._isEditMode
};
};
ScheduleCreationPopup.prototype._setDatepickerState = function(newState) {
util.extend(this._datepickerState, newState);
};
/**
* Set popup position and arrow direction to appear near guide element
* @param {MonthCreationGuide|TimeCreationGuide|DayGridCreationGuide} guideBound - creation guide element
*/
ScheduleCreationPopup.prototype._setPopupPositionAndArrowDirection = function(guideBound) {
var layer = domutil.find(config.classname('.popup'), this.layer.container);
var layerSize = {
width: layer.offsetWidth,
height: layer.offsetHeight
};
var containerBound = this.container.getBoundingClientRect();
var pos = this._calcRenderingData(layerSize, containerBound, guideBound);
this.layer.setPosition(pos.x, pos.y);
this._setArrowDirection(pos.arrow);
};
/**
* Get guide elements from creation guide object
* It is used to calculate rendering position of popup
* It will be disappeared when hiding popup
* @param {MonthCreationGuide|TimeCreationGuide|AlldayCreationGuide} guide - creation guide
* @returns {Array.<HTMLElement>} creation guide element
*/
ScheduleCreationPopup.prototype._getGuideElements = function(guide) {
var guideElements = [];
var i = 0;
if (guide.guideElement) {
guideElements.push(guide.guideElement);
} else if (guide.guideElements) {
for (; i < MAX_WEEK_OF_MONTH; i += 1) {
if (guide.guideElements[i]) {
guideElements.push(guide.guideElements[i]);
}
}
}
return guideElements;
};
/**
* Get guide element's bound data which only includes top, right, bottom, left
* @param {Array.<HTMLElement>} guideElements - creation guide elements
* @returns {Object} - popup bound data
*/
ScheduleCreationPopup.prototype._getBoundOfFirstRowGuideElement = function(guideElements) {
var bound;
if (!guideElements.length) {
return null;
}
bound = guideElements[0].getBoundingClientRect();
return {
top: bound.top,
left: bound.left,
bottom: bound.bottom,
right: bound.right
};
};
/**
* Get calculate rendering positions of y and arrow direction by guide block elements
* @param {number} guideBoundTop - guide block's top
* @param {number} guideBoundBottom - guide block's bottom
* @param {number} layerHeight - popup layer's height
* @param {number} containerTop - container's top
* @param {number} containerBottom - container's bottom
* @returns {YAndArrowDirection} y and arrowDirection
*/
ScheduleCreationPopup.prototype._getYAndArrowDirection = function(
guideBoundTop,
guideBoundBottom,
layerHeight,
containerTop,
containerBottom
) {
var arrowDirection = 'arrow-bottom';
var MARGIN = 3;
var y = guideBoundTop - layerHeight;
if (y < containerTop) {
y = guideBoundBottom - containerTop + MARGIN;
arrowDirection = 'arrow-top';
} else {
y = y - containerTop - MARGIN;
}
if (y + layerHeight > containerBottom) {
y = containerBottom - layerHeight - containerTop - MARGIN;
}
/**
* @typedef {Object} YAndArrowDirection
* @property {number} y - top position of popup layer
* @property {string} [arrowDirection] - direction of popup arrow
*/
return {
y: y,
arrowDirection: arrowDirection
};
};
/**
* Get calculate rendering x position and arrow left by guide block elements
* @param {number} guideBoundLeft - guide block's left
* @param {number} guideBoundRight - guide block's right
* @param {number} layerWidth - popup layer's width
* @param {number} containerLeft - container's left
* @param {number} containerRight - container's right
* @returns {XAndArrowLeft} x and arrowLeft
*/
ScheduleCreationPopup.prototype._getXAndArrowLeft = function(
guideBoundLeft,
guideBoundRight,
layerWidth,
containerLeft,
containerRight
) {
var guideHorizontalCenter = (guideBoundLeft + guideBoundRight) / 2;
var x = guideHorizontalCenter - (layerWidth / 2);
var ARROW_WIDTH_HALF = 8;
var arrowLeft;
if (x + layerWidth > containerRight) {
x = guideBoundRight - layerWidth + ARROW_WIDTH_HALF;
arrowLeft = guideHorizontalCenter - x;
} else {
x += ARROW_WIDTH_HALF;
}
if (x < containerLeft) {
x = 0;
arrowLeft = guideHorizontalCenter - containerLeft - ARROW_WIDTH_HALF;
} else {
x = x - containerLeft - ARROW_WIDTH_HALF;
}
/**
* @typedef {Object} XAndArrowLeft
* @property {number} x - left position of popup layer
* @property {numbe3er} arrowLeft - relative position of popup arrow, if it is not set, arrow appears on the middle of popup
*/
return {
x: x,
arrowLeft: arrowLeft
};
};
/**
* Calculate rendering position usering guide elements
* @param {{width: {number}, height: {number}}} layerSize - popup layer's width and height
* @param {{top: {number}, left: {number}, right: {number}, bottom: {number}}} containerBound - width and height of the upper layer, that acts as a border of popup
* @param {{top: {number}, left: {number}, right: {number}, bottom: {number}}} guideBound - guide element bound data
* @returns {PopupRenderingData} rendering position of popup and popup arrow
*/
ScheduleCreationPopup.prototype._calcRenderingData = function(layerSize, containerBound, guideBound) {
var yPosInfo = this._getYAndArrowDirection(
guideBound.top,
guideBound.bottom,
layerSize.height,
containerBound.top,
containerBound.bottom
);
var xPosInfo = this._getXAndArrowLeft(
guideBound.left,
guideBound.right,
layerSize.width,
containerBound.left,
containerBound.right
);
/**
* @typedef {Object} PopupRenderingData
* @property {number} x - left position
* @property {number} y - top position
* @property {string} arrow.direction - direction of popup arrow
* @property {number} [arrow.position] - relative position of popup arrow, if it is not set, arrow appears on the middle of popup
*/
return {
x: xPosInfo.x,
y: yPosInfo.y,
arrow: {
direction: yPosInfo.arrowDirection,
position: xPosInfo.arrowLeft
}
};
};
/**
* Set arrow's direction and position
* @param {Object} arrow rendering data for popup arrow
*/
ScheduleCreationPopup.prototype._setArrowDirection = function(arrow) {
var direction = arrow.direction || 'arrow-bottom';
var arrowEl = domutil.get(config.classname('popup-arrow'));
var borderElement = domutil.find(config.classname('.popup-arrow-border', arrowEl));
if (direction !== config.classname('arrow-bottom')) {
domutil.removeClass(arrowEl, config.classname('arrow-bottom'));
domutil.addClass(arrowEl, config.classname(direction));
}
if (arrow.position) {
borderElement.style.left = arrow.position + 'px';
}
};
/**
* Create date range picker using start date and end date
*/
ScheduleCreationPopup.prototype._createDatepicker = function() {
var cssPrefix = config.cssPrefix;
var start = this._datepickerState.start;
var end = this._datepickerState.end;
var isAllDay = this._datepickerState.isAllDay;
this.rangePicker = DatePicker.createRangePicker({
startpicker: {
date: new TZDate(start).toDate(),
input: '#' + cssPrefix + 'schedule-start-date',
container: '#' + cssPrefix + 'startpicker-container'
},
endpicker: {
date: new TZDate(end).toDate(),
input: '#' + cssPrefix + 'schedule-end-date',
container: '#' + cssPrefix + 'endpicker-container'
},
format: isAllDay ? 'yyyy-MM-dd' : 'yyyy-MM-dd HH:mm',
timepicker: isAllDay ? null : {
showMeridiem: false,
usageStatistics: this._usageStatistics
},
usageStatistics: this._usageStatistics
});
this.rangePicker.on('change:start', function() {
this._setDatepickerState({start: this.rangePicker.getStartDate()});
}.bind(this));
this.rangePicker.on('change:end', function() {
this._setDatepickerState({end: this.rangePicker.getEndDate()});
}.bind(this));
};
/**
* Hide layer
*/
ScheduleCreationPopup.prototype.hide = function() {
this.layer.hide();
if (this.guide) {
this.guide.clearGuideElement();
this.guide = null;
}
domevent.off(document.body, 'mousedown', this._onMouseDown, this);
};
/**
* refresh layer
*/
ScheduleCreationPopup.prototype.refresh = function() {
if (this._viewModel) {
this.layer.setContent(this.tmpl(this._viewModel));
}
};
/**
* Set calendar list
* @param {Array.<Calendar>} calendars - calendar list
*/
ScheduleCreationPopup.prototype.setCalendars = function(calendars) {
this.calendars = calendars || [];
};
/**
* Validate the form
* @param {string} title title of then entered schedule
* @param {TZDate} startDate start date time from range picker
* @param {TZDate} endDate end date time from range picker
* @returns {boolean} Returns false if the form is not valid for submission.
*/
ScheduleCreationPopup.prototype._validateForm = function(title, startDate, endDate) {
if (!title.value) {
return false;
}
if (!startDate && !endDate) {
return false;
}
if (datetime.compare(startDate, endDate) === 1) {
return false;
}
return true;
};
/**
* Get range date from range picker
* @param {TZDate} startDate start date time from range picker
* @param {TZDate} endDate end date time from range picker
* @param {boolean} isAllDay whether it is an all-day schedule
* @returns {RangeDate} Returns the start and end time data that is the range date
*/
ScheduleCreationPopup.prototype._getRangeDate = function(startDate, endDate, isAllDay) {
var start = isAllDay ? datetime.start(startDate) : startDate;
var end = isAllDay ? datetime.renderEnd(startDate, datetime.end(endDate)) : endDate;
/**
* @typedef {object} RangeDate
* @property {TZDate} start start time
* @property {TZDate} end end time
*/
return {
start: new TZDate(start),
end: new TZDate(end)
};
};
/**
* Request schedule model creation to controller by custom schedules.
* @fires {ScheduleCreationPopup#beforeUpdateSchedule}
* @param {{
calendarId: {string},
title: {string},
location: {string},
start: {TZDate},
end: {TZDate},
isAllDay: {boolean},
state: {string},
isPrivate: {boolean}
}} form schedule input form data
*/
ScheduleCreationPopup.prototype._onClickUpdateSchedule = function(form) {
var changes = common.getScheduleChanges(
this._schedule,
['calendarId', 'title', 'location', 'start', 'end', 'isAllDay', 'state', 'isPrivate'],
{
calendarId: form.calendarId,
title: form.title.value,
location: form.location.value,
start: form.start,
end: form.end,
isAllDay: form.isAllDay,
state: form.state,
isPrivate: form.isPrivate
}
);
/**
* @event ScheduleCreationPopup#beforeUpdateSchedule
* @type {object}
* @property {Schedule} schedule - schedule object to be updated
*/
this.fire('beforeUpdateSchedule', {
schedule: this._schedule,
changes: changes,
start: form.start,
end: form.end,
calendar: this._selectedCal,
triggerEventName: 'click'
});
};
/**
* Request the controller to update the schedule model according to the custom schedule.
* @fires {ScheduleCreationPopup#beforeCreateSchedule}
* @param {{
calendarId: {string},
title: {string},
location: {string},
start: {TZDate},
end: {TZDate},
isAllDay: {boolean},
state: {string}
}} form schedule input form data
*/
ScheduleCreationPopup.prototype._onClickCreateSchedule = function(form) {
/**
* @event ScheduleCreationPopup#beforeCreateSchedule
* @type {object}
* @property {Schedule} schedule - new schedule instance to be added
*/
this.fire('beforeCreateSchedule', {
calendarId: form.calendarId,
title: form.title.value,
location: form.location.value,
isPrivate: form.isPrivate,
start: form.start,
end: form.end,
isAllDay: form.isAllDay,
state: form.state
});
};
module.exports = ScheduleCreationPopup;