mirror of
https://github.com/strapi/strapi.git
synced 2025-12-14 08:44:16 +00:00
Merge pull request #10640 from strapi/features/code-generators
Add code generators
This commit is contained in:
commit
3a5c559505
@ -37,6 +37,7 @@
|
|||||||
"lint-staged": "^10.5.4",
|
"lint-staged": "^10.5.4",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
|
"plop": "2.7.4",
|
||||||
"prettier": "^1.18.2",
|
"prettier": "^1.18.2",
|
||||||
"qs": "6.10.1",
|
"qs": "6.10.1",
|
||||||
"react-test-renderer": "^17.0.2",
|
"react-test-renderer": "^17.0.2",
|
||||||
@ -56,6 +57,7 @@
|
|||||||
"setup": "yarn && yarn build",
|
"setup": "yarn && yarn build",
|
||||||
"watch": "lerna run --stream watch --no-private",
|
"watch": "lerna run --stream watch --no-private",
|
||||||
"build": "lerna run --stream build --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": "npm-run-all -p lint:code lint:css",
|
||||||
"lint:code": "eslint .",
|
"lint:code": "eslint .",
|
||||||
"lint:css": "stylelint packages/**/admin/src/**/*.js",
|
"lint:css": "stylelint packages/**/admin/src/**/*.js",
|
||||||
|
|||||||
82
packages/generators/admin/component/index.js
Normal file
82
packages/generators/admin/component/index.js
Normal 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 };
|
||||||
14
packages/generators/admin/component/templates/actions.js.hbs
Normal file
14
packages/generators/admin/component/templates/actions.js.hbs
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
*
|
||||||
|
* {{name}} constants
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
|
export const DEFAULT_ACTION = '{{plugin}}/{{name}}/DEFAULT_ACTION';
|
||||||
61
packages/generators/admin/component/templates/index.js.hbs
Normal file
61
packages/generators/admin/component/templates/index.js.hbs
Normal 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}};
|
||||||
24
packages/generators/admin/component/templates/reducer.js.hbs
Normal file
24
packages/generators/admin/component/templates/reducer.js.hbs
Normal 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;
|
||||||
@ -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;
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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}}']);
|
||||||
|
});
|
||||||
|
});
|
||||||
78
packages/generators/admin/plopfile.js
Normal file
78
packages/generators/admin/plopfile.js
Normal 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);
|
||||||
|
};
|
||||||
13
packages/generators/admin/utils/fileExistsInPackages.js
Normal file
13
packages/generators/admin/utils/fileExistsInPackages.js
Normal 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;
|
||||||
30
packages/generators/admin/utils/getPluginList.js
Normal file
30
packages/generators/admin/utils/getPluginList.js
Normal 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;
|
||||||
7
packages/generators/admin/utils/packagesFolder.js
Normal file
7
packages/generators/admin/utils/packagesFolder.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { join } = require('path');
|
||||||
|
|
||||||
|
const packagesFolder = join(__dirname, '../../..');
|
||||||
|
|
||||||
|
module.exports = packagesFolder;
|
||||||
2
packages/plugins/documentation/admin/src/utils/index.js
Normal file
2
packages/plugins/documentation/admin/src/utils/index.js
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
|
export { default as getTrad } from './getTrad';
|
||||||
Loading…
x
Reference in New Issue
Block a user