`;
}
});
});
define('views/advanced_settings',['require','exports','module','templates/alarm','view','ext/co','core','utils/dom','router','templates/account','dom!advanced-settings-view'],function(require, exports, module) {
'use strict';
var AlarmTemplate = require('templates/alarm');
var View = require('view');
var co = require('ext/co');
var core = require('core');
var dom = require('utils/dom');
var router = require('router');
var template = require('templates/account');
require('dom!advanced-settings-view');
function AdvancedSettings(options) {
View.apply(this, arguments);
}
module.exports = AdvancedSettings;
AdvancedSettings.prototype = {
__proto__: View.prototype,
selectors: {
element: '#advanced-settings-view',
accountList: '#advanced-settings-view .account-list',
createAccountButton: '#advanced-settings-view .create-account',
syncFrequency: '#setting-sync-frequency',
header: '#advanced-settings-header',
standardAlarmLabel: '#default-event-alarm',
alldayAlarmLabel: '#default-allday-alarm'
},
get accountList() {
return this._findElement('accountList');
},
get createAccountButton() {
return this._findElement('createAccountButton');
},
get syncFrequency() {
return this._findElement('syncFrequency');
},
get standardAlarmLabel() {
return this._findElement('standardAlarmLabel');
},
get alldayAlarmLabel() {
return this._findElement('alldayAlarmLabel');
},
get header() {
return this._findElement('header');
},
get standardAlarm() {
return this.standardAlarmLabel.querySelector('select');
},
get alldayAlarm() {
return this.alldayAlarmLabel.querySelector('select');
},
_formatModel: function(model) {
// XXX: Here for l10n
return {
id: model._id,
preset: model.preset,
user: model.user,
hasError: !!model.error
};
},
_initEvents: function() {
var bridge = core.bridge;
bridge.observeAccounts().listen(this._renderAccounts.bind(this));
bridge.observeSetting('syncFrequency').listen(
this._renderSyncFrequency.bind(this)
);
this.createAccountButton.addEventListener('click',
this.onCreateAccount.bind(this));
this.syncFrequency.addEventListener('change', this);
this.standardAlarmLabel.addEventListener('change', this);
this.alldayAlarmLabel.addEventListener('change', this);
},
handleSettingUiChange: function(type, value) {
// basic conversions
if (value === 'null') {
value = null;
}
switch (type) {
case 'alldayAlarmDefault':
case 'standardAlarmDefault':
case 'syncFrequency':
if (value !== null) {
value = parseInt(value);
}
core.bridge.setSetting(type, value);
break;
}
},
handleEvent: function(event) {
switch (event.type) {
case 'change':
var target = event.target;
this.handleSettingUiChange(target.name, target.value);
break;
}
},
onCreateAccount: function(event) {
event.stopPropagation();
event.preventDefault();
router.show(event.target.getAttribute('href'));
},
render: co.wrap(function *() {
this.header.runFontFitSoon();
// the accounts and syncFrequency are already handled by the _initEvents
var bridge = core.bridge;
var data = yield {
standardAlarmDefault: bridge.getSetting('standardAlarmDefault'),
alldayAlarmDefault: bridge.getSetting('alldayAlarmDefault')
};
this._renderAlarmDefault('standard', data.standardAlarmDefault);
this._renderAlarmDefault('allday', data.alldayAlarmDefault);
}),
_renderSyncFrequency: function(value) {
this.syncFrequency.value = String(value);
},
_renderAccounts: function(accounts) {
this.accountList.innerHTML = '';
accounts
.filter(data => data.provider.hasAccountSettings)
.forEach(data => {
var model = data.account;
var idx = this.accountList.children.length;
var item = template.account.render(this._formatModel(model));
this.accountList.insertAdjacentHTML('beforeend', item);
if (model.error) {
this.accountList.children[idx].classList.add('error');
}
});
},
_renderAlarmDefault: function(type, value) {
var element = type + 'AlarmLabel';
dom.removeElements(this[element].querySelector('select'));
// Render the select box
var template = AlarmTemplate;
var select = document.createElement('select');
select.name = type + 'AlarmDefault';
select.innerHTML = template.options.render({
layout: type,
trigger: value
});
this[element].querySelector('.button').appendChild(select);
},
onfirstseen: function() {
this._initEvents();
this.render();
}
};
});
define('views/create_account',['require','exports','module','common/presets','view','ext/co','core','templates/account','dom!create-account-view'],function(require, exports, module) {
'use strict';
var Presets = require('common/presets');
var View = require('view');
var co = require('ext/co');
var core = require('core');
var template = require('templates/account');
require('dom!create-account-view');
function CreateAccount(options) {
View.apply(this, arguments);
this.cancel = this.cancel.bind(this);
}
module.exports = CreateAccount;
CreateAccount.prototype = {
__proto__: View.prototype,
_changeToken: 0,
presets: Presets,
selectors: {
element: '#create-account-view',
accounts: '#create-account-presets',
header: '#create-account-header'
},
get accounts() {
return this._findElement('accounts');
},
get header() {
return this._findElement('header');
},
_initEvents: function() {
this._accountStream = core.bridge.observeAccounts();
this._accountStream.listen(() => this.render());
this.header.addEventListener('action', this.cancel);
},
destroy: function() {
this._accountStream && this._accountStream.cancel();
this.header.removeEventListener('action', this.cancel);
},
render: co.wrap(function *() {
var presets = this.presets;
var listElement = this.accounts;
var currentToken = ++this._changeToken;
listElement.innerHTML = '';
function renderPreset(presetName) {
listElement.insertAdjacentHTML(
'beforeend',
template.provider.render({ name: presetName })
);
}
try {
var available = yield core.bridge.availablePresets(presets);
if (this._changeToken !== currentToken) {
// another render call takes priority over this one.
return;
}
available.forEach(renderPreset);
} catch (err) {
console.error('Error displaying presets', err);
}
}),
onfirstseen: function() {
// render will be called as a side-effect of _initEvents
this._initEvents();
},
cancel: function() {
window.history.back();
}
};
});
define('views/day',['require','exports','module','./multi_day','dom!day-view'],function(require, exports, module) {
'use strict';
var MultiDay = require('./multi_day');
require('dom!day-view');
function DayView(opts) {
MultiDay.apply(this, arguments);
}
module.exports = DayView;
DayView.prototype = {
__proto__: MultiDay.prototype,
scale: 'day',
visibleCells: 1,
get element() {
return document.getElementById('day-view');
}
};
});
define('oauth_window',['require','exports','module','querystring','view','dom!oauth2'],function(require, exports, module) {
'use strict';
var QueryString = require('querystring');
var View = require('view');
require('dom!oauth2');
/**
* Creates a oAuth dialog given a set of parameters.
*
* var oauth = new OAuthWindow(
* elementContainer,
* 'https://accounts.google.com/o/oauth2/auth',
* {
* response_type: 'code',
* client_id: 'xxx',
* scope: 'https://www.googleapis.com/auth/calendar',
* redirect_uri: 'xxx',
* state: 'foobar',
* access_type: 'offline',
* approval_prompt: 'force'
* }
* );
*
* oauth.oncomplete = function(evt) {
* if (evt.detail.code) {
* // success
* }
* };
*
* oauth.onabort = function() {
* // oauth was aborted
* };
*
*
*/
function OAuthWindow(container, server, params) {
if (!params.redirect_uri) {
throw new Error(
'must provide params.redirect_uri so oauth flow can complete'
);
}
this.params = {};
for (var key in params) {
this.params[key] = params[key];
}
this._element = container;
View.call(this);
this.target = server + '?' + QueryString.stringify(params);
this._handleUserTriggeredClose =
this._handleUserTriggeredClose.bind(this);
}
module.exports = OAuthWindow;
OAuthWindow.prototype = {
__proto__: View.prototype,
get element() {
return this._element;
},
get isOpen() {
return !!this.browserFrame;
},
selectors: {
browserHeader: 'gaia-header',
browserTitle: 'gaia-header > h1',
browserContainer: '.browser-container'
},
get browserContainer() {
return this._findElement('browserContainer', this.element);
},
get browserTitle() {
return this._findElement('browserTitle', this.element);
},
get browserHeader() {
return this._findElement('browserHeader', this.element);
},
_handleFinalRedirect: function(url) {
this.close();
if (this.oncomplete) {
var params;
// find query string
var queryStringIdx = url.indexOf('?');
if (queryStringIdx !== -1) {
params = QueryString.parse(url.slice(queryStringIdx + 1));
}
this.oncomplete(params || {});
}
},
_handleLocationChange: function(url) {
this.browserTitle.textContent = url;
},
_handleUserTriggeredClose: function() {
// close the oauth flow
this.close();
// trigger an event so others can cleanup
if (this.onabort) {
this.onabort();
}
},
handleEvent: function(event) {
switch (event.type) {
case 'mozbrowserlocationchange':
var url = event.detail;
if (url.indexOf(this.params.redirect_uri) === 0) {
return this._handleFinalRedirect(url);
}
this._handleLocationChange(url);
break;
}
},
open: function() {
if (this.browserFrame) {
throw new Error('attempting to open frame while another is open');
}
// add the active class
this.element.classList.add(View.ACTIVE);
// handle cancel events
this.browserHeader.addEventListener(
'action', this._handleUserTriggeredClose
);
// setup browser iframe
var iframe = this.browserFrame =
document.createElement('iframe');
iframe.setAttribute('mozbrowser', true);
iframe.setAttribute('src', this.target);
this.browserContainer.appendChild(iframe);
iframe.addEventListener('mozbrowserlocationchange', this);
},
close: function() {
if (!this.isOpen) {
return;
}
this.browserFrame.removeEventListener(
'mozbrowserlocationchange', this
);
this.browserHeader.removeEventListener(
'action', this._handleUserTriggeredClose
);
this.element.classList.remove(View.ACTIVE);
this.browserFrame.parentNode.removeChild(
this.browserFrame
);
this.browserFrame = undefined;
}
};
});
define('utils/uri',['require','exports','module'],function(require, exports) {
'use strict';
/**
* Get the port of the url.
* @param {string} url full url.
* @return {?number} port number, null if none found.
*/
exports.getPort = function(url) {
var parts = url.split(':');
if (parts.length < 2) {
return null;
}
// If we found a port and path, it's the last part
var candidate = parts[parts.length - 1];
parts = candidate.split('/');
// If we found a port, it's the first part
candidate = parts[0];
if (!isInteger(candidate)) {
return null;
}
return parseInt(candidate, 10);
};
/**
* Get the scheme of the url. Note that this only detects http and https.
* @param {string} url full url.
* @return {?string} uri scheme (ie http), null if none found.
*/
exports.getScheme = function(url) {
var parts = url.split(':');
if (parts.length < 2) {
return null;
}
// If we found a scheme, it's the first part
var candidate = parts[0];
if (candidate !== 'http' && candidate !== 'https') {
return null;
}
return candidate;
};
/**
* Decide whether or not this string represents an integer.
* @param {string} str some string.
* @param {boolean} whether or not str represents an integer.
*/
function isInteger(str) {
return (/^\d+$/).test(str);
}
});
define('views/modify_account',['require','exports','module','models/account','oauth_window','common/presets','utils/uri','view','ext/co','core','common/is_offline','router','dom!modify-account-view'],function(require, exports, module) {
'use strict';
var Account = require('models/account');
var OAuthWindow = require('oauth_window');
var Presets = require('common/presets');
var URI = require('utils/uri');
var View = require('view');
var co = require('ext/co');
var core = require('core');
var isOffline = require('common/is_offline');
var router = require('router');
require('dom!modify-account-view');
var DEFAULT_AUTH_TYPE = 'basic';
var OAUTH_AUTH_CREDENTIALS = [
'client_id',
'scope',
'redirect_uri',
'state'
];
function ModifyAccount(options) {
View.apply(this, arguments);
this.deleteRecord = this.deleteRecord.bind(this);
this.cancel = this.cancel.bind(this);
this.displayOAuth2 = this.displayOAuth2.bind(this);
this.hideHeaderAndForm = this.hideHeaderAndForm.bind(this);
this.cancelDelete = this.cancelDelete.bind(this);
// bound so we can add remove listeners
this._boundSaveUpdateModel = this.save.bind(this, { updateModel: true });
}
module.exports = ModifyAccount;
ModifyAccount.prototype = {
__proto__: View.prototype,
_changeToken: 0,
selectors: {
element: '#modify-account-view',
form: '.modify-account-form',
fields: '*[name]',
saveButton: '#modify-account-view .save',
deleteButton: '#modify-account-view .delete-confirm',
deleteRecordButton: '.delete-record',
cancelDeleteButton: '#modify-account-view .delete-cancel',
header: '#modify-account-header',
status: '#modify-account-view section[role="status"]',
errors: '#modify-account-view .errors',
oauth2Window: '#oauth2',
oauth2SignIn: '#modify-account-view .force-oauth2'
},
progressClass: 'in-progress',
removeDialogClass: 'remove-dialog',
get authenticationType() {
if (this.preset && this.preset.authenticationType) {
return this.preset.authenticationType;
}
return DEFAULT_AUTH_TYPE;
},
get oauth2Window() {
return this._findElement('oauth2Window');
},
get oauth2SignIn() {
return this._findElement('oauth2SignIn');
},
get deleteRecordButton() {
return this._findElement('deleteRecordButton');
},
get deleteButton() {
return this._findElement('deleteButton');
},
get cancelDeleteButton() {
return this._findElement('cancelDeleteButton');
},
get header() {
return this._findElement('header');
},
get saveButton() {
return this._findElement('saveButton');
},
get form() {
return this._findElement('form');
},
get fields() {
if (!this._fields) {
var result = this._fields = {};
var elements = this.element.querySelectorAll(
this.selectors.fields
);
var i = 0;
var len = elements.length;
for (i; i < len; i++) {
var el = elements[i];
result[el.getAttribute('name')] = el;
}
}
return this._fields;
},
updateForm: function() {
var update = ['user', 'fullUrl'];
update.forEach(function(name) {
var field = this.fields[name];
field.value = this.model[name];
}, this);
},
updateModel: function() {
var update = ['user', 'password', 'fullUrl'];
update.forEach(function(name) {
var field = this.fields[name];
var value = field.value;
if (name === 'fullUrl') {
// Prepend a scheme if url has neither port nor scheme
var port = URI.getPort(value);
var scheme = URI.getScheme(value);
if (!port && !scheme) {
value = 'https://' + value;
}
}
this.model[name] = value;
}, this);
},
deleteRecord: function(e) {
if (e) {
e.preventDefault();
}
// begin the removal (which will emit the preRemove event) but don't wait
// for it to complete...
core.bridge.deleteAccount(this.model._id);
// semi-hack clear the :target - harmless in tests
// but important in the current UI because css :target
// does not get cleared (for some reason)
window.location.replace('#');
// TODO: in the future we may want to store the entry
// url of this view and use that instead of this
// hard coded value...
router.show('/advanced-settings/');
},
cancel: function(event) {
if (event) {
event.preventDefault();
}
window.history.back();
},
cancelDelete: function(event) {
this.element.classList.remove(this.removeDialogClass);
this.cancel(event);
},
isOffline: isOffline,
save: co.wrap(function *(options, e) {
if (e) {
e.preventDefault();
}
var list = this.element.classList;
if (this.isOffline()) {
this.showErrors([{name: 'offline'}]);
return;
}
list.add(this.progressClass);
this.errors.textContent = '';
if (options && options.updateModel) {
this.updateModel();
}
try {
yield core.bridge.createAccount(this.model);
list.remove(this.progressClass);
router.go(this.completeUrl);
} catch(err) {
list.remove(this.progressClass);
this.showErrors(err);
}
}),
hideHeaderAndForm: function() {
this.element.classList.add(this.removeDialogClass);
},
displayOAuth2: function(event) {
if (event) {
event.preventDefault();
}
var self = this;
this.oauth2Window.classList.add(View.ACTIVE);
navigator.mozApps.getSelf().onsuccess = function(e) {
var app = e.target.result;
app.clearBrowserData().onsuccess = function() {
self._redirectToOAuthFlow();
};
};
},
/**
* @param {String} preset name of value in Calendar.Presets.
*/
_createModel: function(preset, callback) {
var settings = Presets[preset];
var model = new Account(settings.options);
model.preset = preset;
return model;
},
_redirectToOAuthFlow: function() {
var apiCredentials = this.preset.apiCredentials;
var params = {
/*
* code response type for now might change when we can use window.open
*/
response_type: 'code',
/* offline so we get refresh_token[s] */
access_type: 'offline',
/* we us force so we always get a refresh_token */
approval_prompt: 'force'
};
OAUTH_AUTH_CREDENTIALS.forEach(function(key) {
if (key in apiCredentials) {
params[key] = apiCredentials[key];
}
});
var oauth = this._oauthDialog = new OAuthWindow(
this.oauth2Window,
apiCredentials.authorizationUrl,
params
);
var self = this;
oauth.open();
oauth.onabort = function() {
self.cancel();
};
oauth.oncomplete = function(params) {
if ('error' in params) {
// Ruh roh
return self.cancel();
}
if (!params.code) {
return console.error('authentication error');
}
// Fistpump!
self.model.oauth = { code: params.code };
self.save();
};
},
render: function() {
if (!this.model) {
throw new Error('must provider model to ModifyAccount');
}
this.form.addEventListener('submit', this._boundSaveUpdateModel);
this.saveButton.addEventListener('click', this._boundSaveUpdateModel);
this.header.addEventListener('action', this.cancel);
this.deleteRecordButton.addEventListener('click', this.hideHeaderAndForm);
if (this.model._id) {
this.type = 'update';
this.deleteButton.addEventListener('click', this.deleteRecord);
this.cancelDeleteButton.addEventListener('click', this.cancelDelete);
} else {
this.type = 'create';
}
var list = this.element.classList;
list.add(this.type);
list.add('preset-' + this.model.preset);
list.add('provider-' + this.model.providerType);
list.add('auth-' + this.authenticationType);
if (this.model.error) {
list.add('error');
}
if (this.authenticationType === 'oauth2') {
this.oauth2SignIn.addEventListener('click', this.displayOAuth2);
if (this.type === 'create') {
this.displayOAuth2();
}
this.fields.user.disabled = true;
this.saveButton.disabled = true;
}
this.form.reset();
this.updateForm();
var usernameType = this.model.usernameType;
this.fields.user.type = (usernameType === undefined) ?
'text' : usernameType;
this.header.runFontFitSoon();
},
destroy: function() {
var list = this.element.classList;
list.remove(this.type);
list.remove('preset-' + this.model.preset);
list.remove('provider-' + this.model.providerType);
list.remove('auth-' + this.authenticationType);
list.remove('error');
list.remove(this.removeDialogClass);
this.fields.user.disabled = false;
this.saveButton.disabled = false;
this._fields = null;
this.form.reset();
this.deleteRecordButton.removeEventListener('click',
this.hideHeaderAndForm);
this.oauth2SignIn.removeEventListener('click', this.displayOAuth2);
this.saveButton.removeEventListener('click', this._boundSaveUpdateModel);
this.deleteButton.removeEventListener('click', this.deleteRecord);
this.cancelDeleteButton.removeEventListener('click', this.cancelDelete);
this.header.removeEventListener('action',
this.cancel);
this.form.removeEventListener('submit', this._boundSaveUpdateModel);
},
dispatch: co.wrap(function *(data) {
if (this.model) {
this.destroy();
}
var params = data.params;
var changeToken = ++this._changeToken;
this.completeUrl = '/settings/';
try {
var model;
if (params.id) {
model = yield core.bridge.getAccount(params.id);
} else if (params.preset) {
model = this._createModel(params.preset);
}
this.preset = Presets[model.preset];
// race condition another dispatch has queued
// while we where waiting for an async event.
if (this._changeToken !== changeToken) {
return;
}
this.model = model;
this.render();
} catch (err) {
console.error('Error displaying model in ModifyAccount', data, err);
}
}),
oninactive: function() {
View.prototype.oninactive.apply(this, arguments);
if (this._oauthDialog) {
this._oauthDialog.close();
this._oauthDialog = null;
}
}
};
});
define('models/event',['require','exports','module','common/calc','common/probably_parse_int'],function(require, exports, module) {
'use strict';
var Calc = require('common/calc');
var probablyParseInt = require('common/probably_parse_int');
/**
* Creates a wrapper around a event instance from the db
*/
function Event(data) {
var isNew = false;
if (typeof(data) === 'undefined') {
isNew = true;
data = Object.create(null);
data.remote = {};
}
this.data = data;
/** shortcut */
var remote = this.remote = this.data.remote;
if ('start' in remote && !('startDate' in remote)) {
remote.startDate = Calc.dateFromTransport(
remote.start
);
}
if ('end' in remote && !('endDate' in remote)) {
remote.endDate = Calc.dateFromTransport(
remote.end
);
}
if (isNew) {
this.resetToDefaults();
}
var start = this.remote.startDate;
var end = this.remote.endDate;
// the typeof check is to see if we have already
// set the value in resetToDefaults (or prior)
if (
typeof(this._isAllDay) === 'undefined' &&
Calc.isOnlyDate(start) &&
Calc.isOnlyDate(end)
) {
// mostly to handle the case before the time
// where we actually managed isAllDay as a setter.
this.isAllDay = true;
} else {
// not on prototype intentionally because
// we need to either need to resetToDefaults
// or check startDate/endDate in the constructor.
this.isAllDay = false;
}
}
module.exports = Event;
Event.prototype = {
/**
* Sets default values of an event.
*/
resetToDefaults: function() {
var now = new Date();
this.isAllDay = false;
this.startDate = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate()
);
this.endDate = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() + 1
);
},
get _id() {
return this.data._id;
},
_setDate: function(date, field) {
if (!(date instanceof Date)) {
throw new TypeError('must pass instance of Date');
}
var allDay = this.isAllDay;
if (allDay) {
// clone the existing date
date = new Date(date.valueOf());
// filter out the stuff we don't care about
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
}
this.remote[field] = Calc.dateToTransport(
date,
null, // TODO allow setting tzid
allDay
);
this.remote[field + 'Date'] = date;
},
/* start date */
get startDate() {
return this.remote.startDate;
},
set startDate(value) {
this._setDate(value, 'start');
},
/* end date */
get endDate() {
return this.remote.endDate;
},
set endDate(value) {
this._setDate(value, 'end');
},
set isAllDay(value) {
this._isAllDay = value;
// send values through the their setter.
if (this.endDate) {
this.endDate = this.endDate;
}
if (this.startDate) {
this.startDate = this.startDate;
}
},
get isAllDay() {
return this._isAllDay;
},
/* associated records */
get calendarId() {
return this.data.calendarId;
},
set calendarId(value) {
if (value && typeof(value) !== 'number') {
value = probablyParseInt(value);
}
this.data.calendarId = value;
},
/* simple setters */
get syncToken() {
return this.remote.syncToken;
},
set syncToken(value) {
this.remote.syncToken = value;
},
get title() {
return this.remote.title || '';
},
set title(value) {
this.remote.title = value;
},
get description() {
return this.remote.description || '';
},
set description(value) {
this.remote.description = value;
},
get location() {
return this.remote.location || '';
},
set location(value) {
this.remote.location = value;
return this.remote.location;
},
get alarms() {
return this.remote.alarms || [];
},
set alarms(value) {
this.remote.alarms = value;
return this.remote.alarms;
},
/**
* If data doesn't have any errors, the event
* takes on the attributes of data.
*
* @param {Object} data, object that contains
* at least some attributes of the event object.
*
* @return {Object} errors if validationErrors returns erros,
* true otherwise.
*/
updateAttributes: function(data) {
var errors = this.validationErrors(data);
if (errors) {
return errors;
}
for (var field in data) {
this[field] = data[field];
}
return true;
},
/**
* Validates the contents of the model.
*
* Output example:
*
* [
* {
* name: 'invalidDate',
* properties: ['startDate', 'endDate']
* }
* //...
* ]
*
* @param {Object} data, optional object that contains
* at least some attributes of the event object.
* @return {Array|False} see above.
*/
validationErrors: function(data) {
var obj = data || this;
var end = obj.endDate.valueOf();
var start = obj.startDate.valueOf();
var errors = [];
if (start >= end) {
errors.push({
name: 'start-after-end'
});
}
if (errors.length) {
return errors;
}
return false;
}
};
});
define('views/event_base',['require','exports','module','models/event','view','ext/co','core','common/calc','common/next_tick','router'],function(require, exports, module) {
'use strict';
var Event = require('models/event');
var View = require('view');
var co = require('ext/co');
var core = require('core');
var isToday = require('common/calc').isToday;
var nextTick = require('common/next_tick');
var router = require('router');
function EventBase(options) {
View.apply(this, arguments);
this._els = Object.create(null);
this._changeToken = 0;
this.cancel = this.cancel.bind(this);
this.primary = this.primary.bind(this);
this._initEvents();
}
module.exports = EventBase;
EventBase.prototype = {
__proto__: View.prototype,
READONLY: 'readonly',
CREATE: 'create',
UPDATE: 'update',
PROGRESS: 'in-progress',
ALLDAY: 'allday',
LOADING: 'loading',
DEFAULT_VIEW: '/month/',
_initEvents: function() {
this.header.addEventListener('action', this.cancel);
this.primaryButton.addEventListener('click', this.primary);
},
uiSelector: '.%',
get header() {
return this._findElement('header');
},
get primaryButton() {
return this._findElement('primaryButton');
},
get fieldRoot() {
return this.element;
},
/**
* Returns the url the view will "redirect" to
* after completing the current add/edit/delete operation.
*
* @return {String} redirect url.
*/
returnTo: function() {
var path = this._returnTo || this.DEFAULT_VIEW;
return path;
},
/**
* Returns the top level URL, or returnTo()
* Resets the returnTop variable so we can override on next visit
*/
returnTop: function() {
var path = this._returnTop || this.returnTo();
delete this._returnTop;
return path;
},
/**
* Dismiss modification and go back to previous screen.
*/
cancel: function() {
window.history.back();
},
/**
* This method is overridden
*/
primary: function() {},
/**
* This method is overridden
*/
_markReadonly: function() {},
/**
* When the event is something like this:
* 2012-01-02 and we detect this is an all day event
* we want to display the end date like this 2012-01-02.
*/
formatEndDate: function(endDate) {
if (
endDate.getHours() === 0 &&
endDate.getSeconds() === 0 &&
endDate.getMinutes() === 0
) {
// subtract the date to give the user a better
// idea of which dates the event spans...
endDate = new Date(
endDate.getFullYear(),
endDate.getMonth(),
endDate.getDate() - 1
);
}
return endDate;
},
/**
* Assigns and displays event & busytime information.
* Marks view as "loading"
*
* @param {{event, busytime, capabilities}} record
*/
_useModel: function(record) {
this.record = record;
this.event = new Event(record.event);
this.busytime = record.busytime;
this.originalCalendar = record.calendar;
if (!record.capabilities.canUpdate) {
this._markReadonly(true);
this.element.classList.add(this.READONLY);
}
// inheritance hook...
this._updateUI();
},
/** override me! **/
_updateUI: function() {},
/**
* Loads event and triggers form update.
* Gracefully will handle race conditions
* if rapidly switching between events.
* TODO: This token may no longer be needed
* as we have an aria-disabled guard now.
*
* @param {String} id busytime id.
*/
_loadModel: co.wrap(function *(id) {
var token = ++this._changeToken;
var classList = this.element.classList;
var result = Promise.resolve();
classList.add(this.LOADING);
try {
var record = yield core.bridge.fetchRecord(id);
if (token === this._changeToken) {
this._useModel(record);
}
} catch(err) {
console.error(`Error looking up records for id: ${id}`);
console.error(`${err.message}\n${err.stack}`);
result = Promise.reject(err);
}
classList.remove(this.LOADING);
return result;
}),
/**
* Builds and sets defaults for a new model.
*
* @return {Calendar.Models.Model} new model.
*/
_createModel: function(time) {
// time can be null in some cases, default to today (eg. unit tests)
time = time || new Date();
this._setDefaultHour(time);
var model = new Event();
model.startDate = time;
var end = new Date(time.valueOf());
end.setHours(end.getHours() + 1);
model.endDate = end;
return model;
},
_setDefaultHour: function(date) {
if (isToday(date)) {
var now = new Date();
// events created today default to begining of the next hour
date.setHours(now.getHours() + 1, 0, 0, 0);
} else {
// events created on other days default to 8AM
date.setHours(8, 0, 0, 0);
}
},
/**
* Gets and caches an element by selector
*/
getEl: function(name) {
if (!(name in this._els)) {
var el = this.fieldRoot.querySelector(
this.uiSelector.replace('%', name)
);
if (el) {
this._els[name] = el;
}
}
return this._els[name];
},
oninactive: function() {
View.prototype.oninactive.apply(this, arguments);
},
/**
* Handles the url parameters for when this view
* comes into focus.
*
* When the (busytime) id parameter is given the event will
* be found via the time controller.
*/
dispatch: function(data) {
// always remove loading initially (to prevent worst case)
this.element.classList.remove(this.LOADING);
// Re-run the header font fit when it comes into view.
// Since the header is already in the markup on load and the view is hidden
// the font fit calculations will be wrong initially.
this.header.runFontFitSoon();
var id = data.params.id;
var classList = this.element.classList;
var last = router.last;
if (last && last.path) {
if (!(/^\/(day|event|month|week)/.test(last.path))) {
// We came from some place suspicious so fall back to default.
this._returnTo = this.DEFAULT_VIEW;
} else {
// Return to the default view if we just added an event.
// Else go back to where we came from.
this._returnTo = /^\/event\/add\//.test(last.path) ?
this.DEFAULT_VIEW : last.path;
}
}
if (!this._returnTop && this._returnTo) {
this._returnTop = this._returnTo;
}
var completeDispatch = () => this.ondispatch && this.ondispatch();
if (id) {
classList.add(this.UPDATE);
this._loadModel(id).then(completeDispatch);
} else {
classList.add(this.CREATE);
var controller = core.timeController;
this.event = this._createModel(controller.mostRecentDay);
this._updateUI();
nextTick(completeDispatch);
}
this.primaryButton.removeAttribute('aria-disabled');
},
onfirstseen: function() {}
};
});
/* exported InputParser */
/**
* Stateless object for input parser functions..
* The intent is the methods here will only relate to the parsing
* of input[type="date|time"]
*/
var InputParser = (function() {
var InputParser = {
_dateParts: ['year', 'month', 'date'],
_timeParts: ['hours', 'minutes', 'seconds'],
/**
* Import HTML5 input[type="time"] string value
*
* @param {String} value 23:20:50.52, 17:39:57.
* @return {Object} { hours: 23, minutes: 20, seconds: 50 }.
*/
importTime: function(value) {
var result = {
hours: 0,
minutes: 0,
seconds: 0
};
if (!value) {
return result;
}
var parts = value.split(':');
var part;
var partName;
var i = 0;
var len = InputParser._timeParts.length;
for (; i < len; i++) {
partName = InputParser._timeParts[i];
part = parts[i];
if (part) {
result[partName] = parseInt(part.slice(0, 2), 10) || 0;
}
}
return result;
},
/**
* Export date to HTML5 input[type="time"]
*
* @param {Date} value export value.
* @return {String} 17:39:57.
*/
exportTime: function(value) {
return value.toLocaleString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
},
/**
* Import HTML5 input[type="time"] to object.
*
* @param {String} value 1997-12-19.
* @return {Object} { year: 1997, month: 12, date: 19 }.
*/
importDate: function(value) {
var result = {
year: 0,
month: 0,
date: 0
};
if (!value) {
return result;
}
var parts = value.split('-');
var part;
var partName;
var i = 0;
var len = InputParser._dateParts.length;
for (; i < len; i++) {
partName = InputParser._dateParts[i];
part = parts[i];
if (part) {
result[partName] = parseInt(part, 10);
}
}
if (result.month > 0) {
result.month = result.month - 1;
}
result.date = result.date || 1;
return result;
},
/**
* Export js local date to HTML5 input[type="date"].
*
* @param {Date} value export value.
* @return {String} date string (1997-12-19).
*/
exportDate: function(value) {
var year = value.getFullYear();
var month = value.getMonth() + 1;
var day = value.getDate();
if (month < 10) {
month = `0${month}`;
}
if (day < 10) {
day = `0${day}`;
}
return `${year}-${month}-${day}`;
},
/**
* Designed to take a date & time value from
* html5 input types and returns a JS Date.
*
* @param {String} date input date.
* @param {String} time input time.
*
* @return {Date} full date object from date/time.
*/
formatInputDate: function(date, time) {
time = InputParser.importTime(time);
date = InputParser.importDate(date);
return new Date(
date.year,
date.month,
date.date,
time.hours,
time.minutes,
time.seconds
);
},
};
return InputParser;
}());
define("shared/input_parser", (function (global) {
return function () {
var ret, fn;
return ret || global.InputParser;
};
}(this)));
define('views/modify_event',['require','exports','module','templates/alarm','./event_base','shared/input_parser','shared/intl_helper','querystring','ext/co','core','common/constants','notification','router','./factory','dom!modify-event-view'],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 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'
};
});
define('templates/calendar',['require','exports','module','template','common/constants'],function(require, exports, module) {
'use strict';
var create = require('template').create;
var localCalendarId = require('common/constants').localCalendarId;
module.exports = create({
item: function() {
var id = this.h('_id');
var color = this.h('color');
var l10n = '';
var name = '';
// localize only the default calendar; there is no need to set the name
// the [data-l10n-id] will take care of setting the proper value
if (id && localCalendarId === id) {
// localize the default calendar name
l10n = 'data-l10n-id="calendar-local"';
} else {
name = this.h('name');
}
var checked = this.bool('localDisplayed', 'checked');
return `
`;
}
});
});
define('views/settings',['require','exports','module','templates/calendar','view','ext/co','core','common/debug','router','dom!settings'],function(require, exports, module) {
'use strict';
var CalendarTemplate = require('templates/calendar');
var View = require('view');
var co = require('ext/co');
var core = require('core');
var debug = require('common/debug')('views/settings');
var router = require('router');
require('dom!settings');
function Settings(options) {
View.apply(this, arguments);
this.calendarList = [];
this._hideSettings = this._hideSettings.bind(this);
this._onDrawerTransitionEnd = this._onDrawerTransitionEnd.bind(this);
this._observeUI();
}
module.exports = Settings;
Settings.prototype = {
__proto__: View.prototype,
calendarList: null,
/**
* used internally to check if calendar and account data was loaded from db
* @type {Promise}
* @private
*/
_load: null,
waitBeforePersist: 600,
/**
* Local update is a flag
* used to indicate that the incoming
* update was made by this view and
* should not fire the _update method.
*/
_localUpdate: false,
/**
* Name of the class that will be applied to the
* body element when sync is in progress.
*/
selectors: {
element: '#settings',
calendars: '#settings .calendars',
calendarName: '.name',
toolbar: '#settings [role="tablist"]',
header: '#settings-header',
headerTitle: '#settings-header h1',
// A dark semi-opaque layer that is used to "gray out" the view behind
// the element used for settings. Tapping on it will also close out
// the settings element, per ux desire.
shield: '#settings .settings-shield',
// This outer div is used to hide .settings-drawer via an
// overflow: hidden, so that the .settings-drawer can translateY
// animate downward and appear to come out from under the view
// header that is visible "behind" the element used for settings.
drawerContainer: '#settings .settings-drawer-container',
// Holds the actual visible drawer contents: list of calendars
// and bottom toolbar.
drawer: '#settings .settings-drawer',
advancedSettingsButton: '#settings .settings',
syncButton: '#settings .sync',
syncProgress: '#settings .sync-progress',
// A view that settings overlays. Still needs to be active/visible but
// hidden from the screen reader.
timeViews: '#time-views'
},
get calendars() {
return this._findElement('calendars');
},
get toolbar() {
return this._findElement('toolbar');
},
get header() {
return this._findElement('header');
},
get headerTitle() {
return this._findElement('headerTitle');
},
get shield() {
return this._findElement('shield');
},
get drawerContainer() {
return this._findElement('drawerContainer');
},
get drawer() {
return this._findElement('drawer');
},
get advancedSettingsButton() {
return this._findElement('advancedSettingsButton');
},
get syncButton() {
return this._findElement('syncButton');
},
get syncProgress() {
return this._findElement('syncProgress');
},
get timeViews() {
return this._findElement('timeViews');
},
_syncStartStatus: function () {
this.syncProgress.setAttribute('data-l10n-id', 'sync-progress-syncing');
this.syncProgress.classList.add('syncing');
},
_syncCompleteStatus: function () {
this.syncProgress.setAttribute('data-l10n-id', 'sync-progress-complete');
this.syncProgress.classList.remove('syncing');
},
_observeUI: function() {
this.advancedSettingsButton.addEventListener('click', function(e) {
e.stopPropagation();
router.show('/advanced-settings/');
});
this.syncButton.addEventListener('click', this._onSyncClick.bind(this));
core.syncController.on('syncStart', this._syncStartStatus.bind(this));
core.syncController.on('syncComplete',
this._syncCompleteStatus.bind(this));
this.calendars.addEventListener(
'change', this._onCalendarDisplayToggle.bind(this)
);
},
_observeAccounts: function() {
var stream = core.bridge.observeAccounts();
stream.listen(this._updateSyncButton.bind(this));
return new Promise(resolve => stream.listen(resolve));
},
_observeCalendars: function() {
var stream = core.bridge.observeCalendars();
stream.listen(this._updateCalendars.bind(this));
return new Promise(resolve => stream.listen(resolve));
},
_updateCalendars: function(records) {
this.calendarList = records.map(r => r.calendar);
this.render();
},
_persistCalendarDisplay: co.wrap(function *(id, displayed) {
try {
var calendar = this.calendarList.find(c => String(c._id) === String(id));
// we "clone" the calendar otherwise the caching logic will affect the
// 'calendarVisibilityChange' event (we check if value changed this way)
calendar = Object.create(calendar);
calendar.localDisplayed = displayed;
yield core.bridge.updateCalendar(calendar);
} catch (err) {
console.error(`Can't save calendar display: ${err}`);
}
}),
_onCalendarDisplayToggle: function(e) {
var input = e.target;
this._persistCalendarDisplay(input.getAttribute('value'), !!input.checked);
},
_onSyncClick: function() {
// trigger the sync the syncStart/complete events
// will hide/show the button.
core.syncController.all();
},
// Ajust size of drawer scroll area to fit size of calendars, within
// a min/max that is controlled by CSS. This has to be a manual
// calculation because UX wants the list of calendars to form-fit
// without a scrollbar, but enforce a minimum height and a maximum.
// The alternative to this approach is to size drawerContainer and
// drawer to be height 100%, and put the min/max height CSS on the
// .calendars. However, that means the translate animation is over
// a 100% height div, which ends up looking not so smooth on close
// of the animation, since the actual visible content is about half
// the size of that 100% and in the easing, zips by too quickly that
// it is harder to track, almost looks like just a harder visibility
// discontinuity.
_setCalendarContainerSize: function() {
var nodes = this.calendars.children;
var calendarsHeight = nodes[0] ?
nodes[0].getBoundingClientRect().height *
nodes.length : 0;
this.drawerContainer.style.height = (calendarsHeight +
this.toolbar.clientHeight) + 'px';
},
onrender: function() {
this._setCalendarContainerSize();
this._rendered = true;
this._animateDrawer();
},
render: function() {
debug('Will render settings view.');
this.calendars.innerHTML = '';
debug('Inject calendars into settings list.');
this.calendarList.forEach(calendar => {
debug('Will add calendar to settings view', calendar.id, calendar);
var html = CalendarTemplate.item.render(calendar);
this.calendars.insertAdjacentHTML('beforeend', html);
if (calendar.error) {
console.error('Views.Settings calendar error:', calendar.error);
var idx = this.calendars.children.length - 1;
var el = this.calendars.children[idx];
el.classList.add('error');
}
this._setCalendarContainerSize();
});
this.onrender && this.onrender();
},
_updateSyncButton: function(list) {
var canSync = list.some(o => o.provider.canSync);
this.toolbar.classList.toggle('noaccount', !canSync);
},
_onDrawerTransitionEnd: function(e) {
this._updateDrawerAnimState('done');
if (!document.body.classList.contains('settings-drawer-visible')) {
router.resetState();
}
},
// Update a state visible in the DOM for when animation is taking place.
// This is mostly useful for a test hook to know when the animation is
// done.
_updateDrawerAnimState: function(state) {
this.drawer.dataset.animstate = state;
},
_hideSettings: function() {
this._updateDrawerAnimState('animating');
document.body.classList.remove('settings-drawer-visible');
},
_animateDrawer: function() {
// Wait for both _rendered and _activated before triggering
// the animation, so that it is smooth, without jank due to
// changes in style/layout from activating or rendering.
// Also, set the style on the body, since other views will also
// have items animate based on the class. For instance, the +
// to add an event in the view-selector views fades out.
if (!this._rendered) {
return debug('Skip animation since not yet rendered.');
}
if (!this._activated) {
return debug('Skip animation since not yet activated.');
}
var classList = document.body.classList;
if (classList.contains('settings-drawer-visible')) {
return debug('Skip animation since drawer already visible?');
}
this._updateDrawerAnimState('animating');
classList.add('settings-drawer-visible');
},
_loadBaseData: function() {
if (!this._load) {
// make sure we only observe data once and wait until it's ready before
// showing the animation
this._load = Promise.all([
this._observeCalendars(),
this._observeAccounts()
]);
}
return this._load;
},
onactive: co.wrap(function *() {
debug('Will do settings animation.');
try {
yield this._loadBaseData();
} catch(err) {
console.error('Error fetching calendars in View.Settings', err);
return;
}
// View#onactive will call Views.Settings#render the first time.
View.prototype.onactive.apply(this, arguments);
// onactive can be called more times than oninactive, since
// settings can overlay over and not trigger an inactive state,
// so only bind these listeners and do the drawer animation once.
if (document.body.classList.contains('settings-drawer-visible')) {
return;
}
debug('Settings drawer is not visible... will activate.');
this._activated = true;
this._animateDrawer();
// Set header title to same as time view header
this.headerTitle.textContent =
document.getElementById('current-month-year').textContent;
// Both the transparent back and clicking on the semi-opaque
// shield should close the settings since visually those sections
// do not look like part of the drawer UI, and UX wants to give
// the user a few options to close the drawer since there is no
// explicit close button.
this.header.addEventListener('action', this._hideSettings);
this.shield.addEventListener('click', this._hideSettings);
this.timeViews.setAttribute('aria-hidden', true);
this.drawer.addEventListener('transitionend', this._onDrawerTransitionEnd);
}),
oninactive: function() {
debug('Will deactivate settings.');
View.prototype.oninactive.apply(this, arguments);
this._activated = false;
this.header.removeEventListener('action', this._hideSettings);
this.shield.removeEventListener('click', this._hideSettings);
this.timeViews.removeAttribute('aria-hidden');
this.drawer.removeEventListener('transitionend',
this._onDrawerTransitionEnd);
}
};
Settings.prototype.onfirstseen = Settings.prototype.render;
});
define('templates/duration_time',['require','exports','module','common/calc','templates/date_span','template'],function(require, exports, module) {
'use strict';
var Calc = require('common/calc');
var DateSpan = require('templates/date_span');
var create = require('template').create;
var l10n = navigator.mozL10n;
module.exports = create({
durationTime: function() {
var format = '';
var startDate = this.arg('startDate');
var endDate = this.arg('endDate');
var isAllDay = this.arg('isAllDay');
if (isAllDay) {
// Use the last second of previous day as the base for endDate
// (e.g., 1991-09-14T23:59:59 insteads of 1991-09-15T00:00:00).
endDate = new Date(endDate - 1000);
format = Calc.isSameDate(startDate, endDate) ?
'one-all-day-duration' :
'multiple-all-day-duration';
} else {
format = Calc.isSameDate(startDate, endDate) ?
'one-day-duration' :
'multiple-day-duration';
}
return l10n.get(format, {
startTime: formatTime(startDate),
startDate: formatDate(startDate),
endTime: formatTime(endDate),
endDate: formatDate(endDate)
});
}
});
function formatDate(date) {
return date.toLocaleString(navigator.languages, {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric'
});
}
function formatTime(time) {
return DateSpan.time.render({
time: time,
format: 'shortTimeFormat'
});
}
});
define('views/view_event',['require','exports','module','templates/duration_time','./event_base','templates/alarm','common/constants','router','dom!event-view'],function(require, exports, module) {
'use strict';
var DurationTime = require('templates/duration_time');
var EventBase = require('./event_base');
var alarmTemplate = require('templates/alarm');
var localCalendarId = require('common/constants').localCalendarId;
var router = require('router');
require('dom!event-view');
function ViewEvent(options) {
EventBase.apply(this, arguments);
}
module.exports = ViewEvent;
ViewEvent.prototype = {
__proto__: EventBase.prototype,
DEFAULT_VIEW: '/month/',
selectors: {
element: '#event-view',
header: '#show-event-header',
primaryButton: '#event-view .edit'
},
_initEvents: function() {
EventBase.prototype._initEvents.apply(this, arguments);
},
/**
* Dismiss modification and go back to previous screen.
*/
cancel: function() {
router.go(this.returnTop());
},
primary: function(event) {
if (event) {
event.preventDefault();
}
// Disable the button on primary event to avoid race conditions
this.primaryButton.setAttribute('aria-disabled', 'true');
router.go('/event/edit/' + this.busytime._id + '/');
},
/**
* Mark the event readOnly
* Hides/shows the edit button
*
* @param {Boolean} boolean true/false.
*/
_markReadonly: function(boolean) {
this.primaryButton.disabled = boolean;
},
/**
* Sets content for an element
* Hides the element if there's no content to set
*/
setContent: function(element, content, method) {
method = method || 'textContent';
element = this.getEl(element);
element.querySelector('.content')[method] = content;
if (!content) {
element.style.display = 'none';
} else {
element.style.display = '';
}
},
/**
* Updates the UI to use values from the current model.
*/
_updateUI: function() {
var model = this.event;
this.setContent('title', model.title);
this.setContent('location', model.location);
if (this.originalCalendar) {
this.element.querySelector('.icon-calendar-dot').style.color =
this.originalCalendar.color;
var calendarId = this.originalCalendar.remote.id;
var isLocalCalendar = calendarId === localCalendarId;
var calendarName = isLocalCalendar ?
navigator.mozL10n.get('calendar-local') :
this.originalCalendar.remote.name;
this.setContent(
'current-calendar',
calendarName
);
if (isLocalCalendar) {
this.getEl('current-calendar')
.querySelector('.content')
.setAttribute('data-l10n-id', 'calendar-local');
}
}
var dateSrc = model;
if (model.remote.isRecurring && this.busytime) {
dateSrc = this.busytime;
}
var duationTimeContent = DurationTime.durationTime.render(dateSrc);
this.setContent('duration-time', duationTimeContent, 'innerHTML');
var alarmContent = '';
var alarms = this.event.alarms;
if (alarms) {
this.getEl('alarms')
.classList
.toggle('multiple', alarms.length > 1);
alarmContent = alarmTemplate.reminder.render({
alarms: alarms,
isAllDay: this.event.isAllDay,
});
}
this.setContent('alarms', alarmContent, 'innerHTML');
this.setContent('description', model.description);
},
oninactive: function() {
EventBase.prototype.oninactive.apply(this, arguments);
this._markReadonly(false);
}
};
});
define("lazy_loaded", function(){});