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.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

View file

@ -0,0 +1,178 @@
define(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();
}
};
});

View file

@ -0,0 +1,86 @@
define(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();
}
};
});

View file

@ -0,0 +1,186 @@
define(function(require, exports, module) {
'use strict';
var IntlHelper = require('shared/intl_helper');
var View = require('view');
var createDay = require('common/calc').createDay;
var timeObserver = require('time_observer');
var activeClass = View.ACTIVE;
IntlHelper.define('current-time', 'mozdatetime', {
hour: 'numeric',
minute: 'numeric',
dayperiod: false,
});
function CurrentTime(options) {
this._container = options.container;
// timespan can be injected later! this is just a convenience
this.timespan = options.timespan;
this._sticky = options.sticky;
this.activate = this.activate.bind(this);
this._tick = this._tick.bind(this);
}
module.exports = CurrentTime;
CurrentTime.prototype = {
_create: function() {
if (this.element) {
return;
}
this.element = document.createElement('div');
this.element.setAttribute('aria-hidden', true);
this.element.classList.add('md__current-time');
this._container.appendChild(this.element);
},
refresh: function() {
this._clearInterval();
if (this._previousOverlap) {
this._previousOverlap.classList.remove('is-hidden');
}
this._unmarkCurrentDay();
this._hide();
this.activate();
},
activate: function() {
if (!this.timespan.containsNumeric(Date.now())) {
this._maybeActivateInTheFuture();
return;
}
this._create();
this.element.classList.add(activeClass);
this._tick();
},
_maybeActivateInTheFuture: function() {
var now = Date.now();
var diff = this.timespan.start - now;
if (diff >= 0) {
// if timespan is in the "future" we make sure it will start rendering
// the current time as soon as it reaches 00:00:00 of the first day
// inside timespan (eg. current time is 2014-05-22T23:59:50 and user is
// viewing 2014-05-23 until past midnight)
this._clearInterval();
timeObserver.once('day', this.activate);
}
},
deactivate: function() {
this._clearInterval();
this._hide();
},
_hide: function() {
if (this.element) {
this.element.classList.remove(activeClass);
}
},
destroy: function() {
this.deactivate();
if (this.element) {
this._container.removeChild(this.element);
}
},
_clearInterval: function() {
timeObserver.off('day', this.activate);
timeObserver.off('minute', this._tick);
},
_tick: function() {
this._clearInterval();
if (!this.timespan.contains(new Date())) {
this.deactivate();
this._unmarkCurrentDay();
return;
}
this._render();
timeObserver.once('minute', this._tick);
},
_render: function() {
var now = new Date();
var formatter = IntlHelper.get('current-time');
this.element.textContent = formatter.format(now);
this.element.dataset.date = now;
this.element.dataset.l10nDateFormat = 'current-time';
this.element.id = 'current-time-indicator';
var hour = now.getHours();
var elapsedMinutes = (hour * 60) + now.getMinutes();
var totalMinutes = 24 * 60;
var percentage = ((elapsedMinutes / totalMinutes) * 100);
// we limit the position between 0.5-99.5% to avoid cropping the text
this.element.style.top = Math.max(Math.min(percentage, 99.5), 0.5) + '%';
this._checkOverlap(hour);
this._markCurrentDay(now);
},
_checkOverlap: function(hour) {
// we only need to check the current hour (with current design there is
// no way to overlap previous/next hours)
var displayHour = this._container.querySelector(
`.md__hour-${hour} .md__display-hour`
);
displayHour.classList.toggle('is-hidden', this._intersect(displayHour));
// just in case last time it checked was against a different hour
if (this._previousOverlap && this._previousOverlap !== displayHour) {
this._previousOverlap.classList.remove('is-hidden');
}
this._previousOverlap = displayHour;
},
_intersect: function(displayHour) {
var b1 = this.element.getBoundingClientRect();
var b2 = displayHour.getBoundingClientRect();
return (
b1.left <= b2.right &&
b2.left <= b1.right &&
b1.top <= b2.bottom &&
b2.top <= b1.bottom
);
},
_markCurrentDay: function(date) {
if (!this._sticky) {
return;
}
var day = createDay(date);
var selector = `.md__allday[data-date="${day}"] .md__day-name`;
var header = this._sticky.querySelector(selector);
if (header) {
header.classList.add('is-today');
}
if (this._previousHeader !== header) {
this._unmarkCurrentDay();
}
this._previousHeader = header;
},
_unmarkCurrentDay: function() {
if (this._previousHeader) {
this._previousHeader.classList.remove('is-today');
}
}
};
});

View file

@ -0,0 +1,24 @@
define(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');
}
};
});

View file

@ -0,0 +1,32 @@
define(function(require, exports, module) {
'use strict';
var View = require('view');
var core = require('core');
require('dom!errors');
function Errors() {
View.apply(this, arguments);
core.syncController.on('offline', this);
}
module.exports = Errors;
Errors.prototype = {
__proto__: View.prototype,
selectors: {
status: '*[role="application"] > section[role="status"]',
errors: '*[role="application"] > section > .errors'
},
handleEvent: function(event) {
switch (event.type) {
case 'offline':
this.showErrors([{name: 'offline'}]);
break;
}
}
};
});

View file

@ -0,0 +1,283 @@
define(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() {}
};
});

View file

@ -0,0 +1,78 @@
define(function(require, exports, module) {
'use strict';
var asyncRequire = require('common/async_require');
var debug = require('common/debug')('viewFactory');
var nextTick = require('common/next_tick');
var snakeCase = require('snake_case');
exports._instances = Object.create(null);
/**
* Initializes a view and stores
* a internal reference so when
* view is called a second
* time the same view is used.
*
* Makes an asynchronous call to
* load the script if we do not
* have the view cached.
*
* // for example if you have
* // a calendar view Foo
*
* viewFactory.get('Foo', function(view) {
* (view instanceof require('views/foo')) === true
* });
*
* @param {String} name view name.
* @param {Function} view loaded callback.
*/
exports.get = function(name, cb) {
if (this._registered(name)) {
debug(`Found view named ${name}`);
this._get(name, cb);
return;
}
var path = `views/${snakeCase(name)}`;
try {
// we try sync require first since it should be faster (skip an extra
// "tick"); our modules are bundled into few files during build time, so
// sync require should work in almost all the calls (will only fail if
// module was not defined/loaded before)
var Ctor = require(path);
debug(`Initializing view ${name} registered as ${path}`);
this._initView(name, Ctor, cb);
} catch(e) {
debug(`Will try to load view ${name} at ${path}`);
asyncRequire(path).then(Ctor => {
debug(`Loaded view ${name}`);
this._initView(name, Ctor, cb);
});
}
},
exports._registered = function(name) {
return name in this._instances;
};
exports._get = function(name, cb) {
var view = this._instances[name];
cb && nextTick(() => cb.call(null, view));
};
exports._initView = function(name, Ctor, cb) {
if (this._registered(name)) {
// need to safeguard in case async load is triggered multiple times before
// we can save a reference to the Constructor and/or view instance (would
// trigger the window.require callback multiple times)
return;
}
var view = new Ctor();
this._instances[name] = view;
this._get(name, cb);
};
});

View file

@ -0,0 +1,178 @@
define(function(require, exports, module) {
'use strict';
// this will be replaced later for a better logic (see Bug 992728) but it was
// too much to do in a single patch, so for now we do a simple double tap
// without any visual feedback (similar to the old day view behavior)
var QueryString = require('querystring');
var absoluteOffsetTop = require('utils/dom').absoluteOffsetTop;
var closest = require('utils/dom').closest;
var createDay = require('common/calc').createDay;
function HourDoubleTap(options) {
this.main = options.main;
this.daysHolder = options.daysHolder;
this.alldaysHolder = options.alldaysHolder;
this.hourHeight = options.hourHeight;
this._onDayTap = this._onDayTap.bind(this);
this._onAllDayTap = this._onAllDayTap.bind(this);
this.removeAddEventLink = this.removeAddEventLink.bind(this);
}
module.exports = HourDoubleTap;
HourDoubleTap.prototype = {
_isActive: false,
_addEventLink: null,
setup: function() {
this._mainOffset = absoluteOffsetTop(this.main);
this.daysHolder.addEventListener('click', this._onDayTap);
this.alldaysHolder.addEventListener('click', this._onAllDayTap);
},
destroy: function() {
this.removeAddEventLink();
this.daysHolder.removeEventListener('click', this._onDayTap);
this.alldaysHolder.removeEventListener('click', this._onAllDayTap);
},
/**
* If the event target is an existing event, the method finds whether the tap
* was _really_ on the existing event, to account for possible event
* retargeting that's not desirable in that case.
*
* @param {MouseEvent} evt This is the event to check.
* @returns {Boolean} True if we tapped on a child of a day.
*/
_isEventRightOnEventLink(evt) {
var target = evt.target;
if (!target.classList.contains('md__day')) {
// Could be aggressive event fuzzing registering a tap on an existing
// event or the "add event" UI, where the tap is really below that item.
// See bug 1210201.
// In this case, offsetY is the vertical space between the top of the
// target element and the actual tap, so if the tap is bigger than the
// height of the element, or smaller than 0, the tap really happened out
// of the element.
var tappedOnExistingEvent =
// intentionally keeping retargeting behavior for the `add` button.
target.classList.contains('md__add-event') ||
target.classList.contains('md__event') &&
evt.offsetY >= 0 &&
evt.offsetY <= target.getBoundingClientRect().height;
if (tappedOnExistingEvent) {
return true;
}
}
return false;
},
_onDayTap: function(evt) {
if (this._isEventRightOnEventLink(evt)) {
return;
} else {
// Having this in the `else` block is a hint to the reader that we could
// remove it if the event retargeting gets smarter and we don't need this
// `if` anymore.
// It works because the event handler for the <a> is actually on `window`
// and checks if the event was defaultPrevented. Check js/ext/page.js.
// Until Bug 1091889 gets fixed we'll still get the :active
// styles because they're being applied with `touchstart` and we can't
// defaultPrevent this event without breaking everything.
evt.preventDefault();
}
var target = evt.target.closest('.md__day');
var y = evt.clientY + this.main.scrollTop - this._mainOffset;
var hour = Math.floor(y / this.hourHeight);
var baseDate = new Date(target.dataset.date);
this._onTap(target, {
startDate: addHours(baseDate, hour).toString(),
endDate: addHours(baseDate, hour + 1).toString()
}, hour);
},
_onAllDayTap: function(evt) {
var target = evt.target;
if (!target.classList.contains('md__allday-events')) {
return;
}
var startDate = new Date(closest(target, '.md__allday').dataset.date);
this._onTap(target, {
isAllDay: true,
startDate: startDate.toString(),
endDate: createDay(startDate, startDate.getDate() + 1).toString()
}, null, evt.mozInputSource);
},
_onTap: function(container, data, hour, source) {
hour = hour || 0;
if (this._addEventLink) {
this.removeAddEventLink();
return;
}
var link = document.createElement('a');
link.href = '/event/add/?' + QueryString.stringify(data);
link.className = 'md__add-event gaia-icon icon-newadd';
link.dataset.l10nId = 'multi-day-new-event-link';
link.style.top = (hour * this.hourHeight) + 'px';
link.style.opacity = 0;
link.addEventListener('click', this.removeAddEventLink);
container.appendChild(link);
this._addEventLink = link;
// Initiated by a screen reader on double tap.
if (source === 0) {
link.click();
return;
}
// opacity will trigger transition, needs to happen after nextTick
setTimeout(() => {
this._addEventLink && (this._addEventLink.style.opacity = 1);
});
},
removeAddEventLink: function() {
var link = this._addEventLink;
if (!link) {
return;
}
link.removeEventListener('click', this.removeAddEventLink);
link.addEventListener('transitionend', function onTransitionEnd() {
link.removeEventListener('transitionend', onTransitionEnd);
link.parentNode && link.parentNode.removeChild(link);
});
link.style.opacity = 0;
this._addEventLink = null;
}
};
function addHours(date, hourDiff) {
var result = new Date(date);
result.setHours(result.getHours() + hourDiff);
return result;
}
});

View file

@ -0,0 +1,413 @@
define(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;
}
}
};
});

View file

@ -0,0 +1,753 @@
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'
};
});

View file

@ -0,0 +1,241 @@
define(function(require, exports, module) {
'use strict';
var Calc = require('common/calc');
var GestureDetector = require('shared/gesture_detector');
var SingleMonth = require('./single_month');
var View = require('view');
var core = require('core');
var dateFromId = Calc.dateFromId;
var monthStart = Calc.monthStart;
var performance = require('performance');
var router = require('router');
var timeObserver = require('time_observer');
// minimum difference between X and Y axis to be considered an horizontal swipe
var XSWIPE_OFFSET = window.innerWidth / 10;
function Month() {
View.apply(this, arguments);
this.frames = new Map();
window.addEventListener('localized', this);
this._setPresentDate = this._setPresentDate.bind(this);
}
module.exports = Month;
Month.prototype = {
__proto__: View.prototype,
SCALE: 'month',
selectors: {
element: '#month-view',
},
date: null,
/** @type {SingleMonth} */
currentFrame: null,
/**
* used to detect if dbltap happened on same date
* @type {DOMElement}
*/
_lastTarget: null,
/**
* store current, previous and next months
* we load them beforehand and keep on the cache to speed up swipes
* @type {Array<SingleMonth>}
*/
frames: null,
onactive: function() {
View.prototype.onactive.apply(this, arguments);
core.timeController.scale = this.SCALE;
if (this.currentFrame) {
this.currentFrame.activate();
}
},
_onswipe: function(data) {
// only move to a different month if it's an horizontal swipe
if (Math.abs(data.dy) > (Math.abs(data.dx) - XSWIPE_OFFSET)) {
return;
}
var dir = document.documentElement.dir === 'rtl' ? -1 : 1;
this._move(dir * data.dx < 0);
},
_onwheel: function(event) {
// mouse wheel is used for a10y
if (event.deltaMode !== event.DOM_DELTA_PAGE || event.deltaX === 0) {
return;
}
this._move(event.deltaX > 0);
},
_move: function(isNext) {
var controller = core.timeController;
var date = isNext ? this._nextTime() : this._previousTime();
// If we changed months, set the selected day to the 1st
controller.selectedDay = date;
controller.move(date);
},
_nextTime: function() {
return monthStart(this.date, 1);
},
_previousTime: function() {
return monthStart(this.date, -1);
},
_initEvents: function() {
this.controller = core.timeController;
this.element.addEventListener('swipe', this);
this.element.addEventListener('wheel', this);
this.controller.on('monthChange', this);
this.delegate(this.element, 'click', '.month-day', this);
this.delegate(this.element, 'dbltap', '.month-day', this);
timeObserver.on('day', this._setPresentDate);
this.gd = new GestureDetector(this.element);
this.gd.startDetecting();
},
handleEvent: function(e, target) {
switch (e.type) {
case 'swipe':
this._onswipe(e.detail);
break;
case 'wheel':
this._onwheel(e);
break;
case 'click':
var date = dateFromId(target.dataset.date);
this.controller.selectedDay = date;
break;
case 'dbltap':
// make sure we discard double taps that started on a different day
if (this._lastTarget === target) {
this._goToAddEvent();
}
break;
case 'monthChange':
this.changeDate(e.data[0]);
break;
case 'localized':
this.reconstruct();
break;
}
this._lastTarget = target;
},
_goToAddEvent: function(date) {
// slight delay to avoid tapping the elements inside the add event screen
setTimeout(() => {
// don't need to set the date since the first tap triggers a click that
// sets the timeController.selectedDay
router.go('/event/add/');
}, 50);
},
changeDate: function(time) {
this.date = monthStart(time);
if (this.currentFrame) {
this.currentFrame.deactivate();
}
this.currentFrame = this._getFrame(this.date);
this._trimFrames();
this._appendFrames();
this.currentFrame.activate();
},
_getFrame: function(date) {
var id = date.getTime();
var frame = this.frames.get(id);
if (!frame) {
frame = new SingleMonth({
date: date,
container: this.element
});
frame.create();
this.frames.set(id, frame);
}
return frame;
},
_trimFrames: function() {
if (this.frames.size <= 3) {
return;
}
// full month (we always keep previous/next months)
var delta = 31 * 24 * 60 * 60 * 1000;
this.frames.forEach((frame, ts) => {
var base = Number(this.date);
if (Math.abs(base - ts) > delta) {
frame.destroy();
this.frames.delete(ts);
}
});
},
_appendFrames: function() {
// sort elements by timestamp (key = timestamp) so DOM makes more sense
Array.from(this.frames.keys())
.sort((a, b) => a - b)
.forEach(key => this.frames.get(key).append());
},
_setPresentDate: function() {
var id = Calc.getDayId(new Date());
var presentDate = this.element.querySelector('[data-date="' + id + '"]');
var previousDate = this.element.querySelector('.present');
if (previousDate) {
previousDate.classList.remove('present');
previousDate.classList.add('past');
}
if (presentDate) {
presentDate.classList.add('present');
}
},
oninactive: function() {
View.prototype.oninactive.call(this);
if (this.currentFrame) {
this.currentFrame.deactivate();
}
},
onfirstseen: function() {
this._initEvents();
this.changeDate(this.controller.month);
performance.monthReady();
},
destroy: function() {
this.frames.forEach((frame, key) => {
this.frames.delete(key);
frame.destroy();
});
},
reconstruct: function() {
// Watch for changed value from transition of a locale to another
this.destroy();
this.changeDate(this.controller.month);
}
};
});

View file

@ -0,0 +1,108 @@
define(function(require, exports, module) {
'use strict';
var Calc = require('common/calc');
var core = require('core');
// MonthDay represents a single day inside the Month view grid.
function MonthDay(options) {
this.container = options.container;
this.date = options.date;
this.month = options.month;
this._updateBusyCount = this._updateBusyCount.bind(this);
}
module.exports = MonthDay;
MonthDay.prototype = {
container: null,
date: null,
element: null,
month: null,
_observer: null,
create: function() {
var dayId = Calc.getDayId(this.date);
var id = 'month-view-day-' + dayId;
var state = Calc.relativeState(this.date, this.month);
var l10nStateId = state.replace(/\s/g, '-');
var date = this.date.getDate();
// we don't care about future/past states for l10n unless it's on
// a different month (Bug 1092729)
if (l10nStateId === 'future' || l10nStateId === 'past') {
l10nStateId = '';
} else {
l10nStateId += '-description';
}
var el = document.createElement('li');
el.setAttribute('role', 'gridcell');
el.id = id;
el.tabindex = 0;
el.setAttribute(
'aria-describedby',
`${id}-busy-indicator ${id}-description`
);
el.dataset.date = dayId;
el.className = state;
el.classList.add('month-day');
el.innerHTML = `<span class="day" role="button">${date}</span>
<div id="${id}-busy-indicator" class="busy-indicator"
aria-hidden="true"></div>
<span id="${id}-description" aria-hidden="true"
data-l10n-id="${l10nStateId}"></span>`;
this.element = el;
this.container.appendChild(el);
},
activate: function() {
this._observer = core.bridge.observeDay(this.date);
this._observer.listen(this._updateBusyCount);
},
deactivate: function() {
this._observer && this._observer.cancel();
},
destroy: function() {
this.deactivate();
this.container = this.element = null;
},
_updateBusyCount: function(data) {
var count = Math.min(3, data.amount);
var holder = this.element.querySelector('.busy-indicator');
if (count > 0) {
holder.setAttribute('aria-label', navigator.mozL10n.get('busy', {
n: count
}));
} else {
holder.removeAttribute('aria-label');
}
var diff = count - holder.childNodes.length;
if (diff === 0) {
return;
}
if (diff > 0) {
var dot;
while (diff--) {
dot = document.createElement('div');
dot.className = 'gaia-icon icon-calendar-dot';
holder.appendChild(dot);
}
return;
}
// difference < 0
while (diff++) {
holder.removeChild(holder.firstChild);
}
}
};
});

View file

@ -0,0 +1,120 @@
define(function(require, exports, module) {
'use strict';
var IntlHelper = require('shared/intl_helper');
var Parent = require('view');
var core = require('core');
var createDay = require('common/calc').createDay;
var isAllDay = require('common/calc').isAllDay;
var performance = require('performance');
var template = require('templates/month_day_agenda');
function MonthDayAgenda() {
Parent.apply(this, arguments);
this._render = this._render.bind(this);
this.controller = core.timeController;
}
module.exports = MonthDayAgenda;
MonthDayAgenda.prototype = {
__proto__: Parent.prototype,
date: null,
_observer: null,
selectors: {
element: '#month-day-agenda',
events: '.day-events',
currentDate: '#event-list-date',
emptyMessage: '#empty-message'
},
get element() {
return this._findElement('element');
},
get events() {
return this._findElement('events');
},
get currentDate() {
return this._findElement('currentDate');
},
get emptyMessage() {
return this._findElement('emptyMessage');
},
onactive: function() {
Parent.prototype.onactive.call(this);
this.controller.on('selectedDayChange', this);
this.changeDate(this.controller.selectedDay);
},
oninactive: function() {
Parent.prototype.oninactive.call(this);
this._unobserve();
this.controller.removeEventListener('selectedDayChange', this);
this.date = null;
},
_unobserve: function() {
this._observer && this._observer.cancel();
this._observer = null;
},
changeDate: function(date) {
// first time view is active the `selectedDay` is null
date = date || createDay(new Date());
this.date = date;
this._unobserve();
this._observer = core.bridge.observeDay(date);
this._observer.listen(this._render);
var formatId = 'months-day-view-header-format';
var formatter = IntlHelper.get(formatId);
this.currentDate.textContent = formatter.format(date);
// we need to set the [data-date] and [data-l10n-date-format] because
// locale might change while the app is still open
this.currentDate.dataset.date = date;
this.currentDate.dataset.l10nDateFormat = formatId;
},
_render: function(records) {
// we should always render allday events at the top
this.events.innerHTML = records.allday.concat(records.basic)
.map(this._renderEvent, this)
.join('');
this.emptyMessage.classList.toggle('active', records.amount === 0);
performance.monthsDayReady();
},
_renderEvent: function(record) {
var {event, busytime, color} = record;
var {startDate, endDate} = busytime;
return template.event.render({
hasAlarms: !!(event.remote.alarms && event.remote.alarms.length),
busytimeId: busytime._id,
color: color,
title: event.remote.title,
location: event.remote.location,
startTime: startDate,
endTime: endDate,
isAllDay: isAllDay(this.date, startDate, endDate)
});
},
handleEvent: function(e) {
switch (e.type) {
case 'selectedDayChange':
this.changeDate(e.data[0]);
break;
}
}
};
});

View file

@ -0,0 +1,359 @@
define(function(require, exports, module) {
'use strict';
var Calc = require('common/calc');
var CurrentTime = require('./current_time');
var HourDoubleTap = require('./hour_double_tap');
var IntlHelper = require('shared/intl_helper');
var Pan = require('./pan');
var SingleDay = require('./single_day');
var Timespan = require('common/timespan');
var View = require('view');
var animatedScroll = require('utils/animated_scroll');
var core = require('core');
var createDay = require('common/calc').createDay;
var template = require('templates/multi_day');
var throttle = require('utils/mout').throttle;
IntlHelper.define('hour-format', 'datetime', {
hour: 'numeric'
});
function MultiDay(opts) {
this.children = [];
this._render = throttle(this._render, 200);
}
module.exports = MultiDay;
MultiDay.prototype = {
// override these properties on child classes to change the behavior!
scale: 'week',
visibleCells: 5,
element: null,
_hourFormat: 'hour-format',
_oneDayLabelFormat: 'event-one-day-duration',
_addAmPmClass: false,
childClass: SingleDay,
children: null,
seen: false,
_baseDate: null,
_hourHeight: 0,
_prevRange: null,
_visibleRange: null,
set baseDate(date) {
// it's very important that base date doesn't hold hour info otherwise we
// could create duplicate days (because range wouldn't contain datetime)
this._baseDate = createDay(date);
},
get baseDate() {
return this._baseDate;
},
get daysHolder() {
return this.element.querySelector('.md__days');
},
get alldaysHolder() {
return this.element.querySelector('.md__alldays');
},
get main() {
return this.element.querySelector('.md__main');
},
get mainContent() {
return this.element.querySelector('.md__main-content');
},
get sidebar() {
return this.element.querySelector('.md__sidebar');
},
get allDayIcon() {
return this.element.querySelector('.md__all-day');
},
onactive: function() {
this.element.classList.add(View.ACTIVE);
if (!this.seen) {
this.onfirstseen();
this.seen = true;
}
var controller = core.timeController;
controller.scale = this.scale;
controller.moveToMostRecentDay();
var previousBaseDate = this.baseDate;
this.baseDate = this._calcBaseDate(controller.position);
this._render();
if (window.history.state && 'eventStartHour' in window.history.state) {
// scroll to last edited event
this._scrollToHour({
hour: Math.max(window.history.state.eventStartHour - 1, 0)
});
} else if (!(previousBaseDate &&
Calc.isSameDate(previousBaseDate, this.baseDate))) {
// Do not scroll when come back from other time views without changing the
// base date
this._resetScroll();
this._scrollToHour();
}
// add listeners afterwards to avoid calling render twice
controller.on('dayChange', this);
},
_calcBaseDate: function(date) {
// this is overwritten by week view, and only called during onactivate
return date;
},
onfirstseen: function() {
this._setupPan();
this._setupHours();
this._setupCurrentTime();
this._setupDoubleTap();
this.allDayIcon.id = 'md__all-day-icon-' + this.scale;
// we keep the localized listener even when view is inactive to avoid
// rebuilding the hours/dates every time we switch between views
window.addEventListener('localized', this);
window.addEventListener('timeformatchange', this);
// When screen reader is used, scrolling is done using wheel events.
this.element.addEventListener('wheel', this);
},
_setupPan: function() {
var containerWidth = this.daysHolder.parentNode.offsetWidth;
this._pan = new Pan({
gridSize: Math.round(containerWidth / this.visibleCells),
visibleCells: this.visibleCells,
eventTarget: this.element,
overflows: [
this.main
],
targets: [
this.alldaysHolder,
this.daysHolder
]
});
this._pan.setup();
this._pan.on('start', () => this._hourDoubleTap.removeAddEventLink());
this._pan.on('release', obj => this._updateBaseDateAfterScroll(obj.diff));
},
_setupHours: function() {
var sidebar = this.sidebar;
// we need to remove all children because when locale change we rebuild
// the hours (we can't use data-l10n-id because of special format)
sidebar.innerHTML = '';
var i = -1, hours = '';
while (++i < 24) {
hours += template.hour.render({
hour: i,
format: this._hourFormat,
addAmPmClass: this._addAmPmClass
});
}
sidebar.innerHTML = hours;
this._hourHeight = sidebar.querySelector('.md__hour').offsetHeight;
},
_setupCurrentTime: function() {
this._currentTime = new CurrentTime({
container: this.mainContent,
sticky: this.alldaysHolder
});
},
_setupDoubleTap: function() {
this._hourDoubleTap = new HourDoubleTap({
main: this.main,
daysHolder: this.daysHolder,
alldaysHolder: this.alldaysHolder,
hourHeight: this._hourHeight
});
this._hourDoubleTap.setup();
},
handleEvent: function(e) {
switch (e.type) {
case 'dayChange':
this._onDayChange(e.data[0]);
break;
case 'localized':
case 'timeformatchange':
this._localize();
break;
case 'wheel':
this._onwheel(e);
break;
}
},
_onwheel: function(event) {
if (event.deltaMode !== event.DOM_DELTA_PAGE || event.deltaX === 0) {
return;
}
// Update dates based on the number of visible cells after screen reader
// wheel.
this._updateBaseDateAfterScroll(event.deltaX * this.visibleCells);
},
_onDayChange: function(date) {
// _render() updates the _visibleRange, so we need to check it first
var containedToday = this._visibleRange.contains(new Date());
this.baseDate = date;
this._render();
if (!containedToday) {
this._scrollToHour({ onlyToday: true });
}
},
_localize: function() {
this._setupHours();
this._refreshCurrentTime();
},
_updateBaseDateAfterScroll: function(diff) {
var day = createDayDiff(this.baseDate, diff);
core.timeController.move(day);
core.timeController.selectedDay = day;
},
_render: function() {
var currentRange = this._getRange();
this._removeDatesOutsideRange(currentRange);
// very important to re-activate child views in case we change views
// without moving to a different date
this.children.forEach(child => child.onactive());
this._addDatesInsideRange(currentRange);
this._prevRange = currentRange;
this._visibleRange = this._getVisibleRange();
this._sortDays();
this._setVisibleForScreenReader();
this._pan.refresh();
this._refreshCurrentTime();
},
_refreshCurrentTime: function() {
this._currentTime.timespan = this._visibleRange;
this._currentTime.refresh();
},
_setVisibleForScreenReader: function() {
this.children.forEach(
child => child.setVisibleForScreenReader(this._visibleRange));
},
_removeDatesOutsideRange: function(range) {
if (this.children.length) {
this.children = this.children.filter(child => {
if (range.contains(child.date)) {
return true;
}
child.destroy();
return false;
});
}
},
_addDatesInsideRange: function(range) {
this._getPendingDates(range)
.forEach(date => {
var day = new this.childClass({
date: date,
daysHolder: this.daysHolder,
alldaysHolder: this.alldaysHolder,
allDayIcon: this.allDayIcon,
hourHeight: this._hourHeight,
oneDayLabelFormat: this._oneDayLabelFormat
});
day.setup();
this.children.push(day);
});
},
_getPendingDates: function(range) {
var dates = Calc.daysBetween(range);
if (this._prevRange) {
dates = dates.filter(date => {
return !this._prevRange.contains(date);
});
}
return dates;
},
_sortDays: function() {
// decided to use float and reappend the elements in the right order
// since using position:absolute or css transforms felt "slower"
// (we have a reflow anyway since we might add new elements to the DOM)
this.children
.sort((a, b) => a.date - b.date)
.forEach(day => day.append());
},
_getRange: function() {
return new Timespan(
createDayDiff(this.baseDate, -this.visibleCells),
createDayDiff(this.baseDate, (this.visibleCells * 2) - 1)
);
},
_getVisibleRange: function() {
return new Timespan(
this.baseDate,
createDayDiff(this.baseDate, this.visibleCells)
);
},
_resetScroll: function() {
this.main.scrollTop = 0;
},
_scrollToHour: function(options) {
var hour = this._getScrollDestinationHour(options);
if (hour != null) {
animatedScroll({
content: this.mainContent,
container: this.main,
scrollTop: hour * this._hourHeight
});
}
},
_getScrollDestinationHour: function(options) {
var hour = options && options.hour;
if (hour != null) {
return hour;
}
var now = new Date();
if (this._visibleRange.contains(now)) {
return Math.max(now.getHours() - 1, 0);
}
return (options && options.onlyToday) ? null : 8;
},
oninactive: function() {
this.element.classList.remove(View.ACTIVE);
core.timeController.removeEventListener('dayChange', this);
this.children.forEach(child => child.oninactive());
}
};
function createDayDiff(date, diff) {
return createDay(date, date.getDate() + diff);
}
});

View file

@ -0,0 +1,231 @@
// this module is responsible for the touch/panning of MultiDay views
define(function(require, exports, module) {
'use strict';
var EventEmitter2 = require('ext/eventemitter2');
var clamp = require('utils/mout').clamp;
var lerp = require('utils/mout').lerp;
var norm = require('utils/mout').norm;
function Pan(options) {
EventEmitter2.call(this);
this.eventTarget = options.eventTarget;
this.targets = options.targets;
this.overflows = options.overflows || [];
this._gridSize = Math.max(options.gridSize || 0, 1);
this._visibleCells = Math.max(options.visibleCells || 0, 1);
this._startMouseX = this._startMouseY = 0;
this._isVertical = false;
this._startTime = 0;
this._touchStart = 0;
this._dx = 0;
// _lockedAxis is used to control if we detected if the movement is
// vertical/horizontal, very important for ignoring clicks and also to be
// able to set a threshold for the axis detection
this._lockedAxis = false;
this._onTouchStart = this._onTouchStart.bind(this);
this._onTouchMove = this._onTouchMove.bind(this);
this._onTouchEnd = this._onTouchEnd.bind(this);
this._tick = this._tick.bind(this);
this._setBaseValues = this._setBaseValues.bind(this);
this._onTweenEnd = null;
}
module.exports = Pan;
Pan.prototype = {
__proto__: EventEmitter2.prototype,
TRANSITION_DURATION: 800,
setup: function() {
var element = this.eventTarget;
element.addEventListener('touchstart', this._onTouchStart);
element.addEventListener('touchmove', this._onTouchMove);
element.addEventListener('touchend', this._onTouchEnd);
element.addEventListener('touchcancel', this._onTouchEnd);
window.addEventListener('localized', this._setBaseValues);
this._setBaseValues();
},
_setBaseValues: function() {
var delta = this._gridSize * this._visibleCells;
if (document.documentElement.dir === 'rtl') {
this._dir = -1;
this._minX = 0;
this._maxX = delta * 2;
} else {
this._dir = 1;
this._minX = delta * -2;
this._maxX = 0;
}
this._origX = this._startX = this._curX = this._destX = delta * -this._dir;
this._set(this._origX);
},
_onTouchStart: function(evt) {
this._startMouseX = evt.touches[0].clientX;
this._startMouseY = evt.touches[0].clientY;
this._isVertical = false;
this._lockedAxis = false;
this._touchStart = Date.now();
// we need to reset the tween callback because we should only call it
// once and only if user did not trigger a new touch
this._onTweenEnd = null;
},
_onTouchMove: function(evt) {
if (this._isVertical) {
return;
}
var dx = this._startMouseX - evt.touches[0].clientX;
var dy = this._startMouseY - evt.touches[0].clientY;
this._dx = dx;
if (!this._lockedAxis) {
var adx = Math.abs(dx);
var ady = Math.abs(dy);
// we wait until we are sure movement is horizontal before we do anything.
// if absolute difference between x/y movement is over a threshold (10px)
// we assume drag follows a single axis.
if (Math.abs(adx - ady) < 10) {
return;
}
this._isVertical = adx < ady;
this._lockedAxis = true;
this.emit('start');
if (this._isVertical) {
return;
}
// we should only lock scroll once and only if dragging horizontally
this._lockScroll();
}
this._updateDestination(this._origX - dx, 0);
},
_lockScroll: function() {
this.overflows.forEach(el => el.style.overflowY = 'hidden');
},
_unlockScroll: function() {
this.overflows.forEach(el => el.style.overflowY = 'scroll');
},
_updateDestination: function(x, duration) {
duration = duration != null ? duration : this.TRANSITION_DURATION;
this._startX = this._curX;
this._destX = clamp(x, this._minX, this._maxX);
var now = Date.now();
this._endTime = now + duration;
if (!this._requestId) {
this._startTime = now;
this._tween();
}
},
_tween: function() {
this._requestId = window.requestAnimationFrame(this._tick);
},
_tick: function() {
var t = norm(Date.now(), this._startTime, this._endTime);
if (t >= 1 || this._curX === this._destX) {
this._killTween();
this._set(this._destX);
return;
}
var x = lerp(ease(t), this._startX, this._destX);
this._set(x);
this._tween();
},
_killTween: function() {
if (this._requestId) {
window.cancelAnimationFrame(this._requestId);
this._requestId = null;
}
this._onTweenEnd && this._onTweenEnd.call(this);
},
_onTouchEnd: function() {
// if touch is very fast momentum would be bigger than our threshold,
// this is very important for click events otherwise they wouldn't open
// the event details
if (this._isVertical || !this._lockedAxis) {
this._unlockScroll();
return;
}
var duration = Date.now() - this._touchStart;
var momentum = Math.abs(this._dx) / duration;
var snap;
if (momentum > 0.5) {
// if the drag was fast we consider it as a swipe (move multiple cells
// at once)
var direction = this._dx > 0 ? -1 : 1;
snap = this._origX + (direction * this._gridSize * this._visibleCells);
} else {
// we only round up if very close to the next column, this behavior is
// better for the user than a regular round/ceil/floor
snap = Math.round((this._destX / this._gridSize) + 0.2) *
this._gridSize;
}
// we only unlock the scroll after the tween is complete to make multiple
// consecutive swipes faster (also avoids flickering y-axis position)
this._onTweenEnd = this._unlockScroll;
this._updateDestination(snap);
this.emit('release', {
diff: Math.round((this._origX - this._destX) / this._gridSize) * this._dir
});
},
_set: function(x) {
x = clamp(x, this._minX, this._maxX);
this.targets.forEach(el => {
el.style.transform = 'translateX(' + x +'px)';
});
this._curX = x;
},
refresh: function() {
var diff = Math.abs(this._curX - this._destX);
diff *= this._curX < this._destX ? -1 : 1;
// we update the position based on the relative distance to keep a smooth
// transition
if (diff) {
this._set(this._origX + diff);
this._updateDestination(this._origX);
} else {
this._set(this._origX);
}
}
};
// borrowed from zeh/ztween (expoOut)
function ease(t) {
return (t >= 0.999) ? 1 : 1.001 * (-Math.pow(2, -10 * t) + 1);
}
});

View file

@ -0,0 +1,358 @@
define(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;
});

View file

@ -0,0 +1,219 @@
/* global IntlHelper */
define(function(require, exports, module) {
'use strict';
var Overlap = require('utils/overlap');
var buildElement = require('utils/dom').buildElement;
var core = require('core');
var isSameDate = require('common/calc').isSameDate;
var relativeDuration = require('common/calc').relativeDuration;
var relativeOffset = require('common/calc').relativeOffset;
var spanOfDay = require('common/calc').spanOfDay;
var template = require('templates/multi_day');
var _id = 0;
IntlHelper.define('longDateFormat', 'datetime', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
});
function SingleDay(config) {
this.date = config.date;
this._hourHeight = config.hourHeight;
this._daysHolder = config.daysHolder;
this._allDayIcon = config.allDayIcon;
this._alldaysHolder = config.alldaysHolder;
this._oneDayLabelFormat = config.oneDayLabelFormat;
this._render = this._render.bind(this);
this._instanceID = _id++;
this.overlaps = new Overlap();
}
module.exports = SingleDay;
SingleDay.prototype = {
_isActive: false,
_borderWidth: 0.1,
_attached: false,
_observer: null,
setup: function() {
this.day = buildElement(template.day.render({
date: this.date
}));
this.allday = buildElement(template.allday.render({
date: this.date,
id: this._instanceID
}));
this._dayName = this.allday.querySelector('.md__day-name');
this._alldayEvents = this.allday.querySelector('.md__allday-events');
this._updateDayName();
this.onactive();
},
_updateDayName: function() {
// we can't use [data-l10n-date-format] because format might change based
// on locale
this._dayName.textContent = this.date.toLocaleString(navigator.languages, {
weekday: 'short',
day: 'numeric'
});
},
handleEvent: function(evt) {
switch(evt.type) {
case 'localized':
this._updateDayName();
break;
}
},
append: function() {
this._daysHolder.appendChild(this.day);
this._alldaysHolder.appendChild(this.allday);
this._attached = true;
},
onactive: function() {
if (this._isActive) {
return;
}
this._observer = core.bridge.observeDay(this.date);
this._observer.listen(this._render);
window.addEventListener('localized', this);
this._isActive = true;
},
_render: function(records) {
this._alldayEvents.innerHTML = '';
records.allday.forEach(this._renderAlldayEvent, this);
this.overlaps.reset();
this.day.innerHTML = '';
records.basic.forEach(this._renderEvent, this);
if (this._alldayEvents.children.length > 0) {
// If there are all day events, the section acts as a listbox.
this._alldayEvents.setAttribute('role', 'listbox');
this._alldayEvents.setAttribute('aria-labelledby', this._allDayIcon.id +
' ' + this._dayName.id);
} else {
// If there are no all day events, the section acts as a create new all
// day event button.
this._alldayEvents.setAttribute('role', 'button');
this._alldayEvents.setAttribute('data-l10n-id', 'create-all-day-event');
this._alldayEvents.setAttribute('aria-describedby', this._dayName.id);
}
},
_renderEvent: function(record) {
var el = this._buildEventElement(record);
var {busytime} = record;
var {startDate, endDate} = busytime;
// we don't set the height on the template because we use the same markup
// for all day events as well
var duration = relativeDuration(this.date, startDate, endDate);
// we subtract border to keep a margin between consecutive events
var hei = duration * this._hourHeight - this._borderWidth;
// It doesn't make sense to set height less than 1 px, so we set it to 1 px
// in order to show events correctly.
hei = Math.max(1, hei);
el.style.height = hei + 'px';
if (duration < 1) {
el.classList.add('is-partial');
var size = '';
// we need to toggle layout if event lasts less than 20, 30 and 45min
if (duration < 0.3) {
size = 'micro';
} else if (duration < 0.5) {
size = 'tiny';
} else if (duration < 0.75) {
size = 'small';
}
if (size) {
el.classList.add('is-partial-' + size);
}
}
var offset = relativeOffset(this.date, startDate);
el.style.top = (offset * this._hourHeight) + 'px';
this.overlaps.add(busytime, el);
this.day.appendChild(el);
},
_buildEventElement: function(record) {
var {title, location, alarms} = record.event.remote;
var {startDate, endDate, _id} = record.busytime;
// screen reader should be aware if the event spans multiple dates and also
// know the event duration without having the open it
var labelFormat = isSameDate(startDate, endDate) ? this._oneDayLabelFormat :
'event-multiple-day-duration';
var longDateFormat = IntlHelper.get('longDateFormat');
var shortTimeFormat = IntlHelper.get('shortTimeFormat');
var labelFormatArgs = JSON.stringify({
startDate: longDateFormat.format(startDate),
startTime: shortTimeFormat.format(startDate),
endDate: longDateFormat.format(endDate),
endTime: shortTimeFormat.format(endDate)
});
return buildElement(template.event.render({
id: _id,
instance: this._instanceID,
title: title,
location: location,
hasAlarms: alarms && alarms.length,
color: record.color,
labelFormat: labelFormat,
labelFormatArgs: labelFormatArgs
}));
},
_renderAlldayEvent: function(record) {
var el = this._buildEventElement(record);
el.classList.add('is-allday');
el.setAttribute('role', 'option');
this._alldayEvents.appendChild(el);
},
destroy: function() {
this.oninactive();
this._detach();
},
_detach: function() {
if (this._attached) {
this._daysHolder.removeChild(this.day);
this._alldaysHolder.removeChild(this.allday);
this._attached = false;
}
},
oninactive: function() {
if (!this._isActive) {
return;
}
this._observer.cancel();
window.removeEventListener('localized', this);
this._isActive = false;
},
setVisibleForScreenReader: function(visibleRange) {
var visible = visibleRange.contains(spanOfDay(this.date));
this.day.setAttribute('aria-hidden', !visible);
this.allday.setAttribute('aria-hidden', !visible);
}
};
});

View file

@ -0,0 +1,159 @@
define(function(require, exports, module) {
'use strict';
var Calc = require('common/calc');
var MonthDay = require('./month_day');
var View = require('view');
var core = require('core');
var daysBetween = Calc.daysBetween;
var daysInWeek = Calc.daysInWeek;
var getDayId = Calc.getDayId;
var spanOfMonth = Calc.spanOfMonth;
var SELECTED = 'selected';
// SingleMonth contains all the logic required to build the Month view grid and
// groups all the MonthDay instances for that given month.
function SingleMonth() {
View.apply(this, arguments);
this.days = [];
this.timespan = spanOfMonth(this.date);
}
module.exports = SingleMonth;
SingleMonth.prototype = {
__proto__: View.prototype,
active: false,
container: null,
date: null,
days: null,
element: null,
create: function() {
var element = document.createElement('section');
element.className = 'month';
element.setAttribute('role', 'grid');
element.setAttribute('aria-labelledby', 'current-month-year');
element.setAttribute('aria-readonly', true);
element.innerHTML = this._renderDayHeaders();
element.dataset.date = getDayId(this.date);
this.element = element;
this._buildWeeks();
},
_renderDayHeaders: function() {
// startDay might change during the 'localized' event
var startDay = Calc.startDay;
var days = [];
var i;
for (i = startDay; i < 7; i++) {
days.push(this._dayHeader(i));
}
if (startDay > 0) {
for (i = 0; i < startDay; i++) {
days.push(this._dayHeader(i));
}
}
var html = `<header id="month-days" role="presentation">
<ol role="row">${days.join('')}</ol>
</header>`;
return html;
},
_dayHeader: function(dayIndex) {
return `<li data-l10n-id="weekday-${dayIndex}-single-char"
role="columnheader"></li>`;
},
_buildWeeks: function() {
var weekLength = daysInWeek();
var week;
daysBetween(this.timespan).forEach((date, i) => {
if (i % weekLength === 0) {
week = document.createElement('ol');
week.setAttribute('role', 'row');
this.element.appendChild(week);
}
var day = new MonthDay({
date: date,
month: this.date,
container: week
});
day.create();
this.days.push(day);
});
},
activate: function() {
if (this.active) {
return;
}
this.active = true;
this.onactive();
this.days.forEach(day => day.activate());
this._onSelectedDayChange(core.timeController.selectedDay);
core.timeController.on('selectedDayChange', this);
},
deactivate: function() {
if (!this.active) {
return;
}
this.active = false;
this.oninactive();
this.days.forEach(day => day.deactivate());
core.timeController.off('selectedDayChange', this);
},
destroy: function() {
this.deactivate();
this._detach();
this.days.forEach(day => day.destroy());
this.days = [];
},
append: function() {
this.container.appendChild(this.element);
},
_detach: function() {
if (this.element && this.element.parentNode) {
this.element.parentNode.removeChild(this.element);
}
},
handleEvent: function(e) {
if (e.type === 'selectedDayChange') {
this._onSelectedDayChange(e.data[0]);
}
},
_clearSelectedDay: function() {
var day = this.element.querySelector(`li.${SELECTED}`);
if (day) {
day.classList.remove(SELECTED);
day.removeAttribute('aria-selected');
}
},
_onSelectedDayChange: function(date) {
this._clearSelectedDay();
if (!date || !this.timespan.contains(date)) {
return;
}
var el = this.element.querySelector(`li[data-date="${getDayId(date)}"]`);
el.classList.add(SELECTED);
el.setAttribute('aria-selected', true);
// Put the screen reader cursor onto the selected day.
el.focus();
}
};
});

View file

@ -0,0 +1,150 @@
define(function(require, exports, module) {
'use strict';
var IntlHelper = require('shared/intl_helper');
var View = require('view');
var core = require('core');
var router = require('router');
var SETTINGS = /settings/;
function TimeHeader() {
View.apply(this, arguments);
this.controller = core.timeController;
this.controller.on('scaleChange', this);
this.element.addEventListener('action', (e) => {
e.stopPropagation();
var path = window.location.pathname;
if (SETTINGS.test(path)) {
router.resetState();
} else {
router.show('/settings/');
}
});
}
module.exports = TimeHeader;
TimeHeader.prototype = {
__proto__: View.prototype,
selectors: {
element: '#time-header',
title: '#time-header h1'
},
scales: {
month: 'multi-month-view-header-format',
day: 'day-view-header-format',
// when week starts in one month and ends
// in another, we need both of them
// in the header
multiMonth: 'multi-month-view-header-format'
},
handleEvent: function(e) {
// respond to all events here but
// we add/remove listeners to reduce
// calls
switch (e.type) {
case 'yearChange':
case 'monthChange':
case 'dayChange':
case 'weekChange':
this._updateTitle();
break;
case 'scaleChange':
this._updateScale.apply(this, e.data);
break;
}
},
get title() {
return this._findElement('title');
},
_scaleEvent: function(event) {
switch (event) {
case 'month':
return 'monthChange';
case 'year':
return 'yearChange';
case 'week':
return 'weekChange';
}
return 'dayChange';
},
_updateScale: function(newScale, oldScale) {
if (oldScale) {
this.controller.removeEventListener(
this._scaleEvent(oldScale),
this
);
}
this.controller.addEventListener(
this._scaleEvent(newScale),
this
);
this._updateTitle();
},
getScale: function(type) {
var position = this.controller.position;
if (type === 'week') {
var lastWeekday = this._getLastWeekday();
if (position.getMonth() !== lastWeekday.getMonth()) {
// when displaying dates from multiple months we use a different
// format to avoid overflowing
return this._localeFormat(position, 'multiMonth') + ' ' +
this._localeFormat(lastWeekday, 'multiMonth');
}
// if it isn't "multiMonth" we use "month" instead
type = 'month';
}
return this._localeFormat(position, type || 'month');
},
_getLastWeekday: function(){
// we display 5 days at a time, controller.position is always the day on
// the left of the view
var position = this.controller.position;
return new Date(
position.getFullYear(),
position.getMonth(),
position.getDate() + 4
);
},
_localeFormat: function(date, scale) {
if (!this.scales[scale]) {
return;
}
var formatter = IntlHelper.get(this.scales[scale]);
return formatter.format(date);
},
_updateTitle: function() {
var con = core.timeController;
var title = this.title;
title.dataset.l10nDateFormat =
this.scales[con.scale] || this.scales.month;
title.dataset.date = con.position.toString();
title.textContent = this.getScale(
con.scale
);
},
render: function() {
this._updateScale(this.controller.scale);
}
};
});

View file

@ -0,0 +1,138 @@
define(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);
}
};
});

View file

@ -0,0 +1,63 @@
define(function(require, exports, module) {
'use strict';
var View = require('view');
var core = require('core');
var nextTick = require('common/next_tick');
var timeObserver = require('time_observer');
function ViewSelector(opts) {
View.call(this, opts);
this._showTodayDate = this._showTodayDate.bind(this);
}
module.exports = ViewSelector;
ViewSelector.prototype = {
__proto__: View.prototype,
selectors: {
element: '#view-selector'
},
render: function() {
this._showTodayDate();
this.delegate(this.element, 'click', 'a', this);
timeObserver.on('day', this._showTodayDate);
},
handleEvent: function(event, target) {
if (target.matches('[role="tab"]')) {
this._toggleActiveTabOnClick(event);
} else {
this._moveToTodayOnClick(event);
}
},
_moveToTodayOnClick: function(event) {
var date = new Date();
core.timeController.move(date);
core.timeController.selectedDay = date;
event.preventDefault();
},
// FIXME: this should be handled by the router (Bug 1021445)
// Handle aria-selected attribute for tabs.
_toggleActiveTabOnClick: function(event) {
var tabs = this.element.querySelectorAll('[role="tab"]');
Array.from(tabs).forEach(tab => {
if (tab !== event.target) {
tab.setAttribute('aria-selected', false);
return;
}
nextTick(() => tab.setAttribute('aria-selected', true));
});
},
_showTodayDate: function() {
var icon = this.element.querySelector('.icon-calendar-today');
icon.innerHTML = (new Date()).getDate();
}
};
});

View file

@ -0,0 +1,48 @@
define(function(require, exports, module) {
'use strict';
var Calc = require('common/calc');
var IntlHelper = require('shared/intl_helper');
var MultiDay = require('./multi_day');
require('dom!week-view');
IntlHelper.define('week-hour-format', 'mozdatetime', {
hour: 'numeric',
dayperiod: true,
});
function WeekView(opts) {
MultiDay.apply(this, arguments);
}
module.exports = WeekView;
WeekView.prototype = {
__proto__: MultiDay.prototype,
scale: 'week',
visibleCells: 5,
_hourFormat: 'week-hour-format',
_oneDayLabelFormat: 'week-event-one-day-duration',
_addAmPmClass: true,
get element() {
return document.getElementById('week-view');
},
_calcBaseDate: function(date) {
// Don't reset the first day when come back from other screens.
if (this.baseDate && Calc.isSameDate(date, this.baseDate)) {
return this.baseDate;
}
// Show monday as the first day of the grid if date is between Mon-Fri.
var index = Calc.dayOfWeekFromMonday(date.getDay());
if (index < 5) {
date = Calc.createDay(date, date.getDate() - index);
}
return date;
}
};
});