diff --git a/packages/react/migrations.json b/packages/react/migrations.json index 63c3499fdf..79f25e8323 100644 --- a/packages/react/migrations.json +++ b/packages/react/migrations.json @@ -98,6 +98,12 @@ "version": "13.0.0-beta.0", "description": "Update tsconfig.json to use `jsxImportSource` to support css prop", "factory": "./src/migrations/update-13-0-0/update-emotion-setup" + }, + "migrate-storybook-to-webpack-5-13.0.0": { + "cli": "nx", + "version": "13.0.0-beta.0", + "description": "Migrate Storybook to use webpack 5", + "factory": "./src/migrations/update-13-0-0/migrate-storybook-to-webpack-5" } }, "packageJsonUpdates": { diff --git a/packages/react/src/migrations/update-13-0-0/migrate-storybook-to-webpack-5.spec.ts b/packages/react/src/migrations/update-13-0-0/migrate-storybook-to-webpack-5.spec.ts new file mode 100644 index 0000000000..dcbcff0f3f --- /dev/null +++ b/packages/react/src/migrations/update-13-0-0/migrate-storybook-to-webpack-5.spec.ts @@ -0,0 +1,361 @@ +import { readJson, Tree, updateJson } from '@nrwl/devkit'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import { migrateStorybookToWebPack5 } from './migrate-storybook-to-webpack-5'; + +describe('migrateStorybookToWebPack5', () => { + let tree: Tree; + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + }); + + it('should add packages needed by Storybook if workspace has the @storybook/react package', async () => { + updateJson(tree, 'package.json', (json) => { + json.devDependencies = { + '@storybook/react': '~6.3.0', + }; + return json; + }); + + await migrateStorybookToWebPack5(tree); + + const json = readJson(tree, '/package.json'); + + expect(json.devDependencies['@storybook/builder-webpack5']).toBe('~6.3.0'); + expect(json.devDependencies['@storybook/manager-webpack5']).toBe('~6.3.0'); + }); + + it('should not add the webpack Storybook packages again if they already exist', async () => { + let newTree = createTreeWithEmptyWorkspace(); + updateJson(newTree, 'package.json', (json) => { + json.dependencies = { + '@storybook/react': '~6.3.0', + '@storybook/builder-webpack5': '~6.3.0', + '@storybook/manager-webpack5': '~6.3.0', + }; + return json; + }); + await migrateStorybookToWebPack5(newTree); + const json = readJson(newTree, '/package.json'); + expect(json.devDependencies['@storybook/builder-webpack5']).toBeUndefined(); + expect(json.devDependencies['@storybook/manager-webpack5']).toBeUndefined(); + }); + + it('should not add Storybook packages if @storybook/react does not exist', async () => { + updateJson(tree, 'package.json', (json) => { + json.devDependencies = { + '@storybook/angular': '~6.3.0', + }; + return json; + }); + await migrateStorybookToWebPack5(tree); + const json = readJson(tree, '/package.json'); + expect(json.devDependencies['@storybook/builder-webpack5']).toBeUndefined(); + expect(json.devDependencies['@storybook/manager-webpack5']).toBeUndefined(); + }); + + describe('updating project-level .storybook/main.js configurations for webpack 5', () => { + beforeEach(async () => { + updateJson(tree, 'package.json', (json) => { + json.devDependencies = { + '@storybook/react': '~6.3.0', + }; + return json; + }); + + updateJson(tree, 'workspace.json', (json) => { + json = { + ...json, + projects: { + ...json.projects, + 'test-one': { + targets: { + storybook: { + options: { + uiFramework: '@storybook/react', + config: { + configFolder: 'libs/test-one/.storybook', + }, + }, + }, + }, + }, + 'test-two': { + targets: { + storybook: { + options: { + uiFramework: '@storybook/react', + config: { + configFolder: 'libs/test-two/.storybook', + }, + }, + }, + }, + }, + }, + }; + return json; + }); + + tree.write( + `.storybook/main.js`, + `module.exports = { + stories: [], + addons: ['@storybook/addon-essentials'], + // uncomment the property below if you want to apply some webpack config globally + // webpackFinal: async (config, { configType }) => { + // // Make whatever fine-grained changes you need that should apply to all storybook configs + + // // Return the altered config + // return config; + // }, + }; + ` + ); + }); + + describe('when main.js uses new syntax', () => { + it('should update the project-level .storybook/main.js if there is a core object', async () => { + tree.write( + `libs/test-one/.storybook/main.js`, + `const rootMain = require('../../../.storybook/main'); + module.exports = { + ...rootMain, + + core: { ...rootMain.core }, + stories: [ + ...rootMain.stories, + '../src/lib/**/*.stories.mdx', + '../src/lib/**/*.stories.@(js|jsx|ts|tsx)', + ], + addons: [...rootMain.addons, '@nrwl/react/plugins/storybook'], + webpackFinal: async (config, { configType }) => { + // apply any global webpack configs that might have been specified in .storybook/main.js + if (rootMain.webpackFinal) { + config = await rootMain.webpackFinal(config, { configType }); + } + + // add your own webpack tweaks if needed + + return config; + }, + };` + ); + + tree.write( + `libs/test-two/.storybook/main.js`, + `const rootMain = require('../../../.storybook/main'); + module.exports = { + ...rootMain, + core: { ...rootMain.core }, + stories: [ + ...rootMain.stories, + '../src/lib/**/*.stories.mdx', + '../src/lib/**/*.stories.@(js|jsx|ts|tsx)', + ], + addons: [...rootMain.addons, '@nrwl/react/plugins/storybook'], + webpackFinal: async (config, { configType }) => { + // apply any global webpack configs that might have been specified in .storybook/main.js + if (rootMain.webpackFinal) { + config = await rootMain.webpackFinal(config, { configType }); + } + + // add your own webpack tweaks if needed + + return config; + }, + };` + ); + + await migrateStorybookToWebPack5(tree); + + const projectOne = tree.read( + `libs/test-one/.storybook/main.js`, + 'utf-8' + ); + expect(projectOne).toContain(`builder: 'webpack5'`); + const projectTwo = tree.read( + `libs/test-two/.storybook/main.js`, + 'utf-8' + ); + expect(projectTwo).toContain(`builder: 'webpack5'`); + }); + + it('should update the project-level .storybook/main.js if there is not a core object', async () => { + tree.write( + `libs/test-one/.storybook/main.js`, + `const rootMain = require('../../../.storybook/main'); + module.exports = { + ...rootMain, + + stories: [ + ...rootMain.stories, + '../src/lib/**/*.stories.mdx', + '../src/lib/**/*.stories.@(js|jsx|ts|tsx)', + ], + addons: [...rootMain.addons, '@nrwl/react/plugins/storybook'], + webpackFinal: async (config, { configType }) => { + // apply any global webpack configs that might have been specified in .storybook/main.js + if (rootMain.webpackFinal) { + config = await rootMain.webpackFinal(config, { configType }); + } + + // add your own webpack tweaks if needed + + return config; + }, + };` + ); + + tree.write( + `libs/test-two/.storybook/main.js`, + `const rootMain = require('../../../.storybook/main'); + module.exports = { + ...rootMain, + + stories: [ + ...rootMain.stories, + '../src/lib/**/*.stories.mdx', + '../src/lib/**/*.stories.@(js|jsx|ts|tsx)', + ], + addons: [...rootMain.addons, '@nrwl/react/plugins/storybook'], + webpackFinal: async (config, { configType }) => { + // apply any global webpack configs that might have been specified in .storybook/main.js + if (rootMain.webpackFinal) { + config = await rootMain.webpackFinal(config, { configType }); + } + + // add your own webpack tweaks if needed + + return config; + }, + };` + ); + + await migrateStorybookToWebPack5(tree); + const projectOne = tree.read( + `libs/test-one/.storybook/main.js`, + 'utf-8' + ); + expect(projectOne).toContain(`builder: 'webpack5'`); + const projectTwo = tree.read( + `libs/test-two/.storybook/main.js`, + 'utf-8' + ); + expect(projectTwo).toContain(`builder: 'webpack5'`); + }); + + it('should not do anything if project-level .storybook/main.js is invalid', async () => { + tree.write( + `libs/test-one/.storybook/main.js`, + `const rootMain = require('../../../.storybook/main'); + module.exports = { + };` + ); + + tree.write( + `libs/test-two/.storybook/main.js`, + `const rootMain = require('../../../.storybook/main'); + module.exports = { + ...rootMain, + }, + };` + ); + + await migrateStorybookToWebPack5(tree); + const projectOne = tree.read( + `libs/test-one/.storybook/main.js`, + 'utf-8' + ); + expect(projectOne).not.toContain(`builder: 'webpack5'`); + const projectTwo = tree.read( + `libs/test-two/.storybook/main.js`, + 'utf-8' + ); + expect(projectTwo).not.toContain(`builder: 'webpack5'`); + }); + }); + + describe('when main.js uses old syntax', () => { + it('should update the project-level .storybook/main.js if there is a core object', async () => { + tree.write( + `libs/test-one/.storybook/main.js`, + `const rootMain = require('../../../.storybook/main'); + + rootMain.core = { + ...rootMain.core + }; + // Use the following syntax to add addons! + // rootMain.addons.push(''); + rootMain.stories.push( + ...['../src/lib/**/*.stories.mdx', '../src/lib/**/*.stories.@(js|jsx|ts|tsx)'] + ); + module.exports = rootMain;` + ); + + tree.write( + `libs/test-two/.storybook/main.js`, + `const rootMain = require('../../../.storybook/main'); + + rootMain.core = { ...rootMain.core, builder: 'webpack5' }; + // Use the following syntax to add addons! + // rootMain.addons.push(''); + rootMain.stories.push( + ...['../src/lib/**/*.stories.mdx', '../src/lib/**/*.stories.@(js|jsx|ts|tsx)'] + ); + module.exports = rootMain;` + ); + + await migrateStorybookToWebPack5(tree); + + const projectOne = tree.read( + `libs/test-one/.storybook/main.js`, + 'utf-8' + ); + expect(projectOne).toContain(`builder: 'webpack5'`); + const projectTwo = tree.read( + `libs/test-two/.storybook/main.js`, + 'utf-8' + ); + expect(projectTwo).toContain(`builder: 'webpack5'`); + }); + + it('should update the project-level .storybook/main.js if there is not a core object', async () => { + tree.write( + `libs/test-one/.storybook/main.js`, + `const rootMain = require('../../../.storybook/main'); + + // Use the following syntax to add addons! + // rootMain.addons.push(''); + rootMain.stories.push( + ...['../src/lib/**/*.stories.mdx', '../src/lib/**/*.stories.@(js|jsx|ts|tsx)'] + ); + module.exports = rootMain;` + ); + + tree.write( + `libs/test-two/.storybook/main.js`, + `const rootMain = require('../../../.storybook/main'); + + // Use the following syntax to add addons! + // rootMain.addons.push(''); + rootMain.stories.push( + ...['../src/lib/**/*.stories.mdx', '../src/lib/**/*.stories.@(js|jsx|ts|tsx)'] + ); + module.exports = rootMain;` + ); + + await migrateStorybookToWebPack5(tree); + const projectOne = tree.read( + `libs/test-one/.storybook/main.js`, + 'utf-8' + ); + expect(projectOne).toContain(`builder: 'webpack5'`); + const projectTwo = tree.read( + `libs/test-two/.storybook/main.js`, + 'utf-8' + ); + expect(projectTwo).toContain(`builder: 'webpack5'`); + }); + }); + }); +}); diff --git a/packages/react/src/migrations/update-13-0-0/migrate-storybook-to-webpack-5.ts b/packages/react/src/migrations/update-13-0-0/migrate-storybook-to-webpack-5.ts new file mode 100644 index 0000000000..d05226e182 --- /dev/null +++ b/packages/react/src/migrations/update-13-0-0/migrate-storybook-to-webpack-5.ts @@ -0,0 +1,46 @@ +import { Tree, logger, updateJson, readJson } from '@nrwl/devkit'; +import { + migrateToWebPack5, + workspaceHasStorybookForReact, +} from './webpack5-changes-utils'; + +let needsInstall = false; + +export async function migrateStorybookToWebPack5(tree: Tree) { + const packageJson = readJson(tree, 'package.json'); + if (workspaceHasStorybookForReact(packageJson)) { + updateJson(tree, 'package.json', (json) => { + json.dependencies = json.dependencies || {}; + json.devDependencies = json.devDependencies || {}; + + if ( + !json.dependencies['@storybook/builder-webpack5'] && + !json.devDependencies['@storybook/builder-webpack5'] + ) { + needsInstall = true; + json.devDependencies['@storybook/builder-webpack5'] = + workspaceHasStorybookForReact(packageJson); + } + + if ( + !json.dependencies['@storybook/manager-webpack5'] && + !json.devDependencies['@storybook/manager-webpack5'] + ) { + needsInstall = true; + json.devDependencies['@storybook/manager-webpack5'] = + workspaceHasStorybookForReact(packageJson); + } + + return json; + }); + await migrateToWebPack5(tree); + + if (needsInstall) { + logger.info( + 'Please make sure to run npm install or yarn install to get the latest packages added by this migration' + ); + } + } +} + +export default migrateStorybookToWebPack5; diff --git a/packages/react/src/migrations/update-13-0-0/webpack5-changes-utils.ts b/packages/react/src/migrations/update-13-0-0/webpack5-changes-utils.ts new file mode 100644 index 0000000000..d6046ac5f0 --- /dev/null +++ b/packages/react/src/migrations/update-13-0-0/webpack5-changes-utils.ts @@ -0,0 +1,322 @@ +import { getProjects, Tree } from '@nrwl/devkit'; +import { + logger, + formatFiles, + applyChangesToString, + ChangeType, +} from '@nrwl/devkit'; + +import ts = require('typescript'); +import { findNodes } from '@nrwl/workspace/src/utilities/typescript/find-nodes'; + +export async function migrateToWebPack5(tree: Tree) { + allReactProjectsWithStorybookConfiguration(tree).forEach((project) => { + editProjectMainJs( + tree, + `${project.storybookConfigPath}/main.js`, + project.projectName + ); + }); + await formatFiles(tree); +} + +export function workspaceHasStorybookForReact( + packageJson: any +): string | undefined { + return ( + packageJson.dependencies['@storybook/react'] || + packageJson.devDependencies['@storybook/react'] + ); +} + +export function allReactProjectsWithStorybookConfiguration(tree: Tree): { + projectName: string; + storybookConfigPath: string; +}[] { + const projects = getProjects(tree); + const reactProjectsThatHaveStorybookConfiguration: { + projectName: string; + storybookConfigPath: string; + }[] = [...projects.entries()] + ?.filter( + ([_, projectConfig]) => + projectConfig.targets && + projectConfig.targets.storybook && + projectConfig.targets.storybook.options + ) + ?.map(([projectName, projectConfig]) => { + if ( + projectConfig.targets && + projectConfig.targets.storybook && + projectConfig.targets.storybook.options?.config?.configFolder && + projectConfig.targets.storybook.options?.uiFramework === + '@storybook/react' + ) { + return { + projectName, + storybookConfigPath: + projectConfig.targets.storybook.options.config.configFolder, + }; + } + }); + return reactProjectsThatHaveStorybookConfiguration; +} + +export function editProjectMainJs( + tree: Tree, + projectMainJsFile: string, + projectName: string +) { + let newContents: string; + let moduleExportsIsEmptyOrNonExistentOrInvalid = false; + let alreadyHasBuilder: any; + const rootMainJsExists = tree.exists(projectMainJsFile); + let moduleExportsFull: ts.Node[] = []; + + if (rootMainJsExists) { + const file = getTsSourceFile(tree, projectMainJsFile); + const appFileContent = tree.read(projectMainJsFile, 'utf-8'); + newContents = appFileContent; + moduleExportsFull = findNodes(file, [ts.SyntaxKind.ExpressionStatement]); + + if (moduleExportsFull && moduleExportsFull[0]) { + const moduleExports = moduleExportsFull[0]; + + const listOfStatements = findNodes(moduleExports, [ + ts.SyntaxKind.SyntaxList, + ]); + + /** + * Keep the index of the stories node + * to put the core object before it + * if it does not exist already + */ + + let indexOfStoriesNode = -1; + + const hasCoreObject = listOfStatements[0]?.getChildren()?.find((node) => { + if ( + node && + node.getText().length > 0 && + indexOfStoriesNode < 0 && + node?.getText().startsWith('stories') + ) { + indexOfStoriesNode = node.getStart(); + } + return ( + node?.kind === ts.SyntaxKind.PropertyAssignment && + node?.getText().startsWith('core') + ); + }); + + if (hasCoreObject) { + const contentsOfCoreNode = hasCoreObject.getChildren().find((node) => { + return node.kind === ts.SyntaxKind.ObjectLiteralExpression; + }); + const everyAttributeInsideCoreNode = contentsOfCoreNode + .getChildren() + .find((node) => node.kind === ts.SyntaxKind.SyntaxList); + + alreadyHasBuilder = everyAttributeInsideCoreNode + .getChildren() + .find((node) => node.getText() === "builder: 'webpack5'"); + + if (!alreadyHasBuilder) { + newContents = applyChangesToString(newContents, [ + { + type: ChangeType.Insert, + index: contentsOfCoreNode.getEnd() - 1, + text: ", builder: 'webpack5'", + }, + ]); + } + } else if (indexOfStoriesNode >= 0) { + /** + * Does not have core object, + * so just write one, at the start. + */ + newContents = applyChangesToString(newContents, [ + { + type: ChangeType.Insert, + index: indexOfStoriesNode - 1, + text: "core: { ...rootMain.core, builder: 'webpack5' }, ", + }, + ]); + } else { + /** + * Module exports is empty or does not + * contain stories - most probably invalid + */ + moduleExportsIsEmptyOrNonExistentOrInvalid = true; + } + } else { + /** + * module.exports does not exist + */ + moduleExportsIsEmptyOrNonExistentOrInvalid = true; + } + } else { + moduleExportsIsEmptyOrNonExistentOrInvalid = true; + } + + if (moduleExportsIsEmptyOrNonExistentOrInvalid) { + const usesOldSyntax = checkMainJsForOldSyntax( + moduleExportsFull, + newContents + ); + if (moduleExportsFull.length > 0 && usesOldSyntax) { + newContents = usesOldSyntax; + tree.write(projectMainJsFile, newContents); + return; + } else { + logger.info( + `Please configure Storybook for project "${projectName}"", since it has not been configured properly.` + ); + return; + } + } + + if (!alreadyHasBuilder) { + tree.write(projectMainJsFile, newContents); + } +} + +export function checkMainJsForOldSyntax( + nodeList: ts.Node[], + fileContent: string +): string | undefined { + let alreadyContainsBuilder = false; + let coreNode: ts.Node | undefined; + let hasCoreNode = false; + const lastIndexOfFirstNode = nodeList[0].getEnd(); + + if (!fileContent.includes('stories.push') || nodeList.length === 0) { + return undefined; + } + + // Go through the node list and find if the core object exists + // If it does, then we need to check if it has the builder property + // If it does not, then we need to add it + for (let topNode of nodeList) { + if ( + topNode.kind === ts.SyntaxKind.ExpressionStatement && + topNode.getChildren()?.length > 0 + ) { + for (let node of topNode.getChildren()) { + if ( + node.kind === ts.SyntaxKind.BinaryExpression && + node.getChildren()?.length + ) { + for (let childNode of node.getChildren()) { + if ( + childNode.kind === ts.SyntaxKind.PropertyAccessExpression && + childNode.getChildren()?.length + ) { + for (let grandChildNode of childNode.getChildren()) { + if ( + grandChildNode.kind === ts.SyntaxKind.Identifier && + grandChildNode.getText() === 'core' + ) { + coreNode = node; + hasCoreNode = true; + break; + } + } + } + if (hasCoreNode) { + break; + } + } + } + if (hasCoreNode) { + if (coreNode.getChildren()?.length) { + for (let coreChildNode of coreNode.getChildren()) { + if ( + coreChildNode.kind === ts.SyntaxKind.ObjectLiteralExpression && + coreChildNode.getChildren()?.length + ) { + for (let coreChildNodeChild of coreChildNode.getChildren()) { + if (coreChildNodeChild.kind === ts.SyntaxKind.SyntaxList) { + for (let coreChildNodeGrandChild of coreChildNodeChild.getChildren()) { + if ( + coreChildNodeGrandChild.kind === + ts.SyntaxKind.PropertyAssignment && + coreChildNodeGrandChild.getText().startsWith('builder') + ) { + for (let coreChildNodeGrandChildChild of coreChildNodeGrandChild.getChildren()) { + if ( + coreChildNodeGrandChildChild.kind === + ts.SyntaxKind.StringLiteral && + coreChildNodeGrandChildChild.getText() === + 'webpack5' + ) { + alreadyContainsBuilder = true; + break; + } + } + } + if (alreadyContainsBuilder) { + break; + } + } + } + if (alreadyContainsBuilder) { + break; + } + } + } + if (alreadyContainsBuilder) { + break; + } + } + } + break; + } + } + } + if (hasCoreNode) { + if (alreadyContainsBuilder) { + break; + } else { + // Add builder + const indexOfCoreNodeEnd = coreNode.getEnd(); + fileContent = applyChangesToString(fileContent, [ + { + type: ChangeType.Insert, + index: indexOfCoreNodeEnd - 1, + text: ", builder: 'webpack5'", + }, + ]); + break; + } + } + } + + if (!hasCoreNode) { + fileContent = applyChangesToString(fileContent, [ + { + type: ChangeType.Insert, + index: lastIndexOfFirstNode + 1, + text: "rootMain.core = { ...rootMain.core, builder: 'webpack5' };\n", + }, + ]); + } + + return fileContent; +} + +export function getTsSourceFile(host: Tree, path: string): ts.SourceFile { + const buffer = host.read(path); + if (!buffer) { + throw new Error(`Could not read TS file (${path}).`); + } + const content = buffer.toString(); + const source = ts.createSourceFile( + path, + content, + ts.ScriptTarget.Latest, + true + ); + + return source; +}