<commit_before>var Task = require('./task').Task,
    IonicStats = require('./stats').IonicStats,
    fs = require('fs'),
    Q = require('q'),
    argv = require('optimist').argv,
    xml2js = require('xml2js'),
    path = require('path'),
    exec = require('child_process').exec,
    colors = require('colors');

var IonicTask = function() {};

IonicTask.prototype = new Task();

IonicTask.prototype.run = function(ionic) {
  this.ionic = ionic;
  var self = this;
  var cmdName = process.argv[2].toLowerCase();
  var q;

  this.isLiveReload = ((cmdName == 'run' || cmdName == 'emulate') && (argv.livereload || argv['live-reload'] || argv.l));

  if(this.isLiveReload) {
    q = self.setupLiveReload();

  } else {
    // ensure the content node was set back to its original
    q = this.setConfigXml({
      resetContent: true,
      errorWhenNotFound: false
    });
  }

  q.then(function(){
    self.runCordova(cmdName);
  })
};

IonicTask.prototype.runCordova = function(cmdName) {
  var self = this;
  var cmdArgs = (process.argv.length > 3 ? process.argv.slice(3) : []);
  var cmdArg, x, y;

  // backwards compatibility prior to fully wrapping cordova cmds
  if(cmdName == 'platform') {
    // `ionic platform <PLATFORM>` used to actually run `ionic platform add <PLATFORM>`
    // if a cordova platform cmd isn't the cmd then automatically insert `add`
    var hasCordovaCmd = false;
    var validCommands = 'add remove rm list ls update up check'.split(' ');
    for(x=0; x<cmdArgs.length; x++) {
      cmdArg = cmdArgs[x].toLowerCase();
      for(y=0; y<validCommands.length; y++) {
        if(cmdArg == validCommands[y]) {
          hasCordovaCmd = true;
          break;
        }
      }
    }
    if(!hasCordovaCmd) {
      cmdArgs.unshift('add');
    }
  }
  this.addHooks();

  cmdArgs.unshift(cmdName);

  // clean out any cmds that may confuse cordova
  var cleanArgs = [];
  var port = argv.port || argv.p || '';
  var liveReloadPort = argv.livereloadport || argv['livereload-port'] || argv.i || '';
  var ignoreCmds = '--livereload -l --consolelogs -c --serverlogs -s --port -p --livereload-port -i'.split(' ');
  var isValdCmd;
  for(x=0; x<cmdArgs.length; x++) {
    cmdArg = cmdArgs[x];
    if(port && cmdArg == port) continue;
    if(liveReloadPort && cmdArg == liveReloadPort) continue;
    isValdCmd = true;
    for(y=0; y<ignoreCmds.length; y++) {
      if(cmdArg == ignoreCmds[y]) {
        isValdCmd = false;
        break;
      }
    }
    if(isValdCmd) {
      cleanArgs.push(cmdArg);
    }
  }

  var cordovaProcess = exec('cordova ' + cleanArgs.join(' '));

  cordovaProcess.stdout.on('data', function (data) {
    process.stdout.write(data);
  });

  cordovaProcess.stderr.on('data', function (data) {
    if(data) {
      process.stderr.write(data.toString().error.bold);
    }
  });

  if(self.isLiveReload) {
    cordovaProcess.on('exit', function(){
      setTimeout(function(){
        // set it back to the original src after a few seconds
        self.setConfigXml({
          resetContent: true,
          errorWhenNotFound: true
        });
      }, 5000);
    });

    process.on('exit', function(){
      // verify it was set back
      self.setConfigXml({
        resetContent: true,
        errorWhenNotFound: false
      });
    });
  }

  IonicStats.t();
};

IonicTask.prototype.setupLiveReload = function() {
  var d = Q.defer();

  console.log('Setup Live Reload'.green.bold);

  var self = this;
  var serve = new require('./serve');
  var serveTask = new serve.IonicTask();
  serveTask.ionic = this.ionic;
  serveTask.isPlatformServe = true;

  serveTask.loadSettings(function(){
    serveTask.runLivereload = true;
    serveTask.launchBrowser = false;
    serveTask.start(self.ionic);

    self.setConfigXml({
      devServer: serveTask.devServer
    }).then(function(){
      d.resolve();
    });

  });

  return d.promise;
};

IonicTask.prototype.setConfigXml = function(options) {
  var d = Q.defer();

  var self = this;
  var madeChange = false;

  try {
    var configXmlPath = path.resolve('config.xml');

    if(!fs.existsSync(configXmlPath)) {
      // working directory does not have the config.xml file
      if(options.errorWhenNotFound) {
        d.reject('Unable to locate config.xml file. Please ensure the working directory is at the root of the app where the config.xml should be located.');
      } else {
        d.resolve();
      }
      return d.promise;
    }

    var configString = fs.readFileSync(configXmlPath, { encoding: 'utf8' });

    var parseString = xml2js.parseString;
    parseString(configString, function (err, jsonConfig) {
      if(err) {
        d.reject(err);
        return self.ionic.fail('Error parsing ' + configXmlPath + ': ' + err);
      }

      if(options.devServer) {
        if( !jsonConfig.widget.content[0].$['original-src'] ) {
          jsonConfig.widget.content[0].$['original-src'] = jsonConfig.widget.content[0].$.src;
          madeChange = true;
        }
        if(jsonConfig.widget.content[0].$.src !== options.devServer) {
          jsonConfig.widget.content[0].$.src = options.devServer;
          madeChange = true;
        }

      } else if(options.resetContent) {

        if( jsonConfig.widget.content[0].$['original-src'] ) {
          jsonConfig.widget.content[0].$.src = jsonConfig.widget.content[0].$['original-src'];
          delete jsonConfig.widget.content[0].$['original-src'];
          madeChange = true;
        }
      }

      if(madeChange) {
        var xmlBuilder = new xml2js.Builder();
        configString = xmlBuilder.buildObject(jsonConfig);
        fs.writeFileSync(configXmlPath, configString);
      }

      d.resolve();
    });

  } catch(e) {
    d.reject(e);
    self.ionic.fail('Error updating ' + configXmlPath + ': ' + e);
  }

  return d.promise;
};

IonicTask.prototype.addHooks = function() {
  // Add hooks which this Ionic project doesn't already have
  // note: hook scripts must be executable!

  if( !fs.existsSync(path.join('www')) ) {
    // don't both doing any of this if they aren't
    // in the correct working directory, which would have `www`
    return;
  }

  // loop through all the hook directories added to the ionic-cli
  var cliHooksPath = path.join(__filename, '../../hooks');
  fs.readdir(cliHooksPath, function(err, files){
    if(err) return;
    for(var x=0; x<files.length; x++) {
      if(files[x].indexOf('.') > -1) continue;
      addCliHookDirectory( path.join(cliHooksPath, files[x]), files[x] );
    }
  });

  function addCliHookDirectory(cliHookPath, hookDirectoryName) {
    fs.readdir(cliHookPath, function(err, files){
      // loop through each of the scripts in the ionic-cli hook directory
      if(err) return;
      for(var x=0; x<files.length; x++) {
        var hookFilename = files[x];
        if(hookFilename.indexOf('.js') === -1) return;

        // check if this hook script has already been added to this ionic project
        var projectHookPath = path.join('hooks', hookDirectoryName, hookFilename);
        addHookScript(cliHookPath, hookDirectoryName, hookFilename);
      }
    });
  }

  function addHookScript(cliHookPath, hookDirectoryName, hookFilename) {
    // add the root hooks directory if the project doesn't have it
    try {
      var projectHookPath = path.join('hooks');
      if( !fs.existsSync(projectHookPath) ) {
        fs.mkdirSync(projectHookPath);
      }

      // add the hook directory (ie: after_prepare) if the project doesn't have it
      projectHookPath = path.join(projectHookPath, hookDirectoryName);
      if( !fs.existsSync(projectHookPath) ) {
        fs.mkdirSync(projectHookPath);
      }

      var projectScript = path.join(projectHookPath, hookFilename);
      if( !fs.existsSync(projectHookPath) ) {
        // copy the hook script to the project
        try {
          var cliScript = path.join(cliHookPath, hookFilename);
          fs.createReadStream( cliScript ).pipe(fs.createWriteStream( projectScript ));
        } catch(e) {
          console.log( ('addCliHookDirectory fs.createReadStream: ' + e).error );
          return;
        }
      }

      // make the script file executable
      try {
        fs.chmodSync(projectScript, '755');
      } catch(e) {
        console.log( ('addCliHookDirectory fs.chmodSync: ' + e).error );
      }

    } catch(e) {
      console.log('Error adding hook script ' + hookDirectoryName + '/' + hookFilename + ', ' + e);
    }
  }

};

exports.IonicTask = IonicTask;
<commit_msg>sync writing hook<commit_after>var Task = require('./task').Task,
    IonicStats = require('./stats').IonicStats,
    fs = require('fs'),
    Q = require('q'),
    argv = require('optimist').argv,
    xml2js = require('xml2js'),
    path = require('path'),
    exec = require('child_process').exec,
    colors = require('colors');

var IonicTask = function() {};

IonicTask.prototype = new Task();

IonicTask.prototype.run = function(ionic) {
  this.ionic = ionic;
  var self = this;
  var cmdName = process.argv[2].toLowerCase();
  var q;

  this.isLiveReload = ((cmdName == 'run' || cmdName == 'emulate') && (argv.livereload || argv['live-reload'] || argv.l));

  if(this.isLiveReload) {
    q = self.setupLiveReload();

  } else {
    // ensure the content node was set back to its original
    q = this.setConfigXml({
      resetContent: true,
      errorWhenNotFound: false
    });
  }

  q.then(function(){
    self.runCordova(cmdName);
  })
};

IonicTask.prototype.runCordova = function(cmdName) {
  var self = this;
  var cmdArgs = (process.argv.length > 3 ? process.argv.slice(3) : []);
  var cmdArg, x, y;

  // backwards compatibility prior to fully wrapping cordova cmds
  if(cmdName == 'platform') {
    // `ionic platform <PLATFORM>` used to actually run `ionic platform add <PLATFORM>`
    // if a cordova platform cmd isn't the cmd then automatically insert `add`
    var hasCordovaCmd = false;
    var validCommands = 'add remove rm list ls update up check'.split(' ');
    for(x=0; x<cmdArgs.length; x++) {
      cmdArg = cmdArgs[x].toLowerCase();
      for(y=0; y<validCommands.length; y++) {
        if(cmdArg == validCommands[y]) {
          hasCordovaCmd = true;
          break;
        }
      }
    }
    if(!hasCordovaCmd) {
      cmdArgs.unshift('add');
    }
  }
  this.addHooks();

  cmdArgs.unshift(cmdName);

  // clean out any cmds that may confuse cordova
  var cleanArgs = [];
  var port = argv.port || argv.p || '';
  var liveReloadPort = argv.livereloadport || argv['livereload-port'] || argv.i || '';
  var ignoreCmds = '--livereload -l --consolelogs -c --serverlogs -s --port -p --livereload-port -i'.split(' ');
  var isValdCmd;
  for(x=0; x<cmdArgs.length; x++) {
    cmdArg = cmdArgs[x];
    if(port && cmdArg == port) continue;
    if(liveReloadPort && cmdArg == liveReloadPort) continue;
    isValdCmd = true;
    for(y=0; y<ignoreCmds.length; y++) {
      if(cmdArg == ignoreCmds[y]) {
        isValdCmd = false;
        break;
      }
    }
    if(isValdCmd) {
      cleanArgs.push(cmdArg);
    }
  }

  var cordovaProcess = exec('cordova ' + cleanArgs.join(' '));

  cordovaProcess.stdout.on('data', function (data) {
    process.stdout.write(data);
  });

  cordovaProcess.stderr.on('data', function (data) {
    if(data) {
      process.stderr.write(data.toString().error.bold);
    }
  });

  if(self.isLiveReload) {
    cordovaProcess.on('exit', function(){
      setTimeout(function(){
        // set it back to the original src after a few seconds
        self.setConfigXml({
          resetContent: true,
          errorWhenNotFound: true
        });
      }, 5000);
    });

    process.on('exit', function(){
      // verify it was set back
      self.setConfigXml({
        resetContent: true,
        errorWhenNotFound: false
      });
    });
  }

  IonicStats.t();
};

IonicTask.prototype.setupLiveReload = function() {
  var d = Q.defer();

  console.log('Setup Live Reload'.green.bold);

  var self = this;
  var serve = new require('./serve');
  var serveTask = new serve.IonicTask();
  serveTask.ionic = this.ionic;
  serveTask.isPlatformServe = true;

  serveTask.loadSettings(function(){
    serveTask.runLivereload = true;
    serveTask.launchBrowser = false;
    serveTask.start(self.ionic);

    self.setConfigXml({
      devServer: serveTask.devServer
    }).then(function(){
      d.resolve();
    });

  });

  return d.promise;
};

IonicTask.prototype.setConfigXml = function(options) {
  var d = Q.defer();

  var self = this;
  var madeChange = false;

  try {
    var configXmlPath = path.resolve('config.xml');

    if(!fs.existsSync(configXmlPath)) {
      // working directory does not have the config.xml file
      if(options.errorWhenNotFound) {
        d.reject('Unable to locate config.xml file. Please ensure the working directory is at the root of the app where the config.xml should be located.');
      } else {
        d.resolve();
      }
      return d.promise;
    }

    var configString = fs.readFileSync(configXmlPath, { encoding: 'utf8' });

    var parseString = xml2js.parseString;
    parseString(configString, function (err, jsonConfig) {
      if(err) {
        d.reject(err);
        return self.ionic.fail('Error parsing ' + configXmlPath + ': ' + err);
      }

      if(options.devServer) {
        if( !jsonConfig.widget.content[0].$['original-src'] ) {
          jsonConfig.widget.content[0].$['original-src'] = jsonConfig.widget.content[0].$.src;
          madeChange = true;
        }
        if(jsonConfig.widget.content[0].$.src !== options.devServer) {
          jsonConfig.widget.content[0].$.src = options.devServer;
          madeChange = true;
        }

      } else if(options.resetContent) {

        if( jsonConfig.widget.content[0].$['original-src'] ) {
          jsonConfig.widget.content[0].$.src = jsonConfig.widget.content[0].$['original-src'];
          delete jsonConfig.widget.content[0].$['original-src'];
          madeChange = true;
        }
      }

      if(madeChange) {
        var xmlBuilder = new xml2js.Builder();
        configString = xmlBuilder.buildObject(jsonConfig);
        fs.writeFileSync(configXmlPath, configString);
      }

      d.resolve();
    });

  } catch(e) {
    d.reject(e);
    self.ionic.fail('Error updating ' + configXmlPath + ': ' + e);
  }

  return d.promise;
};

IonicTask.prototype.addHooks = function() {
  // Add hooks which this Ionic project doesn't already have
  // note: hook scripts must be executable!

  if( !fs.existsSync(path.join('www')) ) {
    // don't both doing any of this if they aren't
    // in the correct working directory, which would have `www`
    return;
  }

  // loop through all the hook directories added to the ionic-cli
  var cliHooksPath = path.join(__filename, '../../hooks');
  fs.readdir(cliHooksPath, function(err, files){
    if(err) return;
    for(var x=0; x<files.length; x++) {
      if(files[x].indexOf('.') > -1) continue;
      addCliHookDirectory( path.join(cliHooksPath, files[x]), files[x] );
    }
  });

  function addCliHookDirectory(cliHookPath, hookDirectoryName) {
    fs.readdir(cliHookPath, function(err, files){
      // loop through each of the scripts in the ionic-cli hook directory
      if(err) return;
      for(var x=0; x<files.length; x++) {
        var hookFilename = files[x];
        if(hookFilename.indexOf('.js') === -1) return;

        // check if this hook script has already been added to this ionic project
        var projectHookPath = path.join('hooks', hookDirectoryName, hookFilename);
        addHookScript(cliHookPath, hookDirectoryName, hookFilename);
      }
    });
  }

  function addHookScript(cliHookPath, hookDirectoryName, hookFilename) {
    // add the root hooks directory if the project doesn't have it
    try {
      var projectHookPath = path.join('hooks');
      if( !fs.existsSync(projectHookPath) ) {
        fs.mkdirSync(projectHookPath);
      }

      // add the hook directory (ie: after_prepare) if the project doesn't have it
      projectHookPath = path.join(projectHookPath, hookDirectoryName);
      if( !fs.existsSync(projectHookPath) ) {
        fs.mkdirSync(projectHookPath);
      }

      var projectScript = path.join(projectHookPath, hookFilename);
      if( !fs.existsSync(projectHookPath) ) {
        // copy the hook script to the project
        try {
          var cliScript = path.join(cliHookPath, hookFilename);
          fs.writeFileSync(projectScript, fs.readFileSync(cliScript));
        } catch(e) {
          console.log( ('addCliHookDirectory fs.createReadStream: ' + e).error );
          return;
        }
      }

      // make the script file executable
      try {
        fs.chmodSync(projectScript, '755');
      } catch(e) {
        console.log( ('addCliHookDirectory fs.chmodSync: ' + e).error );
      }

    } catch(e) {
      console.log('Error adding hook script ' + hookDirectoryName + '/' + hookFilename + ', ' + e);
    }
  }

};

exports.IonicTask = IonicTask;
<|endoftext|>


<commit_before>/*
* @cache: 数据存储
* 缓存类型分为:
* 1.Store: 渲染表格时所使用的json数据 [存储在GM实例]
* 2.UserMemory: 用户记忆 [存储在localStorage]
* */
import jTool from './jTool';
import base from './base';
import { Settings, TextSettings } from './Settings';
import store from './Store';
import { TR_CACHE_KEY, CACHE_ERROR_KEY, MEMORY_KEY, VERSION_KEY } from './constants';

class Cache {
    /**
     * 版本信息
     * @returns {*}
     */
    getVersion() {
        return store.version;
    }

    /**
     * 获取当前GM所在的域
     * @param gridManagerName
     * @returns {*}
     */
    getScope(gridManagerName) {
        return store.scope[gridManagerName];
    }

    /**
     * 存储当前GM所在的域, 当前
     * @param gridManagerName
     * @param scope
     */
    setScope(gridManagerName, scope) {
        store.scope[gridManagerName] = scope;
    }

    /**
     * 获取当前行使用的数据
     * @param gridManagerName
     * @param target 将要获取数据所对应的tr[Element or NodeList]
     * @returns {*}
     */
    getRowData(gridManagerName, target) {
        const tableData = this.getTableData(gridManagerName);
        // target type = Element 元素时, 返回单条数据对象;
        if (jTool.type(target) === 'element') {
            return tableData[target.getAttribute(TR_CACHE_KEY)];
        }

        // target type =  NodeList 类型时, 返回数组
        if (jTool.type(target) === 'nodeList') {
            let rodData = [];
            jTool.each(target, function (i, v) {
                rodData.push(tableData[v.getAttribute(TR_CACHE_KEY)]);
            });
            return rodData;
        }

        // 不为Element 和 NodeList时, 返回空对象
        return {};
    }

    /**
     * 更新列数据
     * @param gridManagerName
     * @param key: 列数据的主键
     * @param rowDataList: 需要更新的数据列表
     * @returns tableData: 更新后的表格数据
     */
    updateRowData(gridManagerName, key, rowDataList) {
        const tableData = this.getTableData(gridManagerName);
        tableData.forEach(item => {
            rowDataList.forEach(newItem => {
                if (newItem[key] === item[key]) {
                    jTool.extend(item, newItem);
                }
            });
        });
        // 存储表格数据
        this.setTableData(gridManagerName, tableData);
        return tableData;
    }

    /**
     * 获取表格数据
     * @param gridManagerName
     */
    getTableData(gridManagerName) {
        return store.responseData[gridManagerName] || [];
    }

    /**
     * 存储表格数据
     * @param gridManagerName
     * @param data
     */
    setTableData(gridManagerName, data) {
        store.responseData[gridManagerName] = data;
    }

    /**
     * 获取选中的数据
     * @param gridManagerName
     * @returns {*|Array}
     */
    getCheckedData(gridManagerName) {
        const checkedList = store.checkedData[gridManagerName] || [];

        // 返回clone后的数组，以防止在外部操作导致数据错误。
        return checkedList.map(item => jTool.extend(true, {}, item));
    }

    /**
     * 设置选中的数据: 覆盖操作，会将原有的选中值清除
     * @param gridManagerName
     * @param dataList: 数据列表， isClear===true时该项只能为选中的数据
     * @param isClear: 是否清空原有的选中项 (该参数不公开)
     */
    setCheckedData(gridManagerName, dataList, isClear) {
        const { columnMap } = this.getSettings(gridManagerName);
        // 覆盖操作，清空原有的选中数据。 并且 dataList 将会按选中状态进行处理
        if (isClear) {
            store.checkedData[gridManagerName] = dataList.map(item => base.getCloneRowData(columnMap, item));
            return;
        }

        // 合并操作，不清空原有的选中数据
        if (!store.checkedData[gridManagerName]) {
            store.checkedData[gridManagerName] = [];
        }
        const tableCheckedList = store.checkedData[gridManagerName];

        dataList.forEach(item => {
            let cloneObj = base.getCloneRowData(columnMap, item);
            let checked = item['gm_checkbox'];
            let index = base.getObjectIndexToArray(tableCheckedList, cloneObj);

            // 新增: 已选中 且 未存储
            if (checked && index === -1) {
                tableCheckedList.push(cloneObj);
                return;
            }

            // 删除: 未选中 且 已存储
            if (!checked && index !== -1) {
                tableCheckedList.splice(index, 1);
            }
        });
    }

    /**
     * 更新选中的数据
     * @param gridManagerName
     * @param columnMap
     * @param key
     * @param rowDataList
     */
    updateCheckedData(gridManagerName, columnMap, key, rowDataList) {
        if (!store.checkedData[gridManagerName]) {
            return;
        }
        store.checkedData[gridManagerName] = store.checkedData[gridManagerName].map(item => {
            rowDataList.forEach(newItem => {
                if (item[key] === newItem[key]) {
                    jTool.extend(item, base.getCloneRowData(columnMap, newItem));
                }
            });
            return item;
        });
    }

    /**
     * 获取表格的用户记忆标识码
     * @param gridManagerName
     * @returns {*}
     */
    getMemoryKey(gridManagerName) {
        return window.location.pathname + window.location.hash + '-' + gridManagerName;
    }

    /**
     * 获取用户记忆
     * @param gridManagerName
     * @returns {*} 成功则返回本地存储数据,失败则返回空对象
     */
    getUserMemory(gridManagerName) {
        const memoryKey = this.getMemoryKey(gridManagerName);

        let GridManagerMemory = window.localStorage.getItem(MEMORY_KEY);
        // 如无数据，增加缓存错误标识
        if (!GridManagerMemory || GridManagerMemory === '{}') {
            base.getTable(gridManagerName).attr(CACHE_ERROR_KEY, 'error');
            return {};
        }
        GridManagerMemory = JSON.parse(GridManagerMemory);
        return JSON.parse(GridManagerMemory[memoryKey] || '{}');
    }

    /**
     * 存储用户记忆
     * @param settings
     * @returns {boolean}
     */
    saveUserMemory(settings) {
        const { disableCache, gridManagerName, columnMap, supportAjaxPage, pageData, pageSizeKey } = settings;
        // 当前为禁用缓存模式，直接跳出
        if (disableCache) {
            return;
        }

        let _cache = {};
        _cache.column = columnMap;

        // 存储分页
        if (supportAjaxPage) {
	        const _pageCache = {};
            _pageCache[pageSizeKey] = pageData[pageSizeKey];
            _cache.page = _pageCache;
        }

        // 注意: 这行代码会将columnMap中以下情况的字段清除:
        // 1.函数类型的模板
        // 2.值为undefined
        const cacheString = JSON.stringify(_cache);
        let GridManagerMemory = window.localStorage.getItem(MEMORY_KEY);
        if (!GridManagerMemory) {
            GridManagerMemory = {};
        } else {
            GridManagerMemory = JSON.parse(GridManagerMemory);
        }
        GridManagerMemory[this.getMemoryKey(gridManagerName)] = cacheString;
        window.localStorage.setItem(MEMORY_KEY, JSON.stringify(GridManagerMemory));
    }

    /**
     * 删除用户记忆
     * @param gridManagerName
     * @returns {boolean}
     */
    delUserMemory(gridManagerName) {
        // 如果未指定删除的table, 则全部清除
        if (!gridManagerName) {
            window.localStorage.removeItem(MEMORY_KEY);
            base.outInfo('delete user memory of all');
            return true;
        }

        let GridManagerMemory = window.localStorage.getItem(MEMORY_KEY);
        if (!GridManagerMemory) {
            return false;
        }
        GridManagerMemory = JSON.parse(GridManagerMemory);

        // 指定删除的table, 则定点清除
        const _key = this.getMemoryKey(gridManagerName);
        delete GridManagerMemory[_key];

        // 清除后, 重新存储
        window.localStorage.setItem(MEMORY_KEY, JSON.stringify(GridManagerMemory));
        base.outInfo(`delete user memory of ${gridManagerName}`);
        return true;
    }

    /**
     * 初始化设置相关: 合并, 存储
     * @param arg
     * @param checkboxColumnFn
     * @param orderColumnFn
     */
    initSettings(arg, checkboxColumnFn, orderColumnFn) {
        // 合并参数
        const settings = new Settings();
        settings.textConfig = new TextSettings();
        jTool.extend(true, settings, arg);

        // 存储初始配置项
        this.setSettings(settings);

        // 为 columnData 增加 序号列
        if (settings.supportAutoOrder) {
            settings.columnData.unshift(orderColumnFn(settings));
        }

        // 为 columnData 增加 选择列
        if (settings.supportCheckbox) {
            settings.columnData.unshift(checkboxColumnFn(settings));
        }

        // 为 columnData 提供锚 => columnMap
        // columnData 在此之后, 将不再被使用到
        // columnData 与 columnMap 已经过特殊处理, 不会彼此影响
        const columnMap = {};
        // const columnLeftMap = {};
        // const columnRightMap = {};

        let isError = false;
        settings.columnData.forEach((col, index) => {
            if (!col.key) {
                base.outError(`columnData[${index}].key undefined`);
                isError = true;
                return;
            }
            columnMap[col.key] = col;

            // 如果未设定, 设置默认值为true
            columnMap[col.key].isShow = col.isShow || typeof (col.isShow) === 'undefined';

            // 为列Map 增加索引
            columnMap[col.key].index = index;

            // 存储由用户配置的列宽度值, 该值不随着之后的操作变更
            columnMap[col.key].__width = col.width;

            // 存储由用户配置的列显示状态, 该值不随着之后的操作变更
            columnMap[col.key].__isShow = col.isShow;

            // TODO fixed 暂时先不做
            // if (col.fixed === 'left') {
            //     columnLeftMap[col.key] = Object.assign(columnMap[col.key], {disableCustomize: true});
            // }
            // if (col.fixed === 'right') {
            //     columnRightMap[col.key] = Object.assign(columnMap[col.key], {disableCustomize: true});
            // }
        });
        if (isError) {
            return false;
        }
        settings.columnMap = columnMap;

        // 合并用户记忆至 settings, 每页显示条数记忆不在此处
        const mergeUserMemory = () => {
            // 当前为禁用状态
            if (settings.disableCache) {
                return;
            }

            const { gridManagerName, columnMap } = settings;
            const userMemory = this.getUserMemory(gridManagerName);
            const columnCache = userMemory.column || {};
            const columnCacheKeys = Object.keys(columnCache);
            const columnMapKeys = Object.keys(columnMap);

            // 无用户记忆
            if (columnCacheKeys.length === 0) {
                return;
            }

            // 是否为有效的用户记忆
            let isUsable = true;

            // 与用户记忆数量不匹配
            if (columnCacheKeys.length !== columnMapKeys.length) {
                isUsable = false;
            }

            // 与用户记忆项不匹配
            isUsable && jTool.each(columnMap, (key, col) => {
                if (!columnCache[key]
                    // 显示文本
                    || columnCache[key].text !== col.text

                    // 宽度
                    || columnCache[key].__width !== col.width

                    // 显示状态
                    || columnCache[key].__isShow !== col.isShow

                    // 文本排列方向
                    || columnCache[key].align !== col.align

                    // 数据排序
                    || columnCache[key].sorting !== col.sorting

                    // 字段描述
                    || columnCache[key].remind !== col.remind

                    // 禁止使用个性配置功能
                    || columnCache[key].disableCustomize !== col.disableCustomize

                    || JSON.stringify(columnCache[key].filter) !== JSON.stringify(col.filter)

                    // 字段模版: 仅对非函数类型的模板进行验证
                    || (columnCache[key].template && columnCache[key].template !== col.template)) {
                    isUsable = false;
                    return false;
                }
            });

            // 将用户记忆并入 columnMap 内
            if (isUsable) {
                jTool.extend(true, columnMap, columnCache);
            } else {
                // 清除用户记忆
                this.delUserMemory(gridManagerName);
            }
        };

        // 合并用户记忆
        mergeUserMemory();

        // 更新存储配置项
        this.setSettings(settings);
        return settings;
    }

    /**
     * 获取配置项
     * @param gridManagerName
     * @returns {*}
     */
    getSettings(gridManagerName) {
        // 返回的是 clone 对象 而非对象本身
        return jTool.extend(true, {}, store.settings[gridManagerName] || {});
    }

    /**
     * 重置配置项
     * @param settings
     */
    setSettings(settings) {
        store.settings[settings.gridManagerName] = jTool.extend(true, {}, settings);
    }

    /**
     * 更新Cache, 包含[更新表格列Map, 重置settings, 存储用户记忆]
     * @param gridManagerName
     */
    update(gridManagerName) {
        const settings = this.getSettings(gridManagerName);
        const columnMap = settings.columnMap;

        // 更新 columnMap , 适用操作[宽度调整, 位置调整, 可视状态调整]
        jTool.each(columnMap, (key, col) => {
            // 禁用定制列: 不处理
            if (col.disableCustomize) {
                return;
            }

            let th = base.getTh(gridManagerName, col.key);
            // 宽度
            col.width = th.width() + 'px';

            // 位置索引
            col.index = th.index();

            // 可视状态
            col.isShow = th.attr('th-visible') === 'visible';
        });

        // 重置settings
        this.setSettings(settings);

        // 存储用户记忆
        this.saveUserMemory(settings);

        return settings;
    }

    /**
     * 验证版本号,如果版本号变更清除用户记忆
     */
    verifyVersion() {
        const cacheVersion = window.localStorage.getItem(VERSION_KEY);
        // 当前为第一次渲染, 存储版本号
        if (!cacheVersion) {
            window.localStorage.setItem(VERSION_KEY, store.version);
        }
        // 版本变更, 清除所有的用户记忆
        if (cacheVersion && cacheVersion !== store.version) {
            this.delUserMemory();
            window.localStorage.setItem(VERSION_KEY, store.version);
        }
    }

    /**
     * 销毁实例store
     * @param gridManagerName
     */
    clear(gridManagerName) {
        delete store.scope[gridManagerName];
        delete store.responseData[gridManagerName];
        delete store.checkedData[gridManagerName];
        delete store.settings[gridManagerName];
    }
}
export default new Cache();
<commit_msg>optimization: col.disableCustomize of true, checked col.width<commit_after>/*
* @cache: 数据存储
* 缓存类型分为:
* 1.Store: 渲染表格时所使用的json数据 [存储在GM实例]
* 2.UserMemory: 用户记忆 [存储在localStorage]
* */
import jTool from './jTool';
import base from './base';
import { Settings, TextSettings } from './Settings';
import store from './Store';
import { TR_CACHE_KEY, CACHE_ERROR_KEY, MEMORY_KEY, VERSION_KEY } from './constants';

class Cache {
    /**
     * 版本信息
     * @returns {*}
     */
    getVersion() {
        return store.version;
    }

    /**
     * 获取当前GM所在的域
     * @param gridManagerName
     * @returns {*}
     */
    getScope(gridManagerName) {
        return store.scope[gridManagerName];
    }

    /**
     * 存储当前GM所在的域, 当前
     * @param gridManagerName
     * @param scope
     */
    setScope(gridManagerName, scope) {
        store.scope[gridManagerName] = scope;
    }

    /**
     * 获取当前行使用的数据
     * @param gridManagerName
     * @param target 将要获取数据所对应的tr[Element or NodeList]
     * @returns {*}
     */
    getRowData(gridManagerName, target) {
        const tableData = this.getTableData(gridManagerName);
        // target type = Element 元素时, 返回单条数据对象;
        if (jTool.type(target) === 'element') {
            return tableData[target.getAttribute(TR_CACHE_KEY)];
        }

        // target type =  NodeList 类型时, 返回数组
        if (jTool.type(target) === 'nodeList') {
            let rodData = [];
            jTool.each(target, function (i, v) {
                rodData.push(tableData[v.getAttribute(TR_CACHE_KEY)]);
            });
            return rodData;
        }

        // 不为Element 和 NodeList时, 返回空对象
        return {};
    }

    /**
     * 更新列数据
     * @param gridManagerName
     * @param key: 列数据的主键
     * @param rowDataList: 需要更新的数据列表
     * @returns tableData: 更新后的表格数据
     */
    updateRowData(gridManagerName, key, rowDataList) {
        const tableData = this.getTableData(gridManagerName);
        tableData.forEach(item => {
            rowDataList.forEach(newItem => {
                if (newItem[key] === item[key]) {
                    jTool.extend(item, newItem);
                }
            });
        });
        // 存储表格数据
        this.setTableData(gridManagerName, tableData);
        return tableData;
    }

    /**
     * 获取表格数据
     * @param gridManagerName
     */
    getTableData(gridManagerName) {
        return store.responseData[gridManagerName] || [];
    }

    /**
     * 存储表格数据
     * @param gridManagerName
     * @param data
     */
    setTableData(gridManagerName, data) {
        store.responseData[gridManagerName] = data;
    }

    /**
     * 获取选中的数据
     * @param gridManagerName
     * @returns {*|Array}
     */
    getCheckedData(gridManagerName) {
        const checkedList = store.checkedData[gridManagerName] || [];

        // 返回clone后的数组，以防止在外部操作导致数据错误。
        return checkedList.map(item => jTool.extend(true, {}, item));
    }

    /**
     * 设置选中的数据: 覆盖操作，会将原有的选中值清除
     * @param gridManagerName
     * @param dataList: 数据列表， isClear===true时该项只能为选中的数据
     * @param isClear: 是否清空原有的选中项 (该参数不公开)
     */
    setCheckedData(gridManagerName, dataList, isClear) {
        const { columnMap } = this.getSettings(gridManagerName);
        // 覆盖操作，清空原有的选中数据。 并且 dataList 将会按选中状态进行处理
        if (isClear) {
            store.checkedData[gridManagerName] = dataList.map(item => base.getCloneRowData(columnMap, item));
            return;
        }

        // 合并操作，不清空原有的选中数据
        if (!store.checkedData[gridManagerName]) {
            store.checkedData[gridManagerName] = [];
        }
        const tableCheckedList = store.checkedData[gridManagerName];

        dataList.forEach(item => {
            let cloneObj = base.getCloneRowData(columnMap, item);
            let checked = item['gm_checkbox'];
            let index = base.getObjectIndexToArray(tableCheckedList, cloneObj);

            // 新增: 已选中 且 未存储
            if (checked && index === -1) {
                tableCheckedList.push(cloneObj);
                return;
            }

            // 删除: 未选中 且 已存储
            if (!checked && index !== -1) {
                tableCheckedList.splice(index, 1);
            }
        });
    }

    /**
     * 更新选中的数据
     * @param gridManagerName
     * @param columnMap
     * @param key
     * @param rowDataList
     */
    updateCheckedData(gridManagerName, columnMap, key, rowDataList) {
        if (!store.checkedData[gridManagerName]) {
            return;
        }
        store.checkedData[gridManagerName] = store.checkedData[gridManagerName].map(item => {
            rowDataList.forEach(newItem => {
                if (item[key] === newItem[key]) {
                    jTool.extend(item, base.getCloneRowData(columnMap, newItem));
                }
            });
            return item;
        });
    }

    /**
     * 获取表格的用户记忆标识码
     * @param gridManagerName
     * @returns {*}
     */
    getMemoryKey(gridManagerName) {
        return window.location.pathname + window.location.hash + '-' + gridManagerName;
    }

    /**
     * 获取用户记忆
     * @param gridManagerName
     * @returns {*} 成功则返回本地存储数据,失败则返回空对象
     */
    getUserMemory(gridManagerName) {
        const memoryKey = this.getMemoryKey(gridManagerName);

        let GridManagerMemory = window.localStorage.getItem(MEMORY_KEY);
        // 如无数据，增加缓存错误标识
        if (!GridManagerMemory || GridManagerMemory === '{}') {
            base.getTable(gridManagerName).attr(CACHE_ERROR_KEY, 'error');
            return {};
        }
        GridManagerMemory = JSON.parse(GridManagerMemory);
        return JSON.parse(GridManagerMemory[memoryKey] || '{}');
    }

    /**
     * 存储用户记忆
     * @param settings
     * @returns {boolean}
     */
    saveUserMemory(settings) {
        const { disableCache, gridManagerName, columnMap, supportAjaxPage, pageData, pageSizeKey } = settings;
        // 当前为禁用缓存模式，直接跳出
        if (disableCache) {
            return;
        }

        let _cache = {};
        _cache.column = columnMap;

        // 存储分页
        if (supportAjaxPage) {
	        const _pageCache = {};
            _pageCache[pageSizeKey] = pageData[pageSizeKey];
            _cache.page = _pageCache;
        }

        // 注意: 这行代码会将columnMap中以下情况的字段清除:
        // 1.函数类型的模板
        // 2.值为undefined
        const cacheString = JSON.stringify(_cache);
        let GridManagerMemory = window.localStorage.getItem(MEMORY_KEY);
        if (!GridManagerMemory) {
            GridManagerMemory = {};
        } else {
            GridManagerMemory = JSON.parse(GridManagerMemory);
        }
        GridManagerMemory[this.getMemoryKey(gridManagerName)] = cacheString;
        window.localStorage.setItem(MEMORY_KEY, JSON.stringify(GridManagerMemory));
    }

    /**
     * 删除用户记忆
     * @param gridManagerName
     * @returns {boolean}
     */
    delUserMemory(gridManagerName) {
        // 如果未指定删除的table, 则全部清除
        if (!gridManagerName) {
            window.localStorage.removeItem(MEMORY_KEY);
            base.outInfo('delete user memory of all');
            return true;
        }

        let GridManagerMemory = window.localStorage.getItem(MEMORY_KEY);
        if (!GridManagerMemory) {
            return false;
        }
        GridManagerMemory = JSON.parse(GridManagerMemory);

        // 指定删除的table, 则定点清除
        const _key = this.getMemoryKey(gridManagerName);
        delete GridManagerMemory[_key];

        // 清除后, 重新存储
        window.localStorage.setItem(MEMORY_KEY, JSON.stringify(GridManagerMemory));
        base.outInfo(`delete user memory of ${gridManagerName}`);
        return true;
    }

    /**
     * 初始化设置相关: 合并, 存储
     * @param arg
     * @param checkboxColumnFn
     * @param orderColumnFn
     */
    initSettings(arg, checkboxColumnFn, orderColumnFn) {
        // 合并参数
        const settings = new Settings();
        settings.textConfig = new TextSettings();
        jTool.extend(true, settings, arg);

        // 存储初始配置项
        this.setSettings(settings);

        // 为 columnData 增加 序号列
        if (settings.supportAutoOrder) {
            settings.columnData.unshift(orderColumnFn(settings));
        }

        // 为 columnData 增加 选择列
        if (settings.supportCheckbox) {
            settings.columnData.unshift(checkboxColumnFn(settings));
        }

        // 为 columnData 提供锚 => columnMap
        // columnData 在此之后, 将不再被使用到
        // columnData 与 columnMap 已经过特殊处理, 不会彼此影响
        const columnMap = {};

        let isError = false;
        settings.columnData.forEach((col, index) => {
            // key字段不允许为空
            if (!col.key) {
                base.outError(`columnData[${index}].key undefined`);
                isError = true;
                return;
            }

            // disableCustomize存在时，必须设置width
            if (col.disableCustomize && !col.width) {
                base.outError(`column ${col.key}: when disableCustomize exists, width must be set`);
                isError = true;
                return;
            }
            columnMap[col.key] = col;

            // 如果未设定, 设置默认值为true
            columnMap[col.key].isShow = col.isShow || typeof (col.isShow) === 'undefined';

            // 为列Map 增加索引
            columnMap[col.key].index = index;

            // 存储由用户配置的列宽度值, 该值不随着之后的操作变更
            columnMap[col.key].__width = col.width;

            // 存储由用户配置的列显示状态, 该值不随着之后的操作变更
            columnMap[col.key].__isShow = col.isShow;
        });
        if (isError) {
            return false;
        }
        settings.columnMap = columnMap;

        // 合并用户记忆至 settings, 每页显示条数记忆不在此处
        const mergeUserMemory = () => {
            // 当前为禁用状态
            if (settings.disableCache) {
                return;
            }

            const { gridManagerName, columnMap } = settings;
            const userMemory = this.getUserMemory(gridManagerName);
            const columnCache = userMemory.column || {};
            const columnCacheKeys = Object.keys(columnCache);
            const columnMapKeys = Object.keys(columnMap);

            // 无用户记忆
            if (columnCacheKeys.length === 0) {
                return;
            }

            // 是否为有效的用户记忆
            let isUsable = true;

            // 与用户记忆数量不匹配
            if (columnCacheKeys.length !== columnMapKeys.length) {
                isUsable = false;
            }

            // 与用户记忆项不匹配
            isUsable && jTool.each(columnMap, (key, col) => {
                if (!columnCache[key]
                    // 显示文本
                    || columnCache[key].text !== col.text

                    // 宽度
                    || columnCache[key].__width !== col.width

                    // 显示状态
                    || columnCache[key].__isShow !== col.isShow

                    // 文本排列方向
                    || columnCache[key].align !== col.align

                    // 数据排序
                    || columnCache[key].sorting !== col.sorting

                    // 字段描述
                    || columnCache[key].remind !== col.remind

                    // 禁止使用个性配置功能
                    || columnCache[key].disableCustomize !== col.disableCustomize

                    || JSON.stringify(columnCache[key].filter) !== JSON.stringify(col.filter)

                    // 字段模版: 仅对非函数类型的模板进行验证
                    || (columnCache[key].template && columnCache[key].template !== col.template)) {
                    isUsable = false;
                    return false;
                }
            });

            // 将用户记忆并入 columnMap 内
            if (isUsable) {
                jTool.extend(true, columnMap, columnCache);
            } else {
                // 清除用户记忆
                this.delUserMemory(gridManagerName);
            }
        };

        // 合并用户记忆
        mergeUserMemory();

        // 更新存储配置项
        this.setSettings(settings);
        return settings;
    }

    /**
     * 获取配置项
     * @param gridManagerName
     * @returns {*}
     */
    getSettings(gridManagerName) {
        // 返回的是 clone 对象 而非对象本身
        return jTool.extend(true, {}, store.settings[gridManagerName] || {});
    }

    /**
     * 重置配置项
     * @param settings
     */
    setSettings(settings) {
        store.settings[settings.gridManagerName] = jTool.extend(true, {}, settings);
    }

    /**
     * 更新Cache, 包含[更新表格列Map, 重置settings, 存储用户记忆]
     * @param gridManagerName
     */
    update(gridManagerName) {
        const settings = this.getSettings(gridManagerName);
        const columnMap = settings.columnMap;

        // 更新 columnMap , 适用操作[宽度调整, 位置调整, 可视状态调整]
        jTool.each(columnMap, (key, col) => {
            // 禁用定制列: 不处理
            if (col.disableCustomize) {
                return;
            }

            let th = base.getTh(gridManagerName, col.key);
            // 宽度
            col.width = th.width() + 'px';

            // 位置索引
            col.index = th.index();

            // 可视状态
            col.isShow = th.attr('th-visible') === 'visible';
        });

        // 重置settings
        this.setSettings(settings);

        // 存储用户记忆
        this.saveUserMemory(settings);

        return settings;
    }

    /**
     * 验证版本号,如果版本号变更清除用户记忆
     */
    verifyVersion() {
        const cacheVersion = window.localStorage.getItem(VERSION_KEY);
        // 当前为第一次渲染, 存储版本号
        if (!cacheVersion) {
            window.localStorage.setItem(VERSION_KEY, store.version);
        }
        // 版本变更, 清除所有的用户记忆
        if (cacheVersion && cacheVersion !== store.version) {
            this.delUserMemory();
            window.localStorage.setItem(VERSION_KEY, store.version);
        }
    }

    /**
     * 销毁实例store
     * @param gridManagerName
     */
    clear(gridManagerName) {
        delete store.scope[gridManagerName];
        delete store.responseData[gridManagerName];
        delete store.checkedData[gridManagerName];
        delete store.settings[gridManagerName];
    }
}
export default new Cache();
<|endoftext|>


<commit_before>/**
 * `modules/idea-hub` data store: idea-state.
 *
 * Site Kit by Google, Copyright 2021 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * External dependencies
 */
import invariant from 'invariant';

/**
 * Internal dependencies
 */
import API from 'googlesitekit-api';
import Data from 'googlesitekit-data';
import { createFetchStore } from '../../../googlesitekit/data/create-fetch-store';
import { actions as errorStoreActions } from '../../../googlesitekit/data/create-error-store';
const { receiveError, clearError } = errorStoreActions;

const fetchPostUpdateIdeaStateStore = createFetchStore( {
	baseName: 'updateIdeaState',
	controlCallback: ( { name, saved, dismissed } ) => {
		const params = { name };

		if ( saved !== undefined ) {
			params.saved = saved;
		} else {
			params.dismissed = dismissed;
		}
		return API.set( 'modules', 'idea-hub', 'update-idea-state', params );
	},
	argsToParams( { name, saved, dismissed } ) {
		return { name, saved, dismissed };
	},
	validateParams( { name, saved, dismissed } = {} ) {
		invariant( typeof name === 'string' && name.length > 0, 'name must be a non empty string' );
		invariant( saved !== undefined || dismissed !== undefined, 'either saved or dimissed property must be set' );
	},
	reducerCallback: ( state, idea ) => {
		if ( idea.dismissed === true ) {
			return {
				...state,
				newIdeas: ( state.newIdeas || [] ).filter( ( { name } ) => name !== idea.name ),
			};
		}

		if ( idea.saved === true ) {
			const ideaDetails = ( state.newIdeas || [] ).filter( ( { name } ) => name === idea.name );

			if ( ! ideaDetails.length ) {
				return state;
			}

			return {
				...state,
				newIdeas: ( state.newIdeas || [] ).filter( ( { name } ) => name !== idea.name ),
				savedIdeas: [ ...( state.savedIdeas || [] ), ...ideaDetails ],
			};
		}

		if ( idea.saved === false ) {
			return {
				...state,
				savedIdeas: ( state.savedIdeas || [] ).filter( ( { name } ) => name !== idea.name ),
			};
		}
	},
} );

const baseInitialState = {};

const baseActions = {
	/**
	 * Updates a given Idea's state.
	 *
	 * @since 1.36.0
	 *
	 * @param {Object}  ideaState           Idea Hub Idea state.
	 * @param {string}  ideaState.name      Idea Hub Idea name.
	 * @param {boolean} ideaState.saved     Whether the Idea is saved [optional].
	 * @param {boolean} ideaState.dismissed Whether the Idea is dismissed [optional].
	 * @return {Object} Object with `response` and `error`.
	 */
	*updateIdeaState( ideaState ) {
		const response = yield fetchPostUpdateIdeaStateStore.actions.fetchUpdateIdeaState( ideaState );

		return response;
	},
	/**
	 * Saves an Idea.
	 *
	 * @since 1.36.0
	 *
	 * @param {string} ideaName Idea Hub Idea name.
	 * @return {Object} Object with `response` and `error`.
	 */
	*saveIdea( ideaName ) {
		invariant( typeof ideaName === 'string', 'ideaName must be a string.' );

		yield clearError( 'saveIdea', [ ideaName ] );

		const { response, error } = yield baseActions.updateIdeaState( {
			name: ideaName,
			saved: true,
		} );

		if ( error ) {
			yield receiveError( error, 'saveIdea', [ ideaName ] );
		}

		return { response, error };
	},
	/**
	 * Unsaves an Idea.
	 *
	 * @since 1.36.0
	 *
	 * @param {string} ideaName Idea Hub Idea name.
	 * @return {Object} Object with `response` and `error`.
	 */
	*unsaveIdea( ideaName ) {
		invariant( typeof ideaName === 'string', 'ideaName must be a string.' );

		yield clearError( 'unsaveIdea', [ ideaName ] );

		const { response, error } = yield baseActions.updateIdeaState( {
			name: ideaName,
			saved: false,
		} );

		if ( error ) {
			yield receiveError( error, 'unsaveIdea', [ ideaName ] );
		}

		return { response, error };
	},
	/**
	 * Dismisses an Idea.
	 *
	 * @since 1.36.0
	 *
	 * @param {string} ideaName Idea Hub Idea name.
	 * @return {Object} Object with `response` and `error`.
	 */
	*dismissIdea( ideaName ) {
		invariant( typeof ideaName === 'string', 'ideaName must be a string.' );

		yield clearError( 'dismissIdea', [ ideaName ] );

		const { response, error } = yield baseActions.updateIdeaState( {
			name: ideaName,
			dismissed: true,
		} );

		if ( error ) {
			yield receiveError( error, 'dismissIdea', [ ideaName ] );
		}

		return { response, error };
	},
};

const store = Data.combineStores(
	fetchPostUpdateIdeaStateStore,
	{
		actions: baseActions,
		initialState: baseInitialState,
	}
);

export const initialState = store.initialState;
export const actions = store.actions;
export const controls = store.controls;
export const reducer = store.reducer;
export const resolvers = store.resolvers;
export const selectors = store.selectors;

export default store;
<commit_msg>Address CR feedback.<commit_after>/**
 * `modules/idea-hub` data store: idea-state.
 *
 * Site Kit by Google, Copyright 2021 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * External dependencies
 */
import invariant from 'invariant';

/**
 * Internal dependencies
 */
import API from 'googlesitekit-api';
import Data from 'googlesitekit-data';
import { createFetchStore } from '../../../googlesitekit/data/create-fetch-store';
import { actions as errorStoreActions } from '../../../googlesitekit/data/create-error-store';
const { receiveError, clearError } = errorStoreActions;

const fetchPostUpdateIdeaStateStore = createFetchStore( {
	baseName: 'updateIdeaState',
	controlCallback: ( { name, saved, dismissed } ) => new Promise( async ( resolve ) => {
		const params = { name };

		if ( saved !== undefined ) {
			params.saved = saved;
		} else {
			params.dismissed = dismissed;
		}

		const results = await API.set( 'modules', 'idea-hub', 'update-idea-state', params );
		await API.invalidateCache( 'modules', 'idea-hub', 'new-ideas' );
		await API.invalidateCache( 'modules', 'idea-hub', 'saved-ideas' );

		resolve( results );
	} ),
	argsToParams( { name, saved, dismissed } ) {
		return { name, saved, dismissed };
	},
	validateParams( { name, saved, dismissed } = {} ) {
		invariant( typeof name === 'string' && name.length > 0, 'name must be a non empty string' );
		invariant( saved !== undefined || dismissed !== undefined, 'either saved or dimissed property must be set' );
	},
	reducerCallback: ( state, idea ) => {
		if ( idea.dismissed === true ) {
			return {
				...state,
				newIdeas: ( state.newIdeas || [] ).filter( ( { name } ) => name !== idea.name ),
			};
		}

		if ( idea.saved === true ) {
			const ideaDetails = ( state.newIdeas || [] ).filter( ( { name } ) => name === idea.name );

			if ( ! ideaDetails.length ) {
				return state;
			}

			return {
				...state,
				newIdeas: ( state.newIdeas || [] ).filter( ( { name } ) => name !== idea.name ),
				savedIdeas: [ ...( state.savedIdeas || [] ), ...ideaDetails ],
			};
		}

		if ( idea.saved === false ) {
			return {
				...state,
				savedIdeas: ( state.savedIdeas || [] ).filter( ( { name } ) => name !== idea.name ),
			};
		}

		return state;
	},
} );

const baseInitialState = {};

const baseActions = {
	/**
	 * Updates a given Idea's state.
	 *
	 * @since 1.36.0
	 *
	 * @param {Object}  ideaState           Idea Hub Idea state.
	 * @param {string}  ideaState.name      Idea Hub Idea name.
	 * @param {boolean} ideaState.saved     Whether the Idea is saved [optional].
	 * @param {boolean} ideaState.dismissed Whether the Idea is dismissed [optional].
	 * @return {Object} Object with `response` and `error`.
	 */
	*updateIdeaState( ideaState ) {
		const response = yield fetchPostUpdateIdeaStateStore.actions.fetchUpdateIdeaState( ideaState );

		return response;
	},
	/**
	 * Saves an Idea.
	 *
	 * @since 1.36.0
	 *
	 * @param {string} ideaName Idea Hub Idea name.
	 * @return {Object} Object with `response` and `error`.
	 */
	*saveIdea( ideaName ) {
		invariant( typeof ideaName === 'string', 'ideaName must be a string.' );

		yield clearError( 'saveIdea', [ ideaName ] );

		const { response, error } = yield baseActions.updateIdeaState( {
			name: ideaName,
			saved: true,
		} );

		if ( error ) {
			yield receiveError( error, 'saveIdea', [ ideaName ] );
		}

		return { response, error };
	},
	/**
	 * Unsaves an Idea.
	 *
	 * @since 1.36.0
	 *
	 * @param {string} ideaName Idea Hub Idea name.
	 * @return {Object} Object with `response` and `error`.
	 */
	*unsaveIdea( ideaName ) {
		invariant( typeof ideaName === 'string', 'ideaName must be a string.' );

		yield clearError( 'unsaveIdea', [ ideaName ] );

		const { response, error } = yield baseActions.updateIdeaState( {
			name: ideaName,
			saved: false,
		} );

		if ( error ) {
			yield receiveError( error, 'unsaveIdea', [ ideaName ] );
		}

		return { response, error };
	},
	/**
	 * Dismisses an Idea.
	 *
	 * @since 1.36.0
	 *
	 * @param {string} ideaName Idea Hub Idea name.
	 * @return {Object} Object with `response` and `error`.
	 */
	*dismissIdea( ideaName ) {
		invariant( typeof ideaName === 'string', 'ideaName must be a string.' );

		yield clearError( 'dismissIdea', [ ideaName ] );

		const { response, error } = yield baseActions.updateIdeaState( {
			name: ideaName,
			dismissed: true,
		} );

		if ( error ) {
			yield receiveError( error, 'dismissIdea', [ ideaName ] );
		}

		return { response, error };
	},
};

const store = Data.combineStores(
	fetchPostUpdateIdeaStateStore,
	{
		actions: baseActions,
		initialState: baseInitialState,
	}
);

export const initialState = store.initialState;
export const actions = store.actions;
export const controls = store.controls;
export const reducer = store.reducer;
export const resolvers = store.resolvers;
export const selectors = store.selectors;

export default store;
<|endoftext|>


<commit_before>/*
 * Copyright 2020 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * WordPress dependencies
 */
import { __, sprintf } from '@wordpress/i18n';

/**
 * External dependencies
 */
import { useEffect, useState, useContext, useMemo, useCallback } from 'react';

/**
 * Internal dependencies
 */
import { useConfig } from '../../config';
import useRouteHistory from '../../router/useRouteHistory';
import { ApiContext } from '../../api/apiProvider';
import { ReactComponent as LeftArrow } from '../../../icons/left-arrow.svg';
import { ReactComponent as RightArrow } from '../../../icons/right-arrow.svg';
import { TransformProvider } from '../../../../edit-story/components/transform';
import { UnitsProvider } from '../../../../edit-story/units';
import FontProvider from '../../font/fontProvider';
import {
  CardGallery,
  ColorList,
  PreviewPage,
  Pill,
  TemplateNavBar,
} from '../../../components';
import { ICON_METRICS } from '../../../constants';
import { clamp, usePagePreviewSize } from '../../../utils/';
import { StoryGridView } from '../shared';

import {
  ByLine,
  ContentContainer,
  ColumnContainer,
  Column,
  DetailContainer,
  MetadataContainer,
  NavButton,
  RowContainer,
  SubHeading,
  Text,
  Title,
} from './components';

function TemplateDetail() {
  const [template, setTemplate] = useState(null);
  const { pageSize } = usePagePreviewSize();
  const {
    state: {
      queryParams: { id: templateId, isLocal },
    },
    actions,
  } = useRouteHistory();
  const {
    state: {
      templates: { templates, templatesOrderById },
    },
    actions: {
      templateApi: { fetchMyTemplateById, fetchExternalTemplateById },
    },
  } = useContext(ApiContext);
  const { isRTL } = useConfig();

  useEffect(() => {
    if (!templateId) {
      return;
    }

    const id = parseInt(templateId);
    const isLocalTemplate = isLocal && isLocal.toLowerCase() === 'true';

    if (isLocalTemplate) {
      fetchMyTemplateById(id).then((fetchedTemplate) =>
        setTemplate(fetchedTemplate)
      );
    } else {
      fetchExternalTemplateById(id).then((fetchedTemplate) =>
        setTemplate(fetchedTemplate)
      );
    }
  }, [fetchMyTemplateById, fetchExternalTemplateById, templateId, isLocal]);

  const orderedTemplates = useMemo(() => {
    return templatesOrderById.map((templateByOrderId) => {
      return templates[templateByOrderId];
    });
  }, [templatesOrderById, templates]);

  const relatedTemplates = useMemo(() => orderedTemplates.slice(0, 4), [
    orderedTemplates,
  ]);

  const { byLine } = useMemo(() => {
    if (!template) {
      return {};
    }

    return {
      byLine: sprintf(
        /* translators: %s: template author  */
        __('by %s', 'web-stories'),
        template.createdBy
      ),
    };
  }, [template]);

  const activeTemplateIndex = useMemo(() => {
    if (orderedTemplates.length <= 0) {
      return 0;
    }

    return orderedTemplates.findIndex((t) => t.id === template?.id);
  }, [template, orderedTemplates]);

  const previewPages = useMemo(
    () =>
      template &&
      template.pages.map((page) => <PreviewPage key={page.id} page={page} />),
    [template]
  );

  const switchToTemplateByOffset = useCallback(
    (offset) => {
      const index = clamp(activeTemplateIndex + offset, [
        0,
        orderedTemplates.length - 1,
      ]);
      const selectedTemplate = orderedTemplates[index];

      actions.push(
        `?id=${selectedTemplate.id}&isLocal=${selectedTemplate.isLocal}`
      );
    },
    [activeTemplateIndex, orderedTemplates, actions]
  );

  const { NextButton, PrevButton } = useMemo(() => {
    const Previous = (
      <NavButton
        aria-label={__('View previous template', 'web-stories')}
        onClick={() => switchToTemplateByOffset(-1)}
        disabled={!orderedTemplates?.length || activeTemplateIndex === 0}
      >
        <LeftArrow {...ICON_METRICS.LEFT_RIGHT_ARROW} />
      </NavButton>
    );

    const Next = (
      <NavButton
        aria-label={__('View next template', 'web-stories')}
        onClick={() => switchToTemplateByOffset(1)}
        disabled={
          !orderedTemplates?.length ||
          activeTemplateIndex === orderedTemplates.length - 1
        }
      >
        <RightArrow {...ICON_METRICS.LEFT_RIGHT_ARROW} />
      </NavButton>
    );

    return isRTL
      ? {
          NextButton: Previous,
          PrevButton: Next,
        }
      : {
          NextButton: Next,
          PrevButton: Previous,
        };
  }, [isRTL, switchToTemplateByOffset, activeTemplateIndex, orderedTemplates]);

  if (!template) {
    return null;
  }

  return (
    template && (
      <FontProvider>
        <TransformProvider>
          <UnitsProvider pageSize={pageSize}>
            <TemplateNavBar />
            <ContentContainer>
              <ColumnContainer>
                <Column>
                  {PrevButton}
                  <CardGallery>{previewPages}</CardGallery>
                </Column>
                <Column>
                  <DetailContainer>
                    <Title>{template.title}</Title>
                    <ByLine>{byLine}</ByLine>
                    <Text>{template.description}</Text>
                    <MetadataContainer>
                      {template.tags.map((tag) => (
                        <Pill
                          name={tag}
                          key={tag}
                          disabled
                          onClick={() => {}}
                          value={tag}
                        >
                          {tag}
                        </Pill>
                      ))}
                    </MetadataContainer>
                    <MetadataContainer>
                      <ColorList colors={template.colors} size={30} />
                    </MetadataContainer>
                  </DetailContainer>
                  {NextButton}
                </Column>
              </ColumnContainer>
              <RowContainer>
                <SubHeading>
                  {__('Related Templates', 'web-stories')}
                </SubHeading>
                <StoryGridView
                  filteredStories={relatedTemplates}
                  centerActionLabel={__('View', 'web-stories')}
                  bottomActionLabel={__('Use template', 'web-stories')}
                  isTemplate
                />
              </RowContainer>
            </ContentContainer>
          </UnitsProvider>
        </TransformProvider>
      </FontProvider>
    )
  );
}

export default TemplateDetail;
<commit_msg>randomize related templates for display<commit_after>/*
 * Copyright 2020 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * WordPress dependencies
 */
import { __, sprintf } from '@wordpress/i18n';

/**
 * External dependencies
 */
import { useEffect, useState, useContext, useMemo, useCallback } from 'react';

/**
 * Internal dependencies
 */
import { useConfig } from '../../config';
import useRouteHistory from '../../router/useRouteHistory';
import { ApiContext } from '../../api/apiProvider';
import { ReactComponent as LeftArrow } from '../../../icons/left-arrow.svg';
import { ReactComponent as RightArrow } from '../../../icons/right-arrow.svg';
import { TransformProvider } from '../../../../edit-story/components/transform';
import { UnitsProvider } from '../../../../edit-story/units';
import FontProvider from '../../font/fontProvider';
import {
  CardGallery,
  ColorList,
  PreviewPage,
  Pill,
  TemplateNavBar,
} from '../../../components';
import { ICON_METRICS } from '../../../constants';
import { clamp, usePagePreviewSize } from '../../../utils/';
import { StoryGridView } from '../shared';

import {
  ByLine,
  ContentContainer,
  ColumnContainer,
  Column,
  DetailContainer,
  MetadataContainer,
  NavButton,
  RowContainer,
  SubHeading,
  Text,
  Title,
} from './components';

function TemplateDetail() {
  const [template, setTemplate] = useState(null);
  const { pageSize } = usePagePreviewSize();
  const {
    state: {
      queryParams: { id: templateId, isLocal },
    },
    actions,
  } = useRouteHistory();
  const {
    state: {
      templates: { templates, templatesOrderById },
    },
    actions: {
      templateApi: { fetchMyTemplateById, fetchExternalTemplateById },
    },
  } = useContext(ApiContext);
  const { isRTL } = useConfig();

  useEffect(() => {
    if (!templateId) {
      return;
    }

    const id = parseInt(templateId);
    const isLocalTemplate = isLocal && isLocal.toLowerCase() === 'true';

    if (isLocalTemplate) {
      fetchMyTemplateById(id).then((fetchedTemplate) =>
        setTemplate(fetchedTemplate)
      );
    } else {
      fetchExternalTemplateById(id).then((fetchedTemplate) =>
        setTemplate(fetchedTemplate)
      );
    }
  }, [fetchMyTemplateById, fetchExternalTemplateById, templateId, isLocal]);

  const orderedTemplates = useMemo(() => {
    return templatesOrderById.map((templateByOrderId) => {
      return templates[templateByOrderId];
    });
  }, [templatesOrderById, templates]);

  const relatedTemplates = useMemo(() => {
    if (templatesOrderById && orderedTemplates) {
      const getRandomTemplateId = () =>
        templatesOrderById[
          Math.floor(Math.random() * templatesOrderById.length)
        ];
      const randomlySelectedTemplates = [];
      for (let i = 0; i < 5; i++) {
        randomlySelectedTemplates.push(orderedTemplates[getRandomTemplateId()]);
      }
      return randomlySelectedTemplates;
    }
    return false;
  }, [orderedTemplates, templatesOrderById]);

  const { byLine } = useMemo(() => {
    if (!template) {
      return {};
    }

    return {
      byLine: sprintf(
        /* translators: %s: template author  */
        __('by %s', 'web-stories'),
        template.createdBy
      ),
    };
  }, [template]);

  const activeTemplateIndex = useMemo(() => {
    if (orderedTemplates.length <= 0) {
      return 0;
    }

    return orderedTemplates.findIndex((t) => t.id === template?.id);
  }, [template, orderedTemplates]);

  const previewPages = useMemo(
    () =>
      template &&
      template.pages.map((page) => <PreviewPage key={page.id} page={page} />),
    [template]
  );

  const switchToTemplateByOffset = useCallback(
    (offset) => {
      const index = clamp(activeTemplateIndex + offset, [
        0,
        orderedTemplates.length - 1,
      ]);
      const selectedTemplate = orderedTemplates[index];

      actions.push(
        `?id=${selectedTemplate.id}&isLocal=${selectedTemplate.isLocal}`
      );
    },
    [activeTemplateIndex, orderedTemplates, actions]
  );

  const { NextButton, PrevButton } = useMemo(() => {
    const Previous = (
      <NavButton
        aria-label={__('View previous template', 'web-stories')}
        onClick={() => switchToTemplateByOffset(-1)}
        disabled={!orderedTemplates?.length || activeTemplateIndex === 0}
      >
        <LeftArrow {...ICON_METRICS.LEFT_RIGHT_ARROW} />
      </NavButton>
    );

    const Next = (
      <NavButton
        aria-label={__('View next template', 'web-stories')}
        onClick={() => switchToTemplateByOffset(1)}
        disabled={
          !orderedTemplates?.length ||
          activeTemplateIndex === orderedTemplates.length - 1
        }
      >
        <RightArrow {...ICON_METRICS.LEFT_RIGHT_ARROW} />
      </NavButton>
    );

    return isRTL
      ? {
          NextButton: Previous,
          PrevButton: Next,
        }
      : {
          NextButton: Next,
          PrevButton: Previous,
        };
  }, [isRTL, switchToTemplateByOffset, activeTemplateIndex, orderedTemplates]);

  if (!template) {
    return null;
  }

  return (
    template && (
      <FontProvider>
        <TransformProvider>
          <UnitsProvider pageSize={pageSize}>
            <TemplateNavBar />
            <ContentContainer>
              <ColumnContainer>
                <Column>
                  {PrevButton}
                  <CardGallery>{previewPages}</CardGallery>
                </Column>
                <Column>
                  <DetailContainer>
                    <Title>{template.title}</Title>
                    <ByLine>{byLine}</ByLine>
                    <Text>{template.description}</Text>
                    <MetadataContainer>
                      {template.tags.map((tag) => (
                        <Pill
                          name={tag}
                          key={tag}
                          disabled
                          onClick={() => {}}
                          value={tag}
                        >
                          {tag}
                        </Pill>
                      ))}
                    </MetadataContainer>
                    <MetadataContainer>
                      <ColorList colors={template.colors} size={30} />
                    </MetadataContainer>
                  </DetailContainer>
                  {NextButton}
                </Column>
              </ColumnContainer>
              {relatedTemplates && (
                <RowContainer>
                  <SubHeading>
                    {__('Related Templates', 'web-stories')}
                  </SubHeading>
                  <StoryGridView
                    filteredStories={relatedTemplates}
                    centerActionLabel={__('View', 'web-stories')}
                    bottomActionLabel={__('Use template', 'web-stories')}
                    isTemplate
                  />
                </RowContainer>
              )}
            </ContentContainer>
          </UnitsProvider>
        </TransformProvider>
      </FontProvider>
    )
  );
}

export default TemplateDetail;
<|endoftext|>


<commit_before>/*
 * Copyright 2020 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * External dependencies
 */
import { waitForElementToBeRemoved } from '@testing-library/react';

/**
 * Internal dependencies
 */
import useInsertElement from '../components/canvas/useInsertElement';
import { TEXT_ELEMENT_DEFAULT_FONT } from '../app/font/defaultFonts';
import { Fixture } from './fixture';

describe('CUJ: Creator Can Style Text', () => {
  let fixture;

  const addText = async (extraProps = null) => {
    const insertElement = await fixture.renderHook(() => useInsertElement());
    await fixture.act(() =>
      insertElement('text', {
        font: TEXT_ELEMENT_DEFAULT_FONT,
        content: 'hello world!',
        x: 40,
        y: 40,
        width: 250,
        ...extraProps,
      })
    );
  };

  beforeEach(async () => {
    fixture = new Fixture();
    await fixture.render();
    await addText();
  });

  afterEach(() => {
    fixture.restore();
  });

  describe('Action: Use font picker', () => {
    const TOTAL_FONTS = 6;
    // Timeout used for submitting / search update + 50ms (250 + 50).
    const TIMEOUT = 300;
    const openFontPicker = async () => {
      const input = await fixture.screen.getByLabelText('Edit: Font family');
      await fixture.events.click(input);
    };

    beforeEach(async () => {
      await openFontPicker();
    });

    it('it should render the font picker', async () => {
      await fixture.snapshot('font picker open');
    });

    // Disable reason: @todo
    // eslint-disable-next-line jasmine/no-disabled-tests
    xit('it should apply the selected font on the element', async () => {
      const option = fixture.screen.getByText('Abel');
      await fixture.events.click(option);
    });

    describe('when searching fonts', () => {
      it('should display the correct fonts when searching', async () => {
        await fixture.events.keyboard.type('Ab');
        // Ensure the debounced callback has taken effect.
        await wait(TIMEOUT);
        let options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        expect(options.length).toBe(2);
        expect(options[0].textContent).toBe('Abel');
        expect(options[1].textContent).toBe('Abhaya Libre');

        await fixture.events.keyboard.type('el');
        // Ensure the debounced callback has taken effect.
        await wait(TIMEOUT);
        options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        expect(options.length).toBe(1);
        expect(options[0].textContent).toBe('Abel');
      });

      it('should not search with less than 2 characters', async () => {
        await fixture.events.keyboard.type('A');
        // Ensure the debounced callback has taken effect.
        await wait(TIMEOUT);
        let options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        expect(options.length).toBe(TOTAL_FONTS);
      });

      it('should restore default fonts list when emptying search', async () => {
        await fixture.events.keyboard.type('Ab');
        // Ensure the debounced callback has taken effect.
        await wait(TIMEOUT);
        let options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        expect(options.length).toBe(2);

        await fixture.events.keyboard.press('Del');
        // Ensure the debounced callback has taken effect.
        await wait(TIMEOUT);
        options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        // Back to all options.
        expect(options.length).toBe(TOTAL_FONTS);
      });

      it('should show empty list in case of no results', async () => {
        await fixture.events.keyboard.type('No fonts here');
        // Ensure the debounced callback has taken effect.
        await wait(TIMEOUT);
        expect(fixture.screen.getByText('No matches found')).toBeDefined();
      });
    });

    describe('with recent fonts', () => {
      it('should not display any recent fonts by default', () => {
        let options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        expect(options.length).toBe(TOTAL_FONTS);
      });

      it('should add up to 5 recent fonts, displaying the most recent first', async () => {
        let option = fixture.screen.getByText('Space Mono');
        await fixture.events.click(option);
        await wait(TIMEOUT);
        await openFontPicker();

        let options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        expect(options.length).toBe(TOTAL_FONTS + 1);
        expect(options[0].textContent).toBe('Space Mono');

        option = fixture.screen.getByText('Abel');
        await fixture.events.click(option);
        await wait(TIMEOUT);
        await openFontPicker();
        options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        expect(options.length).toBe(TOTAL_FONTS + 2);

        option = fixture.screen.getByText('Abhaya Libre');
        await fixture.events.click(option);
        await wait(TIMEOUT);
        await openFontPicker();
        options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        expect(options.length).toBe(TOTAL_FONTS + 3);

        option = fixture.screen.getByText('Source Serif Pro');
        await fixture.events.click(option);
        await wait(TIMEOUT);
        await openFontPicker();
        options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        expect(options.length).toBe(TOTAL_FONTS + 4);

        option = fixture.screen.getByText('Roboto');
        await fixture.events.click(option);
        await wait(TIMEOUT);
        await openFontPicker();
        options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        expect(options.length).toBe(TOTAL_FONTS + 5);

        option = fixture.screen.getByText('Yrsa');
        await fixture.events.click(option);
        await wait(TIMEOUT);
        await openFontPicker();

        options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');

        // Ensure there are only 5 extra options added.
        expect(options.length).toBe(TOTAL_FONTS + 5);
        // Ensure the first one is the last chosen.
        expect(options[0].textContent).toBe('Yrsa');
      });

      // Disable reason: Not implemented yet
      // eslint-disable-next-line jasmine/no-disabled-tests
      xit('should display the selected recent font with a tick', () => {});

      // Disable reason: Not implemented yet
      // eslint-disable-next-line jasmine/no-disabled-tests
      xit('should include recent fonts to search', () => {});
    });

    describe('using keyboard only', () => {
      it('should allow selecting a font with arrow keys and Enter', async () => {
        await fixture.events.keyboard.press('up');
        await fixture.events.keyboard.press('up');
        await fixture.events.keyboard.press('Enter');
        await openFontPicker();
        const selected = fixture.screen.getByRole('option', {
          name: 'Selected Abel',
        });
        expect(selected).toBeDefined();
      });

      it('should close the font picker with Esc', async () => {
        const input = await fixture.screen.getByLabelText('Edit: Font family');
        expect(input.getAttribute('aria-expanded')).toBe('true');
        await fixture.events.keyboard.press('Esc');
        await waitForElementToBeRemoved(
          document.getElementById('editor-font-picker-list')
        );
      });
    });
  });
});

function wait(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}
<commit_msg>More tests for recents fonts.<commit_after>/*
 * Copyright 2020 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * External dependencies
 */
import { waitForElementToBeRemoved } from '@testing-library/react';

/**
 * Internal dependencies
 */
import useInsertElement from '../components/canvas/useInsertElement';
import { TEXT_ELEMENT_DEFAULT_FONT } from '../app/font/defaultFonts';
import { Fixture } from './fixture';

describe('CUJ: Creator Can Style Text', () => {
  let fixture;

  const addText = async (extraProps = null) => {
    const insertElement = await fixture.renderHook(() => useInsertElement());
    await fixture.act(() =>
      insertElement('text', {
        font: TEXT_ELEMENT_DEFAULT_FONT,
        content: 'hello world!',
        x: 40,
        y: 40,
        width: 250,
        ...extraProps,
      })
    );
  };

  beforeEach(async () => {
    fixture = new Fixture();
    await fixture.render();
    await addText();
  });

  afterEach(() => {
    fixture.restore();
  });

  describe('Action: Use font picker', () => {
    const TOTAL_FONTS = 6;
    // Timeout used for submitting / search update + 50ms (250 + 50).
    const TIMEOUT = 300;
    const openFontPicker = async () => {
      const input = await fixture.screen.getByLabelText('Edit: Font family');
      await fixture.events.click(input);
    };

    beforeEach(async () => {
      await openFontPicker();
    });

    it('it should render the font picker', async () => {
      await fixture.snapshot('font picker open');
    });

    // Disable reason: @todo
    // eslint-disable-next-line jasmine/no-disabled-tests
    xit('it should apply the selected font on the element', async () => {
      const option = fixture.screen.getByText('Abel');
      await fixture.events.click(option);
    });

    describe('when searching fonts', () => {
      it('should display the correct fonts when searching', async () => {
        await fixture.events.keyboard.type('Ab');
        // Ensure the debounced callback has taken effect.
        await wait(TIMEOUT);
        let options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        expect(options.length).toBe(2);
        expect(options[0].textContent).toBe('Abel');
        expect(options[1].textContent).toBe('Abhaya Libre');

        await fixture.events.keyboard.type('el');
        // Ensure the debounced callback has taken effect.
        await wait(TIMEOUT);
        options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        expect(options.length).toBe(1);
        expect(options[0].textContent).toBe('Abel');
      });

      it('should not search with less than 2 characters', async () => {
        await fixture.events.keyboard.type('A');
        // Ensure the debounced callback has taken effect.
        await wait(TIMEOUT);
        let options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        expect(options.length).toBe(TOTAL_FONTS);
      });

      it('should restore default fonts list when emptying search', async () => {
        await fixture.events.keyboard.type('Ab');
        // Ensure the debounced callback has taken effect.
        await wait(TIMEOUT);
        let options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        expect(options.length).toBe(2);

        await fixture.events.keyboard.press('Del');
        // Ensure the debounced callback has taken effect.
        await wait(TIMEOUT);
        options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        // Back to all options.
        expect(options.length).toBe(TOTAL_FONTS);
      });

      it('should show empty list in case of no results', async () => {
        await fixture.events.keyboard.type('No fonts here');
        // Ensure the debounced callback has taken effect.
        await wait(TIMEOUT);
        expect(fixture.screen.getByText('No matches found')).toBeDefined();
      });
    });

    describe('with recent fonts', () => {
      it('should not display any recent fonts by default', () => {
        let options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        expect(options.length).toBe(TOTAL_FONTS);
      });

      it('should add up to 5 recent fonts, displaying the most recent first', async () => {
        let option = fixture.screen.getByText('Space Mono');
        await fixture.events.click(option);
        await wait(TIMEOUT);
        await openFontPicker();

        let options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        expect(options.length).toBe(TOTAL_FONTS + 1);
        expect(options[0].textContent).toBe('Space Mono');

        option = fixture.screen.getByText('Abel');
        await fixture.events.click(option);
        await wait(TIMEOUT);
        await openFontPicker();
        options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        expect(options.length).toBe(TOTAL_FONTS + 2);

        option = fixture.screen.getByText('Abhaya Libre');
        await fixture.events.click(option);
        await wait(TIMEOUT);
        await openFontPicker();
        options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        expect(options.length).toBe(TOTAL_FONTS + 3);

        option = fixture.screen.getByText('Source Serif Pro');
        await fixture.events.click(option);
        await wait(TIMEOUT);
        await openFontPicker();
        options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        expect(options.length).toBe(TOTAL_FONTS + 4);

        option = fixture.screen.getByText('Roboto');
        await fixture.events.click(option);
        await wait(TIMEOUT);
        await openFontPicker();
        options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');
        expect(options.length).toBe(TOTAL_FONTS + 5);

        option = fixture.screen.getByText('Yrsa');
        await fixture.events.click(option);
        await wait(TIMEOUT);
        await openFontPicker();

        options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');

        // Ensure there are only 5 extra options added.
        expect(options.length).toBe(TOTAL_FONTS + 5);
        // Ensure the first one is the last chosen.
        expect(options[0].textContent).toBe('Yrsa');
      });

      it('should display the selected recent font with a tick', async () => {
        const option = fixture.screen.getByText('Abel');
        await fixture.events.click(option);
        await wait(TIMEOUT);
        await openFontPicker();
        const selected = fixture.screen.getAllByRole('option', {
          name: 'Selected Abel',
        });
        // One from the recent fonts and one from the general list.
        expect(selected.length).toBe(2);
      });

      it('should include recent fonts to search', async () => {
        const option = fixture.screen.getByText('Abel');
        await fixture.events.click(option);
        await wait(TIMEOUT);
        await openFontPicker();

        await fixture.events.keyboard.type('Ab');
        // Ensure the debounced callback has taken effect.
        await wait(TIMEOUT);
        let options = document
          .getElementById('editor-font-picker-list')
          .querySelectorAll('li');

        // Twice for 'Abel', once for Abhaya Libre.
        expect(options.length).toBe(3);
      });
    });

    describe('using keyboard only', () => {
      it('should allow selecting a font with arrow keys and Enter', async () => {
        await fixture.events.keyboard.press('up');
        await fixture.events.keyboard.press('up');
        await fixture.events.keyboard.press('Enter');
        await openFontPicker();
        const selected = fixture.screen.getByRole('option', {
          name: 'Selected Abel',
        });
        expect(selected).toBeDefined();
      });

      it('should close the font picker with Esc', async () => {
        const input = await fixture.screen.getByLabelText('Edit: Font family');
        expect(input.getAttribute('aria-expanded')).toBe('true');
        await fixture.events.keyboard.press('Esc');
        await waitForElementToBeRemoved(
          document.getElementById('editor-font-picker-list')
        );
      });
    });
  });
});

function wait(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}
<|endoftext|>


<commit_before>/*-
 * Copyright 2014 Patrizio Bruno <patrizio@desertconsulting.net>
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * Handles decryption.
 *
 * @param cryptographer  an instance of WebCryptographer (or equivalent). Keep
 *                       in mind that decryption mutates the cryptographer.
 * @param rsa_key        private RSA key in json format. Parameters can be base64
 *                       encoded, strings or number (for 'e').
 */
JoseJWS.Signer = function(cryptographer, rsa_key) {
  this.cryptographer = cryptographer;
  this.key_promise = JoseJWE.Utils.importRsaPrivateKey(rsa_key, cryptographer.getContentSignAlgorithm(), "sign");

  this.headers = {};
};

JoseJWS.Signer.prototype.getHeaders = function() {
  return this.headers;
};

/**
 * Performs decryption.
 *
 * @param payload json or String to be signed
 * @param aad protected header (JS object)
 * @param header unprotected header (JS object)
 * @return Promise<String>
 */
JoseJWS.Signer.prototype.sign = function(payload, aad, header) {

  if (!aad) {
    aad = {};
  }

  if (!aad.alg) {
    aad.alg = cryptographer.getContentSignAlgorithm();
    aad.typ = "JWT";
  }

  if (Utils.isString(payload)) {
    payload = Utils.arrayFromString(payload);
  } else {
    try {
      payload = Utils.arrayish(payload);
    } catch (e) {
      if (payload instanceof Object) {
        payload = Utils.arrayFromString(JSON.stringify(payload));
      }
    }
  }

  return this.cryptographer.sign(aad, payload, this.key_promise).then(function(signature) {
    return new JWS(aad, header, payload, signature);
  });
};

/**
 * Initialize a JWS object.
 *
 * @param protectedHeader protected header (JS object)
 * @param payload Uint8Array payload to be signed
 * @param signature ArrayBuffer signature of the payload
 * @param header unprotected header (JS object)
 *
 * @constructor
 */
var JWS = function(protectedHeader, header, payload, signature) {
  this.header = header;
  this.payload = Utils.Base64Url.encodeArray(payload);
  this.signature = Utils.Base64Url.encodeArray(signature);
  this.protected = Utils.Base64Url.encode(JSON.stringify(protectedHeader));
};

/**
 * Serialize a JWS object using the JSON serialization format
 *
 * @returns {Object} a copy of this
 */
JWS.prototype.JsonSerialize = function() {
  var that = this;
  return Object.create(that);
};

/**
 * Serialize a JWS object using the Compact Serialization Format
 *
 * @returns {string} BASE64URL(UTF8(PROTECTED HEADER)).BASE64URL(PAYLOAD).BASE64URL(SIGNATURE)
 */
JWS.prototype.CompactSerialize = function() {
  return this.protected + '.' + this.payload + '.' + this.signature;
};
<commit_msg>JsonSerialize now returns a string containing a JSON encoded object. This commit includes some minor comment fix<commit_after>/*-
 * Copyright 2015 Peculiar Ventures
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * Handles decryption.
 *
 * @param cryptographer  an instance of WebCryptographer (or equivalent). Keep
 *                       in mind that decryption mutates the cryptographer.
 * @param rsa_key        private RSA key in json format. Parameters can be base64
 *                       encoded, strings or number (for 'e').
 *
 * @author Patrizio Bruno <patrizio@desertconsulting.net>
 */
JoseJWS.Signer = function(cryptographer, rsa_key) {
  this.cryptographer = cryptographer;
  this.key_promise = JoseJWE.Utils.importRsaPrivateKey(rsa_key, cryptographer.getContentSignAlgorithm(), "sign");

  this.headers = {};
};

JoseJWS.Signer.prototype.getHeaders = function() {
  return this.headers;
};

/**
 * Performs decryption.
 *
 * @param payload json or String to be signed
 * @param aad protected header (JS object)
 * @param header unprotected header (JS object)
 * @return Promise<String>
 */
JoseJWS.Signer.prototype.sign = function(payload, aad, header) {

  if (!aad) {
    aad = {};
  }

  if (!aad.alg) {
    aad.alg = cryptographer.getContentSignAlgorithm();
    aad.typ = "JWT";
  }

  if (Utils.isString(payload)) {
    payload = Utils.arrayFromString(payload);
  } else {
    try {
      payload = Utils.arrayish(payload);
    } catch (e) {
      if (payload instanceof Object) {
        payload = Utils.arrayFromString(JSON.stringify(payload));
      }
    }
  }

  return this.cryptographer.sign(aad, payload, this.key_promise).then(function(signature) {
    return new JWS(aad, header, payload, signature);
  });
};

/**
 * Initialize a JWS object.
 *
 * @param protectedHeader protected header (JS object)
 * @param payload Uint8Array payload to be signed
 * @param signature ArrayBuffer signature of the payload
 * @param header unprotected header (JS object)
 *
 * @constructor
 */
var JWS = function(protectedHeader, header, payload, signature) {
  this.header = header;
  this.payload = Utils.Base64Url.encodeArray(payload);
  this.signature = Utils.Base64Url.encodeArray(signature);
  this.protected = Utils.Base64Url.encode(JSON.stringify(protectedHeader));
};

/**
 * Serialize a JWS object using the JSON serialization format
 *
 * @returns {Object} a copy of this
 */
JWS.prototype.JsonSerialize = function() {
  return JSON.stringify(this);
};

/**
 * Serialize a JWS object using the Compact Serialization Format
 *
 * @returns {string} BASE64URL(UTF8(PROTECTED HEADER)).BASE64URL(PAYLOAD).BASE64URL(SIGNATURE)
 */
JWS.prototype.CompactSerialize = function() {
  return this.protected + '.' + this.payload + '.' + this.signature;
};
<|endoftext|>