diff --git a/packages/grafana-toolkit/src/cli/index.ts b/packages/grafana-toolkit/src/cli/index.ts index 9fc493f2a4d..c06d874b3e0 100644 --- a/packages/grafana-toolkit/src/cli/index.ts +++ b/packages/grafana-toolkit/src/cli/index.ts @@ -19,6 +19,7 @@ import { ciBuildPluginTask, ciBuildPluginDocsTask, ciPackagePluginTask, ciPlugin import { buildPackageTask } from './tasks/package.build'; import { pluginCreateTask } from './tasks/plugin.create'; import { bundleManagedTask } from './tasks/plugin/bundle.managed'; +import { componentCreateTask } from './tasks/component.create'; export const run = (includeInternalScripts = false) => { if (includeInternalScripts) { @@ -114,6 +115,16 @@ export const run = (includeInternalScripts = false) => { milestone: cmd.milestone, }); }); + + // React generator + program + .command('component:create') + .description( + 'Scaffold React components. Optionally add test, story and .mdx files. The components are created in the same dir the script is run from.' + ) + .action(async () => { + await execTask(componentCreateTask)({}); + }); } program diff --git a/packages/grafana-toolkit/src/cli/tasks/component.create.ts b/packages/grafana-toolkit/src/cli/tasks/component.create.ts new file mode 100644 index 00000000000..c730cb1c671 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/component.create.ts @@ -0,0 +1,86 @@ +import { Task, TaskRunner } from './task'; +import fs from 'fs'; +import _ from 'lodash'; +import { prompt } from 'inquirer'; +import { pascalCase } from '../utils/pascalCase'; +import { promptConfirm, promptInput, promptList } from '../utils/prompt'; +import { componentTpl, docsTpl, storyTpl, testTpl } from '../templates'; + +interface Details { + name?: string; + hasStory: boolean; + group?: string; + isStoryPublic: boolean; + hasTests: boolean; +} + +interface GeneratorOptions { + details: Details; + path: string; +} + +type ComponentGenerator = (options: GeneratorOptions) => Promise; + +const componentGroups = [ + { name: 'General', value: 'General' }, + { name: 'Forms', value: 'Forms' }, + { name: 'Panel', value: 'Panel' }, + { name: 'Visualizations', value: 'Visualizations' }, + { name: 'Others', value: 'Others' }, +]; + +export const promptDetails = () => { + return prompt
([ + promptInput('name', 'Component name', true), + promptConfirm('hasTests', "Generate component's test file?"), + promptConfirm('hasStory', "Generate component's story file?"), + promptConfirm( + 'isStoryPublic', + 'Generate public story? (Selecting "No" will create an internal story)', + true, + ({ hasStory }) => hasStory + ), + promptList( + 'group', + 'Select component group for the story (e.g. Forms, Layout)', + () => componentGroups, + 0, + ({ hasStory }) => hasStory + ), + ]); +}; + +export const generateComponents: ComponentGenerator = async ({ details, path }) => { + const name = pascalCase(details.name); + const getCompiled = (template: string) => { + return _.template(template)({ ...details, name }); + }; + const filePath = `${path}/${name}`; + let paths = []; + + fs.writeFileSync(`${filePath}.tsx`, getCompiled(componentTpl)); + paths.push(`${filePath}.tsx`); + + if (details.hasTests) { + fs.writeFileSync(`${filePath}.test.tsx`, getCompiled(testTpl)); + paths.push(`${filePath}.test.tsx`); + } + + if (details.hasStory) { + const storyExt = details.isStoryPublic ? '.story.tsx' : '.story.internal.tsx'; + fs.writeFileSync(`${filePath}${storyExt}`, getCompiled(storyTpl)); + fs.writeFileSync(`${filePath}.mdx`, getCompiled(docsTpl)); + paths.push(`${filePath}${storyExt}`, `${filePath}.mdx`); + } + + console.log('Generated files:'); + console.log(paths.join('\n')); +}; + +const componentCreateRunner: TaskRunner = async () => { + const destPath = process.cwd(); + const details = await promptDetails(); + await generateComponents({ details, path: destPath }); +}; + +export const componentCreateTask = new Task('component:create', componentCreateRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/manifest.test.ts b/packages/grafana-toolkit/src/cli/tasks/manifest.test.ts index 63d0b701313..4f3be288850 100644 --- a/packages/grafana-toolkit/src/cli/tasks/manifest.test.ts +++ b/packages/grafana-toolkit/src/cli/tasks/manifest.test.ts @@ -8,6 +8,7 @@ describe('Manifest', () => { "changelog.ts", "cherrypick.ts", "closeMilestone.ts", + "component.create.ts", "core.start.ts", "manifest.test.ts", "manifest.ts", diff --git a/packages/grafana-toolkit/src/cli/templates/component.test.tsx.template.ts b/packages/grafana-toolkit/src/cli/templates/component.test.tsx.template.ts new file mode 100644 index 00000000000..9c5b198a1ea --- /dev/null +++ b/packages/grafana-toolkit/src/cli/templates/component.test.tsx.template.ts @@ -0,0 +1,12 @@ +export const testTpl = ` +import React from 'react'; +import { shallow } from 'enzyme'; +import { <%= name %> } from './<%= name %>'; + + +describe('<%= name %>', () => { + it.skip('should render', () => { + + }); +}); +`; diff --git a/packages/grafana-toolkit/src/cli/templates/component.tsx.template.ts b/packages/grafana-toolkit/src/cli/templates/component.tsx.template.ts new file mode 100644 index 00000000000..3beb79f06e2 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/templates/component.tsx.template.ts @@ -0,0 +1,11 @@ +export const componentTpl = ` +import React, { FC } from 'react'; + +interface Props = {}; + +export const <%= name %>: FC = (props) => { + return ( +
Hello world!
+ ) +}; +`; diff --git a/packages/grafana-toolkit/src/cli/templates/docs.mdx.template.ts b/packages/grafana-toolkit/src/cli/templates/docs.mdx.template.ts new file mode 100644 index 00000000000..e3cd6e90b6e --- /dev/null +++ b/packages/grafana-toolkit/src/cli/templates/docs.mdx.template.ts @@ -0,0 +1,16 @@ +export const docsTpl = `import { Story, Preview, Props } from '@storybook/addon-docs/blocks'; +import { <%= name %> } from './<%= name %>'; + +# <%= name %> + +### Usage + +\`\`\`jsx +import { <%= name %> } from '@grafana/ui'; + +<<%= name %> /> +\`\`\` + +### Props +} /> +`; diff --git a/packages/grafana-toolkit/src/cli/templates/index.ts b/packages/grafana-toolkit/src/cli/templates/index.ts new file mode 100644 index 00000000000..57a7319ccbb --- /dev/null +++ b/packages/grafana-toolkit/src/cli/templates/index.ts @@ -0,0 +1,4 @@ +export { componentTpl } from './component.tsx.template'; +export { storyTpl } from './story.tsx.template'; +export { docsTpl } from './docs.mdx.template'; +export { testTpl } from './component.test.tsx.template'; diff --git a/packages/grafana-toolkit/src/cli/templates/story.tsx.template.ts b/packages/grafana-toolkit/src/cli/templates/story.tsx.template.ts new file mode 100644 index 00000000000..56c997e6963 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/templates/story.tsx.template.ts @@ -0,0 +1,22 @@ +export const storyTpl = ` +import React from 'react'; +import { <%= name %> } from './<%= name %>'; +import { withCenteredStory } from '@grafana/ui/src/utils/storybook/withCenteredStory'; +import mdx from './<%= name %>.mdx'; + + +export default { + title: '<%= group %>/<%= name %>', + component: <%= name %>, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + }, +}; + +export const single = () => { + return <<%= name %> />; +}; +`; diff --git a/packages/grafana-toolkit/src/cli/utils/pascalCase.ts b/packages/grafana-toolkit/src/cli/utils/pascalCase.ts new file mode 100644 index 00000000000..e842a711aa8 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/utils/pascalCase.ts @@ -0,0 +1,3 @@ +import _ from 'lodash'; + +export const pascalCase = _.flow(_.camelCase, _.upperFirst); diff --git a/packages/grafana-toolkit/src/cli/utils/prompt.ts b/packages/grafana-toolkit/src/cli/utils/prompt.ts index 5cdf9211056..668f64c7fcd 100644 --- a/packages/grafana-toolkit/src/cli/utils/prompt.ts +++ b/packages/grafana-toolkit/src/cli/utils/prompt.ts @@ -6,6 +6,8 @@ import { PasswordQuestion, EditorQuestion, ConfirmQuestion, + ListQuestion, + ChoiceOptions, } from 'inquirer'; type QuestionWithValidation = @@ -40,6 +42,25 @@ export const promptInput = ( return required ? answerRequired(model) : model; }; +export const promptList = ( + name: string, + message: string | ((answers: A) => string), + choices: () => ChoiceOptions[], + def: any = undefined, + when: boolean | ((answers: A) => boolean | Promise) = true +) => { + const model: ListQuestion = { + type: 'list', + name, + message, + choices, + default: def, + when, + }; + + return model; +}; + export const promptConfirm = ( name: string, message: string | ((answers: A) => string),