From 2c0d8718ecdcca1d5eacbee34b5dd2d56fd681c3 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 24 Jun 2026 21:22:30 -0500 Subject: [PATCH 1/6] feat: MUI 4 to BUI migration - form control codemods Add 5 form control codemods: - migrate-mui-select-family-to-bui-select (#122) - migrate-mui-textfield-to-bui-textfield (#123) - migrate-mui-accordion-to-bui-accordion (#124) - migrate-mui-radio-checkbox-groups-to-bui-groups (#125) - migrate-mui-slider-to-bui-slider (#126) Closes #122, #123, #124, #125, #126 --- .changeset/mui-to-bui-form-controls.md | 9 + .../CHANGELOG.md | 1 + .../codemod.yaml | 20 + .../package.json | 13 + .../scripts/codemod.ts | 433 +++++++++++++ .../tests/complex-summary-todo/expected.tsx | 14 + .../tests/complex-summary-todo/input.tsx | 13 + .../tests/complex-summary-todo/metrics.json | 23 + .../tests/controlled-todo/expected.tsx | 11 + .../tests/controlled-todo/input.tsx | 10 + .../tests/controlled-todo/metrics.json | 23 + .../tests/merge-existing-bui/expected.tsx | 11 + .../tests/merge-existing-bui/input.tsx | 14 + .../tests/merge-existing-bui/metrics.json | 34 + .../tests/named-barrel-import/expected.tsx | 5 + .../tests/named-barrel-import/input.tsx | 8 + .../tests/named-barrel-import/metrics.json | 34 + .../tests/noop-no-import/expected.tsx | 8 + .../tests/noop-no-import/input.tsx | 8 + .../tests/simple-accordion/expected.tsx | 7 + .../tests/simple-accordion/input.tsx | 12 + .../tests/simple-accordion/metrics.json | 40 ++ .../tsconfig.json | 16 + .../workflow.yaml | 19 + .../CHANGELOG.md | 1 + .../codemod.yaml | 20 + .../package.json | 13 + .../scripts/codemod.ts | 477 ++++++++++++++ .../complex-form-group-todo/expected.tsx | 11 + .../tests/complex-form-group-todo/input.tsx | 10 + .../complex-form-group-todo/metrics.json | 17 + .../tests/merge-existing-bui/expected.tsx | 11 + .../tests/merge-existing-bui/input.tsx | 14 + .../tests/merge-existing-bui/metrics.json | 28 + .../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 | 7 + .../tests/noop-no-import/input.tsx | 7 + .../tests/simple-checkbox-group/expected.tsx | 7 + .../tests/simple-checkbox-group/input.tsx | 10 + .../tests/simple-checkbox-group/metrics.json | 28 + .../tests/simple-radio-group/expected.tsx | 7 + .../tests/simple-radio-group/input.tsx | 10 + .../tests/simple-radio-group/metrics.json | 28 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + .../CHANGELOG.md | 1 + .../codemod.yaml | 20 + .../package.json | 13 + .../scripts/codemod.ts | 603 ++++++++++++++++++ .../tests/basic-form-control/expected.tsx | 8 + .../tests/basic-form-control/input.tsx | 14 + .../tests/basic-form-control/metrics.json | 28 + .../tests/helper-text-todo/expected.tsx | 17 + .../tests/helper-text-todo/input.tsx | 16 + .../tests/helper-text-todo/metrics.json | 23 + .../tests/merge-existing-bui/expected.tsx | 12 + .../tests/merge-existing-bui/input.tsx | 18 + .../tests/merge-existing-bui/metrics.json | 28 + .../tests/multiple-select-todo/expected.tsx | 15 + .../tests/multiple-select-todo/input.tsx | 14 + .../tests/multiple-select-todo/metrics.json | 23 + .../tests/named-barrel-import/expected.tsx | 5 + .../tests/named-barrel-import/input.tsx | 11 + .../tests/named-barrel-import/metrics.json | 28 + .../tests/noop-no-import/expected.tsx | 5 + .../tests/noop-no-import/input.tsx | 5 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + .../CHANGELOG.md | 1 + .../codemod.yaml | 20 + .../package.json | 13 + .../scripts/codemod.ts | 409 ++++++++++++ .../tests/basic-slider/expected.tsx | 5 + .../tests/basic-slider/input.tsx | 5 + .../tests/basic-slider/metrics.json | 44 ++ .../tests/complex-on-change-todo/expected.tsx | 6 + .../tests/complex-on-change-todo/input.tsx | 5 + .../tests/complex-on-change-todo/metrics.json | 39 ++ .../tests/disabled-and-aria/expected.tsx | 5 + .../tests/disabled-and-aria/input.tsx | 5 + .../tests/disabled-and-aria/metrics.json | 46 ++ .../tests/marks-todo/expected.tsx | 6 + .../tests/marks-todo/input.tsx | 5 + .../tests/marks-todo/metrics.json | 23 + .../tests/merge-existing-bui/expected.tsx | 9 + .../tests/merge-existing-bui/input.tsx | 9 + .../tests/merge-existing-bui/metrics.json | 38 ++ .../tests/named-barrel-import/expected.tsx | 5 + .../tests/named-barrel-import/input.tsx | 5 + .../tests/named-barrel-import/metrics.json | 38 ++ .../tests/no-min-max/expected.tsx | 5 + .../tests/no-min-max/input.tsx | 5 + .../tests/no-min-max/metrics.json | 22 + .../tests/noop-no-import/expected.tsx | 5 + .../tests/noop-no-import/input.tsx | 5 + .../on-change-committed-todo/expected.tsx | 5 + .../tests/on-change-committed-todo/input.tsx | 5 + .../on-change-committed-todo/metrics.json | 44 ++ .../tests/step-and-default-value/expected.tsx | 5 + .../tests/step-and-default-value/input.tsx | 5 + .../tests/step-and-default-value/metrics.json | 38 ++ .../tests/value-label-todo/expected.tsx | 6 + .../tests/value-label-todo/input.tsx | 5 + .../tests/value-label-todo/metrics.json | 23 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + .../CHANGELOG.md | 1 + .../codemod.yaml | 20 + .../package.json | 13 + .../scripts/codemod.ts | 438 +++++++++++++ .../tests/basic-controlled/expected.tsx | 5 + .../tests/basic-controlled/input.tsx | 10 + .../tests/basic-controlled/metrics.json | 36 ++ .../tests/complex-on-change-todo/expected.tsx | 6 + .../tests/complex-on-change-todo/input.tsx | 5 + .../tests/complex-on-change-todo/metrics.json | 11 + .../tests/disabled-and-fullwidth/expected.tsx | 6 + .../tests/disabled-and-fullwidth/input.tsx | 5 + .../tests/disabled-and-fullwidth/metrics.json | 44 ++ .../tests/merge-existing-bui/expected.tsx | 9 + .../tests/merge-existing-bui/input.tsx | 9 + .../tests/merge-existing-bui/metrics.json | 28 + .../tests/multiline-todo/expected.tsx | 6 + .../tests/multiline-todo/input.tsx | 5 + .../tests/multiline-todo/metrics.json | 11 + .../tests/noop-no-import/expected.tsx | 5 + .../tests/noop-no-import/input.tsx | 5 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + yarn.lock | 45 ++ 132 files changed, 4222 insertions(+) create mode 100644 .changeset/mui-to-bui-form-controls.md create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/codemod.yaml create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/package.json create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/input.tsx create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/metrics.json create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/expected.tsx create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/input.tsx create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/metrics.json create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/tsconfig.json create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/workflow.yaml create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/codemod.yaml create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/package.json create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/input.tsx create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/metrics.json create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/expected.tsx create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/input.tsx create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/metrics.json create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/expected.tsx create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/input.tsx create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/metrics.json create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tsconfig.json create mode 100644 codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/workflow.yaml create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/codemod.yaml create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/package.json create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/basic-form-control/expected.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/basic-form-control/input.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/basic-form-control/metrics.json create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/named-barrel-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/named-barrel-import/input.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/named-barrel-import/metrics.json create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tsconfig.json create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/workflow.yaml create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/codemod.yaml create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/package.json create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/expected.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/input.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/metrics.json create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/expected.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/input.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/metrics.json create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/input.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/metrics.json create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/expected.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/input.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/metrics.json create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/expected.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/input.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/metrics.json create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/tsconfig.json create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/workflow.yaml create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/codemod.yaml create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/package.json create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/expected.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/input.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/metrics.json create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/expected.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/input.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/metrics.json create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tsconfig.json create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/workflow.yaml diff --git a/.changeset/mui-to-bui-form-controls.md b/.changeset/mui-to-bui-form-controls.md new file mode 100644 index 0000000..6f9a590 --- /dev/null +++ b/.changeset/mui-to-bui-form-controls.md @@ -0,0 +1,9 @@ +--- +'@backstage/migrate-mui-select-family-to-bui-select': minor +'@backstage/migrate-mui-textfield-to-bui-textfield': minor +'@backstage/migrate-mui-accordion-to-bui-accordion': minor +'@backstage/migrate-mui-radio-checkbox-groups-to-bui-groups': minor +'@backstage/migrate-mui-slider-to-bui-slider': minor +--- + +Add form control codemods for the MUI 4 to BUI migration: Select, TextField, Accordion, radio/checkbox groups, and Slider. diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/CHANGELOG.md b/codemods/misc/migrate-mui-accordion-to-bui-accordion/CHANGELOG.md new file mode 100644 index 0000000..2c35592 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-accordion-to-bui-accordion diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/codemod.yaml b/codemods/misc/migrate-mui-accordion-to-bui-accordion/codemod.yaml new file mode 100644 index 0000000..64501f8 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-accordion-to-bui-accordion' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Accordion with BUI Accordion' +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', 'accordion', 'bui', 'accordion'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/package.json b/codemods/misc/migrate-mui-accordion-to-bui-accordion/package.json new file mode 100644 index 0000000..23aa92c --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-accordion-to-bui-accordion", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Accordion with BUI Accordion", + "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-accordion-to-bui-accordion/scripts/codemod.ts b/codemods/misc/migrate-mui-accordion-to-bui-accordion/scripts/codemod.ts new file mode 100644 index 0000000..1a94331 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/scripts/codemod.ts @@ -0,0 +1,433 @@ +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-accordion-to-bui-accordion') + +const BUI_SOURCE = '@backstage/ui' + +const MUI_ACCORDION_COMPONENTS = ['Accordion', 'AccordionSummary', 'AccordionDetails', 'AccordionActions'] + +/** Props on Accordion that trigger a TODO — controlled state or complex behavior. */ +const ACCORDION_TODO_PROPS = new Set([ + 'expanded', + 'onChange', + 'defaultExpanded', + 'TransitionComponent', + 'TransitionProps', + 'classes', + 'square', +]) + +/** Props on AccordionSummary that trigger a TODO. */ +const SUMMARY_TODO_PROPS = new Set(['classes', 'IconButtonProps']) + +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 AccordionImports { + localNames: Map + importNodesToRemove: SgNode[] +} + +function collectAccordionImports(rootNode: SgNode): AccordionImports { + const localNames = new Map() + const importNodesToRemove: SgNode[] = [] + + for (const componentName of MUI_ACCORDION_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_ACCORDION_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 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 +} + +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 +} + +/** + * Extract the plain-text title from an AccordionSummary element. + * Returns the title string if simple text, or null if complex content. + */ +function extractSummaryTitle(summaryElement: SgNode): { title: string | null; isComplex: boolean } { + const children = getNonWhitespaceChildren(summaryElement) + + if (children.length === 0) { + return { title: null, isComplex: false } + } + + // Single text node + if (children.length === 1 && children[0]!.kind() === 'jsx_text') { + const text = children[0]!.text().trim() + return { title: text.length > 0 ? text : null, isComplex: false } + } + + // Multiple text-only nodes (whitespace-separated) + if (children.every((c) => c.kind() === 'jsx_text')) { + const text = children + .map((c) => c.text().trim()) + .filter(Boolean) + .join(' ') + return { title: text.length > 0 ? text : null, isComplex: false } + } + + // Single Typography element wrapping text + if (children.length === 1) { + const child = children[0]! + if (child.kind() === 'jsx_element') { + const innerChildren = getNonWhitespaceChildren(child) + if (innerChildren.length === 1 && innerChildren[0]!.kind() === 'jsx_text') { + const text = innerChildren[0]!.text().trim() + if (text.length > 0) { + return { title: text, isComplex: false } + } + } + } + } + + // Anything else is complex + return { title: null, isComplex: true } +} + +function transformAccordionChildren(accordionElement: SgNode, localNames: Map): string | null { + const children = getJsxChildren(accordionElement) + const parts: string[] = [] + + const summaryLocalName = [...localNames.entries()].find(([, v]) => v === 'AccordionSummary')?.[0] ?? null + const detailsLocalName = [...localNames.entries()].find(([, v]) => v === 'AccordionDetails')?.[0] ?? null + const actionsLocalName = [...localNames.entries()].find(([, v]) => v === 'AccordionActions')?.[0] ?? null + + 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) { + parts.push(child.text()) + continue + } + + const childName = getElementName(childOpening) + + // AccordionSummary → AccordionTrigger + if (childName && childName === summaryLocalName) { + // Check for TODO-triggering summary props + let summaryNeedsTodo = false + for (const prop of SUMMARY_TODO_PROPS) { + if (hasProp(childOpening, prop)) { + summaryNeedsTodo = true + break + } + } + + if (summaryNeedsTodo) { + return null // signal parent to TODO the whole accordion + } + + const { title, isComplex } = extractSummaryTitle(child) + + if (isComplex || !title) { + return null // signal parent to TODO the whole accordion + } + + // Drop expandIcon — BUI handles its own icon + if (hasProp(childOpening, 'expandIcon')) { + migrationMetric.increment({ action: 'expandIcon-dropped' }) + } + + parts.push(``) + migrationMetric.increment({ action: 'summary-migrated' }) + continue + } + + // AccordionDetails → AccordionPanel + if (childName && childName === detailsLocalName) { + const innerContent = getChildContent(child) + parts.push(`${innerContent}`) + migrationMetric.increment({ action: 'details-migrated' }) + continue + } + + // AccordionActions → TODO (no BUI equivalent) + if (childName && childName === actionsLocalName) { + return null // signal parent to TODO the whole accordion + } + } + + // Preserve anything else as-is + parts.push(child.text()) + } + + return parts.join('') +} + +function transformAccordionElements(rootNode: SgNode, localNames: Map, edits: Edit[]): void { + const accordionLocalName = [...localNames.entries()].find(([, v]) => v === 'Accordion')?.[0] + if (!accordionLocalName) { + 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 !== accordionLocalName) { + continue + } + + // Check for TODO-triggering props on Accordion + let needsTodo = false + const todoReasons: string[] = [] + for (const prop of ACCORDION_TODO_PROPS) { + if (hasProp(opening, prop)) { + needsTodo = true + todoReasons.push(prop) + } + } + + if (needsTodo) { + edits.push( + el.replace( + `{/* TODO(backstage-codemod): finish accordion 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: 'accordion-migrated' }) + continue + } + + // Transform children + const transformedChildren = transformAccordionChildren(el, localNames) + + if (transformedChildren === null) { + // Complex content — TODO the whole thing + edits.push( + el.replace( + `{/* TODO(backstage-codemod): finish accordion migration manually (complex-summary) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-summary' }) + continue + } + + edits.push(el.replace(`${transformedChildren}`)) + migrationMetric.increment({ action: 'accordion-migrated' }) + } +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { localNames, importNodesToRemove } = collectAccordionImports(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() + buiNames.add('Accordion') + for (const [, muiName] of localNames) { + if (muiName === 'AccordionSummary') { + buiNames.add('AccordionTrigger') + } + if (muiName === 'AccordionDetails') { + buiNames.add('AccordionPanel') + } + } + + addBuiImport(rootNode, [...buiNames], edits) + + // Transform elements + transformAccordionElements(rootNode, localNames, edits) + + return edits.length > 0 ? rootNode.commitEdits(edits) : null +} + +export default transform diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/expected.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/expected.tsx new file mode 100644 index 0000000..34498b7 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/expected.tsx @@ -0,0 +1,14 @@ + + + + +const MyComponent = () => ( + {/* TODO(backstage-codemod): finish accordion migration manually (complex-summary) */} + + + Title + Subtitle + + Body + +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/input.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/input.tsx new file mode 100644 index 0000000..3edd45c --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/input.tsx @@ -0,0 +1,13 @@ +import Accordion from '@material-ui/core/Accordion'; +import AccordionSummary from '@material-ui/core/AccordionSummary'; +import AccordionDetails from '@material-ui/core/AccordionDetails'; + +const MyComponent = () => ( + + + Title + Subtitle + + Body + +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/metrics.json b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/metrics.json new file mode 100644 index 0000000..0bef907 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-accordion-to-bui-accordion": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "complex-summary" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/expected.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/expected.tsx new file mode 100644 index 0000000..07b7e9c --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/expected.tsx @@ -0,0 +1,11 @@ + + + + +const MyComponent = ({ expanded, onChange }: any) => ( + {/* TODO(backstage-codemod): finish accordion migration manually (expanded, onChange) */} + + Settings + Content + +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/input.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/input.tsx new file mode 100644 index 0000000..4479e6b --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/input.tsx @@ -0,0 +1,10 @@ +import Accordion from '@material-ui/core/Accordion'; +import AccordionSummary from '@material-ui/core/AccordionSummary'; +import AccordionDetails from '@material-ui/core/AccordionDetails'; + +const MyComponent = ({ expanded, onChange }: any) => ( + + Settings + Content + +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/metrics.json b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/metrics.json new file mode 100644 index 0000000..1fd51b8 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-accordion-to-bui-accordion": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "expanded, onChange" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..29478d5 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,11 @@ + + + +import { Accordion, AccordionPanel, AccordionTrigger, Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + Details + +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..49e4020 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/input.tsx @@ -0,0 +1,14 @@ +import Accordion from '@material-ui/core/Accordion'; +import AccordionSummary from '@material-ui/core/AccordionSummary'; +import AccordionDetails from '@material-ui/core/AccordionDetails'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + Info + Details + + +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..8da74ad --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/metrics.json @@ -0,0 +1,34 @@ +{ + "migrate-mui-accordion-to-bui-accordion": [ + { + "cardinality": { + "action": "accordion-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "details-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "summary-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..31649ca --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + Answers here +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..a759db7 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/input.tsx @@ -0,0 +1,8 @@ +import { Accordion, AccordionSummary, AccordionDetails } from '@material-ui/core'; + +const MyComponent = () => ( + + FAQ + Answers here + +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..665a47d --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/metrics.json @@ -0,0 +1,34 @@ +{ + "migrate-mui-accordion-to-bui-accordion": [ + { + "cardinality": { + "action": "accordion-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "details-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "summary-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..04e934e --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/noop-no-import/expected.tsx @@ -0,0 +1,8 @@ +import { Accordion, AccordionTrigger, AccordionPanel } from '@backstage/ui'; + +const MyComponent = () => ( + + + Content + +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..04e934e --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/noop-no-import/input.tsx @@ -0,0 +1,8 @@ +import { Accordion, AccordionTrigger, AccordionPanel } from '@backstage/ui'; + +const MyComponent = () => ( + + + Content + +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/expected.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/expected.tsx new file mode 100644 index 0000000..ef16d06 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/expected.tsx @@ -0,0 +1,7 @@ + + + + +const MyComponent = () => ( + Body content here +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/input.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/input.tsx new file mode 100644 index 0000000..d7a669c --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/input.tsx @@ -0,0 +1,12 @@ +import Accordion from '@material-ui/core/Accordion'; +import AccordionSummary from '@material-ui/core/AccordionSummary'; +import AccordionDetails from '@material-ui/core/AccordionDetails'; + +const MyComponent = () => ( + + }> + Section title + + Body content here + +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/metrics.json b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/metrics.json new file mode 100644 index 0000000..903d2b4 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/metrics.json @@ -0,0 +1,40 @@ +{ + "migrate-mui-accordion-to-bui-accordion": [ + { + "cardinality": { + "action": "accordion-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "details-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "expandIcon-dropped" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "summary-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tsconfig.json b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/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-accordion-to-bui-accordion/workflow.yaml b/codemods/misc/migrate-mui-accordion-to-bui-accordion/workflow.yaml new file mode 100644 index 0000000..59c1712 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/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 Accordion with BUI Accordion' + 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-radio-checkbox-groups-to-bui-groups/CHANGELOG.md b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/CHANGELOG.md new file mode 100644 index 0000000..b47c510 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-radio-checkbox-groups-to-bui-groups diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/codemod.yaml b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/codemod.yaml new file mode 100644 index 0000000..c7b26c2 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-radio-checkbox-groups-to-bui-groups' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace radio and checkbox group patterns with BUI groups' +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', 'radio', 'checkbox', 'groups', 'bui'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/package.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/package.json new file mode 100644 index 0000000..ab49424 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-radio-checkbox-groups-to-bui-groups", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace radio and checkbox group patterns with BUI groups", + "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-radio-checkbox-groups-to-bui-groups/scripts/codemod.ts b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/scripts/codemod.ts new file mode 100644 index 0000000..16dbc6b --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/scripts/codemod.ts @@ -0,0 +1,477 @@ +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-radio-checkbox-groups-to-bui-groups') + +const BUI_SOURCE = '@backstage/ui' + +const MUI_COMPONENTS = ['RadioGroup', 'Radio', 'Checkbox', 'FormControlLabel', 'FormGroup', 'FormControl', 'FormLabel'] + +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 GroupImports { + localNames: Map + importNodesToRemove: SgNode[] +} + +function collectGroupImports(rootNode: SgNode): GroupImports { + const localNames = new Map() + const importNodesToRemove: SgNode[] = [] + + for (const componentName of MUI_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_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 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 +} + +function detectControlType(fclOpening: SgNode, localNames: Map): 'Radio' | 'Checkbox' | null { + const controlAttr = getPropAttr(fclOpening, 'control') + if (!controlAttr) { + return null + } + const expr = controlAttr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return null + } + const selfClosing = expr.find({ rule: { kind: 'jsx_self_closing_element' } }) + if (!selfClosing) { + return null + } + const controlName = getElementName(selfClosing) + if (!controlName) { + return null + } + const muiName = localNames.get(controlName) + if (muiName === 'Radio') { + return 'Radio' + } + if (muiName === 'Checkbox') { + return 'Checkbox' + } + return null +} + +function extractControlProps(fclOpening: SgNode): SgNode | null { + const controlAttr = getPropAttr(fclOpening, 'control') + if (!controlAttr) { + return null + } + const expr = controlAttr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return null + } + return expr.find({ rule: { kind: 'jsx_self_closing_element' } }) +} + +function transformRadioGroupChildren(groupElement: SgNode, localNames: Map): string | null { + const fclLocalName = [...localNames.entries()].find(([, v]) => v === 'FormControlLabel')?.[0] ?? null + const children = getJsxChildren(groupElement) + 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' && fclLocalName) { + const childName = getElementName(child) + if (childName === fclLocalName) { + const controlType = detectControlType(child, localNames) + if (controlType !== 'Radio') { + return null + } + const label = getPropStringValue(child, 'label') + if (!label) { + return null + } + const valueStr = getPropStringValue(child, 'value') + const valueRaw = getPropRawValue(child, 'value') + const props: string[] = [] + if (valueStr !== null) { + props.push(`value="${valueStr}"`) + } else if (valueRaw !== null) { + props.push(`value=${valueRaw}`) + } + if (hasProp(child, 'disabled')) { + props.push('isDisabled') + } + const propsStr = props.length > 0 ? ` ${props.join(' ')}` : '' + parts.push(`${label}`) + migrationMetric.increment({ action: 'radio-option-migrated' }) + continue + } + } + parts.push(child.text()) + } + + return parts.join('') +} + +function transformCheckboxGroupChildren(groupElement: SgNode, localNames: Map): string | null { + const fclLocalName = [...localNames.entries()].find(([, v]) => v === 'FormControlLabel')?.[0] ?? null + const children = getJsxChildren(groupElement) + 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' && fclLocalName) { + const childName = getElementName(child) + if (childName === fclLocalName) { + const controlType = detectControlType(child, localNames) + if (controlType !== 'Checkbox') { + return null + } + const label = getPropStringValue(child, 'label') + if (!label) { + return null + } + const controlEl = extractControlProps(child) + if (!controlEl) { + return null + } + const props: string[] = [] + const checkedRaw = getPropRawValue(controlEl, 'checked') + if (checkedRaw !== null) { + props.push(`isSelected=${checkedRaw}`) + } + const onChangeRaw = getPropRawValue(controlEl, 'onChange') + if (onChangeRaw !== null) { + props.push(`onChange=${onChangeRaw}`) + } + const nameRaw = getPropRawValue(controlEl, 'name') + if (nameRaw !== null) { + props.push(`name=${nameRaw}`) + } + if (hasProp(child, 'disabled') || hasProp(controlEl, 'disabled')) { + props.push('isDisabled') + } + const propsStr = props.length > 0 ? ` ${props.join(' ')}` : '' + parts.push(`${label}`) + migrationMetric.increment({ action: 'checkbox-option-migrated' }) + continue + } + } + parts.push(child.text()) + } + + return parts.join('') +} + +function isCheckboxFormGroup(element: SgNode, localNames: Map): boolean { + const fclLocalName = [...localNames.entries()].find(([, v]) => v === 'FormControlLabel')?.[0] ?? null + if (!fclLocalName) { + return false + } + const meaningfulChildren = getNonWhitespaceChildren(element) + if (meaningfulChildren.length === 0) { + return false + } + let hasCheckbox = false + for (const child of meaningfulChildren) { + if (!child.is('jsx_self_closing_element')) { + return false + } + const childName = getElementName(child) + if (childName !== fclLocalName) { + return false + } + const controlType = detectControlType(child, localNames) + if (controlType !== 'Checkbox') { + return false + } + hasCheckbox = true + } + return hasCheckbox +} + +function transformGroupElements(rootNode: SgNode, localNames: Map, edits: Edit[]): Set { + const usedBuiNames = new Set() + const radioGroupLocal = [...localNames.entries()].find(([, v]) => v === 'RadioGroup')?.[0] ?? null + const formGroupLocal = [...localNames.entries()].find(([, v]) => v === 'FormGroup')?.[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 + } + + if (name === radioGroupLocal && !isSelfClosing) { + const transformedChildren = transformRadioGroupChildren(el, localNames) + if (transformedChildren === null) { + edits.push(el.replace(`{/* TODO(backstage-codemod): finish choice-group migration manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-radio-group' }) + continue + } + const newProps: string[] = [] + const valueRaw = getPropRawValue(opening, 'value') + if (valueRaw !== null) { + newProps.push(`value=${valueRaw}`) + } + const onChangeRaw = getPropRawValue(opening, 'onChange') + if (onChangeRaw !== null) { + newProps.push(`onChange=${onChangeRaw}`) + } + const nameRaw = getPropRawValue(opening, 'name') + if (nameRaw !== null) { + newProps.push(`name=${nameRaw}`) + } + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + edits.push(el.replace(`${transformedChildren}`)) + usedBuiNames.add('RadioGroup') + usedBuiNames.add('Radio') + migrationMetric.increment({ action: 'radio-group-migrated' }) + continue + } + + if (name === formGroupLocal && !isSelfClosing) { + if (!isCheckboxFormGroup(el, localNames)) { + edits.push(el.replace(`{/* TODO(backstage-codemod): finish choice-group migration manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-form-group' }) + continue + } + const transformedChildren = transformCheckboxGroupChildren(el, localNames) + if (transformedChildren === null) { + edits.push(el.replace(`{/* TODO(backstage-codemod): finish choice-group migration manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-checkbox-group' }) + continue + } + edits.push(el.replace(`${transformedChildren}`)) + usedBuiNames.add('CheckboxGroup') + usedBuiNames.add('Checkbox') + migrationMetric.increment({ action: 'checkbox-group-migrated' }) + continue + } + } + + return usedBuiNames +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { localNames, importNodesToRemove } = collectGroupImports(rootNode) + + const hasTarget = [...localNames.values()].some( + (v) => v === 'RadioGroup' || v === 'FormGroup' || v === 'FormControlLabel', + ) + if (!hasTarget) { + return null + } + + for (const imp of importNodesToRemove) { + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + + const usedBuiNames = transformGroupElements(rootNode, localNames, edits) + + if (usedBuiNames.size > 0) { + addBuiImport(rootNode, [...usedBuiNames], edits) + } + + return edits.length > 0 ? rootNode.commitEdits(edits) : null +} + +export default transform diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/expected.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/expected.tsx new file mode 100644 index 0000000..b2e6436 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/expected.tsx @@ -0,0 +1,11 @@ + + + + +const MyComponent = () => ( + {/* TODO(backstage-codemod): finish choice-group migration manually */} + + } label="A" /> + Custom separator + +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/input.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/input.tsx new file mode 100644 index 0000000..3cd3401 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/input.tsx @@ -0,0 +1,10 @@ +import FormGroup from '@material-ui/core/FormGroup'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Checkbox from '@material-ui/core/Checkbox'; + +const MyComponent = () => ( + + } label="A" /> + Custom separator + +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/metrics.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/metrics.json new file mode 100644 index 0000000..d2b86e2 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/metrics.json @@ -0,0 +1,17 @@ +{ + "migrate-mui-radio-checkbox-groups-to-bui-groups": [ + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "complex-form-group" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..068e39a --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,11 @@ + + + +import { Button, Radio, RadioGroup } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + OnOff + +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..9eb8a03 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/input.tsx @@ -0,0 +1,14 @@ +import RadioGroup from '@material-ui/core/RadioGroup'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Radio from '@material-ui/core/Radio'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + } label="On" /> + } label="Off" /> + + +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..22acef3 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-radio-checkbox-groups-to-bui-groups": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "radio-group-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "radio-option-migrated" + }, + "count": 2 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..83adec6 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + XY +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..8b24474 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/input.tsx @@ -0,0 +1,8 @@ +import { RadioGroup, Radio, FormControlLabel } from '@material-ui/core'; + +const MyComponent = () => ( + + } label="X" /> + } label="Y" /> + +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..29f54dc --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-radio-checkbox-groups-to-bui-groups": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "radio-group-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "radio-option-migrated" + }, + "count": 2 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..96ee1e6 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/noop-no-import/expected.tsx @@ -0,0 +1,7 @@ +import { RadioGroup, Radio } from '@backstage/ui'; + +const MyComponent = () => ( + + Already migrated + +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..96ee1e6 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/noop-no-import/input.tsx @@ -0,0 +1,7 @@ +import { RadioGroup, Radio } from '@backstage/ui'; + +const MyComponent = () => ( + + Already migrated + +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/expected.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/expected.tsx new file mode 100644 index 0000000..3b1cb39 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/expected.tsx @@ -0,0 +1,7 @@ + + + + +const MyComponent = () => ( + EnabledVisible +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/input.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/input.tsx new file mode 100644 index 0000000..aadeebe --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/input.tsx @@ -0,0 +1,10 @@ +import FormGroup from '@material-ui/core/FormGroup'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Checkbox from '@material-ui/core/Checkbox'; + +const MyComponent = () => ( + + } label="Enabled" /> + } label="Visible" /> + +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/metrics.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/metrics.json new file mode 100644 index 0000000..73c6a62 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-radio-checkbox-groups-to-bui-groups": [ + { + "cardinality": { + "action": "checkbox-group-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "checkbox-option-migrated" + }, + "count": 2 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/expected.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/expected.tsx new file mode 100644 index 0000000..2c1cd8e --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/expected.tsx @@ -0,0 +1,7 @@ + + + + +const MyComponent = () => ( + Option AOption B +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/input.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/input.tsx new file mode 100644 index 0000000..87f0b0c --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/input.tsx @@ -0,0 +1,10 @@ +import RadioGroup from '@material-ui/core/RadioGroup'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Radio from '@material-ui/core/Radio'; + +const MyComponent = () => ( + + } label="Option A" /> + } label="Option B" /> + +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/metrics.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/metrics.json new file mode 100644 index 0000000..a0b9bbf --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-radio-checkbox-groups-to-bui-groups": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "radio-group-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "radio-option-migrated" + }, + "count": 2 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tsconfig.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/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-radio-checkbox-groups-to-bui-groups/workflow.yaml b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/workflow.yaml new file mode 100644 index 0000000..50ded5d --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/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 radio and checkbox group patterns with BUI groups' + 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-select-family-to-bui-select/CHANGELOG.md b/codemods/misc/migrate-mui-select-family-to-bui-select/CHANGELOG.md new file mode 100644 index 0000000..0168303 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-select-family-to-bui-select diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/codemod.yaml b/codemods/misc/migrate-mui-select-family-to-bui-select/codemod.yaml new file mode 100644 index 0000000..cda5560 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-select-family-to-bui-select' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Select wrapper patterns with BUI Select' +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', 'select', 'family', 'bui', 'select'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/package.json b/codemods/misc/migrate-mui-select-family-to-bui-select/package.json new file mode 100644 index 0000000..9aa58d4 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-select-family-to-bui-select", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Select wrapper patterns with BUI Select", + "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-select-family-to-bui-select/scripts/codemod.ts b/codemods/misc/migrate-mui-select-family-to-bui-select/scripts/codemod.ts new file mode 100644 index 0000000..518c5e4 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/scripts/codemod.ts @@ -0,0 +1,603 @@ +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-select-family-to-bui-select') + +const BUI_SOURCE = '@backstage/ui' + +const MUI_SELECT_COMPONENTS = ['FormControl', 'InputLabel', 'Select', 'MenuItem', 'FormHelperText'] + +/** Props on Select that trigger a TODO. */ +const TODO_PROPS = new Set([ + 'multiple', + 'native', + 'renderValue', + 'displayEmpty', + 'autoWidth', + 'MenuProps', + 'input', + 'inputProps', + 'variant', + 'classes', + 'IconComponent', +]) + +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 SelectImports { + localNames: Map + importNodesToRemove: SgNode[] +} + +function collectSelectImports(rootNode: SgNode): SelectImports { + const localNames = new Map() + const importNodesToRemove: SgNode[] = [] + + for (const componentName of MUI_SELECT_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_SELECT_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 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 +} + +function getTextContent(element: SgNode): string | null { + const parts: string[] = [] + for (const child of element.children()) { + if (child.kind() === 'jsx_opening_element' || child.kind() === 'jsx_closing_element') { + continue + } + if (child.kind() === 'jsx_text') { + const trimmed = child.text().trim() + if (trimmed.length > 0) { + parts.push(trimmed) + } + } else { + return null + } + } + return parts.length > 0 ? parts.join(' ') : null +} + +interface OptionInfo { + id: string + label: string +} + +function extractMenuItemOptions(selectElement: SgNode, menuItemLocalName: string): OptionInfo[] | null { + const options: OptionInfo[] = [] + const children = getNonWhitespaceChildren(selectElement) + + for (const child of children) { + const kind = child.kind() + + if (kind === 'jsx_element') { + const childOpening = child.child(0) + if (!childOpening) { + return null + } + const childName = getElementName(childOpening) + if (childName !== menuItemLocalName) { + return null + } + + const valueStr = getPropStringValue(childOpening, 'value') + if (!valueStr) { + return null + } + + const label = getTextContent(child) + if (!label) { + return null + } + + options.push({ id: valueStr, label }) + continue + } + + if (kind === 'jsx_self_closing_element') { + const childName = getElementName(child) + if (childName !== menuItemLocalName) { + return null + } + const valueStr = getPropStringValue(child, 'value') + if (!valueStr) { + return null + } + options.push({ id: valueStr, label: valueStr }) + continue + } + + if (kind !== 'jsx_text') { + return null + } + } + + return options.length > 0 ? options : null +} + +function getArrowSingleParamName(arrow: SgNode): string | null { + const paramNode = arrow.field('parameter') + if (paramNode?.is('identifier')) { + return paramNode.text() + } + + const params = arrow.field('parameters') + if (!params) { + return null + } + + if (params.is('identifier')) { + return params.text() + } + + const paramNames: string[] = [] + for (const child of params.children()) { + if (child.is('identifier')) { + paramNames.push(child.text()) + } else if (child.is('required_parameter')) { + const ident = child.find({ rule: { kind: 'identifier' } }) + if (ident) { + paramNames.push(ident.text()) + } + } + } + + if (paramNames.length !== 1) { + return null + } + + return paramNames[0] ?? null +} + +function targetValuePattern(eventName: string): RegExp { + return new RegExp(`${escapeRegex(eventName)}\\.target\\.value`, 'g') +} + +function tryRewriteOnChangeHandler(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) { + return null + } + + const eventName = getArrowSingleParamName(arrow) + if (!eventName) { + return null + } + + const body = arrow.field('body') + if (!body) { + return null + } + + const bodyText = body.text() + const pattern = targetValuePattern(eventName) + if (!pattern.test(bodyText)) { + return null + } + + const rewrittenBody = bodyText.replace(targetValuePattern(eventName), 'key') + return `{key => ${rewrittenBody}}` +} + +function escapeSingleQuotes(value: string): string { + return value.replaceAll('\\', '\\\\').replaceAll("'", "\\'") +} + +function formatOption(option: OptionInfo): string { + return `{ id: '${escapeSingleQuotes(option.id)}', label: '${escapeSingleQuotes(option.label)}' }` +} + +function findSelectInFormControl( + formControlElement: SgNode, + localNames: Map, +): { + label: string | null + selectEl: SgNode | null + selectOpening: SgNode | null + hasHelperText: boolean +} { + const result = { + label: null as string | null, + selectEl: null as SgNode | null, + selectOpening: null as SgNode | null, + hasHelperText: false, + } + + const inputLabelLocal = [...localNames.entries()].find(([, v]) => v === 'InputLabel')?.[0] ?? null + const selectLocal = [...localNames.entries()].find(([, v]) => v === 'Select')?.[0] ?? null + const helperTextLocal = [...localNames.entries()].find(([, v]) => v === 'FormHelperText')?.[0] ?? null + + const children = getNonWhitespaceChildren(formControlElement) + + for (const child of children) { + const kind = child.kind() + + if (kind === 'jsx_element' || kind === 'jsx_self_closing_element') { + const childOpening = kind === 'jsx_self_closing_element' ? child : child.child(0) + if (!childOpening) { + continue + } + const childName = getElementName(childOpening) + + if (childName && childName === inputLabelLocal) { + if (kind === 'jsx_element') { + result.label = getTextContent(child) + } + continue + } + + if (childName && childName === selectLocal) { + result.selectEl = child + result.selectOpening = childOpening + continue + } + + if (childName && childName === helperTextLocal) { + result.hasHelperText = true + continue + } + } + } + + return result +} + +function transformSelectPatterns(rootNode: SgNode, localNames: Map, edits: Edit[]): void { + const formControlLocal = [...localNames.entries()].find(([, v]) => v === 'FormControl')?.[0] ?? null + const selectLocal = [...localNames.entries()].find(([, v]) => v === 'Select')?.[0] ?? null + const menuItemLocal = [...localNames.entries()].find(([, v]) => v === 'MenuItem')?.[0] ?? null + + if (!selectLocal) { + return + } + + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + const processedSelectIds = new Set() + + 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 + } + + if (name === formControlLocal && !isSelfClosing) { + const { label, selectEl, selectOpening, hasHelperText } = findSelectInFormControl(el, localNames) + + if (!selectEl || !selectOpening) { + continue + } + + processedSelectIds.add(selectEl.id()) + + if (hasHelperText) { + edits.push(el.replace(`{/* TODO(backstage-codemod): finish Select migration manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'helper-text' }) + continue + } + + let needsTodo = false + for (const prop of TODO_PROPS) { + if (hasProp(selectOpening, prop)) { + needsTodo = true + break + } + } + + if (needsTodo) { + edits.push(el.replace(`{/* TODO(backstage-codemod): finish Select migration manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-select-props' }) + continue + } + + let options: OptionInfo[] | null = null + if (menuItemLocal && !selectEl.is('jsx_self_closing_element')) { + options = extractMenuItemOptions(selectEl, menuItemLocal) + } + + if (!options) { + edits.push(el.replace(`{/* TODO(backstage-codemod): finish Select migration manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-options' }) + continue + } + + const newProps: string[] = [] + + if (label) { + newProps.push(`label="${label}"`) + } + + const valueRaw = getPropRawValue(selectOpening, 'value') + if (valueRaw) { + newProps.push(`selectedKey=${valueRaw}`) + } + + const onChangeAttr = getPropAttr(selectOpening, 'onChange') + if (onChangeAttr) { + const rewritten = tryRewriteOnChangeHandler(onChangeAttr) + if (rewritten) { + newProps.push(`onSelectionChange=${rewritten}`) + migrationMetric.increment({ action: 'onChange-rewritten' }) + } else { + edits.push(el.replace(`{/* TODO(backstage-codemod): finish Select migration manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-onChange' }) + continue + } + } + + const optionsStr = options.map(formatOption).join(', ') + newProps.push(`options={[${optionsStr}]}`) + + const propsStr = newProps.join(' ') + edits.push(el.replace(``)) + migrationMetric.increment({ action: 'select-migrated' }) + continue + } + } +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { localNames, importNodesToRemove } = collectSelectImports(rootNode) + + const hasSelect = [...localNames.values()].includes('Select') + if (!hasSelect) { + return null + } + + for (const imp of importNodesToRemove) { + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + + addBuiImport(rootNode, ['Select'], edits) + transformSelectPatterns(rootNode, localNames, edits) + + return edits.length > 0 ? rootNode.commitEdits(edits) : null +} + +export default transform diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/basic-form-control/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/basic-form-control/expected.tsx new file mode 100644 index 0000000..2507f82 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/basic-form-control/expected.tsx @@ -0,0 +1,8 @@ + + + + + +const MyComponent = () => ( + setValue(e.target.value as string)}> + React + Angular + + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/basic-form-control/metrics.json b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/basic-form-control/metrics.json new file mode 100644 index 0000000..5fb7f56 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/basic-form-control/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-select-family-to-bui-select": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 4 + }, + { + "cardinality": { + "action": "onChange-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "select-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/expected.tsx new file mode 100644 index 0000000..8cc7edd --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/expected.tsx @@ -0,0 +1,17 @@ + + + + + + +const MyComponent = () => ( + {/* TODO(backstage-codemod): finish Select migration manually */} + + Color + + Pick a color + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/input.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/input.tsx new file mode 100644 index 0000000..d3bc43b --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/input.tsx @@ -0,0 +1,16 @@ +import FormControl from '@material-ui/core/FormControl'; +import InputLabel from '@material-ui/core/InputLabel'; +import Select from '@material-ui/core/Select'; +import MenuItem from '@material-ui/core/MenuItem'; +import FormHelperText from '@material-ui/core/FormHelperText'; + +const MyComponent = () => ( + + Color + + Pick a color + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/metrics.json b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/metrics.json new file mode 100644 index 0000000..94b0c11 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-select-family-to-bui-select": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 5 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "helper-text" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..61661c6 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,12 @@ + + + + +import { Button, Select } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + setMode(e.target.value as string)}> + Auto + Manual + + + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..0798bd8 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/merge-existing-bui/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-select-family-to-bui-select": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 4 + }, + { + "cardinality": { + "action": "onChange-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "select-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/expected.tsx new file mode 100644 index 0000000..57a1877 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/expected.tsx @@ -0,0 +1,15 @@ + + + + + +const MyComponent = () => ( + {/* TODO(backstage-codemod): finish Select migration manually */} + + Tags + + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/input.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/input.tsx new file mode 100644 index 0000000..7196bfb --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/input.tsx @@ -0,0 +1,14 @@ +import FormControl from '@material-ui/core/FormControl'; +import InputLabel from '@material-ui/core/InputLabel'; +import Select from '@material-ui/core/Select'; +import MenuItem from '@material-ui/core/MenuItem'; + +const MyComponent = () => ( + + Tags + + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/metrics.json b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/metrics.json new file mode 100644 index 0000000..1f776fa --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-select-family-to-bui-select": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 4 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "complex-select-props" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..4d1271e --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/named-barrel-import/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + setSize(e.target.value as string)}> + Small + Large + + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..3a52d24 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/named-barrel-import/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-select-family-to-bui-select": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onChange-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "select-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..68102c6 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import { Select } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tsconfig.json b/codemods/misc/migrate-mui-select-family-to-bui-select/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/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-select-family-to-bui-select/workflow.yaml b/codemods/misc/migrate-mui-select-family-to-bui-select/workflow.yaml new file mode 100644 index 0000000..edcbfcb --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/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 Select wrapper patterns with BUI Select' + 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-slider-to-bui-slider/CHANGELOG.md b/codemods/misc/migrate-mui-slider-to-bui-slider/CHANGELOG.md new file mode 100644 index 0000000..c602f6d --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-slider-to-bui-slider diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/codemod.yaml b/codemods/misc/migrate-mui-slider-to-bui-slider/codemod.yaml new file mode 100644 index 0000000..ba00f64 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-slider-to-bui-slider' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Slider with BUI Slider' +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', 'slider', 'bui', 'slider'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/package.json b/codemods/misc/migrate-mui-slider-to-bui-slider/package.json new file mode 100644 index 0000000..2bed7ad --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-slider-to-bui-slider", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Slider with BUI Slider", + "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-slider-to-bui-slider/scripts/codemod.ts b/codemods/misc/migrate-mui-slider-to-bui-slider/scripts/codemod.ts new file mode 100644 index 0000000..8a20395 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/scripts/codemod.ts @@ -0,0 +1,409 @@ +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-slider-to-bui-slider') + +const BUI_SOURCE = '@backstage/ui' + +/** Props that rename mechanically. */ +const PROP_RENAMES: Record = { + min: 'minValue', + max: 'maxValue', + disabled: 'isDisabled', +} + +/** Props that pass through unchanged. */ +const PASSTHROUGH_PROPS = new Set([ + 'step', + 'value', + 'defaultValue', + 'name', + 'id', + 'className', + 'style', + 'aria-label', + 'aria-labelledby', + 'aria-valuetext', +]) + +/** Props that trigger a TODO — not mechanically migratable. */ +const TODO_PROPS = new Set([ + 'marks', + 'track', + 'orientation', + 'scale', + 'getAriaLabel', + 'getAriaValueText', + 'valueLabelDisplay', + 'valueLabelFormat', + 'ValueLabelComponent', + 'ThumbComponent', + 'classes', + 'color', +]) + +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 collectSliderImports(rootNode: SgNode): { + sliderLocalName: string | null + importNodesToRemove: SgNode[] +} { + let sliderLocalName: string | null = null + const importNodesToRemove: SgNode[] = [] + + // Default import: import Slider from '@material-ui/core/Slider' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Slider')) { + sliderLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + // Named import from barrel: import { Slider } from '@material-ui/core' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + const localName = getNamedImportLocalName(imp, 'Slider') + if (localName) { + sliderLocalName = localName + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (allSpecifiers.length <= 1) { + importNodesToRemove.push(imp) + } + } + } + + return { sliderLocalName, importNodesToRemove } +} + +function addSliderToBuiImport(rootNode: SgNode, 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 names = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + if (!names.includes('Slider')) { + names.push('Slider') + } + names.sort() + edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + } else { + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push(lastImport.replace(`${lastImport.text()}\nimport { Slider } 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 +} + +/** + * Check if the onChange handler is a trivial arrow `(_e, val) => ...` + * where the event param is unused (starts with _). + * Returns the rewritten handler text without the event param, or null if complex. + */ +function tryRewriteOnChangeHandler(attr: SgNode): string | null { + const expr = attr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return null + } + + // Look for arrow function: (_e, val) => body + const arrow = expr.find({ rule: { kind: 'arrow_function' } }) + if (!arrow) { + return null + } + + const params = arrow.field('parameters') + if (!params) { + return null + } + + // Must be a formal_parameters node with exactly 2 params + if (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 eventParam = paramChildren[0]! + const valueParam = paramChildren[1]! + + // Event param must start with _ to indicate unused + const eventName = eventParam.text() + if (!eventName.startsWith('_')) { + return null + } + + const valueText = valueParam.text() + const body = arrow.field('body') + if (!body) { + return null + } + + const bodyText = body.text() + return `{${valueText} => ${bodyText}}` +} + +function transformSliderElements(rootNode: SgNode, sliderLocalName: string, edits: Edit[]): void { + 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 !== sliderLocalName) { + 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) + } + } + + if (needsTodo) { + edits.push( + el.replace( + `{/* TODO(backstage-codemod): finish slider migration manually (${todoReasons.join(', ')}) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: todoReasons.join(', ') }) + continue + } + + // Build new props + const newProps: string[] = [] + let handlerTodo = false + + 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() + + // Rename props + const renamed = PROP_RENAMES[propName] + if (renamed) { + let valuePart: string | null = null + for (const child of attr.children()) { + const kind = child.kind() + if (kind === 'string' || kind === 'jsx_expression') { + valuePart = child.text() + break + } + } + if (valuePart !== null) { + newProps.push(`${renamed}=${valuePart}`) + } else { + newProps.push(renamed) + } + migrationMetric.increment({ action: 'prop-renamed', from: propName, to: renamed }) + continue + } + + // Handle onChange + if (propName === 'onChange') { + const rewritten = tryRewriteOnChangeHandler(attr) + if (rewritten !== null) { + newProps.push(`onChange=${rewritten}`) + migrationMetric.increment({ action: 'onChange-rewritten' }) + } else { + // Non-trivial handler — insert TODO for whole element + edits.push( + el.replace( + `{/* TODO(backstage-codemod): finish slider migration manually (complex-onChange) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-onChange' }) + handlerTodo = true + break + } + continue + } + + // Handle onChangeCommitted → onChangeEnd when handler is trivial + if (propName === 'onChangeCommitted') { + const rewritten = tryRewriteOnChangeHandler(attr) + if (rewritten !== null) { + newProps.push(`onChangeEnd=${rewritten}`) + migrationMetric.increment({ action: 'onChangeCommitted-rewritten' }) + } else { + edits.push( + el.replace( + `{/* TODO(backstage-codemod): finish slider migration manually (onChangeCommitted) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'onChangeCommitted' }) + handlerTodo = true + } + continue + } + + // Passthrough props + if (PASSTHROUGH_PROPS.has(propName)) { + newProps.push(attr.text()) + continue + } + + // Unknown prop — preserve as-is + newProps.push(attr.text()) + } + + if (handlerTodo) { + continue + } + + // Preserve spread attributes + 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 { + // Preserve children via AST traversal + const children = el + .children() + .filter((c) => c.kind() !== 'jsx_opening_element' && c.kind() !== 'jsx_closing_element') + .map((c) => c.text()) + .join('') + edits.push(el.replace(`${children}`)) + } + + migrationMetric.increment({ action: 'slider-migrated' }) + } +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { sliderLocalName, importNodesToRemove } = collectSliderImports(rootNode) + + if (!sliderLocalName) { + return null + } + + // Remove MUI imports + for (const imp of importNodesToRemove) { + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + + // Add BUI import + addSliderToBuiImport(rootNode, edits) + + // Transform JSX elements + transformSliderElements(rootNode, sliderLocalName, edits) + + return edits.length > 0 ? rootNode.commitEdits(edits) : null +} + +export default transform diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/expected.tsx new file mode 100644 index 0000000..20b3017 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + setValue(next as number)} /> +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/input.tsx new file mode 100644 index 0000000..90af9ec --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/input.tsx @@ -0,0 +1,5 @@ +import Slider from '@material-ui/core/Slider'; + +const MyComponent = () => ( + setValue(next as number)} /> +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/metrics.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/metrics.json new file mode 100644 index 0000000..19ba817 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/metrics.json @@ -0,0 +1,44 @@ +{ + "migrate-mui-slider-to-bui-slider": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onChange-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "max", + "to": "maxValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "min", + "to": "minValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "slider-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/expected.tsx new file mode 100644 index 0000000..0b58ec9 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/expected.tsx @@ -0,0 +1,6 @@ + + +const MyComponent = () => ( + {/* TODO(backstage-codemod): finish slider migration manually (complex-onChange) */} + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/input.tsx new file mode 100644 index 0000000..bf19d84 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/input.tsx @@ -0,0 +1,5 @@ +import Slider from '@material-ui/core/Slider'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/metrics.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/metrics.json new file mode 100644 index 0000000..bff3a21 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/metrics.json @@ -0,0 +1,39 @@ +{ + "migrate-mui-slider-to-bui-slider": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "max", + "to": "maxValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "min", + "to": "minValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "complex-onChange" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/expected.tsx new file mode 100644 index 0000000..763e405 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/input.tsx new file mode 100644 index 0000000..4784c20 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/input.tsx @@ -0,0 +1,5 @@ +import Slider from '@material-ui/core/Slider'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/metrics.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/metrics.json new file mode 100644 index 0000000..fe41229 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/metrics.json @@ -0,0 +1,46 @@ +{ + "migrate-mui-slider-to-bui-slider": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "disabled", + "to": "isDisabled" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "max", + "to": "maxValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "min", + "to": "minValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "slider-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/expected.tsx new file mode 100644 index 0000000..c925fb4 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/expected.tsx @@ -0,0 +1,6 @@ + + +const MyComponent = () => ( + {/* TODO(backstage-codemod): finish slider migration manually (marks) */} + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/input.tsx new file mode 100644 index 0000000..997ba66 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/input.tsx @@ -0,0 +1,5 @@ +import Slider from '@material-ui/core/Slider'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/metrics.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/metrics.json new file mode 100644 index 0000000..30c0b9e --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-slider-to-bui-slider": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "marks" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..0aa8670 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + +import { Button, Slider } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..ba51e43 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/input.tsx @@ -0,0 +1,9 @@ +import Slider from '@material-ui/core/Slider'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..d864256 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/metrics.json @@ -0,0 +1,38 @@ +{ + "migrate-mui-slider-to-bui-slider": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "max", + "to": "maxValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "min", + "to": "minValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "slider-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..75b7526 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..257029c --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/input.tsx @@ -0,0 +1,5 @@ +import { Slider } from '@material-ui/core'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..8a25112 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/metrics.json @@ -0,0 +1,38 @@ +{ + "migrate-mui-slider-to-bui-slider": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "max", + "to": "maxValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "min", + "to": "minValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "slider-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/expected.tsx new file mode 100644 index 0000000..9d1133c --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/input.tsx new file mode 100644 index 0000000..446b9df --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/input.tsx @@ -0,0 +1,5 @@ +import Slider from '@material-ui/core/Slider'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/metrics.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/metrics.json new file mode 100644 index 0000000..6493d1e --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-slider-to-bui-slider": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "slider-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..0750ff6 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import { Slider } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..0750ff6 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/noop-no-import/input.tsx @@ -0,0 +1,5 @@ +import { Slider } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/expected.tsx new file mode 100644 index 0000000..ffca8dc --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + save(val)} /> +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/input.tsx new file mode 100644 index 0000000..c22ec86 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/input.tsx @@ -0,0 +1,5 @@ +import Slider from '@material-ui/core/Slider'; + +const MyComponent = () => ( + save(val)} /> +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/metrics.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/metrics.json new file mode 100644 index 0000000..3bed2e3 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/metrics.json @@ -0,0 +1,44 @@ +{ + "migrate-mui-slider-to-bui-slider": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onChangeCommitted-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "max", + "to": "maxValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "min", + "to": "minValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "slider-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/expected.tsx new file mode 100644 index 0000000..5c792f1 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/input.tsx new file mode 100644 index 0000000..f82ecd2 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/input.tsx @@ -0,0 +1,5 @@ +import Slider from '@material-ui/core/Slider'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/metrics.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/metrics.json new file mode 100644 index 0000000..8a25112 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/metrics.json @@ -0,0 +1,38 @@ +{ + "migrate-mui-slider-to-bui-slider": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "max", + "to": "maxValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "min", + "to": "minValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "slider-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/expected.tsx new file mode 100644 index 0000000..3522c03 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/expected.tsx @@ -0,0 +1,6 @@ + + +const MyComponent = () => ( + {/* TODO(backstage-codemod): finish slider migration manually (valueLabelDisplay) */} + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/input.tsx new file mode 100644 index 0000000..2bc1d81 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/input.tsx @@ -0,0 +1,5 @@ +import Slider from '@material-ui/core/Slider'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/metrics.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/metrics.json new file mode 100644 index 0000000..bf7f2bb --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-slider-to-bui-slider": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "valueLabelDisplay" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tsconfig.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/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-slider-to-bui-slider/workflow.yaml b/codemods/misc/migrate-mui-slider-to-bui-slider/workflow.yaml new file mode 100644 index 0000000..a3fa3a9 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/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 Slider with BUI Slider' + 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-textfield-to-bui-textfield/CHANGELOG.md b/codemods/misc/migrate-mui-textfield-to-bui-textfield/CHANGELOG.md new file mode 100644 index 0000000..60c09c9 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-textfield-to-bui-textfield diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/codemod.yaml b/codemods/misc/migrate-mui-textfield-to-bui-textfield/codemod.yaml new file mode 100644 index 0000000..55482fc --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-textfield-to-bui-textfield' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace TextField with BUI TextField' +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', 'textfield', 'bui', 'textfield'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/package.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/package.json new file mode 100644 index 0000000..08729ca --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-textfield-to-bui-textfield", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace TextField with BUI TextField", + "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-textfield-to-bui-textfield/scripts/codemod.ts b/codemods/misc/migrate-mui-textfield-to-bui-textfield/scripts/codemod.ts new file mode 100644 index 0000000..9db2017 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/scripts/codemod.ts @@ -0,0 +1,438 @@ +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-textfield-to-bui-textfield') + +const BUI_SOURCE = '@backstage/ui' + +/** Props that trigger a TODO — not mechanically migratable. */ +const TODO_PROPS = new Set([ + 'multiline', + 'rows', + 'rowsMax', + 'minRows', + 'maxRows', + 'select', + 'SelectProps', + 'InputProps', + 'inputProps', + 'InputLabelProps', + 'FormHelperTextProps', + 'helperText', + 'error', + 'variant', + 'margin', + 'size', + 'color', + 'classes', + 'inputRef', + 'InputAdornment', +]) + +/** Props that rename mechanically. */ +const PROP_RENAMES: Record = { + required: 'isRequired', + disabled: 'isDisabled', +} + +/** Props that pass through unchanged. */ +const PASSTHROUGH_PROPS = new Set([ + 'label', + 'value', + 'defaultValue', + 'placeholder', + 'name', + 'id', + 'type', + 'autoFocus', + 'autoComplete', + 'className', + 'style', + 'aria-label', + 'aria-labelledby', + 'data-testid', +]) + +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 collectTextFieldImports(rootNode: SgNode): { + textFieldLocalName: string | null + importNodesToRemove: SgNode[] +} { + let textFieldLocalName: string | null = null + const importNodesToRemove: SgNode[] = [] + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/TextField')) { + textFieldLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + const localName = getNamedImportLocalName(imp, 'TextField') + if (localName) { + textFieldLocalName = localName + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (allSpecifiers.length <= 1) { + importNodesToRemove.push(imp) + } + } + } + + return { textFieldLocalName, importNodesToRemove } +} + +function addTextFieldToBuiImport(rootNode: SgNode, importNodesToRemove: SgNode[], edits: Edit[]): boolean { + 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 names = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + if (!names.includes('TextField')) { + names.push('TextField') + } + names.sort() + edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + 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 { TextField } from '${BUI_SOURCE}';`)) + } else if (importNodesToRemove.length === 1) { + edits.push(importNodesToRemove[0]!.replace(`import { TextField } 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 { TextField } from '${BUI_SOURCE}';`)) + } + } + + migrationMetric.increment({ action: 'import-added' }) + 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 +} + +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 getParamName(paramNode: SgNode): string { + const ident = paramNode.find({ rule: { kind: 'identifier' } }) + return ident?.text() ?? paramNode.text().replace(/:.*$/, '').trim() +} + +function getArrowSingleParamName(arrow: SgNode): string | null { + const parameter = arrow.field('parameter') + if (parameter) { + return getParamName(parameter) + } + + const params = arrow.field('parameters') + if (!params) { + return null + } + + if (params.is('identifier')) { + return params.text() + } + + if (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 !== 1) { + return null + } + + return getParamName(paramChildren[0]!) +} + +function targetValuePattern(eventName: string): RegExp { + return new RegExp(`${escapeRegex(eventName)}\\.target\\.value`, 'g') +} + +function tryRewriteOnChangeHandler(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) { + return null + } + + const eventName = getArrowSingleParamName(arrow) + if (!eventName) { + return null + } + + const body = arrow.field('body') + if (!body) { + return null + } + + const bodyText = body.text() + const pattern = targetValuePattern(eventName) + if (!pattern.test(bodyText)) { + return null + } + + const rewrittenBody = bodyText.replace(targetValuePattern(eventName), 'newValue') + return `{newValue => ${rewrittenBody}}` +} + +function transformTextFieldElements( + rootNode: SgNode, + textFieldLocalName: string, + edits: Edit[], +): { preserveImport: boolean; migrated: boolean } { + let preserveImport = false + let migrated = false + 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 !== textFieldLocalName) { + continue + } + + let needsTodo = false + const todoReasons: string[] = [] + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + needsTodo = true + todoReasons.push(prop) + } + } + + if (needsTodo) { + preserveImport = true + edits.push( + el.replace( + `{/* TODO(backstage-codemod): finish TextField migration manually (${todoReasons.join(', ')}) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: todoReasons.join(', ') }) + continue + } + + const newProps: string[] = [] + let handlerTodo = false + let droppedFullWidth = false + + 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() + + const renamed = PROP_RENAMES[propName] + if (renamed) { + const exprNode = attr.find({ rule: { kind: 'jsx_expression' } }) + const strNode = attr.find({ rule: { kind: 'string' } }) + if (exprNode) { + newProps.push(`${renamed}=${exprNode.text()}`) + } else if (strNode) { + newProps.push(`${renamed}=${strNode.text()}`) + } else { + newProps.push(renamed) + } + migrationMetric.increment({ action: 'prop-renamed', from: propName, to: renamed }) + continue + } + + if (propName === 'onChange') { + const rewritten = tryRewriteOnChangeHandler(attr) + if (rewritten !== null) { + newProps.push(`onChange=${rewritten}`) + migrationMetric.increment({ action: 'onChange-rewritten' }) + } else { + preserveImport = true + edits.push( + el.replace( + `{/* TODO(backstage-codemod): finish TextField migration manually (complex-onChange) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-onChange' }) + handlerTodo = true + break + } + continue + } + + if (propName === 'fullWidth') { + droppedFullWidth = true + migrationMetric.increment({ action: 'prop-dropped', prop: 'fullWidth' }) + migrationMetric.increment({ action: 'todo-inserted', reason: 'fullWidth' }) + continue + } + + newProps.push(attr.text()) + } + + if (handlerTodo) { + continue + } + + 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(' ')}` : '' + const fullWidthTodo = droppedFullWidth + ? '{/* TODO(backstage-codemod): finish TextField migration manually (fullWidth) */}\n' + : '' + + if (isSelfClosing) { + edits.push(el.replace(`${fullWidthTodo}`)) + } else { + // Preserve children via AST traversal + const children = el + .children() + .filter((c) => c.kind() !== 'jsx_opening_element' && c.kind() !== 'jsx_closing_element') + .map((c) => c.text()) + .join('') + edits.push(el.replace(`${fullWidthTodo}${children}`)) + } + + migrated = true + migrationMetric.increment({ action: 'textfield-migrated' }) + } + + return { preserveImport, migrated } +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { textFieldLocalName, importNodesToRemove } = collectTextFieldImports(rootNode) + + if (!textFieldLocalName) { + return null + } + + const { preserveImport, migrated } = transformTextFieldElements(rootNode, textFieldLocalName, edits) + + let replacedImport = false + if (migrated) { + replacedImport = addTextFieldToBuiImport(rootNode, 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 edits.length > 0 ? rootNode.commitEdits(edits) : null +} + +export default transform diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/expected.tsx new file mode 100644 index 0000000..fb1f7b1 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/expected.tsx @@ -0,0 +1,5 @@ +import { TextField } from '@backstage/ui'; + +const MyComponent = () => ( + setValue(newValue)} /> +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/input.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/input.tsx new file mode 100644 index 0000000..d203c23 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/input.tsx @@ -0,0 +1,10 @@ +import TextField from '@material-ui/core/TextField'; + +const MyComponent = () => ( + setValue(e.target.value)} + /> +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/metrics.json new file mode 100644 index 0000000..f297188 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/metrics.json @@ -0,0 +1,36 @@ +{ + "migrate-mui-textfield-to-bui-textfield": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onChange-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "required", + "to": "isRequired" + }, + "count": 1 + }, + { + "cardinality": { + "action": "textfield-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/expected.tsx new file mode 100644 index 0000000..b91c2e9 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/expected.tsx @@ -0,0 +1,6 @@ +import TextField from '@material-ui/core/TextField'; + +const MyComponent = () => ( + {/* TODO(backstage-codemod): finish TextField migration manually (complex-onChange) */} + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/input.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/input.tsx new file mode 100644 index 0000000..23a61e9 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/input.tsx @@ -0,0 +1,5 @@ +import TextField from '@material-ui/core/TextField'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/metrics.json new file mode 100644 index 0000000..f8d5369 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-textfield-to-bui-textfield": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "complex-onChange" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/expected.tsx new file mode 100644 index 0000000..504a065 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/expected.tsx @@ -0,0 +1,6 @@ +import { TextField } from '@backstage/ui'; + +const MyComponent = () => ( + {/* TODO(backstage-codemod): finish TextField migration manually (fullWidth) */} + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/input.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/input.tsx new file mode 100644 index 0000000..12ad529 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/input.tsx @@ -0,0 +1,5 @@ +import TextField from '@material-ui/core/TextField'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/metrics.json new file mode 100644 index 0000000..c102358 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/metrics.json @@ -0,0 +1,44 @@ +{ + "migrate-mui-textfield-to-bui-textfield": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-dropped", + "prop": "fullWidth" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "disabled", + "to": "isDisabled" + }, + "count": 1 + }, + { + "cardinality": { + "action": "textfield-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "fullWidth" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..a3deac1 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + +import { Button, TextField } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + setEmail(newValue)} /> + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..68e507e --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/input.tsx @@ -0,0 +1,9 @@ +import TextField from '@material-ui/core/TextField'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + setEmail(e.target.value)} /> + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..419552c --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-textfield-to-bui-textfield": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onChange-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "textfield-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/expected.tsx new file mode 100644 index 0000000..6511285 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/expected.tsx @@ -0,0 +1,6 @@ +import TextField from '@material-ui/core/TextField'; + +const MyComponent = () => ( + {/* TODO(backstage-codemod): finish TextField migration manually (multiline, rows) */} + setDesc(e.target.value)} /> +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/input.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/input.tsx new file mode 100644 index 0000000..8ce95a8 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/input.tsx @@ -0,0 +1,5 @@ +import TextField from '@material-ui/core/TextField'; + +const MyComponent = () => ( + setDesc(e.target.value)} /> +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/metrics.json new file mode 100644 index 0000000..9cba3f2 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-textfield-to-bui-textfield": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "multiline, rows" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..d4be079 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import { TextField } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..d4be079 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/noop-no-import/input.tsx @@ -0,0 +1,5 @@ +import { TextField } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tsconfig.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/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-textfield-to-bui-textfield/workflow.yaml b/codemods/misc/migrate-mui-textfield-to-bui-textfield/workflow.yaml new file mode 100644 index 0000000..d027ff6 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/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 TextField with BUI TextField' + 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..e13f4b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -172,6 +172,51 @@ __metadata: languageName: unknown linkType: soft +"@backstage/migrate-mui-accordion-to-bui-accordion@workspace:codemods/misc/migrate-mui-accordion-to-bui-accordion": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-accordion-to-bui-accordion@workspace:codemods/misc/migrate-mui-accordion-to-bui-accordion" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-radio-checkbox-groups-to-bui-groups@workspace:codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-radio-checkbox-groups-to-bui-groups@workspace:codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-select-family-to-bui-select@workspace:codemods/misc/migrate-mui-select-family-to-bui-select": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-select-family-to-bui-select@workspace:codemods/misc/migrate-mui-select-family-to-bui-select" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-slider-to-bui-slider@workspace:codemods/misc/migrate-mui-slider-to-bui-slider": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-slider-to-bui-slider@workspace:codemods/misc/migrate-mui-slider-to-bui-slider" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-textfield-to-bui-textfield@workspace:codemods/misc/migrate-mui-textfield-to-bui-textfield": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-textfield-to-bui-textfield@workspace:codemods/misc/migrate-mui-textfield-to-bui-textfield" + 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 bbc0eeda41761427a353e8a38da2d137cd16ad05 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 09:25:24 -0500 Subject: [PATCH 2/6] fix: resolve lint warnings in form control MUI-to-BUI codemods Use Promise.resolve returns and remove non-null assertions to satisfy oxlint --deny-warnings in CI. Co-authored-by: Cursor --- .../scripts/codemod.ts | 20 +++++++------- .../scripts/codemod.ts | 6 ++--- .../scripts/codemod.ts | 6 ++--- .../scripts/codemod.ts | 12 +++++---- .../scripts/codemod.ts | 26 ++++++++++++------- 5 files changed, 41 insertions(+), 29 deletions(-) diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/scripts/codemod.ts b/codemods/misc/migrate-mui-accordion-to-bui-accordion/scripts/codemod.ts index 1a94331..9196549 100644 --- a/codemods/misc/migrate-mui-accordion-to-bui-accordion/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/scripts/codemod.ts @@ -216,8 +216,9 @@ function extractSummaryTitle(summaryElement: SgNode): { title: string | nul } // Single text node - if (children.length === 1 && children[0]!.kind() === 'jsx_text') { - const text = children[0]!.text().trim() + const [firstChild] = children + if (children.length === 1 && firstChild?.kind() === 'jsx_text') { + const text = firstChild.text().trim() return { title: text.length > 0 ? text : null, isComplex: false } } @@ -232,11 +233,12 @@ function extractSummaryTitle(summaryElement: SgNode): { title: string | nul // Single Typography element wrapping text if (children.length === 1) { - const child = children[0]! - if (child.kind() === 'jsx_element') { + const child = firstChild + if (child?.kind() === 'jsx_element') { const innerChildren = getNonWhitespaceChildren(child) - if (innerChildren.length === 1 && innerChildren[0]!.kind() === 'jsx_text') { - const text = innerChildren[0]!.text().trim() + const [innerChild] = innerChildren + if (innerChildren.length === 1 && innerChild?.kind() === 'jsx_text') { + const text = innerChild.text().trim() if (text.length > 0) { return { title: text, isComplex: false } } @@ -394,14 +396,14 @@ function transformAccordionElements(rootNode: SgNode, localNames: Map = async (root) => { +const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] const { localNames, importNodesToRemove } = collectAccordionImports(rootNode) if (localNames.size === 0) { - return null + return Promise.resolve(null) } // Remove MUI imports @@ -427,7 +429,7 @@ const transform: Codemod = async (root) => { // Transform elements transformAccordionElements(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-radio-checkbox-groups-to-bui-groups/scripts/codemod.ts b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/scripts/codemod.ts index 16dbc6b..f398220 100644 --- a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/scripts/codemod.ts @@ -447,7 +447,7 @@ function transformGroupElements(rootNode: SgNode, localNames: Map = async (root) => { +const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] @@ -457,7 +457,7 @@ const transform: Codemod = async (root) => { (v) => v === 'RadioGroup' || v === 'FormGroup' || v === 'FormControlLabel', ) if (!hasTarget) { - return null + return Promise.resolve(null) } for (const imp of importNodesToRemove) { @@ -471,7 +471,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 diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/scripts/codemod.ts b/codemods/misc/migrate-mui-select-family-to-bui-select/scripts/codemod.ts index 518c5e4..0e2e599 100644 --- a/codemods/misc/migrate-mui-select-family-to-bui-select/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/scripts/codemod.ts @@ -578,7 +578,7 @@ function transformSelectPatterns(rootNode: SgNode, localNames: Map = async (root) => { +const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] @@ -586,7 +586,7 @@ const transform: Codemod = async (root) => { const hasSelect = [...localNames.values()].includes('Select') if (!hasSelect) { - return null + return Promise.resolve(null) } for (const imp of importNodesToRemove) { @@ -597,7 +597,7 @@ const transform: Codemod = async (root) => { addBuiImport(rootNode, ['Select'], edits) transformSelectPatterns(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-slider-to-bui-slider/scripts/codemod.ts b/codemods/misc/migrate-mui-slider-to-bui-slider/scripts/codemod.ts index 8a20395..3b22248 100644 --- a/codemods/misc/migrate-mui-slider-to-bui-slider/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/scripts/codemod.ts @@ -212,8 +212,10 @@ function tryRewriteOnChangeHandler(attr: SgNode): string | null { return null } - const eventParam = paramChildren[0]! - const valueParam = paramChildren[1]! + const [eventParam, valueParam] = paramChildren + if (!eventParam || !valueParam) { + return null + } // Event param must start with _ to indicate unused const eventName = eventParam.text() @@ -381,14 +383,14 @@ function transformSliderElements(rootNode: SgNode, sliderLocalName: string, } } -const transform: Codemod = async (root) => { +const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] const { sliderLocalName, importNodesToRemove } = collectSliderImports(rootNode) if (!sliderLocalName) { - return null + return Promise.resolve(null) } // Remove MUI imports @@ -403,7 +405,7 @@ const transform: Codemod = async (root) => { // Transform JSX elements transformSliderElements(rootNode, sliderLocalName, 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-textfield-to-bui-textfield/scripts/codemod.ts b/codemods/misc/migrate-mui-textfield-to-bui-textfield/scripts/codemod.ts index 9db2017..af24583 100644 --- a/codemods/misc/migrate-mui-textfield-to-bui-textfield/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/scripts/codemod.ts @@ -36,8 +36,8 @@ const PROP_RENAMES: Record = { disabled: 'isDisabled', } -/** Props that pass through unchanged. */ -const PASSTHROUGH_PROPS = new Set([ +/** Props that pass through unchanged (documented for manual review). */ +const _PASSTHROUGH_PROPS = new Set([ 'label', 'value', 'defaultValue', @@ -156,9 +156,12 @@ function addTextFieldToBuiImport(rootNode: SgNode, importNodesToRemove: SgN if (anchorImport) { edits.push(anchorImport.replace(`${anchorImport.text()}\nimport { TextField } from '${BUI_SOURCE}';`)) } else if (importNodesToRemove.length === 1) { - edits.push(importNodesToRemove[0]!.replace(`import { TextField } from '${BUI_SOURCE}';`)) - migrationMetric.increment({ action: 'import-added' }) - return true + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { TextField } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } } else if (allImports.length > 0) { const lastImport = allImports.at(-1) if (lastImport) { @@ -230,7 +233,12 @@ function getArrowSingleParamName(arrow: SgNode): string | null { return null } - return getParamName(paramChildren[0]!) + const [param] = paramChildren + if (!param) { + return null + } + + return getParamName(param) } function targetValuePattern(eventName: string): RegExp { @@ -404,14 +412,14 @@ function transformTextFieldElements( return { preserveImport, migrated } } -const transform: Codemod = async (root) => { +const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] const { textFieldLocalName, importNodesToRemove } = collectTextFieldImports(rootNode) if (!textFieldLocalName) { - return null + return Promise.resolve(null) } const { preserveImport, migrated } = transformTextFieldElements(rootNode, textFieldLocalName, edits) @@ -432,7 +440,7 @@ const transform: Codemod = async (root) => { } } - return edits.length > 0 ? rootNode.commitEdits(edits) : null + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) } export default transform From 6e0883541c3cfc84f7b22505de3a66a8731f126d Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 09:29:29 -0500 Subject: [PATCH 3/6] fix: shorten radio-checkbox codemod package name for registry limit Rename @backstage/migrate-mui-radio-checkbox-groups-to-bui-groups (58 chars) to @backstage/migrate-mui-radio-checkbox-to-bui (44 chars) and regenerate README. Co-authored-by: Cursor --- .changeset/mui-to-bui-form-controls.md | 2 +- README.md | 10 ++++++++++ .../CHANGELOG.md | 2 +- .../codemod.yaml | 2 +- .../package.json | 2 +- .../scripts/codemod.ts | 2 +- .../tests/complex-form-group-todo/metrics.json | 2 +- .../tests/merge-existing-bui/metrics.json | 2 +- .../tests/named-barrel-import/metrics.json | 2 +- .../tests/simple-checkbox-group/metrics.json | 2 +- .../tests/simple-radio-group/metrics.json | 2 +- yarn.lock | 4 ++-- 12 files changed, 22 insertions(+), 12 deletions(-) diff --git a/.changeset/mui-to-bui-form-controls.md b/.changeset/mui-to-bui-form-controls.md index 6f9a590..339436a 100644 --- a/.changeset/mui-to-bui-form-controls.md +++ b/.changeset/mui-to-bui-form-controls.md @@ -2,7 +2,7 @@ '@backstage/migrate-mui-select-family-to-bui-select': minor '@backstage/migrate-mui-textfield-to-bui-textfield': minor '@backstage/migrate-mui-accordion-to-bui-accordion': minor -'@backstage/migrate-mui-radio-checkbox-groups-to-bui-groups': minor +'@backstage/migrate-mui-radio-checkbox-to-bui': minor '@backstage/migrate-mui-slider-to-bui-slider': minor --- diff --git a/README.md b/README.md index b200fef..8b6ab11 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-accordion-to-bui-accordion](./codemods/misc/migrate-mui-accordion-to-bui-accordion) | MUI 4 to BUI: Replace Accordion with BUI Accordion | +| [migrate-mui-radio-checkbox-groups-to-bui-groups](./codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups) | MUI 4 to BUI: Replace radio and checkbox group patterns with BUI groups | +| [migrate-mui-select-family-to-bui-select](./codemods/misc/migrate-mui-select-family-to-bui-select) | MUI 4 to BUI: Replace Select wrapper patterns with BUI Select | +| [migrate-mui-slider-to-bui-slider](./codemods/misc/migrate-mui-slider-to-bui-slider) | MUI 4 to BUI: Replace Slider with BUI Slider | +| [migrate-mui-textfield-to-bui-textfield](./codemods/misc/migrate-mui-textfield-to-bui-textfield) | MUI 4 to BUI: Replace TextField with BUI TextField | + ## Usage diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/CHANGELOG.md b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/CHANGELOG.md index b47c510..7010e1a 100644 --- a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/CHANGELOG.md +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/CHANGELOG.md @@ -1 +1 @@ -# @backstage/migrate-mui-radio-checkbox-groups-to-bui-groups +# @backstage/migrate-mui-radio-checkbox-to-bui diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/codemod.yaml b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/codemod.yaml index c7b26c2..49cf7e5 100644 --- a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/codemod.yaml +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/codemod.yaml @@ -1,6 +1,6 @@ schema_version: '1.0' -name: '@backstage/migrate-mui-radio-checkbox-groups-to-bui-groups' +name: '@backstage/migrate-mui-radio-checkbox-to-bui' version: '0.1.0' description: 'MUI 4 to BUI: Replace radio and checkbox group patterns with BUI groups' author: 'Paul Schultz' diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/package.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/package.json index ab49424..97692be 100644 --- a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/package.json +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/package.json @@ -1,5 +1,5 @@ { - "name": "@backstage/migrate-mui-radio-checkbox-groups-to-bui-groups", + "name": "@backstage/migrate-mui-radio-checkbox-to-bui", "version": "0.1.0", "description": "MUI 4 to BUI: Replace radio and checkbox group patterns with BUI groups", "type": "module", diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/scripts/codemod.ts b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/scripts/codemod.ts index f398220..623cfd1 100644 --- a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/scripts/codemod.ts @@ -2,7 +2,7 @@ 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-radio-checkbox-groups-to-bui-groups') +const migrationMetric = useMetricAtom('migrate-mui-radio-checkbox-to-bui') const BUI_SOURCE = '@backstage/ui' diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/metrics.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/metrics.json index d2b86e2..f19496b 100644 --- a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/metrics.json +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/metrics.json @@ -1,5 +1,5 @@ { - "migrate-mui-radio-checkbox-groups-to-bui-groups": [ + "migrate-mui-radio-checkbox-to-bui": [ { "cardinality": { "action": "import-removed" diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/metrics.json index 22acef3..7a73b16 100644 --- a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/metrics.json +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/metrics.json @@ -1,5 +1,5 @@ { - "migrate-mui-radio-checkbox-groups-to-bui-groups": [ + "migrate-mui-radio-checkbox-to-bui": [ { "cardinality": { "action": "import-merged" diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/metrics.json index 29f54dc..0df8159 100644 --- a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/metrics.json +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/metrics.json @@ -1,5 +1,5 @@ { - "migrate-mui-radio-checkbox-groups-to-bui-groups": [ + "migrate-mui-radio-checkbox-to-bui": [ { "cardinality": { "action": "import-added" diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/metrics.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/metrics.json index 73c6a62..df0a8b0 100644 --- a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/metrics.json +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/metrics.json @@ -1,5 +1,5 @@ { - "migrate-mui-radio-checkbox-groups-to-bui-groups": [ + "migrate-mui-radio-checkbox-to-bui": [ { "cardinality": { "action": "checkbox-group-migrated" diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/metrics.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/metrics.json index a0b9bbf..5ccb759 100644 --- a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/metrics.json +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/metrics.json @@ -1,5 +1,5 @@ { - "migrate-mui-radio-checkbox-groups-to-bui-groups": [ + "migrate-mui-radio-checkbox-to-bui": [ { "cardinality": { "action": "import-added" diff --git a/yarn.lock b/yarn.lock index e13f4b9..e4cecd4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -181,9 +181,9 @@ __metadata: languageName: unknown linkType: soft -"@backstage/migrate-mui-radio-checkbox-groups-to-bui-groups@workspace:codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups": +"@backstage/migrate-mui-radio-checkbox-to-bui@workspace:codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups": version: 0.0.0-use.local - resolution: "@backstage/migrate-mui-radio-checkbox-groups-to-bui-groups@workspace:codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups" + resolution: "@backstage/migrate-mui-radio-checkbox-to-bui@workspace:codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups" dependencies: "@codemod.com/jssg-types": "npm:1.6.2" codemod: "npm:1.12.3" From 3cd9e23d1d52f9587d97c4f3f2866b344d12f862 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 09:57:39 -0500 Subject: [PATCH 4/6] fix: address Copilot review feedback on form control codemods Co-authored-by: Cursor --- .lintstagedrc.json | 2 +- .../scripts/codemod.ts | 165 ++++++++--- .../tests/complex-summary-todo/expected.tsx | 10 +- .../tests/complex-summary-todo/metrics.json | 12 - .../tests/controlled-todo/expected.tsx | 10 +- .../tests/controlled-todo/metrics.json | 12 - .../tests/named-barrel-import/expected.tsx | 2 +- .../tests/simple-accordion/expected.tsx | 3 +- .../scripts/codemod.ts | 150 ++++++++-- .../complex-form-group-todo/expected.tsx | 10 +- .../complex-form-group-todo/metrics.json | 6 - .../tests/named-barrel-import/expected.tsx | 2 +- .../tests/simple-checkbox-group/expected.tsx | 3 +- .../tests/simple-radio-group/expected.tsx | 3 +- .../scripts/codemod.ts | 172 +++++++++--- .../tests/basic-form-control/expected.tsx | 3 +- .../tests/helper-text-todo/expected.tsx | 14 +- .../tests/helper-text-todo/metrics.json | 12 - .../tests/multiple-select-todo/expected.tsx | 12 +- .../tests/multiple-select-todo/metrics.json | 12 - .../tests/named-barrel-import/expected.tsx | 2 +- .../scripts/codemod.ts | 264 +++++++++++------- .../tests/basic-slider/expected.tsx | 2 +- .../tests/complex-on-change-todo/expected.tsx | 6 +- .../tests/complex-on-change-todo/metrics.json | 12 - .../tests/disabled-and-aria/expected.tsx | 2 +- .../tests/marks-todo/expected.tsx | 6 +- .../tests/marks-todo/metrics.json | 12 - .../tests/named-barrel-import/expected.tsx | 2 +- .../tests/no-min-max/expected.tsx | 2 +- .../on-change-committed-todo/expected.tsx | 2 +- .../tests/step-and-default-value/expected.tsx | 2 +- .../tests/value-label-todo/expected.tsx | 6 +- .../tests/value-label-todo/metrics.json | 12 - .../scripts/codemod.ts | 150 ++++++---- .../tests/complex-on-change-todo/expected.tsx | 4 +- .../tests/disabled-and-fullwidth/expected.tsx | 4 +- .../tests/multiline-todo/expected.tsx | 4 +- 38 files changed, 709 insertions(+), 400 deletions(-) diff --git a/.lintstagedrc.json b/.lintstagedrc.json index a324132..082efb9 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,5 +1,5 @@ { "*.{ts,js,mts,mjs}": ["yarn format", "yarn lint:fix"], - "*.{json,yaml,yml,md}": ["yarn format"], + "!(**/tests/**)*.{json,yaml,yml,md}": ["yarn format"], "codemods/**/scripts/*.ts": "bash scripts/test-staged.sh" } diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/scripts/codemod.ts b/codemods/misc/migrate-mui-accordion-to-bui-accordion/scripts/codemod.ts index 9196549..2e44d85 100644 --- a/codemods/misc/migrate-mui-accordion-to-bui-accordion/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/scripts/codemod.ts @@ -5,6 +5,7 @@ import { useMetricAtom } from 'codemod:metrics' const migrationMetric = useMetricAtom('migrate-mui-accordion-to-bui-accordion') const BUI_SOURCE = '@backstage/ui' +const MUI_BARREL_SOURCE = '@material-ui/core' const MUI_ACCORDION_COMPONENTS = ['Accordion', 'AccordionSummary', 'AccordionDetails', 'AccordionActions'] @@ -26,6 +27,39 @@ function escapeRegex(str: string): string { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') } +function wrapWithTodo(todoComment: string, elementText: string): string { + return `<> +${todoComment} +${elementText} +` +} + +function rebuildImportWithout(importStmt: SgNode, specifiersToRemove: Set): string { + const specifiers = importStmt.findAll({ rule: { kind: 'import_specifier' } }) + const remaining: string[] = [] + for (const spec of specifiers) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const importedName = identifiers[0]?.text() + if (importedName && !specifiersToRemove.has(importedName)) { + remaining.push(spec.text()) + } + } + + if (remaining.length === 0) { + return '' + } + + const sourceNode = importStmt.find({ rule: { kind: 'string' } }) + const sourceText = sourceNode?.text() ?? `'${MUI_BARREL_SOURCE}'` + + if (remaining.length <= 2) { + return `import { ${remaining.join(', ')} } from ${sourceText};` + } + return `import {\n ${remaining.join(',\n ')},\n} from ${sourceText};` +} + function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { return rootNode.findAll({ rule: { @@ -71,11 +105,13 @@ function getNamedImportLocalName(imp: SgNode, targetName: string): string | interface AccordionImports { localNames: Map importNodesToRemove: SgNode[] + barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] } function collectAccordionImports(rootNode: SgNode): AccordionImports { const localNames = new Map() const importNodesToRemove: SgNode[] = [] + const barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] = [] for (const componentName of MUI_ACCORDION_COMPONENTS) { for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { @@ -87,27 +123,34 @@ function collectAccordionImports(rootNode: SgNode): AccordionImports { } } - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { - let foundCount = 0 + for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL_SOURCE)) { + const foundNames = new Set() for (const componentName of MUI_ACCORDION_COMPONENTS) { const localName = getNamedImportLocalName(imp, componentName) if (localName) { localNames.set(localName, componentName) - foundCount++ + foundNames.add(componentName) } } - if (foundCount > 0) { + if (foundNames.size > 0) { const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (foundCount >= allSpecifiers.length) { + if (foundNames.size >= allSpecifiers.length) { importNodesToRemove.push(imp) + } else { + barrelImportsToPrune.push({ imp, namesToRemove: foundNames }) } } } - return { localNames, importNodesToRemove } + return { localNames, importNodesToRemove, barrelImportsToPrune } } -function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): void { +function addBuiImport( + rootNode: SgNode, + importNodesToRemove: SgNode[], + names: string[], + edits: Edit[], +): boolean { const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) const existingImport = existingImports[0] ?? null @@ -129,19 +172,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 sortedNames = [...names].sort() + 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 { @@ -327,10 +384,16 @@ function transformAccordionChildren(accordionElement: SgNode, localNames: M return parts.join('') } -function transformAccordionElements(rootNode: SgNode, localNames: Map, edits: Edit[]): void { +function transformAccordionElements( + rootNode: SgNode, + localNames: Map, + edits: Edit[], +): { preserveImport: boolean; migrated: boolean } { + let preserveImport = false + let migrated = false const accordionLocalName = [...localNames.entries()].find(([, v]) => v === 'Accordion')?.[0] if (!accordionLocalName) { - return + return { preserveImport: false, migrated: false } } const jsxElements = rootNode.findAll({ @@ -362,9 +425,13 @@ function transformAccordionElements(rootNode: SgNode, localNames: Map, localNames: Map')) + migrated = true migrationMetric.increment({ action: 'accordion-migrated' }) continue } @@ -382,9 +450,13 @@ function transformAccordionElements(rootNode: SgNode, localNames: Map, localNames: Map${transformedChildren}`)) + migrated = true migrationMetric.increment({ action: 'accordion-migrated' }) } + + return { preserveImport, migrated } } const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { localNames, importNodesToRemove } = collectAccordionImports(rootNode) + const { localNames, importNodesToRemove, barrelImportsToPrune } = collectAccordionImports(rootNode) if (localNames.size === 0) { return Promise.resolve(null) } - // Remove MUI imports - for (const imp of importNodesToRemove) { - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) - } + const { preserveImport, migrated } = transformAccordionElements(rootNode, localNames, edits) - // Determine BUI names needed const buiNames = new Set() - buiNames.add('Accordion') - for (const [, muiName] of localNames) { - if (muiName === 'AccordionSummary') { - buiNames.add('AccordionTrigger') - } - if (muiName === 'AccordionDetails') { - buiNames.add('AccordionPanel') + if (migrated) { + buiNames.add('Accordion') + for (const [, muiName] of localNames) { + if (muiName === 'AccordionSummary') { + buiNames.add('AccordionTrigger') + } + if (muiName === 'AccordionDetails') { + buiNames.add('AccordionPanel') + } } } - addBuiImport(rootNode, [...buiNames], edits) + let replacedImport = false + if (buiNames.size > 0) { + replacedImport = addBuiImport(rootNode, importNodesToRemove, [...buiNames], edits) + } - // Transform elements - transformAccordionElements(rootNode, localNames, edits) + if (!preserveImport) { + for (const { imp, namesToRemove } of barrelImportsToPrune) { + edits.push(imp.replace(rebuildImportWithout(imp, namesToRemove))) + migrationMetric.increment({ action: 'import-pruned' }) + } + 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-accordion-to-bui-accordion/tests/complex-summary-todo/expected.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/expected.tsx index 34498b7..47e2e6e 100644 --- a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/expected.tsx +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/expected.tsx @@ -1,9 +1,10 @@ - - - +import Accordion from '@material-ui/core/Accordion'; +import AccordionSummary from '@material-ui/core/AccordionSummary'; +import AccordionDetails from '@material-ui/core/AccordionDetails'; const MyComponent = () => ( - {/* TODO(backstage-codemod): finish accordion migration manually (complex-summary) */} + <> +{/* TODO(backstage-codemod): finish accordion migration manually (complex-summary) */} Title @@ -11,4 +12,5 @@ const MyComponent = () => ( Body + ); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/metrics.json b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/metrics.json index 0bef907..bef93c3 100644 --- a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/metrics.json +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/metrics.json @@ -1,17 +1,5 @@ { "migrate-mui-accordion-to-bui-accordion": [ - { - "cardinality": { - "action": "import-added" - }, - "count": 1 - }, - { - "cardinality": { - "action": "import-removed" - }, - "count": 3 - }, { "cardinality": { "action": "todo-inserted", diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/expected.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/expected.tsx index 07b7e9c..0f961e1 100644 --- a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/expected.tsx +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/expected.tsx @@ -1,11 +1,13 @@ - - - +import Accordion from '@material-ui/core/Accordion'; +import AccordionSummary from '@material-ui/core/AccordionSummary'; +import AccordionDetails from '@material-ui/core/AccordionDetails'; const MyComponent = ({ expanded, onChange }: any) => ( - {/* TODO(backstage-codemod): finish accordion migration manually (expanded, onChange) */} + <> +{/* TODO(backstage-codemod): finish accordion migration manually (expanded, onChange) */} Settings Content + ); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/metrics.json b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/metrics.json index 1fd51b8..9f05bcb 100644 --- a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/metrics.json +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/metrics.json @@ -1,17 +1,5 @@ { "migrate-mui-accordion-to-bui-accordion": [ - { - "cardinality": { - "action": "import-added" - }, - "count": 1 - }, - { - "cardinality": { - "action": "import-removed" - }, - "count": 3 - }, { "cardinality": { "action": "todo-inserted", diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/expected.tsx index 31649ca..6920673 100644 --- a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/expected.tsx +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/expected.tsx @@ -1,4 +1,4 @@ - +import { Accordion, AccordionPanel, AccordionTrigger } from '@backstage/ui'; const MyComponent = () => ( Answers here diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/expected.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/expected.tsx index ef16d06..d4b941f 100644 --- a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/expected.tsx +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/expected.tsx @@ -1,6 +1,7 @@ - +import AccordionDetails from '@material-ui/core/AccordionDetails'; +import { Accordion, AccordionPanel, AccordionTrigger } from '@backstage/ui'; const MyComponent = () => ( Body content here diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/scripts/codemod.ts b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/scripts/codemod.ts index 623cfd1..483cefb 100644 --- a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/scripts/codemod.ts @@ -5,6 +5,7 @@ import { useMetricAtom } from 'codemod:metrics' const migrationMetric = useMetricAtom('migrate-mui-radio-checkbox-to-bui') const BUI_SOURCE = '@backstage/ui' +const MUI_BARREL_SOURCE = '@material-ui/core' const MUI_COMPONENTS = ['RadioGroup', 'Radio', 'Checkbox', 'FormControlLabel', 'FormGroup', 'FormControl', 'FormLabel'] @@ -12,6 +13,39 @@ function escapeRegex(str: string): string { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') } +function wrapWithTodo(todoComment: string, elementText: string): string { + return `<> +${todoComment} +${elementText} +` +} + +function rebuildImportWithout(importStmt: SgNode, specifiersToRemove: Set): string { + const specifiers = importStmt.findAll({ rule: { kind: 'import_specifier' } }) + const remaining: string[] = [] + for (const spec of specifiers) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const importedName = identifiers[0]?.text() + if (importedName && !specifiersToRemove.has(importedName)) { + remaining.push(spec.text()) + } + } + + if (remaining.length === 0) { + return '' + } + + const sourceNode = importStmt.find({ rule: { kind: 'string' } }) + const sourceText = sourceNode?.text() ?? `'${MUI_BARREL_SOURCE}'` + + if (remaining.length <= 2) { + return `import { ${remaining.join(', ')} } from ${sourceText};` + } + return `import {\n ${remaining.join(',\n ')},\n} from ${sourceText};` +} + function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { return rootNode.findAll({ rule: { @@ -57,11 +91,13 @@ function getNamedImportLocalName(imp: SgNode, targetName: string): string | interface GroupImports { localNames: Map importNodesToRemove: SgNode[] + barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] } function collectGroupImports(rootNode: SgNode): GroupImports { const localNames = new Map() const importNodesToRemove: SgNode[] = [] + const barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] = [] for (const componentName of MUI_COMPONENTS) { for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { @@ -73,27 +109,34 @@ function collectGroupImports(rootNode: SgNode): GroupImports { } } - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { - let foundCount = 0 + for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL_SOURCE)) { + const foundNames = new Set() for (const componentName of MUI_COMPONENTS) { const localName = getNamedImportLocalName(imp, componentName) if (localName) { localNames.set(localName, componentName) - foundCount++ + foundNames.add(componentName) } } - if (foundCount > 0) { + if (foundNames.size > 0) { const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (foundCount >= allSpecifiers.length) { + if (foundNames.size >= allSpecifiers.length) { importNodesToRemove.push(imp) + } else { + barrelImportsToPrune.push({ imp, namesToRemove: foundNames }) } } } - return { localNames, importNodesToRemove } + return { localNames, importNodesToRemove, barrelImportsToPrune } } -function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): void { +function addBuiImport( + rootNode: SgNode, + importNodesToRemove: SgNode[], + names: string[], + edits: Edit[], +): boolean { const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) const existingImport = existingImports[0] ?? null @@ -115,19 +158,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 sortedNames = [...names].sort() + 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 { @@ -374,8 +431,13 @@ function isCheckboxFormGroup(element: SgNode, localNames: Map, localNames: Map, edits: Edit[]): Set { +function transformGroupElements( + rootNode: SgNode, + localNames: Map, + edits: Edit[], +): { usedBuiNames: Set; preserveImport: boolean } { const usedBuiNames = new Set() + let preserveImport = false const radioGroupLocal = [...localNames.entries()].find(([, v]) => v === 'RadioGroup')?.[0] ?? null const formGroupLocal = [...localNames.entries()].find(([, v]) => v === 'FormGroup')?.[0] ?? null @@ -399,7 +461,12 @@ function transformGroupElements(rootNode: SgNode, localNames: Map, localNames: Map, localNames: Map = (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { localNames, importNodesToRemove } = collectGroupImports(rootNode) + const { localNames, importNodesToRemove, barrelImportsToPrune } = collectGroupImports(rootNode) const hasTarget = [...localNames.values()].some( (v) => v === 'RadioGroup' || v === 'FormGroup' || v === 'FormControlLabel', @@ -460,15 +537,26 @@ const transform: Codemod = (root) => { return Promise.resolve(null) } - for (const imp of importNodesToRemove) { - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) - } - - const usedBuiNames = transformGroupElements(rootNode, localNames, edits) + const { usedBuiNames, preserveImport } = transformGroupElements(rootNode, localNames, edits) + let replacedImport = false if (usedBuiNames.size > 0) { - addBuiImport(rootNode, [...usedBuiNames], edits) + replacedImport = addBuiImport(rootNode, importNodesToRemove, [...usedBuiNames], edits) + } + + if (!preserveImport) { + for (const { imp, namesToRemove } of barrelImportsToPrune) { + edits.push(imp.replace(rebuildImportWithout(imp, namesToRemove))) + migrationMetric.increment({ action: 'import-pruned' }) + } + 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-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/expected.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/expected.tsx index b2e6436..ecff772 100644 --- a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/expected.tsx +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/expected.tsx @@ -1,11 +1,13 @@ - - - +import FormGroup from '@material-ui/core/FormGroup'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Checkbox from '@material-ui/core/Checkbox'; const MyComponent = () => ( - {/* TODO(backstage-codemod): finish choice-group migration manually */} + <> +{/* TODO(backstage-codemod): finish choice-group migration manually */} } label="A" /> Custom separator + ); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/metrics.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/metrics.json index f19496b..1e76e2f 100644 --- a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/metrics.json +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/metrics.json @@ -1,11 +1,5 @@ { "migrate-mui-radio-checkbox-to-bui": [ - { - "cardinality": { - "action": "import-removed" - }, - "count": 3 - }, { "cardinality": { "action": "todo-inserted", diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/expected.tsx index 83adec6..c291676 100644 --- a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/expected.tsx +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/expected.tsx @@ -1,4 +1,4 @@ - +import { Radio, RadioGroup } from '@backstage/ui'; const MyComponent = () => ( XY diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/expected.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/expected.tsx index 3b1cb39..5debd3d 100644 --- a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/expected.tsx +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/expected.tsx @@ -1,6 +1,7 @@ - +import Checkbox from '@material-ui/core/Checkbox'; +import { Checkbox, CheckboxGroup } from '@backstage/ui'; const MyComponent = () => ( EnabledVisible diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/expected.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/expected.tsx index 2c1cd8e..1ba6bd4 100644 --- a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/expected.tsx +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/expected.tsx @@ -1,6 +1,7 @@ - +import Radio from '@material-ui/core/Radio'; +import { Radio, RadioGroup } from '@backstage/ui'; const MyComponent = () => ( Option AOption B diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/scripts/codemod.ts b/codemods/misc/migrate-mui-select-family-to-bui-select/scripts/codemod.ts index 0e2e599..56ddf4f 100644 --- a/codemods/misc/migrate-mui-select-family-to-bui-select/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/scripts/codemod.ts @@ -5,6 +5,7 @@ import { useMetricAtom } from 'codemod:metrics' const migrationMetric = useMetricAtom('migrate-mui-select-family-to-bui-select') const BUI_SOURCE = '@backstage/ui' +const MUI_BARREL_SOURCE = '@material-ui/core' const MUI_SELECT_COMPONENTS = ['FormControl', 'InputLabel', 'Select', 'MenuItem', 'FormHelperText'] @@ -27,6 +28,39 @@ function escapeRegex(str: string): string { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') } +function wrapWithTodo(todoComment: string, elementText: string): string { + return `<> +${todoComment} +${elementText} +` +} + +function rebuildImportWithout(importStmt: SgNode, specifiersToRemove: Set): string { + const specifiers = importStmt.findAll({ rule: { kind: 'import_specifier' } }) + const remaining: string[] = [] + for (const spec of specifiers) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const importedName = identifiers[0]?.text() + if (importedName && !specifiersToRemove.has(importedName)) { + remaining.push(spec.text()) + } + } + + if (remaining.length === 0) { + return '' + } + + const sourceNode = importStmt.find({ rule: { kind: 'string' } }) + const sourceText = sourceNode?.text() ?? `'${MUI_BARREL_SOURCE}'` + + if (remaining.length <= 2) { + return `import { ${remaining.join(', ')} } from ${sourceText};` + } + return `import {\n ${remaining.join(',\n ')},\n} from ${sourceText};` +} + function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { return rootNode.findAll({ rule: { @@ -72,11 +106,13 @@ function getNamedImportLocalName(imp: SgNode, targetName: string): string | interface SelectImports { localNames: Map importNodesToRemove: SgNode[] + barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] } function collectSelectImports(rootNode: SgNode): SelectImports { const localNames = new Map() const importNodesToRemove: SgNode[] = [] + const barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] = [] for (const componentName of MUI_SELECT_COMPONENTS) { for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { @@ -88,27 +124,34 @@ function collectSelectImports(rootNode: SgNode): SelectImports { } } - for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { - let foundCount = 0 + for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL_SOURCE)) { + const foundNames = new Set() for (const componentName of MUI_SELECT_COMPONENTS) { const localName = getNamedImportLocalName(imp, componentName) if (localName) { localNames.set(localName, componentName) - foundCount++ + foundNames.add(componentName) } } - if (foundCount > 0) { + if (foundNames.size > 0) { const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - if (foundCount >= allSpecifiers.length) { + if (foundNames.size >= allSpecifiers.length) { importNodesToRemove.push(imp) + } else { + barrelImportsToPrune.push({ imp, namesToRemove: foundNames }) } } } - return { localNames, importNodesToRemove } + return { localNames, importNodesToRemove, barrelImportsToPrune } } -function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): void { +function addBuiImport( + rootNode: SgNode, + importNodesToRemove: SgNode[], + names: string[], + edits: Edit[], +): boolean { const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) const existingImport = existingImports[0] ?? null @@ -130,19 +173,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 sortedNames = [...names].sort() + 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 { @@ -353,6 +410,10 @@ function tryRewriteOnChangeHandler(attr: SgNode): string | null { } const rewrittenBody = bodyText.replace(targetValuePattern(eventName), 'key') + const eventRefPattern = new RegExp(`\\b${escapeRegex(eventName)}\\b`) + if (eventRefPattern.test(rewrittenBody)) { + return null + } return `{key => ${rewrittenBody}}` } @@ -419,13 +480,19 @@ function findSelectInFormControl( return result } -function transformSelectPatterns(rootNode: SgNode, localNames: Map, edits: Edit[]): void { +function transformSelectPatterns( + rootNode: SgNode, + localNames: Map, + edits: Edit[], +): { preserveImport: boolean; migrated: boolean } { + let preserveImport = false + let migrated = false const formControlLocal = [...localNames.entries()].find(([, v]) => v === 'FormControl')?.[0] ?? null const selectLocal = [...localNames.entries()].find(([, v]) => v === 'Select')?.[0] ?? null const menuItemLocal = [...localNames.entries()].find(([, v]) => v === 'MenuItem')?.[0] ?? null if (!selectLocal) { - return + return { preserveImport: false, migrated: false } } const jsxElements = rootNode.findAll({ @@ -458,7 +525,10 @@ function transformSelectPatterns(rootNode: SgNode, localNames: Map, localNames: Map, localNames: Map, localNames: Map, localNames: Map`)) + migrated = true migrationMetric.increment({ action: 'select-migrated' }) continue } @@ -531,7 +611,10 @@ function transformSelectPatterns(rootNode: SgNode, localNames: Map, localNames: Map, localNames: Map, localNames: Map`)) + migrated = true migrationMetric.increment({ action: 'select-migrated' }) continue } } + + return { preserveImport, migrated } } const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { localNames, importNodesToRemove } = collectSelectImports(rootNode) + const { localNames, importNodesToRemove, barrelImportsToPrune } = collectSelectImports(rootNode) const hasSelect = [...localNames.values()].includes('Select') if (!hasSelect) { return Promise.resolve(null) } - for (const imp of importNodesToRemove) { - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) + const { preserveImport, migrated } = transformSelectPatterns(rootNode, localNames, edits) + + let replacedImport = false + if (migrated) { + replacedImport = addBuiImport(rootNode, importNodesToRemove, ['Select'], edits) } - addBuiImport(rootNode, ['Select'], edits) - transformSelectPatterns(rootNode, localNames, edits) + if (!preserveImport) { + for (const { imp, namesToRemove } of barrelImportsToPrune) { + edits.push(imp.replace(rebuildImportWithout(imp, namesToRemove))) + migrationMetric.increment({ action: 'import-pruned' }) + } + 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-select-family-to-bui-select/tests/basic-form-control/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/basic-form-control/expected.tsx index 2507f82..5a9eeae 100644 --- a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/basic-form-control/expected.tsx +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/basic-form-control/expected.tsx @@ -1,7 +1,8 @@ - +import MenuItem from '@material-ui/core/MenuItem'; +import { Select } from '@backstage/ui'; const MyComponent = () => ( setColor(e.target.value as string)}> @@ -14,4 +15,5 @@ const MyComponent = () => ( Pick a color + ); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/metrics.json b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/metrics.json index 94b0c11..8166561 100644 --- a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/metrics.json +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/metrics.json @@ -1,17 +1,5 @@ { "migrate-mui-select-family-to-bui-select": [ - { - "cardinality": { - "action": "import-added" - }, - "count": 1 - }, - { - "cardinality": { - "action": "import-removed" - }, - "count": 5 - }, { "cardinality": { "action": "todo-inserted", diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/expected.tsx index 57a1877..eb94551 100644 --- a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/expected.tsx +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/expected.tsx @@ -1,10 +1,11 @@ - - - - +import FormControl from '@material-ui/core/FormControl'; +import InputLabel from '@material-ui/core/InputLabel'; +import Select from '@material-ui/core/Select'; +import MenuItem from '@material-ui/core/MenuItem'; const MyComponent = () => ( - {/* TODO(backstage-codemod): finish Select migration manually */} + <> +{/* TODO(backstage-codemod): finish Select migration manually */} Tags + ); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/metrics.json b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/metrics.json index 1f776fa..d2bb654 100644 --- a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/metrics.json +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/metrics.json @@ -1,17 +1,5 @@ { "migrate-mui-select-family-to-bui-select": [ - { - "cardinality": { - "action": "import-added" - }, - "count": 1 - }, - { - "cardinality": { - "action": "import-removed" - }, - "count": 4 - }, { "cardinality": { "action": "todo-inserted", diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/named-barrel-import/expected.tsx index 4d1271e..8175add 100644 --- a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/named-barrel-import/expected.tsx +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/named-barrel-import/expected.tsx @@ -1,4 +1,4 @@ - +import { Select } from '@backstage/ui'; const MyComponent = () => ( setValue(key as string)} options={[{ id: 'react', label: 'React' }, { id: 'angular', label: 'Angular' }]} /> + setMode(key as string)} options={[{ id: 'auto', label: 'Auto' }, { id: 'manual', label: 'Manual' }]} /> + setSize(key as string)} options={[{ id: 'sm', label: 'Small' }, { id: 'lg', label: 'Large' }]} /> +