Merge pull request #21979 from awanlin/topic/mui-4-rule

Added new @backstage/no-top-level-material-ui-4-imports rule
This commit is contained in:
Ben Lambert
2024-01-16 15:55:54 +01:00
committed by GitHub
30 changed files with 392 additions and 62 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-azure-devops': patch
'@backstage/plugin-devtools': patch
'@backstage/plugin-linguist': patch
---
Updated imports from named to default imports to help with the Material UI v4 to v5 migration
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/eslint-plugin': patch
---
Added new `@backstage/no-top-level-material-ui-4-imports` rule that forbids top level imports from Material UI v4 packages
+1 -1
View File
@@ -56,7 +56,7 @@ For current known issues with the Material UI v5 migration, follow our [Mileston
To migrate your plugin to Material UI v5, you can build on the resources available.
1. Manually fix the imports from named to default imports to match the new [linting rules for minimizing bundle size](https://mui.com/material-ui/guides/minimizing-bundle-size).
1. Manually fix the imports from named to default imports to match the new [linting rules for minimizing bundle size](https://mui.com/material-ui/guides/minimizing-bundle-size). Note: you can use the [new `@backstage/no-top-level-material-ui-4-imports` ESLint](https://github.com/backstage/backstage/blob/master/packages/eslint-plugin/docs/rules/no-top-level-material-ui-4-imports.md) rule to help with this.
2. Run the migration `codemod` for the path of the specific plugin: `npx @mui/codemod v5.0.0/preset-safe plugins/<path>`.
3. Take a look at possible `TODO:` items the `codemod` could not fix.
4. Remove types & methods from `@backstage/theme` which are marked as `@deprecated`.
+6 -5
View File
@@ -35,8 +35,9 @@ rules: {
The following rules are provided by this plugin:
| Rule | Description |
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| [@backstage/no-forbidden-package-imports](./docs/rules/no-forbidden-package-imports.md) | Disallow internal monorepo imports from package subpaths that are not exported. |
| [@backstage/no-relative-monorepo-imports](./docs/rules/no-relative-monorepo-imports.md) | Forbid relative imports that reach outside of the package in a monorepo. |
| [@backstage/no-undeclared-imports](./docs/rules/no-undeclared-imports.md) | Forbid imports of external packages that have not been declared in the appropriate dependencies field in `package.json`. |
| Rule | Description |
| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| [@backstage/no-forbidden-package-imports](./docs/rules/no-forbidden-package-imports.md) | Disallow internal monorepo imports from package subpaths that are not exported. |
| [@backstage/no-relative-monorepo-imports](./docs/rules/no-relative-monorepo-imports.md) | Forbid relative imports that reach outside of the package in a monorepo. |
| [@backstage/no-undeclared-imports](./docs/rules/no-undeclared-imports.md) | Forbid imports of external packages that have not been declared in the appropriate dependencies field in `package.json`. |
| [@backstage/no-top-level-material-ui-4-imports](./docs/rules/no-top-level-material-ui-4-imports.md) | Forbid top level import from Material UI v4 packages. |
@@ -0,0 +1,43 @@
# @backstage/no-top-level-material-ui-4-imports
Forbid top level import from Material UI v4 packages.
## Usage
Add the rules as follows, it has no options:
```js
'@backstage/no-top-level-material-ui-4-imports': 'error'
```
## Rule Details
Automatically fixes imports from named to default imports. This will help you comply with [Material UI recommendations](https://mui.com/material-ui/guides/minimizing-bundle-size/) and make migrating to Material UI v5 easier.
### Fail
```tsx
import { Box, Typography } from '@material-ui/core';
```
```tsx
import Box from '@material-ui/core';
```
```tsx
import {
Box,
DialogActions,
DialogContent,
DialogTitle,
Grid,
makeStyles,
} from '@material-ui/core';
```
### Pass
```tsx
import Typography from '@material-ui/core/Typography';
import Box from '@material-ui/core/Box';
```
+1
View File
@@ -29,5 +29,6 @@ module.exports = {
'no-forbidden-package-imports': require('./rules/no-forbidden-package-imports'),
'no-relative-monorepo-imports': require('./rules/no-relative-monorepo-imports'),
'no-undeclared-imports': require('./rules/no-undeclared-imports'),
'no-top-level-material-ui-4-imports': require('./rules/no-top-level-material-ui-4-imports'),
},
};
@@ -0,0 +1,156 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @ts-check
const KNOWN_STYLES = [
'makeStyles',
'withStyles',
'createStyles',
'styled',
'useTheme',
'Theme',
];
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
messages: {
topLevelImport: 'Top level imports for Material UI are not allowed',
},
docs: {
description: 'Forbid top level import from Material UI v4 packages.',
url: 'https://github.com/backstage/backstage/blob/master/packages/eslint-plugin/docs/rules/no-top-level-material-ui-4-imports.md',
},
},
create: context => ({
ImportDeclaration: node => {
// Anatomy of a Node
// Example: import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon';
// Specifiers are the part between the `import` and `from`, in the example that would be `SvgIcon, { SvgIconProps }`
// Source is the part after the `from`, in the example that would be `'@material-ui/core/SvgIcon'`
// Source value gets you `@material-ui/core/SvgIcon` without the quotes, where as Source raw gets it as is
// Return if empty import
if (node.specifiers.length === 0) return;
// Return if empty source value
if (!node.source.value) return;
// Return if source value not a string
if (typeof node.source.value !== 'string') return;
// Return if import does not start with '@material-ui/'
if (!node.source.value.startsWith('@material-ui/')) return;
// Return if import is from '@material-ui/core/styles', as it's valid already
if (node.source.value === '@material-ui/core/styles') return;
// Return if proper import eg. `import Box from '@material-ui/core/Box'`
// Or if third level or deeper imports
if (node.source.value?.split('/').length >= 3) return;
// Report all other imports
context.report({
node,
messageId: 'topLevelImport',
fix: fixer => {
const replacements = [];
const styles = [];
const specifiers = node.specifiers.filter(
s => s.type === 'ImportSpecifier',
);
const specifiersMap = specifiers.map(s => {
const propsMatch = /^([A-Z]\w+)Props$/.exec(s.local.name);
return {
emitComponent: !(propsMatch !== null),
emitProp: propsMatch !== null,
value: s.local.name,
propValue: propsMatch ? propsMatch[1] : undefined,
};
});
// We have 3 cases:
// 1 - Just Prop: import { TabProps } from '@material-ui/core';
// 2 - Just Component: import { Box } from '@material-ui/core';
// 3 - Component and Prop: import { SvgIcon, SvgIconProps } from '@material-ui/core';
const components = specifiersMap
.filter(f => {
return f.emitComponent;
})
.map(m => m.value);
const props = specifiersMap
.filter(f => {
return f.emitProp;
})
.map(m => m.value);
if (
specifiersMap.some(s => s.emitProp) &&
!specifiersMap.some(s => s.emitComponent)
) {
// 1 - Just Prop
const propValue = specifiersMap
.filter(f => {
return f.emitProp;
})
.map(m => m.propValue);
replacements.push(
`import { ${props.join(', ')} } from '@material-ui/core/${
propValue[0]
}';`,
);
} else if (
!specifiersMap.some(s => s.emitProp) &&
specifiersMap.some(s => s.emitComponent)
) {
// 2 - Just Component
for (const specifier of specifiers) {
if (KNOWN_STYLES.includes(specifier.local.name)) {
styles.push(specifier.local.name);
} else {
const replacement = `import ${specifier.local.name} from '${node.source.value}/${specifier.local.name}';`;
replacements.push(replacement);
}
}
} else if (
specifiersMap.some(s => s.emitProp) &&
specifiersMap.some(s => s.emitComponent)
) {
// 3 - Component and Prop
replacements.push(
`import ${components[0]}, { ${props.join(
', ',
)} } from '@material-ui/core/${components[0]}';`,
);
}
if (styles.length > 0) {
const stylesReplacement = `import { ${styles.join(
', ',
)} } from '@material-ui/core/styles';`;
replacements.push(stylesReplacement);
}
const result = fixer.replaceText(node, replacements.join('\n'));
return result;
},
});
},
}),
};
@@ -0,0 +1,107 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RuleTester } from 'eslint';
import rule from '../rules/no-top-level-material-ui-4-imports';
const ruleTester = new RuleTester({
parserOptions: {
sourceType: 'module',
ecmaVersion: 2021,
},
});
ruleTester.run('path-imports-rule', rule, {
valid: [
{
code: `import Typography from '@material-ui/core/Typography';`,
},
{
code: `import Box from '@material-ui/core/Box'`,
},
{
code: `import { styled, withStyles } from '@material-ui/core/styles';`,
},
{
code: `import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon';`,
},
{
code: `import { StyleRules } from '@material-ui/core/styles/withStyles';`,
},
{
code: `import { CreateCSSProperties, StyledComponentProps } from '@material-ui/core/styles/withStyles';`,
},
],
invalid: [
{
code: `import { Box, Typography } from '@material-ui/core';`,
errors: [{ messageId: 'topLevelImport' }],
output: `import Box from '@material-ui/core/Box';
import Typography from '@material-ui/core/Typography';`,
},
{
code: `import { Box } from '@material-ui/core';`,
errors: [{ messageId: 'topLevelImport' }],
output: `import Box from '@material-ui/core/Box';`,
},
{
code: `import {
Box,
DialogActions,
DialogContent,
DialogTitle,
Grid,
makeStyles,
} from '@material-ui/core';`,
errors: [{ messageId: 'topLevelImport' }],
output: `import Box from '@material-ui/core/Box';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import Grid from '@material-ui/core/Grid';
import { makeStyles } from '@material-ui/core/styles';`,
},
{
code: `import { Box, Button, makeStyles } from '@material-ui/core';`,
errors: [{ messageId: 'topLevelImport' }],
output: `import Box from '@material-ui/core/Box';
import Button from '@material-ui/core/Button';
import { makeStyles } from '@material-ui/core/styles';`,
},
{
code: `import { Paper, Typography, styled, withStyles } from '@material-ui/core';`,
errors: [{ messageId: 'topLevelImport' }],
output: `import Paper from '@material-ui/core/Paper';
import Typography from '@material-ui/core/Typography';
import { styled, withStyles } from '@material-ui/core/styles';`,
},
{
code: `import { styled } from '@material-ui/core';`,
errors: [{ messageId: 'topLevelImport' }],
output: `import { styled } from '@material-ui/core/styles';`,
},
{
code: `import { SvgIcon, SvgIconProps } from '@material-ui/core';`,
errors: [{ messageId: 'topLevelImport' }],
output: `import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon';`,
},
{
code: `import { TabProps } from '@material-ui/core';`,
errors: [{ messageId: 'topLevelImport' }],
output: `import { TabProps } from '@material-ui/core/Tab';`,
},
],
});
+5 -1
View File
@@ -1 +1,5 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, {
rules: {
'@backstage/no-top-level-material-ui-4-imports': 'error',
},
});
+1 -1
View File
@@ -22,7 +22,7 @@ import { Readme } from '@backstage/plugin-azure-devops-common';
import { ReadmeConfig } from '@backstage/plugin-azure-devops-common';
import { RepoBuild } from '@backstage/plugin-azure-devops-common';
import { RepoBuildOptions } from '@backstage/plugin-azure-devops-common';
import { SvgIconProps } from '@material-ui/core';
import { SvgIconProps } from '@material-ui/core/SvgIcon';
import { Team } from '@backstage/plugin-azure-devops-common';
// @public (undocumented)
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { SvgIcon, SvgIconProps } from '@material-ui/core';
import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon';
import React from 'react';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { SvgIcon, SvgIconProps } from '@material-ui/core';
import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon';
import React from 'react';
@@ -15,7 +15,7 @@
*/
import React from 'react';
import { SvgIcon, SvgIconProps } from '@material-ui/core';
import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon';
/** @public */
export const AzurePullRequestsIcon = (props: SvgIconProps) => (
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { Box, Typography } from '@material-ui/core';
import Box from '@material-ui/core/Box';
import Typography from '@material-ui/core/Typography';
import {
BuildResult,
BuildRun,
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Box } from '@material-ui/core';
import Box from '@material-ui/core/Box';
import {
Link,
ResponseErrorPanel,
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { Button, ButtonGroup } from '@material-ui/core';
import Button from '@material-ui/core/Button';
import ButtonGroup from '@material-ui/core/ButtonGroup';
import { PullRequestStatus } from '@backstage/plugin-azure-devops-common';
import React from 'react';
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { Box, Chip } from '@material-ui/core';
import Box from '@material-ui/core/Box';
import Chip from '@material-ui/core/Chip';
import {
Link,
ResponseErrorPanel,
@@ -15,7 +15,9 @@
*/
import { Avatar, Link } from '@backstage/core-components';
import { Card, CardContent, CardHeader } from '@material-ui/core';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import CardHeader from '@material-ui/core/CardHeader';
import Typography from '@material-ui/core/Typography';
import { AutoCompleteIcon } from '../AutoCompleteIcon';
@@ -17,7 +17,7 @@
import { PullRequestGridColumn } from '../PullRequestGridColumn';
import { PullRequestGroup } from '../types';
import React from 'react';
import { styled } from '@material-ui/core';
import { styled } from '@material-ui/core/styles';
const GridDiv = styled('div')(({ theme }) => ({
display: 'flex',
@@ -14,7 +14,9 @@
* limitations under the License.
*/
import { Paper, Typography, styled, withStyles } from '@material-ui/core';
import Paper from '@material-ui/core/Paper';
import Typography from '@material-ui/core/Typography';
import { styled, withStyles } from '@material-ui/core/styles';
import { PullRequestCard } from '../PullRequestCard';
import { PullRequestGroup } from '../types';
@@ -14,7 +14,9 @@
* limitations under the License.
*/
import { Box, Button, makeStyles } from '@material-ui/core';
import Box from '@material-ui/core/Box';
import Button from '@material-ui/core/Button';
import { makeStyles } from '@material-ui/core/styles';
import {
InfoCard,
Progress,
+5 -1
View File
@@ -1 +1,5 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, {
rules: {
'@backstage/no-top-level-material-ui-4-imports': 'error',
},
});
+1 -1
View File
@@ -10,7 +10,7 @@ import { default as default_2 } from 'react';
import { JSX as JSX_2 } from 'react';
import { default as React_2 } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
import { TabProps } from '@material-ui/core';
import { TabProps } from '@material-ui/core/Tab';
// @public (undocumented)
export const ConfigContent: () => React_2.JSX.Element;
@@ -15,16 +15,16 @@
*/
import { Progress, WarningPanel } from '@backstage/core-components';
import Box from '@material-ui/core/Box';
import Paper from '@material-ui/core/Paper';
import Typography from '@material-ui/core/Typography';
import {
Box,
createStyles,
makeStyles,
Paper,
Theme,
Typography,
useTheme,
} from '@material-ui/core';
import { Alert } from '@material-ui/lab';
} from '@material-ui/core/styles';
import Alert from '@material-ui/lab/Alert';
import React from 'react';
import ReactJson from 'react-json-view';
import { useConfig } from '../../../hooks';
@@ -23,16 +23,12 @@ import {
TableColumn,
} from '@backstage/core-components';
import { ExternalDependency } from '@backstage/plugin-devtools-common';
import {
Box,
createStyles,
Grid,
makeStyles,
Paper,
Theme,
Typography,
} from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import Box from '@material-ui/core/Box';
import Grid from '@material-ui/core/Grid';
import Paper from '@material-ui/core/Paper';
import Typography from '@material-ui/core/Typography';
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles';
import Alert from '@material-ui/lab/Alert';
import React from 'react';
import { useExternalDependencies } from '../../../hooks';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { SvgIcon, SvgIconProps } from '@material-ui/core';
import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon';
import React from 'react';
@@ -15,20 +15,16 @@
*/
import { Progress } from '@backstage/core-components';
import {
Avatar,
Box,
createStyles,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText,
makeStyles,
Paper,
Theme,
} from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import Avatar from '@material-ui/core/Avatar';
import Box from '@material-ui/core/Box';
import Divider from '@material-ui/core/Divider';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemAvatar from '@material-ui/core/ListItemAvatar';
import ListItemText from '@material-ui/core/ListItemText';
import Paper from '@material-ui/core/Paper';
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles';
import Alert from '@material-ui/lab/Alert';
import React from 'react';
import { useInfo } from '../../../hooks';
import { InfoDependenciesTable } from './InfoDependenciesTable';
@@ -19,7 +19,7 @@ import {
attachComponentData,
useElementFilter,
} from '@backstage/core-plugin-api';
import { TabProps } from '@material-ui/core';
import { TabProps } from '@material-ui/core/Tab';
import { default as React } from 'react';
/** @public */
+5 -1
View File
@@ -1 +1,5 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, {
rules: {
'@backstage/no-top-level-material-ui-4-imports': 'error',
},
});
@@ -14,15 +14,12 @@
* limitations under the License.
*/
import {
Box,
Chip,
Tooltip,
Typography,
makeStyles,
Grid,
useTheme,
} from '@material-ui/core';
import Box from '@material-ui/core/Box';
import Chip from '@material-ui/core/Chip';
import Tooltip from '@material-ui/core/Tooltip';
import Typography from '@material-ui/core/Typography';
import Grid from '@material-ui/core/Grid';
import { makeStyles, useTheme } from '@material-ui/core/styles';
import { InfoCard, Progress } from '@backstage/core-components';
import Alert from '@material-ui/lab/Alert';
import { DateTime } from 'luxon';