Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[codemod] Add accordion props deprecation #40771

Merged
merged 15 commits into from
Jan 30, 2024
Merged
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions packages/mui-codemod/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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:
- `<codemod>.js` - the transform implementation
- `<codemod>.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 <codemod>` 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@<link> <codemod> <path>
```

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
```
35 changes: 33 additions & 2 deletions packages/mui-codemod/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,40 @@ npx @mui/codemod@latest <transform> <path> --jscodeshift="--printOptions='{\"quo

## Included scripts

- [Deprecation](#deprecations)
- [v5](#v500)
- [v4](#v400)
- [v1](#v100)
- [v0.15](#v0150)

### Deprecations

```bash
npx @mui/codemod@latest deprecations/all <path>
```

#### `all`

A combination of all deprecations.

#### `accordion-props`

```diff
<Accordion
- TransitionComponent={CustomTransition}
- TransitionProps={{ unmountOnExit: true }}
+ slots={{ transition: CustomTransition }}
+ slotProps={{ transition: { unmountOnExit: true } }}
/>
```

```bash
npx @mui/codemod@latest deprecations/accordion-props <path>
```

### 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.
Expand All @@ -81,7 +112,7 @@ This codemod updates the import and re-export statements.
npx @mui/codemod@latest v5.0.0/base-use-named-exports <path>
```

### `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.

Expand Down
48 changes: 30 additions & 18 deletions packages/mui-codemod/codemod.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './accordion-props';
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Accordion from '@mui/material/Accordion';
import { Accordion as MyAccordion } from '@mui/material';

<Accordion slots={{
transition: CustomTransition
}} slotProps={{
transition: { unmountOnExit: true }
}} />;
<MyAccordion slots={{
transition: CustomTransition
}} slotProps={{
transition: transitionVars
}} />;
<Accordion
slots={{
root: 'div',
transition: CustomTransition
}}
slotProps={{
root: { className: 'foo' },
transition: { unmountOnExit: true }
}} />;
<MyAccordion
slots={{
...outerSlots,
transition: CustomTransition
}}
slotProps={{
...outerSlotProps,
transition: { unmountOnExit: true }
}} />;
// should skip non MUI components
<NonMuiAccordion
TransitionComponent={CustomTransition}
TransitionProps={{ unmountOnExit: true }}
/>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Accordion from '@mui/material/Accordion';
import { Accordion as MyAccordion } from '@mui/material';

<Accordion slots={{
transition: CustomTransition
}} slotProps={{
transition: { unmountOnExit: true }
}} />;
<MyAccordion slots={{
transition: CustomTransition
}} slotProps={{
transition: transitionVars
}} />;
<Accordion
slots={{
root: 'div',
transition: CustomTransition
}}
slotProps={{
root: { className: 'foo' },
transition: { unmountOnExit: true }
}} />;
<MyAccordion
slots={{
...outerSlots,
transition: CustomTransition
}}
slotProps={{
...outerSlotProps,
transition: { unmountOnExit: true }
}} />;
// should skip non MUI components
<NonMuiAccordion
TransitionComponent={CustomTransition}
TransitionProps={{ unmountOnExit: true }}
/>;
Loading
Loading