Skip to content

Commit

Permalink
Add component-lifecycle-imports ESLint rule (#2164)
Browse files Browse the repository at this point in the history
  • Loading branch information
connor-baer authored Jul 14, 2023
1 parent 8cb91e3 commit e1db849
Show file tree
Hide file tree
Showing 8 changed files with 298 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/brown-berries-impress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sumup/eslint-plugin-circuit-ui': major
---

Added `circuit-ui/component-lifecycle-imports` rule to update component imports when they move to a different lifecycle stage.
1 change: 1 addition & 0 deletions packages/eslint-plugin-circuit-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Rules are configured under the rules section:

## Supported Rules

- [`component-lifecycle-imports`](https://github.com/sumup-oss/circuit-ui/tree/main/packages/eslint-plugin-circuit-ui/component-lifecycle-imports)
- [`no-invalid-custom-properties`](https://github.com/sumup-oss/circuit-ui/tree/main/packages/eslint-plugin-circuit-ui/no-invalid-custom-properties)
- [`no-deprecated-components`](https://github.com/sumup-oss/circuit-ui/tree/main/packages/eslint-plugin-circuit-ui/no-deprecated-components)
- [`no-deprecated-props`](https://github.com/sumup-oss/circuit-ui/tree/main/packages/eslint-plugin-circuit-ui/no-deprecated-props)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Import components from the entrypoint for their lifecycle stage (`component-lifecycle-imports`)

Circuit UI components move through different stages throughout their [lifecycle](https://circuit.sumup.com/?path=/docs/introduction-component-lifecycle--docs). Each stage is associated with its own package entrypoint.

## Rule Details

This rule flags components that have moved to a different stage and can automatically update their imports. Setting the rule's error level to `error` (or `2`) is recommended.

Examples of **incorrect** code for this rule:

```tsx
import { RangePicker } from '@sumup/circuit-ui';
import type { RangePickerProps } from '@sumup/circuit-ui';
```

Examples of **correct** code for this rule:

```tsx
import { RangePicker } from '@sumup/circuit-ui/legacy';
import type { RangePickerProps } from '@sumup/circuit-ui/legacy';
```

### Options

n/a

## When Not To Use It

n/a

## Further Reading

- [Component lifecycle documentation](https://circuit.sumup.com/?path=/docs/introduction-component-lifecycle--docs) on the Circuit UI docs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* Copyright 2023, SumUp Ltd.
* 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.
*/

// We disable the rule in this file because we explicitly test invalid cases
/* eslint-disable @sumup/circuit-ui/no-invalid-custom-properties */

import { ESLintUtils } from '@typescript-eslint/utils';

import { componentLifecycleImports } from '.';

const ruleTester = new ESLintUtils.RuleTester({
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
});

ruleTester.run('component-lifecycle-imports', componentLifecycleImports, {
valid: [
{
name: 'matching component import from unrelated package',
code: `
import { RangePicker } from 'material-ui';
`,
},
{
name: 'matching import from correct package',
code: `
import { RangePicker } from '@sumup/circuit-ui/legacy';
`,
},
{
name: 'unrelated import from matching package',
code: `
import { Button } from '@sumup/circuit-ui';
`,
},
{
name: 'unrelated import with matching local name',
code: `
import { Button as RangePicker } from '@sumup/circuit-ui';
`,
},
],
invalid: [
{
name: 'single import with single match',
code: `
import { RangePicker } from '@sumup/circuit-ui';
`,
output: `
import { RangePicker } from '@sumup/circuit-ui/legacy';
`,
errors: [{ messageId: 'refactor' }],
},
{
name: 'single import with single match with local name',
code: `
import { RangePicker as RangeInput } from '@sumup/circuit-ui';
`,
output: `
import { RangePicker as RangeInput } from '@sumup/circuit-ui/legacy';
`,
errors: [{ messageId: 'refactor' }],
},
{
name: 'multiple imports with single match',
code: `
import { RangePicker, Button } from '@sumup/circuit-ui';
`,
output: `
import { Button } from '@sumup/circuit-ui';import { RangePicker } from '@sumup/circuit-ui/legacy';
`,
errors: [{ messageId: 'refactor' }],
},
{
name: 'multiple imports with multiple matches',
code: `
import { RangePicker, RangePickerController } from '@sumup/circuit-ui';
`,
// The other component will be migrated on the second pass.
output: `
import { RangePickerController } from '@sumup/circuit-ui';import { RangePicker } from '@sumup/circuit-ui/legacy';
`,
errors: [{ messageId: 'refactor' }, { messageId: 'refactor' }],
},
{
name: 'single type import with single match',
code: `
import type { RangePickerProps } from '@sumup/circuit-ui';
`,
output: `
import type { RangePickerProps } from '@sumup/circuit-ui/legacy';
`,
errors: [{ messageId: 'refactor' }],
},
],
});
143 changes: 143 additions & 0 deletions packages/eslint-plugin-circuit-ui/component-lifecycle-imports/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* Copyright 2023, SumUp Ltd.
* 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 { ESLintUtils, TSESTree } from '@typescript-eslint/utils';

const createRule = ESLintUtils.RuleCreator(
(name) =>
`https://github.com/sumup-oss/circuit-ui/tree/main/packages/eslint-plugin-circuit-ui/${name}`,
);

const mappings = [
{
from: '@sumup/circuit-ui',
to: '@sumup/circuit-ui/legacy',
specifiers: [
'RangePicker',
'RangePickerProps',
'RangePickerController',
'RangePickerControllerProps',
'SingleDayPicker',
'SingleDayPickerProps',
'CalendarConstants',
'CalendarTag',
'CalendarTagProps',
'CalendarTagTwoStep',
'CalendarTagTwoStepProps',
'Grid',
'Row',
'Col',
'ColProps',
'InlineElements',
'InlineElementsProps',
'Header',
'HeaderProps',
'Sidebar',
'SidebarProps',
'SidebarContextProvider',
'SidebarContextConsumer',
'Tooltip',
'TooltipProps',
'uniqueId',
],
},
];

export const componentLifecycleImports = createRule({
name: 'component-lifecycle-imports',
meta: {
type: 'suggestion',
schema: [],
fixable: 'code',
docs: {
description:
'Components that have moved to a different stage in their lifecycle should be imported from the relevant path.',
recommended: 'error',
},
messages: {
refactor:
'`{{name}}` has moved to a different stage in its lifecycle. Import it from `{{source}}` instead.',
},
},
defaultOptions: [],
create(context) {
return mappings.reduce((visitors, config) => {
const { from, to, specifiers } = config;

return {
...visitors,
[`ImportDeclaration:has(Literal[value="${from}"])`]: (
node: TSESTree.ImportDeclaration,
) => {
node.specifiers.forEach((specifier) => {
if (specifier.type !== 'ImportSpecifier') {
return;
}

const importedName = specifier.imported.name;
const localName = specifier.local.name;

if (!specifiers.includes(importedName)) {
return;
}

const importStatement =
node.importKind === 'type' ? 'import type' : 'import';
const importSpecifier =
importedName === localName
? importedName
: `${importedName} as ${localName}`;

context.report({
node: specifier,
messageId: 'refactor',
data: { name: importedName, source: to },
fix(fixer) {
const fixes = [];

if (node.specifiers.length === 1) {
// Remove the import entirely if there's only one specifier
fixes.push(fixer.remove(node));
} else {
// ...otherwise, only remove the specifier
fixes.push(
fixer.replaceText(
node,
context
.getSourceCode()
.getText(node)
.replace(importSpecifier, '')
.replace(' ,', ''),
),
);
}

// Insert the new import
fixes.push(
fixer.insertTextAfter(
node,
`${importStatement} { ${importSpecifier} } from '${to}';`,
),
);

return fixes;
},
});
});
},
};
}, {});
},
});
2 changes: 2 additions & 0 deletions packages/eslint-plugin-circuit-ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
* limitations under the License.
*/

import { componentLifecycleImports } from './component-lifecycle-imports';
import { noInvalidCustomProperties } from './no-invalid-custom-properties';
import { noDeprecatedComponents } from './no-deprecated-components';
import { noDeprecatedProps } from './no-deprecated-props';
import { noRenamedProps } from './no-renamed-props';
import { preferCustomProperties } from './prefer-custom-properties';

export const rules = {
'component-lifecycle-imports': componentLifecycleImports,
'no-invalid-custom-properties': noInvalidCustomProperties,
'no-deprecated-components': noDeprecatedComponents,
'no-deprecated-props': noDeprecatedProps,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const noInvalidCustomProperties = createRule({
recommended: 'error',
},
messages: {
invalid: '"{{name}}" is not a valid Circuit UI design token.',
invalid: "'{{name}}' is not a valid Circuit UI design token.",
},
},
defaultOptions: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const preferCustomProperties = createRule({
},
messages: {
replace:
'Use CSS custom properties instead of the Emotion.js theme. Replace "{{jsToken}}" with "{{cssVariable}}".',
"Use CSS custom properties instead of the Emotion.js theme. Replace '{{jsToken}}' with '{{cssVariable}}'.",
refactor: 'Use CSS custom properties instead of the Emotion.js theme.',
},
},
Expand Down

0 comments on commit e1db849

Please sign in to comment.