<commit_before>
/**
*  Tables list
*
*  It will show the user tables in a list
*
*  Usage example:
*
*  var tableList = new cdb.admin.dashboard.TableList({
*    el: this.$('#tablelist'),
*    collection: this.tables,
*    user: this.user   // it needs it to know if the user has limitations or not
*  });
*
*/

cdb.admin.dashboard.TableList = cdb.core.View.extend({

  tagName: 'ul',

  initialize: function() {
    this.collection.bind('add remove reset', this.render, this);
  },

  render: function() {
    this.clearSubViews();
    var self = this;

    this.collection.each(function(table) {
      var table_item = new cdb.admin.dashboard.TableItem({
        model: table,
        user: self.options.user,
        table_options: self.collection.options
      })
      .bind('remove', function() {

        self.trigger('showLoader');

        var xhr = this.model.destroy();
        $.when(xhr).done(function() {
          self.trigger('remove');
        });
      });

      self.$el.append(table_item.render().el);
      self.addView(table_item);

    });
  }

});

/**
* Single table item in dashboard tables list
*
* Usage example:
*
*  var li = new cdb.admin.dashboard.TableItem({
*    model: table_model,
*    limitation: !this.options.user.get("private_tables")
*  });
*
*/

cdb.admin.dashboard.TableItem = cdb.core.View.extend({

  tagName: 'li',

  _TAGS_PER_ITEM: 6,

  _PRIVACY_VALUES: ['public','private'],

  _TEXTS: {
    sync: {
      state: {
        enabled: 'SYNCED <%= ran_at %> AGO',
        error: 'ERROR WITH SYNC'
      },
      msg: {
        enabled: '',
        error: 'The source is not available anymore. Please check its location'
      }
    }
  },

  events: {
    'click a.delete': '_deleteTable',
    'click a.status': '_changePrivacy'
  },

  initialize: function() {
  _.bindAll(this, "_changePrivacy", "_showDeleteDialog");

    this.template = cdb.templates.getTemplate('dashboard/views/table_list_item');

    this.model.bind('change:privacy',     this._setPrivacy,     this);
  },

  _generateTagList: function(tags, selected_tag) {

    if (!tags) return;

    var tags_count = this._TAGS_PER_ITEM;

    tags = tags.slice(0, tags_count);

    var template = _.template('<a href="/dashboard/table/tag/<%= tag %>" data-tag="<%= tag %>"><%= tag %>');

    if (selected_tag) {
      var selected_tag_included = _.contains(tags, selected_tag);
      if (!selected_tag_included) tags_count--;
    }

    // Render the tags
    var result = _.map(tags, function(t) {
      return template({ tag: t });
    }).slice(0, tags_count);

    // Add the selected_tag in case it wasn't already added
    if (!selected_tag_included && selected_tag) {
      result.push(template({ tag: selected_tag }));
    }

    return result.reverse().join(" ");

  },

  _formatNumber:function(x) {
    if (x) {
      var parts = x.toString().split(".");
      parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
      return parts.join(".");
    }
  },

  _cleanString: function(s, n) {

    if (s) {
      s = s.replace(/<(?:.|\n)*?>/gm, ''); // strip HTML tags
      s = s.substr(0, n-1) + (s.length > n ? '…' : ''); // truncate string
    }

    return s;

  },

  render: function() {
    var attrs = this.model.toJSON();

    attrs.table_size = this._bytesToSize(attrs.table.size);

    var selected_tag = this.options.table_options.get("tags")

    var tags             = this._generateTagList(attrs.tags, selected_tag);
    var name             = this._cleanString(this.model.get("name"), 30);
    var description      = this.model.get("description");
    var pretty_row_count = this._formatNumber(this.model.attributes.table.row_count);
    var extra_tags_count = attrs.tags.length - this._TAGS_PER_ITEM;

    var template_vars = {
      sync_status: "",
      order: this.options.table_options.get("order"),
      row_count: pretty_row_count,
      name: name,
      description: description,
      tags: tags,
      extra_tags_count: extra_tags_count
    };

    // Check sync status
    // If table is under synchronization, it will show the info
    // available about the sync.
    if (!_.isEmpty(this.model.get("synchronization"))) {

      var sync_attrs = _.clone(this.model.get("synchronization"));
      sync_attrs.ran_at = moment(sync_attrs.ran_at || new Date()).fromNow();
      var state = sync_attrs.state;

      _.extend(
        template_vars,
        {
          sync_status: state,
          sync_message: _.template(this._TEXTS.sync.state[state == "failure" ? "error" : "enabled" ])(sync_attrs),
          sync_error_message: this._TEXTS.sync.msg[state == "failure" ? "error" : "enabled" ]
        }
      );
    }

    this.$el.append(this.template(_.extend(attrs, template_vars)));

    this.$el.find(".feed.failure a").tipsy({
      fade: true,
      gravity: "s",
      className: 'feed',
      offset: 20,
      title: function() {
        return $(this).attr("data-tipsy")
      }
    });

    this.$('a.delete').tipsy({ gravity: 's', fade: true });

    return this;
  },

  _deleteTable: function(e) {
    this.killEvent(e);
    this._confirmAndDelete(e);
  },

  /**
  * Show delete confirmation after decides delete a table
  */
  _confirmAndDelete: function(ev) {
    ev && ev.preventDefault();

    this.table = new cdb.admin.CartoDBTableMetadata({ id: this.model.get("table").id });
    this._showDeleteDialog();

  },

  _showDeleteDialog: function() {

    var self = this;

    this.delete_dialog = new cdb.admin.DeleteDialog({
      model: this.table,
      title: "Delete this table",
      content: 'You are about to delete this table. Doing so will result in the deletion of this dataset. We recommend you export it before deleting it.',
      config: this.options.config
    });

    $("body").append(this.delete_dialog.render().el);
    this.delete_dialog.open();

    this.delete_dialog.wait()
    .done(this.deleteTable.bind(this))
    .fail(this.cleanDeleteDialog.bind(this));

  },

  cleanDeleteDialog: function() {
    this.delete_dialog.clean();
  },

  deleteTable: function() {
    this.trigger('remove');
  },

  clean: function() {
    // Remove tipsy
    if (this.$("a.delete").data('tipsy')) {
      this.$("a.delete").unbind('mouseenter mouseleave');
      this.$("a.delete").data('tipsy').remove();
    }

    cdb.core.View.prototype.clean.call(this);
  },

  /**
   *  Set privacy status
   */
  _setPrivacy: function() {
    var $status = this.$('a.status');

    for(var n in this._PRIVACY_VALUES) {
      var privacyName = this._PRIVACY_VALUES[n]
      $status.removeClass(privacyName);
    }

    $status
      .addClass(this.model.get('privacy').toLowerCase())
      .html(this.model.get('privacy'))
      .show();
  },

  /**
   *  Update the privacy status
   */
  _changePrivacy: function(ev) {
    ev.preventDefault();

    this.privacy && this.privacy.clean();
    cdb.god.trigger("closeDialogs");

    // Add privacy selector
    var privacy = this.privacy = new cdb.admin.PrivacySelector({
      model: this.model,
      limitation: !this.options.user.get("actions").private_tables,
      direction: 'up',
      upgrade_url: window.location.protocol + '//' + config.account_host + "/account/" + user_data.username + "/upgrade"
    });

    cdb.god.bind("closeDialogs", this.privacy.hide, this.privacy);

    // Set position and show privacy selector
    this.$el.find(".table_info").append(this.privacy.render().el);
    this.privacy.show($(ev.target), "position");

    return false;
  },

  // Help functions
  _bytesToSize: function(by) {
    var bytes = parseInt(by.toString())
    , sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
    if (bytes == 0) return '0 KB';
    var i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
    var value = (bytes / Math.pow(1024, i)).toFixed(2);

    if (value % 1 == 0) {
      value = parseInt(value)
    }

    return value + " " + sizes[i];
  }
});
<commit_msg>removed double ago word for sync table item in dashboard list<commit_after>
/**
*  Tables list
*
*  It will show the user tables in a list
*
*  Usage example:
*
*  var tableList = new cdb.admin.dashboard.TableList({
*    el: this.$('#tablelist'),
*    collection: this.tables,
*    user: this.user   // it needs it to know if the user has limitations or not
*  });
*
*/

cdb.admin.dashboard.TableList = cdb.core.View.extend({

  tagName: 'ul',

  initialize: function() {
    this.collection.bind('add remove reset', this.render, this);
  },

  render: function() {
    this.clearSubViews();
    var self = this;

    this.collection.each(function(table) {
      var table_item = new cdb.admin.dashboard.TableItem({
        model: table,
        user: self.options.user,
        table_options: self.collection.options
      })
      .bind('remove', function() {

        self.trigger('showLoader');

        var xhr = this.model.destroy();
        $.when(xhr).done(function() {
          self.trigger('remove');
        });
      });

      self.$el.append(table_item.render().el);
      self.addView(table_item);

    });
  }

});

/**
* Single table item in dashboard tables list
*
* Usage example:
*
*  var li = new cdb.admin.dashboard.TableItem({
*    model: table_model,
*    limitation: !this.options.user.get("private_tables")
*  });
*
*/

cdb.admin.dashboard.TableItem = cdb.core.View.extend({

  tagName: 'li',

  _TAGS_PER_ITEM: 6,

  _PRIVACY_VALUES: ['public','private'],

  _TEXTS: {
    sync: {
      state: {
        enabled: 'SYNCED <%= ran_at %>',
        error: 'ERROR WITH SYNC'
      },
      msg: {
        enabled: '',
        error: 'The source is not available anymore. Please check its location'
      }
    }
  },

  events: {
    'click a.delete': '_deleteTable',
    'click a.status': '_changePrivacy'
  },

  initialize: function() {
  _.bindAll(this, "_changePrivacy", "_showDeleteDialog");

    this.template = cdb.templates.getTemplate('dashboard/views/table_list_item');

    this.model.bind('change:privacy',     this._setPrivacy,     this);
  },

  _generateTagList: function(tags, selected_tag) {

    if (!tags) return;

    var tags_count = this._TAGS_PER_ITEM;

    tags = tags.slice(0, tags_count);

    var template = _.template('<a href="/dashboard/table/tag/<%= tag %>" data-tag="<%= tag %>"><%= tag %>');

    if (selected_tag) {
      var selected_tag_included = _.contains(tags, selected_tag);
      if (!selected_tag_included) tags_count--;
    }

    // Render the tags
    var result = _.map(tags, function(t) {
      return template({ tag: t });
    }).slice(0, tags_count);

    // Add the selected_tag in case it wasn't already added
    if (!selected_tag_included && selected_tag) {
      result.push(template({ tag: selected_tag }));
    }

    return result.reverse().join(" ");

  },

  _formatNumber:function(x) {
    if (x) {
      var parts = x.toString().split(".");
      parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
      return parts.join(".");
    }
  },

  _cleanString: function(s, n) {

    if (s) {
      s = s.replace(/<(?:.|\n)*?>/gm, ''); // strip HTML tags
      s = s.substr(0, n-1) + (s.length > n ? '…' : ''); // truncate string
    }

    return s;

  },

  render: function() {
    var attrs = this.model.toJSON();

    attrs.table_size = this._bytesToSize(attrs.table.size);

    var selected_tag = this.options.table_options.get("tags")

    var tags             = this._generateTagList(attrs.tags, selected_tag);
    var name             = this._cleanString(this.model.get("name"), 30);
    var description      = this.model.get("description");
    var pretty_row_count = this._formatNumber(this.model.attributes.table.row_count);
    var extra_tags_count = attrs.tags.length - this._TAGS_PER_ITEM;

    var template_vars = {
      sync_status: "",
      order: this.options.table_options.get("order"),
      row_count: pretty_row_count,
      name: name,
      description: description,
      tags: tags,
      extra_tags_count: extra_tags_count
    };

    // Check sync status
    // If table is under synchronization, it will show the info
    // available about the sync.
    if (!_.isEmpty(this.model.get("synchronization"))) {

      var sync_attrs = _.clone(this.model.get("synchronization"));
      sync_attrs.ran_at = moment(sync_attrs.ran_at || new Date()).fromNow();
      var state = sync_attrs.state;

      _.extend(
        template_vars,
        {
          sync_status: state,
          sync_message: _.template(this._TEXTS.sync.state[state == "failure" ? "error" : "enabled" ])(sync_attrs),
          sync_error_message: this._TEXTS.sync.msg[state == "failure" ? "error" : "enabled" ]
        }
      );
    }

    this.$el.append(this.template(_.extend(attrs, template_vars)));

    this.$el.find(".feed.failure a").tipsy({
      fade: true,
      gravity: "s",
      className: 'feed',
      offset: 20,
      title: function() {
        return $(this).attr("data-tipsy")
      }
    });

    this.$('a.delete').tipsy({ gravity: 's', fade: true });

    return this;
  },

  _deleteTable: function(e) {
    this.killEvent(e);
    this._confirmAndDelete(e);
  },

  /**
  * Show delete confirmation after decides delete a table
  */
  _confirmAndDelete: function(ev) {
    ev && ev.preventDefault();

    this.table = new cdb.admin.CartoDBTableMetadata({ id: this.model.get("table").id });
    this._showDeleteDialog();

  },

  _showDeleteDialog: function() {

    var self = this;

    this.delete_dialog = new cdb.admin.DeleteDialog({
      model: this.table,
      title: "Delete this table",
      content: 'You are about to delete this table. Doing so will result in the deletion of this dataset. We recommend you export it before deleting it.',
      config: this.options.config
    });

    $("body").append(this.delete_dialog.render().el);
    this.delete_dialog.open();

    this.delete_dialog.wait()
    .done(this.deleteTable.bind(this))
    .fail(this.cleanDeleteDialog.bind(this));

  },

  cleanDeleteDialog: function() {
    this.delete_dialog.clean();
  },

  deleteTable: function() {
    this.trigger('remove');
  },

  clean: function() {
    // Remove tipsy
    if (this.$("a.delete").data('tipsy')) {
      this.$("a.delete").unbind('mouseenter mouseleave');
      this.$("a.delete").data('tipsy').remove();
    }

    cdb.core.View.prototype.clean.call(this);
  },

  /**
   *  Set privacy status
   */
  _setPrivacy: function() {
    var $status = this.$('a.status');

    for(var n in this._PRIVACY_VALUES) {
      var privacyName = this._PRIVACY_VALUES[n]
      $status.removeClass(privacyName);
    }

    $status
      .addClass(this.model.get('privacy').toLowerCase())
      .html(this.model.get('privacy'))
      .show();
  },

  /**
   *  Update the privacy status
   */
  _changePrivacy: function(ev) {
    ev.preventDefault();

    this.privacy && this.privacy.clean();
    cdb.god.trigger("closeDialogs");

    // Add privacy selector
    var privacy = this.privacy = new cdb.admin.PrivacySelector({
      model: this.model,
      limitation: !this.options.user.get("actions").private_tables,
      direction: 'up',
      upgrade_url: window.location.protocol + '//' + config.account_host + "/account/" + user_data.username + "/upgrade"
    });

    cdb.god.bind("closeDialogs", this.privacy.hide, this.privacy);

    // Set position and show privacy selector
    this.$el.find(".table_info").append(this.privacy.render().el);
    this.privacy.show($(ev.target), "position");

    return false;
  },

  // Help functions
  _bytesToSize: function(by) {
    var bytes = parseInt(by.toString())
    , sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
    if (bytes == 0) return '0 KB';
    var i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
    var value = (bytes / Math.pow(1024, i)).toFixed(2);

    if (value % 1 == 0) {
      value = parseInt(value)
    }

    return value + " " + sizes[i];
  }
});
<|endoftext|>


<commit_before>const _ = require('underscore')
const fs = require('fs')
const {remote} = require('electron')
const {Actions, OnlineStatusStore} = require('nylas-exports')
const SyncWorker = require('./sync-worker');
const LocalSyncDeltaEmitter = require('./local-sync-delta-emitter').default
const LocalDatabaseConnector = require('../shared/local-database-connector')

class SyncProcessManager {
  constructor() {
    this._exiting = false;
    this._resettingEmailCache = false
    this._workersByAccountId = {};
    this._localSyncDeltaEmittersByAccountId = new Map()

    OnlineStatusStore.listen(this._onOnlineStatusChanged, this)
    Actions.resetEmailCache.listen(this._resetEmailCache, this)
    Actions.debugSync.listen(this._onDebugSync, this)
    Actions.wakeLocalSyncWorkerForAccount.listen((accountId) =>
      this.wakeWorkerForAccount(accountId, {interrupt: true})
    );
  }

  _onOnlineStatusChanged() {
    if (OnlineStatusStore.isOnline()) {
      Object.keys(this._workersByAccountId).forEach((id) => {
        this.wakeWorkerForAccount(id, {reason: 'Came back online'})
      })
    }
  }

  _onDebugSync() {
    const win = NylasEnv.getCurrentWindow()
    win.show()
    win.maximize()
    win.openDevTools()
  }

  async _resetEmailCache() {
    if (this._resettingEmailCache) return;
    this._resettingEmailCache = true
    try {
      for (const worker of this.workers()) {
        worker.stopSync()
      }
      setTimeout(async () => {
        // Give the sync a chance to stop first before killing the whole
        // DB
        fs.unlinkSync(`${NylasEnv.getConfigDirPath()}/edgehill.db`)
        for (const accountId of Object.keys(this._workersByAccountId)) {
          await LocalDatabaseConnector.destroyAccountDatabase(accountId)
        }
        remote.app.relaunch()
        remote.app.quit()
      }, 100)
    } catch (err) {
      global.Logger.error('Error resetting email cache', err)
      this._resettingEmailCache = false
    }
  }

  /**
   * Useful for debugging.
   */
  async start() {
    global.Logger.log(`SyncProcessManager: Starting sync`)

    const {Account} = await LocalDatabaseConnector.forShared();
    const accounts = await Account.findAll();
    for (const account of accounts) {
      this.addWorkerForAccount(account);
    }
  }

  workers() {
    return _.values(this._workersByAccountId)
  }

  dbs() {
    return this.workers().map(w => w._db)
  }

  wakeWorkerForAccount(accountId, {reason = 'Waking sync', interrupt} = {}) {
    const worker = this._workersByAccountId[accountId]
    if (worker) {
      worker.syncNow({reason, interrupt});
    }
  }

  async addWorkerForAccount(account) {
    await LocalDatabaseConnector.ensureAccountDatabase(account.id);
    const logger = global.Logger.forAccount(account)

    try {
      if (this._workersByAccountId[account.id]) {
        logger.warn(`SyncProcessManager.addWorkerForAccount: Worker for account already exists - skipping`)
        return
      }
      const db = await LocalDatabaseConnector.forAccount(account.id);
      this._workersByAccountId[account.id] = new SyncWorker(account, db, this);

      const localSyncDeltaEmitter = new LocalSyncDeltaEmitter(account, db)
      await localSyncDeltaEmitter.activate()
      this._localSyncDeltaEmittersByAccountId.set(account.id, localSyncDeltaEmitter)
      logger.log(`SyncProcessManager: Claiming Account Succeeded`)
    } catch (err) {
      logger.error(`SyncProcessManager: Claiming Account Failed`, err)
    }
  }

  async removeWorkerForAccountId(accountId) {
    if (this._workersByAccountId[accountId]) {
      await this._workersByAccountId[accountId].cleanup();
      this._workersByAccountId[accountId] = null;
    }

    if (this._localSyncDeltaEmittersByAccountId.has(accountId)) {
      this._localSyncDeltaEmittersByAccountId.get(accountId).deactivate();
      this._localSyncDeltaEmittersByAccountId.delete(accountId)
    }
  }
}

window.$n.SyncProcessManager = new SyncProcessManager();
module.exports = window.$n.SyncProcessManager
<commit_msg>[client-sync] Correctly wait for sync to stop before resetting email cache<commit_after>const _ = require('underscore')
const fs = require('fs')
const {remote} = require('electron')
const {Actions, OnlineStatusStore} = require('nylas-exports')
const SyncWorker = require('./sync-worker');
const LocalSyncDeltaEmitter = require('./local-sync-delta-emitter').default
const LocalDatabaseConnector = require('../shared/local-database-connector')

class SyncProcessManager {
  constructor() {
    this._exiting = false;
    this._resettingEmailCache = false
    this._workersByAccountId = {};
    this._localSyncDeltaEmittersByAccountId = new Map()

    OnlineStatusStore.listen(this._onOnlineStatusChanged, this)
    Actions.resetEmailCache.listen(this._resetEmailCache, this)
    Actions.debugSync.listen(this._onDebugSync, this)
    Actions.wakeLocalSyncWorkerForAccount.listen((accountId) =>
      this.wakeWorkerForAccount(accountId, {interrupt: true})
    );
  }

  _onOnlineStatusChanged() {
    if (OnlineStatusStore.isOnline()) {
      Object.keys(this._workersByAccountId).forEach((id) => {
        this.wakeWorkerForAccount(id, {reason: 'Came back online'})
      })
    }
  }

  _onDebugSync() {
    const win = NylasEnv.getCurrentWindow()
    win.show()
    win.maximize()
    win.openDevTools()
  }

  async _resetEmailCache() {
    if (this._resettingEmailCache) return;
    this._resettingEmailCache = true
    try {
      try {
        await Promise.all(
          this.workers().map(w => w.stopSync())
        )
        .timeout(500, 'Timed out while trying to stop sync')
      } catch (err) {
        console.warn('SyncProcessManager._resetEmailCache: Error while stopping sync', err)
      }
      fs.unlinkSync(`${NylasEnv.getConfigDirPath()}/edgehill.db`)
      for (const accountId of Object.keys(this._workersByAccountId)) {
        await LocalDatabaseConnector.destroyAccountDatabase(accountId)
      }
      remote.app.relaunch()
      remote.app.quit()
    } catch (err) {
      global.Logger.error('Error resetting email cache', err)
      this._resettingEmailCache = false
    }
  }

  /**
   * Useful for debugging.
   */
  async start() {
    global.Logger.log(`SyncProcessManager: Starting sync`)

    const {Account} = await LocalDatabaseConnector.forShared();
    const accounts = await Account.findAll();
    for (const account of accounts) {
      this.addWorkerForAccount(account);
    }
  }

  workers() {
    return _.values(this._workersByAccountId)
  }

  dbs() {
    return this.workers().map(w => w._db)
  }

  wakeWorkerForAccount(accountId, {reason = 'Waking sync', interrupt} = {}) {
    const worker = this._workersByAccountId[accountId]
    if (worker) {
      worker.syncNow({reason, interrupt});
    }
  }

  async addWorkerForAccount(account) {
    await LocalDatabaseConnector.ensureAccountDatabase(account.id);
    const logger = global.Logger.forAccount(account)

    try {
      if (this._workersByAccountId[account.id]) {
        logger.warn(`SyncProcessManager.addWorkerForAccount: Worker for account already exists - skipping`)
        return
      }
      const db = await LocalDatabaseConnector.forAccount(account.id);
      this._workersByAccountId[account.id] = new SyncWorker(account, db, this);

      const localSyncDeltaEmitter = new LocalSyncDeltaEmitter(account, db)
      await localSyncDeltaEmitter.activate()
      this._localSyncDeltaEmittersByAccountId.set(account.id, localSyncDeltaEmitter)
      logger.log(`SyncProcessManager: Claiming Account Succeeded`)
    } catch (err) {
      logger.error(`SyncProcessManager: Claiming Account Failed`, err)
    }
  }

  async removeWorkerForAccountId(accountId) {
    if (this._workersByAccountId[accountId]) {
      await this._workersByAccountId[accountId].cleanup();
      this._workersByAccountId[accountId] = null;
    }

    if (this._localSyncDeltaEmittersByAccountId.has(accountId)) {
      this._localSyncDeltaEmittersByAccountId.get(accountId).deactivate();
      this._localSyncDeltaEmittersByAccountId.delete(accountId)
    }
  }
}

window.$n.SyncProcessManager = new SyncProcessManager();
module.exports = window.$n.SyncProcessManager
<|endoftext|>


<commit_before>// Copyright 2002-2014, University of Colorado Boulder

/**
 * Radio buttons. See ButtonsDemoView for example usage.
 *
 * @author Aaron Davis
 */
define( function( require ) {
  'use strict';

  // modules
  var inherit = require( 'PHET_CORE/inherit' );
  var Node = require( 'SCENERY/nodes/Node' );
  var HBox = require( 'SCENERY/nodes/HBox' );
  var VBox = require( 'SCENERY/nodes/VBox' );
  var RectangularPushButton = require( 'SUN/buttons/RectangularPushButton' );
  var RectangularButtonView = require( 'SUN/buttons/RectangularButtonView' );
  var RadioButton = require( 'SUN/RadioButton' );
  var Color = require( 'SCENERY/util/Color' );
  var RadioButtonsAppearance = require( 'SUN/buttons/RadioButtonsAppearance' );
  var Property = require( 'AXON/Property' );
  var ColorConstants = require( 'SUN/ColorConstants' );

  /**
   * RadioButtonGroup constructor.
   *
   * @param {Property} property
   * @param {Array<Object>} content an array of objects that have two keys each: value and node
   * the node key holds a scenery Node that is the content for a given radio button.
   * the value key should hold the value that the property takes on for the corresponding
   * node to be selected.
   * @param {Object} options
   * @constructor
   */
  function RadioButtonGroup( property, content, options ) {

    // make sure every object in the content array has properties 'node' and 'value'
    assert && assert( _.every( content, function( obj ) {
      return obj.hasOwnProperty( 'node' ) && obj.hasOwnProperty( 'value' );
    } ) );

    options = _.extend( {

      //The distance between the radio buttons (as in VBox or HBox)
      spacing: 10,
      alignVertically: false,
      enabledProperty: new Property( true ), // whether or not the set of radio buttons as a whole is enabled

      //The fill for the rectangle behind the radio buttons.  Default color is bluish color, as in the other button library.
      baseColor: ColorConstants.LIGHT_BLUE,
      disabledBaseColor: ColorConstants.LIGHT_GRAY,
      selectedStroke: 'black',
      deselectedStroke: new Color( 50, 50, 50 ),
      selectedLineWidth: 1.5,
      deselectedLineWidth: 1,

      //These margins are *within* each button
      buttonContentXMargin: 5,
      buttonContentYMargin: 5,

      //The radius for each button
      cornerRadius: 4,
      deselectedOpacity: 0.6,

      //The default appearances use the color values specified above, but other appearances could be specified for more
      //customized behavior.  Generally setting the color values above should be enough to specify the desired look.
      selectedButtonAppearanceStrategy: RadioButtonsAppearance.flatAppearanceStrategyWithBorder,
      deselectedButtonAppearanceStrategy: RadioButtonsAppearance.flatAppearanceStrategyDeselected,
      contentAppearanceStrategy: RectangularButtonView.fadeContentWhenDisabled
    }, options );

    // options for the panels that house each radio button
    var panelOptions = {
      xMargin: options.buttonContentXMargin,
      yMargin: options.buttonContentYMargin,
      cornerRadius: options.cornerRadius,
      baseColor: options.baseColor,
      disabledBaseColor: options.disabledBaseColor,
      contentAppearanceStrategy: options.contentAppearanceStrategy
    };

    // add extra for selected vs deselcted panels
    var selectedOptions = _.extend(
      {
        stroke: options.selectedStroke,
        lineWidth: options.selectedLineWidth,
        buttonAppearanceStrategy: options.selectedButtonAppearanceStrategy
      }, panelOptions );

    var deselectedOptions = _.extend(
      {
        stroke: options.deselectedStroke,
        lineWidth: options.deselectedLineWidth,
        opacity: options.deselectedOpacity,
        buttonAppearanceStrategy: options.deselectedButtonAppearanceStrategy
      }, panelOptions );

    // calculate the maximum width and height of the content so we can make all radio buttons the same size
    var maxWidth = _.max( content, function( obj ) { return obj.node.width; } ).node.width;
    var maxHeight = _.max( content, function( obj ) { return obj.node.height; } ).node.height;

    var buttons = [];
    var selectedNodes = [];
    var deselectedNodes = [];
    for ( var i = 0; i < content.length; i++ ) {
      // make sure all radio buttons are the same size
      selectedOptions.xMargin = ( ( maxWidth - content[i].node.width ) / 2 ) + options.buttonContentXMargin;
      selectedOptions.yMargin = ( ( maxHeight - content[i].node.height ) / 2 ) + options.buttonContentYMargin;
      deselectedOptions.xMargin = ( ( maxWidth - content[i].node.width ) / 2 ) + options.buttonContentXMargin;
      deselectedOptions.yMargin = ( ( maxHeight - content[i].node.height ) / 2 ) + options.buttonContentYMargin;

      selectedNodes.push( new RectangularPushButton( _.extend( { content: content[i].node }, selectedOptions ) ) );
      deselectedNodes.push( new RectangularPushButton( _.extend( { content: content[i].node }, deselectedOptions ) ) );
      buttons.push( new RadioButton( property, content[i].value, selectedNodes[i], deselectedNodes[i], { cursor: null } ) );
    }

    this.enabledProperty = options.enabledProperty;
    this.enabledProperty.link( function( isEnabled ) {
      for ( i = 0; i < content.length; i++ ) {
        selectedNodes[i].enabled = isEnabled;
        deselectedNodes[i].enabled = isEnabled;
        buttons[i].pickable = isEnabled;
      }
    } );

    // make the unselected buttons pickable and have a pointer cursor
    var thisNode = this;
    property.link( function( value ) {
      if ( thisNode.enabledProperty.get() ) {
        for ( i = 0; i < content.length; i++ ) {
          if ( content[i].value === value ) {
            selectedNodes[i].pickable = false;
            selectedNodes[i].cursor = null;
          }
          else {
            deselectedNodes[i].pickable = true;
            deselectedNodes[i].cursor = 'pointer';
          }
        }
      }
    } );

    var boxOptions = { children: buttons, spacing: options.spacing };
    var box = ( options.alignVertically ) ? new VBox( boxOptions ) : new HBox( boxOptions );

    Node.call( this, { children: [box] } );

    this.mutate( options );
  }

  return inherit( Node, RadioButtonGroup,
    {

      set enabled( value ) {
        assert && assert( typeof value === 'boolean', 'RadioButtonGroup.enabled must be a boolean value' );
        this.enabledProperty.set( value );
      },

      get enabled() { return this.enabledProperty.get(); }

    } );
} );
<commit_msg>Updated options and renamed a var, see #4<commit_after>// Copyright 2002-2014, University of Colorado Boulder

/**
 * Radio buttons. See ButtonsDemoView for example usage.
 *
 * @author Aaron Davis
 */
define( function( require ) {
  'use strict';

  // modules
  var inherit = require( 'PHET_CORE/inherit' );
  var Node = require( 'SCENERY/nodes/Node' );
  var HBox = require( 'SCENERY/nodes/HBox' );
  var VBox = require( 'SCENERY/nodes/VBox' );
  var RectangularPushButton = require( 'SUN/buttons/RectangularPushButton' );
  var RectangularButtonView = require( 'SUN/buttons/RectangularButtonView' );
  var RadioButton = require( 'SUN/RadioButton' );
  var Color = require( 'SCENERY/util/Color' );
  var RadioButtonsAppearance = require( 'SUN/buttons/RadioButtonsAppearance' );
  var Property = require( 'AXON/Property' );
  var ColorConstants = require( 'SUN/ColorConstants' );

  /**
   * RadioButtonGroup constructor.
   *
   * @param {Property} property
   * @param {Array<Object>} content an array of objects that have two keys each: value and node
   * the node key holds a scenery Node that is the content for a given radio button.
   * the value key should hold the value that the property takes on for the corresponding
   * node to be selected.
   * @param {Object} options
   * @constructor
   */
  function RadioButtonGroup( property, content, options ) {

    // make sure every object in the content array has properties 'node' and 'value'
    assert && assert( _.every( content, function( obj ) {
      return obj.hasOwnProperty( 'node' ) && obj.hasOwnProperty( 'value' );
    } ) );

    options = _.extend( {

      //The distance between the radio buttons (as in VBox or HBox)
      spacing: 10,
      alignVertically: false,
      enabledProperty: new Property( true ), // whether or not the set of radio buttons as a whole is enabled

      //The fill for the rectangle behind the radio buttons.  Default color is bluish color, as in the other button library.
      baseColor: ColorConstants.LIGHT_BLUE,
      disabledBaseColor: ColorConstants.LIGHT_GRAY,
      selectedStroke: 'black',

      //The selected node by default is fully opaque, but we specify 'null' because if you specify '1' then a more expensive
      //rendering pipeline may be triggered. (Maybe?)
      selectedOpacity: null,
      deselectedStroke: new Color( 50, 50, 50 ),
      selectedLineWidth: 1.5,
      deselectedLineWidth: 1,

      //These margins are *within* each button
      buttonContentXMargin: 5,
      buttonContentYMargin: 5,

      //The radius for each button
      cornerRadius: 4,
      deselectedOpacity: 0.6,

      //The default appearances use the color values specified above, but other appearances could be specified for more
      //customized behavior.  Generally setting the color values above should be enough to specify the desired look.
      selectedButtonAppearanceStrategy: RadioButtonsAppearance.flatAppearanceStrategyWithBorder,
      deselectedButtonAppearanceStrategy: RadioButtonsAppearance.flatAppearanceStrategyDeselected,
      contentAppearanceStrategy: RectangularButtonView.fadeContentWhenDisabled
    }, options );

    // options for the panels that house each radio button
    // These options are the same for buttons whether they are selected or deselected
    var buttonOptions = {
      xMargin: options.buttonContentXMargin,
      yMargin: options.buttonContentYMargin,
      cornerRadius: options.cornerRadius,
      baseColor: options.baseColor,
      disabledBaseColor: options.disabledBaseColor,
      contentAppearanceStrategy: options.contentAppearanceStrategy
    };

    // add extra options for selected vs deselcted panels
    var selectedOptions = _.extend(
      {
        stroke: options.selectedStroke,
        lineWidth: options.selectedLineWidth,
        opacity: options.selectedOpacity,
        buttonAppearanceStrategy: options.selectedButtonAppearanceStrategy
      }, buttonOptions );

    var deselectedOptions = _.extend(
      {
        stroke: options.deselectedStroke,
        lineWidth: options.deselectedLineWidth,
        opacity: options.deselectedOpacity,
        buttonAppearanceStrategy: options.deselectedButtonAppearanceStrategy
      }, buttonOptions );

    // calculate the maximum width and height of the content so we can make all radio buttons the same size
    var maxWidth = _.max( content, function( obj ) { return obj.node.width; } ).node.width;
    var maxHeight = _.max( content, function( obj ) { return obj.node.height; } ).node.height;

    var buttons = [];
    var selectedNodes = [];
    var deselectedNodes = [];
    for ( var i = 0; i < content.length; i++ ) {
      // make sure all radio buttons are the same size
      selectedOptions.xMargin = ( ( maxWidth - content[i].node.width ) / 2 ) + options.buttonContentXMargin;
      selectedOptions.yMargin = ( ( maxHeight - content[i].node.height ) / 2 ) + options.buttonContentYMargin;
      deselectedOptions.xMargin = ( ( maxWidth - content[i].node.width ) / 2 ) + options.buttonContentXMargin;
      deselectedOptions.yMargin = ( ( maxHeight - content[i].node.height ) / 2 ) + options.buttonContentYMargin;

      selectedNodes.push( new RectangularPushButton( _.extend( { content: content[i].node }, selectedOptions ) ) );
      deselectedNodes.push( new RectangularPushButton( _.extend( { content: content[i].node }, deselectedOptions ) ) );
      buttons.push( new RadioButton( property, content[i].value, selectedNodes[i], deselectedNodes[i], { cursor: null } ) );
    }

    this.enabledProperty = options.enabledProperty;
    this.enabledProperty.link( function( isEnabled ) {
      for ( i = 0; i < content.length; i++ ) {
        selectedNodes[i].enabled = isEnabled;
        deselectedNodes[i].enabled = isEnabled;
        buttons[i].pickable = isEnabled;
      }
    } );

    // make the unselected buttons pickable and have a pointer cursor
    var thisNode = this;
    property.link( function( value ) {
      if ( thisNode.enabledProperty.get() ) {
        for ( i = 0; i < content.length; i++ ) {
          if ( content[i].value === value ) {
            selectedNodes[i].pickable = false;
            selectedNodes[i].cursor = null;
          }
          else {
            deselectedNodes[i].pickable = true;
            deselectedNodes[i].cursor = 'pointer';
          }
        }
      }
    } );

    var boxOptions = { children: buttons, spacing: options.spacing };
    var box = ( options.alignVertically ) ? new VBox( boxOptions ) : new HBox( boxOptions );

    Node.call( this, { children: [box] } );

    this.mutate( options );
  }

  return inherit( Node, RadioButtonGroup,
    {

      set enabled( value ) {
        assert && assert( typeof value === 'boolean', 'RadioButtonGroup.enabled must be a boolean value' );
        this.enabledProperty.set( value );
      },

      get enabled() { return this.enabledProperty.get(); }

    } );
} );
<|endoftext|>


<commit_before>// Copyright 2014-2015, University of Colorado Boulder

/**
 * Radio buttons. See ButtonsDemoView for example usage.
 *
 * This type creates a group of radio buttons in either a horizontal or vertical formation.
 * Each button inherits from RectangularButtonView, and can take a Scenery Node as its content.
 * A typical use case is when you want to have different modes of a view to select from. Typically,
 * RadioButtonGroup radio buttons display some kind of icon or image, but that is not mandatory.
 *
 * @author Aaron Davis
 */
define( function( require ) {
  'use strict';

  // modules
  var AccessiblePeer = require( 'SCENERY/accessibility/AccessiblePeer' );
  var Color = require( 'SCENERY/util/Color' );
  var ColorConstants = require( 'SUN/ColorConstants' );
  var inherit = require( 'PHET_CORE/inherit' );
  var LayoutBox = require( 'SCENERY/nodes/LayoutBox' );
  var Property = require( 'AXON/Property' );
  var RadioButtonGroupAppearance = require( 'SUN/buttons/RadioButtonGroupAppearance' );
  var RadioButtonGroupMember = require( 'SUN/buttons/RadioButtonGroupMember' );
  var Rectangle = require( 'SCENERY/nodes/Rectangle' );
  var Shape = require( 'KITE/Shape' );
  var sun = require( 'SUN/sun' );

  /**
   * RadioButtonGroup constructor.
   *
   * @param {Property} property
   * @param {Array} contentArray an array of objects that have two keys each: value and node the node key holds a
   * scenery Node that is the content for a given radio button. the value key should hold the value that the property
   * takes on for the corresponding node to be selected. Optionally, these objects can have an attribute 'label', which
   * is a {Node} used to label the button.
   * @param {Object} [options]
   * @constructor
   */
  function RadioButtonGroup( property, contentArray, options ) {
    options = options || {};
    assert && assert( !options.hasOwnProperty( 'children' ), 'Cannot pass in children to a RadioButtonGroup, ' +
                                                             'create siblings in the parent node instead' );

    // make sure every object in the content array has properties 'node' and 'value'
    assert && assert( _.every( contentArray, function( obj ) {
      return obj.hasOwnProperty( 'node' ) && obj.hasOwnProperty( 'value' );
    } ), 'contentArray must be an array of objects with properties "node" and "value"' );

    var i; // for loops

    // make sure that each value passed into the contentArray is unique
    var uniqueValues = [];
    for ( i = 0; i < contentArray.length; i++ ) {
      if ( uniqueValues.indexOf( contentArray[ i ].value ) < 0 ) {
        uniqueValues.push( contentArray[ i ].value );
      }
      else {
        throw new Error( 'Duplicate value: "' + contentArray[ i ].value + '" passed into RadioButtonGroup.js' );
      }
    }

    // make sure that the property passed in currently has a value from the contentArray
    if ( uniqueValues.indexOf( property.get() ) === -1 ) {
      throw new Error( 'The property passed in to RadioButtonGroup has an illegal value "' + property.get() +
                       '" that is not present in the contentArray' );
    }

    var defaultOptions = {

      // LayoutBox options (super class of RadioButtonGroup)
      spacing: 10,
      orientation: 'vertical',
      align: 'center',

      enabledProperty: new Property( true ), // whether or not the set of radio buttons as a whole is enabled

      // The fill for the rectangle behind the radio buttons.  Default color is bluish color, as in the other button library.
      baseColor: ColorConstants.LIGHT_BLUE,
      disabledBaseColor: ColorConstants.LIGHT_GRAY,

      // Opacity can be set separately for the buttons and button content.
      selectedButtonOpacity: 1,
      deselectedButtonOpacity: 0.6,
      selectedContentOpacity: 1,
      deselectedContentOpacity: 0.6,
      overButtonOpacity: 0.8,
      overContentOpacity: 0.8,

      selectedStroke: 'black',
      deselectedStroke: new Color( 50, 50, 50 ),
      selectedLineWidth: 1.5,
      deselectedLineWidth: 1,

      // The following options specify highlight behavior overrides, leave as null to get the default behavior
      // Note that highlighting applies only to deselected buttons
      overFill: null,
      overStroke: null,
      overLineWidth: null,

      // These margins are *within* each button
      buttonContentXMargin: 5,
      buttonContentYMargin: 5,

      // TouchArea expansion
      touchAreaXDilation: 0,
      touchAreaYDilation: 0,

      // MouseArea expansion
      mouseAreaXDilation: 0,
      mouseAreaYDilation: 0,

      //The radius for each button
      cornerRadius: 4,

      // How far from the button the text label is (only applies if labels are passed in)
      labelSpacing: 0,

      // Which side of the button the label will appear, options are 'top', 'bottom', 'left', 'right'
      // (only applies if labels are passed in)
      labelAlign: 'bottom',

      // The default appearances use the color values specified above, but other appearances could be specified for more
      // customized behavior.  Generally setting the color values above should be enough to specify the desired look.
      buttonAppearanceStrategy: RadioButtonGroupAppearance.defaultRadioButtonsAppearance,
      contentAppearanceStrategy: RadioButtonGroupAppearance.contentAppearanceStrategy,

      // optional accessibility description, which applies to the entire group of buttons
      accessibleLegendDescription: ''
    };

    options = _.extend( _.clone( defaultOptions ), options );

    // make a copy of the options to pass to individual buttons that includes all default options but not scenery options
    var buttonOptions = _.pick( options, _.keys( defaultOptions ) );

    // calculate the maximum width and height of the content so we can make all radio buttons the same size
    var maxWidth = _.max( contentArray, function( content ) { return content.node.width; } ).node.width;
    var maxHeight = _.max( contentArray, function( content ) { return content.node.height; } ).node.height;

    // make sure all radio buttons are the same size and create the RadioButtons
    var buttons = [];
    var button;
    for ( i = 0; i < contentArray.length; i++ ) {

      // each individual radio button will have a different margin to make sure they are all the same size
      var xMargin = ( ( maxWidth - contentArray[ i ].node.width ) / 2 ) + options.buttonContentXMargin;
      var yMargin = ( ( maxHeight - contentArray[ i ].node.height ) / 2 ) + options.buttonContentYMargin;

      assert && assert( !contentArray[ i ].hasOwnProperty( 'phetioValueType' ),
        'phetioValueType should be provided by the property passed to the RadioButtonGroup constructor' );

      var radioButton = new RadioButtonGroupMember( property, contentArray[ i ].value,
        _.extend( {
          content: contentArray[ i ].node,
          xMargin: xMargin,
          yMargin: yMargin,
          tandem: contentArray[ i ].tandem,
          accessibleLabel: contentArray[ i ].accessibleLabel
        }, buttonOptions ) );

      // ensure the buttons don't resize when selected vs unselected by adding a rectangle with the max size
      var maxLineWidth = Math.max( options.selectedLineWidth, options.deselectedLineWidth );
      var maxButtonWidth = maxLineWidth + contentArray[ i ].node.width + xMargin * 2;
      var maxButtonHeight = maxLineWidth + contentArray[ i ].node.height + yMargin * 2;
      var boundingRect = new Rectangle( 0, 0, maxButtonWidth, maxButtonHeight, {
        fill: 'rgba(0,0,0,0)',
        center: radioButton.center
      } );
      radioButton.addChild( boundingRect );

      // if a label is given, the button becomes a LayoutBox with the label and button
      if ( contentArray[ i ].label ) {
        var label = contentArray[ i ].label;
        var labelOrientation = ( options.labelAlign === 'bottom' || options.labelAlign === 'top' ) ? 'vertical' : 'horizontal';
        var labelChildren = ( options.labelAlign === 'left' || options.labelAlign === 'top' ) ? [ label, radioButton ] : [ radioButton, label ];
        button = new LayoutBox( {
          children: labelChildren,
          spacing: options.labelSpacing,
          orientation: labelOrientation
        } );

        var xDilation = options.touchAreaXDilation;
        var yDilation = options.touchAreaYDilation;

        // override the touch and mouse areas defined in RectangularButtonView
        // extra width is added to the SingleRadioButtons so they don't change size if the line width changes,
        // that is why lineWidth is subtracted from the width and height when calculating these new areas
        radioButton.touchArea = Shape.rectangle( -xDilation, -yDilation, button.width + 2 * xDilation - maxLineWidth, button.height + 2 * yDilation - maxLineWidth );

        xDilation = options.mouseAreaXDilation;
        yDilation = options.mouseAreaYDilation;
        radioButton.mouseArea = Shape.rectangle( -xDilation, -yDilation, button.width + 2 * xDilation - maxLineWidth, button.height + 2 * yDilation - maxLineWidth );

        // make sure the label mouse and touch areas don't block the expanded button touch and mouse areas
        label.pickable = false;

        // use the same content appearance strategy for the labels that is used for the button content
        options.contentAppearanceStrategy( label, radioButton.interactionStateProperty, options );
      }
      else {
        button = radioButton;
      }
      buttons.push( button );
    }

    // @private
    this.enabledProperty = options.enabledProperty;

    // super call
    options.children = buttons;
    LayoutBox.call( this, options );
    var self = this;

    // When the entire RadioButtonGroup gets disabled, gray them out and make them unpickable (and vice versa)
    this.enabledProperty.link( function( isEnabled ) {
      self.pickable = isEnabled;

      for ( i = 0; i < contentArray.length; i++ ) {
        if ( buttons[ i ] instanceof LayoutBox ) {
          for ( var j = 0; j < 2; j++ ) {
            buttons[ i ].children[ j ].enabled = isEnabled;
          }
        }
        else {
          buttons[ i ].enabled = isEnabled;
        }
      }
    } );

    // make the unselected buttons pickable and have a pointer cursor
    property.link( function( value ) {
      if ( self.enabledProperty.get() ) {
        for ( i = 0; i < contentArray.length; i++ ) {
          if ( contentArray[ i ].value === value ) {
            buttons[ i ].pickable = false;
            buttons[ i ].cursor = null;
          }
          else {
            buttons[ i ].pickable = true;
            buttons[ i ].cursor = 'pointer';
          }
        }
      }
    } );

    // generate accessible peer for the parallel DOM
    this.accessibleContent = {
      createPeer: function( accessibleInstance ) {
        var trail = accessibleInstance.trail;
        var uniqueId = trail.getUniqueId();

        /*
         We want the element of the parallel DOM to look like
         <fieldset id="radio-button-group" role="radiogroup" aria-describedby="legend-id group-description">
         <legend>Translatable legend text</legend>
         ... (radio inputs defined in RadioButtonGroupMember)

Translatable description of the entire group.

         </fieldset>
         */

        // create the fieldset holding all radio buttons
        var domElement = document.createElement( 'fieldset' );
        domElement.id = 'radio-button-group-' + uniqueId;
        domElement.setAttribute( 'role', 'radiogroup' );

        var legendElement = document.createElement( 'legend' );
        legendElement.innerHTML = options.accessibleLegendDescription;

        domElement.appendChild( legendElement );

        return new AccessiblePeer( accessibleInstance, domElement );

      }
    };
  }

  sun.register( 'RadioButtonGroup', RadioButtonGroup );

  return inherit( LayoutBox, RadioButtonGroup, {

    // @public
    dispose: function() {
      //TODO implement this, see sun#212
    },

    // @public
    set enabled( value ) {
      assert && assert( typeof value === 'boolean', 'RadioButtonGroup.enabled must be a boolean value' );
      this.enabledProperty.set( value );
    },

    // @public
    get enabled() {
      return this.enabledProperty.get();
    }
  } );
} );
<commit_msg>Added KeyboardHelpButton to TPhetButton legal types, https://github.com/phetsims/joist/issues/374<commit_after>// Copyright 2014-2015, University of Colorado Boulder

/**
 * Radio buttons. See ButtonsDemoView for example usage.
 *
 * This type creates a group of radio buttons in either a horizontal or vertical formation.
 * Each button inherits from RectangularButtonView, and can take a Scenery Node as its content.
 * A typical use case is when you want to have different modes of a view to select from. Typically,
 * RadioButtonGroup radio buttons display some kind of icon or image, but that is not mandatory.
 *
 * @author Aaron Davis
 */
define( function( require ) {
  'use strict';

  // modules
  var AccessiblePeer = require( 'SCENERY/accessibility/AccessiblePeer' );
  var Color = require( 'SCENERY/util/Color' );
  var ColorConstants = require( 'SUN/ColorConstants' );
  var inherit = require( 'PHET_CORE/inherit' );
  var LayoutBox = require( 'SCENERY/nodes/LayoutBox' );
  var Property = require( 'AXON/Property' );
  var RadioButtonGroupAppearance = require( 'SUN/buttons/RadioButtonGroupAppearance' );
  var RadioButtonGroupMember = require( 'SUN/buttons/RadioButtonGroupMember' );
  var Rectangle = require( 'SCENERY/nodes/Rectangle' );
  var Shape = require( 'KITE/Shape' );
  var sun = require( 'SUN/sun' );

  /**
   * RadioButtonGroup constructor.
   *
   * @param {Property} property
   * @param {Array} contentArray an array of objects that have two keys each: value and node the node key holds a
   * scenery Node that is the content for a given radio button. the value key should hold the value that the property
   * takes on for the corresponding node to be selected. Optionally, these objects can have an attribute 'label', which
   * is a {Node} used to label the button.
   * @param {Object} [options]
   * @constructor
   */
  function RadioButtonGroup( property, contentArray, options ) {
    options = options || {};
    assert && assert( !options.hasOwnProperty( 'children' ), 'Cannot pass in children to a RadioButtonGroup, ' +
                                                             'create siblings in the parent node instead' );

    // make sure every object in the content array has properties 'node' and 'value'
    assert && assert( _.every( contentArray, function( obj ) {
      return obj.hasOwnProperty( 'node' ) && obj.hasOwnProperty( 'value' );
    } ), 'contentArray must be an array of objects with properties "node" and "value"' );

    var i; // for loops

    // make sure that each value passed into the contentArray is unique
    var uniqueValues = [];
    for ( i = 0; i < contentArray.length; i++ ) {
      if ( uniqueValues.indexOf( contentArray[ i ].value ) < 0 ) {
        uniqueValues.push( contentArray[ i ].value );
      }
      else {
        throw new Error( 'Duplicate value: "' + contentArray[ i ].value + '" passed into RadioButtonGroup.js' );
      }
    }

    // make sure that the property passed in currently has a value from the contentArray
    if ( uniqueValues.indexOf( property.get() ) === -1 ) {
      throw new Error( 'The property passed in to RadioButtonGroup has an illegal value "' + property.get() +
                       '" that is not present in the contentArray' );
    }

    var defaultOptions = {

      // LayoutBox options (super class of RadioButtonGroup)
      spacing: 10,
      orientation: 'vertical',
      align: 'center',

      enabledProperty: new Property( true ), // whether or not the set of radio buttons as a whole is enabled

      // The fill for the rectangle behind the radio buttons.  Default color is bluish color, as in the other button library.
      baseColor: ColorConstants.LIGHT_BLUE,
      disabledBaseColor: ColorConstants.LIGHT_GRAY,

      // Opacity can be set separately for the buttons and button content.
      selectedButtonOpacity: 1,
      deselectedButtonOpacity: 0.6,
      selectedContentOpacity: 1,
      deselectedContentOpacity: 0.6,
      overButtonOpacity: 0.8,
      overContentOpacity: 0.8,

      selectedStroke: 'black',
      deselectedStroke: new Color( 50, 50, 50 ),
      selectedLineWidth: 1.5,
      deselectedLineWidth: 1,

      // The following options specify highlight behavior overrides, leave as null to get the default behavior
      // Note that highlighting applies only to deselected buttons
      overFill: null,
      overStroke: null,
      overLineWidth: null,

      // These margins are *within* each button
      buttonContentXMargin: 5,
      buttonContentYMargin: 5,

      // TouchArea expansion
      touchAreaXDilation: 0,
      touchAreaYDilation: 0,

      // MouseArea expansion
      mouseAreaXDilation: 0,
      mouseAreaYDilation: 0,

      //The radius for each button
      cornerRadius: 4,

      // How far from the button the text label is (only applies if labels are passed in)
      labelSpacing: 0,

      // Which side of the button the label will appear, options are 'top', 'bottom', 'left', 'right'
      // (only applies if labels are passed in)
      labelAlign: 'bottom',

      // The default appearances use the color values specified above, but other appearances could be specified for more
      // customized behavior.  Generally setting the color values above should be enough to specify the desired look.
      buttonAppearanceStrategy: RadioButtonGroupAppearance.defaultRadioButtonsAppearance,
      contentAppearanceStrategy: RadioButtonGroupAppearance.contentAppearanceStrategy,

      // optional accessibility description, which applies to the entire group of buttons
      accessibleLegendDescription: ''
    };

    options = _.extend( _.clone( defaultOptions ), options );

    // make a copy of the options to pass to individual buttons that includes all default options but not scenery options
    var buttonOptions = _.pick( options, _.keys( defaultOptions ) );

    // calculate the maximum width and height of the content so we can make all radio buttons the same size
    var maxWidth = _.max( contentArray, function( content ) { return content.node.width; } ).node.width;
    var maxHeight = _.max( contentArray, function( content ) { return content.node.height; } ).node.height;

    // make sure all radio buttons are the same size and create the RadioButtons
    var buttons = [];
    var button;
    for ( i = 0; i < contentArray.length; i++ ) {

      // each individual radio button will have a different margin to make sure they are all the same size
      var xMargin = ( ( maxWidth - contentArray[ i ].node.width ) / 2 ) + options.buttonContentXMargin;
      var yMargin = ( ( maxHeight - contentArray[ i ].node.height ) / 2 ) + options.buttonContentYMargin;

      assert && assert( !contentArray[ i ].hasOwnProperty( 'phetioValueType' ),
        'phetioValueType should be provided by the property passed to the RadioButtonGroup constructor' );

      assert && assert( !contentArray[ i ].tandem, 'content arrays should not have tandem instances, they should use ' +
                                                   'tandemName instead' );

      var radioButton = new RadioButtonGroupMember( property, contentArray[ i ].value,
        _.extend( {
          content: contentArray[ i ].node,
          xMargin: xMargin,
          yMargin: yMargin,
          tandem: options.tandem.createTandem( contentArray[ i ].tandemName ),
          accessibleLabel: contentArray[ i ].accessibleLabel
        }, buttonOptions ) );

      // ensure the buttons don't resize when selected vs unselected by adding a rectangle with the max size
      var maxLineWidth = Math.max( options.selectedLineWidth, options.deselectedLineWidth );
      var maxButtonWidth = maxLineWidth + contentArray[ i ].node.width + xMargin * 2;
      var maxButtonHeight = maxLineWidth + contentArray[ i ].node.height + yMargin * 2;
      var boundingRect = new Rectangle( 0, 0, maxButtonWidth, maxButtonHeight, {
        fill: 'rgba(0,0,0,0)',
        center: radioButton.center
      } );
      radioButton.addChild( boundingRect );

      // if a label is given, the button becomes a LayoutBox with the label and button
      if ( contentArray[ i ].label ) {
        var label = contentArray[ i ].label;
        var labelOrientation = ( options.labelAlign === 'bottom' || options.labelAlign === 'top' ) ? 'vertical' : 'horizontal';
        var labelChildren = ( options.labelAlign === 'left' || options.labelAlign === 'top' ) ? [ label, radioButton ] : [ radioButton, label ];
        button = new LayoutBox( {
          children: labelChildren,
          spacing: options.labelSpacing,
          orientation: labelOrientation
        } );

        var xDilation = options.touchAreaXDilation;
        var yDilation = options.touchAreaYDilation;

        // override the touch and mouse areas defined in RectangularButtonView
        // extra width is added to the SingleRadioButtons so they don't change size if the line width changes,
        // that is why lineWidth is subtracted from the width and height when calculating these new areas
        radioButton.touchArea = Shape.rectangle( -xDilation, -yDilation, button.width + 2 * xDilation - maxLineWidth, button.height + 2 * yDilation - maxLineWidth );

        xDilation = options.mouseAreaXDilation;
        yDilation = options.mouseAreaYDilation;
        radioButton.mouseArea = Shape.rectangle( -xDilation, -yDilation, button.width + 2 * xDilation - maxLineWidth, button.height + 2 * yDilation - maxLineWidth );

        // make sure the label mouse and touch areas don't block the expanded button touch and mouse areas
        label.pickable = false;

        // use the same content appearance strategy for the labels that is used for the button content
        options.contentAppearanceStrategy( label, radioButton.interactionStateProperty, options );
      }
      else {
        button = radioButton;
      }
      buttons.push( button );
    }

    // @private
    this.enabledProperty = options.enabledProperty;

    // super call
    options.children = buttons;
    LayoutBox.call( this, options );
    var self = this;

    // When the entire RadioButtonGroup gets disabled, gray them out and make them unpickable (and vice versa)
    this.enabledProperty.link( function( isEnabled ) {
      self.pickable = isEnabled;

      for ( i = 0; i < contentArray.length; i++ ) {
        if ( buttons[ i ] instanceof LayoutBox ) {
          for ( var j = 0; j < 2; j++ ) {
            buttons[ i ].children[ j ].enabled = isEnabled;
          }
        }
        else {
          buttons[ i ].enabled = isEnabled;
        }
      }
    } );

    // make the unselected buttons pickable and have a pointer cursor
    property.link( function( value ) {
      if ( self.enabledProperty.get() ) {
        for ( i = 0; i < contentArray.length; i++ ) {
          if ( contentArray[ i ].value === value ) {
            buttons[ i ].pickable = false;
            buttons[ i ].cursor = null;
          }
          else {
            buttons[ i ].pickable = true;
            buttons[ i ].cursor = 'pointer';
          }
        }
      }
    } );

    // generate accessible peer for the parallel DOM
    this.accessibleContent = {
      createPeer: function( accessibleInstance ) {
        var trail = accessibleInstance.trail;
        var uniqueId = trail.getUniqueId();

        /*
         We want the element of the parallel DOM to look like
         <fieldset id="radio-button-group" role="radiogroup" aria-describedby="legend-id group-description">
         <legend>Translatable legend text</legend>
         ... (radio inputs defined in RadioButtonGroupMember)

Translatable description of the entire group.

         </fieldset>
         */

        // create the fieldset holding all radio buttons
        var domElement = document.createElement( 'fieldset' );
        domElement.id = 'radio-button-group-' + uniqueId;
        domElement.setAttribute( 'role', 'radiogroup' );

        var legendElement = document.createElement( 'legend' );
        legendElement.innerHTML = options.accessibleLegendDescription;

        domElement.appendChild( legendElement );

        return new AccessiblePeer( accessibleInstance, domElement );

      }
    };

    options.tandem.addInstance( this, TNode );
  }

  sun.register( 'RadioButtonGroup', RadioButtonGroup );

  return inherit( LayoutBox, RadioButtonGroup, {

    // @public
    dispose: function() {
      //TODO implement this, see sun#212
    },

    // @public
    set enabled( value ) {
      assert && assert( typeof value === 'boolean', 'RadioButtonGroup.enabled must be a boolean value' );
      this.enabledProperty.set( value );
    },

    // @public
    get enabled() {
      return this.enabledProperty.get();
    }
  } );
} );
<|endoftext|>


<commit_before>var _ = require('underscore');
var linkLayerInfowindow = require('./deep-insights-integration/link-layer-infowindow');
var linkLayerTooltip = require('./deep-insights-integration/link-layer-tooltip');

/**
 * Integration between various data collections/models with cartodb.js and deep-insights.
 */
var F = function (opts) {
  if (!opts.deepInsightsDashboard) throw new Error('deepInsightsDashboard is required');
  if (!opts.analysisDefinitionNodesCollection) throw new Error('analysisDefinitionNodesCollection is required');
  if (!opts.analysisDefinitionsCollection) throw new Error('analysisDefinitionsCollection is required');
  if (!opts.layerDefinitionsCollection) throw new Error('layerDefinitionsCollection is required');
  if (!opts.widgetDefinitionsCollection) throw new Error('widgetDefinitionsCollection is required');

  this._diDashboard = opts.deepInsightsDashboard;
  this._analysisDefinitionNodesCollection = opts.analysisDefinitionNodesCollection;
  this._analysisDefinitionsCollection = opts.analysisDefinitionsCollection;

  this._analysisDefinitionNodesCollection.on('add', this._analyseDefinitionNode, this);
  this._analysisDefinitionNodesCollection.on('change', this._analyseDefinitionNode, this);
  this._analysisDefinitionNodesCollection.on('remove', this._onAnalysisDefinitionNodeRemoved, this);
  this._analysisDefinitionsCollection.on('add change:node_id sync', this._analyseDefinition, this);

  opts.layerDefinitionsCollection.on('add', this._onLayerDefinitionAdded, this);
  opts.layerDefinitionsCollection.on('sync', this._onLayerDefinitionSynced, this);
  opts.layerDefinitionsCollection.on('change', this._onLayerDefinitionChanged, this);
  opts.layerDefinitionsCollection.on('remove', this._onLayerDefinitionRemoved, this);
  opts.layerDefinitionsCollection.on('layerMoved', this._onLayerDefinitionMoved, this);
  opts.layerDefinitionsCollection.on('change:cartocss', this._onLayerChangeCheckWidgetAutoStyling, this);

  opts.layerDefinitionsCollection.each(function (layerDefModel) {
    linkLayerInfowindow(layerDefModel, this.visMap());
    linkLayerTooltip(layerDefModel, this.visMap());
  }, this);

  opts.widgetDefinitionsCollection.on('add', this._onWidgetDefinitionAdded, this);
  opts.widgetDefinitionsCollection.on('sync', this._onWidgetDefinitionSynced, this);
  opts.widgetDefinitionsCollection.on('change', this._onWidgetDefinitionChanged, this);
  opts.widgetDefinitionsCollection.on('destroy', this._onWidgetDefinitionDestroyed, this);
  opts.widgetDefinitionsCollection.on('add remove reset', this._invalidateSize, this);

  this.widgetDefinitionsCollection = opts.widgetDefinitionsCollection;

  this._analysisDefinitionsCollection.each(this._analyseDefinition, this);
};

F.prototype._onAnalysisDefinitionNodeRemoved = function (m) {
  var node = this._analysis().findNodeById(m.id);
  if (node) {
    node.remove();
  }
};

F.prototype._analyseDefinition = function (m) {
  var id = m.get('node_id');
  var nodeDefModel = this._analysisDefinitionNodesCollection.get(id);
  this._analyseDefinitionNode(nodeDefModel);
};

F.prototype._analyseDefinitionNode = function (nodeDefModel) {
  var attrs = nodeDefModel.toJSON({skipOptions: true});
  this._analysis().analyse(attrs);

  // Unfortunately have to try to setup sync until this point, since a node doesn't exist until after analyse call
  this._analysisDefinitionNodesCollection.each(this._tryToSetupDefinitionNodeSync, this);
};

F.prototype._tryToSetupDefinitionNodeSync = function (m) {
  if (m.__syncSetup) return; // only setup once

  var node = this._analysis().findNodeById(m.id);
  if (!node) return; // might not exist when method is called, so do nothing to allow retries

  m.__syncSetup = true;

  // Don't need to sync source nodes
  if (node.get('type') !== 'source') {
    var updateAnalysisQuerySchema = function () {
      m.querySchemaModel.set({
        query: node.get('query'),
        may_have_rows: node.get('status') === 'ready'
      });
    };
    updateAnalysisQuerySchema();
    m.listenTo(node, 'change', updateAnalysisQuerySchema);
    m.listenToOnce(node, 'destroy', m.stopListening);
  }
};

F.prototype._onWidgetDefinitionAdded = function (m) {
  var widgetModel = this._diDashboard.getWidget(m.id);
  if (widgetModel) {
    widgetModel.set('show_stats', true);
  }
};

F.prototype._onWidgetDefinitionSynced = function (m) {
  var widgetModel = this._diDashboard.getWidget(m.id);
  if (!widgetModel) {
    this._createWidgetModel(m);
  }
};

F.prototype._onLayerChangeCheckWidgetAutoStyling = function (model, style) {
  var self = this;
  var currentLayerId = model.get('id');
  var widgetsToInvalidate = this.widgetDefinitionsCollection.where({'layer_id': currentLayerId});
  widgetsToInvalidate.forEach(function (widget) {
    var widgetModel = self._diDashboard.getWidget(widget.id);
    if (widgetModel && _.contains(['histogram', 'category'], widgetModel.get('type'))) {
      widgetModel.cancelAutoStyle();
    }
  });
  this._diDashboard._dashboard.vis._layersCollection.where({'id': currentLayerId})[0];
};

F.prototype._onWidgetDefinitionChanged = function (m) {
  var widgetModel = this._diDashboard.getWidget(m.id);

  // Only try to update if there's a corresponding widget model
  // E.g. the change of type will remove the model and provoke change events, which are not of interest (here),
  // since the widget model should be re-created for the new type anyway.
  if (widgetModel) {
    if (m.hasChanged('type')) {
      widgetModel.remove();
      this._createWidgetModel(m);
    } else {
      var attrs = this._formatWidgetAttrs(m.changedAttributes());
      widgetModel.update(attrs);
    }
  }
};

F.prototype._onWidgetDefinitionDestroyed = function (m) {
  var widgetModel = this._diDashboard.getWidget(m.id);

  if (widgetModel) {
    widgetModel.remove();
  }
};

F.prototype._createWidgetModel = function (m) {
  // e.g. 'time-series' => createTimeSeriesWidget
  var infix = m.get('type').replace(/(^\w|-\w)/g, function (match) {
    return match.toUpperCase().replace('-', '');
  });
  var methodName = 'create' + infix + 'Widget';

  var layerModel = this.visMap().getLayerById(m.get('layer_id'));
  var attrs = this._formatWidgetAttrs(m.attributes);

  var widgetModel = this._diDashboard[methodName](attrs, layerModel);

  if (widgetModel) {
    widgetModel.set('show_stats', true);
  }
};

/**
 * Massage some data points to the expected format of deep-insights API
 */
F.prototype._formatWidgetAttrs = function (attrs) {
  if (!_.has(attrs, 'source')) return attrs; // nothing to format

  var formattedAttrs = _.omit(attrs, 'source');
  formattedAttrs.source = {id: attrs.source};

  return formattedAttrs;
};

F.prototype._onLayerDefinitionAdded = function (m, c, opts) {
  // If added but not yet saved, postpone the creation until persisted (see sync listener)
  if (!m.isNew()) {
    if (!this._getLayer(m)) {
      this._createLayer(m);
    }
  }
};

F.prototype._onLayerDefinitionSynced = function (m) {
  if (!this._getLayer(m)) {
    this._createLayer(m);
  }
};

F.prototype._onLayerDefinitionChanged = function (m) {
  if (!m.isNew()) {
    var layer = this._getLayer(m);

    if (!layer) {
      this._createLayer(m);
      return;
    }

    var attrs = m.changedAttributes();
    if (attrs.type) {
      layer.remove();
      this._createLayer(m);
    } else {
      if (m.get('source') && !layer.get('source')) {
        attrs.source = m.get('source');
      }
      layer.update(attrs);
    }
  }
};

F.prototype._onLayerDefinitionRemoved = function (m) {
  if (!m.isNew()) {
    this._getLayer(m).remove();
  }
};

F.prototype._onLayerDefinitionMoved = function (m, at, c) {
  this.visMap().layers.remove(m, { silent: true });
  this._createLayer(m, {
    at: at
  });
};

var LAYER_TYPE_TO_LAYER_CREATE_METHOD;
F.prototype._createLayer = function (layerDefModel, opts) {
  opts = _.extend({
    at: layerDefModel.get('order')
  }, opts);
  var attrs = JSON.parse(JSON.stringify(layerDefModel.attributes)); // deep clone;

  if (attrs.source) {
    // Make sure to analysis is created first
    var nodeDefModel = this._analysisDefinitionNodesCollection.get(attrs.source);
    this._analyseDefinitionNode(nodeDefModel);
  }

  LAYER_TYPE_TO_LAYER_CREATE_METHOD = LAYER_TYPE_TO_LAYER_CREATE_METHOD || {
    'cartodb': 'createCartoDBLayer',
    'gmapsbase': 'createGMapsBaseLayer',
    'plain': 'createPlainLayer',
    'tiled': 'createTileLayer',
    'torque': 'createTorqueLayer',
    'wms': 'createWMSLayer'
  };
  var createMethodName = LAYER_TYPE_TO_LAYER_CREATE_METHOD[attrs.type.toLowerCase()];
  if (!createMethodName) throw new Error('no create method name found for type ' + attrs.type);

  var visMap = this.visMap();
  visMap[createMethodName](attrs, opts);

  linkLayerInfowindow(layerDefModel, visMap);
  linkLayerTooltip(layerDefModel, visMap);
};

F.prototype._getLayer = function (m) {
  return this.visMap().getLayerById(m.id);
};

F.prototype.visMap = function () {
  return this._vis().map;
};

F.prototype._analysis = function () {
  return this._vis().analysis;
};

F.prototype._vis = function () {
  return this._diDashboard.getMap();
};

F.prototype._invalidateSize = function () {
  this._vis().invalidateSize();
};

module.exports = F;
<commit_msg>regenerates timeseries widget when toggling animation<commit_after>var _ = require('underscore');
var linkLayerInfowindow = require('./deep-insights-integration/link-layer-infowindow');
var linkLayerTooltip = require('./deep-insights-integration/link-layer-tooltip');

/**
 * Integration between various data collections/models with cartodb.js and deep-insights.
 */
var F = function (opts) {
  if (!opts.deepInsightsDashboard) throw new Error('deepInsightsDashboard is required');
  if (!opts.analysisDefinitionNodesCollection) throw new Error('analysisDefinitionNodesCollection is required');
  if (!opts.analysisDefinitionsCollection) throw new Error('analysisDefinitionsCollection is required');
  if (!opts.layerDefinitionsCollection) throw new Error('layerDefinitionsCollection is required');
  if (!opts.widgetDefinitionsCollection) throw new Error('widgetDefinitionsCollection is required');

  this._diDashboard = opts.deepInsightsDashboard;
  this._analysisDefinitionNodesCollection = opts.analysisDefinitionNodesCollection;
  this._analysisDefinitionsCollection = opts.analysisDefinitionsCollection;

  this._analysisDefinitionNodesCollection.on('add', this._analyseDefinitionNode, this);
  this._analysisDefinitionNodesCollection.on('change', this._analyseDefinitionNode, this);
  this._analysisDefinitionNodesCollection.on('remove', this._onAnalysisDefinitionNodeRemoved, this);
  this._analysisDefinitionsCollection.on('add change:node_id sync', this._analyseDefinition, this);

  opts.layerDefinitionsCollection.on('add', this._onLayerDefinitionAdded, this);
  opts.layerDefinitionsCollection.on('sync', this._onLayerDefinitionSynced, this);
  opts.layerDefinitionsCollection.on('change', this._onLayerDefinitionChanged, this);
  opts.layerDefinitionsCollection.on('remove', this._onLayerDefinitionRemoved, this);
  opts.layerDefinitionsCollection.on('layerMoved', this._onLayerDefinitionMoved, this);
  opts.layerDefinitionsCollection.on('change:cartocss', this._onLayerChangeCheckWidgetAutoStyling, this);

  opts.layerDefinitionsCollection.each(function (layerDefModel) {
    linkLayerInfowindow(layerDefModel, this.visMap());
    linkLayerTooltip(layerDefModel, this.visMap());
  }, this);

  opts.widgetDefinitionsCollection.on('add', this._onWidgetDefinitionAdded, this);
  opts.widgetDefinitionsCollection.on('sync', this._onWidgetDefinitionSynced, this);
  opts.widgetDefinitionsCollection.on('change', this._onWidgetDefinitionChanged, this);
  opts.widgetDefinitionsCollection.on('destroy', this._onWidgetDefinitionDestroyed, this);
  opts.widgetDefinitionsCollection.on('add remove reset', this._invalidateSize, this);

  this.widgetDefinitionsCollection = opts.widgetDefinitionsCollection;

  this._analysisDefinitionsCollection.each(this._analyseDefinition, this);
};

F.prototype._onAnalysisDefinitionNodeRemoved = function (m) {
  var node = this._analysis().findNodeById(m.id);
  if (node) {
    node.remove();
  }
};

F.prototype._analyseDefinition = function (m) {
  var id = m.get('node_id');
  var nodeDefModel = this._analysisDefinitionNodesCollection.get(id);
  this._analyseDefinitionNode(nodeDefModel);
};

F.prototype._analyseDefinitionNode = function (nodeDefModel) {
  var attrs = nodeDefModel.toJSON({skipOptions: true});
  this._analysis().analyse(attrs);

  // Unfortunately have to try to setup sync until this point, since a node doesn't exist until after analyse call
  this._analysisDefinitionNodesCollection.each(this._tryToSetupDefinitionNodeSync, this);
};

F.prototype._tryToSetupDefinitionNodeSync = function (m) {
  if (m.__syncSetup) return; // only setup once

  var node = this._analysis().findNodeById(m.id);
  if (!node) return; // might not exist when method is called, so do nothing to allow retries

  m.__syncSetup = true;

  // Don't need to sync source nodes
  if (node.get('type') !== 'source') {
    var updateAnalysisQuerySchema = function () {
      m.querySchemaModel.set({
        query: node.get('query'),
        may_have_rows: node.get('status') === 'ready'
      });
    };
    updateAnalysisQuerySchema();
    m.listenTo(node, 'change', updateAnalysisQuerySchema);
    m.listenToOnce(node, 'destroy', m.stopListening);
  }
};

F.prototype._onWidgetDefinitionAdded = function (m) {
  var widgetModel = this._diDashboard.getWidget(m.id);
  if (widgetModel) {
    widgetModel.set('show_stats', true);
  }
};

F.prototype._onWidgetDefinitionSynced = function (m) {
  var widgetModel = this._diDashboard.getWidget(m.id);
  if (!widgetModel) {
    this._createWidgetModel(m);
  }
};

F.prototype._onLayerChangeCheckWidgetAutoStyling = function (model, style) {
  var self = this;
  var currentLayerId = model.get('id');
  var widgetsToInvalidate = this.widgetDefinitionsCollection.where({'layer_id': currentLayerId});
  widgetsToInvalidate.forEach(function (widget) {
    var widgetModel = self._diDashboard.getWidget(widget.id);
    if (widgetModel && _.contains(['histogram', 'category'], widgetModel.get('type'))) {
      widgetModel.cancelAutoStyle();
    }
  });
  this._diDashboard._dashboard.vis._layersCollection.where({'id': currentLayerId})[0];
};

F.prototype._onWidgetDefinitionChanged = function (m) {
  var widgetModel = this._diDashboard.getWidget(m.id);

  // Only try to update if there's a corresponding widget model
  // E.g. the change of type will remove the model and provoke change events, which are not of interest (here),
  // since the widget model should be re-created for the new type anyway.
  if (widgetModel) {
    if (m.hasChanged('type')) {
      widgetModel.remove();
      this._createWidgetModel(m);
    } else {
      var attrs = this._formatWidgetAttrs(m.changedAttributes());
      widgetModel.update(attrs);
    }
  }
};

F.prototype._onWidgetDefinitionDestroyed = function (m) {
  var widgetModel = this._diDashboard.getWidget(m.id);

  if (widgetModel) {
    widgetModel.remove();
  }
};

F.prototype._createWidgetModel = function (m) {
  // e.g. 'time-series' => createTimeSeriesWidget
  var infix = m.get('type').replace(/(^\w|-\w)/g, function (match) {
    return match.toUpperCase().replace('-', '');
  });
  var methodName = 'create' + infix + 'Widget';

  var layerModel = this.visMap().getLayerById(m.get('layer_id'));
  var attrs = this._formatWidgetAttrs(m.attributes);

  var widgetModel = this._diDashboard[methodName](attrs, layerModel);

  if (widgetModel) {
    widgetModel.set('show_stats', true);
  }
};

/**
 * Massage some data points to the expected format of deep-insights API
 */
F.prototype._formatWidgetAttrs = function (attrs) {
  if (!_.has(attrs, 'source')) return attrs; // nothing to format

  var formattedAttrs = _.omit(attrs, 'source');
  formattedAttrs.source = {id: attrs.source};

  return formattedAttrs;
};

F.prototype._onLayerDefinitionAdded = function (m, c, opts) {
  // If added but not yet saved, postpone the creation until persisted (see sync listener)
  if (!m.isNew()) {
    if (!this._getLayer(m)) {
      this._createLayer(m);
    }
  }
};

F.prototype._onLayerDefinitionSynced = function (m) {
  if (!this._getLayer(m)) {
    this._createLayer(m);
  }
};

F.prototype._onLayerDefinitionChanged = function (m) {
  if (!m.isNew()) {
    var layer = this._getLayer(m);

    if (!layer) {
      this._createLayer(m);
      return;
    }

    var attrs = m.changedAttributes();
    if (attrs.type) {
      layer.remove();
      this._createLayer(m);
      if (attrs.type === 'torque' || m.previous('type') === 'torque') {
        var widgetModel = this._diDashboard.getWidgets().filter(function (m) {
          return m.get('type') === 'time-series';
        })[0];
        widgetModel.remove();
        this._diDashboard.createTimeSeriesWidget(widgetModel.dataviewModel.attributes, this._getLayer(m));
      }
    } else {
      if (m.get('source') && !layer.get('source')) {
        attrs.source = m.get('source');
      }
      layer.update(attrs);
    }
  }
};

F.prototype._onLayerDefinitionRemoved = function (m) {
  if (!m.isNew()) {
    this._getLayer(m).remove();
  }
};

F.prototype._onLayerDefinitionMoved = function (m, at, c) {
  this.visMap().layers.remove(m, { silent: true });
  this._createLayer(m, {
    at: at
  });
};

var LAYER_TYPE_TO_LAYER_CREATE_METHOD;
F.prototype._createLayer = function (layerDefModel, opts) {
  opts = _.extend({
    at: layerDefModel.get('order')
  }, opts);
  var attrs = JSON.parse(JSON.stringify(layerDefModel.attributes)); // deep clone;

  if (attrs.source) {
    // Make sure to analysis is created first
    var nodeDefModel = this._analysisDefinitionNodesCollection.get(attrs.source);
    this._analyseDefinitionNode(nodeDefModel);
  }

  LAYER_TYPE_TO_LAYER_CREATE_METHOD = LAYER_TYPE_TO_LAYER_CREATE_METHOD || {
    'cartodb': 'createCartoDBLayer',
    'gmapsbase': 'createGMapsBaseLayer',
    'plain': 'createPlainLayer',
    'tiled': 'createTileLayer',
    'torque': 'createTorqueLayer',
    'wms': 'createWMSLayer'
  };
  var createMethodName = LAYER_TYPE_TO_LAYER_CREATE_METHOD[attrs.type.toLowerCase()];
  if (!createMethodName) throw new Error('no create method name found for type ' + attrs.type);

  var visMap = this.visMap();
  visMap[createMethodName](attrs, opts);

  linkLayerInfowindow(layerDefModel, visMap);
  linkLayerTooltip(layerDefModel, visMap);
};

F.prototype._getLayer = function (m) {
  return this.visMap().getLayerById(m.id);
};

F.prototype.visMap = function () {
  return this._vis().map;
};

F.prototype._analysis = function () {
  return this._vis().analysis;
};

F.prototype._vis = function () {
  return this._diDashboard.getMap();
};

F.prototype._invalidateSize = function () {
  this._vis().invalidateSize();
};

module.exports = F;
<|endoftext|>


<commit_before>var $ = require('jquery');
var _ = require('underscore');
var Backbone = require('backbone');
var StyleModel = require('../../../../../javascripts/cartodb3/editor/style/style-definition-model');
var QueryGeometryModel = require('../../../../../javascripts/cartodb3/data/query-geometry-model');
var QuerySchemaModel = require('../../../../../javascripts/cartodb3/data/query-schema-model');
var CartoCSSModel = require('../../../../../javascripts/cartodb3/editor/style/style-cartocss-model');
var LayerDefinitionsCollection = require('../../../../../javascripts/cartodb3/data/layer-definitions-collection');
var StyleView = require('../../../../../javascripts/cartodb3/editor/style/style-view');
var UserActions = require('../../../../../javascripts/cartodb3/data/user-actions');

describe('editor/style/style-view', function () {
  beforeEach(function () {
    this.styleModel = new StyleModel({}, { parse: true });
    this.cartocssModel = new CartoCSSModel();

    this.layerDefinitionModel = new Backbone.Model();
    this.layerDefinitionModel.styleModel = this.styleModel;
    this.layerDefinitionModel.cartocssModel = this.cartocssModel;
    spyOn(this.layerDefinitionModel, 'sync');

    this.queryGeometryModel = new QueryGeometryModel({
      query: 'SELECT * FROM table',
      status: 'fetched'
    }, {
      configModel: {}
    });
    this.queryGeometryModel.set('simple_geom', 'point');

    this.querySchemaModel = new QuerySchemaModel({
      query: 'SELECT * FROM table',
      status: 'fetched'
    }, {
      configModel: {}
    });

    this.layerDefinitionsCollection = new LayerDefinitionsCollection([], {
      analysisDefinitionNodesCollection: new Backbone.Collection(),
      configModel: {},
      mapId: 'map-123'
    });
    spyOn(this.layerDefinitionsCollection, 'isThereAnyTorqueLayer').and.returnValue(false);

    this.editorModel = new Backbone.Model();
    this.editorModel.isEditing = function () { return false; };

    this.userActions = UserActions({
      userModel: {},
      analysisDefinitionsCollection: {},
      analysisDefinitionNodesCollection: {},
      layerDefinitionsCollection: {},
      widgetDefinitionsCollection: {}
    });

    this.promise = $.Deferred();
    spyOn(this.userActions, 'saveLayer').and.returnValue(this.promise);

    this.view = new StyleView({
      layerDefinitionsCollection: this.layerDefinitionsCollection,
      layerDefinitionModel: this.layerDefinitionModel,
      queryGeometryModel: this.queryGeometryModel,
      querySchemaModel: this.querySchemaModel,
      modals: new Backbone.Model(),
      editorModel: this.editorModel,
      userActions: this.userActions,
      configModel: {}
    });
    this.view.render();
  });

  it('should render properly', function () {
    expect(_.size(this.view._subviews)).toBe(1);
  });

  it('should create internal codemirror model', function () {
    expect(this.view._codemirrorModel).toBeDefined();
  });

  it('should show infobox if layer is hidden', function () {
    this.layerDefinitionModel.set('visible', false);

    expect(this.view.$('.Infobox').length).toBe(1);
    expect(this.view.$('.Infobox').html()).toContain('editor.style.messages.layer-hidden');

    this.view._codemirrorModel.set('content', 'foo');
    this.editorModel.set('edition', true);

    // Infobox only visible in forms
    expect(this.view.$('.Infobox').length).toBe(0);

    this.editorModel.set('edition', false);
    expect(this.view.$('.Infobox').length).toBe(1);
    expect(this.view.$('.Infobox').html()).toContain('editor.style.messages.layer-hidden');

    this.layerDefinitionModel.set('visible', true);
    expect(this.view.$('.Infobox').length).toBe(0);
    expect(this.view.$('.Infobox').html()).not.toContain('editor.style.messages.layer-hidden');
  });

  it('should append existing Maps API errors to codemirror model when view is initialized', function () {
    this.layerDefinitionModel.set('error', {
      type: 'turbo-carto',
      line: 99,
      message: 'something went wrong'
    }); // setting this silently

    this.view = new StyleView({
      layerDefinitionsCollection: this.layerDefinitionsCollection,
      layerDefinitionModel: this.layerDefinitionModel,
      queryGeometryModel: this.queryGeometryModel,
      querySchemaModel: this.querySchemaModel,
      modals: new Backbone.Model(),
      editorModel: this.editorModel,
      userActions: this.userActions,
      configModel: {}
    });

    expect(this.view._codemirrorModel.get('errors')).toEqual([{
      line: 99,
      message: 'something went wrong'
    }]);
  });

  describe('bindings', function () {
    it('should set codemirror value when style model changes', function () {
      spyOn(this.view._codemirrorModel, 'set');

      this.styleModel.set('type', 'squares');
      expect(this.view._codemirrorModel.set).toHaveBeenCalled();
    });

    it('should set codemirror value when undo or redo is applied', function () {
      this.styleModel.set('type', 'hexabins');
      spyOn(this.view._codemirrorModel, 'set');

      this.styleModel.undo();
      expect(this.view._codemirrorModel.set).toHaveBeenCalled();

      this.view._codemirrorModel.set.calls.reset();
      this.styleModel.redo();
      expect(this.view._codemirrorModel.set).toHaveBeenCalled();
    });

    it('should append Maps API errors to existing codemirror errors', function () {
      this.view._codemirrorModel.set('errors', [
        { line: 0, message: 'error 1' }
      ]);

      this.layerDefinitionModel.set('error', {
        type: 'turbo-carto',
        line: 1,
        message: 'error 2'
      });

      expect(this.view._codemirrorModel.get('errors')).toEqual([
        { line: 0, message: 'error 1' },
        { line: 1, message: 'error 2' }
      ]);
    });
  });

  it('should save cartocss', function () {
    var content = '#layer {marker-fill:red}';
    this.view._codemirrorModel.set('content', content);

    this.view._saveCartoCSS();
    expect(this.view._cartocssModel.get('content')).toBe(content);
    expect(this.layerDefinitionModel.get('cartocss_custom')).toBeTruthy();
    expect(this.layerDefinitionModel.get('cartocss')).toBe(content);
    expect(this.userActions.saveLayer).toHaveBeenCalledWith(this.layerDefinitionModel);
  });

  it('should not save cartocss if content is empty', function () {
    var content = '';
    this.view._codemirrorModel.set('content', '');
    this.view._saveCartoCSS();
    expect(this.layerDefinitionModel.get('cartocss_custom')).toBeFalsy();
    expect(this.layerDefinitionModel.get('cartocss')).not.toBe(content);
    expect(this.userActions.saveLayer).not.toHaveBeenCalled();
  });

  it('should not have leaks', function () {
    expect(this.view).toHaveNoLeaks();
  });
});
<commit_msg>Fix tests.<commit_after>var $ = require('jquery');
var _ = require('underscore');
var Backbone = require('backbone');
var StyleModel = require('../../../../../javascripts/cartodb3/editor/style/style-definition-model');
var QueryGeometryModel = require('../../../../../javascripts/cartodb3/data/query-geometry-model');
var QuerySchemaModel = require('../../../../../javascripts/cartodb3/data/query-schema-model');
var CartoCSSModel = require('../../../../../javascripts/cartodb3/editor/style/style-cartocss-model');
var LayerDefinitionsCollection = require('../../../../../javascripts/cartodb3/data/layer-definitions-collection');
var StyleView = require('../../../../../javascripts/cartodb3/editor/style/style-view');
var UserActions = require('../../../../../javascripts/cartodb3/data/user-actions');

describe('editor/style/style-view', function () {
  beforeEach(function () {
    this.styleModel = new StyleModel({}, { parse: true });
    this.cartocssModel = new CartoCSSModel();

    this.layerDefinitionModel = new Backbone.Model();
    this.layerDefinitionModel.styleModel = this.styleModel;
    this.layerDefinitionModel.cartocssModel = this.cartocssModel;
    this.layerDefinitionModel.getTableName = function () {
      return 'table';
    };

    spyOn(this.layerDefinitionModel, 'sync');

    this.queryGeometryModel = new QueryGeometryModel({
      query: 'SELECT * FROM table',
      status: 'fetched'
    }, {
      configModel: {}
    });
    this.queryGeometryModel.set('simple_geom', 'point');

    this.querySchemaModel = new QuerySchemaModel({
      query: 'SELECT * FROM table',
      status: 'fetched'
    }, {
      configModel: {}
    });

    this.layerDefinitionsCollection = new LayerDefinitionsCollection([], {
      analysisDefinitionNodesCollection: new Backbone.Collection(),
      configModel: {},
      mapId: 'map-123'
    });
    spyOn(this.layerDefinitionsCollection, 'isThereAnyTorqueLayer').and.returnValue(false);

    this.editorModel = new Backbone.Model();
    this.editorModel.isEditing = function () { return false; };

    this.userActions = UserActions({
      userModel: {},
      analysisDefinitionsCollection: {},
      analysisDefinitionNodesCollection: {},
      layerDefinitionsCollection: {},
      widgetDefinitionsCollection: {}
    });

    this.promise = $.Deferred();
    spyOn(this.userActions, 'saveLayer').and.returnValue(this.promise);

    this.view = new StyleView({
      layerDefinitionsCollection: this.layerDefinitionsCollection,
      layerDefinitionModel: this.layerDefinitionModel,
      queryGeometryModel: this.queryGeometryModel,
      querySchemaModel: this.querySchemaModel,
      modals: new Backbone.Model(),
      editorModel: this.editorModel,
      userActions: this.userActions,
      configModel: {}
    });
    this.view.render();
  });

  it('should render properly', function () {
    expect(_.size(this.view._subviews)).toBe(1);
  });

  it('should create internal codemirror model', function () {
    expect(this.view._codemirrorModel).toBeDefined();
  });

  it('should show infobox if layer is hidden', function () {
    this.layerDefinitionModel.set('visible', false);

    expect(this.view.$('.Infobox').length).toBe(1);
    expect(this.view.$('.Infobox').html()).toContain('editor.style.messages.layer-hidden');

    this.view._codemirrorModel.set('content', 'foo');
    this.editorModel.set('edition', true);

    // Infobox only visible in forms
    expect(this.view.$('.Infobox').length).toBe(0);

    this.editorModel.set('edition', false);
    expect(this.view.$('.Infobox').length).toBe(1);
    expect(this.view.$('.Infobox').html()).toContain('editor.style.messages.layer-hidden');

    this.layerDefinitionModel.set('visible', true);
    expect(this.view.$('.Infobox').length).toBe(0);
    expect(this.view.$('.Infobox').html()).not.toContain('editor.style.messages.layer-hidden');
  });

  it('should append existing Maps API errors to codemirror model when view is initialized', function () {
    this.layerDefinitionModel.set('error', {
      type: 'turbo-carto',
      line: 99,
      message: 'something went wrong'
    }); // setting this silently

    this.view = new StyleView({
      layerDefinitionsCollection: this.layerDefinitionsCollection,
      layerDefinitionModel: this.layerDefinitionModel,
      queryGeometryModel: this.queryGeometryModel,
      querySchemaModel: this.querySchemaModel,
      modals: new Backbone.Model(),
      editorModel: this.editorModel,
      userActions: this.userActions,
      configModel: {}
    });

    expect(this.view._codemirrorModel.get('errors')).toEqual([{
      line: 99,
      message: 'something went wrong'
    }]);
  });

  describe('bindings', function () {
    it('should set codemirror value when style model changes', function () {
      spyOn(this.view._codemirrorModel, 'set');

      this.styleModel.set('type', 'squares');
      expect(this.view._codemirrorModel.set).toHaveBeenCalled();
    });

    it('should set codemirror value when undo or redo is applied', function () {
      this.styleModel.set('type', 'hexabins');
      spyOn(this.view._codemirrorModel, 'set');

      this.styleModel.undo();
      expect(this.view._codemirrorModel.set).toHaveBeenCalled();

      this.view._codemirrorModel.set.calls.reset();
      this.styleModel.redo();
      expect(this.view._codemirrorModel.set).toHaveBeenCalled();
    });

    it('should append Maps API errors to existing codemirror errors', function () {
      this.view._codemirrorModel.set('errors', [
        { line: 0, message: 'error 1' }
      ]);

      this.layerDefinitionModel.set('error', {
        type: 'turbo-carto',
        line: 1,
        message: 'error 2'
      });

      expect(this.view._codemirrorModel.get('errors')).toEqual([
        { line: 0, message: 'error 1' },
        { line: 1, message: 'error 2' }
      ]);
    });
  });

  it('should save cartocss', function () {
    var content = '#layer {marker-fill:red}';
    this.view._codemirrorModel.set('content', content);

    this.view._saveCartoCSS();
    expect(this.view._cartocssModel.get('content')).toBe(content);
    expect(this.layerDefinitionModel.get('cartocss_custom')).toBeTruthy();
    expect(this.layerDefinitionModel.get('cartocss')).toBe(content);
    expect(this.userActions.saveLayer).toHaveBeenCalledWith(this.layerDefinitionModel);
  });

  it('should not save cartocss if content is empty', function () {
    var content = '';
    this.view._codemirrorModel.set('content', '');
    this.view._saveCartoCSS();
    expect(this.layerDefinitionModel.get('cartocss_custom')).toBeFalsy();
    expect(this.layerDefinitionModel.get('cartocss')).not.toBe(content);
    expect(this.userActions.saveLayer).not.toHaveBeenCalled();
  });

  it('should not have leaks', function () {
    expect(this.view).toHaveNoLeaks();
  });
});
<|endoftext|>