feature.js

'use strict';

/**
 * Feature manager. It creates the basic feature folder structure and provides the capability to rename or remove it.
 * @module
**/

const path = require('path');
const _ = require('lodash');
const traverse = require('babel-traverse').default;
// const shell = require('shelljs');
const utils = require('./utils');
const vio = require('./vio');
const refactor = require('./refactor');
const constant = require('./constant');
const entry = require('./entry');
const template = require('./template');
const assert = require('./assert');

/**
 * Add a feature. Create the basic folder strcutre of a feature, such as files of `index.js`, `style.less`, `redux` folder, etc.
 * @param {string} name - feature name.
 * @alias module:feature.add
 *
 * @example <caption>Create a feature</caption>
 * const feature = require('rekit-core').feature;
 * feature.add('customer'); // create a feature named 'customer'
 * // Result => Create a folder named 'customer' in 'src/features' folder.
**/
function add(name) {
  assert.notEmpty(name);
  name = _.kebabCase(name);
  assert.featureNotExist(name);
  const targetDir = utils.joinPath(utils.getProjectRoot(), `src/features/${name}`);

  // if (vio.dirExists(targetDir)) {
  //   utils.fatalError(`Feature already exists: ${name}`);
  // }

  vio.mkdir(targetDir);
  vio.mkdir(utils.joinPath(targetDir, 'redux'));
  vio.mkdir(utils.joinPath(utils.getProjectRoot(), 'tests/features', name));
  vio.mkdir(utils.joinPath(utils.getProjectRoot(), 'tests/features', name, 'redux'));

  // Create files from template
  [
    'index.js',
    'route.js',
    'style.' + utils.getCssExt(),
    'redux/actions.js',
    'redux/reducer.js',
    'redux/constants.js',
    'redux/initialState.js',
  ].forEach((fileName) => {
    template.generate(utils.joinPath(targetDir, fileName), {
      templateFile: fileName,
      context: { feature: name }
    });
  });

  // Create wrapper reducer for the feature
  template.generate(utils.joinPath(utils.getProjectRoot(), `tests/features/${name}/redux/reducer.test.js`), {
    templateFile: 'reducer.test.js',
    context: { feature: name }
  });
}

/**
 * Remove a feature
 * @param {string} name - feature name.
 * @alias module:feature.remove
**/
function remove(name) {
  vio.del(utils.joinPath(utils.getProjectRoot(), 'src/features', _.kebabCase(name)));
  vio.del(utils.joinPath(utils.getProjectRoot(), 'tests/features', _.kebabCase(name)));
}

/**
 * Rename a feature
 * @param {string} oldName - The old name.
 * @param {string} newName - The new name.
 * @alias module:feature.move
**/
function move(oldName, newName) {
  // Summary:
  //  Rename a feature. Seems a bit heavy operation.

  assert.notEmpty(oldName);
  assert.notEmpty(newName);
  assert.featureExist(oldName);
  assert.featureNotExist(newName);

  oldName = _.kebabCase(oldName);
  newName = _.kebabCase(newName);

  const prjRoot = utils.getProjectRoot();

  // Move feature folder
  const oldFolder = utils.joinPath(prjRoot, 'src/features', oldName);
  const newFolder = utils.joinPath(prjRoot, 'src/features', newName);
  vio.moveDir(oldFolder, newFolder);

  // Move feature test folder
  const oldTestFolder = utils.joinPath(prjRoot, 'tests/features', oldName);
  const newTestFolder = utils.joinPath(prjRoot, 'tests/features', newName);
  vio.moveDir(oldTestFolder, newTestFolder);

  // Update common/routeConfig
  entry.renameInRouteConfig(oldName, newName);

  // Update common/rootReducer
  entry.renameInRootReducer(oldName, newName);

  // Update styles/index.less
  entry.renameInRootStyle(oldName, newName);

  // Update feature/route.js for path and name if they bind to feature name
  refactor.updateFile(utils.mapFeatureFile(newName, 'route.js'), ast => [].concat(
    refactor.replaceStringLiteral(ast, _.kebabCase(oldName), _.kebabCase(newName)), // Rename path
    refactor.replaceStringLiteral(ast, _.upperFirst(_.lowerCase(oldName)), _.upperFirst(_.lowerCase(newName))) // Rename name
  ));

  // Try to rename css class names for components
  const folder = utils.joinPath(prjRoot, 'src/features', newName);
  vio.ls(folder)
    // It simply assumes component file name is pascal case
    .filter(f => /^[A-Z]/.test(path.basename(f)))
    .forEach((filePath) => {
      const moduleName = path.basename(filePath).split('.')[0];

      if (/\.js$/.test(filePath)) {
        // For components, update the css class name inside
        refactor.updateFile(filePath, ast => [].concat(
          refactor.replaceStringLiteral(ast, `${oldName}-${_.kebabCase(moduleName)}`, `${newName}-${_.kebabCase(moduleName)}`, false) // rename css class name
        ));
      } else if (/\.less$|\.scss$/.test(filePath)) {
        // For style update
        let lines = vio.getLines(filePath);
        const oldCssClass = `${oldName}-${_.kebabCase(moduleName)}`;
        const newCssClass = `${newName}-${_.kebabCase(moduleName)}`;

        lines = lines.map(line => line.replace(`.${oldCssClass}`, `.${newCssClass}`));
        vio.save(filePath, lines);
      }
    });

  // Rename action constants
  const reduxFolder = utils.joinPath(prjRoot, 'src/features', newName, 'redux');
  const constantsFile = utils.joinPath(reduxFolder, 'constants.js');
  const constants = [];
  traverse(vio.getAst(constantsFile), {
    VariableDeclarator(p) {
      const name = _.get(p, 'node.id.name');
      if (name && _.startsWith(name, `${_.upperSnakeCase(oldName)}_`) && name === _.get(p, 'node.init.value')) {
        constants.push(name);
      }
    }
  });

  constants.forEach((name) => {
    const oldConstant = name;
    const newConstant = name.replace(new RegExp(`^${_.upperSnakeCase(oldName)}`), _.upperSnakeCase(newName));
    constant.rename(newName, oldConstant, newConstant);
  });

  // Rename actions
  const reduxTestFolder = utils.joinPath(prjRoot, 'tests/features', newName, 'redux');
  vio.ls(reduxFolder)
  .concat(vio.ls(reduxTestFolder))
    // It simply assumes component file name is pascal case
    .forEach((filePath) => {
      if (/\.js$/.test(filePath)) {
        refactor.updateFile(filePath, (ast) => {
          let changes = [];
          constants.forEach((name) => {
            const oldConstant = name;
            const newConstant = name.replace(new RegExp(`^${_.upperSnakeCase(oldName)}`), _.upperSnakeCase(newName));
            changes = changes.concat(refactor.renameImportSpecifier(ast, oldConstant, newConstant));
          });
          return changes;
        });
      }
    });

  // Try to do a rougth string replacement based on the original generated code structure
  const testFolder = utils.joinPath(prjRoot, 'tests/features', newName);
  // const files = _.union(vio.ls(testFolder), vio.ls(utils.joinPath(testFolder, 'redux'))
  _.union(vio.ls(testFolder), vio.ls(utils.joinPath(testFolder, 'redux')))
    .filter(f => /\.test\.js$/.test(f))
    .forEach((filePath) => {
      const moduleName = path.basename(filePath).replace('.test.js', '');
      refactor.updateFile(filePath, ast => [].concat(
        refactor.replaceStringLiteral(ast, `src/features/${oldName}`, `src/features/${newName}`), // import module path
        refactor.replaceStringLiteral(ast, `../../../src/features/${oldName}'`, `../../../src/features/${newName}`), // import module path
        refactor.replaceStringLiteral(ast, `features/${oldName}/`, `features/${newName}/`, false), // import module path
        refactor.replaceStringLiteral(ast, `${oldName}/${moduleName}`, `${newName}/${moduleName}`), // describe component/page test
        refactor.replaceStringLiteral(ast, `${oldName}/redux/${moduleName}`, `${newName}/redux/${moduleName}`), // describe action test
        refactor.replaceStringLiteral(ast, `${oldName}/redux/reducer`, `${newName}/redux/reducer`), // describe reducer test
        refactor.replaceStringLiteral(ast, `${oldName}-${_.kebabCase(moduleName)}`, `${newName}-${_.kebabCase(moduleName)}`, false) // root css class name
      ));
    });
}

module.exports = {
  add,
  remove,
  move,
};