diff --git a/.gitignore b/.gitignore index 5812842..611a92f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,7 @@ *-debug.log *-error.log -.oclif.manifest.json -/.nyc_output -/dist -/lib -/tmp node_modules -public + +/lib +/.zwen +/test diff --git a/package-lock.json b/package-lock.json index f06e21f..15b1e43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2780,8 +2780,7 @@ "fs-readdir-recursive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz", - "integrity": "sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA==", - "dev": true + "integrity": "sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA==" }, "fs.realpath": { "version": "1.0.0", @@ -2808,8 +2807,7 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.2.0", @@ -2830,14 +2828,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2852,20 +2848,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -2982,8 +2975,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -2995,7 +2987,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -3010,7 +3001,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -3018,14 +3008,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -3044,7 +3032,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -3125,8 +3112,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -3138,7 +3124,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -3224,8 +3209,7 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "safer-buffer": { "version": "2.1.2", @@ -3261,7 +3245,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -3281,7 +3264,6 @@ "version": "3.0.1", "bundled": true, "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -3325,14 +3307,12 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true } } }, @@ -3342,6 +3322,11 @@ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", "dev": true }, + "fuzzy": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/fuzzy/-/fuzzy-0.1.3.tgz", + "integrity": "sha1-THbsL/CsGjap3M+aAN+GIweNTtg=" + }, "get-caller-file": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", @@ -3697,6 +3682,17 @@ } } }, + "inquirer-autocomplete-prompt": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/inquirer-autocomplete-prompt/-/inquirer-autocomplete-prompt-1.0.1.tgz", + "integrity": "sha512-Y4V6ifAu9LNrNjcEtYq8YUKhrgmmufUn5fsDQqeWgHY8rEO6ZAQkNUiZtBm2kw2uUQlC9HdgrRCHDhTPPguH5A==", + "requires": { + "ansi-escapes": "^3.0.0", + "chalk": "^2.0.0", + "figures": "^2.0.0", + "run-async": "^2.3.0" + } + }, "interpret": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.2.0.tgz", diff --git a/package.json b/package.json index 0732729..b1e5090 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,9 @@ "chalk": "^2.4.2", "constant-case": "^2.0.0", "ejs": "^2.6.1", + "fs-readdir-recursive": "^1.1.0", + "fuzzy": "^0.1.3", + "inquirer-autocomplete-prompt": "^1.0.1", "minimist": "^1.2.0", "pascal-case": "^2.0.1", "yeoman-environment": "^2.3.4", diff --git a/src/constants/regex.ts b/src/constants/regex.ts index be9b1af..73d6911 100644 --- a/src/constants/regex.ts +++ b/src/constants/regex.ts @@ -11,6 +11,10 @@ export const selectExportsAll = new RegExp( 'export \\*(.+?(?=export \\*|$))', 'gs', ); +export const selectExportConstNames = new RegExp( + '(?<=\nexport const )\\w*', + 'gs', +); export const selectDefaultImports = new RegExp( 'import \\w(.+?(?=import|$|\\n\\n))', 'gs', diff --git a/src/constants/templateStrings.ts b/src/constants/templateStrings.ts index 8a9d2d1..d2148ba 100644 --- a/src/constants/templateStrings.ts +++ b/src/constants/templateStrings.ts @@ -16,14 +16,18 @@ export const importAllAsFrom = (as: string, from: string) => export const importNamedFrom = (name: string, from: string) => `import { ${name} } from '${from}';\n`; -// ACTIONS -export const creatorTestHead = (path: string) => - `import * as t from '@/actions/types';\n` + - `import * as actions from './creators';\n\n` + - `describe('actions/${path}', () => {\n`; +export const describeTestStart = (path: string) => + `describe('${path}', () => {\n`; -export const creatorTestFoot = () => '});\n'; +export const describeTestEnd = () => + `});\n`; // REDUCERS export const exportDefaultCombineReducers = () => 'export default combineReducers({\n'; + +// SELECTORS +export const exportAllFromReducers = () => + `/* zwen-keep-start */\n` + + `export * from '@/reducers';\n` + + `/* zwen-keep-end */\n\n`; diff --git a/src/generators/action.ts b/src/generators/action.ts index 7a18431..4420384 100644 --- a/src/generators/action.ts +++ b/src/generators/action.ts @@ -33,9 +33,9 @@ export default class ActionGenerator extends Generator implements Zwenerator { async prompting() { const answer = await this.prompt({ - message: 'Create an action type with the same name?', - name: 'withActionType', type: 'confirm', + name: 'withActionType', + message: 'Create an action type with the same name?', }); this.withActionType = answer.withActionType; @@ -80,12 +80,12 @@ export default class ActionGenerator extends Generator implements Zwenerator { } addActionCreator() { - const importStatement = t.importAllAsFrom('t', '@/actions/types') + '\n'; const creatorTemplate = this.fs.read(this.templatePath(`${PATH_PREFIX}/creator.ejs`)); const newCreator = ejs.render(creatorTemplate, this.templateConfig); // read - const creatorsFile = this.fs.read(`${this.absolutePath}/creators.js`, { defaults: importStatement }); + const fileHead = t.importAllAsFrom('t', '@/actions/types') + '\n'; + const creatorsFile = this.fs.read(`${this.absolutePath}/creators.js`, { defaults: fileHead }); // update const updateOptions = { separator: '\n\n', @@ -98,13 +98,16 @@ export default class ActionGenerator extends Generator implements Zwenerator { addActionCreatorTest() { const testTemplate = this.fs.read(this.templatePath(`${PATH_PREFIX}/creator.test.ejs`)); const newTest = ejs.render(testTemplate, this.templateConfig); - const fileDefaults = t.creatorTestHead(this.destPath); // read - const testFile = this.fs.read(`${this.absolutePath}/creators.test.js`, { defaults: fileDefaults }); + const fileHead = + t.importAllAsFrom('t', '@/actions/types') + + t.importAllAsFrom('actions', './creators') + '\n' + + t.describeTestStart(`actions/${this.destPath}`); + const testFile = this.fs.read(`${this.absolutePath}/creators.test.js`, { defaults: fileHead }); // update const updateOptions = { - appendixIfNew: t.creatorTestFoot(), + appendixIfNew: t.describeTestEnd(), replaceStringSeparator: ' ', separator: '\n\n ', }; diff --git a/src/generators/component.ts b/src/generators/component.ts index 4b545df..6905e32 100644 --- a/src/generators/component.ts +++ b/src/generators/component.ts @@ -58,7 +58,6 @@ export default class ComponentGenerator extends Generator implements Zwenerator } writing() { - const destPath = `${this.absolutePath}`; const templateName = this.classComp ? 'classComp' : 'component'; const componentName = this.fileName.toPascalCase(); this.fs.copyTpl( diff --git a/src/generators/index.ts b/src/generators/index.ts index 705a991..9ffcf10 100644 --- a/src/generators/index.ts +++ b/src/generators/index.ts @@ -3,4 +3,5 @@ export const registeredGenerators = [ 'component', 'middleware', 'reducer', + 'selector', ]; diff --git a/src/generators/reducer.ts b/src/generators/reducer.ts index 7ec6f5f..6ee7ac7 100644 --- a/src/generators/reducer.ts +++ b/src/generators/reducer.ts @@ -52,8 +52,8 @@ export default class ReducerGenerator extends Generator implements Zwenerator { destDirWithFileName.forEach((subPath: string) => { // read - const importWithNewLine = t.importNamedFrom('combineReducers', 'redux') + '\n'; - const file = this.fs.read(`${currentPath}/index.js`, { defaults: importWithNewLine }); + const fileHead = t.importNamedFrom('combineReducers', 'redux') + '\n'; + const file = this.fs.read(`${currentPath}/index.js`, { defaults: fileHead }); // update imports let updatedFile = addToFile(file, t.importDefaultFrom(subPath, `./${subPath}`), r.selectDefaultImports); diff --git a/src/generators/selector.ts b/src/generators/selector.ts new file mode 100644 index 0000000..9e5b25b --- /dev/null +++ b/src/generators/selector.ts @@ -0,0 +1,202 @@ +import ejs from 'ejs'; +import fs from 'fs'; +import read from 'fs-readdir-recursive'; +import fuzzy, { FilterResult } from 'fuzzy'; +import autocomplete from 'inquirer-autocomplete-prompt'; +import path from 'path'; +import { promisify } from 'util'; +import Generator from 'yeoman-generator'; + +import * as r from '../constants/regex'; +import * as t from '../constants/templateStrings'; +import logger from '../logger'; +import { FileToWrite, GeneratorOptions, Zwenerator } from '../types'; +import addToFile from '../utils/addToFile'; + +interface ExistingSelector { + source: 'reducers' | 'selectors'; + path: string; + name: string; + displayName: string; +} + +interface SelectorZwenerator extends Zwenerator { + srcDir: string; + topLevelReducerPath: string; + existingSelectors: ExistingSelector[]; + chosenSelectors: ExistingSelector[]; +} + +const readFile = promisify(fs.readFile); + +const PATH_PREFIX = 'selectors'; +const REDUCER_PATH_PREFIX = 'reducers'; +const POSSIBLE_STATE_NAMES = 'abcdefghijklmnopqrstuvwxyz'.split(''); + +const defaultExistingSelector: ExistingSelector = { + source: 'reducers', path: '', name: '', displayName: '', +}; + +export default class SelectorGenerator extends Generator implements SelectorZwenerator { + filesToWrite: FileToWrite[] = []; + templateConfig: object = {}; + indent: string; + srcDir: string; + destDir: string[]; + destPath: string; + topLevelPath: string; + topLevelReducerPath: string; + absolutePath: string; + fileName: string; + existingSelectors: ExistingSelector[] = []; + chosenSelectors: ExistingSelector[] = []; + + constructor(args: string[], options: GeneratorOptions) { + super(args, options); + + this.indent = options.indent; + this.srcDir = options.srcDir; + this.destDir = options.destDir; + this.destPath = this.destDir.join('/'); + this.topLevelPath = `${options.srcDir}/${PATH_PREFIX}`; + this.topLevelReducerPath = `${options.srcDir}/${REDUCER_PATH_PREFIX}`; + this.absolutePath = `${this.topLevelPath}/${this.destPath}`; + this.fileName = options.fileName; + } + + async initializing() { + this.env.adapter.promptModule.registerPrompt('autocomplete', autocomplete); + + const reducerFiles: string[] = read( + this.topLevelReducerPath, + (name: string) => + !name.startsWith('.') && + !name.endsWith('index.js') && + !name.endsWith('test.js'), + ); + + await Promise.all(reducerFiles.map(async filePath => { + const file = await readFile(path.resolve(this.topLevelReducerPath, filePath), 'utf8'); + const fileSelectors = file.match(r.selectExportConstNames) || []; + + fileSelectors.forEach(name => { + const displayedPath = filePath.replace(/\/\w*\.js/, '').replace(/\//g, ' › '); + this.existingSelectors.push({ + name, + path: displayedPath, + source: REDUCER_PATH_PREFIX, + displayName: `${displayedPath} › ${name}`, + }); + }); + })); + } + + async prompting() { + const DONE_CMD = 'done!'; + const doneSelector: ExistingSelector = { + ...defaultExistingSelector, + displayName: DONE_CMD, + }; + const answers: string[] = []; + let remainingExistingSelectors = [...this.existingSelectors, doneSelector]; + + logger.selectorPrompt(DONE_CMD); + + const promptAnswer = async () => { + const { selector } = await this.prompt({ + type: 'autocomplete', + name: 'selector', + message: '›', + // autocomplete plugin requires this to be a promise + source: async (_: string[], input: string = '') => ( + fuzzy + .filter( + input, + remainingExistingSelectors, + { extract: (s: ExistingSelector) => s.displayName }, + ) + .map((result: FilterResult) => result.string) + ), + }); + + if (selector && selector !== DONE_CMD) { + answers.push(selector); + remainingExistingSelectors = remainingExistingSelectors.filter(s => s.displayName !== selector); + await promptAnswer(); + } + }; + + await promptAnswer(); + + this.chosenSelectors = answers.map( + answer => this.existingSelectors.find(({ displayName }) => answer === displayName) || defaultExistingSelector, + ); + } + + configuring() { + this.templateConfig = { + SELECTOR_NAME: this.fileName, + USED_SELECTORS: this.chosenSelectors.map(s => s.source === 'reducers' ? `s.${s.name}` : s.name), + STATE_NAMES: POSSIBLE_STATE_NAMES.slice(0, this.chosenSelectors.length).join(', '), + indent: (amount = 1) => this.indent.repeat(amount), + }; + } + + addExports() { + let currentPath = `${this.topLevelPath}`; + + this.destDir.forEach((subPath: string, index: number) => { + // read + const fileHead = index === 0 ? t.exportAllFromReducers() : ''; + const file = this.fs.read(`${currentPath}/index.js`, { defaults: fileHead }); + // update + const updatedFile = addToFile(file, t.exportAllFrom(subPath), r.selectExportsAll); + // write + this.filesToWrite.push({ name: `${currentPath}/index.js`, content: updatedFile }); + + currentPath += `/${subPath}`; + }); + } + + addSelector() { + const selectorTemplate = this.fs.read(this.templatePath(`${PATH_PREFIX}/selector.ejs`)); + const newSelector = ejs.render(selectorTemplate, this.templateConfig); + + // read + const fileHead = + t.importNamedFrom('createSelector', 'reselect') + '\n' + + t.importAllAsFrom('s', '@/reducers') + '\n'; + const selectorsFile = this.fs.read(`${this.absolutePath}.js`, { defaults: fileHead }); + // update + const updateOptions = { + separator: '\n\n', + }; + const updatedFile = addToFile(selectorsFile, newSelector, r.selectExports, updateOptions); + // write + this.filesToWrite.push({ name: `${this.absolutePath}.js`, content: updatedFile }); + } + + addSelectorTest() { + const testTemplate = this.fs.read(this.templatePath(`${PATH_PREFIX}/selector.test.ejs`)); + const newTest = ejs.render(testTemplate, this.templateConfig); + + // read + const fileHead = + t.importAllAsFrom('s', `./${this.destPath}`) + '\n' + + t.describeTestStart(`selectors/${this.destPath}`); + const testFile = this.fs.read(`${this.absolutePath}.test.js`, { defaults: fileHead }); + // update + const updateOptions = { + appendixIfNew: t.describeTestEnd(), + replaceStringSeparator: ' ', + separator: '\n\n ', + }; + const updatedFile = addToFile(testFile, newTest, r.selectDescribes, updateOptions); + + this.filesToWrite.push({ name: `${this.absolutePath}.test.js`, content: updatedFile }); + } + + writing() { + this.filesToWrite.forEach(({ name, content }) => this.fs.write(name, `${content.trim()}\n`)); + } +} diff --git a/src/generators/templates/selectors/selector.ejs b/src/generators/templates/selectors/selector.ejs new file mode 100644 index 0000000..6e5169e --- /dev/null +++ b/src/generators/templates/selectors/selector.ejs @@ -0,0 +1,8 @@ +export const <%=SELECTOR_NAME%> = createSelector( +<%=indent()%>[ +<%_ USED_SELECTORS.forEach(usedSelector => { _%> +<%=indent(2)%><%= usedSelector %>, +<%_ }); _%> +<%=indent()%>], +<%=indent()%>(<%=STATE_NAMES%>) => {} +); diff --git a/src/generators/templates/selectors/selector.test.ejs b/src/generators/templates/selectors/selector.test.ejs new file mode 100644 index 0000000..4f6b534 --- /dev/null +++ b/src/generators/templates/selectors/selector.test.ejs @@ -0,0 +1,8 @@ +<%=indent()%>describe('<%=SELECTOR_NAME%>', () => { +<%=indent(2)%>it('should return the correct values', () => { +<%=indent(3)%>const state = {}; +<%=indent(3)%>const result = s.<%=SELECTOR_NAME%>(state); + +<%=indent(3)%>expect(result).toBe('myResult'); +<%=indent(2)%>}); +<%=indent()%>}); diff --git a/src/logger.ts b/src/logger.ts index f2c94f4..a5798eb 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -53,4 +53,7 @@ export default { log(comment); } }, + selectorPrompt(command: string) { + log(`Which selectors do you want to use?\nType ${gray.underline(command)} when you're done.`); + }, }; diff --git a/tslint.json b/tslint.json index 2b3827b..6cc1a39 100644 --- a/tslint.json +++ b/tslint.json @@ -2,8 +2,9 @@ "extends": "tslint:recommended", "rules": { "arrow-parens": [true, "ban-single-arg-parens"], - "interface-name":false, - "member-access":false, - "quotemark": [true, "single"] + "interface-name": false, + "member-access": false, + "quotemark": [true, "single"], + "object-literal-sort-keys": false } }