VulpesOS/gaia/apps/calendar/js/views/modify_event.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

753 lines
21 KiB
JavaScript
Executable file

define(function(require, exports, module) {
'use strict';
var AlarmTemplate = require('templates/alarm');
var EventBase = require('./event_base');
var InputParser = require('shared/input_parser');
var IntlHelper = require('shared/intl_helper');
var QueryString = require('querystring');
var co = require('ext/co');
var core = require('core');
var localCalendarId = require('common/constants').localCalendarId;
var notification = require('notification');
var router = require('router');
var viewFactory = require('./factory');
require('dom!modify-event-view');
IntlHelper.define('formalDate', 'datetime', {
month: '2-digit',
year: '2-digit',
day: '2-digit'
});
IntlHelper.define('shortTime', 'datetime', {
hour: 'numeric',
minute: 'numeric'
});
function ModifyEvent(options) {
this.deleteRecord = this.deleteRecord.bind(this);
this._toggleAllDay = this._toggleAllDay.bind(this);
EventBase.apply(this, arguments);
}
module.exports = ModifyEvent;
ModifyEvent.prototype = {
__proto__: EventBase.prototype,
ERROR_PREFIX: 'event-error-',
MAX_ALARMS: 5,
formats: {
date: 'formalDate',
time: 'shortTime',
},
selectors: {
element: '#modify-event-view',
alarmList: '#modify-event-view .alarms',
form: '#modify-event-view form',
startTimeLocale: '#start-time-locale',
endDateLocale: '#end-date-locale',
endTimeLocale: '#end-time-locale',
status: '#modify-event-view section[role="status"]',
errors: '#modify-event-view .errors',
primaryButton: '#modify-event-view .save',
deleteButton: '#modify-event-view .delete-record',
header: '#modify-event-header'
},
uiSelector: '[name="%"]',
_duration: 0, // The duration between start and end dates.
_initEvents: function() {
EventBase.prototype._initEvents.apply(this, arguments);
this.deleteButton.addEventListener('click', this.deleteRecord);
this.form.addEventListener('click', this.focusHandler);
this.form.addEventListener('submit', this.primary);
this.form.addEventListener('input', this._toggleSave.bind(this));
var allday = this.getEl('allday');
allday.addEventListener('change', this._toggleAllDay);
this.alarmList.addEventListener('change', this._changeAlarm.bind(this));
},
/**
* Fired when the allday checkbox changes.
*/
_toggleAllDay: function(e) {
var allday = this.getEl('allday').checked;
if (allday) {
// enable case
this.element.classList.add(this.ALLDAY);
} else {
// disable case
this.element.classList.remove(this.ALLDAY);
if (e) {
// only reset the start/end time if coming from an user interaction
this._resetDateTime();
}
}
// because of race conditions it is theoretically possible
// for the user to check/uncheck this value
// when we don't actually have a model loaded.
if (this.event) {
this.event.isAllDay = !!allday;
}
// Reset alarms if we come from a user event
if (e) {
this.event.alarms = [];
this.updateAlarms(allday);
}
},
_resetDateTime: function() {
// if start event was "all day" and switch to regular event start/end time
// will be the same, so we reset to default start time, otherwise we keep
// the previously selected value
var startDateTime = this._getStartDateTime();
if (startDateTime === this._getEndDateTime()) {
var startDate = new Date(startDateTime);
this._setDefaultHour(startDate);
this.getEl('startTime').value = InputParser.exportTime(startDate);
this._renderDateTimeLocale(
this._findElement('startTimeLocale'), startDate);
// default event duration is 1 hour
this._duration = 60 * 60 * 1000;
this._setEndDateTimeWithCurrentDuration();
}
},
/**
* Called when any alarm is changed
*/
_changeAlarm: function(e) {
var template = AlarmTemplate;
if (e.target.value == 'none') {
var parent = e.target.parentNode;
parent.parentNode.removeChild(parent);
return;
}
// Append a new alarm select only if we don't have an empty one or if we
// didn't reach the maximum number of alarms
var alarms = this.queryAlarms();
if (alarms.length >= this.MAX_ALARMS ||
alarms.some(el => el.value === 'none')) {
return;
}
var newAlarm = document.createElement('div');
newAlarm.innerHTML = template.picker.render({
layout: this.event.isAllDay ? 'allday' : 'standard'
});
this.alarmList.appendChild(newAlarm);
},
/**
* Check if current event has been stored in the database
*/
isSaved: function() {
return this.element.classList.contains(this.UPDATE);
},
/**
* Build the initial list of calendar ids.
*/
onfirstseen: function() {
// we need to notify users (specially automation tests) somehow that the
// options are still being loaded from DB, this is very important to
// avoid race conditions (eg. trying to set calendar before list is
// built) notice that we also add the class to the markup because on some
// really rare occasions "onfirstseen" is called after the EventBase
// removed the "loading" class from the root element (seen it happen less
// than 1% of the time)
this.getEl('calendarId').classList.add(this.LOADING);
},
_renderCalendars: function(records) {
// remove old calendars (this will be called every time we have a change on
// any calendar)
var select = this.getEl('calendarId');
// remove all the options
select.options.length = 0;
select.classList.remove(this.LOADING);
records
.filter(r => r.capabilities.canCreateEvent)
.forEach(record => {
var id = record.calendar._id;
var {name} = record.calendar.remote;
var option = document.createElement('option');
if (id === localCalendarId) {
option.text = navigator.mozL10n.get('calendar-local');
option.setAttribute('data-l10n-id', 'calendar-local');
} else {
option.text = name;
}
option.value = id;
select.add(option);
});
},
/**
* Mark all field's readOnly flag.
*
* @param {Boolean} boolean true/false.
*/
_markReadonly: function(boolean) {
var i = 0;
var fields = this.form.querySelectorAll('[name]');
var len = fields.length;
for (; i < len; i++) {
fields[i].readOnly = boolean;
}
},
queryAlarms: function() {
return Array.from(document.querySelectorAll('[name="alarm[]"]'));
},
get alarmList() {
return this._findElement('alarmList');
},
get form() {
return this._findElement('form');
},
get deleteButton() {
return this._findElement('deleteButton');
},
get fieldRoot() {
return this.form;
},
/**
* Ask the provider to persist an event:
*
* 1. update the model with form data
*
* 2. send it to the provider if it has the capability
*
* 3. set the position of the calendar to startDate of new/edited event.
*
* 4. redirect to last view.
*
* For now both update & create share the same
* behaviour (redirect) in the future we may change this.
*/
_persistEvent: co.wrap(function *(method) {
// create model data
var data = this.formData();
var errors;
// we check explicitly for true, because the alternative
// is an error object.
if ((errors = this.event.updateAttributes(data)) !== true) {
this.showErrors(errors);
return;
}
// can't create without a calendar id
// because of defaults this should be impossible.
if (!data.calendarId) {
return;
}
// mark view as 'in progress' so we can style
// it via css during that time period
this.element.classList.add(this.PROGRESS);
try {
// need to store date before async operation (event might change)
var moveDate = this.event.startDate;
yield core.bridge[method](this.event);
// move the position in the calendar to the added/edited day
core.timeController.move(moveDate);
// order is important the above method triggers the building
// of the dom elements so selectedDay must come after.
core.timeController.selectedDay = moveDate;
this._navigateAfterPersist(method, moveDate);
} catch(err) {
this.showErrors(err);
}
this.element.classList.remove(this.PROGRESS);
}),
_navigateAfterPersist: function(method, moveDate) {
// we pass the date so we are able to scroll to the event on the
// day/week views
var state = {
eventStartHour: moveDate.getHours()
};
if (method === 'updateEvent') {
// If we edit a view our history stack looks like:
// /week -> /event/view -> /event/save -> /event/view
// We need to return all the way to the top of the stack
// We can remove this once we have a history stack
viewFactory.get('ViewEvent', v => router.go(v.returnTop(), state));
return;
}
router.go(this.returnTo(), state);
},
/**
* Deletes current record if provider is present and has the capability.
*/
deleteRecord: co.wrap(function *(event) {
if (event) {
event.preventDefault();
}
if (!this.isSaved()) {
return;
}
try {
yield notification.revokeNotificationsForEvent(this.event);
yield core.bridge.deleteEvent(this.event);
// If we edit a view our history stack looks like:
// /week -> /event/view -> /event/save -> /event/view
// We need to return all the way to the top of the stack
// We can remove this once we have a history stack
viewFactory.get('ViewEvent', view => router.go(view.returnTop()));
} catch (err) {
this.showErrors(err);
}
}),
/**
* Persist current model.
*/
primary: function(event) {
if (event) {
event.preventDefault();
}
// Disable the button on primary event to avoid race conditions
this.disablePrimary();
if (this.isSaved()) {
this._persistEvent('updateEvent');
} else {
this._persistEvent('createEvent');
}
},
/**
* Enlarges focus areas for .button controls
*/
focusHandler: function(e) {
var input = e.target.querySelector('input, select');
if (input && e.target.classList.contains('button')) {
input.focus();
}
},
/**
* Export form information into a format
* the model can understand.
*
* @return {Object} formatted data suitable
* for use with Calendar.Model.Event.
*/
formData: function() {
var fields = {
title: this.getEl('title').value,
location: this.getEl('location').value,
description: this.getEl('description').value,
calendarId: this.getEl('calendarId').value
};
var startTime;
var endTime;
var allday = this.getEl('allday').checked;
if (allday) {
startTime = null;
endTime = null;
} else {
startTime = this.getEl('startTime').value;
endTime = this.getEl('endTime').value;
}
fields.startDate = InputParser.formatInputDate(
this.getEl('startDate').value,
startTime
);
fields.endDate = InputParser.formatInputDate(
this.getEl('endDate').value,
endTime
);
// Yahoo uses the same start and end date & time for all day events.
// In order to distinguish all-day event from Yahoo calendar and event
// with 0 duration we set duration of the event starting and ending at
// the same date and time to 1 millisecond.
if (!allday && fields.endDate.getTime() === fields.startDate.getTime()) {
fields.endDate.setTime(
fields.startDate.getTime() + 1
);
}
if (allday) {
// when the event is all day we display the same
// day that the entire event spans but we must actually
// end the event at the first second, minute hour of the next
// day. This will ensure the server handles it as an all day event.
fields.endDate.setDate(
fields.endDate.getDate() + 1
);
}
fields.alarms = [];
var triggers = ['none'];
this.queryAlarms().forEach(alarm => {
if (triggers.indexOf(alarm.value) !== -1) {
return;
}
triggers.push(alarm.value);
fields.alarms.push({
action: 'DISPLAY',
trigger: parseInt(alarm.value, 10)
});
});
return fields;
},
enablePrimary: function() {
var btn = this.primaryButton;
btn.removeAttribute('aria-disabled');
btn.removeAttribute('disabled');
},
disablePrimary: function() {
var btn = this.primaryButton;
btn.setAttribute('aria-disabled', 'true');
btn.setAttribute('disabled', 'disabled');
},
/**
* Re-enable the primary button when we show errors
*/
showErrors: function() {
this.enablePrimary();
EventBase.prototype.showErrors.apply(this, arguments);
},
/**
* Read the urlparams and override stuff on our event model.
* @param {string} search Optional string of the form ?foo=bar&cat=dog.
* @private
*/
_overrideEvent: function(search) {
if (!search) {
var hash = window.location.hash;
search = hash.split('?')[1];
}
if (!search || search.length === 0) {
return;
}
// Remove the question mark that begins the search.
if (search.substr(0, 1) === '?') {
search = search.substr(1, search.length - 1);
}
var field, value;
// Parse the urlparams.
var params = QueryString.parse(search);
for (field in params) {
value = params[field];
switch (field) {
case ModifyEvent.OverrideableField.START_DATE:
case ModifyEvent.OverrideableField.END_DATE:
params[field] = new Date(value);
break;
default:
params[field] = value;
break;
}
}
// Override fields on our event.
var model = this.event;
for (field in ModifyEvent.OverrideableField) {
value = ModifyEvent.OverrideableField[field];
model[value] = params[value] || model[value];
}
},
/**
* Updates form to use values from the current model.
*
* Does not handle readonly flags or calenarId associations.
* Suitable for use in pre-populating values for both new and
* existing events.
*
* Resets any value on the current form.
*/
_updateUI: function() {
this._overrideEvent();
this.form.reset();
var model = this.event;
this.getEl('title').value = model.title;
this.getEl('location').value = model.location;
var dateSrc = model;
if (model.remote.isRecurring && this.busytime) {
dateSrc = this.busytime;
}
var startDate = dateSrc.startDate;
var endDate = dateSrc.endDate;
this._duration = endDate.getTime() - startDate.getTime();
// update the allday status of the view
var allday = this.getEl('allday');
if (allday && (allday.checked = model.isAllDay)) {
this._toggleAllDay();
endDate = this.formatEndDate(endDate);
}
this.getEl('startDate').value = InputParser.exportDate(startDate);
this._setupDateTimeSync('startDate', 'start-date-locale', startDate);
this.getEl('endDate').value = InputParser.exportDate(endDate);
this._setupDateTimeSync('endDate', 'end-date-locale', endDate);
this.getEl('startTime').value = InputParser.exportTime(startDate);
this._setupDateTimeSync('startTime', 'start-time-locale', startDate);
this.getEl('endTime').value = InputParser.exportTime(endDate);
this._setupDateTimeSync('endTime', 'end-time-locale', endDate);
this.getEl('description').textContent = model.description;
// update calendar id
this.getEl('calendarId').value = model.calendarId;
// calendar display
var currentCalendar = this.getEl('currentCalendar');
if (this.originalCalendar) {
currentCalendar.value =
this.originalCalendar.remote.name;
currentCalendar.readOnly = true;
}
this.updateAlarms(model.isAllDay);
this._toggleSave();
},
_toggleSave: function() {
var enabled = this.getEl('title').value || this.getEl('location').value;
if (enabled) {
this.enablePrimary();
} else {
this.disablePrimary();
}
},
/**
* Handling a layer over <input> to have localized
* date/time
*/
_setupDateTimeSync: function(src, target, value) {
var targetElement = document.getElementById(target);
if (!targetElement) {
return;
}
this._renderDateTimeLocale(targetElement, value);
var type = targetElement.dataset.type;
var callback = type === 'date' ?
this._updateDateLocaleOnInput : this._updateTimeLocaleOnInput;
this.getEl(src)
.addEventListener('input', function(e) {
callback.call(this, targetElement, e);
// We only auto change the end date and end time
// when user changes start date or start time,
// or end datetime is before start datetime
// after changing end date or end time.
// Otherwise, we don't auto change end date and end time.
if (targetElement.id === 'start-date-locale' ||
targetElement.id === 'start-time-locale') {
this._setEndDateTimeWithCurrentDuration();
} else if (this._getEndDateTime() < this._getStartDateTime()) {
this._setEndDateTimeWithCurrentDuration();
this.showErrors({
name: type === 'date' ?
'start-date-after-end-date' :
'start-time-after-end-time'
});
}
this._duration = this._getEndDateTime() - this._getStartDateTime();
}.bind(this));
},
_setEndDateTimeWithCurrentDuration: function() {
var date = new Date(this._getStartDateTime() + this._duration);
var endDateLocale = this._findElement('endDateLocale');
var endTimeLocale = this._findElement('endTimeLocale');
this.getEl('endDate').value = InputParser.exportDate(date);
this.getEl('endTime').value = InputParser.exportTime(date);
this._renderDateTimeLocale(endDateLocale, date);
this._renderDateTimeLocale(endTimeLocale, date);
},
_getStartDateTime: function() {
return new Date(this.getEl('startDate').value + 'T' +
this.getEl('startTime').value).getTime();
},
_getEndDateTime: function() {
return new Date(this.getEl('endDate').value + 'T' +
this.getEl('endTime').value).getTime();
},
_renderDateTimeLocale: function(targetElement, value) {
// we inject the targetElement to make it easier to test
var type = targetElement.dataset.type;
var formatName = this.formats[type];
var formatter = IntlHelper.get(formatName);
targetElement.textContent = formatter.format(value);
// we need to store the format and date for l10n
targetElement.setAttribute('data-l10n-date-format', formatName);
targetElement.dataset.date = value;
},
_updateDateLocaleOnInput: function(targetElement, e) {
var selected = InputParser.importDate(e.target.value);
// use date constructor to avoid issues, see Bug 966516
var date = new Date(selected.year, selected.month, selected.date);
this._renderDateTimeLocale(targetElement, date);
},
_updateTimeLocaleOnInput: function(targetElement, e) {
var selected = InputParser.importTime(e.target.value);
var date = new Date();
date.setHours(selected.hours);
date.setMinutes(selected.minutes);
date.setSeconds(0);
this._renderDateTimeLocale(targetElement, date);
},
/**
* Called on render or when toggling an all-day event
*/
updateAlarms: co.wrap(function *(isAllDay) {
var template = AlarmTemplate;
var alarms = [];
// Used to make sure we don't duplicate alarms
var alarmMap = {};
if (this.event.alarms) {
//jshint boss:true
for (var i = 0, alarm; alarm = this.event.alarms[i]; i++) {
alarmMap[alarm.trigger] = true;
alarm.layout = isAllDay ? 'allday' : 'standard';
alarms.push(alarm);
}
}
var layout = isAllDay ? 'allday' : 'standard';
var value = yield core.bridge.getSetting(layout + 'AlarmDefault');
if (!this.isSaved() && !alarmMap[value] && !this.event.alarms.length) {
alarms.push({
layout: layout,
trigger: value
});
}
// Bug_898242 to show an event when default is 'none',
// we check if the event is not saved, if so, we push
// the default alarm on to the list.
if ((value === 'none' && this.isSaved()) || value !== 'none') {
alarms.push({
layout: layout
});
}
this.alarmList.innerHTML = template.picker.renderEach(alarms).join('');
}),
reset: function() {
var list = this.element.classList;
list.remove(this.UPDATE);
list.remove(this.CREATE);
list.remove(this.READONLY);
list.remove(this.ALLDAY);
var allday = this.getEl('allday');
if (allday) {
allday.checked = false;
}
this._returnTo = null;
this._markReadonly(false);
this.event = null;
this.busytime = null;
this.alarmList.innerHTML = '';
this.form.reset();
},
onactive: function() {
EventBase.prototype.onactive.apply(this, arguments);
this.calendarStream = core.bridge.observeCalendars();
this.calendarStream.listen(this._renderCalendars.bind(this));
},
oninactive: function() {
EventBase.prototype.oninactive.apply(this, arguments);
this.reset();
this.calendarStream && this.calendarStream.cancel();
}
};
/**
* The fields on our event model which urlparams may override.
* @enum {string}
*/
ModifyEvent.OverrideableField = {
CALENDAR_ID: 'calendarId',
DESCRIPTION: 'description',
END_DATE: 'endDate',
IS_ALL_DAY: 'isAllDay',
LOCATION: 'location',
START_DATE: 'startDate',
TITLE: 'title'
};
});