From 1060637bf9018a34a8fc3603857b1655e631c9ab Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 24 Jun 2026 21:22:18 -0500 Subject: [PATCH 1/6] feat: MUI 4 to BUI migration - complex component codemods Add 5 complex component codemods: - migrate-mui-dialog-to-bui-dialog (#117) - migrate-mui-tabs-to-bui-tabs (#118) - migrate-mui-menu-popover-to-bui-menu (#119) - migrate-mui-list-family-to-bui-list (#120) - migrate-mui-chip-to-tag (#121) Closes #117, #118, #119, #120, #121 --- .changeset/mui-to-bui-complex-components.md | 9 + .../misc/migrate-mui-chip-to-tag/CHANGELOG.md | 1 + .../misc/migrate-mui-chip-to-tag/codemod.yaml | 20 + .../misc/migrate-mui-chip-to-tag/package.json | 13 + .../scripts/codemod.ts | 358 ++++++++++ .../tests/chip-group/expected.tsx | 11 + .../tests/chip-group/input.tsx | 8 + .../tests/chip-group/metrics.json | 23 + .../tests/chip-no-size/expected.tsx | 5 + .../tests/chip-no-size/input.tsx | 5 + .../tests/chip-no-size/metrics.json | 22 + .../tests/clickable-chip-todo/expected.tsx | 6 + .../tests/clickable-chip-todo/input.tsx | 5 + .../tests/clickable-chip-todo/metrics.json | 23 + .../tests/dynamic-label/expected.tsx | 5 + .../tests/dynamic-label/input.tsx | 5 + .../tests/dynamic-label/metrics.json | 22 + .../tests/interactive-chip-todo/expected.tsx | 6 + .../tests/interactive-chip-todo/input.tsx | 5 + .../tests/interactive-chip-todo/metrics.json | 23 + .../tests/merge-existing-bui/expected.tsx | 9 + .../tests/merge-existing-bui/input.tsx | 9 + .../tests/merge-existing-bui/metrics.json | 22 + .../tests/named-import-barrel/expected.tsx | 5 + .../tests/named-import-barrel/input.tsx | 5 + .../tests/named-import-barrel/metrics.json | 22 + .../tests/noop-no-import/expected.tsx | 5 + .../tests/noop-no-import/input.tsx | 5 + .../tests/simple-display-chip/expected.tsx | 5 + .../tests/simple-display-chip/input.tsx | 5 + .../tests/simple-display-chip/metrics.json | 22 + .../migrate-mui-chip-to-tag/tsconfig.json | 16 + .../migrate-mui-chip-to-tag/workflow.yaml | 19 + .../CHANGELOG.md | 1 + .../codemod.yaml | 20 + .../package.json | 13 + .../scripts/codemod.ts | 458 ++++++++++++ .../tests/complex-on-close-todo/expected.tsx | 11 + .../tests/complex-on-close-todo/input.tsx | 10 + .../tests/complex-on-close-todo/metrics.json | 23 + .../tests/controlled-dialog/expected.tsx | 8 + .../tests/controlled-dialog/input.tsx | 14 + .../tests/controlled-dialog/metrics.json | 58 ++ .../tests/full-width-todo/expected.tsx | 11 + .../tests/full-width-todo/input.tsx | 10 + .../tests/full-width-todo/metrics.json | 23 + .../tests/merge-existing-bui/expected.tsx | 8 + .../tests/merge-existing-bui/input.tsx | 11 + .../tests/merge-existing-bui/metrics.json | 44 ++ .../tests/named-barrel-import/expected.tsx | 5 + .../tests/named-barrel-import/input.tsx | 8 + .../tests/named-barrel-import/metrics.json | 44 ++ .../tests/noop-no-import/expected.tsx | 7 + .../tests/noop-no-import/input.tsx | 7 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + .../CHANGELOG.md | 1 + .../codemod.yaml | 20 + .../package.json | 13 + .../scripts/codemod.ts | 492 +++++++++++++ .../tests/interactive-todo/expected.tsx | 12 + .../tests/interactive-todo/input.tsx | 11 + .../tests/interactive-todo/metrics.json | 23 + .../tests/merge-existing-bui/expected.tsx | 13 + .../tests/merge-existing-bui/input.tsx | 15 + .../tests/merge-existing-bui/metrics.json | 22 + .../tests/named-barrel-import/expected.tsx | 7 + .../tests/named-barrel-import/input.tsx | 10 + .../tests/named-barrel-import/metrics.json | 22 + .../tests/noop-no-import/expected.tsx | 7 + .../tests/noop-no-import/input.tsx | 7 + .../tests/simple-list-with-icon/expected.tsx | 10 + .../tests/simple-list-with-icon/input.tsx | 13 + .../tests/simple-list-with-icon/metrics.json | 22 + .../tests/text-only-no-icon/expected.tsx | 9 + .../tests/text-only-no-icon/input.tsx | 11 + .../tests/text-only-no-icon/metrics.json | 22 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + .../CHANGELOG.md | 1 + .../codemod.yaml | 20 + .../package.json | 13 + .../scripts/codemod.ts | 504 +++++++++++++ .../tests/anchor-el-todo/expected.tsx | 9 + .../tests/anchor-el-todo/input.tsx | 8 + .../tests/anchor-el-todo/metrics.json | 23 + .../tests/merge-existing-bui/expected.tsx | 10 + .../tests/merge-existing-bui/input.tsx | 12 + .../tests/merge-existing-bui/metrics.json | 41 ++ .../tests/named-barrel-import/expected.tsx | 5 + .../tests/named-barrel-import/input.tsx | 7 + .../tests/named-barrel-import/metrics.json | 41 ++ .../tests/noop-no-import/expected.tsx | 7 + .../tests/noop-no-import/input.tsx | 7 + .../tests/popover-with-menu-list/expected.tsx | 7 + .../tests/popover-with-menu-list/input.tsx | 11 + .../tests/popover-with-menu-list/metrics.json | 53 ++ .../tests/simple-menu/expected.tsx | 6 + .../tests/simple-menu/input.tsx | 9 + .../tests/simple-menu/metrics.json | 47 ++ .../tsconfig.json | 16 + .../workflow.yaml | 19 + .../migrate-mui-tabs-to-bui-tabs/CHANGELOG.md | 1 + .../migrate-mui-tabs-to-bui-tabs/codemod.yaml | 20 + .../migrate-mui-tabs-to-bui-tabs/package.json | 13 + .../scripts/codemod.ts | 674 ++++++++++++++++++ .../tests/merge-existing-bui/expected.tsx | 9 + .../tests/merge-existing-bui/input.tsx | 14 + .../tests/merge-existing-bui/metrics.json | 46 ++ .../tests/named-barrel-import/expected.tsx | 5 + .../tests/named-barrel-import/input.tsx | 8 + .../tests/named-barrel-import/metrics.json | 28 + .../tests/noop-no-import/expected.tsx | 9 + .../tests/noop-no-import/input.tsx | 9 + .../tests/standalone-tabs/expected.tsx | 6 + .../tests/standalone-tabs/input.tsx | 9 + .../tests/standalone-tabs/metrics.json | 34 + .../tests/tab-context-pattern/expected.tsx | 8 + .../tests/tab-context-pattern/input.tsx | 15 + .../tests/tab-context-pattern/metrics.json | 46 ++ .../tests/vertical-tabs-todo/expected.tsx | 10 + .../tests/vertical-tabs-todo/input.tsx | 9 + .../tests/vertical-tabs-todo/metrics.json | 23 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + yarn.lock | 45 ++ 126 files changed, 4302 insertions(+) create mode 100644 .changeset/mui-to-bui-complex-components.md create mode 100644 codemods/misc/migrate-mui-chip-to-tag/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-chip-to-tag/codemod.yaml create mode 100644 codemods/misc/migrate-mui-chip-to-tag/package.json create mode 100644 codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/expected.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/input.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/metrics.json create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/expected.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/input.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/metrics.json create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/expected.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/input.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/metrics.json create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/expected.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/input.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/metrics.json create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/expected.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/input.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/metrics.json create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tsconfig.json create mode 100644 codemods/misc/migrate-mui-chip-to-tag/workflow.yaml create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/codemod.yaml create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/package.json create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/expected.tsx create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/input.tsx create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/metrics.json create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/input.tsx create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/metrics.json create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/tsconfig.json create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/workflow.yaml create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/codemod.yaml create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/package.json create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/input.tsx create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/metrics.json create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/expected.tsx create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/input.tsx create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/metrics.json create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/expected.tsx create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/input.tsx create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/metrics.json create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/tsconfig.json create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/workflow.yaml create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/codemod.yaml create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/package.json create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/input.tsx create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/metrics.json create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/expected.tsx create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/input.tsx create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/metrics.json create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/expected.tsx create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/input.tsx create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/metrics.json create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tsconfig.json create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/workflow.yaml create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/codemod.yaml create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/package.json create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/input.tsx create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/metrics.json create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/expected.tsx create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/input.tsx create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/metrics.json create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/expected.tsx create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/input.tsx create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/metrics.json create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/tsconfig.json create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/workflow.yaml diff --git a/.changeset/mui-to-bui-complex-components.md b/.changeset/mui-to-bui-complex-components.md new file mode 100644 index 0000000..a837378 --- /dev/null +++ b/.changeset/mui-to-bui-complex-components.md @@ -0,0 +1,9 @@ +--- +'@backstage/migrate-mui-dialog-to-bui-dialog': minor +'@backstage/migrate-mui-tabs-to-bui-tabs': minor +'@backstage/migrate-mui-menu-popover-to-bui-menu': minor +'@backstage/migrate-mui-list-family-to-bui-list': minor +'@backstage/migrate-mui-chip-to-tag': minor +--- + +Add complex component codemods for the MUI 4 to BUI migration: Dialog, Tabs, Menu/Popover, List family, and Chip to Tag. diff --git a/codemods/misc/migrate-mui-chip-to-tag/CHANGELOG.md b/codemods/misc/migrate-mui-chip-to-tag/CHANGELOG.md new file mode 100644 index 0000000..d93d678 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-chip-to-tag diff --git a/codemods/misc/migrate-mui-chip-to-tag/codemod.yaml b/codemods/misc/migrate-mui-chip-to-tag/codemod.yaml new file mode 100644 index 0000000..ad01b87 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-chip-to-tag' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Chip with Tag' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'chip', 'tag'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-chip-to-tag/package.json b/codemods/misc/migrate-mui-chip-to-tag/package.json new file mode 100644 index 0000000..36191df --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-chip-to-tag", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Chip with Tag", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts b/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts new file mode 100644 index 0000000..3a814fe --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts @@ -0,0 +1,358 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-chip-to-tag') + +const BUI_SOURCE = '@backstage/ui' + +/** Props that indicate an interactive chip — not safe to auto-migrate. */ +const INTERACTIVE_PROPS = new Set(['onDelete', 'clickable', 'avatar', 'deleteIcon', 'onClick']) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): string | null { + const clause = imp.find({ rule: { kind: 'import_clause' } }) + if (!clause) { + return null + } + for (const child of clause.children()) { + if (child.is('identifier')) { + return child.text() + } + } + return null +} + +function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (importedNameNode?.text() === targetName) { + const localNameNode = identifiers[1] ?? importedNameNode + return localNameNode.text() + } + } + return null +} + +function collectChipImports(rootNode: SgNode): { + chipLocalName: string | null + importNodesToRemove: SgNode[] +} { + let chipLocalName: string | null = null + const importNodesToRemove: SgNode[] = [] + + // Default import: import Chip from '@material-ui/core/Chip' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Chip')) { + chipLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + // Named import from barrel: import { Chip } from '@material-ui/core' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + const localName = getNamedImportLocalName(imp, 'Chip') + if (localName) { + chipLocalName = localName + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (allSpecifiers.length <= 1) { + importNodesToRemove.push(imp) + } + } + } + + return { chipLocalName, importNodesToRemove } +} + +function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): void { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const existing = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + for (const name of names) { + if (!existing.includes(name)) { + existing.push(name) + } + } + existing.sort() + edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + } else { + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const sortedNames = [...names].sort() + if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push( + lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + } + } + migrationMetric.increment({ action: 'import-added' }) + } +} + +function hasProp(opening: SgNode, propName: string): boolean { + return ( + opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) !== null + ) +} + +function getPropStringValue(opening: SgNode, propName: string): string | null { + const attr = opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) + if (!attr) { + return null + } + const stringNode = attr.find({ rule: { kind: 'string' } }) + if (stringNode) { + const frag = stringNode.find({ rule: { kind: 'string_fragment' } }) + return frag?.text() ?? null + } + return null +} + +function getPropRawValue(opening: SgNode, propName: string): string | null { + const attr = opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) + if (!attr) { + return null + } + for (const child of attr.children()) { + const kind = child.kind() + if (kind === 'string' || kind === 'jsx_expression') { + return child.text() + } + } + return null +} + +function isInteractiveChip(opening: SgNode): boolean { + for (const propName of INTERACTIVE_PROPS) { + if (hasProp(opening, propName)) { + return true + } + } + return false +} + +function getElementName(opening: SgNode): string | null { + for (const child of opening.children()) { + if (child.is('identifier') || child.is('member_expression')) { + return child.text() + } + } + return null +} + +interface ChipInfo { + element: SgNode + opening: SgNode + isInteractive: boolean + labelStr: string | null + labelRaw: string | null + sizeValue: string | null +} + +function analyzeChipElement(el: SgNode, chipLocalName: string): ChipInfo | null { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + return null + } + + const nameNode = getElementName(opening) + if (nameNode !== chipLocalName) { + return null + } + + return { + element: el, + opening, + isInteractive: isInteractiveChip(opening), + labelStr: getPropStringValue(opening, 'label'), + labelRaw: getPropRawValue(opening, 'label'), + sizeValue: getPropStringValue(opening, 'size'), + } +} + +function buildTagReplacement(info: ChipInfo): string { + const props: string[] = [] + if (info.sizeValue === 'small') { + props.push('size="small"') + } + const propsStr = props.length > 0 ? ` ${props.join(' ')}` : '' + + // Static string label + if (info.labelStr !== null) { + return `${info.labelStr}` + } + + // Dynamic label (JSX expression like {variable}) + if (info.labelRaw !== null) { + return `${info.labelRaw}` + } + + // No label prop — self-closing Tag + return `` +} + +function transformChipElements( + rootNode: SgNode, + chipLocalName: string, + edits: Edit[], +): { needsTagGroup: boolean } { + let needsTagGroup = false + + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + // Collect chip info + const chipInfos: ChipInfo[] = [] + for (const el of jsxElements) { + const info = analyzeChipElement(el, chipLocalName) + if (info) { + chipInfos.push(info) + } + } + + // Group chips by parent for TagGroup detection + const parentGroups = new Map() + for (const info of chipInfos) { + const parent = info.element.parent() + if (!parent) { + continue + } + const parentId = parent.id() + const group = parentGroups.get(parentId) ?? [] + group.push(info) + parentGroups.set(parentId, group) + } + + // Track which elements are part of a group + const groupedElements = new Set() + + for (const [, group] of parentGroups) { + // Only group if 2+ chips are siblings AND all are plain display chips + if (group.length >= 2 && group.every((c) => !c.isInteractive)) { + needsTagGroup = true + const tags = group.map((c) => buildTagReplacement(c)) + const tagGroupContent = tags.join('\n ') + edits.push(group[0]!.element.replace(`\n ${tagGroupContent}\n`)) + migrationMetric.increment({ action: 'tag-group-created', count: `${group.length}` }) + + for (let i = 1; i < group.length; i++) { + edits.push(group[i]!.element.replace('')) + } + for (const c of group) { + groupedElements.add(c.element.id()) + } + } + } + + // Process remaining individual chips + for (const info of chipInfos) { + if (groupedElements.has(info.element.id())) { + continue + } + + if (info.isInteractive) { + edits.push( + info.element.replace( + `{/* TODO(backstage-codemod): verify interactive chip migration manually */}\n${info.element.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'interactive-chip' }) + continue + } + + edits.push(info.element.replace(buildTagReplacement(info))) + migrationMetric.increment({ action: 'chip-migrated' }) + } + + return { needsTagGroup } +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { chipLocalName, importNodesToRemove } = collectChipImports(rootNode) + + if (!chipLocalName) { + return null + } + + // Remove MUI imports + for (const imp of importNodesToRemove) { + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + + // Transform chip elements + const { needsTagGroup } = transformChipElements(rootNode, chipLocalName, edits) + + // Add BUI import + const importNames = ['Tag'] + if (needsTagGroup) { + importNames.push('TagGroup') + } + addBuiImport(rootNode, importNames, edits) + + return edits.length > 0 ? rootNode.commitEdits(edits) : null +} + +export default transform diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/expected.tsx new file mode 100644 index 0000000..9a250f2 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/expected.tsx @@ -0,0 +1,11 @@ + + +const Tags = () => ( + <> + + A + B + + + +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/input.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/input.tsx new file mode 100644 index 0000000..f22d832 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/input.tsx @@ -0,0 +1,8 @@ +import Chip from '@material-ui/core/Chip'; + +const Tags = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/metrics.json new file mode 100644 index 0000000..4eb1d53 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-chip-to-tag": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tag-group-created", + "count": "2" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/expected.tsx new file mode 100644 index 0000000..4772599 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + Status +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/input.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/input.tsx new file mode 100644 index 0000000..be5fca8 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/input.tsx @@ -0,0 +1,5 @@ +import Chip from '@material-ui/core/Chip'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/metrics.json new file mode 100644 index 0000000..7eb48c8 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-chip-to-tag": [ + { + "cardinality": { + "action": "chip-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/expected.tsx new file mode 100644 index 0000000..bd505fc --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/expected.tsx @@ -0,0 +1,6 @@ + + +const MyComponent = () => ( + {/* TODO(backstage-codemod): verify interactive chip migration manually */} + navigate('/page')} /> +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/input.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/input.tsx new file mode 100644 index 0000000..8d9e104 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/input.tsx @@ -0,0 +1,5 @@ +import Chip from '@material-ui/core/Chip'; + +const MyComponent = () => ( + navigate('/page')} /> +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/metrics.json new file mode 100644 index 0000000..d89225b --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-chip-to-tag": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "interactive-chip" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/expected.tsx new file mode 100644 index 0000000..a49712d --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = ({ name }: { name: string }) => ( + {name} +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/input.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/input.tsx new file mode 100644 index 0000000..abf3393 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/input.tsx @@ -0,0 +1,5 @@ +import Chip from '@material-ui/core/Chip'; + +const MyComponent = ({ name }: { name: string }) => ( + +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/metrics.json new file mode 100644 index 0000000..7eb48c8 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-chip-to-tag": [ + { + "cardinality": { + "action": "chip-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/expected.tsx new file mode 100644 index 0000000..115e38f --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/expected.tsx @@ -0,0 +1,6 @@ + + +const MyComponent = () => ( + {/* TODO(backstage-codemod): verify interactive chip migration manually */} + handleDelete()} /> +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/input.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/input.tsx new file mode 100644 index 0000000..15b454c --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/input.tsx @@ -0,0 +1,5 @@ +import Chip from '@material-ui/core/Chip'; + +const MyComponent = () => ( + handleDelete()} /> +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/metrics.json new file mode 100644 index 0000000..d89225b --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-chip-to-tag": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "interactive-chip" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..791e1ee --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + +import { Button, Tag } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + Info + +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..d403957 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/input.tsx @@ -0,0 +1,9 @@ +import Chip from '@material-ui/core/Chip'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..6e13209 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-chip-to-tag": [ + { + "cardinality": { + "action": "chip-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/expected.tsx new file mode 100644 index 0000000..bb3b030 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + Tag +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/input.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/input.tsx new file mode 100644 index 0000000..2470087 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/input.tsx @@ -0,0 +1,5 @@ +import { Chip } from '@material-ui/core'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/metrics.json new file mode 100644 index 0000000..7eb48c8 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-chip-to-tag": [ + { + "cardinality": { + "action": "chip-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..0facfd0 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import { Tag } from '@backstage/ui'; + +const MyComponent = () => ( + Already migrated +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..0facfd0 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/noop-no-import/input.tsx @@ -0,0 +1,5 @@ +import { Tag } from '@backstage/ui'; + +const MyComponent = () => ( + Already migrated +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/expected.tsx new file mode 100644 index 0000000..af8aa49 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + Category +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/input.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/input.tsx new file mode 100644 index 0000000..f42114e --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/input.tsx @@ -0,0 +1,5 @@ +import Chip from '@material-ui/core/Chip'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/metrics.json new file mode 100644 index 0000000..7eb48c8 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-chip-to-tag": [ + { + "cardinality": { + "action": "chip-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tsconfig.json b/codemods/misc/migrate-mui-chip-to-tag/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-chip-to-tag/workflow.yaml b/codemods/misc/migrate-mui-chip-to-tag/workflow.yaml new file mode 100644 index 0000000..125bb48 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace Chip with Tag' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/CHANGELOG.md b/codemods/misc/migrate-mui-dialog-to-bui-dialog/CHANGELOG.md new file mode 100644 index 0000000..5542f2d --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-dialog-to-bui-dialog diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/codemod.yaml b/codemods/misc/migrate-mui-dialog-to-bui-dialog/codemod.yaml new file mode 100644 index 0000000..a726c05 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-dialog-to-bui-dialog' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Dialog shell with BUI Dialog' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'dialog', 'bui', 'dialog'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/package.json b/codemods/misc/migrate-mui-dialog-to-bui-dialog/package.json new file mode 100644 index 0000000..4466011 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-dialog-to-bui-dialog", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Dialog shell with BUI Dialog", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts b/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts new file mode 100644 index 0000000..6d697b2 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts @@ -0,0 +1,458 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-dialog-to-bui-dialog') + +const BUI_SOURCE = '@backstage/ui' + +const COMPONENT_MAP: Record = { + DialogTitle: 'DialogHeader', + DialogContent: 'DialogBody', + DialogActions: 'DialogFooter', +} + +const TODO_PROPS = new Set([ + 'maxWidth', + 'fullWidth', + 'fullScreen', + 'scroll', + 'TransitionComponent', + 'TransitionProps', + 'transitionDuration', + 'PaperComponent', + 'PaperProps', + 'BackdropComponent', + 'BackdropProps', + 'classes', + 'disableEscapeKeyDown', + 'disableBackdropClick', + 'keepMounted', +]) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): string | null { + const clause = imp.find({ rule: { kind: 'import_clause' } }) + if (!clause) { + return null + } + for (const child of clause.children()) { + if (child.is('identifier')) { + return child.text() + } + } + return null +} + +function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (importedNameNode?.text() === targetName) { + const localNameNode = identifiers[1] ?? importedNameNode + return localNameNode.text() + } + } + return null +} + +const MUI_DIALOG_COMPONENTS = ['Dialog', 'DialogTitle', 'DialogContent', 'DialogActions'] + +interface DialogImports { + localNames: Map + importNodesToRemove: SgNode[] +} + +function collectDialogImports(rootNode: SgNode): DialogImports { + const localNames = new Map() + const importNodesToRemove: SgNode[] = [] + + for (const componentName of MUI_DIALOG_COMPONENTS) { + for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { + const name = getDefaultImportName(imp) + if (name) { + localNames.set(name, componentName) + } + importNodesToRemove.push(imp) + } + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + let foundCount = 0 + for (const componentName of MUI_DIALOG_COMPONENTS) { + const localName = getNamedImportLocalName(imp, componentName) + if (localName) { + localNames.set(localName, componentName) + foundCount++ + } + } + if (foundCount > 0) { + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (foundCount >= allSpecifiers.length) { + importNodesToRemove.push(imp) + } + } + } + + return { localNames, importNodesToRemove } +} + +function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): void { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const existing = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + for (const name of names) { + if (!existing.includes(name)) { + existing.push(name) + } + } + existing.sort() + edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + } else { + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const sortedNames = [...names].sort() + if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push( + lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + } + } + migrationMetric.increment({ action: 'import-added' }) + } +} + +function getElementName(opening: SgNode): string | null { + for (const child of opening.children()) { + if (child.is('identifier') || child.is('member_expression')) { + return child.text() + } + } + return null +} + +function getPropAttr(opening: SgNode, propName: string): SgNode | null { + return opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) +} + +function hasProp(opening: SgNode, propName: string): boolean { + return getPropAttr(opening, propName) !== null +} + +function getPropRawValue(opening: SgNode, propName: string): string | null { + const attr = getPropAttr(opening, propName) + if (!attr) { + return null + } + for (const child of attr.children()) { + const kind = child.kind() + if (kind === 'string' || kind === 'jsx_expression') { + return child.text() + } + } + return null +} + +function getSimpleOnCloseHandler(opening: SgNode): string | null { + const attr = getPropAttr(opening, 'onClose') + if (!attr) { + return null + } + const expr = attr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return null + } + const children: SgNode[] = [] + for (const child of expr.children()) { + if (child.kind() !== '{' && child.kind() !== '}') { + children.push(child) + } + } + if (children.length === 1 && children[0]!.is('identifier')) { + return children[0].text() + } + return null +} + +function getJsxChildren(element: SgNode): SgNode[] { + const children: SgNode[] = [] + for (const child of element.children()) { + const kind = child.kind() + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + children.push(child) + } + return children +} + +function getChildContent(element: SgNode): string { + return element + .children() + .filter((child) => child.kind() !== 'jsx_opening_element' && child.kind() !== 'jsx_closing_element') + .map((child) => child.text()) + .join('') +} + +function transformFooterCloseButtons(content: string, closeHandler: string | null): string { + if (!closeHandler) { + return content + } + + const buttonPattern = new RegExp( + `]*)>([\\s\\S]*?)`, + 'g', + ) + + return content.replace(buttonPattern, (_match, attrs, label) => { + migrationMetric.increment({ action: 'footer-close-button-migrated' }) + const extraAttrs = attrs.trim() + const attrStr = extraAttrs.length > 0 ? ` ${extraAttrs}` : '' + return `` + }) +} + +function transformDialogChildren( + dialogElement: SgNode, + localNames: Map, + closeHandler: string | null, +): string { + const children = getJsxChildren(dialogElement) + const parts: string[] = [] + + for (const child of children) { + const kind = child.kind() + + if (kind === 'jsx_text') { + parts.push(child.text()) + continue + } + + if (kind === 'jsx_element') { + const childOpening = child.child(0) + if (childOpening) { + const childName = getElementName(childOpening) + if (childName && localNames.has(childName)) { + const muiName = localNames.get(childName)! + const buiName = COMPONENT_MAP[muiName] + if (buiName) { + let innerContent = getChildContent(child) + if (muiName === 'DialogActions') { + innerContent = transformFooterCloseButtons(innerContent, closeHandler) + } + parts.push(`<${buiName}>${innerContent}`) + migrationMetric.increment({ action: 'child-renamed', from: muiName, to: buiName }) + continue + } + } + } + } + + parts.push(child.text()) + } + + return parts.join('') +} + +function transformDialogElements( + rootNode: SgNode, + localNames: Map, + edits: Edit[], + buiNames: Set, +): void { + const dialogLocalName = [...localNames.entries()].find(([, v]) => v === 'Dialog')?.[0] + if (!dialogLocalName) { + return + } + + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + for (const el of jsxElements) { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + continue + } + + const name = getElementName(opening) + if (name !== dialogLocalName) { + continue + } + + let needsTodo = false + const todoReasons: string[] = [] + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + needsTodo = true + todoReasons.push(prop) + } + } + + if (needsTodo) { + edits.push( + el.replace( + `{/* TODO(backstage-codemod): verify dialog width, dismiss behavior, or custom close logic manually (${todoReasons.join(', ')}) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: todoReasons.join(', ') }) + continue + } + + const newProps: string[] = [] + + const openValue = getPropRawValue(opening, 'open') + if (openValue) { + newProps.push(`isOpen=${openValue}`) + } + + const simpleHandler = getSimpleOnCloseHandler(opening) + let usesFooterCloseButton = false + if (simpleHandler) { + newProps.push(`onOpenChange={isOpen => !isOpen && ${simpleHandler}()}`) + migrationMetric.increment({ action: 'onClose-rewritten' }) + } else if (hasProp(opening, 'onClose')) { + edits.push( + el.replace( + `{/* TODO(backstage-codemod): verify dialog width, dismiss behavior, or custom close logic manually (complex-onClose) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-onClose' }) + continue + } + + const handledProps = new Set(['open', 'onClose']) + const allAttrs = opening.findAll({ rule: { kind: 'jsx_attribute' } }) + for (const attr of allAttrs) { + const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + const propName = propIdent.text() + if (handledProps.has(propName)) { + continue + } + newProps.push(attr.text()) + } + + const spreadAttrs = opening.findAll({ rule: { kind: 'jsx_expression' } }) + for (const spread of spreadAttrs) { + if (spread.text().startsWith('{...')) { + newProps.push(spread.text()) + } + } + + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + + if (isSelfClosing) { + edits.push(el.replace(``)) + } else { + const dialogActionsLocal = [...localNames.entries()].find(([, v]) => v === 'DialogActions')?.[0] + if (simpleHandler && dialogActionsLocal) { + const actionsElements = el.findAll({ + rule: { + kind: 'jsx_element', + has: { + kind: 'jsx_opening_element', + has: { + kind: 'identifier', + regex: `^${escapeRegex(dialogActionsLocal)}$`, + }, + }, + }, + }) + for (const actionsEl of actionsElements) { + const actionsContent = getChildContent(actionsEl) + if (actionsContent.includes(`onClick={${simpleHandler}}`)) { + usesFooterCloseButton = true + break + } + } + } + + const transformedChildren = transformDialogChildren(el, localNames, simpleHandler) + edits.push(el.replace(`${transformedChildren}`)) + + if (usesFooterCloseButton) { + buiNames.add('Button') + } + } + + migrationMetric.increment({ action: 'dialog-migrated' }) + } +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { localNames, importNodesToRemove } = collectDialogImports(rootNode) + + if (localNames.size === 0) { + return null + } + + for (const imp of importNodesToRemove) { + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + + const buiNames = new Set() + buiNames.add('Dialog') + for (const [, muiName] of localNames) { + const buiName = COMPONENT_MAP[muiName] + if (buiName) { + buiNames.add(buiName) + } + } + + transformDialogElements(rootNode, localNames, edits, buiNames) + addBuiImport(rootNode, [...buiNames], edits) + + return edits.length > 0 ? rootNode.commitEdits(edits) : null +} + +export default transform diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/expected.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/expected.tsx new file mode 100644 index 0000000..4ddb407 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/expected.tsx @@ -0,0 +1,11 @@ + + + + +const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + {/* TODO(backstage-codemod): verify dialog width, dismiss behavior, or custom close logic manually (complex-onClose) */} + { cleanup(); onClose(); }}> + Complex + Body + +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/input.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/input.tsx new file mode 100644 index 0000000..7e6e13c --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/input.tsx @@ -0,0 +1,10 @@ +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; + +const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + { cleanup(); onClose(); }}> + Complex + Body + +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/metrics.json b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/metrics.json new file mode 100644 index 0000000..2fa1e44 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-dialog-to-bui-dialog": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "complex-onClose" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/expected.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/expected.tsx new file mode 100644 index 0000000..7df9042 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/expected.tsx @@ -0,0 +1,8 @@ + + + + + +const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + !isOpen && onClose()}>ConfirmAre you sure? +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/input.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/input.tsx new file mode 100644 index 0000000..5109073 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/input.tsx @@ -0,0 +1,14 @@ +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogActions from '@material-ui/core/DialogActions'; + +const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + + Confirm + Are you sure? + + + + +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/metrics.json b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/metrics.json new file mode 100644 index 0000000..c41d48b --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/metrics.json @@ -0,0 +1,58 @@ +{ + "migrate-mui-dialog-to-bui-dialog": [ + { + "cardinality": { + "action": "child-renamed", + "from": "DialogActions", + "to": "DialogFooter" + }, + "count": 1 + }, + { + "cardinality": { + "action": "child-renamed", + "from": "DialogContent", + "to": "DialogBody" + }, + "count": 1 + }, + { + "cardinality": { + "action": "child-renamed", + "from": "DialogTitle", + "to": "DialogHeader" + }, + "count": 1 + }, + { + "cardinality": { + "action": "dialog-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "footer-close-button-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 4 + }, + { + "cardinality": { + "action": "onClose-rewritten" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/expected.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/expected.tsx new file mode 100644 index 0000000..77a8df4 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/expected.tsx @@ -0,0 +1,11 @@ + + + + +const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + {/* TODO(backstage-codemod): verify dialog width, dismiss behavior, or custom close logic manually (maxWidth, fullWidth) */} + + Wide + Content + +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/input.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/input.tsx new file mode 100644 index 0000000..8c5ae79 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/input.tsx @@ -0,0 +1,10 @@ +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; + +const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + + Wide + Content + +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/metrics.json b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/metrics.json new file mode 100644 index 0000000..67b181e --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-dialog-to-bui-dialog": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "maxWidth, fullWidth" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..a09d747 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,8 @@ + + + +import { Button, Dialog, DialogBody, DialogHeader } from '@backstage/ui'; + +const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + !isOpen && onClose()}>Action +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..6670583 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/input.tsx @@ -0,0 +1,11 @@ +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; +import { Button } from '@backstage/ui'; + +const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + + Action + + +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..049a717 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/metrics.json @@ -0,0 +1,44 @@ +{ + "migrate-mui-dialog-to-bui-dialog": [ + { + "cardinality": { + "action": "child-renamed", + "from": "DialogContent", + "to": "DialogBody" + }, + "count": 1 + }, + { + "cardinality": { + "action": "child-renamed", + "from": "DialogTitle", + "to": "DialogHeader" + }, + "count": 1 + }, + { + "cardinality": { + "action": "dialog-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "onClose-rewritten" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..6899af7 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/expected.tsx @@ -0,0 +1,5 @@ + + +const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + !isOpen && onClose()}>InfoDetails here +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..193d2cf --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/input.tsx @@ -0,0 +1,8 @@ +import { Dialog, DialogTitle, DialogContent } from '@material-ui/core'; + +const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + + Info + Details here + +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..de9d374 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/metrics.json @@ -0,0 +1,44 @@ +{ + "migrate-mui-dialog-to-bui-dialog": [ + { + "cardinality": { + "action": "child-renamed", + "from": "DialogContent", + "to": "DialogBody" + }, + "count": 1 + }, + { + "cardinality": { + "action": "child-renamed", + "from": "DialogTitle", + "to": "DialogHeader" + }, + "count": 1 + }, + { + "cardinality": { + "action": "dialog-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onClose-rewritten" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..3092c4f --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/noop-no-import/expected.tsx @@ -0,0 +1,7 @@ +import { Dialog, DialogHeader } from '@backstage/ui'; + +const MyDialog = () => ( + + Already migrated + +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..3092c4f --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/noop-no-import/input.tsx @@ -0,0 +1,7 @@ +import { Dialog, DialogHeader } from '@backstage/ui'; + +const MyDialog = () => ( + + Already migrated + +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tsconfig.json b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/workflow.yaml b/codemods/misc/migrate-mui-dialog-to-bui-dialog/workflow.yaml new file mode 100644 index 0000000..4c7ce3e --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace Dialog shell with BUI Dialog' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/CHANGELOG.md b/codemods/misc/migrate-mui-list-family-to-bui-list/CHANGELOG.md new file mode 100644 index 0000000..725959b --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-list-family-to-bui-list diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/codemod.yaml b/codemods/misc/migrate-mui-list-family-to-bui-list/codemod.yaml new file mode 100644 index 0000000..ab002e6 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-list-family-to-bui-list' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace List family primitives with BUI List' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'list', 'family', 'bui', 'list'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/package.json b/codemods/misc/migrate-mui-list-family-to-bui-list/package.json new file mode 100644 index 0000000..0540b6f --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-list-family-to-bui-list", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace List family primitives with BUI List", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/scripts/codemod.ts b/codemods/misc/migrate-mui-list-family-to-bui-list/scripts/codemod.ts new file mode 100644 index 0000000..c06ee98 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/scripts/codemod.ts @@ -0,0 +1,492 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-list-family-to-bui-list') + +const BUI_SOURCE = '@backstage/ui' + +const MUI_LIST_COMPONENTS = [ + 'List', + 'ListItem', + 'ListItemIcon', + 'ListItemText', + 'ListItemAvatar', + 'ListItemSecondaryAction', + 'ListSubheader', +] + +/** Props on ListItem that indicate complexity beyond a simple row. */ +const TODO_PROPS = new Set([ + 'button', + 'selected', + 'dense', + 'disableGutters', + 'divider', + 'alignItems', + 'ContainerComponent', + 'ContainerProps', + 'component', + 'classes', +]) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): string | null { + const clause = imp.find({ rule: { kind: 'import_clause' } }) + if (!clause) { + return null + } + for (const child of clause.children()) { + if (child.is('identifier')) { + return child.text() + } + } + return null +} + +function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (importedNameNode?.text() === targetName) { + const localNameNode = identifiers[1] ?? importedNameNode + return localNameNode.text() + } + } + return null +} + +interface ListImports { + localNames: Map + importNodesToRemove: SgNode[] +} + +function collectListImports(rootNode: SgNode): ListImports { + const localNames = new Map() + const importNodesToRemove: SgNode[] = [] + + for (const componentName of MUI_LIST_COMPONENTS) { + for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { + const name = getDefaultImportName(imp) + if (name) { + localNames.set(name, componentName) + } + importNodesToRemove.push(imp) + } + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + let foundCount = 0 + for (const componentName of MUI_LIST_COMPONENTS) { + const localName = getNamedImportLocalName(imp, componentName) + if (localName) { + localNames.set(localName, componentName) + foundCount++ + } + } + if (foundCount > 0) { + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (foundCount >= allSpecifiers.length) { + importNodesToRemove.push(imp) + } + } + } + + return { localNames, importNodesToRemove } +} + +function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): void { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const existing = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + for (const name of names) { + if (!existing.includes(name)) { + existing.push(name) + } + } + existing.sort() + edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + } else { + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const sortedNames = [...names].sort() + if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push( + lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + } + } + migrationMetric.increment({ action: 'import-added' }) + } +} + +function getElementName(opening: SgNode): string | null { + for (const child of opening.children()) { + if (child.is('identifier') || child.is('member_expression')) { + return child.text() + } + } + return null +} + +function getPropAttr(opening: SgNode, propName: string): SgNode | null { + return opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) +} + +function hasProp(opening: SgNode, propName: string): boolean { + return getPropAttr(opening, propName) !== null +} + +function getPropStringValue(opening: SgNode, propName: string): string | null { + const attr = getPropAttr(opening, propName) + if (!attr) { + return null + } + const stringNode = attr.find({ rule: { kind: 'string' } }) + if (stringNode) { + const frag = stringNode.find({ rule: { kind: 'string_fragment' } }) + return frag?.text() ?? null + } + return null +} + +function getPropRawValue(opening: SgNode, propName: string): string | null { + const attr = getPropAttr(opening, propName) + if (!attr) { + return null + } + for (const child of attr.children()) { + const kind = child.kind() + if (kind === 'string' || kind === 'jsx_expression') { + return child.text() + } + } + return null +} + +function getChildContent(element: SgNode): string { + return element + .children() + .filter((child) => child.kind() !== 'jsx_opening_element' && child.kind() !== 'jsx_closing_element') + .map((child) => child.text()) + .join('') +} + +function getNonWhitespaceChildren(element: SgNode): SgNode[] { + const children: SgNode[] = [] + for (const child of element.children()) { + const kind = child.kind() + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + if (kind === 'jsx_text' && child.text().trim().length === 0) { + continue + } + children.push(child) + } + return children +} + +interface ListItemAnalysis { + iconContent: string | null + primaryText: string | null + primaryRaw: string | null + secondaryText: string | null + secondaryRaw: string | null + hasComplexContent: boolean + hasTodoProps: boolean +} + +function analyzeListItem(el: SgNode, localNames: Map): ListItemAnalysis { + const result: ListItemAnalysis = { + iconContent: null, + primaryText: null, + primaryRaw: null, + secondaryText: null, + secondaryRaw: null, + hasComplexContent: false, + hasTodoProps: false, + } + + const opening = el.child(0) + if (!opening) { + return result + } + + // Check for TODO-triggering props on ListItem + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + result.hasTodoProps = true + return result + } + } + + // Check for onClick — interactive list items need TODO + if (hasProp(opening, 'onClick')) { + result.hasTodoProps = true + return result + } + + const children = getNonWhitespaceChildren(el) + + const listItemIconLocal = [...localNames.entries()].find(([, v]) => v === 'ListItemIcon')?.[0] ?? null + const listItemTextLocal = [...localNames.entries()].find(([, v]) => v === 'ListItemText')?.[0] ?? null + const listItemAvatarLocal = [...localNames.entries()].find(([, v]) => v === 'ListItemAvatar')?.[0] ?? null + const listItemSecondaryActionLocal = + [...localNames.entries()].find(([, v]) => v === 'ListItemSecondaryAction')?.[0] ?? null + + for (const child of children) { + const kind = child.kind() + + if (kind === 'jsx_text') { + const trimmed = child.text().trim() + if (trimmed.length > 0) { + result.hasComplexContent = true + } + continue + } + + if (kind === 'jsx_element' || kind === 'jsx_self_closing_element') { + const childOpening = kind === 'jsx_self_closing_element' ? child : child.child(0) + if (!childOpening) { + result.hasComplexContent = true + continue + } + + const childName = getElementName(childOpening) + + // ListItemIcon + if (childName && childName === listItemIconLocal) { + if (kind === 'jsx_element') { + const iconChildren = getNonWhitespaceChildren(child) + if (iconChildren.length === 1) { + result.iconContent = iconChildren[0]!.text() + } else { + result.hasComplexContent = true + } + } + continue + } + + // ListItemAvatar — complex, TODO + if (childName && childName === listItemAvatarLocal) { + result.hasComplexContent = true + continue + } + + // ListItemSecondaryAction — complex, TODO + if (childName && childName === listItemSecondaryActionLocal) { + result.hasComplexContent = true + continue + } + + // ListItemText + if (childName && childName === listItemTextLocal) { + const primaryStr = getPropStringValue(childOpening, 'primary') + const primaryRaw = getPropRawValue(childOpening, 'primary') + const secondaryStr = getPropStringValue(childOpening, 'secondary') + const secondaryRaw = getPropRawValue(childOpening, 'secondary') + + if (primaryStr !== null) { + result.primaryText = primaryStr + } else if (primaryRaw !== null) { + result.primaryRaw = primaryRaw + } + + if (secondaryStr !== null) { + result.secondaryText = secondaryStr + } else if (secondaryRaw !== null) { + result.secondaryRaw = secondaryRaw + } + + // If ListItemText has children instead of primary prop, it's complex + if (primaryStr === null && primaryRaw === null && kind === 'jsx_element') { + const textChildren = getNonWhitespaceChildren(child) + if (textChildren.length > 0) { + result.hasComplexContent = true + } + } + continue + } + + // Unknown child element — mark complex + result.hasComplexContent = true + continue + } + + if (kind === 'jsx_expression') { + result.hasComplexContent = true + continue + } + } + + return result +} + +function transformListElements(rootNode: SgNode, localNames: Map, edits: Edit[]): void { + const listLocalName = [...localNames.entries()].find(([, v]) => v === 'List')?.[0] ?? null + const listItemLocalName = [...localNames.entries()].find(([, v]) => v === 'ListItem')?.[0] ?? null + + if (!listLocalName && !listItemLocalName) { + return + } + + const jsxElements = rootNode + .findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + .sort((a, b) => { + const rangeA = a.range() + const rangeB = b.range() + return rangeA.end.index - rangeA.start.index - (rangeB.end.index - rangeB.start.index) + }) + + for (const el of jsxElements) { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + continue + } + + const name = getElementName(opening) + if (!name) { + continue + } + + const muiName = localNames.get(name) + + // Transform ListItem → ListRow (BUI also uses — leave List wrapper unchanged) + if (muiName === 'ListItem') { + if (isSelfClosing) { + edits.push(el.replace('')) + migrationMetric.increment({ action: 'list-item-migrated' }) + continue + } + + const analysis = analyzeListItem(el, localNames) + + if (analysis.hasTodoProps || analysis.hasComplexContent) { + edits.push(el.replace(`{/* TODO(backstage-codemod): verify nonstandard list row manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-list-item' }) + continue + } + + // Build ListRow props + const props: string[] = [] + + if (analysis.iconContent) { + props.push(`icon={${analysis.iconContent}}`) + } + + if (analysis.secondaryText !== null) { + props.push(`description="${analysis.secondaryText}"`) + } else if (analysis.secondaryRaw !== null) { + props.push(`description=${analysis.secondaryRaw}`) + } + + const propsStr = props.length > 0 ? ` ${props.join(' ')}` : '' + + // Primary text becomes children + let children = '' + if (analysis.primaryText !== null) { + children = analysis.primaryText + } else if (analysis.primaryRaw !== null) { + children = analysis.primaryRaw + } + + if (children) { + edits.push(el.replace(`${children}`)) + } else { + edits.push(el.replace(``)) + } + + migrationMetric.increment({ action: 'list-item-migrated' }) + continue + } + } +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { localNames, importNodesToRemove } = collectListImports(rootNode) + + if (localNames.size === 0) { + return null + } + + // Remove MUI imports + for (const imp of importNodesToRemove) { + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + + // Determine BUI names needed + const buiNames = new Set() + for (const [, muiName] of localNames) { + if (muiName === 'List' || muiName === 'ListSubheader') { + buiNames.add('List') + } + if ( + muiName === 'ListItem' || + muiName === 'ListItemIcon' || + muiName === 'ListItemText' || + muiName === 'ListItemAvatar' || + muiName === 'ListItemSecondaryAction' + ) { + buiNames.add('ListRow') + } + } + + addBuiImport(rootNode, [...buiNames], edits) + + // Transform elements + transformListElements(rootNode, localNames, edits) + + return edits.length > 0 ? rootNode.commitEdits(edits) : null +} + +export default transform diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/expected.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/expected.tsx new file mode 100644 index 0000000..7aceef8 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/expected.tsx @@ -0,0 +1,12 @@ + + + + +const MyComponent = () => ( + + {/* TODO(backstage-codemod): verify nonstandard list row manually */} + + + + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/input.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/input.tsx new file mode 100644 index 0000000..29939a3 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/input.tsx @@ -0,0 +1,11 @@ +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemText from '@material-ui/core/ListItemText'; + +const MyComponent = () => ( + + + + + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/metrics.json b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/metrics.json new file mode 100644 index 0000000..f924559 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-list-family-to-bui-list": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "complex-list-item" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..07d9b54 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,13 @@ + + + +import { Button, List, ListRow } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + Item + + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..e535feb --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/input.tsx @@ -0,0 +1,15 @@ +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemText from '@material-ui/core/ListItemText'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + + + + + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..8dfc4e8 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-list-family-to-bui-list": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "list-item-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..45a8d3c --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/expected.tsx @@ -0,0 +1,7 @@ + + +const MyComponent = () => ( + + } description="Your favorites">Starred + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..13b6ad8 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/input.tsx @@ -0,0 +1,10 @@ +import { List, ListItem, ListItemIcon, ListItemText } from '@material-ui/core'; + +const MyComponent = () => ( + + + + + + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..9124d8b --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-list-family-to-bui-list": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "list-item-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..cf6e9fd --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/noop-no-import/expected.tsx @@ -0,0 +1,7 @@ +import { List, ListRow } from '@backstage/ui'; + +const MyComponent = () => ( + + Already migrated + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..cf6e9fd --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/noop-no-import/input.tsx @@ -0,0 +1,7 @@ +import { List, ListRow } from '@backstage/ui'; + +const MyComponent = () => ( + + Already migrated + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/expected.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/expected.tsx new file mode 100644 index 0000000..feafd59 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/expected.tsx @@ -0,0 +1,10 @@ + + + + + +const MyComponent = () => ( + + } description="Read the docs">Docs + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/input.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/input.tsx new file mode 100644 index 0000000..60c2df2 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/input.tsx @@ -0,0 +1,13 @@ +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; + +const MyComponent = () => ( + + + + + + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/metrics.json b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/metrics.json new file mode 100644 index 0000000..39ee381 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-list-family-to-bui-list": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 4 + }, + { + "cardinality": { + "action": "list-item-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/expected.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/expected.tsx new file mode 100644 index 0000000..1feaf51 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/expected.tsx @@ -0,0 +1,9 @@ + + + + +const MyComponent = () => ( + + Settings + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/input.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/input.tsx new file mode 100644 index 0000000..60f213f --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/input.tsx @@ -0,0 +1,11 @@ +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemText from '@material-ui/core/ListItemText'; + +const MyComponent = () => ( + + + + + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/metrics.json b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/metrics.json new file mode 100644 index 0000000..41f3640 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-list-family-to-bui-list": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "list-item-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tsconfig.json b/codemods/misc/migrate-mui-list-family-to-bui-list/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/workflow.yaml b/codemods/misc/migrate-mui-list-family-to-bui-list/workflow.yaml new file mode 100644 index 0000000..0ee2211 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace List family primitives with BUI List' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/CHANGELOG.md b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/CHANGELOG.md new file mode 100644 index 0000000..a1a7eb4 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-menu-popover-to-bui-menu diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/codemod.yaml b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/codemod.yaml new file mode 100644 index 0000000..fefb020 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-menu-popover-to-bui-menu' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Menu and Popover patterns with BUI Menu' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'menu', 'popover', 'bui', 'menu'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/package.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/package.json new file mode 100644 index 0000000..775cb2c --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-menu-popover-to-bui-menu", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Menu and Popover patterns with BUI Menu", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts new file mode 100644 index 0000000..7c8f9aa --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts @@ -0,0 +1,504 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-menu-popover-to-bui-menu') + +const BUI_SOURCE = '@backstage/ui' + +const MUI_MENU_COMPONENTS = ['Menu', 'MenuItem', 'MenuList', 'Popover'] + +/** Props on Menu/Popover that trigger a TODO — not mechanically migratable. */ +const TODO_PROPS = new Set([ + 'anchorOrigin', + 'transformOrigin', + 'getContentAnchorEl', + 'TransitionComponent', + 'TransitionProps', + 'transitionDuration', + 'PaperProps', + 'PopoverClasses', + 'classes', + 'disableAutoFocusItem', + 'MenuListProps', + 'elevation', + 'marginThreshold', + 'container', + 'disablePortal', + 'disableScrollLock', +]) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): string | null { + const clause = imp.find({ rule: { kind: 'import_clause' } }) + if (!clause) { + return null + } + for (const child of clause.children()) { + if (child.is('identifier')) { + return child.text() + } + } + return null +} + +function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (importedNameNode?.text() === targetName) { + const localNameNode = identifiers[1] ?? importedNameNode + return localNameNode.text() + } + } + return null +} + +interface MenuImports { + localNames: Map + importNodesToRemove: SgNode[] +} + +function collectMenuImports(rootNode: SgNode): MenuImports { + const localNames = new Map() + const importNodesToRemove: SgNode[] = [] + + for (const componentName of MUI_MENU_COMPONENTS) { + for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { + const name = getDefaultImportName(imp) + if (name) { + localNames.set(name, componentName) + } + importNodesToRemove.push(imp) + } + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + let foundCount = 0 + for (const componentName of MUI_MENU_COMPONENTS) { + const localName = getNamedImportLocalName(imp, componentName) + if (localName) { + localNames.set(localName, componentName) + foundCount++ + } + } + if (foundCount > 0) { + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (foundCount >= allSpecifiers.length) { + importNodesToRemove.push(imp) + } + } + } + + return { localNames, importNodesToRemove } +} + +function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): void { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const existing = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + for (const name of names) { + if (!existing.includes(name)) { + existing.push(name) + } + } + existing.sort() + edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + } else { + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const sortedNames = [...names].sort() + if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push( + lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + } + } + migrationMetric.increment({ action: 'import-added' }) + } +} + +function getElementName(opening: SgNode): string | null { + for (const child of opening.children()) { + if (child.is('identifier') || child.is('member_expression')) { + return child.text() + } + } + return null +} + +function getPropAttr(opening: SgNode, propName: string): SgNode | null { + return opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) +} + +function hasProp(opening: SgNode, propName: string): boolean { + return getPropAttr(opening, propName) !== null +} + +function getPropRawValue(opening: SgNode, propName: string): string | null { + const attr = getPropAttr(opening, propName) + if (!attr) { + return null + } + for (const child of attr.children()) { + const kind = child.kind() + if (kind === 'string' || kind === 'jsx_expression') { + return child.text() + } + } + return null +} + +function getSimpleOnCloseHandler(opening: SgNode): string | null { + const attr = getPropAttr(opening, 'onClose') + if (!attr) { + return null + } + const expr = attr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return null + } + const children: SgNode[] = [] + for (const child of expr.children()) { + if (child.kind() !== '{' && child.kind() !== '}') { + children.push(child) + } + } + if (children.length === 1 && children[0]!.is('identifier')) { + return children[0].text() + } + return null +} + +function getChildContent(element: SgNode): string { + return element + .children() + .filter((child) => child.kind() !== 'jsx_opening_element' && child.kind() !== 'jsx_closing_element') + .map((child) => child.text()) + .join('') +} + +function getJsxChildren(element: SgNode): SgNode[] { + const children: SgNode[] = [] + for (const child of element.children()) { + const kind = child.kind() + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + children.push(child) + } + return children +} + +function getNonWhitespaceChildren(element: SgNode): SgNode[] { + const children: SgNode[] = [] + for (const child of element.children()) { + const kind = child.kind() + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + if (kind === 'jsx_text' && child.text().trim().length === 0) { + continue + } + children.push(child) + } + return children +} + +/** + * Transform MenuItem children: map onClick → onAction, preserve text content. + */ +function transformMenuItemChildren(element: SgNode, menuItemLocalName: string): string { + const children = getJsxChildren(element) + const parts: string[] = [] + + for (const child of children) { + const kind = child.kind() + + if (kind === 'jsx_text') { + parts.push(child.text()) + continue + } + + if (kind === 'jsx_element' || kind === 'jsx_self_closing_element') { + const childOpening = kind === 'jsx_self_closing_element' ? child : child.child(0) + if (!childOpening) { + parts.push(child.text()) + continue + } + + const childName = getElementName(childOpening) + if (childName === menuItemLocalName) { + const onClickAttr = getPropAttr(childOpening, 'onClick') + + const newProps: string[] = [] + + // Map onClick → onAction + if (onClickAttr) { + for (const attrChild of onClickAttr.children()) { + const attrKind = attrChild.kind() + if (attrKind === 'string' || attrKind === 'jsx_expression') { + newProps.push(`onAction=${attrChild.text()}`) + break + } + } + migrationMetric.increment({ action: 'onClick-to-onAction' }) + } + + // Preserve other safe props + const handledProps = new Set(['onClick']) + const allAttrs = childOpening.findAll({ rule: { kind: 'jsx_attribute' } }) + for (const attr of allAttrs) { + const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + if (handledProps.has(propIdent.text())) { + continue + } + newProps.push(attr.text()) + } + + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + + if (kind === 'jsx_self_closing_element') { + parts.push(``) + } else { + const innerContent = getChildContent(child) + parts.push(`${innerContent}`) + } + migrationMetric.increment({ action: 'menu-item-migrated' }) + continue + } + } + + // Preserve anything else as-is + parts.push(child.text()) + } + + return parts.join('') +} + +/** + * Unwrap MenuList if it's the only structural child inside a Popover/Menu. + * Returns the inner content of the MenuList, or the original content if no MenuList wrapper. + */ +function unwrapMenuList( + element: SgNode, + menuListLocalName: string | null, + menuItemLocalName: string | null, +): string { + if (!menuListLocalName) { + if (menuItemLocalName) { + return transformMenuItemChildren(element, menuItemLocalName) + } + return getChildContent(element) + } + + const meaningfulChildren = getNonWhitespaceChildren(element) + + // If the only meaningful child is a MenuList, unwrap it + if (meaningfulChildren.length === 1) { + const onlyChild = meaningfulChildren[0]! + if (onlyChild.kind() === 'jsx_element') { + const childOpening = onlyChild.child(0) + if (childOpening) { + const childName = getElementName(childOpening) + if (childName === menuListLocalName) { + migrationMetric.increment({ action: 'menu-list-unwrapped' }) + if (menuItemLocalName) { + return transformMenuItemChildren(onlyChild, menuItemLocalName) + } + return getChildContent(onlyChild) + } + } + } + } + + // No unwrapping needed — transform menu items directly + if (menuItemLocalName) { + return transformMenuItemChildren(element, menuItemLocalName) + } + return getChildContent(element) +} + +function transformMenuElements(rootNode: SgNode, localNames: Map, edits: Edit[]): void { + const menuListLocalName = [...localNames.entries()].find(([, v]) => v === 'MenuList')?.[0] ?? null + const menuItemLocalName = [...localNames.entries()].find(([, v]) => v === 'MenuItem')?.[0] ?? null + + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + for (const el of jsxElements) { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + continue + } + + const name = getElementName(opening) + if (!name) { + continue + } + + const muiName = localNames.get(name) + if (!muiName) { + continue + } + + // Only process top-level Menu / Popover elements (skip MenuItem — handled inline) + if (muiName !== 'Menu' && muiName !== 'Popover') { + continue + } + + // Check for TODO-triggering props + let needsTodo = false + const todoReasons: string[] = [] + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + needsTodo = true + todoReasons.push(prop) + } + } + + // anchorEl implies positional control — always TODO + if (hasProp(opening, 'anchorEl')) { + needsTodo = true + if (!todoReasons.includes('anchorEl')) { + todoReasons.push('anchorEl') + } + } + + if (needsTodo) { + edits.push( + el.replace( + `{/* TODO(backstage-codemod): finish menu host migration manually (${todoReasons.join(', ')}) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: todoReasons.join(', ') }) + continue + } + + if (isSelfClosing) { + edits.push(el.replace('')) + migrationMetric.increment({ action: 'menu-migrated', variant: 'self-closing' }) + continue + } + + // Transform children: unwrap MenuList, convert MenuItems + const innerContent = unwrapMenuList(el, menuListLocalName, menuItemLocalName) + let menuOutput = `${innerContent}` + + const hasControlledState = hasProp(opening, 'open') || hasProp(opening, 'onClose') + if (hasControlledState) { + const triggerProps: string[] = [] + const openValue = getPropRawValue(opening, 'open') + if (openValue) { + triggerProps.push(`isOpen=${openValue}`) + } + + const simpleHandler = getSimpleOnCloseHandler(opening) + if (hasProp(opening, 'onClose')) { + if (simpleHandler) { + triggerProps.push(`onOpenChange={isOpen => !isOpen && ${simpleHandler}()}`) + migrationMetric.increment({ action: 'onClose-rewritten' }) + } else { + edits.push( + el.replace( + `{/* TODO(backstage-codemod): finish menu host migration manually (complex-onClose) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-onClose' }) + continue + } + } + + const triggerPropsStr = triggerProps.length > 0 ? ` ${triggerProps.join(' ')}` : '' + menuOutput = `${menuOutput}` + migrationMetric.increment({ action: 'menu-trigger-wrapped' }) + } + + edits.push(el.replace(menuOutput)) + migrationMetric.increment({ action: 'menu-migrated', variant: muiName === 'Popover' ? 'popover' : 'menu' }) + } +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { localNames, importNodesToRemove } = collectMenuImports(rootNode) + + if (localNames.size === 0) { + return null + } + + // Remove MUI imports + for (const imp of importNodesToRemove) { + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + + // Determine which BUI names we need + const buiNames = new Set() + for (const [, muiName] of localNames) { + if (muiName === 'Menu' || muiName === 'Popover' || muiName === 'MenuList') { + buiNames.add('Menu') + buiNames.add('MenuTrigger') + } + if (muiName === 'MenuItem') { + buiNames.add('MenuItem') + } + } + addBuiImport(rootNode, [...buiNames], edits) + + // Transform elements + transformMenuElements(rootNode, localNames, edits) + + return edits.length > 0 ? rootNode.commitEdits(edits) : null +} + +export default transform diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/expected.tsx new file mode 100644 index 0000000..48c74cb --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/expected.tsx @@ -0,0 +1,9 @@ + + + +const MyComponent = ({ anchorEl, open, onClose }: any) => ( + {/* TODO(backstage-codemod): finish menu host migration manually (anchorEl) */} + + Action + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/input.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/input.tsx new file mode 100644 index 0000000..9aa630e --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/input.tsx @@ -0,0 +1,8 @@ +import Menu from '@material-ui/core/Menu'; +import MenuItem from '@material-ui/core/MenuItem'; + +const MyComponent = ({ anchorEl, open, onClose }: any) => ( + + Action + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/metrics.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/metrics.json new file mode 100644 index 0000000..7756164 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-menu-popover-to-bui-menu": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 2 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "anchorEl" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..7a05629 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,10 @@ + + +import { Button, Menu, MenuItem, MenuTrigger } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + Action + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..e90a8f2 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/input.tsx @@ -0,0 +1,12 @@ +import Menu from '@material-ui/core/Menu'; +import MenuItem from '@material-ui/core/MenuItem'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + Action + + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..1e3c553 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/metrics.json @@ -0,0 +1,41 @@ +{ + "migrate-mui-menu-popover-to-bui-menu": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 2 + }, + { + "cardinality": { + "action": "menu-item-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "menu-migrated", + "variant": "menu" + }, + "count": 1 + }, + { + "cardinality": { + "action": "menu-trigger-wrapped" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onClick-to-onAction" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..7120124 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + Do stuff +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..b1f447b --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/input.tsx @@ -0,0 +1,7 @@ +import { Menu, MenuItem } from '@material-ui/core'; + +const MyComponent = () => ( + + Do stuff + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..4051dfb --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/metrics.json @@ -0,0 +1,41 @@ +{ + "migrate-mui-menu-popover-to-bui-menu": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "menu-item-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "menu-migrated", + "variant": "menu" + }, + "count": 1 + }, + { + "cardinality": { + "action": "menu-trigger-wrapped" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onClick-to-onAction" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..0a6943a --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/noop-no-import/expected.tsx @@ -0,0 +1,7 @@ +import { Menu, MenuItem } from '@backstage/ui'; + +const MyComponent = () => ( + + Action + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..0a6943a --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/noop-no-import/input.tsx @@ -0,0 +1,7 @@ +import { Menu, MenuItem } from '@backstage/ui'; + +const MyComponent = () => ( + + Action + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/expected.tsx new file mode 100644 index 0000000..9a6c514 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/expected.tsx @@ -0,0 +1,7 @@ + + + + +const MyComponent = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + !isOpen && onClose()}>Action +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/input.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/input.tsx new file mode 100644 index 0000000..f15fd9a --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/input.tsx @@ -0,0 +1,11 @@ +import Popover from '@material-ui/core/Popover'; +import MenuList from '@material-ui/core/MenuList'; +import MenuItem from '@material-ui/core/MenuItem'; + +const MyComponent = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + + + Action + + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/metrics.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/metrics.json new file mode 100644 index 0000000..5555437 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/metrics.json @@ -0,0 +1,53 @@ +{ + "migrate-mui-menu-popover-to-bui-menu": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "menu-item-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "menu-list-unwrapped" + }, + "count": 1 + }, + { + "cardinality": { + "action": "menu-migrated", + "variant": "popover" + }, + "count": 1 + }, + { + "cardinality": { + "action": "menu-trigger-wrapped" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onClick-to-onAction" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onClose-rewritten" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/expected.tsx new file mode 100644 index 0000000..8787d62 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/expected.tsx @@ -0,0 +1,6 @@ + + + +const MyComponent = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + !isOpen && onClose()}>EditDelete +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/input.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/input.tsx new file mode 100644 index 0000000..853eb36 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/input.tsx @@ -0,0 +1,9 @@ +import Menu from '@material-ui/core/Menu'; +import MenuItem from '@material-ui/core/MenuItem'; + +const MyComponent = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + + Edit + Delete + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/metrics.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/metrics.json new file mode 100644 index 0000000..5e60e55 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/metrics.json @@ -0,0 +1,47 @@ +{ + "migrate-mui-menu-popover-to-bui-menu": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 2 + }, + { + "cardinality": { + "action": "menu-item-migrated" + }, + "count": 2 + }, + { + "cardinality": { + "action": "menu-migrated", + "variant": "menu" + }, + "count": 1 + }, + { + "cardinality": { + "action": "menu-trigger-wrapped" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onClick-to-onAction" + }, + "count": 2 + }, + { + "cardinality": { + "action": "onClose-rewritten" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tsconfig.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/workflow.yaml b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/workflow.yaml new file mode 100644 index 0000000..f5ae9d5 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace Menu and Popover patterns with BUI Menu' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/CHANGELOG.md b/codemods/misc/migrate-mui-tabs-to-bui-tabs/CHANGELOG.md new file mode 100644 index 0000000..e116713 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-tabs-to-bui-tabs diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/codemod.yaml b/codemods/misc/migrate-mui-tabs-to-bui-tabs/codemod.yaml new file mode 100644 index 0000000..6e43625 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-tabs-to-bui-tabs' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace MUI Tabs with BUI Tabs' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'tabs', 'bui', 'tabs'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/package.json b/codemods/misc/migrate-mui-tabs-to-bui-tabs/package.json new file mode 100644 index 0000000..2c6c07d --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-tabs-to-bui-tabs", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace MUI Tabs with BUI Tabs", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts b/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts new file mode 100644 index 0000000..70be0a8 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts @@ -0,0 +1,674 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-tabs-to-bui-tabs') + +const BUI_SOURCE = '@backstage/ui' + +const TODO_PROPS = new Set([ + 'orientation', + 'variant', + 'scrollButtons', + 'centered', + 'indicatorColor', + 'textColor', + 'classes', + 'TabIndicatorProps', + 'TabScrollButtonProps', +]) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): string | null { + const clause = imp.find({ rule: { kind: 'import_clause' } }) + if (!clause) { + return null + } + for (const child of clause.children()) { + if (child.is('identifier')) { + return child.text() + } + } + return null +} + +function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (importedNameNode?.text() === targetName) { + const localNameNode = identifiers[1] ?? importedNameNode + return localNameNode.text() + } + } + return null +} + +interface TabImports { + localNames: Map + importNodesToRemove: SgNode[] +} + +function collectTabImports(rootNode: SgNode): TabImports { + const localNames = new Map() + const importNodesToRemove: SgNode[] = [] + + const corePaths = ['Tabs', 'Tab'] + for (const componentName of corePaths) { + for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { + const name = getDefaultImportName(imp) + if (name) { + localNames.set(name, componentName) + } + importNodesToRemove.push(imp) + } + } + + const labPaths = ['TabContext', 'TabList', 'TabPanel'] + for (const componentName of labPaths) { + for (const imp of findImportStatementsFrom(rootNode, `@material-ui/lab/${componentName}`)) { + const name = getDefaultImportName(imp) + if (name) { + localNames.set(name, componentName) + } + importNodesToRemove.push(imp) + } + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + let foundCount = 0 + for (const componentName of corePaths) { + const localName = getNamedImportLocalName(imp, componentName) + if (localName) { + localNames.set(localName, componentName) + foundCount++ + } + } + if (foundCount > 0) { + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (foundCount >= allSpecifiers.length) { + importNodesToRemove.push(imp) + } + } + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/lab')) { + let foundCount = 0 + for (const componentName of labPaths) { + const localName = getNamedImportLocalName(imp, componentName) + if (localName) { + localNames.set(localName, componentName) + foundCount++ + } + } + if (foundCount > 0) { + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (foundCount >= allSpecifiers.length) { + importNodesToRemove.push(imp) + } + } + } + + return { localNames, importNodesToRemove } +} + +function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): void { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const existing = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + for (const name of names) { + if (!existing.includes(name)) { + existing.push(name) + } + } + existing.sort() + edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + } else { + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const sortedNames = [...names].sort() + if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push( + lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + } + } + migrationMetric.increment({ action: 'import-added' }) + } +} + +function getElementName(opening: SgNode): string | null { + for (const child of opening.children()) { + if (child.is('identifier') || child.is('member_expression')) { + return child.text() + } + } + return null +} + +function getPropAttr(opening: SgNode, propName: string): SgNode | null { + return opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) +} + +function hasProp(opening: SgNode, propName: string): boolean { + return getPropAttr(opening, propName) !== null +} + +function getPropStringValue(opening: SgNode, propName: string): string | null { + const attr = getPropAttr(opening, propName) + if (!attr) { + return null + } + const stringNode = attr.find({ rule: { kind: 'string' } }) + if (stringNode) { + const frag = stringNode.find({ rule: { kind: 'string_fragment' } }) + return frag?.text() ?? null + } + return null +} + +function getPropRawValue(opening: SgNode, propName: string): string | null { + const attr = getPropAttr(opening, propName) + if (!attr) { + return null + } + for (const child of attr.children()) { + const kind = child.kind() + if (kind === 'string' || kind === 'jsx_expression') { + return child.text() + } + } + return null +} + +function isPropDynamic(opening: SgNode, propName: string): boolean { + const attr = getPropAttr(opening, propName) + if (!attr) { + return false + } + return attr.find({ rule: { kind: 'jsx_expression' } }) !== null +} + +function getChildContent(element: SgNode): string { + return element + .children() + .filter((child) => child.kind() !== 'jsx_opening_element' && child.kind() !== 'jsx_closing_element') + .map((child) => child.text()) + .join('') +} + +function getJsxChildren(element: SgNode): SgNode[] { + const children: SgNode[] = [] + for (const child of element.children()) { + const kind = child.kind() + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + children.push(child) + } + return children +} + +function getParamName(paramNode: SgNode): string { + const ident = paramNode.find({ rule: { kind: 'identifier' } }) + return ident?.text() ?? paramNode.text().replace(/:.*$/, '').trim() +} + +function identifierUsedIn(text: string, name: string): boolean { + return new RegExp(`\\b${escapeRegex(name)}\\b`).test(text) +} + +function replaceIdentifier(text: string, name: string, replacement: string): string { + return text.replaceAll(new RegExp(`\\b${escapeRegex(name)}\\b`, 'g'), replacement) +} + +/** + * Rewrite MUI Tabs/TabList onChange to BUI Tabs onSelectionChange. + * MUI: (event, value) => ... or handleChange reference + * BUI: (key) => ... + */ +function rewriteTabsOnChangeHandler(attr: SgNode): string | null { + const expr = attr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return null + } + + const arrow = expr.find({ rule: { kind: 'arrow_function' } }) + if (!arrow) { + const innerText = expr.text().slice(1, -1).trim() + if (/^[\w$.]+$/.test(innerText)) { + return `{(key) => ${innerText}(undefined, key)}` + } + return null + } + + const params = arrow.field('parameters') + if (!params || params.kind() !== 'formal_parameters') { + return null + } + + const paramChildren: SgNode[] = [] + for (const child of params.children()) { + if (child.is('required_parameter') || child.is('identifier')) { + paramChildren.push(child) + } + } + + if (paramChildren.length !== 2) { + return null + } + + const eventName = getParamName(paramChildren[0]!) + const valueName = getParamName(paramChildren[1]!) + + const body = arrow.field('body') + if (!body) { + return null + } + + const bodyText = body.text() + const eventUsed = identifierUsedIn(bodyText, eventName) && !eventName.startsWith('_') + + if (!eventUsed) { + return `{key => ${replaceIdentifier(bodyText, valueName, 'key')}}` + } + + const rewrittenBody = replaceIdentifier(replaceIdentifier(bodyText, eventName, 'undefined'), valueName, 'key') + return `{(key) => ${rewrittenBody}}` +} + +function findTabListOnChange(element: SgNode, tabListLocalName: string | undefined): SgNode | null { + if (!tabListLocalName) { + return null + } + + for (const child of getJsxChildren(element)) { + if (child.kind() !== 'jsx_element') { + continue + } + const opening = child.child(0) + if (!opening) { + continue + } + if (getElementName(opening) === tabListLocalName) { + return getPropAttr(opening, 'onChange') + } + } + + return null +} + +function transformTabListElement(opening: SgNode, innerContent: string): string { + const newProps: string[] = [] + const handledProps = new Set(['onChange']) + + const allAttrs = opening.findAll({ rule: { kind: 'jsx_attribute' } }) + for (const attr of allAttrs) { + const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent || handledProps.has(propIdent.text())) { + continue + } + newProps.push(attr.text()) + } + + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + return `${innerContent}` +} + +function transformTabElement(opening: SgNode): string { + const labelStr = getPropStringValue(opening, 'label') + const labelRaw = getPropRawValue(opening, 'label') + const valueStr = getPropStringValue(opening, 'value') + const valueRaw = getPropRawValue(opening, 'value') + + const newProps: string[] = [] + + if (valueStr !== null) { + newProps.push(`id="${valueStr}"`) + } else if (valueRaw !== null) { + newProps.push(`id=${valueRaw}`) + } + + const handledProps = new Set(['label', 'value', 'wrapped', 'disableRipple', 'classes', 'icon']) + const allAttrs = opening.findAll({ rule: { kind: 'jsx_attribute' } }) + for (const attr of allAttrs) { + const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + if (handledProps.has(propIdent.text())) { + continue + } + newProps.push(attr.text()) + } + + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + + let labelContent = '' + if (labelStr !== null) { + labelContent = labelStr + } else if (labelRaw !== null) { + labelContent = labelRaw + } + + if (labelContent) { + return `${labelContent}` + } + return `` +} + +function transformTabPanelElement(el: SgNode, opening: SgNode): string { + const valueStr = getPropStringValue(opening, 'value') + const valueRaw = getPropRawValue(opening, 'value') + + const newProps: string[] = [] + + if (valueStr !== null) { + newProps.push(`id="${valueStr}"`) + } else if (valueRaw !== null) { + newProps.push(`id=${valueRaw}`) + } + + const handledProps = new Set(['value', 'classes']) + const allAttrs = opening.findAll({ rule: { kind: 'jsx_attribute' } }) + for (const attr of allAttrs) { + const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + if (handledProps.has(propIdent.text())) { + continue + } + newProps.push(attr.text()) + } + + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + + if (el.is('jsx_self_closing_element')) { + return `` + } + + const children = getChildContent(el) + return `${children}` +} + +function transformChildren(element: SgNode, localNames: Map): string { + const children = getJsxChildren(element) + const parts: string[] = [] + + for (const child of children) { + const kind = child.kind() + + if (kind === 'jsx_text') { + parts.push(child.text()) + continue + } + + if (kind === 'jsx_self_closing_element' || kind === 'jsx_element') { + const childOpening = kind === 'jsx_self_closing_element' ? child : child.child(0) + if (childOpening) { + const childName = getElementName(childOpening) + if (childName && localNames.has(childName)) { + const muiName = localNames.get(childName)! + + if (muiName === 'Tab') { + parts.push(transformTabElement(childOpening)) + migrationMetric.increment({ action: 'tab-migrated' }) + continue + } + + if (muiName === 'TabPanel') { + parts.push(transformTabPanelElement(child, childOpening)) + migrationMetric.increment({ action: 'tab-panel-migrated' }) + continue + } + + if (muiName === 'TabList') { + const innerContent = transformChildren(child, localNames) + parts.push(transformTabListElement(childOpening, innerContent)) + migrationMetric.increment({ action: 'tab-list-migrated' }) + continue + } + + if (muiName === 'Tabs') { + const innerContent = transformChildren(child, localNames) + parts.push(`${innerContent}`) + migrationMetric.increment({ action: 'tabs-to-tab-list' }) + continue + } + } + } + } + + parts.push(child.text()) + } + + return parts.join('') +} + +function transformTabElements(rootNode: SgNode, localNames: Map, edits: Edit[]): Set { + const usedBuiNames = new Set() + + const tabContextLocalName = [...localNames.entries()].find(([, v]) => v === 'TabContext')?.[0] + const tabListLocalName = [...localNames.entries()].find(([, v]) => v === 'TabList')?.[0] + const tabsLocalName = [...localNames.entries()].find(([, v]) => v === 'Tabs')?.[0] + + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + for (const el of jsxElements) { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + continue + } + + const name = getElementName(opening) + + if (name && name === tabContextLocalName) { + let needsTodo = false + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + needsTodo = true + break + } + } + + if (needsTodo) { + edits.push( + el.replace( + `{/* TODO(backstage-codemod): verify custom tab orientation or selection logic manually */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-props' }) + continue + } + + const valueRaw = getPropRawValue(opening, 'value') + const isDynamic = isPropDynamic(opening, 'value') + + const newProps: string[] = [] + if (valueRaw !== null) { + if (isDynamic) { + newProps.push(`selectedKey=${valueRaw}`) + } else { + newProps.push(`defaultSelectedKey=${valueRaw}`) + } + } + + const tabListOnChange = findTabListOnChange(el, tabListLocalName) + if (tabListOnChange) { + const rewritten = rewriteTabsOnChangeHandler(tabListOnChange) + if (rewritten !== null) { + newProps.push(`onSelectionChange=${rewritten}`) + migrationMetric.increment({ action: 'onChange-rewritten' }) + } + } + + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + + usedBuiNames.add('Tabs') + + if (isSelfClosing) { + edits.push(el.replace(``)) + } else { + const transformedChildren = transformChildren(el, localNames) + edits.push(el.replace(`${transformedChildren}`)) + } + + migrationMetric.increment({ action: 'tab-context-migrated' }) + continue + } + + if (name && name === tabsLocalName) { + const parent = el.parent() + if (parent) { + const parentOpening = parent.child(0) + if (parentOpening) { + const parentName = getElementName(parentOpening) + if (parentName && parentName === tabContextLocalName) { + continue + } + } + } + + let needsTodo = false + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + needsTodo = true + break + } + } + + if (needsTodo) { + edits.push( + el.replace( + `{/* TODO(backstage-codemod): verify custom tab orientation or selection logic manually */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-props' }) + continue + } + + const valueRaw = getPropRawValue(opening, 'value') + const isDynamic = isPropDynamic(opening, 'value') + + const newTabsProps: string[] = [] + if (valueRaw !== null) { + if (isDynamic) { + newTabsProps.push(`selectedKey=${valueRaw}`) + } else { + newTabsProps.push(`defaultSelectedKey=${valueRaw}`) + } + } + + const onChangeAttr = getPropAttr(opening, 'onChange') + if (onChangeAttr) { + const rewritten = rewriteTabsOnChangeHandler(onChangeAttr) + if (rewritten !== null) { + newTabsProps.push(`onSelectionChange=${rewritten}`) + migrationMetric.increment({ action: 'onChange-rewritten' }) + } + } + + const tabsPropsStr = newTabsProps.length > 0 ? ` ${newTabsProps.join(' ')}` : '' + + usedBuiNames.add('Tabs') + + if (isSelfClosing) { + edits.push(el.replace(``)) + } else { + const innerContent = transformChildren(el, localNames) + edits.push(el.replace(`${innerContent}`)) + usedBuiNames.add('TabList') + } + + migrationMetric.increment({ action: 'tabs-migrated' }) + continue + } + } + + for (const [, muiName] of localNames) { + if (muiName === 'TabContext' || muiName === 'Tabs') { + usedBuiNames.add('Tabs') + } + if (muiName === 'TabList' || muiName === 'Tabs') { + usedBuiNames.add('TabList') + } + if (muiName === 'Tab') { + usedBuiNames.add('Tab') + } + if (muiName === 'TabPanel') { + usedBuiNames.add('TabPanel') + } + } + + return usedBuiNames +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { localNames, importNodesToRemove } = collectTabImports(rootNode) + + if (localNames.size === 0) { + return null + } + + for (const imp of importNodesToRemove) { + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + + const usedBuiNames = transformTabElements(rootNode, localNames, edits) + + addBuiImport(rootNode, [...usedBuiNames], edits) + + return edits.length > 0 ? rootNode.commitEdits(edits) : null +} + +export default transform diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..0110032 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + + + + +import { Button, Tab, TabList, TabPanel, Tabs } from '@backstage/ui'; + +const MyComponent = () => ( + handleChange(undefined, key)}>Info +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..13ffaa8 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/merge-existing-bui/input.tsx @@ -0,0 +1,14 @@ +import TabContext from '@material-ui/lab/TabContext'; +import TabList from '@material-ui/lab/TabList'; +import Tab from '@material-ui/core/Tab'; +import TabPanel from '@material-ui/lab/TabPanel'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + + + + + + +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..39e878a --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/merge-existing-bui/metrics.json @@ -0,0 +1,46 @@ +{ + "migrate-mui-tabs-to-bui-tabs": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 4 + }, + { + "cardinality": { + "action": "onChange-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tab-context-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tab-list-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tab-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tab-panel-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..ac697f3 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + FirstSecond +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..8948ee1 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/input.tsx @@ -0,0 +1,8 @@ +import { Tabs, Tab } from '@material-ui/core'; + +const MyComponent = () => ( + + + + +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..f9d3a06 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-tabs-to-bui-tabs": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tab-migrated" + }, + "count": 2 + }, + { + "cardinality": { + "action": "tabs-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..7169f89 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/noop-no-import/expected.tsx @@ -0,0 +1,9 @@ +import { Tabs, Tab, TabList } from '@backstage/ui'; + +const MyComponent = () => ( + + + First + + +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..7169f89 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/noop-no-import/input.tsx @@ -0,0 +1,9 @@ +import { Tabs, Tab, TabList } from '@backstage/ui'; + +const MyComponent = () => ( + + + First + + +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/expected.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/expected.tsx new file mode 100644 index 0000000..1c95cce --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/expected.tsx @@ -0,0 +1,6 @@ + + + +const MyComponent = () => ( + handleChange(undefined, key)}>FirstSecond +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/input.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/input.tsx new file mode 100644 index 0000000..858514d --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/input.tsx @@ -0,0 +1,9 @@ +import Tabs from '@material-ui/core/Tabs'; +import Tab from '@material-ui/core/Tab'; + +const MyComponent = () => ( + + + + +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/metrics.json b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/metrics.json new file mode 100644 index 0000000..ac8e146 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/metrics.json @@ -0,0 +1,34 @@ +{ + "migrate-mui-tabs-to-bui-tabs": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 2 + }, + { + "cardinality": { + "action": "onChange-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tab-migrated" + }, + "count": 2 + }, + { + "cardinality": { + "action": "tabs-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/expected.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/expected.tsx new file mode 100644 index 0000000..c4081e3 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/expected.tsx @@ -0,0 +1,8 @@ + + + + + +const MyComponent = () => ( + handleChange(undefined, key)}>OverviewDetailsContent AContent B +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/input.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/input.tsx new file mode 100644 index 0000000..6bb8a89 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/input.tsx @@ -0,0 +1,15 @@ +import TabContext from '@material-ui/lab/TabContext'; +import TabList from '@material-ui/lab/TabList'; +import Tab from '@material-ui/core/Tab'; +import TabPanel from '@material-ui/lab/TabPanel'; + +const MyComponent = () => ( + + + + + + Content A + Content B + +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/metrics.json b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/metrics.json new file mode 100644 index 0000000..928667b --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/metrics.json @@ -0,0 +1,46 @@ +{ + "migrate-mui-tabs-to-bui-tabs": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 4 + }, + { + "cardinality": { + "action": "onChange-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tab-context-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tab-list-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tab-migrated" + }, + "count": 2 + }, + { + "cardinality": { + "action": "tab-panel-migrated" + }, + "count": 2 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/expected.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/expected.tsx new file mode 100644 index 0000000..908fc6b --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/expected.tsx @@ -0,0 +1,10 @@ + + + +const MyComponent = () => ( + {/* TODO(backstage-codemod): verify custom tab orientation or selection logic manually */} + + + + +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/input.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/input.tsx new file mode 100644 index 0000000..037f742 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/input.tsx @@ -0,0 +1,9 @@ +import Tabs from '@material-ui/core/Tabs'; +import Tab from '@material-ui/core/Tab'; + +const MyComponent = () => ( + + + + +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/metrics.json b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/metrics.json new file mode 100644 index 0000000..9330e57 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-tabs-to-bui-tabs": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 2 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "complex-props" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tsconfig.json b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/workflow.yaml b/codemods/misc/migrate-mui-tabs-to-bui-tabs/workflow.yaml new file mode 100644 index 0000000..c6a7a64 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace MUI Tabs with BUI Tabs' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/yarn.lock b/yarn.lock index afce4d8..f15a132 100644 --- a/yarn.lock +++ b/yarn.lock @@ -172,6 +172,51 @@ __metadata: languageName: unknown linkType: soft +"@backstage/migrate-mui-chip-to-tag@workspace:codemods/misc/migrate-mui-chip-to-tag": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-chip-to-tag@workspace:codemods/misc/migrate-mui-chip-to-tag" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-dialog-to-bui-dialog@workspace:codemods/misc/migrate-mui-dialog-to-bui-dialog": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-dialog-to-bui-dialog@workspace:codemods/misc/migrate-mui-dialog-to-bui-dialog" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-list-family-to-bui-list@workspace:codemods/misc/migrate-mui-list-family-to-bui-list": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-list-family-to-bui-list@workspace:codemods/misc/migrate-mui-list-family-to-bui-list" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-menu-popover-to-bui-menu@workspace:codemods/misc/migrate-mui-menu-popover-to-bui-menu": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-menu-popover-to-bui-menu@workspace:codemods/misc/migrate-mui-menu-popover-to-bui-menu" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-tabs-to-bui-tabs@workspace:codemods/misc/migrate-mui-tabs-to-bui-tabs": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-tabs-to-bui-tabs@workspace:codemods/misc/migrate-mui-tabs-to-bui-tabs" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + "@backstage/migrate-nav-item-to-page@workspace:codemods/v1.51.0/migrate-nav-item-to-page": version: 0.0.0-use.local resolution: "@backstage/migrate-nav-item-to-page@workspace:codemods/v1.51.0/migrate-nav-item-to-page" From 6838731f7539652c738c509a723624254e7d69ed Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 09:24:15 -0500 Subject: [PATCH 2/6] fix: resolve lint warnings in complex MUI-to-BUI codemods Use Promise.resolve returns, remove non-null assertions, and type regex replace callbacks to satisfy oxlint --deny-warnings in CI. Co-authored-by: Cursor --- .../scripts/codemod.ts | 17 +++++++++----- .../scripts/codemod.ts | 18 +++++++++------ .../scripts/codemod.ts | 19 +++++----------- .../scripts/codemod.ts | 15 +++++++------ .../scripts/codemod.ts | 22 +++++++++++++------ 5 files changed, 52 insertions(+), 39 deletions(-) diff --git a/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts b/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts index 3a814fe..9b1523d 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts @@ -291,11 +291,18 @@ function transformChipElements( needsTagGroup = true const tags = group.map((c) => buildTagReplacement(c)) const tagGroupContent = tags.join('\n ') - edits.push(group[0]!.element.replace(`\n ${tagGroupContent}\n`)) + const [firstChip] = group + if (!firstChip) { + continue + } + edits.push(firstChip.element.replace(`\n ${tagGroupContent}\n`)) migrationMetric.increment({ action: 'tag-group-created', count: `${group.length}` }) for (let i = 1; i < group.length; i++) { - edits.push(group[i]!.element.replace('')) + const chip = group[i] + if (chip) { + edits.push(chip.element.replace('')) + } } for (const c of group) { groupedElements.add(c.element.id()) @@ -326,14 +333,14 @@ function transformChipElements( return { needsTagGroup } } -const transform: Codemod = async (root) => { +const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] const { chipLocalName, importNodesToRemove } = collectChipImports(rootNode) if (!chipLocalName) { - return null + return Promise.resolve(null) } // Remove MUI imports @@ -352,7 +359,7 @@ const transform: Codemod = async (root) => { } addBuiImport(rootNode, importNames, edits) - return edits.length > 0 ? rootNode.commitEdits(edits) : null + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) } export default transform diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts b/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts index 6d697b2..4549856 100644 --- a/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts @@ -208,8 +208,9 @@ function getSimpleOnCloseHandler(opening: SgNode): string | null { children.push(child) } } - if (children.length === 1 && children[0]!.is('identifier')) { - return children[0].text() + const [onlyChild] = children + if (children.length === 1 && onlyChild?.is('identifier')) { + return onlyChild.text() } return null } @@ -244,7 +245,7 @@ function transformFooterCloseButtons(content: string, closeHandler: string | nul 'g', ) - return content.replace(buttonPattern, (_match, attrs, label) => { + return content.replace(buttonPattern, (_match: string, attrs: string, label: string) => { migrationMetric.increment({ action: 'footer-close-button-migrated' }) const extraAttrs = attrs.trim() const attrStr = extraAttrs.length > 0 ? ` ${extraAttrs}` : '' @@ -273,7 +274,10 @@ function transformDialogChildren( if (childOpening) { const childName = getElementName(childOpening) if (childName && localNames.has(childName)) { - const muiName = localNames.get(childName)! + const muiName = localNames.get(childName) + if (!muiName) { + continue + } const buiName = COMPONENT_MAP[muiName] if (buiName) { let innerContent = getChildContent(child) @@ -425,14 +429,14 @@ function transformDialogElements( } } -const transform: Codemod = async (root) => { +const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] const { localNames, importNodesToRemove } = collectDialogImports(rootNode) if (localNames.size === 0) { - return null + return Promise.resolve(null) } for (const imp of importNodesToRemove) { @@ -452,7 +456,7 @@ const transform: Codemod = async (root) => { transformDialogElements(rootNode, localNames, edits, buiNames) addBuiImport(rootNode, [...buiNames], edits) - return edits.length > 0 ? rootNode.commitEdits(edits) : null + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) } export default transform diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/scripts/codemod.ts b/codemods/misc/migrate-mui-list-family-to-bui-list/scripts/codemod.ts index c06ee98..93b4111 100644 --- a/codemods/misc/migrate-mui-list-family-to-bui-list/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/scripts/codemod.ts @@ -204,14 +204,6 @@ function getPropRawValue(opening: SgNode, propName: string): string | null return null } -function getChildContent(element: SgNode): string { - return element - .children() - .filter((child) => child.kind() !== 'jsx_opening_element' && child.kind() !== 'jsx_closing_element') - .map((child) => child.text()) - .join('') -} - function getNonWhitespaceChildren(element: SgNode): SgNode[] { const children: SgNode[] = [] for (const child of element.children()) { @@ -299,8 +291,9 @@ function analyzeListItem(el: SgNode, localNames: Map): List if (childName && childName === listItemIconLocal) { if (kind === 'jsx_element') { const iconChildren = getNonWhitespaceChildren(child) - if (iconChildren.length === 1) { - result.iconContent = iconChildren[0]!.text() + const [iconChild] = iconChildren + if (iconChild) { + result.iconContent = iconChild.text() } else { result.hasComplexContent = true } @@ -448,14 +441,14 @@ function transformListElements(rootNode: SgNode, localNames: Map = async (root) => { +const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] const { localNames, importNodesToRemove } = collectListImports(rootNode) if (localNames.size === 0) { - return null + return Promise.resolve(null) } // Remove MUI imports @@ -486,7 +479,7 @@ const transform: Codemod = async (root) => { // Transform elements transformListElements(rootNode, localNames, edits) - return edits.length > 0 ? rootNode.commitEdits(edits) : null + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) } export default transform diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts index 7c8f9aa..123bfa5 100644 --- a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts @@ -204,8 +204,9 @@ function getSimpleOnCloseHandler(opening: SgNode): string | null { children.push(child) } } - if (children.length === 1 && children[0]!.is('identifier')) { - return children[0].text() + const [onlyChild] = children + if (children.length === 1 && onlyChild?.is('identifier')) { + return onlyChild.text() } return null } @@ -339,8 +340,8 @@ function unwrapMenuList( // If the only meaningful child is a MenuList, unwrap it if (meaningfulChildren.length === 1) { - const onlyChild = meaningfulChildren[0]! - if (onlyChild.kind() === 'jsx_element') { + const [onlyChild] = meaningfulChildren + if (onlyChild?.kind() === 'jsx_element') { const childOpening = onlyChild.child(0) if (childOpening) { const childName = getElementName(childOpening) @@ -466,14 +467,14 @@ function transformMenuElements(rootNode: SgNode, localNames: Map = async (root) => { +const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] const { localNames, importNodesToRemove } = collectMenuImports(rootNode) if (localNames.size === 0) { - return null + return Promise.resolve(null) } // Remove MUI imports @@ -498,7 +499,7 @@ const transform: Codemod = async (root) => { // Transform elements transformMenuElements(rootNode, localNames, edits) - return edits.length > 0 ? rootNode.commitEdits(edits) : null + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) } export default transform diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts b/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts index 70be0a8..772d24f 100644 --- a/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts @@ -283,7 +283,7 @@ function rewriteTabsOnChangeHandler(attr: SgNode): string | null { } const params = arrow.field('parameters') - if (!params || params.kind() !== 'formal_parameters') { + if (params?.kind() !== 'formal_parameters') { return null } @@ -298,8 +298,13 @@ function rewriteTabsOnChangeHandler(attr: SgNode): string | null { return null } - const eventName = getParamName(paramChildren[0]!) - const valueName = getParamName(paramChildren[1]!) + const [firstParam, secondParam] = paramChildren + if (!firstParam || !secondParam) { + return null + } + + const eventName = getParamName(firstParam) + const valueName = getParamName(secondParam) const body = arrow.field('body') if (!body) { @@ -449,7 +454,10 @@ function transformChildren(element: SgNode, localNames: Map if (childOpening) { const childName = getElementName(childOpening) if (childName && localNames.has(childName)) { - const muiName = localNames.get(childName)! + const muiName = localNames.get(childName) + if (!muiName) { + continue + } if (muiName === 'Tab') { parts.push(transformTabElement(childOpening)) @@ -649,14 +657,14 @@ function transformTabElements(rootNode: SgNode, localNames: Map = async (root) => { +const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] const { localNames, importNodesToRemove } = collectTabImports(rootNode) if (localNames.size === 0) { - return null + return Promise.resolve(null) } for (const imp of importNodesToRemove) { @@ -668,7 +676,7 @@ const transform: Codemod = async (root) => { addBuiImport(rootNode, [...usedBuiNames], edits) - return edits.length > 0 ? rootNode.commitEdits(edits) : null + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) } export default transform From c4657a2b0567cbd1bad5bd9d76c5ec9e64d2eaa5 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 09:27:33 -0500 Subject: [PATCH 3/6] docs: regenerate README for core MUI-to-BUI codemods Co-authored-by: Cursor --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index b200fef..f4f2b9e 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,16 @@ Run the [`migration-recipe`](./codemods/v1.51.0/migration-recipe) to apply every Older versions are available in the [`codemods/`](./codemods) directory. +### misc + +| Codemod | Description | +| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| [migrate-mui-chip-to-tag](./codemods/misc/migrate-mui-chip-to-tag) | MUI 4 to BUI: Replace Chip with Tag | +| [migrate-mui-dialog-to-bui-dialog](./codemods/misc/migrate-mui-dialog-to-bui-dialog) | MUI 4 to BUI: Replace Dialog shell with BUI Dialog | +| [migrate-mui-list-family-to-bui-list](./codemods/misc/migrate-mui-list-family-to-bui-list) | MUI 4 to BUI: Replace List family primitives with BUI List | +| [migrate-mui-menu-popover-to-bui-menu](./codemods/misc/migrate-mui-menu-popover-to-bui-menu) | MUI 4 to BUI: Replace Menu and Popover patterns with BUI Menu | +| [migrate-mui-tabs-to-bui-tabs](./codemods/misc/migrate-mui-tabs-to-bui-tabs) | MUI 4 to BUI: Replace MUI Tabs with BUI Tabs | + ## Usage From 4e7e935943e49aacb49df5e451916ab3264430f1 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 09:49:13 -0500 Subject: [PATCH 4/6] fix: address Copilot review feedback on complex component codemods Co-authored-by: Cursor --- .../scripts/codemod.ts | 85 +++++++++----- .../tests/chip-group/expected.tsx | 2 +- .../tests/chip-no-size/expected.tsx | 2 +- .../tests/clickable-chip-todo/expected.tsx | 6 +- .../tests/clickable-chip-todo/metrics.json | 12 -- .../tests/dynamic-label/expected.tsx | 2 +- .../tests/interactive-chip-todo/expected.tsx | 6 +- .../tests/interactive-chip-todo/metrics.json | 12 -- .../tests/named-import-barrel/expected.tsx | 2 +- .../tests/simple-display-chip/expected.tsx | 2 +- .../scripts/codemod.ts | 83 +++++++++---- .../tests/complex-on-close-todo/expected.tsx | 10 +- .../tests/complex-on-close-todo/metrics.json | 12 -- .../tests/controlled-dialog/expected.tsx | 3 +- .../tests/full-width-todo/expected.tsx | 10 +- .../tests/full-width-todo/metrics.json | 12 -- .../tests/named-barrel-import/expected.tsx | 2 +- .../scripts/codemod.ts | 109 ++++++++++++------ .../tests/interactive-todo/expected.tsx | 10 +- .../tests/interactive-todo/metrics.json | 12 -- .../tests/named-barrel-import/expected.tsx | 2 +- .../tests/simple-list-with-icon/expected.tsx | 3 +- .../tests/text-only-no-icon/expected.tsx | 3 +- .../scripts/codemod.ts | 99 +++++++++++----- .../tests/anchor-el-todo/expected.tsx | 8 +- .../tests/anchor-el-todo/metrics.json | 12 -- .../tests/named-barrel-import/expected.tsx | 2 +- .../tests/popover-with-menu-list/expected.tsx | 3 +- .../tests/simple-menu/expected.tsx | 3 +- .../scripts/codemod.ts | 107 +++++++++++------ .../tests/named-barrel-import/expected.tsx | 2 +- .../tests/standalone-tabs/expected.tsx | 3 +- .../tests/tab-context-pattern/expected.tsx | 3 +- .../tests/vertical-tabs-todo/expected.tsx | 8 +- .../tests/vertical-tabs-todo/metrics.json | 12 -- 35 files changed, 382 insertions(+), 282 deletions(-) diff --git a/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts b/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts index 9b1523d..465b6fe 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts @@ -83,9 +83,15 @@ function collectChipImports(rootNode: SgNode): { return { chipLocalName, importNodesToRemove } } -function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): void { +function addBuiImport( + rootNode: SgNode, + names: string[], + importNodesToRemove: SgNode[], + edits: Edit[], +): boolean { const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) const existingImport = existingImports[0] ?? null + const sortedNames = [...names].sort() if (existingImport) { const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) @@ -105,19 +111,33 @@ function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): vo edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) migrationMetric.increment({ action: 'import-merged' }) } - } else { - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const sortedNames = [...names].sort() - if (allImports.length > 0) { - const lastImport = allImports.at(-1) - if (lastImport) { - edits.push( - lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), - ) - } + return false + } + + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push( + anchorImport.replace(`${anchorImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + } else if (importNodesToRemove.length === 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } else if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push(lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) } - migrationMetric.increment({ action: 'import-added' }) } + + migrationMetric.increment({ action: 'import-added' }) + return false } function hasProp(opening: SgNode, propName: string): boolean { @@ -251,8 +271,10 @@ function transformChipElements( rootNode: SgNode, chipLocalName: string, edits: Edit[], -): { needsTagGroup: boolean } { +): { needsTagGroup: boolean; preserveImport: boolean; migrated: boolean } { let needsTagGroup = false + let preserveImport = false + let migrated = false const jsxElements = rootNode.findAll({ rule: { @@ -296,6 +318,7 @@ function transformChipElements( continue } edits.push(firstChip.element.replace(`\n ${tagGroupContent}\n`)) + migrated = true migrationMetric.increment({ action: 'tag-group-created', count: `${group.length}` }) for (let i = 1; i < group.length; i++) { @@ -317,9 +340,10 @@ function transformChipElements( } if (info.isInteractive) { + preserveImport = true edits.push( info.element.replace( - `{/* TODO(backstage-codemod): verify interactive chip migration manually */}\n${info.element.text()}`, + `<>{/* TODO(backstage-codemod): verify interactive chip migration manually */}\n${info.element.text()}`, ), ) migrationMetric.increment({ action: 'todo-inserted', reason: 'interactive-chip' }) @@ -327,10 +351,11 @@ function transformChipElements( } edits.push(info.element.replace(buildTagReplacement(info))) + migrated = true migrationMetric.increment({ action: 'chip-migrated' }) } - return { needsTagGroup } + return { needsTagGroup, preserveImport, migrated } } const transform: Codemod = (root) => { @@ -343,21 +368,27 @@ const transform: Codemod = (root) => { return Promise.resolve(null) } - // Remove MUI imports - for (const imp of importNodesToRemove) { - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) - } + const { needsTagGroup, preserveImport, migrated } = transformChipElements(rootNode, chipLocalName, edits) - // Transform chip elements - const { needsTagGroup } = transformChipElements(rootNode, chipLocalName, edits) + let replacedImport = false + if (migrated) { + const importNames = ['Tag'] + if (needsTagGroup) { + importNames.push('TagGroup') + } + replacedImport = addBuiImport(rootNode, importNames, importNodesToRemove, edits) + } - // Add BUI import - const importNames = ['Tag'] - if (needsTagGroup) { - importNames.push('TagGroup') + if (!preserveImport) { + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } } - addBuiImport(rootNode, importNames, edits) return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) } diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/expected.tsx index 9a250f2..75e3f46 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/expected.tsx +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/expected.tsx @@ -1,4 +1,4 @@ - +import { Tag, TagGroup } from '@backstage/ui'; const Tags = () => ( <> diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/expected.tsx index 4772599..0dc4524 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/expected.tsx +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/expected.tsx @@ -1,4 +1,4 @@ - +import { Tag } from '@backstage/ui'; const MyComponent = () => ( Status diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/expected.tsx index bd505fc..c13deb4 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/expected.tsx +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/expected.tsx @@ -1,6 +1,6 @@ - +import Chip from '@material-ui/core/Chip'; const MyComponent = () => ( - {/* TODO(backstage-codemod): verify interactive chip migration manually */} - navigate('/page')} /> + <>{/* TODO(backstage-codemod): verify interactive chip migration manually */} + navigate('/page')} /> ); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/metrics.json index d89225b..a4c7737 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/metrics.json +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/metrics.json @@ -1,17 +1,5 @@ { "migrate-mui-chip-to-tag": [ - { - "cardinality": { - "action": "import-added" - }, - "count": 1 - }, - { - "cardinality": { - "action": "import-removed" - }, - "count": 1 - }, { "cardinality": { "action": "todo-inserted", diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/expected.tsx index a49712d..26404d4 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/expected.tsx +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/expected.tsx @@ -1,4 +1,4 @@ - +import { Tag } from '@backstage/ui'; const MyComponent = ({ name }: { name: string }) => ( {name} diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/expected.tsx index 115e38f..c763750 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/expected.tsx +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/expected.tsx @@ -1,6 +1,6 @@ - +import Chip from '@material-ui/core/Chip'; const MyComponent = () => ( - {/* TODO(backstage-codemod): verify interactive chip migration manually */} - handleDelete()} /> + <>{/* TODO(backstage-codemod): verify interactive chip migration manually */} + handleDelete()} /> ); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/metrics.json index d89225b..a4c7737 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/metrics.json +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/metrics.json @@ -1,17 +1,5 @@ { "migrate-mui-chip-to-tag": [ - { - "cardinality": { - "action": "import-added" - }, - "count": 1 - }, - { - "cardinality": { - "action": "import-removed" - }, - "count": 1 - }, { "cardinality": { "action": "todo-inserted", diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/expected.tsx index bb3b030..523a91d 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/expected.tsx +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/expected.tsx @@ -1,4 +1,4 @@ - +import { Tag } from '@backstage/ui'; const MyComponent = () => ( Tag diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/expected.tsx index af8aa49..1740fe6 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/expected.tsx +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/expected.tsx @@ -1,4 +1,4 @@ - +import { Tag } from '@backstage/ui'; const MyComponent = () => ( Category diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts b/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts index 4549856..145ded0 100644 --- a/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts @@ -117,9 +117,15 @@ function collectDialogImports(rootNode: SgNode): DialogImports { return { localNames, importNodesToRemove } } -function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): void { +function addBuiImport( + rootNode: SgNode, + names: string[], + importNodesToRemove: SgNode[], + edits: Edit[], +): boolean { const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) const existingImport = existingImports[0] ?? null + const sortedNames = [...names].sort() if (existingImport) { const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) @@ -139,19 +145,33 @@ function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): vo edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) migrationMetric.increment({ action: 'import-merged' }) } - } else { - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const sortedNames = [...names].sort() - if (allImports.length > 0) { - const lastImport = allImports.at(-1) - if (lastImport) { - edits.push( - lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), - ) - } + return false + } + + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push( + anchorImport.replace(`${anchorImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + } else if (importNodesToRemove.length === 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } else if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push(lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) } - migrationMetric.increment({ action: 'import-added' }) } + + migrationMetric.increment({ action: 'import-added' }) + return false } function getElementName(opening: SgNode): string | null { @@ -303,10 +323,12 @@ function transformDialogElements( localNames: Map, edits: Edit[], buiNames: Set, -): void { +): { preserveImport: boolean; migrated: boolean } { + let preserveImport = false + let migrated = false const dialogLocalName = [...localNames.entries()].find(([, v]) => v === 'Dialog')?.[0] if (!dialogLocalName) { - return + return { preserveImport, migrated } } const jsxElements = rootNode.findAll({ @@ -337,9 +359,10 @@ function transformDialogElements( } if (needsTodo) { + preserveImport = true edits.push( el.replace( - `{/* TODO(backstage-codemod): verify dialog width, dismiss behavior, or custom close logic manually (${todoReasons.join(', ')}) */}\n${el.text()}`, + `<>{/* TODO(backstage-codemod): verify dialog width, dismiss behavior, or custom close logic manually (${todoReasons.join(', ')}) */}\n${el.text()}`, ), ) migrationMetric.increment({ action: 'todo-inserted', reason: todoReasons.join(', ') }) @@ -359,9 +382,10 @@ function transformDialogElements( newProps.push(`onOpenChange={isOpen => !isOpen && ${simpleHandler}()}`) migrationMetric.increment({ action: 'onClose-rewritten' }) } else if (hasProp(opening, 'onClose')) { + preserveImport = true edits.push( el.replace( - `{/* TODO(backstage-codemod): verify dialog width, dismiss behavior, or custom close logic manually (complex-onClose) */}\n${el.text()}`, + `<>{/* TODO(backstage-codemod): verify dialog width, dismiss behavior, or custom close logic manually (complex-onClose) */}\n${el.text()}`, ), ) migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-onClose' }) @@ -426,7 +450,10 @@ function transformDialogElements( } migrationMetric.increment({ action: 'dialog-migrated' }) + migrated = true } + + return { preserveImport, migrated } } const transform: Codemod = (root) => { @@ -439,11 +466,6 @@ const transform: Codemod = (root) => { return Promise.resolve(null) } - for (const imp of importNodesToRemove) { - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) - } - const buiNames = new Set() buiNames.add('Dialog') for (const [, muiName] of localNames) { @@ -453,8 +475,23 @@ const transform: Codemod = (root) => { } } - transformDialogElements(rootNode, localNames, edits, buiNames) - addBuiImport(rootNode, [...buiNames], edits) + const { preserveImport, migrated } = transformDialogElements(rootNode, localNames, edits, buiNames) + + let replacedImport = false + if (migrated) { + replacedImport = addBuiImport(rootNode, [...buiNames], importNodesToRemove, edits) + } + + if (!preserveImport) { + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + } return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) } diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/expected.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/expected.tsx index 4ddb407..c713c40 100644 --- a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/expected.tsx +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/expected.tsx @@ -1,11 +1,11 @@ - - - +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( - {/* TODO(backstage-codemod): verify dialog width, dismiss behavior, or custom close logic manually (complex-onClose) */} + <>{/* TODO(backstage-codemod): verify dialog width, dismiss behavior, or custom close logic manually (complex-onClose) */} { cleanup(); onClose(); }}> Complex Body - + ); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/metrics.json b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/metrics.json index 2fa1e44..116db1e 100644 --- a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/metrics.json +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/metrics.json @@ -1,17 +1,5 @@ { "migrate-mui-dialog-to-bui-dialog": [ - { - "cardinality": { - "action": "import-added" - }, - "count": 1 - }, - { - "cardinality": { - "action": "import-removed" - }, - "count": 3 - }, { "cardinality": { "action": "todo-inserted", diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/expected.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/expected.tsx index 7df9042..715f5b8 100644 --- a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/expected.tsx +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/expected.tsx @@ -1,7 +1,8 @@ - +import DialogActions from '@material-ui/core/DialogActions'; +import { Button, Dialog, DialogBody, DialogFooter, DialogHeader } from '@backstage/ui'; const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( !isOpen && onClose()}>ConfirmAre you sure? diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/expected.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/expected.tsx index 77a8df4..2723a07 100644 --- a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/expected.tsx +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/expected.tsx @@ -1,11 +1,11 @@ - - - +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( - {/* TODO(backstage-codemod): verify dialog width, dismiss behavior, or custom close logic manually (maxWidth, fullWidth) */} + <>{/* TODO(backstage-codemod): verify dialog width, dismiss behavior, or custom close logic manually (maxWidth, fullWidth) */} Wide Content - + ); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/metrics.json b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/metrics.json index 67b181e..ac15c0a 100644 --- a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/metrics.json +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/metrics.json @@ -1,17 +1,5 @@ { "migrate-mui-dialog-to-bui-dialog": [ - { - "cardinality": { - "action": "import-added" - }, - "count": 1 - }, - { - "cardinality": { - "action": "import-removed" - }, - "count": 3 - }, { "cardinality": { "action": "todo-inserted", diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/expected.tsx index 6899af7..30d66ac 100644 --- a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/expected.tsx +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/expected.tsx @@ -1,4 +1,4 @@ - +import { Dialog, DialogBody, DialogHeader } from '@backstage/ui'; const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( !isOpen && onClose()}>InfoDetails here diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/scripts/codemod.ts b/codemods/misc/migrate-mui-list-family-to-bui-list/scripts/codemod.ts index 93b4111..ff32480 100644 --- a/codemods/misc/migrate-mui-list-family-to-bui-list/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/scripts/codemod.ts @@ -115,9 +115,15 @@ function collectListImports(rootNode: SgNode): ListImports { return { localNames, importNodesToRemove } } -function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): void { +function addBuiImport( + rootNode: SgNode, + names: string[], + importNodesToRemove: SgNode[], + edits: Edit[], +): boolean { const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) const existingImport = existingImports[0] ?? null + const sortedNames = [...names].sort() if (existingImport) { const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) @@ -137,19 +143,33 @@ function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): vo edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) migrationMetric.increment({ action: 'import-merged' }) } - } else { - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const sortedNames = [...names].sort() - if (allImports.length > 0) { - const lastImport = allImports.at(-1) - if (lastImport) { - edits.push( - lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), - ) - } + return false + } + + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push( + anchorImport.replace(`${anchorImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + } else if (importNodesToRemove.length === 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } else if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push(lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) } - migrationMetric.increment({ action: 'import-added' }) } + + migrationMetric.increment({ action: 'import-added' }) + return false } function getElementName(opening: SgNode): string | null { @@ -356,12 +376,20 @@ function analyzeListItem(el: SgNode, localNames: Map): List return result } -function transformListElements(rootNode: SgNode, localNames: Map, edits: Edit[]): void { +function transformListElements( + rootNode: SgNode, + localNames: Map, + edits: Edit[], +): { preserveImport: boolean; migrated: boolean; buiNames: Set } { + let preserveImport = false + let migrated = false + const buiNames = new Set() + const listLocalName = [...localNames.entries()].find(([, v]) => v === 'List')?.[0] ?? null const listItemLocalName = [...localNames.entries()].find(([, v]) => v === 'ListItem')?.[0] ?? null if (!listLocalName && !listItemLocalName) { - return + return { preserveImport, migrated, buiNames } } const jsxElements = rootNode @@ -394,6 +422,11 @@ function transformListElements(rootNode: SgNode, localNames: Map')) + buiNames.add('ListRow') + if (listLocalName) { + buiNames.add('List') + } + migrated = true migrationMetric.increment({ action: 'list-item-migrated' }) continue } @@ -401,7 +434,10 @@ function transformListElements(rootNode: SgNode, localNames: Map{/* TODO(backstage-codemod): verify nonstandard list row manually */}\n${el.text()}`), + ) migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-list-item' }) continue } @@ -435,10 +471,17 @@ function transformListElements(rootNode: SgNode, localNames: Map`)) } + buiNames.add('ListRow') + if (listLocalName) { + buiNames.add('List') + } + migrated = true migrationMetric.increment({ action: 'list-item-migrated' }) continue } } + + return { preserveImport, migrated, buiNames } } const transform: Codemod = (root) => { @@ -451,34 +494,24 @@ const transform: Codemod = (root) => { return Promise.resolve(null) } - // Remove MUI imports - for (const imp of importNodesToRemove) { - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) + const { preserveImport, migrated, buiNames } = transformListElements(rootNode, localNames, edits) + + let replacedImport = false + if (migrated) { + replacedImport = addBuiImport(rootNode, [...buiNames], importNodesToRemove, edits) } - // Determine BUI names needed - const buiNames = new Set() - for (const [, muiName] of localNames) { - if (muiName === 'List' || muiName === 'ListSubheader') { - buiNames.add('List') - } - if ( - muiName === 'ListItem' || - muiName === 'ListItemIcon' || - muiName === 'ListItemText' || - muiName === 'ListItemAvatar' || - muiName === 'ListItemSecondaryAction' - ) { - buiNames.add('ListRow') + if (!preserveImport) { + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) } } - addBuiImport(rootNode, [...buiNames], edits) - - // Transform elements - transformListElements(rootNode, localNames, edits) - return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) } diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/expected.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/expected.tsx index 7aceef8..75336d7 100644 --- a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/expected.tsx +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/expected.tsx @@ -1,12 +1,12 @@ - - - +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemText from '@material-ui/core/ListItemText'; const MyComponent = () => ( - {/* TODO(backstage-codemod): verify nonstandard list row manually */} + <>{/* TODO(backstage-codemod): verify nonstandard list row manually */} - + ); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/metrics.json b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/metrics.json index f924559..3f2c61a 100644 --- a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/metrics.json +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/metrics.json @@ -1,17 +1,5 @@ { "migrate-mui-list-family-to-bui-list": [ - { - "cardinality": { - "action": "import-added" - }, - "count": 1 - }, - { - "cardinality": { - "action": "import-removed" - }, - "count": 3 - }, { "cardinality": { "action": "todo-inserted", diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/expected.tsx index 45a8d3c..72da391 100644 --- a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/expected.tsx +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/expected.tsx @@ -1,4 +1,4 @@ - +import { List, ListRow } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/expected.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/expected.tsx index feafd59..1c010d7 100644 --- a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/expected.tsx +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/expected.tsx @@ -1,7 +1,8 @@ - +import ListItemText from '@material-ui/core/ListItemText'; +import { List, ListRow } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/expected.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/expected.tsx index 1feaf51..3e9e10a 100644 --- a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/expected.tsx +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/expected.tsx @@ -1,6 +1,7 @@ - +import ListItemText from '@material-ui/core/ListItemText'; +import { List, ListRow } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts index 123bfa5..c5f94e9 100644 --- a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts @@ -113,9 +113,15 @@ function collectMenuImports(rootNode: SgNode): MenuImports { return { localNames, importNodesToRemove } } -function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): void { +function addBuiImport( + rootNode: SgNode, + names: string[], + importNodesToRemove: SgNode[], + edits: Edit[], +): boolean { const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) const existingImport = existingImports[0] ?? null + const sortedNames = [...names].sort() if (existingImport) { const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) @@ -135,19 +141,33 @@ function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): vo edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) migrationMetric.increment({ action: 'import-merged' }) } - } else { - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const sortedNames = [...names].sort() - if (allImports.length > 0) { - const lastImport = allImports.at(-1) - if (lastImport) { - edits.push( - lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), - ) - } + return false + } + + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push( + anchorImport.replace(`${anchorImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + } else if (importNodesToRemove.length === 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } else if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push(lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) } - migrationMetric.increment({ action: 'import-added' }) } + + migrationMetric.increment({ action: 'import-added' }) + return false } function getElementName(opening: SgNode): string | null { @@ -363,7 +383,14 @@ function unwrapMenuList( return getChildContent(element) } -function transformMenuElements(rootNode: SgNode, localNames: Map, edits: Edit[]): void { +function transformMenuElements( + rootNode: SgNode, + localNames: Map, + edits: Edit[], +): { preserveImport: boolean; migrated: boolean; buiNames: Set } { + let preserveImport = false + let migrated = false + const buiNames = new Set() const menuListLocalName = [...localNames.entries()].find(([, v]) => v === 'MenuList')?.[0] ?? null const menuItemLocalName = [...localNames.entries()].find(([, v]) => v === 'MenuItem')?.[0] ?? null @@ -414,9 +441,10 @@ function transformMenuElements(rootNode: SgNode, localNames: Map{/* TODO(backstage-codemod): finish menu host migration manually (${todoReasons.join(', ')}) */}\n${el.text()}`, ), ) migrationMetric.increment({ action: 'todo-inserted', reason: todoReasons.join(', ') }) @@ -425,6 +453,8 @@ function transformMenuElements(rootNode: SgNode, localNames: Map')) + buiNames.add('Menu') + migrated = true migrationMetric.increment({ action: 'menu-migrated', variant: 'self-closing' }) continue } @@ -447,9 +477,10 @@ function transformMenuElements(rootNode: SgNode, localNames: Map !isOpen && ${simpleHandler}()}`) migrationMetric.increment({ action: 'onClose-rewritten' }) } else { + preserveImport = true edits.push( el.replace( - `{/* TODO(backstage-codemod): finish menu host migration manually (complex-onClose) */}\n${el.text()}`, + `<>{/* TODO(backstage-codemod): finish menu host migration manually (complex-onClose) */}\n${el.text()}`, ), ) migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-onClose' }) @@ -459,12 +490,20 @@ function transformMenuElements(rootNode: SgNode, localNames: Map 0 ? ` ${triggerProps.join(' ')}` : '' menuOutput = `${menuOutput}` + buiNames.add('MenuTrigger') migrationMetric.increment({ action: 'menu-trigger-wrapped' }) } + buiNames.add('Menu') + if (menuItemLocalName) { + buiNames.add('MenuItem') + } edits.push(el.replace(menuOutput)) + migrated = true migrationMetric.increment({ action: 'menu-migrated', variant: muiName === 'Popover' ? 'popover' : 'menu' }) } + + return { preserveImport, migrated, buiNames } } const transform: Codemod = (root) => { @@ -477,27 +516,23 @@ const transform: Codemod = (root) => { return Promise.resolve(null) } - // Remove MUI imports - for (const imp of importNodesToRemove) { - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) + const { preserveImport, migrated, buiNames } = transformMenuElements(rootNode, localNames, edits) + + let replacedImport = false + if (migrated) { + replacedImport = addBuiImport(rootNode, [...buiNames], importNodesToRemove, edits) } - // Determine which BUI names we need - const buiNames = new Set() - for (const [, muiName] of localNames) { - if (muiName === 'Menu' || muiName === 'Popover' || muiName === 'MenuList') { - buiNames.add('Menu') - buiNames.add('MenuTrigger') - } - if (muiName === 'MenuItem') { - buiNames.add('MenuItem') + if (!preserveImport) { + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) } } - addBuiImport(rootNode, [...buiNames], edits) - - // Transform elements - transformMenuElements(rootNode, localNames, edits) return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) } diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/expected.tsx index 48c74cb..8061dcc 100644 --- a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/expected.tsx +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/expected.tsx @@ -1,9 +1,9 @@ - - +import Menu from '@material-ui/core/Menu'; +import MenuItem from '@material-ui/core/MenuItem'; const MyComponent = ({ anchorEl, open, onClose }: any) => ( - {/* TODO(backstage-codemod): finish menu host migration manually (anchorEl) */} + <>{/* TODO(backstage-codemod): finish menu host migration manually (anchorEl) */} Action - + ); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/metrics.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/metrics.json index 7756164..775b2bc 100644 --- a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/metrics.json +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/metrics.json @@ -1,17 +1,5 @@ { "migrate-mui-menu-popover-to-bui-menu": [ - { - "cardinality": { - "action": "import-added" - }, - "count": 1 - }, - { - "cardinality": { - "action": "import-removed" - }, - "count": 2 - }, { "cardinality": { "action": "todo-inserted", diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/expected.tsx index 7120124..109b6ff 100644 --- a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/expected.tsx +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/expected.tsx @@ -1,4 +1,4 @@ - +import { Menu, MenuItem, MenuTrigger } from '@backstage/ui'; const MyComponent = () => ( Do stuff diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/expected.tsx index 9a6c514..7cf5021 100644 --- a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/expected.tsx +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/expected.tsx @@ -1,6 +1,7 @@ - +import MenuItem from '@material-ui/core/MenuItem'; +import { Menu, MenuItem, MenuTrigger } from '@backstage/ui'; const MyComponent = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( !isOpen && onClose()}>Action diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/expected.tsx index 8787d62..40b7704 100644 --- a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/expected.tsx +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/expected.tsx @@ -1,5 +1,6 @@ - +import MenuItem from '@material-ui/core/MenuItem'; +import { Menu, MenuItem, MenuTrigger } from '@backstage/ui'; const MyComponent = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( !isOpen && onClose()}>EditDelete diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts b/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts index 772d24f..c708e64 100644 --- a/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts @@ -132,9 +132,15 @@ function collectTabImports(rootNode: SgNode): TabImports { return { localNames, importNodesToRemove } } -function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): void { +function addBuiImport( + rootNode: SgNode, + names: string[], + importNodesToRemove: SgNode[], + edits: Edit[], +): boolean { const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) const existingImport = existingImports[0] ?? null + const sortedNames = [...names].sort() if (existingImport) { const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) @@ -154,19 +160,33 @@ function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): vo edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) migrationMetric.increment({ action: 'import-merged' }) } - } else { - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const sortedNames = [...names].sort() - if (allImports.length > 0) { - const lastImport = allImports.at(-1) - if (lastImport) { - edits.push( - lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), - ) - } + return false + } + + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push( + anchorImport.replace(`${anchorImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + } else if (importNodesToRemove.length === 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } else if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push(lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) } - migrationMetric.increment({ action: 'import-added' }) } + + migrationMetric.increment({ action: 'import-added' }) + return false } function getElementName(opening: SgNode): string | null { @@ -494,8 +514,14 @@ function transformChildren(element: SgNode, localNames: Map return parts.join('') } -function transformTabElements(rootNode: SgNode, localNames: Map, edits: Edit[]): Set { +function transformTabElements( + rootNode: SgNode, + localNames: Map, + edits: Edit[], +): { usedBuiNames: Set; preserveImport: boolean; migrated: boolean } { const usedBuiNames = new Set() + let preserveImport = false + let migrated = false const tabContextLocalName = [...localNames.entries()].find(([, v]) => v === 'TabContext')?.[0] const tabListLocalName = [...localNames.entries()].find(([, v]) => v === 'TabList')?.[0] @@ -526,9 +552,10 @@ function transformTabElements(rootNode: SgNode, localNames: Map{/* TODO(backstage-codemod): verify custom tab orientation or selection logic manually */}\n${el.text()}`, ), ) migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-props' }) @@ -559,6 +586,7 @@ function transformTabElements(rootNode: SgNode, localNames: Map 0 ? ` ${newProps.join(' ')}` : '' usedBuiNames.add('Tabs') + migrated = true if (isSelfClosing) { edits.push(el.replace(``)) @@ -592,9 +620,10 @@ function transformTabElements(rootNode: SgNode, localNames: Map{/* TODO(backstage-codemod): verify custom tab orientation or selection logic manually */}\n${el.text()}`, ), ) migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-props' }) @@ -625,6 +654,7 @@ function transformTabElements(rootNode: SgNode, localNames: Map 0 ? ` ${newTabsProps.join(' ')}` : '' usedBuiNames.add('Tabs') + migrated = true if (isSelfClosing) { edits.push(el.replace(``)) @@ -640,21 +670,23 @@ function transformTabElements(rootNode: SgNode, localNames: Map = (root) => { @@ -667,14 +699,23 @@ const transform: Codemod = (root) => { return Promise.resolve(null) } - for (const imp of importNodesToRemove) { - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) - } + const { usedBuiNames, preserveImport, migrated } = transformTabElements(rootNode, localNames, edits) - const usedBuiNames = transformTabElements(rootNode, localNames, edits) + let replacedImport = false + if (migrated) { + replacedImport = addBuiImport(rootNode, [...usedBuiNames], importNodesToRemove, edits) + } - addBuiImport(rootNode, [...usedBuiNames], edits) + if (!preserveImport) { + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + } return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) } diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/expected.tsx index ac697f3..f791da0 100644 --- a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/expected.tsx +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/expected.tsx @@ -1,4 +1,4 @@ - +import { Tab, TabList, Tabs } from '@backstage/ui'; const MyComponent = () => ( FirstSecond diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/expected.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/expected.tsx index 1c95cce..3b2de7a 100644 --- a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/expected.tsx +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/expected.tsx @@ -1,5 +1,6 @@ - +import Tab from '@material-ui/core/Tab'; +import { Tab, TabList, Tabs } from '@backstage/ui'; const MyComponent = () => ( handleChange(undefined, key)}>FirstSecond diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/expected.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/expected.tsx index c4081e3..375358d 100644 --- a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/expected.tsx +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/expected.tsx @@ -1,7 +1,8 @@ - +import TabPanel from '@material-ui/lab/TabPanel'; +import { Tab, TabList, TabPanel, Tabs } from '@backstage/ui'; const MyComponent = () => ( handleChange(undefined, key)}>OverviewDetailsContent AContent B diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/expected.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/expected.tsx index 908fc6b..65c9fc3 100644 --- a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/expected.tsx +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/expected.tsx @@ -1,10 +1,10 @@ - - +import Tabs from '@material-ui/core/Tabs'; +import Tab from '@material-ui/core/Tab'; const MyComponent = () => ( - {/* TODO(backstage-codemod): verify custom tab orientation or selection logic manually */} + <>{/* TODO(backstage-codemod): verify custom tab orientation or selection logic manually */} - + ); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/metrics.json b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/metrics.json index 9330e57..3115005 100644 --- a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/metrics.json +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/metrics.json @@ -1,17 +1,5 @@ { "migrate-mui-tabs-to-bui-tabs": [ - { - "cardinality": { - "action": "import-added" - }, - "count": 1 - }, - { - "cardinality": { - "action": "import-removed" - }, - "count": 2 - }, { "cardinality": { "action": "todo-inserted", From f5ac28b10287c734f450dc3892f512503d9d8508 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 10:53:19 -0500 Subject: [PATCH 5/6] fix: resolve remaining Copilot review items on complex codemods Harden import anchoring, menu trigger detection, tabs onChange rewriting, list icon props, and consecutive TagGroup grouping so migrations stay valid when MUI-only imports are removed or handlers reference event. Co-authored-by: Cursor --- .../scripts/codemod.ts | 67 ++++++++--- .../scripts/codemod.ts | 10 +- .../tests/controlled-dialog/expected.tsx | 3 +- .../scripts/codemod.ts | 20 +++- .../tests/simple-list-with-icon/expected.tsx | 3 +- .../tests/text-only-no-icon/expected.tsx | 3 +- .../scripts/codemod.ts | 109 ++++++++++++++---- .../tests/merge-existing-bui/expected.tsx | 4 +- .../tests/named-barrel-import/expected.tsx | 7 +- .../tests/named-barrel-import/metrics.json | 34 +----- .../tests/popover-with-menu-list/expected.tsx | 12 +- .../tests/popover-with-menu-list/metrics.json | 46 +------- .../tests/simple-menu/expected.tsx | 9 +- .../tests/simple-menu/metrics.json | 40 +------ .../scripts/codemod.ts | 42 +++++-- .../tests/standalone-tabs/expected.tsx | 3 +- .../tests/tab-context-pattern/expected.tsx | 3 +- 17 files changed, 217 insertions(+), 198 deletions(-) diff --git a/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts b/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts index 465b6fe..c064513 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts @@ -129,10 +129,12 @@ function addBuiImport( migrationMetric.increment({ action: 'import-added' }) return true } - } else if (allImports.length > 0) { - const lastImport = allImports.at(-1) - if (lastImport) { - edits.push(lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + } else if (importNodesToRemove.length > 0) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true } } @@ -267,6 +269,42 @@ function buildTagReplacement(info: ChipInfo): string { return `` } +function getNonWhitespaceJsxSiblings(parent: SgNode): SgNode[] { + const children: SgNode[] = [] + for (const child of parent.children()) { + const kind = child.kind() + if (kind === 'jsx_text' && child.text().trim().length === 0) { + continue + } + children.push(child) + } + return children +} + +function findConsecutiveChipGroupsForParent(parent: SgNode, chipLocalName: string): ChipInfo[][] { + const groups: ChipInfo[][] = [] + let current: ChipInfo[] = [] + + for (const sibling of getNonWhitespaceJsxSiblings(parent)) { + const info = analyzeChipElement(sibling, chipLocalName) + if (info && !info.isInteractive) { + current.push(info) + continue + } + + if (current.length >= 2) { + groups.push(current) + } + current = [] + } + + if (current.length >= 2) { + groups.push(current) + } + + return groups +} + function transformChipElements( rootNode: SgNode, chipLocalName: string, @@ -291,25 +329,18 @@ function transformChipElements( } } - // Group chips by parent for TagGroup detection - const parentGroups = new Map() + // Group consecutive non-interactive chip siblings for TagGroup + const groupedElements = new Set() + const processedParents = new Set() + for (const info of chipInfos) { const parent = info.element.parent() - if (!parent) { + if (!parent || processedParents.has(parent.id())) { continue } - const parentId = parent.id() - const group = parentGroups.get(parentId) ?? [] - group.push(info) - parentGroups.set(parentId, group) - } - - // Track which elements are part of a group - const groupedElements = new Set() + processedParents.add(parent.id()) - for (const [, group] of parentGroups) { - // Only group if 2+ chips are siblings AND all are plain display chips - if (group.length >= 2 && group.every((c) => !c.isInteractive)) { + for (const group of findConsecutiveChipGroupsForParent(parent, chipLocalName)) { needsTagGroup = true const tags = group.map((c) => buildTagReplacement(c)) const tagGroupContent = tags.join('\n ') diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts b/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts index 145ded0..41a81df 100644 --- a/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts @@ -163,10 +163,12 @@ function addBuiImport( migrationMetric.increment({ action: 'import-added' }) return true } - } else if (allImports.length > 0) { - const lastImport = allImports.at(-1) - if (lastImport) { - edits.push(lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + } else if (importNodesToRemove.length > 0) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true } } diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/expected.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/expected.tsx index 715f5b8..3ba106d 100644 --- a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/expected.tsx +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/expected.tsx @@ -1,8 +1,7 @@ +import { Button, Dialog, DialogBody, DialogFooter, DialogHeader } from '@backstage/ui'; -import DialogActions from '@material-ui/core/DialogActions'; -import { Button, Dialog, DialogBody, DialogFooter, DialogHeader } from '@backstage/ui'; const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( !isOpen && onClose()}>ConfirmAre you sure? diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/scripts/codemod.ts b/codemods/misc/migrate-mui-list-family-to-bui-list/scripts/codemod.ts index ff32480..610beca 100644 --- a/codemods/misc/migrate-mui-list-family-to-bui-list/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/scripts/codemod.ts @@ -161,10 +161,12 @@ function addBuiImport( migrationMetric.increment({ action: 'import-added' }) return true } - } else if (allImports.length > 0) { - const lastImport = allImports.at(-1) - if (lastImport) { - edits.push(lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + } else if (importNodesToRemove.length > 0) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true } } @@ -239,6 +241,14 @@ function getNonWhitespaceChildren(element: SgNode): SgNode[] { return children } +function formatIconProp(iconContent: string): string { + const trimmed = iconContent.trim() + if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + return `icon=${trimmed}` + } + return `icon={${trimmed}}` +} + interface ListItemAnalysis { iconContent: string | null primaryText: string | null @@ -446,7 +456,7 @@ function transformListElements( const props: string[] = [] if (analysis.iconContent) { - props.push(`icon={${analysis.iconContent}}`) + props.push(formatIconProp(analysis.iconContent)) } if (analysis.secondaryText !== null) { diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/expected.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/expected.tsx index 1c010d7..1a01d9e 100644 --- a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/expected.tsx +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/expected.tsx @@ -1,8 +1,7 @@ +import { List, ListRow } from '@backstage/ui'; -import ListItemText from '@material-ui/core/ListItemText'; -import { List, ListRow } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/expected.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/expected.tsx index 3e9e10a..2f870aa 100644 --- a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/expected.tsx +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/expected.tsx @@ -1,7 +1,6 @@ +import { List, ListRow } from '@backstage/ui'; -import ListItemText from '@material-ui/core/ListItemText'; -import { List, ListRow } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts index c5f94e9..7201f37 100644 --- a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts @@ -159,10 +159,12 @@ function addBuiImport( migrationMetric.increment({ action: 'import-added' }) return true } - } else if (allImports.length > 0) { - const lastImport = allImports.at(-1) - if (lastImport) { - edits.push(lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + } else if (importNodesToRemove.length > 0) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true } } @@ -266,9 +268,52 @@ function getNonWhitespaceChildren(element: SgNode): SgNode[] { return children } -/** - * Transform MenuItem children: map onClick → onAction, preserve text content. - */ +function isTriggerElement(node: SgNode): boolean { + const kind = node.kind() + if (kind !== 'jsx_element' && kind !== 'jsx_self_closing_element') { + return false + } + + const opening = kind === 'jsx_self_closing_element' ? node : node.child(0) + if (!opening) { + return false + } + + const name = getElementName(opening) + if (!name) { + return false + } + + if (/Button$/i.test(name) || name === 'button') { + return true + } + + return hasProp(opening, 'onClick') +} + +function findTriggerSibling(menuEl: SgNode): SgNode | null { + const parent = menuEl.parent() + if (!parent) { + return null + } + + const parentKind = parent.kind() + if (parentKind !== 'jsx_element' && parentKind !== 'jsx_fragment') { + return null + } + + for (const sibling of getNonWhitespaceChildren(parent)) { + if (sibling.id() === menuEl.id()) { + continue + } + if (isTriggerElement(sibling)) { + return sibling + } + } + + return null +} + function transformMenuItemChildren(element: SgNode, menuItemLocalName: string): string { const children = getJsxChildren(element) const parts: string[] = [] @@ -459,12 +504,38 @@ function transformMenuElements( continue } + const hasControlledState = hasProp(opening, 'open') || hasProp(opening, 'onClose') + let triggerSibling: SgNode | null = null + if (hasControlledState) { + triggerSibling = findTriggerSibling(el) + if (!triggerSibling) { + preserveImport = true + edits.push( + el.replace( + `<>{/* TODO(backstage-codemod): finish menu host migration manually (no-trigger-element) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'no-trigger-element' }) + continue + } + + if (hasProp(opening, 'onClose') && !getSimpleOnCloseHandler(opening)) { + preserveImport = true + edits.push( + el.replace( + `<>{/* TODO(backstage-codemod): finish menu host migration manually (complex-onClose) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-onClose' }) + continue + } + } + // Transform children: unwrap MenuList, convert MenuItems const innerContent = unwrapMenuList(el, menuListLocalName, menuItemLocalName) let menuOutput = `${innerContent}` - const hasControlledState = hasProp(opening, 'open') || hasProp(opening, 'onClose') - if (hasControlledState) { + if (hasControlledState && triggerSibling) { const triggerProps: string[] = [] const openValue = getPropRawValue(opening, 'open') if (openValue) { @@ -472,24 +543,14 @@ function transformMenuElements( } const simpleHandler = getSimpleOnCloseHandler(opening) - if (hasProp(opening, 'onClose')) { - if (simpleHandler) { - triggerProps.push(`onOpenChange={isOpen => !isOpen && ${simpleHandler}()}`) - migrationMetric.increment({ action: 'onClose-rewritten' }) - } else { - preserveImport = true - edits.push( - el.replace( - `<>{/* TODO(backstage-codemod): finish menu host migration manually (complex-onClose) */}\n${el.text()}`, - ), - ) - migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-onClose' }) - continue - } + if (simpleHandler) { + triggerProps.push(`onOpenChange={isOpen => !isOpen && ${simpleHandler}()}`) + migrationMetric.increment({ action: 'onClose-rewritten' }) } const triggerPropsStr = triggerProps.length > 0 ? ` ${triggerProps.join(' ')}` : '' - menuOutput = `${menuOutput}` + menuOutput = `${triggerSibling.text()}${menuOutput}` + edits.push(triggerSibling.replace('')) buiNames.add('MenuTrigger') migrationMetric.increment({ action: 'menu-trigger-wrapped' }) } diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/expected.tsx index 7a05629..f81b64e 100644 --- a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/expected.tsx +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/expected.tsx @@ -4,7 +4,7 @@ import { Button, Menu, MenuItem, MenuTrigger } from '@backstage/ui'; const MyComponent = () => ( <> - - Action + + Action ); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/expected.tsx index 109b6ff..969c004 100644 --- a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/expected.tsx +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/expected.tsx @@ -1,5 +1,8 @@ -import { Menu, MenuItem, MenuTrigger } from '@backstage/ui'; +import { Menu, MenuItem } from '@material-ui/core'; const MyComponent = () => ( - Do stuff + <>{/* TODO(backstage-codemod): finish menu host migration manually (no-trigger-element) */} + + Do stuff + ); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/metrics.json index 4051dfb..9200396 100644 --- a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/metrics.json +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/metrics.json @@ -2,38 +2,8 @@ "migrate-mui-menu-popover-to-bui-menu": [ { "cardinality": { - "action": "import-added" - }, - "count": 1 - }, - { - "cardinality": { - "action": "import-removed" - }, - "count": 1 - }, - { - "cardinality": { - "action": "menu-item-migrated" - }, - "count": 1 - }, - { - "cardinality": { - "action": "menu-migrated", - "variant": "menu" - }, - "count": 1 - }, - { - "cardinality": { - "action": "menu-trigger-wrapped" - }, - "count": 1 - }, - { - "cardinality": { - "action": "onClick-to-onAction" + "action": "todo-inserted", + "reason": "no-trigger-element" }, "count": 1 } diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/expected.tsx index 7cf5021..2829c01 100644 --- a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/expected.tsx +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/expected.tsx @@ -1,8 +1,12 @@ - - +import Popover from '@material-ui/core/Popover'; +import MenuList from '@material-ui/core/MenuList'; import MenuItem from '@material-ui/core/MenuItem'; -import { Menu, MenuItem, MenuTrigger } from '@backstage/ui'; const MyComponent = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( - !isOpen && onClose()}>Action + <>{/* TODO(backstage-codemod): finish menu host migration manually (no-trigger-element) */} + + + Action + + ); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/metrics.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/metrics.json index 5555437..9200396 100644 --- a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/metrics.json +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/metrics.json @@ -2,50 +2,8 @@ "migrate-mui-menu-popover-to-bui-menu": [ { "cardinality": { - "action": "import-added" - }, - "count": 1 - }, - { - "cardinality": { - "action": "import-removed" - }, - "count": 3 - }, - { - "cardinality": { - "action": "menu-item-migrated" - }, - "count": 1 - }, - { - "cardinality": { - "action": "menu-list-unwrapped" - }, - "count": 1 - }, - { - "cardinality": { - "action": "menu-migrated", - "variant": "popover" - }, - "count": 1 - }, - { - "cardinality": { - "action": "menu-trigger-wrapped" - }, - "count": 1 - }, - { - "cardinality": { - "action": "onClick-to-onAction" - }, - "count": 1 - }, - { - "cardinality": { - "action": "onClose-rewritten" + "action": "todo-inserted", + "reason": "no-trigger-element" }, "count": 1 } diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/expected.tsx index 40b7704..aac9718 100644 --- a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/expected.tsx +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/expected.tsx @@ -1,7 +1,10 @@ - +import Menu from '@material-ui/core/Menu'; import MenuItem from '@material-ui/core/MenuItem'; -import { Menu, MenuItem, MenuTrigger } from '@backstage/ui'; const MyComponent = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( - !isOpen && onClose()}>EditDelete + <>{/* TODO(backstage-codemod): finish menu host migration manually (no-trigger-element) */} + + Edit + Delete + ); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/metrics.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/metrics.json index 5e60e55..9200396 100644 --- a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/metrics.json +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/metrics.json @@ -2,44 +2,8 @@ "migrate-mui-menu-popover-to-bui-menu": [ { "cardinality": { - "action": "import-added" - }, - "count": 1 - }, - { - "cardinality": { - "action": "import-removed" - }, - "count": 2 - }, - { - "cardinality": { - "action": "menu-item-migrated" - }, - "count": 2 - }, - { - "cardinality": { - "action": "menu-migrated", - "variant": "menu" - }, - "count": 1 - }, - { - "cardinality": { - "action": "menu-trigger-wrapped" - }, - "count": 1 - }, - { - "cardinality": { - "action": "onClick-to-onAction" - }, - "count": 2 - }, - { - "cardinality": { - "action": "onClose-rewritten" + "action": "todo-inserted", + "reason": "no-trigger-element" }, "count": 1 } diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts b/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts index c708e64..cd8f4dc 100644 --- a/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts @@ -178,10 +178,12 @@ function addBuiImport( migrationMetric.increment({ action: 'import-added' }) return true } - } else if (allImports.length > 0) { - const lastImport = allImports.at(-1) - if (lastImport) { - edits.push(lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + } else if (importNodesToRemove.length > 0) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true } } @@ -338,8 +340,8 @@ function rewriteTabsOnChangeHandler(attr: SgNode): string | null { return `{key => ${replaceIdentifier(bodyText, valueName, 'key')}}` } - const rewrittenBody = replaceIdentifier(replaceIdentifier(bodyText, eventName, 'undefined'), valueName, 'key') - return `{(key) => ${rewrittenBody}}` + // Handler references the event param — cannot safely rewrite without breaking semantics + return null } function findTabListOnChange(element: SgNode, tabListLocalName: string | undefined): SgNode | null { @@ -577,10 +579,18 @@ function transformTabElements( const tabListOnChange = findTabListOnChange(el, tabListLocalName) if (tabListOnChange) { const rewritten = rewriteTabsOnChangeHandler(tabListOnChange) - if (rewritten !== null) { - newProps.push(`onSelectionChange=${rewritten}`) - migrationMetric.increment({ action: 'onChange-rewritten' }) + if (rewritten === null) { + preserveImport = true + edits.push( + el.replace( + `<>{/* TODO(backstage-codemod): verify custom tab orientation or selection logic manually (event-referenced-onChange) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'event-referenced-onChange' }) + continue } + newProps.push(`onSelectionChange=${rewritten}`) + migrationMetric.increment({ action: 'onChange-rewritten' }) } const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' @@ -645,10 +655,18 @@ function transformTabElements( const onChangeAttr = getPropAttr(opening, 'onChange') if (onChangeAttr) { const rewritten = rewriteTabsOnChangeHandler(onChangeAttr) - if (rewritten !== null) { - newTabsProps.push(`onSelectionChange=${rewritten}`) - migrationMetric.increment({ action: 'onChange-rewritten' }) + if (rewritten === null) { + preserveImport = true + edits.push( + el.replace( + `<>{/* TODO(backstage-codemod): verify custom tab orientation or selection logic manually (event-referenced-onChange) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'event-referenced-onChange' }) + continue } + newTabsProps.push(`onSelectionChange=${rewritten}`) + migrationMetric.increment({ action: 'onChange-rewritten' }) } const tabsPropsStr = newTabsProps.length > 0 ? ` ${newTabsProps.join(' ')}` : '' diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/expected.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/expected.tsx index 3b2de7a..af51382 100644 --- a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/expected.tsx +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/expected.tsx @@ -1,7 +1,6 @@ - -import Tab from '@material-ui/core/Tab'; import { Tab, TabList, Tabs } from '@backstage/ui'; + const MyComponent = () => ( handleChange(undefined, key)}>FirstSecond ); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/expected.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/expected.tsx index 375358d..caf832e 100644 --- a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/expected.tsx +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/expected.tsx @@ -1,9 +1,8 @@ - -import TabPanel from '@material-ui/lab/TabPanel'; import { Tab, TabList, TabPanel, Tabs } from '@backstage/ui'; + const MyComponent = () => ( handleChange(undefined, key)}>OverviewDetailsContent AContent B ); From b560540d6994a5dce74ac44206eaec737f08452d Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 11:14:15 -0500 Subject: [PATCH 6/6] fix: address Copilot review feedback on complex component codemods Prune partial MUI barrel imports for tabs, pick the preceding menu trigger sibling, and match dialog footer close buttons regardless of attribute order. Co-authored-by: Cursor --- .../scripts/codemod.ts | 13 ++--- .../scripts/codemod.ts | 5 +- .../scripts/codemod.ts | 52 +++++++++++++++---- 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts b/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts index 41a81df..6cca432 100644 --- a/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts @@ -262,14 +262,15 @@ function transformFooterCloseButtons(content: string, closeHandler: string | nul return content } - const buttonPattern = new RegExp( - `]*)>([\\s\\S]*?)`, - 'g', - ) + const buttonPattern = /]*)>([\s\S]*?)<\/button>/g + const onClickPattern = new RegExp(`onClick=\\{${escapeRegex(closeHandler)}\\}`) - return content.replace(buttonPattern, (_match: string, attrs: string, label: string) => { + return content.replace(buttonPattern, (match: string, attrs: string, label: string) => { + if (!onClickPattern.test(attrs)) { + return match + } migrationMetric.increment({ action: 'footer-close-button-migrated' }) - const extraAttrs = attrs.trim() + const extraAttrs = attrs.replace(onClickPattern, '').trim() const attrStr = extraAttrs.length > 0 ? ` ${extraAttrs}` : '' return `` }) diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts index 7201f37..315204e 100644 --- a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts @@ -302,12 +302,13 @@ function findTriggerSibling(menuEl: SgNode): SgNode | null { return null } + let previousTrigger: SgNode | null = null for (const sibling of getNonWhitespaceChildren(parent)) { if (sibling.id() === menuEl.id()) { - continue + return previousTrigger } if (isTriggerElement(sibling)) { - return sibling + previousTrigger = sibling } } diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts b/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts index cd8f4dc..70b05e1 100644 --- a/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts @@ -67,11 +67,36 @@ function getNamedImportLocalName(imp: SgNode, targetName: string): string | interface TabImports { localNames: Map importNodesToRemove: SgNode[] + barrelImportsToPrune: { imp: SgNode; source: string; migratedNames: string[] }[] +} + +function getImportedName(spec: SgNode): string | null { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + return identifiers[0]?.text() ?? null +} + +function pruneBarrelImport(imp: SgNode, migratedNames: string[], source: string, edits: Edit[]): void { + const specifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + const remainingSpecs = specifiers.filter((spec) => { + const importedName = getImportedName(spec) + return importedName !== null && !migratedNames.includes(importedName) + }) + + if (remainingSpecs.length === 0) { + edits.push(imp.replace('')) + } else { + const specTexts = remainingSpecs.map((spec) => spec.text()).join(', ') + edits.push(imp.replace(`import { ${specTexts} } from '${source}';`)) + } + migrationMetric.increment({ action: 'import-removed' }) } function collectTabImports(rootNode: SgNode): TabImports { const localNames = new Map() const importNodesToRemove: SgNode[] = [] + const barrelImportsToPrune: { imp: SgNode; source: string; migratedNames: string[] }[] = [] const corePaths = ['Tabs', 'Tab'] for (const componentName of corePaths) { @@ -96,40 +121,44 @@ function collectTabImports(rootNode: SgNode): TabImports { } for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { - let foundCount = 0 + const migratedNames: string[] = [] for (const componentName of corePaths) { const localName = getNamedImportLocalName(imp, componentName) if (localName) { localNames.set(localName, componentName) - foundCount++ + migratedNames.push(componentName) } } - if (foundCount > 0) { + if (migratedNames.length > 0) { const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (foundCount >= allSpecifiers.length) { + if (migratedNames.length >= allSpecifiers.length) { importNodesToRemove.push(imp) + } else { + barrelImportsToPrune.push({ imp, source: '@material-ui/core', migratedNames }) } } } for (const imp of findImportStatementsFrom(rootNode, '@material-ui/lab')) { - let foundCount = 0 + const migratedNames: string[] = [] for (const componentName of labPaths) { const localName = getNamedImportLocalName(imp, componentName) if (localName) { localNames.set(localName, componentName) - foundCount++ + migratedNames.push(componentName) } } - if (foundCount > 0) { + if (migratedNames.length > 0) { const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (foundCount >= allSpecifiers.length) { + if (migratedNames.length >= allSpecifiers.length) { importNodesToRemove.push(imp) + } else { + barrelImportsToPrune.push({ imp, source: '@material-ui/lab', migratedNames }) } } } - return { localNames, importNodesToRemove } + return { localNames, importNodesToRemove, barrelImportsToPrune } } function addBuiImport( @@ -711,7 +740,7 @@ const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { localNames, importNodesToRemove } = collectTabImports(rootNode) + const { localNames, importNodesToRemove, barrelImportsToPrune } = collectTabImports(rootNode) if (localNames.size === 0) { return Promise.resolve(null) @@ -725,6 +754,9 @@ const transform: Codemod = (root) => { } if (!preserveImport) { + for (const { imp, source, migratedNames } of barrelImportsToPrune) { + pruneBarrelImport(imp, migratedNames, source, edits) + } for (const imp of importNodesToRemove) { if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { migrationMetric.increment({ action: 'import-removed' })