From f80f1575315ec2604377b2b0bc8c109c976e2f7e Mon Sep 17 00:00:00 2001 From: Austin Sullivan Date: Thu, 18 Jul 2024 15:42:06 -0400 Subject: [PATCH] feat(EmptyState): Add auto fix support for some child components (#703) --- .../rules/helpers/getRemoveElementFixes.ts | 16 ++ .../src/rules/helpers/index.ts | 1 + ...tyStateHeader-move-into-emptyState.test.ts | 140 +++++++++++ .../emptyStateHeader-move-into-emptyState.ts | 236 +++++++++++------- ...mptyStateHeaderMoveIntoEmptyStateInput.tsx | 14 +- ...ptyStateHeaderMoveIntoEmptyStateOutput.tsx | 14 +- 6 files changed, 333 insertions(+), 88 deletions(-) create mode 100644 packages/eslint-plugin-pf-codemods/src/rules/helpers/getRemoveElementFixes.ts diff --git a/packages/eslint-plugin-pf-codemods/src/rules/helpers/getRemoveElementFixes.ts b/packages/eslint-plugin-pf-codemods/src/rules/helpers/getRemoveElementFixes.ts new file mode 100644 index 000000000..21a3de486 --- /dev/null +++ b/packages/eslint-plugin-pf-codemods/src/rules/helpers/getRemoveElementFixes.ts @@ -0,0 +1,16 @@ +import { Rule } from "eslint"; +import { JSXElement } from "estree-jsx"; +import { removeElement, removeEmptyLineAfter } from "./index"; + +export const getRemoveElementFixes = ( + context: Rule.RuleContext, + fixer: Rule.RuleFixer, + elementsToRemove: JSXElement[] +) => { + return elementsToRemove + .map((element) => [ + ...removeElement(fixer, element), + ...removeEmptyLineAfter(context, fixer, element), + ]) + .flat(); +}; 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 07b6cac5e..30a00d689 100644 --- a/packages/eslint-plugin-pf-codemods/src/rules/helpers/index.ts +++ b/packages/eslint-plugin-pf-codemods/src/rules/helpers/index.ts @@ -9,5 +9,6 @@ export * from "./pfPackageMatches"; export * from "./renameProps"; export * from "./getImportDeclaration"; export * from "./getEndRange"; +export * from "./getRemoveElementFixes"; export * from "./removeElement"; export * from "./removeEmptyLineAfter"; diff --git a/packages/eslint-plugin-pf-codemods/src/rules/v6/emptyStateHeaderMoveIntoEmptyState/emptyStateHeader-move-into-emptyState.test.ts b/packages/eslint-plugin-pf-codemods/src/rules/v6/emptyStateHeaderMoveIntoEmptyState/emptyStateHeader-move-into-emptyState.test.ts index d2f19113a..278e05ac1 100644 --- a/packages/eslint-plugin-pf-codemods/src/rules/v6/emptyStateHeaderMoveIntoEmptyState/emptyStateHeader-move-into-emptyState.test.ts +++ b/packages/eslint-plugin-pf-codemods/src/rules/v6/emptyStateHeaderMoveIntoEmptyState/emptyStateHeader-move-into-emptyState.test.ts @@ -452,5 +452,145 @@ ruleTester.run("emptyStateHeader-move-into-emptyState", rule, { }, ], }, + { + // with a Title child and no EmptyStateHeader + code: `import { + EmptyState, + EmptyStateBody, + Title + } from "@patternfly/react-core"; + + export const EmptyStateHeaderMoveIntoEmptyStateInput = () => ( + + + Title text + + + Body + + + ); + `, + output: `import { + EmptyState, + EmptyStateBody, + Title + } from "@patternfly/react-core"; + + export const EmptyStateHeaderMoveIntoEmptyStateInput = () => ( + + Title text + }> + + Body + + + ); + `, + errors: [ + { + message: `EmptyStateHeader has been moved inside of the EmptyState component and is now only customizable using props, and the titleText prop is now required on EmptyState.`, + type: "JSXElement", + }, + ], + }, + { + // with Title and EmptyStateIcon children and no EmptyStateHeader + code: `import { + EmptyState, + EmptyStateBody, + EmptyStateIcon, + Title + } from "@patternfly/react-core"; + import CubesIcon from '@patternfly/react-icons/dist/esm/icons/cubes-icon'; + + export const EmptyStateHeaderMoveIntoEmptyStateInput = () => ( + + + Title text + + + + Body + + + ); + `, + output: `import { + EmptyState, + EmptyStateBody, + EmptyStateIcon, + Title + } from "@patternfly/react-core"; + import CubesIcon from '@patternfly/react-icons/dist/esm/icons/cubes-icon'; + + export const EmptyStateHeaderMoveIntoEmptyStateInput = () => ( + + Title text + } icon={CubesIcon}> + + Body + + + ); + `, + errors: [ + { + message: `The color prop on EmptyStateIcon has been removed. We suggest using the new status prop on EmptyState to apply colors to the icon.`, + type: "JSXElement", + }, + { + message: `EmptyStateHeader has been moved inside of the EmptyState component and is now only customizable using props, and the titleText prop is now required on EmptyState. Additionally, the EmptyStateIcon component now wraps content passed to the icon prop automatically.`, + type: "JSXElement", + }, + ], + }, + { + // with EmptyStateHeader and EmptyStateIcon children + code: `import { + EmptyState, + EmptyStateBody, + EmptyStateHeader, + EmptyStateIcon + } from "@patternfly/react-core"; + import CubesIcon from '@patternfly/react-icons/dist/esm/icons/cubes-icon'; + + export const EmptyStateHeaderMoveIntoEmptyStateInput = () => ( + + Foo + + + Body + + + ); + `, + output: `import { + EmptyState, + EmptyStateBody, + EmptyStateHeader, + EmptyStateIcon + } from "@patternfly/react-core"; + import CubesIcon from '@patternfly/react-icons/dist/esm/icons/cubes-icon'; + + export const EmptyStateHeaderMoveIntoEmptyStateInput = () => ( + + + Body + + + ); + `, + errors: [ + { + message: `The color prop on EmptyStateIcon has been removed. We suggest using the new status prop on EmptyState to apply colors to the icon.`, + type: "JSXElement", + }, + { + message: `EmptyStateHeader has been moved inside of the EmptyState component and is now only customizable using props, and the titleText prop is now required on EmptyState. Additionally, the EmptyStateIcon component now wraps content passed to the icon prop automatically.`, + type: "JSXElement", + }, + ], + }, ], }); diff --git a/packages/eslint-plugin-pf-codemods/src/rules/v6/emptyStateHeaderMoveIntoEmptyState/emptyStateHeader-move-into-emptyState.ts b/packages/eslint-plugin-pf-codemods/src/rules/v6/emptyStateHeaderMoveIntoEmptyState/emptyStateHeader-move-into-emptyState.ts index 71ccff43e..80027d92b 100644 --- a/packages/eslint-plugin-pf-codemods/src/rules/v6/emptyStateHeaderMoveIntoEmptyState/emptyStateHeader-move-into-emptyState.ts +++ b/packages/eslint-plugin-pf-codemods/src/rules/v6/emptyStateHeaderMoveIntoEmptyState/emptyStateHeader-move-into-emptyState.ts @@ -4,6 +4,7 @@ import { JSXAttribute, JSXElement, JSXIdentifier, + Node, } from "estree-jsx"; import { getAttribute, @@ -16,8 +17,7 @@ import { includesImport, nodeIsComponentNamed, getChildrenAsAttributeValueText, - removeElement, - removeEmptyLineAfter, + getRemoveElementFixes, } from "../../helpers"; // https://github.com/patternfly/patternfly-react/pull/9947 @@ -114,9 +114,38 @@ const getIconPropText = ( return ""; }; +const getIcon = ( + context: Rule.RuleContext, + node: Node, + emptyStateIconComponent?: JSXElement, + iconElementIdentifier?: JSXIdentifier +) => { + const emptyStateIconComponentIconAttribute = + emptyStateIconComponent && getAttribute(emptyStateIconComponent, "icon"); + + const emptyStateIconComponentColorAttribute = + emptyStateIconComponent && getAttribute(emptyStateIconComponent, "color"); + + if (emptyStateIconComponentColorAttribute) { + context.report({ + node, + message: `The color prop on EmptyStateIcon has been removed. We suggest using the new status prop on EmptyState to apply colors to the icon.`, + }); + } + + const iconProp = getIconPropText( + context, + iconElementIdentifier, + emptyStateIconComponentIconAttribute + ); + + return iconProp; +}; + module.exports = { meta: { fixable: "code" }, create: function (context: Rule.RuleContext) { + const source = context.getSourceCode(); const pkg = "@patternfly/react-core"; const { imports } = getFromPackage(context, pkg); @@ -139,9 +168,14 @@ module.exports = { return; } - if (!header || header.type !== "JSXElement") { - // report without fixer if there is no header or the header is not a React element, because creating a - // titleText for the EmptyState in this case is difficult + const titleChild = getChildElementByName(node, "Title"); + + if ( + (!header || header.type !== "JSXElement") && + (!titleChild || titleChild.type !== "JSXElement") + ) { + // report without fixer if there is no header/title or the header/title is not a React element, because + // creating a titleText for the EmptyState in this case is difficult context.report({ node, message: composeMessage(), @@ -149,106 +183,138 @@ module.exports = { return; } - const headingClassNameAttribute = getAttribute(header, "className"); - const headingLevelAttribute = getAttribute(header, "headingLevel"); - const titleClassNameAttribute = getAttribute(header, "titleClassName"); - const titleTextAttribute = getAttribute(header, "titleText"); - const headerIconAttribute = getAttribute(header, "icon"); + const newEmptyStateProps: string[] = []; + const removeElements: JSXElement[] = []; - const headerChildren = header.children; - - const message = composeMessage( - !!titleTextAttribute, - !!headerIconAttribute, - !!headerChildren.length - ); + if (titleChild && !header) { + // there is no header, but there is a Title as a child of the emptyState - if (!titleTextAttribute && !headerChildren.length) { - // report without fixer if there is a header, but it doesn't have titleText or children, because creating a - // titleText for the EmptyState in this case is difficult - context.report({ node, message }); - return; - } - - if (titleTextAttribute && headerChildren.length) { - // report without fixer if there is the header has a titleText and children, because creating an accessible - // titleText for the EmptyState in this case is difficult - context.report({ node, message }); - return; + const titleComponentText = source.getText(titleChild); + newEmptyStateProps.push(`titleText={${titleComponentText}}`); + removeElements.push(titleChild); } - const headingClassNameValue = getAttributeValueText( - context, - headingClassNameAttribute + const emptyStateIconChild = getChildElementByName( + node, + "EmptyStateIcon" ); - const headingClassName = headingClassNameValue - ? `headerClassName=${headingClassNameValue}` - : ""; - const headingLevel = getAttributeText(context, headingLevelAttribute); - const titleClassName = getAttributeText( - context, - titleClassNameAttribute - ); - const titleTextPropValue = getAttributeText( - context, - titleTextAttribute - ); + let iconProp: string = ""; - const titleText = - titleTextPropValue || - `titleText=${getChildrenAsAttributeValueText( - context, - headerChildren - )}`; + if (emptyStateIconChild) { + iconProp = getIcon(context, node, emptyStateIconChild); + removeElements.push(emptyStateIconChild); + } - const iconPropValue = getExpression(headerIconAttribute?.value); + if (emptyStateIconChild && !header) { + newEmptyStateProps.push(iconProp); + } - const iconElementIdentifier = - iconPropValue?.type === "JSXElement" && - iconPropValue.openingElement.name.type === "JSXIdentifier" - ? iconPropValue.openingElement.name + let hasTitleText = false; + let hasIcon = !!emptyStateIconChild; + let hasChildren = !!titleChild; + + if (header) { + const headingClassNameAttribute = getAttribute(header, "className"); + const headingLevelAttribute = getAttribute(header, "headingLevel"); + const titleClassNameAttribute = getAttribute( + header, + "titleClassName" + ); + const titleTextAttribute = getAttribute(header, "titleText"); + const headerIconAttribute = getAttribute(header, "icon"); + + hasTitleText = !!titleTextAttribute; + hasIcon ||= !!headerIconAttribute; + hasChildren ||= header.children.length > 0; + + const message = composeMessage(hasTitleText, hasIcon, hasChildren); + + if (!titleTextAttribute && !hasChildren) { + // report without fixer if there is a header, but it doesn't have titleText or children, because creating a + // titleText for the EmptyState in this case is difficult + context.report({ node, message }); + return; + } + + if (titleTextAttribute && hasChildren) { + // report without fixer if there is the header has a titleText and children, because creating an accessible + // titleText for the EmptyState in this case is difficult + context.report({ node, message }); + return; + } + + const headingClassNameValue = getAttributeValueText( + context, + headingClassNameAttribute + ); + const headingClassNameProp = headingClassNameValue + ? `headerClassName=${headingClassNameValue}` + : ""; + + const headingLevel = getAttributeText(context, headingLevelAttribute); + const titleClassName = getAttributeText( + context, + titleClassNameAttribute + ); + const titleTextPropValue = getAttributeText( + context, + titleTextAttribute + ); + + const titleText = + titleTextPropValue || + `titleText=${getChildrenAsAttributeValueText( + context, + header.children + )}`; + + const iconPropValue = getExpression(headerIconAttribute?.value); + + const iconElementIdentifier = + iconPropValue?.type === "JSXElement" && + iconPropValue.openingElement.name.type === "JSXIdentifier" + ? iconPropValue.openingElement.name + : undefined; + + const emptyStateIconComponent = iconPropIsEmptyStateIconComponent( + imports, + iconElementIdentifier + ) + ? (iconPropValue as JSXElement) : undefined; - const emptyStateIconComponent = iconPropIsEmptyStateIconComponent( - imports, - iconElementIdentifier - ) - ? (iconPropValue as JSXElement) - : undefined; - - const emptyStateIconComponentIconAttribute = - emptyStateIconComponent && - getAttribute(emptyStateIconComponent, "icon"); - - const emptyStateIconComponentColorAttribute = - emptyStateIconComponent && - getAttribute(emptyStateIconComponent, "color"); - - if (emptyStateIconComponentColorAttribute) { - context.report({ - node, - message: `The color prop on EmptyStateIcon has been removed. We suggest using the new status prop on EmptyState to apply colors to the icon.`, - }); + if (headerIconAttribute) { + iconProp = getIcon( + context, + node, + emptyStateIconComponent, + iconElementIdentifier + ); + } + + removeElements.push(header); + newEmptyStateProps.push( + ...[ + headingClassNameProp, + headingLevel, + iconProp, + titleClassName, + titleText, + ] + ); } - const icon = getIconPropText( - context, - iconElementIdentifier, - emptyStateIconComponentIconAttribute - ); - context.report({ node, - message, + message: composeMessage(hasTitleText, hasIcon, hasChildren), fix(fixer) { return [ fixer.insertTextAfter( node.openingElement.name, - ` ${headingClassName} ${headingLevel} ${icon} ${titleClassName} ${titleText}` + ` ${newEmptyStateProps.join(" ")}` ), - ...removeElement(fixer, header), - ...removeEmptyLineAfter(context, fixer, header), + ...getRemoveElementFixes(context, fixer, removeElements), ]; }, }); diff --git a/packages/eslint-plugin-pf-codemods/src/rules/v6/emptyStateHeaderMoveIntoEmptyState/emptyStateHeaderMoveIntoEmptyStateInput.tsx b/packages/eslint-plugin-pf-codemods/src/rules/v6/emptyStateHeaderMoveIntoEmptyState/emptyStateHeaderMoveIntoEmptyStateInput.tsx index e3fe33eca..04605a858 100644 --- a/packages/eslint-plugin-pf-codemods/src/rules/v6/emptyStateHeaderMoveIntoEmptyState/emptyStateHeaderMoveIntoEmptyStateInput.tsx +++ b/packages/eslint-plugin-pf-codemods/src/rules/v6/emptyStateHeaderMoveIntoEmptyState/emptyStateHeaderMoveIntoEmptyStateInput.tsx @@ -1,8 +1,10 @@ import { EmptyState, + EmptyStateBody, EmptyStateHeader, EmptyStateIcon, - CubesIcon + CubesIcon, + Title, } from "@patternfly/react-core"; export const EmptyStateHeaderMoveIntoEmptyStateInput = () => ( @@ -14,3 +16,13 @@ export const EmptyStateHeaderMoveIntoEmptyStateInput = () => ( /> ); + +export const EmptyStateWithoutHeaderMoveIntoEmptyStateInput = () => ( + + + Foo + + + Body + +); diff --git a/packages/eslint-plugin-pf-codemods/src/rules/v6/emptyStateHeaderMoveIntoEmptyState/emptyStateHeaderMoveIntoEmptyStateOutput.tsx b/packages/eslint-plugin-pf-codemods/src/rules/v6/emptyStateHeaderMoveIntoEmptyState/emptyStateHeaderMoveIntoEmptyStateOutput.tsx index 2772835bc..1d4f3f451 100644 --- a/packages/eslint-plugin-pf-codemods/src/rules/v6/emptyStateHeaderMoveIntoEmptyState/emptyStateHeaderMoveIntoEmptyStateOutput.tsx +++ b/packages/eslint-plugin-pf-codemods/src/rules/v6/emptyStateHeaderMoveIntoEmptyState/emptyStateHeaderMoveIntoEmptyStateOutput.tsx @@ -1,11 +1,21 @@ import { EmptyState, + EmptyStateBody, EmptyStateHeader, EmptyStateIcon, - CubesIcon + CubesIcon, + Title, } from "@patternfly/react-core"; export const EmptyStateHeaderMoveIntoEmptyStateInput = () => ( - + ); + +export const EmptyStateWithoutHeaderMoveIntoEmptyStateInput = () => ( + + Foo + } icon={CubesIcon}> + Body + +);