diff --git a/tools/generators/react-component/README.md b/tools/generators/react-component/README.md new file mode 100644 index 00000000000000..162cc609c3834a --- /dev/null +++ b/tools/generators/react-component/README.md @@ -0,0 +1,45 @@ +# react-component + +Workspace Generator for creating React Component within v9 package. + + + +- [Usage](#usage) + - [Examples](#examples) +- [Options](#options) + - [`name`](#name) + - [`project`](#project) + + + +## Usage + +```sh +yarn nx workspace-generator react-component --help +``` + +Show what will be generated without writing to disk: + +```sh +yarn nx workspace-generator react-component --dry-run +``` + +### Examples + +```sh +yarn nx workspace-generator react-component +``` + +## Options + +#### `name` + +Type: `string` + +The name of the component. + +#### `project` + +Type: `string` + +The name of the project where the component will be generated. diff --git a/tools/generators/react-component/files/component/__componentName__.test.tsx__tmpl__ b/tools/generators/react-component/files/component/__componentName__.test.tsx__tmpl__ new file mode 100644 index 00000000000000..e0e546f4908c2e --- /dev/null +++ b/tools/generators/react-component/files/component/__componentName__.test.tsx__tmpl__ @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { isConformant } from '../../testing/isConformant'; +import { <%= componentName %> } from './<%= componentName %>'; + +describe('<%= componentName %>', () => { + isConformant({ + Component: <%= componentName %>, + displayName: '<%= componentName %>', + }); + + // TODO add more tests here, and create visual regression tests in /apps/vr-tests + + it('renders a default state', () => { + const result = render(<<%= componentName %>>Default <%= componentName %>>); + expect(result.container).toMatchSnapshot(); + }); +}); diff --git a/tools/generators/react-component/files/component/__componentName__.tsx__tmpl__ b/tools/generators/react-component/files/component/__componentName__.tsx__tmpl__ new file mode 100644 index 00000000000000..970c0c293b20e4 --- /dev/null +++ b/tools/generators/react-component/files/component/__componentName__.tsx__tmpl__ @@ -0,0 +1,18 @@ +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { use<%= componentName %>_unstable } from './use<%= componentName %>'; +import { render<%= componentName %>_unstable } from './render<%= componentName %>'; +import { use<%= componentName %>Styles_unstable } from './use<%= componentName %>Styles.styles'; +import type { <%= componentName %>Props } from './<%= componentName %>.types'; + +/** + * <%= componentName %> component - TODO: add more docs + */ +export const <%= componentName %>: ForwardRefComponent<<%= componentName %>Props> = React.forwardRef((props, ref) => { + const state = use<%= componentName %>_unstable(props, ref); + + use<%= componentName %>Styles_unstable(state); + return render<%= componentName %>_unstable(state); +}); + +<%= componentName %>.displayName = '<%= componentName %>'; diff --git a/tools/generators/react-component/files/component/__componentName__.types.ts__tmpl__ b/tools/generators/react-component/files/component/__componentName__.types.ts__tmpl__ new file mode 100644 index 00000000000000..26aaaef3cb5464 --- /dev/null +++ b/tools/generators/react-component/files/component/__componentName__.types.ts__tmpl__ @@ -0,0 +1,18 @@ +import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; + +export type <%= componentName %>Slots = { + root: Slot<'div'>; +}; + +/** + * <%= componentName %> Props + */ +export type <%= componentName %>Props = ComponentProps<<%= componentName %>Slots> & {}; + +/** + * State used in rendering <%= componentName %> + */ +export type <%= componentName %>State = ComponentState<<%= componentName %>Slots>; +// TODO: Remove semicolon from previous line, uncomment next line, and provide union of props to pick from <%= componentName %>Props. +// & RequiredProps, 'propName'>> +; diff --git a/tools/generators/react-component/files/component/index.ts__tmpl__ b/tools/generators/react-component/files/component/index.ts__tmpl__ new file mode 100644 index 00000000000000..b71d9258498439 --- /dev/null +++ b/tools/generators/react-component/files/component/index.ts__tmpl__ @@ -0,0 +1,5 @@ +export * from './<%= componentName %>'; +export * from './<%= componentName %>.types'; +export * from './render<%= componentName %>'; +export * from './use<%= componentName %>'; +export * from './use<%= componentName %>Styles.styles'; diff --git a/tools/generators/react-component/files/component/render__componentName__.tsx__tmpl__ b/tools/generators/react-component/files/component/render__componentName__.tsx__tmpl__ new file mode 100644 index 00000000000000..77735347d2f0f3 --- /dev/null +++ b/tools/generators/react-component/files/component/render__componentName__.tsx__tmpl__ @@ -0,0 +1,16 @@ +/** @jsxRuntime classic */ +/** @jsx createElement */ + +import { createElement } from '@fluentui/react-jsx-runtime'; +import { getSlotsNext } from '@fluentui/react-utilities'; +import type { <%= componentName %>State, <%= componentName %>Slots } from './<%= componentName %>.types'; + +/** + * Render the final JSX of <%= componentName %> + */ +export const render<%= componentName %>_unstable = (state: <%= componentName %>State) => { + const { slots, slotProps } = getSlotsNext<<%= componentName %>Slots>(state); + + // TODO Add additional slots in the appropriate place + return ; +}; diff --git a/tools/generators/react-component/files/component/use__componentName__.ts__tmpl__ b/tools/generators/react-component/files/component/use__componentName__.ts__tmpl__ new file mode 100644 index 00000000000000..a579cb15496f29 --- /dev/null +++ b/tools/generators/react-component/files/component/use__componentName__.ts__tmpl__ @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { getNativeElementProps } from '@fluentui/react-utilities'; +import type { <%= componentName %>Props, <%= componentName %>State } from './<%= componentName %>.types'; + +/** + * Create the state required to render <%= componentName %>. + * + * The returned state can be modified with hooks such as use<%= componentName %>Styles_unstable, + * before being passed to render<%= componentName %>_unstable. + * + * @param props - props from this instance of <%= componentName %> + * @param ref - reference to root HTMLElement of <%= componentName %> + */ +export const use<%= componentName %>_unstable = (props: <%= componentName %>Props, ref: React.Ref): <%= componentName %>State => { + return { + // TODO add appropriate props/defaults + components: { + // TODO add each slot's element type or component + root: 'div', + }, + // TODO add appropriate slots, for example: + // mySlot: resolveShorthand(props.mySlot), + root: getNativeElementProps('div', { + ref, + ...props, + }), + }; +}; diff --git a/tools/generators/react-component/files/component/use__componentName__Styles.styles.ts__tmpl__ b/tools/generators/react-component/files/component/use__componentName__Styles.styles.ts__tmpl__ new file mode 100644 index 00000000000000..2b504f46913556 --- /dev/null +++ b/tools/generators/react-component/files/component/use__componentName__Styles.styles.ts__tmpl__ @@ -0,0 +1,34 @@ +import { makeStyles, mergeClasses } from '@griffel/react'; +import type { SlotClassNames } from '@fluentui/react-utilities'; +import type { <%= componentName %>Slots, <%= componentName %>State } from './<%= componentName %>.types'; + + +export const <%= propertyName %>ClassNames:SlotClassNames<<%= componentName %>Slots> = { + root: 'fui-<%= componentName %>' + // TODO: add class names for all slots on <%= componentName %>Slots. + // Should be of the form `: 'fui-<%= componentName %>__` +}; + +/** + * Styles for the root slot + */ +const useStyles = makeStyles({ + root: { + // TODO Add default styles for the root element + }, + + // TODO add additional classes for different states and/or slots +}); + +/** + * Apply styling to the <%= componentName %> slots based on the state + */ +export const use<%= componentName %>Styles_unstable = (state: <%= componentName %>State): <%= componentName %>State => { + const styles = useStyles(); + state.root.className = mergeClasses(<%= propertyName %>ClassNames.root, styles.root, state.root.className); + + // TODO Add class names to slots, for example: + // state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className); + + return state; +}; diff --git a/tools/generators/react-component/files/story/__componentName__BestPractices.md__tmpl__ b/tools/generators/react-component/files/story/__componentName__BestPractices.md__tmpl__ new file mode 100644 index 00000000000000..08ff8ddeeb5f86 --- /dev/null +++ b/tools/generators/react-component/files/story/__componentName__BestPractices.md__tmpl__ @@ -0,0 +1,5 @@ +## Best practices + +### Do + +### Don't diff --git a/tools/generators/react-component/files/story/__componentName__Default.stories.tsx__tmpl__ b/tools/generators/react-component/files/story/__componentName__Default.stories.tsx__tmpl__ new file mode 100644 index 00000000000000..51df0d79f6b656 --- /dev/null +++ b/tools/generators/react-component/files/story/__componentName__Default.stories.tsx__tmpl__ @@ -0,0 +1,6 @@ +import * as React from 'react'; +import { <%= componentName %>, <%= componentName %>Props } from '<%= npmPackageName %>'; + +export const Default = (props: Partial<<%= componentName %>Props>) => ( + <<%= componentName %> {...props} /> +); diff --git a/tools/generators/react-component/files/story/__componentName__Description.md__tmpl__ b/tools/generators/react-component/files/story/__componentName__Description.md__tmpl__ new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tools/generators/react-component/files/story/index.stories.tsx__tmpl__ b/tools/generators/react-component/files/story/index.stories.tsx__tmpl__ new file mode 100644 index 00000000000000..37cffb0041e53b --- /dev/null +++ b/tools/generators/react-component/files/story/index.stories.tsx__tmpl__ @@ -0,0 +1,18 @@ +import { <%= componentName %> } from '<%= npmPackageName %>'; + +import descriptionMd from './<%= componentName %>Description.md'; +import bestPracticesMd from './<%= componentName %>BestPractices.md'; + +export { Default } from './<%= componentName %>Default.stories'; + +export default { + title: 'Preview Components/<%= componentName %>', + component: <%= componentName %>, + parameters: { + docs: { + description: { + component: [descriptionMd, bestPracticesMd].join('\n'), + } + } + } +}; diff --git a/tools/generators/react-component/index.spec.ts b/tools/generators/react-component/index.spec.ts new file mode 100644 index 00000000000000..1233ba9d8725d6 --- /dev/null +++ b/tools/generators/react-component/index.spec.ts @@ -0,0 +1,197 @@ +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import { Tree, addProjectConfiguration, writeJson, joinPathFragments } from '@nrwl/devkit'; + +import generator from './index'; + +describe('react-component generator', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + createLibrary(tree, 'react-one'); + }); + + describe(`assertions`, () => { + it(`should throw error if one wants to add component to non v9 package`, async () => { + createLibrary(tree, 'react-old', { tags: ['v8'], version: '8.123.4' }); + try { + await generator(tree, { project: '@proj/react-old', name: 'MyOne' }); + } catch (err) { + expect(err).toMatchInlineSnapshot( + `[Error: this generator works only with v9 packages. "@proj/react-old" is not!]`, + ); + } + }); + + it(`should throw error if component already exists`, async () => { + await generator(tree, { project: '@proj/react-one', name: 'MyOne' }); + + try { + await generator(tree, { project: '@proj/react-one', name: 'MyOne' }); + } catch (err) { + expect(err).toMatchInlineSnapshot(`[Error: The component "MyOne" already exists]`); + } + }); + }); + + it('should create component', async () => { + await generator(tree, { project: '@proj/react-one', name: 'MyOne' }); + + const projectSourceRootPath = 'packages/react-components/react-one/src'; + const componentRootPath = `${projectSourceRootPath}/components/MyOne`; + + expect(tree.read(joinPathFragments(projectSourceRootPath, 'MyOne.ts'), 'utf-8')).toMatchInlineSnapshot(` + "export * from './components/MyOne/index'; + " + `); + + expect(tree.children(componentRootPath)).toMatchInlineSnapshot(` + Array [ + "MyOne.test.tsx", + "MyOne.tsx", + "MyOne.types.ts", + "index.ts", + "renderMyOne.tsx", + "useMyOne.ts", + "useMyOneStyles.styles.ts", + ] + `); + + expect(tree.read(joinPathFragments(componentRootPath, 'MyOne.tsx'), 'utf-8')).toMatchInlineSnapshot(` + "import * as React from 'react'; + import type { ForwardRefComponent } from '@fluentui/react-utilities'; + import { useMyOne_unstable } from './useMyOne'; + import { renderMyOne_unstable } from './renderMyOne'; + import { useMyOneStyles_unstable } from './useMyOneStyles.styles'; + import type { MyOneProps } from './MyOne.types'; + + /** + * MyOne component - TODO: add more docs + */ + export const MyOne: ForwardRefComponent = React.forwardRef( + (props, ref) => { + const state = useMyOne_unstable(props, ref); + + useMyOneStyles_unstable(state); + return renderMyOne_unstable(state); + } + ); + + MyOne.displayName = 'MyOne'; + " + `); + + expect(tree.read(joinPathFragments(componentRootPath, 'useMyOneStyles.styles.ts'), 'utf-8')).toMatchInlineSnapshot(` + "import { makeStyles, mergeClasses } from '@griffel/react'; + import type { SlotClassNames } from '@fluentui/react-utilities'; + import type { MyOneSlots, MyOneState } from './MyOne.types'; + + export const myOneClassNames: SlotClassNames = { + root: 'fui-MyOne', + // TODO: add class names for all slots on MyOneSlots. + // Should be of the form \`: 'fui-MyOne__\` + }; + + /** + * Styles for the root slot + */ + const useStyles = makeStyles({ + root: { + // TODO Add default styles for the root element + }, + + // TODO add additional classes for different states and/or slots + }); + + /** + * Apply styling to the MyOne slots based on the state + */ + export const useMyOneStyles_unstable = (state: MyOneState): MyOneState => { + const styles = useStyles(); + state.root.className = mergeClasses( + myOneClassNames.root, + styles.root, + state.root.className + ); + + // TODO Add class names to slots, for example: + // state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className); + + return state; + }; + " + `); + }); + + it(`should update barrel file`, async () => { + await generator(tree, { project: '@proj/react-one', name: 'MyOne' }); + + const projectSourceRootPath = 'packages/react-components/react-one/src'; + const barrelPath = joinPathFragments(projectSourceRootPath, 'index.ts'); + + expect(tree.read(barrelPath, 'utf-8')).toMatchInlineSnapshot(` + "export * from './MyOne'; + " + `); + + await generator(tree, { project: '@proj/react-one', name: 'MyTwo' }); + + expect(tree.read(barrelPath, 'utf-8')).toMatchInlineSnapshot(` + "export * from './MyOne'; + export * from './MyTwo'; + " + `); + }); + + it('should create component story', async () => { + await generator(tree, { project: '@proj/react-one', name: 'MyOne' }); + + const componentStoryRootPath = 'packages/react-components/react-one/stories/MyOne'; + expect(tree.children(componentStoryRootPath)).toMatchInlineSnapshot(` + Array [ + "MyOneBestPractices.md", + "MyOneDefault.stories.tsx", + "MyOneDescription.md", + "index.stories.tsx", + ] + `); + + expect(tree.read(joinPathFragments(componentStoryRootPath, 'index.stories.tsx'), 'utf-8')).toMatchInlineSnapshot(` + "import { MyOne } from '@proj/react-one'; + + import descriptionMd from './MyOneDescription.md'; + import bestPracticesMd from './MyOneBestPractices.md'; + + export { Default } from './MyOneDefault.stories'; + + export default { + title: 'Preview Components/MyOne', + component: MyOne, + parameters: { + docs: { + description: { + component: [descriptionMd, bestPracticesMd].join('\\\\n'), + }, + }, + }, + }; + " + `); + }); +}); + +function createLibrary(tree: Tree, name: string, options: Partial<{ version: string; tags: string[] }> = {}) { + const _options = { version: '9.0.0', tags: ['vNext'], ...options }; + const projectName = '@proj/' + name; + const root = `packages/react-components/${name}`; + const sourceRoot = `${root}/src`; + addProjectConfiguration(tree, projectName, { root, tags: _options.tags, sourceRoot }); + writeJson(tree, joinPathFragments(root, 'package.json'), { + name: projectName, + version: _options.version, + }); + tree.write(joinPathFragments(root, 'stories/.gitkeep'), ''); + tree.write(joinPathFragments(sourceRoot, 'index.ts'), 'export {}'); + + return tree; +} diff --git a/tools/generators/react-component/index.ts b/tools/generators/react-component/index.ts new file mode 100644 index 00000000000000..4d5e00ed371d78 --- /dev/null +++ b/tools/generators/react-component/index.ts @@ -0,0 +1,105 @@ +import * as path from 'path'; +import { Tree, formatFiles, names, generateFiles, joinPathFragments, workspaceRoot } from '@nrwl/devkit'; + +import { getProjectConfig, isPackageConverged } from '../../utils'; + +import { ReactComponentGeneratorSchema } from './schema'; +import { execSync } from 'child_process'; + +interface NormalizedSchema extends ReturnType {} + +export default async function (tree: Tree, schema: ReactComponentGeneratorSchema) { + const options = normalizeOptions(tree, schema); + + assertComponent(tree, options); + + addFiles(tree, options); + + updateBarrel(tree, options); + + await formatFiles(tree); + + return () => { + const root = workspaceRoot; + const { npmPackageName, componentName } = options; + + execSync(`yarn lage generate-api --to ${npmPackageName}`, { + cwd: root, + stdio: 'inherit', + }); + + execSync(`yarn workspace ${npmPackageName} test -t ${componentName}`, { + cwd: root, + stdio: 'inherit', + }); + + execSync(`yarn workspace ${npmPackageName} lint --fix`, { + cwd: root, + stdio: 'inherit', + }); + }; +} + +function normalizeOptions(tree: Tree, options: ReactComponentGeneratorSchema) { + const project = getProjectConfig(tree, { packageName: options.project }); + const nameCasings = names(options.name); + + return { + ...options, + ...project, + ...nameCasings, + directory: 'components', + componentName: nameCasings.className, + npmPackageName: project.projectConfig.name as string, + }; +} + +function addFiles(tree: Tree, options: NormalizedSchema) { + const sourceRoot = options.projectConfig.sourceRoot as string; + const templateOptions = { + ...options, + tmpl: '', + }; + + // component + generateFiles( + tree, + path.join(__dirname, 'files', 'component'), + path.join(sourceRoot, options.directory, options.componentName), + templateOptions, + ); + + tree.write( + joinPathFragments(sourceRoot, options.componentName + '.ts'), + `export * from './${options.directory}/${options.componentName}/index';`, + ); + + // story + generateFiles( + tree, + path.join(__dirname, 'files', 'story'), + path.join(options.paths.stories, options.componentName), + templateOptions, + ); +} + +function updateBarrel(tree: Tree, options: NormalizedSchema) { + const indexPath = joinPathFragments(options.paths.sourceRoot, 'index.ts'); + const content = tree.read(indexPath, 'utf-8') as string; + let newContent = content.replace('export {}', ''); + newContent = newContent + `export * from './${options.componentName}';` + '\n'; + + tree.write(indexPath, newContent); +} + +function assertComponent(tree: Tree, options: NormalizedSchema) { + const componentDirPath = joinPathFragments(options.projectConfig.sourceRoot as string, options.componentName + '.ts'); + + if (!isPackageConverged(tree, options.projectConfig)) { + throw new Error(`this generator works only with v9 packages. "${options.projectConfig.name}" is not!`); + } + if (tree.exists(componentDirPath)) { + throw new Error(`The component "${options.componentName}" already exists`); + } + return; +} diff --git a/tools/generators/react-component/lib/.gitkeep b/tools/generators/react-component/lib/.gitkeep new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tools/generators/react-component/schema.json b/tools/generators/react-component/schema.json new file mode 100644 index 00000000000000..49c6bd3887161a --- /dev/null +++ b/tools/generators/react-component/schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/schema", + "cli": "nx", + "id": "react-component", + "description": "Create React Component within v9 package", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the component.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What name would you like to use for the component?", + "x-priority": "important" + }, + "project": { + "type": "string", + "description": "The name of the project.", + "alias": "p", + "$default": { + "$source": "projectName" + }, + "x-prompt": "What is the name of the project for this component?", + "x-priority": "important" + } + }, + "required": ["name", "project"] +} diff --git a/tools/generators/react-component/schema.ts b/tools/generators/react-component/schema.ts new file mode 100644 index 00000000000000..b427270528df04 --- /dev/null +++ b/tools/generators/react-component/schema.ts @@ -0,0 +1,20 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Create React Component within v9 package + */ +export interface ReactComponentGeneratorSchema { + /** + * The name of the component. + */ + name: string; + /** + * The name of the project. + */ + project: string; +}