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; });