diff --git a/packages/ra-core/codemods/__testfixtures__/replace-Datagrid-DataTable-Basic.input.tsx b/packages/ra-core/codemods/__testfixtures__/replace-Datagrid-DataTable-Basic.input.tsx new file mode 100644 index 00000000000..6379c0d06b6 --- /dev/null +++ b/packages/ra-core/codemods/__testfixtures__/replace-Datagrid-DataTable-Basic.input.tsx @@ -0,0 +1,15 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import * as React from 'react'; +import { List, Datagrid, TextField } from 'react-admin'; + +const PostList = () => ( + + + + + + + +); + +export default PostList; diff --git a/packages/ra-core/codemods/__testfixtures__/replace-Datagrid-DataTable-Basic.output.tsx b/packages/ra-core/codemods/__testfixtures__/replace-Datagrid-DataTable-Basic.output.tsx new file mode 100644 index 00000000000..97738414c95 --- /dev/null +++ b/packages/ra-core/codemods/__testfixtures__/replace-Datagrid-DataTable-Basic.output.tsx @@ -0,0 +1,15 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import * as React from 'react'; +import { List, DataTable } from 'react-admin'; + +const PostList = () => ( + + + + + + + +); + +export default PostList; diff --git a/packages/ra-core/codemods/__testfixtures__/replace-Datagrid-DataTable-ManyChildren.input.tsx b/packages/ra-core/codemods/__testfixtures__/replace-Datagrid-DataTable-ManyChildren.input.tsx new file mode 100644 index 00000000000..e4a48b18c4a --- /dev/null +++ b/packages/ra-core/codemods/__testfixtures__/replace-Datagrid-DataTable-ManyChildren.input.tsx @@ -0,0 +1,45 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import * as React from 'react'; +import { + List, + Datagrid, + TextField, + EmailField, + NumberField, + EditButton, + ReferenceField, + UrlField, + DateField, +} from 'react-admin'; + +// @ts-ignore +import { MyCustomField } from './MyCustomField'; + +const PostList = () => ( + + + + + + + + + + + + + + + + + +); + +export default PostList; diff --git a/packages/ra-core/codemods/__testfixtures__/replace-Datagrid-DataTable-ManyChildren.output.tsx b/packages/ra-core/codemods/__testfixtures__/replace-Datagrid-DataTable-ManyChildren.output.tsx new file mode 100644 index 00000000000..c2cb0ed8f4b --- /dev/null +++ b/packages/ra-core/codemods/__testfixtures__/replace-Datagrid-DataTable-ManyChildren.output.tsx @@ -0,0 +1,58 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import * as React from 'react'; +import { + List, + DataTable, + TextField, + EmailField, + EditButton, + ReferenceField, + UrlField, + DateField, +} from 'react-admin'; + +// @ts-ignore +import { MyCustomField } from './MyCustomField'; + +const PostList = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default PostList; diff --git a/packages/ra-core/codemods/__testfixtures__/replace-Datagrid-DataTable-Props.input.tsx b/packages/ra-core/codemods/__testfixtures__/replace-Datagrid-DataTable-Props.input.tsx new file mode 100644 index 00000000000..0a556a21985 --- /dev/null +++ b/packages/ra-core/codemods/__testfixtures__/replace-Datagrid-DataTable-Props.input.tsx @@ -0,0 +1,42 @@ +/* eslint-disable prettier/prettier */ +/* eslint-disable import/no-extraneous-dependencies */ +import * as React from 'react'; +import { List, Datagrid, TextField, useRecordContext } from 'react-admin'; + +const CustomEmpty = () =>
No posts found
; + +const PostPanel = () => { + const record = useRecordContext(); + return
; +}; + +const postRowStyle = (record, index) => ({ + backgroundColor: record.nb_views >= 500 ? '#efe' : 'white', +}); + +const PostList = () => ( + + } + expand={} + expandSingle + rowClick={false} + optimized + rowStyle={postRowStyle} + sx={{ + '& .RaDatagrid-row': { + backgroundColor: 'white', + }, + '& .RaDatagrid-row:hover': { + backgroundColor: '#f5f5f5', + }, + }}> + + + + + +); + +export default PostList; diff --git a/packages/ra-core/codemods/__testfixtures__/replace-Datagrid-DataTable-Props.output.tsx b/packages/ra-core/codemods/__testfixtures__/replace-Datagrid-DataTable-Props.output.tsx new file mode 100644 index 00000000000..567028a71f0 --- /dev/null +++ b/packages/ra-core/codemods/__testfixtures__/replace-Datagrid-DataTable-Props.output.tsx @@ -0,0 +1,41 @@ +/* eslint-disable prettier/prettier */ +/* eslint-disable import/no-extraneous-dependencies */ +import * as React from 'react'; +import { List, DataTable, useRecordContext } from 'react-admin'; + +const CustomEmpty = () =>
No posts found
; + +const PostPanel = () => { + const record = useRecordContext(); + return
; +}; + +const postRowStyle = (record, index) => ({ + backgroundColor: record.nb_views >= 500 ? '#efe' : 'white', +}); + +const PostList = () => ( + + } + expand={} + expandSingle + rowClick={false} + rowSx={postRowStyle} + sx={{ + '& .RaDataTable-row': { + backgroundColor: 'white', + }, + '& .RaDataTable-row:hover': { + backgroundColor: '#f5f5f5', + }, + }}> + + + + + +); + +export default PostList; diff --git a/packages/ra-core/codemods/__tests__/replace-Datagrid-DataTable.spec.ts b/packages/ra-core/codemods/__tests__/replace-Datagrid-DataTable.spec.ts new file mode 100644 index 00000000000..f850e5f41eb --- /dev/null +++ b/packages/ra-core/codemods/__tests__/replace-Datagrid-DataTable.spec.ts @@ -0,0 +1,25 @@ +import { defineTest } from 'jscodeshift/dist/testUtils'; + +jest.autoMockOff(); + +defineTest( + __dirname, + 'replace-Datagrid-DataTable', + null, + 'replace-Datagrid-DataTable-Basic', + { parser: 'tsx' } +); +defineTest( + __dirname, + 'replace-Datagrid-DataTable', + null, + 'replace-Datagrid-DataTable-ManyChildren', + { parser: 'tsx' } +); +defineTest( + __dirname, + 'replace-Datagrid-DataTable', + null, + 'replace-Datagrid-DataTable-Props', + { parser: 'tsx' } +); diff --git a/packages/ra-core/codemods/replace-Datagrid-DataTable.ts b/packages/ra-core/codemods/replace-Datagrid-DataTable.ts new file mode 100644 index 00000000000..8202df831c7 --- /dev/null +++ b/packages/ra-core/codemods/replace-Datagrid-DataTable.ts @@ -0,0 +1,317 @@ +import j from 'jscodeshift'; + +module.exports = (file, api: j.API) => { + const j = api.jscodeshift; + const root = j(file.source); + + const continueAfterImport = replaceImport(root, j); + if (!continueAfterImport) { + return root.toSource(); + } + + const continueAfterReplaceDatagrid = replaceDatagrid(root, j); + if (!continueAfterReplaceDatagrid) { + return root.toSource(); + } + + transformChildren(root, j); + cleanImports(root, j); + + return root.toSource({ quote: 'single', lineTerminator: '\n' }); +}; + +const replaceImport = (root, j) => { + // Check if there is an import from react-admin + const reactAdminImport = root.find(j.ImportDeclaration, { + source: { + value: 'react-admin', + }, + }); + if (!reactAdminImport.length) { + return false; + } + + // Check if there is an import of DataGrid from react-admin + const datagridImport = reactAdminImport.filter(path => { + return path.node.specifiers.some( + specifier => + j.ImportSpecifier.check(specifier) && + specifier.imported.name === 'Datagrid' + ); + }); + if (!datagridImport.length) { + return false; + } + + // Replace import of DataGrid with DataTable + reactAdminImport.replaceWith(({ node }) => + j.importDeclaration( + node.specifiers.map(specifier => { + if ( + j.ImportSpecifier.check(specifier) && + specifier.imported.name === 'Datagrid' + ) { + return j.importSpecifier(j.identifier('DataTable')); + } + return specifier; + }), + node.source + ) + ); + return true; +}; + +const replaceDatagrid = (root, j) => { + // Find all instances of Datagrid + const datagridComponents = root.find(j.JSXElement, { + openingElement: { + name: { + type: 'JSXIdentifier', + name: 'Datagrid', + }, + }, + }); + + if (!datagridComponents.length) { + return false; + } + + // Replace Datagrid with DataTable + datagridComponents.replaceWith(({ node }) => { + return { + ...node, + openingElement: { + ...node.openingElement, + name: j.jsxIdentifier('DataTable'), + attributes: cleanAttributes(node, j), + }, + closingElement: { + ...node.closingElement, + name: j.jsxIdentifier('DataTable'), + }, + }; + }); + + return true; +}; + +const cleanAttributes = (node, j) => { + const initialAttributes = node.openingElement.attributes; + + // rename the `rowStyle` attribute to `rowSx` if it exists + const rowSxRenamedAttributes = initialAttributes.map(attr => { + if (j.JSXAttribute.check(attr) && attr.name.name === 'rowStyle') { + return j.jsxAttribute(j.jsxIdentifier('rowSx'), attr.value); + } + return attr; + }); + + // rename the keys of the "sx" prop from "& .RaDatagrid-xxxx" to "& .RaDataTable-xxxx" + const sxRenamedAttributes = rowSxRenamedAttributes.map(attr => { + if ( + j.JSXAttribute.check(attr) && + attr.name.name === 'sx' && + j.JSXExpressionContainer.check(attr.value) + ) { + const expression = attr.value.expression; + if (j.ObjectExpression.check(expression)) { + expression.properties.map(prop => { + if ( + j.ObjectProperty.check(prop) && + j.Literal.check(prop.key) && + typeof prop.key.value === 'string' + ) { + prop.key.value = prop.key.value.replace( + /RaDatagrid-/g, + 'RaDataTable-' + ); + } + return prop; + }); + return attr; + } + } + return attr; + }); + + // remove the `optimized` attribute if it exists + const finalAttributes = sxRenamedAttributes.filter( + attr => !(j.JSXAttribute.check(attr) && attr.name.name === 'optimized') + ); + + return finalAttributes; +}; + +const transformChildren = (root, j) => { + // Find all instances of Datagrid + const datagridComponents = root.find(j.JSXElement, { + openingElement: { + name: { + type: 'JSXIdentifier', + name: 'DataTable', + }, + }, + }); + if (!datagridComponents.length) { + return false; + } + + // For each DataTable component, wrap its children in DataTable.Col + datagridComponents.forEach(dataTableComponent => { + const children = dataTableComponent.value.children.filter(child => + j.JSXElement.check(child) + ); + children.forEach(child => { + transformChild(root, j, child); + }); + }); +}; + +const transformChild = (root, j, child) => { + let newChild; + if ( + j.JSXElement.check(child) && + child.openingElement.name.type === 'JSXIdentifier' && + child.openingElement.name.name === 'TextField' && + !child.openingElement.attributes.some( + attr => + j.JSXAttribute.check(attr) && + !['source', 'label', 'empty'].includes(attr.name.name) + ) + ) { + child.openingElement.name.name = 'DataTable.Col'; + } else if ( + j.JSXElement.check(child) && + child.openingElement.name.type === 'JSXIdentifier' && + child.openingElement.name.name === 'NumberField' && + !child.openingElement.attributes.some( + attr => + j.JSXAttribute.check(attr) && + !['source', 'label', 'empty', 'options', 'locales'].includes( + attr.name.name + ) + ) + ) { + child.openingElement.name.name = 'DataTable.NumberCol'; + } else { + newChild = wrapChild(j, child); + + // Replace the original child with the new child + root.find(j.JSXElement, { + openingElement: { + name: { + type: 'JSXIdentifier', + name: 'DataTable', + }, + }, + }).forEach(dataTableComponent => { + dataTableComponent.value.children = + dataTableComponent.value.children.map(c => + c === child ? newChild : c + ); + }); + } +}; + +const wrapChild = (j, child) => { + const labelAttribute = child.openingElement.attributes.find( + attr => j.JSXAttribute.check(attr) && attr.name.name === 'label' + ); + const sourceAttribute = child.openingElement.attributes.find( + attr => j.JSXAttribute.check(attr) && attr.name.name === 'source' + ); + + // Wrap the child in a DataTable.Col component + return j.jsxElement( + j.jsxOpeningElement( + j.jsxIdentifier('DataTable.Col'), + labelAttribute + ? [labelAttribute] + : sourceAttribute + ? [sourceAttribute] + : [], + false + ), + j.jsxClosingElement(j.jsxIdentifier('DataTable.Col')), + [j.jsxText('\n'), child, j.jsxText('\n')] + ); +}; + +const cleanImports = (root, j) => { + // Check if there is still a use of TextField in the code + const textFieldUsage = root.find(j.JSXElement, { + openingElement: { + name: { + type: 'JSXIdentifier', + name: 'TextField', + }, + }, + }); + // Check if there is still a use of NumberField in the code + const numberFieldUsage = root.find(j.JSXElement, { + openingElement: { + name: { + type: 'JSXIdentifier', + name: 'NumberField', + }, + }, + }); + + const imports = root.find(j.ImportDeclaration, { + source: { + value: 'react-admin', + }, + }); + // Check if there is an import of TextField from react-admin + const textFieldImport = imports.filter(path => { + return path.node.specifiers.some( + specifier => + j.ImportSpecifier.check(specifier) && + specifier.imported.name === 'TextField' + ); + }); + const numberFieldImport = imports.filter(path => { + return path.node.specifiers.some( + specifier => + j.ImportSpecifier.check(specifier) && + specifier.imported.name === 'NumberField' + ); + }); + + if (!textFieldUsage.length && textFieldImport.length) { + // Remove the import of TextField from react-admin + textFieldImport.forEach(path => { + path.node.specifiers = path.node.specifiers.filter( + specifier => + !( + j.ImportSpecifier.check(specifier) && + specifier.imported.name === 'TextField' + ) + ); + }); + // Remove the import declaration if there are no more specifiers + root.find(j.ImportDeclaration).forEach(path => { + if (path.node.specifiers.length === 0) { + j(path).remove(); + } + }); + } + if (!numberFieldUsage.length && numberFieldImport.length) { + // Remove the import of NumberField from react-admin + numberFieldImport.forEach(path => { + path.node.specifiers = path.node.specifiers.filter( + specifier => + !( + j.ImportSpecifier.check(specifier) && + specifier.imported.name === 'NumberField' + ) + ); + }); + // Remove the import declaration if there are no more specifiers + root.find(j.ImportDeclaration).forEach(path => { + if (path.node.specifiers.length === 0) { + j(path).remove(); + } + }); + } +};