diff --git a/.eslintignore b/.eslintignore index 298ccbdd2f0041..2933e3842db111 100644 --- a/.eslintignore +++ b/.eslintignore @@ -11,6 +11,7 @@ /examples/material-ui-nextjs/src /packages/mui-codemod/lib /packages/mui-codemod/src/*/*.test/* +/packages/mui-codemod/src/**/test-cases/* /packages/mui-icons-material/fixtures /packages/mui-icons-material/legacy /packages/mui-icons-material/lib diff --git a/packages/mui-codemod/CONTRIBUTING.md b/packages/mui-codemod/CONTRIBUTING.md new file mode 100644 index 00000000000000..f4fc9feba22676 --- /dev/null +++ b/packages/mui-codemod/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# Contributing + +## Understanding the codemod + +The codemod is a tool that helps developers migrate thier codebase when we introduced changes in new version. The changes could be deprecations, enhancements, or breaking changes. + +The codemod is based on [jscodeshift](https://github.com/facebook/jscodeshift) which is a wrapper of [recast](https://github.com/benjamn/recast). + +## Adding a new codemod + +1. Create a new folder in `packages/mui-codemod/src/*/*` with the name of the codemod. +2. The folder should include: + - `.js` - the transform implementation + - `.test.js` - tests for the codemod (use jscodeshift from the `testUtils` folder) + - `test-cases` - folder with fixtures for the codemod + - `actual.js` - the input for the codemod + - `expected.js` - the expected output of the codemod +3. Use [astexplorer](https://astexplorer.net/) to check the AST types and properties (set to @babel/parser because we use [`tsx`](https://github.com/benjamn/recast/blob/master/parsers/babel.ts) as a default parser for our codemod). +4. [Test the codemod locally](#local) +5. Add the codemod to README.md + +## Testing + +I recommend to follow these steps to test the codemod: + +- Create an `actual.js` file with the code you want to transform. +- Run [local](#local) transformation to check if the codemod is correct. +- Copy the transformed code to `expected.js`. +- Run `pnpm tc ` to final check if the codemod is correct. + +šŸ’” The reason that I don't recommend creating the `expected.js` and run the test with `pnpm` script is because the transformation is likely not pretty-printed and it's hard to compare the output with the expected output. + +### Local transformation (while developing) + +Open the terminal at root directory and run the codemod to test the transformation, for example, testing the `accordion-props` codemod: + +```bash +node packages/mui-codemod/codemod deprecations/accordion-props packages/mui-codemod/src/deprecations/accordion-props/test-cases/theme.actual.js +``` + +### CI (after opening a PR) + +To simulate a consumer-facing experience on any project before merging the PR, open the CodeSandbox CI build and copy the link from the "Local Install Instructions" section. + +Run the codemod to test the transformation: + +```bash +npx @mui/codemod@ +``` + +For example: + +```bash +npx @mui/codemod@https://pkg.csb.dev/mui/material-ui/commit/39bf9464/@mui/codemod deprecations/accordion-props docs/src/modules/brandingTheme.ts +``` diff --git a/packages/mui-codemod/README.md b/packages/mui-codemod/README.md index 0a1f256699e47c..acabaf5646f6cc 100644 --- a/packages/mui-codemod/README.md +++ b/packages/mui-codemod/README.md @@ -60,9 +60,40 @@ npx @mui/codemod@latest --jscodeshift="--printOptions='{\"quo ## Included scripts +- [Deprecation](#deprecations) +- [v5](#v500) +- [v4](#v400) +- [v1](#v100) +- [v0.15](#v0150) + +### Deprecations + +```bash +npx @mui/codemod@latest deprecations/all +``` + +#### `all` + +A combination of all deprecations. + +#### `accordion-props` + +```diff + +``` + +```bash +npx @mui/codemod@latest deprecations/accordion-props +``` + ### v5.0.0 -### `base-use-named-exports` +#### `base-use-named-exports` BaseĀ UI default exports were changed to named ones. Previously we had a mix of default and named ones. This was changed to improve consistency and avoid problems some bundlers have with default exports. @@ -81,7 +112,7 @@ This codemod updates the import and re-export statements. npx @mui/codemod@latest v5.0.0/base-use-named-exports ``` -### `base-remove-unstyled-suffix` +#### `base-remove-unstyled-suffix` The `Unstyled` suffix has been removed from all BaseĀ UI component names, including names of types and other related identifiers. diff --git a/packages/mui-codemod/codemod.js b/packages/mui-codemod/codemod.js index 20c0622c98f27a..5c9e19d739cc49 100755 --- a/packages/mui-codemod/codemod.js +++ b/packages/mui-codemod/codemod.js @@ -10,27 +10,39 @@ const jscodeshiftDirectory = path.dirname(require.resolve('jscodeshift')); const jscodeshiftExecutable = path.join(jscodeshiftDirectory, jscodeshiftPackage.bin.jscodeshift); async function runTransform(transform, files, flags, codemodFlags) { - const transformerSrcPath = path.resolve(__dirname, './src', `${transform}.js`); - const transformerBuildPath = path.resolve(__dirname, './node', `${transform}.js`); + const paths = [ + path.resolve(__dirname, './src', `${transform}/index.js`), + path.resolve(__dirname, './src', `${transform}.js`), + path.resolve(__dirname, './node', `${transform}/index.js`), + path.resolve(__dirname, './node', `${transform}.js`), + ]; + let transformerPath; - try { - await fs.stat(transformerSrcPath); - transformerPath = transformerSrcPath; - } catch (srcPathError) { + let error; + // eslint-disable-next-line no-restricted-syntax + for (const item of paths) { try { - await fs.stat(transformerBuildPath); - transformerPath = transformerBuildPath; - } catch (buildPathError) { - if (buildPathError.code === 'ENOENT') { - throw new Error( - `Transform '${transform}' not found. Check out ${path.resolve( - __dirname, - './README.md for a list of available codemods.', - )}`, - ); - } - throw buildPathError; + // eslint-disable-next-line no-await-in-loop + await fs.stat(item); + error = undefined; + transformerPath = item; + break; + } catch (srcPathError) { + error = srcPathError; + continue; + } + } + + if (error) { + if (error?.code === 'ENOENT') { + throw new Error( + `Transform '${transform}' not found. Check out ${path.resolve( + __dirname, + './README.md for a list of available codemods.', + )}`, + ); } + throw error; } const args = [ diff --git a/packages/mui-codemod/src/deprecations/accordion-props/accordion-props.js b/packages/mui-codemod/src/deprecations/accordion-props/accordion-props.js new file mode 100644 index 00000000000000..dddc01c2288104 --- /dev/null +++ b/packages/mui-codemod/src/deprecations/accordion-props/accordion-props.js @@ -0,0 +1,95 @@ +import findComponentJSX from '../../util/findComponentJSX'; +import assignObject from '../../util/assignObject'; +import appendAttribute from '../../util/appendAttribute'; + +/** + * @param {import('jscodeshift').FileInfo} file + * @param {import('jscodeshift').API} api + */ +export default function transformer(file, api, options) { + const j = api.jscodeshift; + const root = j(file.source); + const printOptions = options.printOptions; + + findComponentJSX(j, { root, componentName: 'Accordion' }, (elementPath) => { + let index = elementPath.node.openingElement.attributes.findIndex( + (attr) => attr.type === 'JSXAttribute' && attr.name.name === 'TransitionComponent', + ); + if (index !== -1) { + const removed = elementPath.node.openingElement.attributes.splice(index, 1); + let hasNode = false; + elementPath.node.openingElement.attributes.forEach((attr) => { + if (attr.name?.name === 'slots') { + hasNode = true; + assignObject(j, { + target: attr, + key: 'transition', + expression: removed[0].value.expression, + }); + } + }); + if (!hasNode) { + appendAttribute(j, { + target: elementPath.node, + attributeName: 'slots', + expression: j.objectExpression([ + j.objectProperty(j.identifier('transition'), removed[0].value.expression), + ]), + }); + } + } + + index = elementPath.node.openingElement.attributes.findIndex( + (attr) => attr.type === 'JSXAttribute' && attr.name.name === 'TransitionProps', + ); + if (index !== -1) { + const removed = elementPath.node.openingElement.attributes.splice(index, 1); + let hasNode = false; + elementPath.node.openingElement.attributes.forEach((attr) => { + if (attr.name?.name === 'slotProps') { + hasNode = true; + assignObject(j, { + target: attr, + key: 'transition', + expression: removed[0].value.expression, + }); + } + }); + if (!hasNode) { + appendAttribute(j, { + target: elementPath.node, + attributeName: 'slotProps', + expression: j.objectExpression([ + j.objectProperty(j.identifier('transition'), removed[0].value.expression), + ]), + }); + } + } + }); + + root.find(j.ObjectProperty, { key: { name: 'TransitionComponent' } }).forEach((path) => { + if (path.parent?.parent?.parent?.parent?.node.key?.name === 'MuiAccordion') { + path.replace( + j.property( + 'init', + j.identifier('slots'), + j.objectExpression([j.objectProperty(j.identifier('transition'), path.node.value)]), + ), + ); + } + }); + + root.find(j.ObjectProperty, { key: { name: 'TransitionProps' } }).forEach((path) => { + if (path.parent?.parent?.parent?.parent?.node.key?.name === 'MuiAccordion') { + path.replace( + j.property( + 'init', + j.identifier('slotProps'), + j.objectExpression([j.objectProperty(j.identifier('transition'), path.node.value)]), + ), + ); + } + }); + + return root.toSource(printOptions); +} diff --git a/packages/mui-codemod/src/deprecations/accordion-props/accordion-props.test.js b/packages/mui-codemod/src/deprecations/accordion-props/accordion-props.test.js new file mode 100644 index 00000000000000..4c807f4721f71c --- /dev/null +++ b/packages/mui-codemod/src/deprecations/accordion-props/accordion-props.test.js @@ -0,0 +1,53 @@ +import path from 'path'; +import { expect } from 'chai'; +import { jscodeshift } from '../../../testUtils'; +import transform from './accordion-props'; +import readFile from '../../util/readFile'; + +function read(fileName) { + return readFile(path.join(__dirname, fileName)); +} + +describe('@mui/codemod', () => { + describe('deprecations', () => { + describe('accordion-props', () => { + it('transforms props as needed', () => { + const actual = transform({ source: read('./test-cases/actual.js') }, { jscodeshift }, {}); + + const expected = read('./test-cases/expected.js'); + expect(actual).to.equal(expected, 'The transformed version should be correct'); + }); + + it('should be idempotent', () => { + const actual = transform({ source: read('./test-cases/expected.js') }, { jscodeshift }, {}); + + const expected = read('./test-cases/expected.js'); + expect(actual).to.equal(expected, 'The transformed version should be correct'); + }); + }); + + describe('[theme] accordion-props', () => { + it('transforms props as needed', () => { + const actual = transform( + { source: read('./test-cases/theme.actual.js') }, + { jscodeshift }, + {}, + ); + + const expected = read('./test-cases/theme.expected.js'); + expect(actual).to.equal(expected, 'The transformed version should be correct'); + }); + + it('should be idempotent', () => { + const actual = transform( + { source: read('./test-cases/theme.expected.js') }, + { jscodeshift }, + {}, + ); + + const expected = read('./test-cases/theme.expected.js'); + expect(actual).to.equal(expected, 'The transformed version should be correct'); + }); + }); + }); +}); diff --git a/packages/mui-codemod/src/deprecations/accordion-props/index.js b/packages/mui-codemod/src/deprecations/accordion-props/index.js new file mode 100644 index 00000000000000..d069f42dc1754a --- /dev/null +++ b/packages/mui-codemod/src/deprecations/accordion-props/index.js @@ -0,0 +1 @@ +export { default } from './accordion-props'; diff --git a/packages/mui-codemod/src/deprecations/accordion-props/test-cases/actual.js b/packages/mui-codemod/src/deprecations/accordion-props/test-cases/actual.js new file mode 100644 index 00000000000000..d0f76993c9142a --- /dev/null +++ b/packages/mui-codemod/src/deprecations/accordion-props/test-cases/actual.js @@ -0,0 +1,36 @@ +import Accordion from '@mui/material/Accordion'; +import { Accordion as MyAccordion } from '@mui/material'; + +; +; +; +; +// should skip non MUI components +; diff --git a/packages/mui-codemod/src/deprecations/accordion-props/test-cases/expected.js b/packages/mui-codemod/src/deprecations/accordion-props/test-cases/expected.js new file mode 100644 index 00000000000000..d0f76993c9142a --- /dev/null +++ b/packages/mui-codemod/src/deprecations/accordion-props/test-cases/expected.js @@ -0,0 +1,36 @@ +import Accordion from '@mui/material/Accordion'; +import { Accordion as MyAccordion } from '@mui/material'; + +; +; +; +; +// should skip non MUI components +; diff --git a/packages/mui-codemod/src/deprecations/accordion-props/test-cases/theme.actual.js b/packages/mui-codemod/src/deprecations/accordion-props/test-cases/theme.actual.js new file mode 100644 index 00000000000000..b21358b649fa77 --- /dev/null +++ b/packages/mui-codemod/src/deprecations/accordion-props/test-cases/theme.actual.js @@ -0,0 +1,8 @@ +fn({ + MuiAccordion: { + defaultProps: { + TransitionComponent: CustomTransition, + TransitionProps: { unmountOnExit: true }, + }, + }, +}); diff --git a/packages/mui-codemod/src/deprecations/accordion-props/test-cases/theme.expected.js b/packages/mui-codemod/src/deprecations/accordion-props/test-cases/theme.expected.js new file mode 100644 index 00000000000000..e98f56af5651d5 --- /dev/null +++ b/packages/mui-codemod/src/deprecations/accordion-props/test-cases/theme.expected.js @@ -0,0 +1,13 @@ +fn({ + MuiAccordion: { + defaultProps: { + slots: { + transition: CustomTransition + }, + + slotProps: { + transition: { unmountOnExit: true } + } + }, + }, +}); diff --git a/packages/mui-codemod/src/deprecations/all/deprecations-all.js b/packages/mui-codemod/src/deprecations/all/deprecations-all.js new file mode 100644 index 00000000000000..08e1d4510f02fd --- /dev/null +++ b/packages/mui-codemod/src/deprecations/all/deprecations-all.js @@ -0,0 +1,11 @@ +import transformAccordionProps from '../accordion-props/accordion-props'; + +/** + * @param {import('jscodeshift').FileInfo} file + * @param {import('jscodeshift').API} api + */ +export default function deprecationsAll(file, api, options) { + file.source = transformAccordionProps(file, api, options); + + return file.source; +} diff --git a/packages/mui-codemod/src/deprecations/all/index.js b/packages/mui-codemod/src/deprecations/all/index.js new file mode 100644 index 00000000000000..dcd97bd9b0e340 --- /dev/null +++ b/packages/mui-codemod/src/deprecations/all/index.js @@ -0,0 +1 @@ +export { default } from './deprecations-all'; diff --git a/packages/mui-codemod/src/util/appendAttribute.js b/packages/mui-codemod/src/util/appendAttribute.js new file mode 100644 index 00000000000000..4cf0e860e6ae49 --- /dev/null +++ b/packages/mui-codemod/src/util/appendAttribute.js @@ -0,0 +1,11 @@ +/** + * @param {import('jscodeshift')} j + * @param {{ target: import('jscodeshift').JSXElement; attributeName: string; expression: import('ast-types/gen/kinds').ExpressionKind | import('ast-types/gen/kinds').JSXEmptyExpressionKind; }} options + */ +export default function appendAttribute(j, options) { + const { target, attributeName, expression } = options; + + target.openingElement.attributes.push( + j.jsxAttribute(j.jsxIdentifier(attributeName), j.jsxExpressionContainer(expression)), + ); +} diff --git a/packages/mui-codemod/src/util/assignObject.js b/packages/mui-codemod/src/util/assignObject.js new file mode 100644 index 00000000000000..f08a1a6fb91d21 --- /dev/null +++ b/packages/mui-codemod/src/util/assignObject.js @@ -0,0 +1,26 @@ +/** + * Pushes an expression to a known object. handles local object and variable declaration. + * + * @param {import('jscodeshift')} j + * @param {{ target: import('jscodeshift').JSXAttribute; expression: import('ast-types/gen/kinds').ExpressionKind; key: string }} options + * + * @example push expression to `slots.transition` => }} /> + * @example push expression to `slots.transition` => }} /> + */ +export default function assignObject(j, options) { + const { target, expression, key } = options; + if (target && target.type === 'JSXAttribute') { + const expContainer = /** @type import('jscodeshift').JSXExpressionContainer */ (target.value); + + if (expContainer.expression.type === 'ObjectExpression') { + // case `={{ ... }}` + expContainer.expression.properties.push(j.objectProperty(j.identifier(key), expression)); + } else if (expContainer.expression.type === 'Identifier') { + // case `={outerVariable} + expContainer.expression = j.objectExpression([ + j.spreadElement(j.identifier(expContainer.expression.name)), + j.objectProperty(j.identifier(key), expression), + ]); + } + } +} diff --git a/packages/mui-codemod/src/util/findComponentJSX.js b/packages/mui-codemod/src/util/findComponentJSX.js new file mode 100644 index 00000000000000..e079d004648f7e --- /dev/null +++ b/packages/mui-codemod/src/util/findComponentJSX.js @@ -0,0 +1,39 @@ +/** + * Find all the JSXElements of a given component name. + * + * @param {import('jscodeshift')} j + * @param {{ root: import('jscodeshift').Collection; componentName: string }} options + * @param {(path: import('jscodeshift').ASTPath) => void} callback + * + */ +export default function findComponentJSX(j, options, callback) { + const { root, componentName } = options; + + // case 1: import ComponentName from '@mui/material/ComponentName'; + // case 2: import { ComponentName } from '@mui/material'; + // case 3: import { ComponentName as SomethingElse } from '@mui/material'; + + const importName = new Set(); + + root + .find(j.ImportDeclaration) + .filter((path) => + path.node.source.value.match(new RegExp(`^@mui/material/?(${componentName})?`)), + ) + .forEach((path) => { + path.node.specifiers.forEach((specifier) => { + if (specifier.type === 'ImportDefaultSpecifier') { + importName.add(specifier.local.name); + } + if (specifier.type === 'ImportSpecifier' && specifier.imported.name === componentName) { + importName.add(specifier.local.name); + } + }); + }); + + [...importName].forEach((name) => { + root.findJSXElements(name).forEach((elementPath) => { + callback(elementPath); + }); + }); +} diff --git a/packages/mui-codemod/testUtils/index.js b/packages/mui-codemod/testUtils/index.js new file mode 100644 index 00000000000000..038f57962c8576 --- /dev/null +++ b/packages/mui-codemod/testUtils/index.js @@ -0,0 +1,4 @@ +/* eslint-disable import/prefer-default-export */ +import j from 'jscodeshift'; + +export const jscodeshift = j.withParser('tsx');