mirror of
https://github.com/strapi/strapi.git
synced 2025-11-02 10:55:37 +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",
|
||||
"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",
|
||||
|
||||
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