Skip to content

Commit

Permalink
Add create-component script and improve create-package (#16197)
Browse files Browse the repository at this point in the history
* Update scripts\create-component\create-component.ts

* Add create-package and supporting template files

Co-authored-by: Josche MacDonnell <[email protected]>
  • Loading branch information
joschemd and joschemd-MS authored Jan 27, 2021
1 parent 94b0ffe commit 1670a00
Show file tree
Hide file tree
Showing 19 changed files with 623 additions and 2 deletions.
16 changes: 16 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,22 @@
},
"sourceMaps": true,
"console": "integratedTerminal"
},
{
"name": "Debug create-package",
"type": "node-terminal",
"request": "launch",
"command": "yarn create-package",
"cwd": "${workspaceFolder}",
"outputCapture": "std"
},
{
"name": "Debug create-component",
"type": "node-terminal",
"request": "launch",
"command": "yarn create-component",
"cwd": "${workspaceFolder}",
"outputCapture": "std"
}
]
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"codepen": "cd packages/react && node ../../scripts/local-codepen.js",
"code-style": "lage code-style --verbose",
"create-package": "plop --plopfile ./scripts/create-package/plopfile.ts --dest . --require ./scripts/ts-node-register",
"create-component": "plop --plopfile ./scripts/create-component/create-component.ts --dest . --require ./scripts/ts-node-register",
"change": "beachball change --scope \"!packages/fluentui/*\"",
"dom-test": "cd apps/dom-tests && just-scripts jest-dom-with-webpack",
"generate-version-files": "yarn workspace @fluentui/scripts just generate-version-files",
Expand Down
270 changes: 270 additions & 0 deletions scripts/create-component/create-component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
// Plop script for templating out a converged React component
//#region Imports
import { NodePlopAPI, AddManyActionConfig } from 'plop';
import { Actions } from 'node-plop';
import * as fs from 'fs-extra';
import * as os from 'os';
import { spawnSync } from 'child_process';
import { findGitRoot, getAllPackageInfo } from '../monorepo/index';

//#endregion

//#region Globals
const allPackages = getAllPackageInfo();

const root = findGitRoot();

const rootPaths = {
package: `packages/{{packageName}}`,
component: `packages/{{packageName}}/src/components/{{componentName}}`,
storybook: `packages/react-examples`,
storybookPackageComponent: `packages/react-examples/src/{{packageName}}/{{componentName}}`,
};

const templatePaths = {
component: `plop-templates-component`,
storybookComponent: `plop-templates-storybook`,
// tests: `${root}/create-component/plop-templates-tests`, // Test template implementation for components TBD
};

interface Answers {
packageName: string;
packageNpmName: string;
componentName: string;
hasStorybook?: boolean;
doComponentTestBuild?: boolean;
// hasTests?: boolean; // Test template implementation for components TBD
suggest?: any;
}
//#endregion

//#region Module Export
module.exports = (plop: NodePlopAPI) => {
plop.setWelcomeMessage('This utility is a helper to create converged React components');

plop.setActionType('confirmPackageLocation', confirmPackageLocation);
plop.setActionType('appendToPackageIndex', appendToPackageIndex);
plop.setActionType('checkIfComponentAlreadyExists', checkIfComponentAlreadyExists);

plop.setGenerator('package', {
description: 'New package',

prompts: [
{
type: 'list',
name: 'packageNpmName',
message: 'Which package to create the new component in?',
choices: () => {
return projectsWithStartCommand.map((e, i) => {
return e.title;
});
},
},

{
type: 'input',
name: 'componentName',
message: 'New component name (ex: MyComponent):',
when: answers => answers.packageNpmName,
validate: (input: string) =>
/^[A-Z][a-zA-Z0-9]+$/.test(input) || 'Must enter a PascalCase component name (ex: MyComponent)',
},
{
type: 'confirm',
name: 'doComponentTestBuild',
message: 'Do you wish to run a test build after component creation (Y/n):',
default: true,
when: answers => answers.packageNpmName,
},
{
type: 'confirm',
name: 'hasStorybook',
message: 'Will this package have storybook examples? (Y/n):',
default: true,
when: answers => answers.packageNpmName,
},
// Test template implementation for components TBD
// {
// type: 'confirm',
// name: 'hasTests',
// message: 'Will this component have a test? (Y/n):',
// default: true,
// when: answers => answers.packageNpmName,
// },
],

actions: (answers: Answers): Actions => {
const globOptions: AddManyActionConfig['globOptions'] = { dot: true };

const data = {
...answers,
...{
packageName: answers.packageNpmName.replace('@fluentui/', ''),
},
};

const renderString = (text: string): string => {
return plop.renderString(text, data);
};

return [
() => {
return 'Running create-component actions';
},
{
type: 'confirmPackageLocation',
data,
},
{
type: 'checkIfComponentAlreadyExists',
data,
},
{
// Copy component templates
type: 'addMany',
destination: renderString(rootPaths.package),
globOptions,
data,
skipIfExists: true,
base: 'plop-templates-component',
templateFiles: [`${renderString(templatePaths.component)}/**/*`],
},
{
type: 'appendToPackageIndex',
data,
},
{
// Copy/Update storybook templates
type: 'addMany',
destination: renderString(rootPaths.storybookPackageComponent),
globOptions,
data,
skipIfExists: true,
skip: () => {
if (!data.hasStorybook) {
return 'Skipping storybook scaffolding';
}
},
base: 'plop-templates-storybook',
templateFiles: [`${renderString(templatePaths.storybookComponent)}/**/*`],
},
() => {
console.log('\nPackage files created! Running yarn to link...\n');
const yarnResult = spawnSync('yarn', ['--ignore-scripts'], { cwd: root, stdio: 'inherit', shell: true });
if (yarnResult.status !== 0) {
console.error('Something went wrong with running yarn. Please check previous logs for details');
process.exit(1);
}
return 'Packages linked!';
},
() => {
if (answers.doComponentTestBuild !== true) {
return 'Skipping component test compile.';
}

console.log('Component files created! Running yarn build...\n');
const yarnResult = spawnSync('yarn', [`buildto *${data.packageName}`], {
cwd: root,
stdio: 'inherit',
shell: true,
});
if (yarnResult.status !== 0) {
console.error('Something went wrong with building. Please check previous logs for details');
process.exit(1);
}
return 'Component compiled!';
},
'\nCreated new component! Please check over it and ensure wording and included files ' +
'make sense for your scenario.',
];
},
});
};
//#endregion

//#region Plop Custom Actions
const confirmPackageLocation = (answers: object, config: object, plop: object): string => {
const { packageName } = answers as Answers;
const plopAPI = plop as NodePlopAPI;
const location = plopAPI.renderString(rootPaths.package, answers);
if (fs.existsSync(location) !== true) {
displayAndThrowError(
`**ABORTING** The package ${packageName} cannot be found at location ${location}. Use yarn create-package first.`,
);
}
if (fs.existsSync(`${location}/src/index.ts`) !== true) {
displayAndThrowError(`**ABORTING** The package ${packageName} was found but missing src/index.ts`);
}

return `Found package ${packageName} at location: ${rootPaths.package}`;
};

const checkIfComponentAlreadyExists = (answers: object, config: object, plop: object): string => {
const { componentName } = answers as Answers;
const plopAPI = plop as NodePlopAPI;
const location = plopAPI.renderString(rootPaths.component, answers);

if (fs.existsSync(location) === true && fs.readdirSync(location).length > 0) {
displayAndThrowError(`**ABORTING** The component ${componentName} already exists at ${location}`);
}
return `Component ${componentName} doesn't exist.`;
};

const appendToPackageIndex = async (answers: object, config: object, plop: object): Promise<string> => {
const { componentName, packageName } = answers as Answers;
const plopAPI = plop as NodePlopAPI;

const options = { flag: 'a' };
// get the package index file path
const indexPath = plopAPI.renderString(`${rootPaths.package}/src/index.ts`, answers);
const appendLine = plopAPI.renderString(`export * from './{{componentName}}';`, answers);
// read contents and see if line is exists
return fs
.readFile(indexPath, { encoding: 'utf8', flag: 'r' })
.then(async data => {
const lines = data.split(/\r?\n/);
const getIndex = (arr: string[], item: string): number =>
lines.findIndex(line => item.toLocaleLowerCase() === line.toLocaleLowerCase());
if (getIndex(lines, appendLine) === -1) {
// doesn't exist so append
await fs.outputFile(indexPath, `${appendLine}${os.EOL}`, options);
return `Updated package ${packageName} index.ts to include ${componentName}`;
}
return `Package ${packageName} index.ts already contains reference to ${componentName}`;
})
.catch(error => {
throw `**ABORTING** There was an error reading index file at ${indexPath}. Error: ${error}`;
});
};
//#endregion

//#region Utilities
const displayAndThrowError = (message: string) => {
console.log(message);
throw message;
};

//#endregion

const ignoreProjects = [
'@fluentui/azure-themes',
'@fluentui/docs',
'@fluentui/dom-utilities',
'@fluentui/foundation-legacy',
'@fluentui/react-examples',
'@fluentui/react-next',
'@fluentui/react',
'@fluentui/test-utilities',
'@fluentui/theme',
'@fluentui/webpack-utilities',
];

const projectsWithStartCommand = Object.entries(allPackages)
.filter(
([pkg, info]) =>
!ignoreProjects.includes(pkg) &&
info.packagePath.startsWith('packages') &&
info.packageJson.dependencies &&
info.packageJson.dependencies['@fluentui/react-compose'] !== undefined,
)
.map(([pkg, info]) => ({ title: pkg, value: { pkg, command: 'start' } }));
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { isConformant as baseIsConformant, IsConformantOptions } from '@fluentui/react-conformance';

export function isConformant(testInfo: Omit<IsConformantOptions, 'componentPath'>) {
const defaultOptions = {
disabledTests: ['has-docblock'],
componentPath: module!.parent!.filename.replace('.test', ''),
};

baseIsConformant(defaultOptions, testInfo);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from './{{componentName}}.types';
export * from './{{componentName}}';
export * from './render{{componentName}}';
export * from './use{{componentName}}';
export * from './use{{componentName}}Classes';
export * from './use{{componentName}}State';
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as React from 'react';
import { getSlots } from '@fluentui/react-compose/lib/next/index';
import { {{componentName}}State } from './{{componentName}}.types';
import { {{camelCase componentName}}ShorthandProps } from './use{{componentName}}';

/**
* Redefine the render function to add slots. Reuse the {{lowerCase componentName}} structure but add
* slots to children.
*/
export const render{{componentName}} = (state: {{componentName}}State) => {
const { slots, slotProps } = getSlots(state, {{camelCase componentName}}ShorthandProps);
const { children } = state;

const contentVisible = (children || slotProps.content?.children);

return (
<slots.root {...slotProps.root}>
{contentVisible && <slots.content {...slotProps.content} />}
</slots.root>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as React from 'react';
import { makeMergeProps, resolveShorthandProps } from '@fluentui/react-compose/lib/next/index';
import { {{componentName}}Props, {{componentName}}State } from './{{componentName}}.types';
import { use{{componentName}}State } from './use{{componentName}}State';
import { useMergedRefs } from '@fluentui/react-hooks';

/**
* Consts listing which props are shorthand props.
*/
export const {{camelCase componentName}}ShorthandProps = ['loader', 'content'];

const mergeProps = makeMergeProps({ deepMerge: {{camelCase componentName}}ShorthandProps });

/**
* Given user props, returns state and render function for a {{componentName}}.
*/
export const use{{componentName}} = (props: {{componentName}}Props, ref: React.Ref<HTMLElement>, defaultProps?: {{componentName}}Props) => {
// Ensure that the `ref` prop can be used by other things (like useFocusRects) to refer to the root.
// NOTE: We are assuming refs should not mutate to undefined. Either they are passed or not.
const defaultRef = React.useRef<HTMLElement>(null);
const resolvedRef = ref || defaultRef;

const state = mergeProps(
{
ref: resolvedRef,
as: 'div'
},
defaultProps,
resolveShorthandProps(props, {{camelCase componentName}}ShorthandProps),
);

use{{componentName}}State(state);

return state as {{componentName}}State;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { makeVariantClasses } from '@fluentui/react-theme-provider';
import { {{componentName}}State, {{componentName}}Variants } from './{{componentName}}.types';

const GlobalClassNames = {
root: 'ms-{{componentName}}'
};

export const use{{componentName}}Classes = makeVariantClasses<{{componentName}}State, {{componentName}}Variants>({
name: '{{componentName}}',
prefix: '--{{lowerCase componentName}}',
styles: {
root: [
GlobalClassNames.root
]
}
});
Loading

0 comments on commit 1670a00

Please sign in to comment.