Merge pull request #10640 from strapi/features/code-generators

Add code generators
This commit is contained in:
cyril lopez 2021-07-26 13:55:34 +02:00 committed by GitHub
commit 3a5c559505
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 1348 additions and 526 deletions

View File

@ -37,6 +37,7 @@
"lint-staged": "^10.5.4",
"lodash": "4.17.21",
"npm-run-all": "^4.1.5",
"plop": "2.7.4",
"prettier": "^1.18.2",
"qs": "6.10.1",
"react-test-renderer": "^17.0.2",
@ -56,6 +57,7 @@
"setup": "yarn && yarn build",
"watch": "lerna run --stream watch --no-private",
"build": "lerna run --stream build --no-private",
"generate": "plop --plopfile ./packages/generators/admin/plopfile.js",
"lint": "npm-run-all -p lint:code lint:css",
"lint:code": "eslint .",
"lint:css": "stylelint packages/**/admin/src/**/*.js",

View File

@ -0,0 +1,82 @@
'use strict';
const { join } = require('path');
const { flow, camelCase, upperFirst, lowerCase } = require('lodash');
const fileExistsInPackages = require('../utils/fileExistsInPackages');
const getPluginList = require('../utils/getPluginList');
const packagesFolder = require('../utils/packagesFolder');
const pascalCase = flow(camelCase, upperFirst);
const prompts = [
{
type: 'list',
name: 'plugin',
message: 'Which plugin should be targeted?',
choices: getPluginList,
},
{
type: 'input',
name: 'name',
message: 'What should be the name of the component?',
validate: async (name, answers) => {
if (!name) {
return 'The name cannot be empty.';
}
return (await fileExistsInPackages(`${answers.plugin}/admin/src/components/${name}`))
? 'This component already exists.'
: true;
},
filter: pascalCase,
},
{
type: 'confirm',
name: 'styled',
message: 'Is this a styled component?',
},
{
type: 'input',
name: 'htmlTag',
message: 'Which HTML tag should be used as a base?',
when: answers => answers.styled,
validate: htmlTag => (!htmlTag ? 'The HTML tag cannot be empty.' : true),
filter: lowerCase,
},
{
type: 'confirm',
name: 'useI18n',
message: 'Will it use i18n?',
when: answers => !answers.styled,
},
{
type: 'confirm',
name: 'useRedux',
message: 'Will it use Redux?',
when: answers => !answers.styled,
},
];
const actions = answers => {
const { useRedux } = answers;
const [pluginFolder, plugin] = answers.plugin.split('/');
answers.plugin = plugin;
const templatesFolder = 'component/templates';
const pattern = useRedux ? '**/**.hbs' : '**/index.*.hbs';
const path = join(packagesFolder, pluginFolder, '{{plugin}}/admin/src/components/{{name}}');
return [
{
type: 'addMany',
destination: path,
templateFiles: [`${templatesFolder}/${pattern}`],
base: templatesFolder,
},
// TODO: If redux will be used then 'append' created reducer inside 'reducers.js'
{
type: 'lint',
files: [path],
},
];
};
module.exports = { prompts, actions };

View File

@ -0,0 +1,14 @@
/**
*
* {{name}} actions
*
*/
import { DEFAULT_ACTION } from './constants';
// eslint-disable-next-line import/prefer-default-export
export const defaultAction = () => {
return {
type: DEFAULT_ACTION,
};
};

View File

@ -0,0 +1,8 @@
/**
*
* {{name}} constants
*
*/
// eslint-disable-next-line import/prefer-default-export
export const DEFAULT_ACTION = '{{plugin}}/{{name}}/DEFAULT_ACTION';

View File

@ -0,0 +1,61 @@
{{#if styled}}
import styled from 'styled-components'
{{else}}
/**
*
* {{name}}
*
*/
// import PropTypes from 'prop-types';
import React from 'react';
{{#if useI18n}}
import { useIntl } from 'react-intl';
{{#unless plugin "===" "admin"}}
import { getTrad } from '../../utils';
{{/unless}}
{{/if}}
{{#if useRedux}}
import { useDispatch, useSelector } from 'react-redux';
import { select{{name}}Domain } from './selectors';
import { defaultAction } from './actions';
{{/if}}
{{/if}}
{{#if styled}}
const {{name}} = styled.{{ htmlTag }}``;
{{else}}
const {{name}} = () => {
{{#if useI18n}}
const { formatMessage } = useIntl();
{{/if}}
{{#if useRedux}}
const dispatch = useDispatch();
// eslint-disable-next-line no-unused-vars
const state = useSelector(select{{name}}Domain);
const handleClick = () => dispatch(defaultAction())
{{/if}}
{{#if useI18n "||" useRedux}}
{{/if}}
return (
<div>
{{#if useRedux}}
<button type="button" onClick={handleClick}>{{titleCase name}}</button>
{{/if}}
{{#if useI18n}}
<p>{formatMessage({ id: {{#if plugin "===" "admin"~}}
'component.name'
{{~else~}}
getTrad('component.name')
{{~/if}}, defaultMessage: '{{titleCase name}}' })}</p>
{{/if}}
</div>
);
};
{{name}}.propTypes = {};
{{/if}}
export default {{name}};

View File

@ -0,0 +1,24 @@
/*
*
* {{name}} reducer
*
*/
import produce from 'immer';
import { DEFAULT_ACTION } from './constants';
export const initialState = {};
const {{camelCase name}}Reducer = (state = initialState, action) =>
// eslint-disable-next-line consistent-return
produce(state, draftState => {
switch (action.type) {
case DEFAULT_ACTION: {
break;
}
default:
return draftState;
}
});
export default {{camelCase name}}Reducer;

View File

@ -0,0 +1,17 @@
import { initialState } from './reducer';
{{#unless plugin "===" "admin"}}
import pluginId from '../../pluginId'
{{/unless}}
/**
* Direct selector to the {{name}} state domain
*/
// eslint-disable-next-line import/prefer-default-export
export const select{{name}}Domain = state => state[`
{{~#if plugin "===" "admin"~}}
{{plugin}}
{{~else~}}
${pluginId}
{{~/if~}}
_{{camelCase name}}`] || initialState;

View File

@ -0,0 +1,13 @@
import { defaultAction } from '../actions';
import { DEFAULT_ACTION } from '../constants';
describe('{{plugin}} | components | {{name}} actions', () => {
describe('Default Action', () => {
it('has a type of DEFAULT_ACTION', () => {
const expected = {
type: DEFAULT_ACTION,
};
expect(defaultAction()).toEqual(expected);
});
});
});

View File

@ -0,0 +1,64 @@
/**
*
* Tests for {{name}}
*
*/
import React from 'react';
import { render {{~#if useRedux}} as tlRender {{~/if}} } from '@testing-library/react';
{{#if useRedux}}
import { Provider } from 'react-redux';
import { createStore, combineReducers } from 'redux';
import { initialState } from '../reducer';
import reducers from '../../../reducers';
{{/if}}
{{#if useI18n}}
import { IntlProvider } from 'react-intl';
{{/if}}
import {{name}} from '../index';
{{#if useRedux}}
const rootReducer = combineReducers(reducers);
const render = (
ui,
{
preloadedState = initialState,
store = createStore(rootReducer, { '{{plugin}}_{{camelCase name}}': preloadedState }),
...renderOptions
} = {},
) => {
// eslint-disable-next-line react/prop-types
const Wrapper = ({ children }) => (
<Provider store={store}>{children}</Provider>
);
return tlRender(ui, { wrapper: Wrapper, ...renderOptions });
};
{{/if}}
{{#if useI18n}}
const messages = {
en: {
'{{plugin}}.component.name': '{{titleCase name}}',
},
};
{{/if}}
describe('<{{name}} />', () => {
it('renders and matches the snapshot', () => {
const {
container: { firstChild },
} = render(
{{#if useI18n}}
<IntlProvider locale="en" messages={messages} textComponent="span">
<{{name}} />
</IntlProvider>,
{{else}}
<{{name}} />
{{/if}}
);
expect(firstChild).toMatchInlineSnapshot();
});
});

View File

@ -0,0 +1,21 @@
// import produce from 'immer';
import {{camelCase name}}Reducer, { initialState } from '../reducer';
import { fixtures } from '../../../../../../../admin-test-utils';
// import { someAction } from '../actions';
/* eslint-disable default-case, no-param-reassign */
describe('{{camelCase name}}Reducer', () => {
let state;
beforeEach(() => {
state = {
...fixtures.store.state,
'{{plugin}}_{{camelCase name}}': initialState,
};
});
it('returns the initial state', () => {
const expectedResult = state['{{plugin}}_{{camelCase name}}'];
expect({{camelCase name}}Reducer(undefined, {})).toEqual(expectedResult);
});
});

View File

@ -0,0 +1,17 @@
import { fixtures } from '../../../../../../../admin-test-utils';
import { select{{name}}Domain } from '../selectors';
describe('select{{name}}Domain', () => {
let store;
beforeEach(() => {
store = { ...fixtures.store.state, '{{plugin}}_{{camelCase name}}': {} };
});
it('expects to have unit tests specified', () => {
const actual = select{{name}}Domain(store);
// TBC
expect(actual).toEqual(store['{{plugin}}_{{camelCase name}}']);
});
});

View File

@ -0,0 +1,78 @@
'use strict';
const { ESLint } = require('eslint');
const componentGenerator = require('./component');
// This is used to be able to indent block inside Handlebars helpers and improve templates visibility.
// It's not very robust, and forces you to use 2 spaces indentation inside for your blocks.
// If it become a pain don't hesitate to remove it.
const leftShift = str => str.replace(/^ {2}/gm, '');
const evaluateExpression = (a, operator, b) => {
switch (operator) {
case '==':
return a == b;
case '===':
return a === b;
case '!=':
return a != b;
case '!==':
return a !== b;
case '<':
return a < b;
case '<=':
return a <= b;
case '>':
return a > b;
case '>=':
return a >= b;
case '&&':
return a && b;
case '||':
return a || b;
default:
return false;
}
};
// ! Don't use arrow functions to register Handlebars helpers
module.exports = function(
/** @type {import('plop').NodePlopAPI} */
plop
) {
plop.setHelper('if', function(/* ...args, options */) {
const end = arguments.length - 1;
const { fn, inverse } = arguments[end];
if (arguments.length === 2) {
const condition = arguments[0];
return leftShift(condition ? fn(this) : inverse(this));
} else {
const [a, operator, b] = Array.from(arguments).slice(0, end);
return leftShift(evaluateExpression(a, operator, b) ? fn(this) : inverse(this));
}
});
plop.setHelper('unless', function(/* ...args, options */) {
const end = arguments.length - 1;
const { fn, inverse } = arguments[end];
if (arguments.length === 2) {
const condition = arguments[0];
return leftShift(!condition ? fn(this) : inverse(this));
} else {
const [a, operator, b] = Array.from(arguments).slice(0, end);
return leftShift(!evaluateExpression(a, operator, b) ? fn(this) : inverse(this));
}
});
plop.setHelper('else', function(_, { fn }) {
return leftShift(fn(this));
});
plop.setActionType('lint', async function(answers, config, plopfileApi) {
const { files } = config;
const patterns = files.map(file => plopfileApi.renderString(file, answers));
const eslint = new ESLint({ fix: true });
const results = await eslint.lintFiles(patterns);
await ESLint.outputFixes(results);
return 'Linting errors autofixed.';
});
plop.setGenerator('component', componentGenerator);
};

View File

@ -0,0 +1,13 @@
'use strict';
const fs = require('fs');
const { join } = require('path');
const packagesFolder = require('./packagesFolder');
const fileExistsInPackages = path =>
fs.promises
.access(join(packagesFolder, path))
.then(() => true)
.catch(() => false);
module.exports = fileExistsInPackages;

View File

@ -0,0 +1,30 @@
'use strict';
const glob = require('glob');
const fileExistsInPackages = require('./fileExistsInPackages');
const packagesFolder = require('./packagesFolder');
const asyncFilter = async (array, predicate) => {
const results = await Promise.all(array.map(predicate));
return array.filter((_, index) => results[index]);
};
const getPluginList = () => {
return new Promise((resolve, reject) => {
glob(
'{core,plugins}/*',
{ ignore: ['**/node_modules'], cwd: packagesFolder },
async (err, matches) => {
if (err) {
reject(err);
}
const extendsAdmin = match => fileExistsInPackages(`${match}/admin/src`);
resolve(await asyncFilter(matches, extendsAdmin));
}
);
});
};
module.exports = getPluginList;

View File

@ -0,0 +1,7 @@
'use strict';
const { join } = require('path');
const packagesFolder = join(__dirname, '../../..');
module.exports = packagesFolder;

View File

@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as getTrad } from './getTrad';

1421
yarn.lock

File diff suppressed because it is too large Load Diff