Skip to content

Commit

Permalink
Added helpers and rule logic
Browse files Browse the repository at this point in the history
  • Loading branch information
thatblindgeye committed Aug 22, 2024
1 parent 0fb616b commit 7ae6100
Show file tree
Hide file tree
Showing 9 changed files with 434 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Rule } from "eslint";
import { JSXAttribute } from "estree-jsx";
import { getVariableDeclaration } from "./JSXAttributes";

/** Used to find the node where a prop value is initially assigned, to then be passed
* as a fixer function's nodeOrToken argument. Useful for when a prop may have an inline value, e.g. `<Comp prop="value" />`, or
* is passed an identifier, e.g. `const val = "value"; <Comp prop={val} />`
*/
export function getNodeForAttributeFixer(
context: Rule.RuleContext,
attribute: JSXAttribute
) {
if (!attribute.value) {
return;
}

if (
attribute.value.type === "JSXExpressionContainer" &&
attribute.value.expression.type === "Identifier"
) {
const scope = context.getSourceCode().getScope(attribute);
const variableDeclaration = getVariableDeclaration(
attribute.value.expression.name,
scope
);

return variableDeclaration && variableDeclaration.defs[0].node.init;
}

if (attribute.value.type === "Literal") {
return attribute.value;
}
if (
attribute.value.type === "JSXExpressionContainer" &&
["ObjectExpression", "MemberExpression"].includes(
attribute.value.expression.type
)
) {
return attribute.value.expression;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Rule } from "eslint";
import { Property, Identifier } from "estree-jsx";
import { getVariableDeclaration } from "./JSXAttributes";

/** Can be used to run logic on the specified property of an ObjectExpression or
* only if the specified property exists.
*/
export function getObjectProperty(
context: Rule.RuleContext,
properties: Property[],
name: string
) {
if (!properties.length) {
return;
}

const matchedProperty = properties.find((property) => {
const isIdentifier = property.key.type === "Identifier";
const { computed } = property;

// E.g. const key = "key"; {[key]: value}
if (isIdentifier && computed) {
const scope = context.getSourceCode().getScope(property);
const propertyName = (property.key as Identifier).name;
const propertyVariable = getVariableDeclaration(propertyName, scope);
return propertyVariable?.defs[0].node.init.value === name;
}
// E.g. {key: value}
if (isIdentifier && !computed) {
return (property.key as Identifier).name === name;
}
// E.g. {"key": value} or {["key"]: value}
if (property.key.type === "Literal") {
return property.key.value === name;
}
});

return matchedProperty;
}
3 changes: 3 additions & 0 deletions packages/eslint-plugin-pf-codemods/src/rules/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ export * from "./getEndRange";
export * from "./getFromPackage";
export * from "./getImportedName";
export * from "./getLocalComponentName";
export * from "./getNodeForAttributeFixer";
export * from "./getNodeName";
export * from "./getObjectProperty";
export * from "./getSpecifierFromImports";
export * from "./getText";
export * from "./hasCodemodDataTag";
Expand All @@ -24,4 +26,5 @@ export * from "./nodeMatches";
export * from "./pfPackageMatches";
export * from "./removeElement";
export * from "./removeEmptyLineAfter";
export * from "./removePropertiesFromObjectExpression";
export * from "./renameProps";
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Property } from "estree-jsx";

/** Can be used to take the returned array and join it into a replacement string of object
* key:value pairs.
*/
export function removePropertiesFromObjectExpression(
currentProperties: Property[],
propertiesToRemove: (Property | undefined)[]
) {
if (!currentProperties) {
return [];
}
if (!propertiesToRemove) {
return currentProperties;
}

const propertyNamesToRemove = propertiesToRemove.map((property) => {
if (property?.key.type === "Identifier") {
return property.key.name;
}

if (property?.key.type === "Literal") {
return property.key.value;
}

return "";
});

const propertiesToKeep = currentProperties.filter((property) => {
if (property.key.type === "Identifier") {
return !propertyNamesToRemove.includes(property.key.name);
}

if (property.key.type === "Literal") {
return propertyNamesToRemove.includes(property.key.value);
}

return false;
});

return propertiesToKeep;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
### card-updated-clickable-markup [(#10859)](https://github.com/patternfly/patternfly-react/pull/10859)

The markup for clickable-only cards has been updated. Additionally, the `selectableActions.selectableActionId` and `selectableActions.name` props are no longer necessary to pass to CardHeader for clickable-only cards. Passing them in will not cause any errors, but running the fix for this rule will remove them.

#### Examples

In:

```jsx
%inputExample%
```

Out:

```jsx
%outputExample%
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
const ruleTester = require("../../ruletester");
import * as rule from "./card-updated-clickable-markup";

ruleTester.run("card-updated-clickable-markup", rule, {
valid: [
{
code: `<Card isClickable />`,
},
{
code: `<Card isClickable><CardHeader selectableActions={{name: 'Test', selectableActionId: 'Id' }} /></Card>`,
},
{
code: `import { Card } from '@patternfly/react-core'; <Card someOtherProp />`,
},
{
code: `import { Card } from '@patternfly/react-core'; <Card isClickable><CardHeader selectableActions={{name: 'Test', selectableActionId: 'Id'}} /></Card>`,
},
{
code: `import { CardHeader } from '@patternfly/react-core'; <Card isClickable><CardHeader selectableActions={{name: 'Test', selectableActionId: 'Id'}} /></Card>`,
},
],
invalid: [
{
code: `import { Card, CardHeader } from '@patternfly/react-core'; <Card isClickable><CardHeader selectableActions={{to: "#"}} /></Card>`,
output: `import { Card, CardHeader } from '@patternfly/react-core'; <Card isClickable><CardHeader selectableActions={{to: "#"}} /></Card>`,
errors: [
{
message: "The markup for clickable-only cards has been updated.",
type: "JSXElement",
},
],
},
{
code: `import { Card, CardHeader } from '@patternfly/react-core'; <Card isClickable><CardHeader selectableActions={{name: 'Test', selectableActionId: 'Id'}} /></Card>`,
output: `import { Card, CardHeader } from '@patternfly/react-core'; <Card isClickable><CardHeader selectableActions={{}} /></Card>`,
errors: [
{
message:
"The markup for clickable-only cards has been updated.Additionally, the `selectableActions.selectableActionId` and `selectableActions.name` props are no longer necessary to pass to CardHeader for clickable-only cards.",
type: "JSXElement",
},
],
},
{
code: `import { Card, CardHeader } from '@patternfly/react-core'; <Card isClickable><CardHeader selectableActions={{to: "#", name: 'Test', selectableActionId: 'Id'}} /></Card>`,
output: `import { Card, CardHeader } from '@patternfly/react-core'; <Card isClickable><CardHeader selectableActions={{to: "#"}} /></Card>`,
errors: [
{
message:
"The markup for clickable-only cards has been updated.Additionally, the `selectableActions.selectableActionId` and `selectableActions.name` props are no longer necessary to pass to CardHeader for clickable-only cards.",
// message: `The markup for clickable-only cards has been updated, now using button and anchor elements for the respective clickable action. The \`selectableActions.selectableActionId\` and \`selectableActions.name\` props are also no longer necessary for clickable-only cards. Passing them in will not cause any errors, but running the fix for this rule will remove them.`,
type: "JSXElement",
},
],
},
{
code: `import { Card, CardHeader } from '@patternfly/react-core'; const obj = {name: 'Test', selectableActionId: 'Id'}; <Card isClickable><CardHeader selectableActions={obj} /></Card>`,
output: `import { Card, CardHeader } from '@patternfly/react-core'; const obj = {}; <Card isClickable><CardHeader selectableActions={obj} /></Card>`,
errors: [
{
message:
"The markup for clickable-only cards has been updated.Additionally, the `selectableActions.selectableActionId` and `selectableActions.name` props are no longer necessary to pass to CardHeader for clickable-only cards.",
type: "JSXElement",
},
],
},
{
code: `import { Card, CardHeader } from '@patternfly/react-core'; const obj = {to: "#", name: 'Test', extra: "thing", selectableActionId: 'Id'}; <Card isClickable><CardHeader selectableActions={obj} /></Card>`,
output: `import { Card, CardHeader } from '@patternfly/react-core'; const obj = {to: "#", extra: "thing"}; <Card isClickable><CardHeader selectableActions={obj} /></Card>`,
errors: [
{
message:
"The markup for clickable-only cards has been updated.Additionally, the `selectableActions.selectableActionId` and `selectableActions.name` props are no longer necessary to pass to CardHeader for clickable-only cards.",
type: "JSXElement",
},
],
},
// Aliased
{
code: `import { Card as CustomCard, CardHeader as CustomCardHeader } from '@patternfly/react-core'; <CustomCard isClickable><CustomCardHeader selectableActions={{to: "#"}} /></CustomCard>`,
output: `import { Card as CustomCard, CardHeader as CustomCardHeader } from '@patternfly/react-core'; <CustomCard isClickable><CustomCardHeader selectableActions={{to: "#"}} /></CustomCard>`,
errors: [
{
message: "The markup for clickable-only cards has been updated.",
type: "JSXElement",
},
],
},
// Dist Imports
{
code: `import { Card, CardHeader } from '@patternfly/react-core/dist/esm/components/Card/index.js'; <Card isClickable><CardHeader selectableActions={{to: "#"}} /></Card>`,
output: `import { Card, CardHeader } from '@patternfly/react-core/dist/esm/components/Card/index.js'; <Card isClickable><CardHeader selectableActions={{to: "#"}} /></Card>`,
errors: [
{
message: "The markup for clickable-only cards has been updated.",
type: "JSXElement",
},
],
},
{
code: `import { Card, CardHeader } from '@patternfly/react-core/dist/js/components/Card/index.js'; <Card isClickable><CardHeader selectableActions={{to: "#"}} /></Card>`,
output: `import { Card, CardHeader } from '@patternfly/react-core/dist/js/components/Card/index.js'; <Card isClickable><CardHeader selectableActions={{to: "#"}} /></Card>`,
errors: [
{
message: "The markup for clickable-only cards has been updated.",
type: "JSXElement",
},
],
},
{
code: `import { Card, CardHeader } from '@patternfly/react-core/dist/dynamic/components/Card/index.js'; <Card isClickable><CardHeader selectableActions={{to: "#"}} /></Card>`,
output: `import { Card, CardHeader } from '@patternfly/react-core/dist/dynamic/components/Card/index.js'; <Card isClickable><CardHeader selectableActions={{to: "#"}} /></Card>`,
errors: [
{
message: "The markup for clickable-only cards has been updated.",
type: "JSXElement",
},
],
},
],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { Rule } from "eslint";
import { JSXElement, Property, Literal } from "estree-jsx";
import {
getAllImportsFromPackage,
checkMatchingJSXOpeningElement,
getAttribute,
getAttributeValue,
getSpecifierFromImports,
getChildJSXElementByName,
getObjectProperty,
removePropertiesFromObjectExpression,
getNodeForAttributeFixer,
} from "../../helpers";

// https://github.com/patternfly/patternfly-react/pull/10859
module.exports = {
meta: { fixable: "code" },
create: function (context: Rule.RuleContext) {
const basePackage = "@patternfly/react-core";
const componentImports = getAllImportsFromPackage(context, basePackage, [
"Card",
"CardHeader",
]);
const cardImport = getSpecifierFromImports(componentImports, "Card");
const cardHeaderImport = getSpecifierFromImports(
componentImports,
"CardHeader"
);

// Actionable cards won't work as intended if both components aren't imported, hence
// we shouldn't need to relay any message if that is the case
return !cardImport || !cardHeaderImport
? {}
: {
JSXElement(node: JSXElement) {
if (
checkMatchingJSXOpeningElement(node.openingElement, cardImport)
) {
const isClickableProp = getAttribute(node, "isClickable");
const isSelectableProp = getAttribute(node, "isSelectable");

if ((isClickableProp && isSelectableProp) || !isClickableProp) {
return;
}

const cardHeaderChild = getChildJSXElementByName(
node,
cardHeaderImport.local.name
);
const selectableActionsProp = cardHeaderChild
? getAttribute(cardHeaderChild, "selectableActions")
: undefined;
if (!cardHeaderChild || !selectableActionsProp) {
return;
}
const selectableActionsValue = getAttributeValue(
context,
selectableActionsProp.value
);
const nameProperty = getObjectProperty(
context,
selectableActionsValue,
"name"
);
const idProperty = getObjectProperty(
context,
selectableActionsValue,
"selectableActionId"
);

const baseMessage =
"The markup for clickable-only cards has been updated.";
const message = `${baseMessage}${
nameProperty || idProperty
? "Additionally, the `selectableActions.selectableActionId` and `selectableActions.name` props are no longer necessary to pass to CardHeader for clickable-only cards."
: ""
}`;
context.report({
node,
message,
fix(fixer) {
const validPropertiesToRemove = [
nameProperty,
idProperty,
].filter((property) => !!property);
if (
!validPropertiesToRemove.length ||
!selectableActionsProp.value
) {
return [];
}
const propertiesToKeep = removePropertiesFromObjectExpression(
selectableActionsValue,
validPropertiesToRemove
);
const replacementProperties = propertiesToKeep
.map((property: Property) =>
context.getSourceCode().getText(property)
)
.join(", ");

const nodeToUpdate = getNodeForAttributeFixer(
context,
selectableActionsProp
);
return fixer.replaceText(
nodeToUpdate,
propertiesToKeep.length
? `{${replacementProperties}}`
: "{}"
);
},
});
}
},
};
},
};
Loading

0 comments on commit 7ae6100

Please sign in to comment.