diff --git a/packages/eslint-plugin-pf-codemods/src/rules/helpers/getImportDeclaration.ts b/packages/eslint-plugin-pf-codemods/src/rules/helpers/getImportDeclaration.ts new file mode 100644 index 000000000..e9a5af2d4 --- /dev/null +++ b/packages/eslint-plugin-pf-codemods/src/rules/helpers/getImportDeclaration.ts @@ -0,0 +1,16 @@ +import { Rule } from 'eslint'; +import { ImportDeclaration } from 'estree-jsx'; + +export function getImportDeclarations( + context: Rule.RuleContext, + packageName: string +) { + const astBody = context.getSourceCode().ast.body; + + const importDeclarationsFromPackage = astBody.filter( + (node) => + node.type === 'ImportDeclaration' && node.source.value === packageName + ) as ImportDeclaration[]; + + return importDeclarationsFromPackage; +} diff --git a/packages/eslint-plugin-pf-codemods/src/rules/helpers/index.ts b/packages/eslint-plugin-pf-codemods/src/rules/helpers/index.ts index 80a263541..a46dba5a1 100644 --- a/packages/eslint-plugin-pf-codemods/src/rules/helpers/index.ts +++ b/packages/eslint-plugin-pf-codemods/src/rules/helpers/index.ts @@ -1,5 +1,6 @@ export * from "./findAncestor"; export * from "./helpers"; +export * from "./getImportDeclaration"; export * from "./getFromPackage"; export * from "./getText"; export * from "./includesImport"; diff --git a/packages/eslint-plugin-pf-codemods/src/rules/v6/chipReplaceWithLabel/chip-replace-with-label.md b/packages/eslint-plugin-pf-codemods/src/rules/v6/chipReplaceWithLabel/chip-replace-with-label.md new file mode 100644 index 000000000..10275e074 --- /dev/null +++ b/packages/eslint-plugin-pf-codemods/src/rules/v6/chipReplaceWithLabel/chip-replace-with-label.md @@ -0,0 +1,18 @@ +### chip-replace-with-label [(#10049)](https://github.com/patternfly/patternfly-react/pull/10049) + +Chip has been deprecated. Running the fix flag will replace Chip and ChipGroup components with Label and LabelGroup components respectively. + +#### Examples + +In: + +```jsx +%inputExample% +``` + +Out: + +```jsx +%outputExample% +``` + diff --git a/packages/eslint-plugin-pf-codemods/src/rules/v6/chipReplaceWithLabel/chip-replace-with-label.test.ts b/packages/eslint-plugin-pf-codemods/src/rules/v6/chipReplaceWithLabel/chip-replace-with-label.test.ts new file mode 100644 index 000000000..b7b1d1715 --- /dev/null +++ b/packages/eslint-plugin-pf-codemods/src/rules/v6/chipReplaceWithLabel/chip-replace-with-label.test.ts @@ -0,0 +1,154 @@ +const ruleTester = require('../../ruletester'); +import * as rule from './chip-replace-with-label'; + +const chipImportError = { + message: `Chip has been deprecated. Running the fix flag will replace Chip and ChipGroup components with Label and LabelGroup components respectively.`, + type: 'ImportDeclaration', +}; + +ruleTester.run('chip-replace-with-label', rule, { + valid: [ + { + code: ``, + }, + { + code: ``, + }, + // with import not from the deprecated package + { + code: `import { Chip, ChipGroup } from '@patternfly/react-core'; `, + }, + ], + invalid: [ + { + code: `import { Chip } from '@patternfly/react-core/deprecated'; This is a chip`, + output: `import { Label } from '@patternfly/react-core'; `, + errors: [chipImportError], + }, + { + code: `import { ChipGroup } from '@patternfly/react-core/deprecated'; This is a chipgroup`, + output: `import { LabelGroup } from '@patternfly/react-core'; This is a chipgroup`, + errors: [chipImportError], + }, + // with Chip nested in ChipGroup + { + code: `import { Chip, ChipGroup } from '@patternfly/react-core/deprecated'; + + This is a chip + `, + output: `import { Label, LabelGroup } from '@patternfly/react-core'; + + + `, + errors: [chipImportError], + }, + // with aliased Chip and ChipGroup + { + code: `import { Chip as PFChip, ChipGroup as PFChipGroup } from '@patternfly/react-core/deprecated'; + + This is a chip + `, + output: `import { Label, LabelGroup } from '@patternfly/react-core'; + + + `, + errors: [chipImportError], + }, + // with other import specifiers from the deprecated package + { + code: `import { Chip, Select } from '@patternfly/react-core/deprecated'; + + This is a chip + `, + output: `import { Select } from '@patternfly/react-core/deprecated';import { Label } from '@patternfly/react-core'; + `, + errors: [chipImportError], + }, + // with Label import already included + { + code: `import { Label } from '@patternfly/react-core'; + import { Chip } from '@patternfly/react-core/deprecated'; + <> + + This is a chip + + + `, + output: `import { Label } from '@patternfly/react-core'; + + <> + + + `, + errors: [chipImportError], + }, + // with LabelGroup import already included + { + code: `import { LabelGroup } from '@patternfly/react-core'; + import { Chip, ChipGroup } from '@patternfly/react-core/deprecated'; + <> + + + This is a chip + + + + This is a label group + + `, + output: `import { LabelGroup, Label } from '@patternfly/react-core'; + + <> + + + + + This is a label group + + `, + errors: [chipImportError], + }, + // with Label and LabelGroup imports already included with aliases + { + code: `import { Label as PFLabel, LabelGroup as PFLabelGroup } from '@patternfly/react-core'; + import { Chip, ChipGroup } from '@patternfly/react-core/deprecated'; + <> + + + This is a chip + + + + + This is a label + + + `, + output: `import { Label as PFLabel, LabelGroup as PFLabelGroup } from '@patternfly/react-core'; + + <> + + + This is a chip + + + + + This is a label + + + `, + errors: [chipImportError], + }, + ], +}); diff --git a/packages/eslint-plugin-pf-codemods/src/rules/v6/chipReplaceWithLabel/chip-replace-with-label.ts b/packages/eslint-plugin-pf-codemods/src/rules/v6/chipReplaceWithLabel/chip-replace-with-label.ts new file mode 100644 index 000000000..10b7bd154 --- /dev/null +++ b/packages/eslint-plugin-pf-codemods/src/rules/v6/chipReplaceWithLabel/chip-replace-with-label.ts @@ -0,0 +1,271 @@ +import { + getAllJSXElements, + getAttribute, + getFromPackage, + getImportDeclarations, +} from '../../helpers'; +import { + ImportDeclaration, + ImportSpecifier, + JSXClosingElement, + JSXElement, + JSXExpressionContainer, + JSXFragment, + Literal, + Node, + JSXIdentifier, +} from 'estree-jsx'; +import { Rule } from 'eslint'; + +type JSXAttributeValue = + | Literal + | JSXExpressionContainer + | JSXElement + | JSXFragment; + +const parseBadgeAttributeValue = ( + badgeAttributeValue: JSXAttributeValue +): Node => { + if (badgeAttributeValue.type !== 'JSXExpressionContainer') { + return badgeAttributeValue; + } + + const isValueJSX = ['JSXElement', 'JSXEmptyExpression'].includes( + badgeAttributeValue.expression.type + ); + + return isValueJSX ? badgeAttributeValue.expression : badgeAttributeValue; +}; + +const moveBadgeAttributeToBody = ( + node: JSXElement, + fixer: Rule.RuleFixer, + context: Rule.RuleContext +) => { + const badgeAttribute = getAttribute(node, 'badge'); + + const textToInsert = badgeAttribute?.value + ? ` ${context + .getSourceCode() + .getText(parseBadgeAttributeValue(badgeAttribute.value))}` + : ''; + + return badgeAttribute + ? [ + fixer.insertTextBefore( + node.closingElement as JSXClosingElement, + textToInsert + ), + fixer.remove(badgeAttribute), + ] + : []; +}; + +const renameOnClickAttribute = (node: JSXElement, fixer: Rule.RuleFixer) => { + const onClickAttribute = getAttribute(node, 'onClick'); + + return onClickAttribute + ? [fixer.replaceText(onClickAttribute.name, 'onClose')] + : []; +}; + +// https://github.com/patternfly/patternfly-react/pull/10049 +module.exports = { + meta: { fixable: 'code' }, + create: function (context: Rule.RuleContext) { + const { imports } = getFromPackage(context, '@patternfly/react-core'); + + const labelImport = imports.find( + (specifier) => specifier.imported.name === 'Label' + ); + + const labelGroupImport = imports.find( + (specifier) => specifier.imported.name === 'LabelGroup' + ); + + const { imports: importsFromDeprecated } = getFromPackage( + context, + '@patternfly/react-core/deprecated' + ); + + const chipImport = importsFromDeprecated.find( + (specifier) => specifier.imported.name === 'Chip' + ); + + const chipGroupImport = importsFromDeprecated.find( + (specifier) => specifier.imported.name === 'ChipGroup' + ); + + const isImportDeclarationWithChipOrChipGroup = (node: ImportDeclaration) => + node.source.value === '@patternfly/react-core/deprecated' && + node.specifiers.every( + (specifier) => specifier.type === 'ImportSpecifier' + ) && + ((chipImport && node.specifiers.includes(chipImport)) || + (chipGroupImport && node.specifiers.includes(chipGroupImport))); + + return chipImport || chipGroupImport + ? { + ImportDeclaration(node: ImportDeclaration) { + if (!isImportDeclarationWithChipOrChipGroup(node)) { + return; + } + + const removeChipAndChipGroupImports = ( + node: ImportDeclaration, + fixer: Rule.RuleFixer + ) => { + const importIncludesOnlyChipAndChipGroup = ( + node.specifiers as ImportSpecifier[] + ).every((specifier) => + ['Chip', 'ChipGroup'].includes(specifier.imported.name) + ); + + if (importIncludesOnlyChipAndChipGroup) { + return [fixer.remove(node)]; + } + + const fixes = []; + for (const specifier of [chipImport, chipGroupImport]) { + if (specifier) { + fixes.push(fixer.remove(specifier)); + const tokenAfter = context + .getSourceCode() + .getTokenAfter(specifier); + if (tokenAfter?.value === ',') { + fixes.push(fixer.remove(tokenAfter)); + } + } + } + return fixes; + }; + + const addLabelAndLabelGroupImports = (fixer: Rule.RuleFixer) => { + if (!labelImport && !labelGroupImport) { + const pfImportDeclarations = getImportDeclarations( + context, + '@patternfly/react-core' + ); + + const labelImportsToAdd = [chipImport, chipGroupImport] + .map((specifier, index) => + specifier ? ['Label', 'LabelGroup'][index] : null + ) + .filter((value) => value !== null) + .join(', '); + + if (pfImportDeclarations.length) { + // add label specifiers at the beginning of first import declaration + return [ + fixer.insertTextBefore( + pfImportDeclarations[0].specifiers[0], + `${labelImportsToAdd}, ` + ), + ]; + } + + // add whole import declaration + return [ + fixer.insertTextAfter( + node, + `import { ${labelImportsToAdd} } from '@patternfly/react-core';` + ), + ]; + } + + if (chipImport && !labelImport) { + return [fixer.insertTextAfter(labelGroupImport!, ', Label')]; + } + if (chipGroupImport && !labelGroupImport) { + return [fixer.insertTextAfter(labelImport!, ', LabelGroup')]; + } + return []; + }; + + const handleJSXElements = (fixer: Rule.RuleFixer) => { + const elements: JSXElement[] = getAllJSXElements(context); + + const labelName = labelImport?.local.name ?? 'Label'; + const labelGroupName = + labelGroupImport?.local.name ?? 'LabelGroup'; + + const getChipFixes = ( + node: JSXElement, + fixer: Rule.RuleFixer + ) => [ + fixer.replaceText(node.openingElement.name, labelName), + fixer.insertTextAfter( + node.openingElement.name, + ' variant="outline"' + ), + ...renameOnClickAttribute(node, fixer), + ...(node.closingElement + ? [ + ...moveBadgeAttributeToBody(node, fixer, context), + fixer.replaceText(node.closingElement.name, labelName), + ] + : []), + ]; + + const getChipGroupFixes = ( + node: JSXElement, + fixer: Rule.RuleFixer + ) => [ + fixer.replaceText(node.openingElement.name, labelGroupName), + ...(node.closingElement + ? [ + fixer.replaceText( + node.closingElement.name, + labelGroupName + ), + ] + : []), + ]; + + const fixes = []; + + for (const node of elements) { + if ( + node.openingElement.name.type !== 'JSXIdentifier' || + ![ + chipImport?.local.name, + chipGroupImport?.local.name, + ].includes(node.openingElement.name.name) + ) { + continue; + } + + if ( + chipImport && + chipImport.local.name === + (node.openingElement.name as JSXIdentifier).name + ) { + fixes.push(...getChipFixes(node, fixer)); + } else if ( + chipGroupImport && + chipGroupImport.local.name === + (node.openingElement.name as JSXIdentifier).name + ) { + fixes.push(...getChipGroupFixes(node, fixer)); + } + } + + return fixes; + }; + + context.report({ + node, + message: `Chip has been deprecated. Running the fix flag will replace Chip and ChipGroup components with Label and LabelGroup components respectively.`, + fix(fixer) { + return [ + ...removeChipAndChipGroupImports(node, fixer), + ...addLabelAndLabelGroupImports(fixer), + ...handleJSXElements(fixer), + ]; + }, + }); + }, + } + : {}; + }, +}; diff --git a/packages/eslint-plugin-pf-codemods/src/rules/v6/chipReplaceWithLabel/chipReplaceWithLabelInput.tsx b/packages/eslint-plugin-pf-codemods/src/rules/v6/chipReplaceWithLabel/chipReplaceWithLabelInput.tsx new file mode 100644 index 000000000..2dc8a3b68 --- /dev/null +++ b/packages/eslint-plugin-pf-codemods/src/rules/v6/chipReplaceWithLabel/chipReplaceWithLabelInput.tsx @@ -0,0 +1,7 @@ +import { Chip } from '@patternfly/react-core/deprecated'; + +export const ChipReplaceWithLabelInput = () => ( + + This is a chip + +); diff --git a/packages/eslint-plugin-pf-codemods/src/rules/v6/chipReplaceWithLabel/chipReplaceWithLabelOutput.tsx b/packages/eslint-plugin-pf-codemods/src/rules/v6/chipReplaceWithLabel/chipReplaceWithLabelOutput.tsx new file mode 100644 index 000000000..c7585bae3 --- /dev/null +++ b/packages/eslint-plugin-pf-codemods/src/rules/v6/chipReplaceWithLabel/chipReplaceWithLabelOutput.tsx @@ -0,0 +1,8 @@ +import { Label } from '@patternfly/react-core'; + +export const ChipReplaceWithLabelInput = () => ( + +);