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();
+ }
+ });
+ }
+};