From fcc87edf6f2b016429c8b2afc30d49a087efb491 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 22 Jul 2026 17:09:20 -0400 Subject: [PATCH 01/11] Harden MUI 4 to BUI codemods and expand recipe coverage. Fix invalid Text variants, remove dead Surface Paper mapping, and improve button/field/menu transforms. Add Switch, Avatar, Skeleton, Card, Autocomplete, and Link packages plus a local recipe workflow for dry-runs. Co-authored-by: Cursor --- .changeset/harden-mui4-to-bui-codemods.md | 8 + .changeset/minor-mui4-bui-new-packages.md | 11 + .changeset/patch-mui4-bui-bugfixes.md | 7 + .gitignore | 2 +- README.md | 14 +- codemods/misc/README.md | 6 +- .../README.md | 21 + .../migrate-mui-alert-to-bui-alert/README.md | 20 + .../CHANGELOG.md | 7 + .../README.md | 22 + .../codemod.yaml | 20 + .../package.json | 13 + .../scripts/codemod.ts | 678 ++++++++++++++++++ .../tests/ambiguous-todo/expected.tsx | 23 + .../tests/ambiguous-todo/input.tsx | 20 + .../tests/ambiguous-todo/metrics.json | 11 + .../tests/combobox-success/expected.tsx | 12 + .../tests/combobox-success/input.tsx | 18 + .../tests/combobox-success/metrics.json | 22 + .../tests/noop-no-import/expected.tsx | 3 + .../tests/noop-no-import/input.tsx | 3 + .../tests/object-options-todo/expected.tsx | 23 + .../tests/object-options-todo/input.tsx | 20 + .../tests/object-options-todo/metrics.json | 11 + .../tests/research-label-todo/expected.tsx | 21 + .../tests/research-label-todo/input.tsx | 18 + .../tests/research-label-todo/metrics.json | 11 + .../tests/search-autocomplete/expected.tsx | 12 + .../tests/search-autocomplete/input.tsx | 18 + .../tests/search-autocomplete/metrics.json | 22 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + .../CHANGELOG.md | 7 + .../README.md | 20 + .../codemod.yaml | 20 + .../package.json | 13 + .../scripts/codemod.ts | 485 +++++++++++++ .../tests/core-import/expected.tsx | 5 + .../tests/core-import/input.tsx | 5 + .../tests/core-import/metrics.json | 30 + .../tests/happy-path-alt-as-name/expected.tsx | 5 + .../tests/happy-path-alt-as-name/input.tsx | 5 + .../tests/happy-path-alt-as-name/metrics.json | 23 + .../tests/merge-existing-bui/expected.tsx | 9 + .../tests/merge-existing-bui/input.tsx | 9 + .../tests/merge-existing-bui/metrics.json | 23 + .../tests/missing-name-todo/expected.tsx | 8 + .../tests/missing-name-todo/input.tsx | 5 + .../tests/missing-name-todo}/metrics.json | 4 +- .../tests/named-barrel-import/expected.tsx | 5 + .../tests/named-barrel-import/input.tsx | 5 + .../tests/named-barrel-import/metrics.json | 23 + .../tests/noop-no-import/expected.tsx | 5 + .../tests/noop-no-import/input.tsx | 5 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + .../migrate-mui-bootstrap-to-bui/README.md | 20 + .../README.md | 13 + .../codemod.yaml | 2 +- .../scripts/codemod.ts | 170 +++-- .../tests/contained-button/expected.tsx | 2 +- .../tests/contained-button/metrics.json | 6 + .../tests/href-button-link/expected.tsx | 7 + .../tests/href-button-link/input.tsx | 7 + .../tests/href-button-link/metrics.json | 31 + .../tests/merge-existing-bui/expected.tsx | 2 +- .../tests/merge-existing-bui/metrics.json | 6 + .../tests/no-variant/expected.tsx | 2 +- .../tests/no-variant/metrics.json | 6 + .../tests/outlined-button/expected.tsx | 2 +- .../tests/outlined-button/metrics.json | 6 + .../tests/start-icon-todo/expected.tsx | 8 - .../tests/start-icon/expected.tsx | 5 + .../{start-icon-todo => start-icon}/input.tsx | 0 .../tests/start-icon/metrics.json | 31 + .../tests/text-button/expected.tsx | 2 +- .../tests/text-button/metrics.json | 6 + .../migrate-mui-card-to-bui-card/CHANGELOG.md | 7 + .../migrate-mui-card-to-bui-card/README.md | 21 + .../migrate-mui-card-to-bui-card/codemod.yaml | 20 + .../migrate-mui-card-to-bui-card/package.json | 13 + .../scripts/codemod.ts | 617 ++++++++++++++++ .../tests/complex-header-todo/expected.tsx | 12 + .../tests/complex-header-todo/input.tsx | 11 + .../tests/complex-header-todo/metrics.json | 39 + .../tests/header-content-actions/expected.tsx | 8 + .../tests/header-content-actions/input.tsx | 14 + .../tests/header-content-actions/metrics.json | 46 ++ .../tests/media-todo/expected.tsx | 11 + .../tests/media-todo/input.tsx | 10 + .../tests/media-todo/metrics.json | 39 + .../tests/named-barrel-import/expected.tsx | 6 + .../tests/named-barrel-import/input.tsx | 9 + .../tests/named-barrel-import/metrics.json | 32 + .../tests/noop-no-import/expected.tsx | 7 + .../tests/noop-no-import/input.tsx | 7 + .../tests/simple-card/expected.tsx | 6 + .../tests/simple-card/input.tsx | 8 + .../tests/simple-card/metrics.json | 32 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + .../misc/migrate-mui-chip-to-tag/README.md | 20 + .../README.md | 21 + .../README.md | 21 + .../scripts/codemod.ts | 56 +- .../tests/basic-migration/expected.tsx | 2 +- .../tests/className-preserved/expected.tsx | 2 +- .../tests/color-primary/expected.tsx | 5 + .../tests/color-primary/input.tsx | 7 + .../tests/color-primary/metrics.json | 29 + .../tests/color-secondary/expected.tsx | 5 + .../tests/color-secondary/input.tsx | 7 + .../tests/color-secondary/metrics.json | 29 + .../tests/disabled-boolean/expected.tsx | 2 +- .../tests/dropped-props/expected.tsx | 2 +- .../tests/merge-existing-bui/expected.tsx | 2 +- .../tests/named-barrel-import/expected.tsx | 2 +- .../tests/spread-props/expected.tsx | 2 +- .../README.md | 21 + .../README.md | 21 + .../scripts/codemod.ts | 74 +- .../paper-card-like-bui-children/expected.tsx | 6 + .../paper-card-like-bui-children/input.tsx | 10 + .../paper-card-like-bui-children/metrics.json | 22 + .../tests/paper-card-like/expected.tsx | 8 + .../tests/paper-card-like/input.tsx | 10 + .../tests/paper-card-like/metrics.json | 22 + .../tests/paper-simple/expected.tsx | 4 +- .../tests/paper-simple/metrics.json | 2 +- .../migrate-mui-link-to-bui-link/CHANGELOG.md | 7 + .../migrate-mui-link-to-bui-link/README.md | 22 + .../migrate-mui-link-to-bui-link/codemod.yaml | 20 + .../migrate-mui-link-to-bui-link/package.json | 13 + .../scripts/codemod.ts | 386 ++++++++++ .../tests/component-todo/expected.tsx | 11 + .../tests/component-todo/input.tsx | 8 + .../tests/component-todo}/metrics.json | 4 +- .../tests/noop-no-import/expected.tsx | 3 + .../tests/noop-no-import/input.tsx | 3 + .../tests/simple-link/expected.tsx | 7 + .../tests/simple-link/input.tsx | 7 + .../tests/simple-link/metrics.json | 22 + .../tests/skip-core-components/expected.tsx | 9 + .../tests/skip-core-components/input.tsx | 9 + .../tests/skip-core-components/metrics.json | 11 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + .../README.md | 20 + .../README.md | 21 + .../codemod.yaml | 2 +- .../scripts/codemod.ts | 196 +++-- .../popover-content-no-trigger/expected.tsx | 10 + .../popover-content-no-trigger/input.tsx | 7 + .../popover-content-no-trigger/metrics.json | 11 + .../popover-content-with-trigger/expected.tsx | 9 + .../popover-content-with-trigger/input.tsx | 11 + .../popover-content-with-trigger/metrics.json | 34 + .../popover-menu-with-trigger/expected.tsx | 11 + .../tests/popover-menu-with-trigger/input.tsx | 15 + .../popover-menu-with-trigger/metrics.json | 53 ++ .../popover-select-menuitem-noop/expected.tsx | 22 + .../popover-select-menuitem-noop/input.tsx | 24 + .../popover-select-menuitem-noop/metrics.json | 34 + .../README.md | 20 + .../codemod.yaml | 2 +- .../scripts/codemod.ts | 145 +++- .../tests/standalone-checkbox/expected.tsx | 5 + .../tests/standalone-checkbox/input.tsx | 5 + .../tests/standalone-checkbox/metrics.json | 23 + .../expected.tsx | 6 + .../input.tsx | 6 + .../metrics.json | 23 + .../README.md | 21 + .../CHANGELOG.md | 7 + .../README.md | 18 + .../codemod.yaml | 20 + .../package.json | 13 + .../scripts/codemod.ts | 367 ++++++++++ .../tests/animation-todo/expected.tsx | 8 + .../tests/animation-todo/input.tsx | 5 + .../tests/animation-todo/metrics.json | 11 + .../tests/core-import/expected.tsx | 5 + .../tests/core-import/input.tsx | 5 + .../tests/core-import/metrics.json | 30 + .../tests/happy-path-rect/expected.tsx | 5 + .../tests/happy-path-rect/input.tsx | 5 + .../tests/happy-path-rect/metrics.json | 22 + .../tests/merge-existing-bui/expected.tsx | 9 + .../tests/merge-existing-bui/input.tsx | 9 + .../tests/merge-existing-bui/metrics.json | 22 + .../tests/named-barrel-import/expected.tsx | 5 + .../tests/named-barrel-import/input.tsx | 5 + .../tests/named-barrel-import/metrics.json | 22 + .../tests/noop-no-import/expected.tsx | 9 + .../tests/noop-no-import/input.tsx | 9 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + .../README.md | 21 + .../CHANGELOG.md | 7 + .../README.md | 19 + .../codemod.yaml | 20 + .../package.json | 13 + .../scripts/codemod.ts | 559 +++++++++++++++ .../tests/color-todo/expected.tsx | 8 + .../tests/color-todo/input.tsx | 5 + .../tests/color-todo/metrics.json | 11 + .../tests/core-import/expected.tsx | 5 + .../tests/core-import/input.tsx | 5 + .../tests/core-import/metrics.json | 38 + .../tests/happy-path-standalone/expected.tsx | 5 + .../tests/happy-path-standalone/input.tsx | 5 + .../tests/happy-path-standalone/metrics.json | 38 + .../tests/merge-existing-bui/expected.tsx | 9 + .../tests/merge-existing-bui/input.tsx | 9 + .../tests/merge-existing-bui/metrics.json | 30 + .../tests/named-barrel-import/expected.tsx | 5 + .../tests/named-barrel-import/input.tsx | 5 + .../tests/named-barrel-import/metrics.json | 31 + .../tests/noop-no-import/expected.tsx | 5 + .../tests/noop-no-import/input.tsx | 5 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + .../migrate-mui-tabs-to-bui-tabs/README.md | 20 + .../README.md | 20 + .../codemod.yaml | 2 +- .../scripts/codemod.ts | 235 ++++-- .../tests/aliased-import/metrics.json | 3 +- .../tests/basic-controlled/metrics.json | 3 +- .../tests/disabled-and-fullwidth/metrics.json | 3 +- .../tests/merge-existing-bui/metrics.json | 3 +- .../tests/multiline-todo/expected.tsx | 8 - .../tests/multiline/expected.tsx | 5 + .../{multiline-todo => multiline}/input.tsx | 0 .../tests/multiline/metrics.json | 44 ++ .../tests/named-barrel-import/metrics.json | 3 +- .../tests/number-field/expected.tsx | 5 + .../tests/number-field/input.tsx | 5 + .../tests/number-field/metrics.json | 36 + .../tests/partial-barrel-import/metrics.json | 3 +- .../tests/password-field/expected.tsx | 5 + .../tests/password-field/input.tsx | 11 + .../tests/password-field/metrics.json | 44 ++ .../README.md | 20 + .../migrate-mui-typography-to-text/README.md | 20 + .../scripts/codemod.ts | 22 +- .../tests/component-to-as/expected.tsx | 2 +- .../tests/dialog-content-text/expected.tsx | 2 +- .../tests/extra-props-preserved/expected.tsx | 2 +- .../tests/gutter-bottom-dropped/expected.tsx | 2 +- .../tests/merge-existing-bui/expected.tsx | 2 +- .../tests/named-barrel-import/expected.tsx | 2 +- .../tests/noop-no-import/expected.tsx | 2 +- .../tests/noop-no-import/input.tsx | 2 +- .../tests/preserve-children/expected.tsx | 2 +- .../mui4-to-bui-migration-recipe/.npmignore | 1 + .../mui4-to-bui-migration-recipe/README.md | 72 +- .../mui4-to-bui-migration-recipe/package.json | 2 +- .../scripts/validate-workflows.sh | 12 + .../workflow.local.yaml | 129 ++++ .../workflow.yaml | 26 +- .../misc/remove-mui-dependencies/README.md | 20 + yarn.lock | 54 ++ 262 files changed, 7084 insertions(+), 266 deletions(-) create mode 100644 .changeset/harden-mui4-to-bui-codemods.md create mode 100644 .changeset/minor-mui4-bui-new-packages.md create mode 100644 .changeset/patch-mui4-bui-bugfixes.md create mode 100644 codemods/misc/migrate-mui-accordion-to-bui-accordion/README.md create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/README.md create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/README.md create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/codemod.yaml create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/package.json create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/ambiguous-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/ambiguous-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/ambiguous-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/combobox-success/expected.tsx create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/combobox-success/input.tsx create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/combobox-success/metrics.json create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/object-options-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/object-options-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/object-options-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/research-label-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/research-label-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/research-label-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/search-autocomplete/expected.tsx create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/search-autocomplete/input.tsx create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/search-autocomplete/metrics.json create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tsconfig.json create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/workflow.yaml create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/README.md create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/codemod.yaml create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/package.json create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/core-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/core-import/input.tsx create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/core-import/metrics.json create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/happy-path-alt-as-name/expected.tsx create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/happy-path-alt-as-name/input.tsx create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/happy-path-alt-as-name/metrics.json create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/missing-name-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/missing-name-todo/input.tsx rename codemods/misc/{migrate-mui-textfield-to-bui-textfield/tests/multiline-todo => migrate-mui-avatar-to-bui-avatar/tests/missing-name-todo}/metrics.json (55%) create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/named-barrel-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/named-barrel-import/input.tsx create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/named-barrel-import/metrics.json create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/tsconfig.json create mode 100644 codemods/misc/migrate-mui-avatar-to-bui-avatar/workflow.yaml create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/README.md create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/README.md create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/expected.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/input.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/metrics.json delete mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon/expected.tsx rename codemods/misc/migrate-mui-button-to-bui-button/tests/{start-icon-todo => start-icon}/input.tsx (100%) create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon/metrics.json create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/README.md create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/codemod.yaml create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/package.json create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/tests/complex-header-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/tests/complex-header-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/tests/complex-header-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/tests/header-content-actions/expected.tsx create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/tests/header-content-actions/input.tsx create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/tests/header-content-actions/metrics.json create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/tests/media-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/tests/media-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/tests/media-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/tests/named-barrel-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/tests/named-barrel-import/input.tsx create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/tests/named-barrel-import/metrics.json create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/tests/simple-card/expected.tsx create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/tests/simple-card/input.tsx create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/tests/simple-card/metrics.json create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/tsconfig.json create mode 100644 codemods/misc/migrate-mui-card-to-bui-card/workflow.yaml create mode 100644 codemods/misc/migrate-mui-chip-to-tag/README.md create mode 100644 codemods/misc/migrate-mui-dialog-to-bui-dialog/README.md create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/README.md create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-primary/expected.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-primary/input.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-primary/metrics.json create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-secondary/expected.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-secondary/input.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-secondary/metrics.json create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/README.md create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/README.md create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like-bui-children/expected.tsx create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like-bui-children/input.tsx create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like-bui-children/metrics.json create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like/expected.tsx create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like/input.tsx create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like/metrics.json create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/README.md create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/codemod.yaml create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/package.json create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/tests/component-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/tests/component-todo/input.tsx rename codemods/misc/{migrate-mui-button-to-bui-button/tests/start-icon-todo => migrate-mui-link-to-bui-link/tests/component-todo}/metrics.json (59%) create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/tests/simple-link/expected.tsx create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/tests/simple-link/input.tsx create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/tests/simple-link/metrics.json create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/tests/skip-core-components/expected.tsx create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/tests/skip-core-components/input.tsx create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/tests/skip-core-components/metrics.json create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/tsconfig.json create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/workflow.yaml create mode 100644 codemods/misc/migrate-mui-list-family-to-bui-list/README.md create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/README.md create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-no-trigger/expected.tsx create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-no-trigger/input.tsx create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-no-trigger/metrics.json create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-with-trigger/expected.tsx create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-with-trigger/input.tsx create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-with-trigger/metrics.json create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-menu-with-trigger/expected.tsx create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-menu-with-trigger/input.tsx create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-menu-with-trigger/metrics.json create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-select-menuitem-noop/expected.tsx create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-select-menuitem-noop/input.tsx create mode 100644 codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-select-menuitem-noop/metrics.json create mode 100644 codemods/misc/migrate-mui-radio-checkbox-to-bui/README.md create mode 100644 codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-checkbox/expected.tsx create mode 100644 codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-checkbox/input.tsx create mode 100644 codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-checkbox/metrics.json create mode 100644 codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-form-control-label-checkbox/expected.tsx create mode 100644 codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-form-control-label-checkbox/input.tsx create mode 100644 codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-form-control-label-checkbox/metrics.json create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/README.md create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/README.md create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/codemod.yaml create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/package.json create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/animation-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/animation-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/animation-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/core-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/core-import/input.tsx create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/core-import/metrics.json create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/happy-path-rect/expected.tsx create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/happy-path-rect/input.tsx create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/happy-path-rect/metrics.json create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/named-barrel-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/named-barrel-import/input.tsx create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/named-barrel-import/metrics.json create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tsconfig.json create mode 100644 codemods/misc/migrate-mui-skeleton-to-bui-skeleton/workflow.yaml create mode 100644 codemods/misc/migrate-mui-slider-to-bui-slider/README.md create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/README.md create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/codemod.yaml create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/package.json create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/tests/color-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/tests/color-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/tests/color-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/tests/core-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/tests/core-import/input.tsx create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/tests/core-import/metrics.json create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/tests/happy-path-standalone/expected.tsx create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/tests/happy-path-standalone/input.tsx create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/tests/happy-path-standalone/metrics.json create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/tests/named-barrel-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/tests/named-barrel-import/input.tsx create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/tests/named-barrel-import/metrics.json create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/tsconfig.json create mode 100644 codemods/misc/migrate-mui-switch-to-bui-switch/workflow.yaml create mode 100644 codemods/misc/migrate-mui-tabs-to-bui-tabs/README.md create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/README.md delete 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/expected.tsx rename codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/{multiline-todo => multiline}/input.tsx (100%) create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline/metrics.json create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/number-field/expected.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/number-field/input.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/number-field/metrics.json create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/password-field/expected.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/password-field/input.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/password-field/metrics.json create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/README.md create mode 100644 codemods/misc/migrate-mui-typography-to-text/README.md create mode 100644 codemods/misc/mui4-to-bui-migration-recipe/.npmignore create mode 100755 codemods/misc/mui4-to-bui-migration-recipe/scripts/validate-workflows.sh create mode 100644 codemods/misc/mui4-to-bui-migration-recipe/workflow.local.yaml create mode 100644 codemods/misc/remove-mui-dependencies/README.md diff --git a/.changeset/harden-mui4-to-bui-codemods.md b/.changeset/harden-mui4-to-bui-codemods.md new file mode 100644 index 00000000..389edc5d --- /dev/null +++ b/.changeset/harden-mui4-to-bui-codemods.md @@ -0,0 +1,8 @@ +--- +'@backstage/migrate-mui-button-to-bui-button': minor +'@backstage/migrate-mui-textfield-to-bui-textfield': minor +'@backstage/migrate-mui-menu-popover-to-bui-menu': minor +'@backstage/migrate-mui-radio-checkbox-to-bui': minor +--- + +Harden MUI 4 → BUI migrations: Button icons/href/onPress, TextField specialized variants, Popover vs Menu heuristic, and standalone Checkbox. diff --git a/.changeset/minor-mui4-bui-new-packages.md b/.changeset/minor-mui4-bui-new-packages.md new file mode 100644 index 00000000..abe8c2da --- /dev/null +++ b/.changeset/minor-mui4-bui-new-packages.md @@ -0,0 +1,11 @@ +--- +'@backstage/migrate-mui-switch-to-bui-switch': minor +'@backstage/migrate-mui-avatar-to-bui-avatar': minor +'@backstage/migrate-mui-skeleton-to-bui-skeleton': minor +'@backstage/migrate-mui-card-to-bui-card': minor +'@backstage/migrate-mui-autocomplete-to-combobox': minor +'@backstage/migrate-mui-link-to-bui-link': minor +'@backstage/mui4-to-bui-migration-recipe': minor +--- + +Add Avatar, Skeleton, Link, Card, Autocomplete→Combobox, and Switch codemods; update the MUI 4 → BUI recipe order and local workflow for pre-publish dry-runs. diff --git a/.changeset/patch-mui4-bui-bugfixes.md b/.changeset/patch-mui4-bui-bugfixes.md new file mode 100644 index 00000000..cb7e4398 --- /dev/null +++ b/.changeset/patch-mui4-bui-bugfixes.md @@ -0,0 +1,7 @@ +--- +'@backstage/migrate-mui-typography-to-text': patch +'@backstage/migrate-mui-layout-to-bui-layout': patch +'@backstage/migrate-mui-icon-button-to-button-icon': patch +--- + +Fix MUI 4 → BUI transforms: Typography maps only valid TextVariants, Paper never becomes Surface (Box bg=neutral / Card / TODO), and IconButton defaults to tertiary with color + onPress mapping. diff --git a/.gitignore b/.gitignore index fbd6161e..04e07bb7 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,4 @@ Thumbs.db # Package bundles *.tar.gz -*.tgz \ No newline at end of file +*.tgz .scratch/ diff --git a/README.md b/README.md index 06c927a5..b89fea7d 100644 --- a/README.md +++ b/README.md @@ -46,21 +46,27 @@ Older versions are available in the [`codemods/`](./codemods) directory. | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | [migrate-mui-accordion-to-bui-accordion](./codemods/misc/migrate-mui-accordion-to-bui-accordion) | MUI 4 to BUI: Replace Accordion with BUI Accordion (packaged with form-control codemods; Accordion is a complex component) | | [migrate-mui-alert-to-bui-alert](./codemods/misc/migrate-mui-alert-to-bui-alert) | MUI 4 to BUI: Replace MUI Alert with BUI Alert | +| [migrate-mui-autocomplete-to-combobox](./codemods/misc/migrate-mui-autocomplete-to-combobox) | MUI 4 to BUI: Replace Autocomplete with BUI Combobox or SearchAutocomplete | +| [migrate-mui-avatar-to-bui-avatar](./codemods/misc/migrate-mui-avatar-to-bui-avatar) | MUI 4 to BUI: Replace Avatar with BUI Avatar | | [migrate-mui-bootstrap-to-bui](./codemods/misc/migrate-mui-bootstrap-to-bui) | MUI 4 to BUI: Bootstrap app dependencies and root CSS | -| [migrate-mui-button-to-bui-button](./codemods/misc/migrate-mui-button-to-bui-button) | MUI 4 to BUI: Replace MUI Button with BUI Button | +| [migrate-mui-button-to-bui-button](./codemods/misc/migrate-mui-button-to-bui-button) | MUI 4 to BUI: Replace MUI Button with BUI Button / ButtonLink | +| [migrate-mui-card-to-bui-card](./codemods/misc/migrate-mui-card-to-bui-card) | MUI 4 to BUI: Replace Card family with BUI Card | | [migrate-mui-chip-to-tag](./codemods/misc/migrate-mui-chip-to-tag) | MUI 4 to BUI: Replace Chip with Tag (omits -bui-; BUI export is Tag) | | [migrate-mui-dialog-to-bui-dialog](./codemods/misc/migrate-mui-dialog-to-bui-dialog) | MUI 4 to BUI: Replace Dialog shell with BUI Dialog | | [migrate-mui-icon-button-to-button-icon](./codemods/misc/migrate-mui-icon-button-to-button-icon) | MUI 4 to BUI: Replace IconButton with ButtonIcon. Package name omits -bui- because ButtonIcon is the BUI export name. | | [migrate-mui-icons-to-remix-icons](./codemods/misc/migrate-mui-icons-to-remix-icons) | MUI 4 to BUI: Replace MUI icons with Remix icons | | [migrate-mui-layout-to-bui-layout](./codemods/misc/migrate-mui-layout-to-bui-layout) | MUI 4 to BUI: Convert common MUI layout primitives to BUI layout | +| [migrate-mui-link-to-bui-link](./codemods/misc/migrate-mui-link-to-bui-link) | MUI 4 to BUI: Replace Material-UI Link with BUI Link | | [migrate-mui-list-family-to-bui-list](./codemods/misc/migrate-mui-list-family-to-bui-list) | MUI 4 to BUI: Replace List family primitives with BUI List | -| [migrate-mui-menu-popover-to-bui-menu](./codemods/misc/migrate-mui-menu-popover-to-bui-menu) | MUI 4 to BUI: Replace Menu and Popover patterns with BUI Menu | -| [migrate-mui-radio-checkbox-to-bui](./codemods/misc/migrate-mui-radio-checkbox-to-bui) | MUI 4 to BUI: Replace radio and checkbox group patterns with BUI groups | +| [migrate-mui-menu-popover-to-bui-menu](./codemods/misc/migrate-mui-menu-popover-to-bui-menu) | MUI 4 to BUI: Replace Menu with BUI Menu; Popover with MenuList/MenuItem becomes Menu, otherwise BUI Popover | +| [migrate-mui-radio-checkbox-to-bui](./codemods/misc/migrate-mui-radio-checkbox-to-bui) | MUI 4 to BUI: Replace radio/checkbox groups and standalone Checkbox patterns with BUI | | [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-skeleton-to-bui-skeleton](./codemods/misc/migrate-mui-skeleton-to-bui-skeleton) | MUI 4 to BUI: Replace Skeleton with BUI Skeleton | | [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-styles-to-bui-css-modules](./codemods/misc/migrate-mui-styles-to-bui-css-modules) | MUI 4 to BUI: Migrate makeStyles usage to BUI CSS modules | +| [migrate-mui-switch-to-bui-switch](./codemods/misc/migrate-mui-switch-to-bui-switch) | MUI 4 to BUI: Replace Switch with BUI Switch | | [migrate-mui-tabs-to-bui-tabs](./codemods/misc/migrate-mui-tabs-to-bui-tabs) | MUI 4 to BUI: Replace MUI Tabs with BUI Tabs | -| [migrate-mui-textfield-to-bui-textfield](./codemods/misc/migrate-mui-textfield-to-bui-textfield) | MUI 4 to BUI: Replace TextField with BUI TextField | +| [migrate-mui-textfield-to-bui-textfield](./codemods/misc/migrate-mui-textfield-to-bui-textfield) | MUI 4 to BUI: Replace TextField with BUI TextField / TextAreaField / PasswordField / NumberField | | [migrate-mui-tooltip-to-bui-tooltip](./codemods/misc/migrate-mui-tooltip-to-bui-tooltip) | MUI 4 to BUI: Replace Tooltip with TooltipTrigger | | [migrate-mui-typography-to-text](./codemods/misc/migrate-mui-typography-to-text) | MUI 4 to BUI: Replace Typography with Text. Package name omits -bui- because Text is the BUI export name. | | [mui4-to-bui-migration-recipe](./codemods/misc/mui4-to-bui-migration-recipe) | MUI 4 to BUI: Migration recipe that runs every deterministic MUI→BUI codemod from the registry in a safe order. | diff --git a/codemods/misc/README.md b/codemods/misc/README.md index 6c084bc5..2771bad9 100644 --- a/codemods/misc/README.md +++ b/codemods/misc/README.md @@ -11,7 +11,9 @@ Prefer the ordered recipe: ### Ordering 1. **Bootstrap first** — `@backstage/migrate-mui-bootstrap-to-bui` -2. **Transforms** — icons, styles/CSS modules, core and complex components, form controls, then layout (see the [recipe README](./mui4-to-bui-migration-recipe/README.md) for the full ordered list) +2. **Transforms** — icons → styles/CSS modules → core (Typography, Alert, Avatar, Skeleton, Button, IconButton, Link, Tooltip) → complex (Dialog, Tabs, Menu/Popover, List, Chip, Card) → forms (Select, TextField, Autocomplete, Accordion, radio/checkbox, Switch, Slider) → layout last 3. **Cleanup last** — `@backstage/remove-mui-dependencies` -See the [recipe README](./mui4-to-bui-migration-recipe/README.md) for registry links, domain coverage, out-of-scope items, and notes on intentional package names that omit `-bui-` in the target segment (`Text`, `Tag`, `ButtonIcon`, Remix). +New packages in this family: Avatar, Skeleton, Link, Card, Autocomplete→Combobox, Switch. Pre-publish dry-runs use [`workflow.local.yaml`](./mui4-to-bui-migration-recipe/workflow.local.yaml) against a sibling `../backstage` checkout. + +See the [recipe README](./mui4-to-bui-migration-recipe/README.md) for the full ordered table, Paper heuristic notes, registry links, out-of-scope items (Badge overlays, Progress, Drawer, Snackbar, Stepper, Timeline, material-table), and intentional package names that omit `-bui-` in the target segment (`Text`, `Tag`, `ButtonIcon`, Remix). diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/README.md b/codemods/misc/migrate-mui-accordion-to-bui-accordion/README.md new file mode 100644 index 00000000..0c76a5a3 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/README.md @@ -0,0 +1,21 @@ +# @backstage/migrate-mui-accordion-to-bui-accordion + +Migrates MUI 4 Accordion patterns to Backstage UI Accordion. + +## Covers + +- Named barrel / partial barrel `@material-ui/core` Accordion imports +- Simple Accordion → BUI Accordion shell +- Merging into an existing `@backstage/ui` import + +## TODOs / won't-do + +- Complex summary content (`complex-summary-todo`) +- Controlled Accordion edge cases (`controlled-todo`) +- Custom Accordion wrappers / theme overrides + +## Test + +```bash +yarn test +``` diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/README.md b/codemods/misc/migrate-mui-alert-to-bui-alert/README.md new file mode 100644 index 00000000..5d75910a --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/README.md @@ -0,0 +1,20 @@ +# @backstage/migrate-mui-alert-to-bui-alert + +Migrates MUI 4 `Alert` to Backstage UI `Alert`. + +## Covers + +- Deep `@material-ui/core/Alert` and barrel imports +- Common severity / children patterns +- Merging into an existing `@backstage/ui` import + +## TODOs / won't-do + +- Unsupported MUI Alert props and action layouts beyond the happy path +- Custom Alert wrappers + +## Test + +```bash +yarn test +``` diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/CHANGELOG.md b/codemods/misc/migrate-mui-autocomplete-to-combobox/CHANGELOG.md new file mode 100644 index 00000000..8acad89f --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/CHANGELOG.md @@ -0,0 +1,7 @@ +# @backstage/migrate-mui-autocomplete-to-combobox + +## 0.1.0 + +### Minor Changes + +- Initial release: heuristically migrate MUI `Autocomplete` to Backstage UI `Combobox` or `SearchAutocomplete` during the MUI 4 to BUI migration. diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/README.md b/codemods/misc/migrate-mui-autocomplete-to-combobox/README.md new file mode 100644 index 00000000..701aeb86 --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/README.md @@ -0,0 +1,22 @@ +# @backstage/migrate-mui-autocomplete-to-combobox + +Heuristically migrates MUI 4 `Autocomplete` to Backstage UI `Combobox` or `SearchAutocomplete`. + +## Covers + +- Form-like option pickers with string[] literal `options` → `Combobox` +- Clear free-text search (`onInputChange` + word-boundary search/find/filter cue, no selection value) → `SearchAutocomplete` +- Deep and barrel imports from `@material-ui/lab` +- Best-effort option literal flattening for string arrays +- Prunes unused `TextField` imports left behind by `renderInput` + +## TODOs left for humans + +- Ambiguous Autocomplete usage (left as MUI with a TODO) +- Object `options` / `getOptionLabel` shapes (not safe for BUI Combobox) +- `freeSolo`, custom `renderOption`, and other complex cases +- Non-trivial `onChange` / options shapes + +## Won't do + +- Perfect API parity for every Autocomplete prop diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/codemod.yaml b/codemods/misc/migrate-mui-autocomplete-to-combobox/codemod.yaml new file mode 100644 index 00000000..ed279855 --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-autocomplete-to-combobox' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Autocomplete with BUI Combobox or SearchAutocomplete' +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', 'autocomplete', 'combobox'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/package.json b/codemods/misc/migrate-mui-autocomplete-to-combobox/package.json new file mode 100644 index 00000000..40ce452a --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-autocomplete-to-combobox", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Autocomplete with BUI Combobox or SearchAutocomplete", + "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.13" + } +} diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/scripts/codemod.ts b/codemods/misc/migrate-mui-autocomplete-to-combobox/scripts/codemod.ts new file mode 100644 index 00000000..71d813d9 --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/scripts/codemod.ts @@ -0,0 +1,678 @@ +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-autocomplete-to-combobox') + +const BUI_SOURCE = '@backstage/ui' +const MUI_LAB_BARREL = '@material-ui/lab' +const MUI_LAB_AUTOCOMPLETE = '@material-ui/lab/Autocomplete' + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function withTodoComment(comment: string, elementText: string): string { + return `<> + ${comment} + ${elementText} +` +} + +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 getImportedName(spec: SgNode): string | null { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + return identifiers[0]?.text() ?? null +} + +function getPrunedBarrelImportText(imp: SgNode, namesToRemove: string[], source: string): string { + const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { + const importedName = getImportedName(spec) + return importedName !== null && !namesToRemove.includes(importedName) + }) + + if (remainingSpecs.length === 0) { + return '' + } + + const typeOnly = imp.text().trimStart().startsWith('import type') + const isMultiline = imp.text().includes('\n') + const specTexts = remainingSpecs.map((spec) => spec.text()) + const typeKw = typeOnly ? 'type ' : '' + + return isMultiline + ? `import ${typeKw}{\n ${specTexts.join(',\n ')},\n} from '${source}';` + : `import ${typeKw}{ ${specTexts.join(', ')} } from '${source}';` +} + +function collectAutocompleteImports(rootNode: SgNode): { + localName: string | null + importNodesToRemove: SgNode[] + importSpecifiersToRemove: Map, { source: string; names: string[] }> +} { + let localName: string | null = null + const importNodesToRemove: SgNode[] = [] + const importSpecifiersToRemove = new Map, { source: string; names: string[] }>() + + for (const imp of findImportStatementsFrom(rootNode, MUI_LAB_AUTOCOMPLETE)) { + localName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + for (const imp of findImportStatementsFrom(rootNode, MUI_LAB_BARREL)) { + const name = getNamedImportLocalName(imp, 'Autocomplete') + if (name) { + localName = name + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (allSpecifiers.length <= 1) { + importNodesToRemove.push(imp) + } else { + importSpecifiersToRemove.set(imp, { source: MUI_LAB_BARREL, names: ['Autocomplete'] }) + } + } + } + + return { localName, importNodesToRemove, importSpecifiersToRemove } +} + +const MUI_TEXT_FIELD = '@material-ui/core/TextField' +const MUI_CORE_BARREL = '@material-ui/core' + +function collectTextFieldImports(rootNode: SgNode): { + localName: string | null + importNodesToRemove: SgNode[] + importSpecifiersToRemove: Map, { source: string; names: string[] }> +} { + let localName: string | null = null + const importNodesToRemove: SgNode[] = [] + const importSpecifiersToRemove = new Map, { source: string; names: string[] }>() + + for (const imp of findImportStatementsFrom(rootNode, MUI_TEXT_FIELD)) { + localName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + for (const imp of findImportStatementsFrom(rootNode, MUI_CORE_BARREL)) { + const name = getNamedImportLocalName(imp, 'TextField') + if (name) { + localName = name + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (allSpecifiers.length <= 1) { + importNodesToRemove.push(imp) + } else { + importSpecifiersToRemove.set(imp, { source: MUI_CORE_BARREL, names: ['TextField'] }) + } + } + } + + return { localName, importNodesToRemove, importSpecifiersToRemove } +} + +function isInsideElementIds(node: SgNode, elementIds: Set): boolean { + let current: SgNode | null = node + while (current) { + if (elementIds.has(current.id())) { + return true + } + current = current.parent() + } + return false +} + +function textFieldUsedOutsideMigrated( + rootNode: SgNode, + textFieldLocalName: string, + migratedElementIds: Set, +): boolean { + for (const el of rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + })) { + const opening = el.is('jsx_self_closing_element') ? el : el.child(0) + if (!opening || getElementName(opening) !== textFieldLocalName) { + continue + } + if (!isInsideElementIds(el, migratedElementIds)) { + return true + } + } + return false +} + +function addBuiImport( + rootNode: SgNode, + names: string[], + importNodesToRemove: SgNode[], + importSpecifiersToRemove: Map, { source: string; names: string[] }>, + edits: Edit[], + handledBarrelIds: Set, + replacedDeepImportIds: Set, +): void { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + const sortedNames = [...names].sort() + const buiImport = `import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';` + + if (existingImport) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) + const existingImported = new Set() + const existingTexts: string[] = [] + for (const spec of specifiers) { + const imported = getImportedName(spec) + if (imported) { + existingImported.add(imported) + } + existingTexts.push(spec.text()) + } + for (const name of names) { + if (!existingImported.has(name)) { + existingTexts.push(name) + existingImported.add(name) + } + } + existingTexts.sort() + edits.push(namedImports.replace(`{ ${existingTexts.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + return + } + + const skipIds = new Set([ + ...importNodesToRemove.map((imp) => imp.id()), + ...[...importSpecifiersToRemove.keys()].map((imp) => imp.id()), + ]) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) + migrationMetric.increment({ action: 'import-added' }) + return + } + + const [barrelToFold] = [...importSpecifiersToRemove.entries()] + if (barrelToFold) { + const [imp, { source, names: namesToRemove }] = barrelToFold + const pruned = getPrunedBarrelImportText(imp, namesToRemove, source) + edits.push(imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) + handledBarrelIds.add(imp.id()) + migrationMetric.increment({ action: 'import-added' }) + migrationMetric.increment({ action: 'import-removed' }) + return + } + + const [deep] = importNodesToRemove + if (deep) { + edits.push(deep.replace(buiImport)) + replacedDeepImportIds.add(deep.id()) + migrationMetric.increment({ action: 'import-added' }) + } +} + +function getElementName(opening: SgNode): string | null { + for (const child of opening.children()) { + if (child.is('identifier') || child.is('member_expression')) { + return child.text() + } + } + return null +} + +function getPropAttr(opening: SgNode, propName: string): SgNode | null { + return opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) +} + +function hasProp(opening: SgNode, propName: string): boolean { + return getPropAttr(opening, propName) !== null +} + +function getPropRawValue(opening: SgNode, propName: string): string | null { + const attr = getPropAttr(opening, propName) + if (!attr) { + return null + } + for (const child of attr.children()) { + const kind = child.kind() + if (kind === 'string' || kind === 'jsx_expression') { + return child.text() + } + } + // boolean prop + return 'true' +} + +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 unwrapJsxExpression(raw: string): string { + if (raw.startsWith('{') && raw.endsWith('}')) { + return raw.slice(1, -1).trim() + } + return raw +} + +function looksLikeSearchText(text: string | null): boolean { + if (!text) { + return false + } + // Word-boundary allowlist — avoid false positives like "Research" / "Searchable". + return /\b(search|find|filter)\b/i.test(text) +} + +function extractRenderInputFieldProp(opening: SgNode, propName: 'label' | 'placeholder'): string | null { + const attr = getPropAttr(opening, 'renderInput') + if (!attr) { + return null + } + const stringAttr = attr.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) + if (!stringAttr) { + return null + } + const stringNode = stringAttr.find({ rule: { kind: 'string' } }) + const frag = stringNode?.find({ rule: { kind: 'string_fragment' } }) + return frag?.text() ?? null +} + +function simplifySecondArgCallback(rawExpr: string): string | null { + const expr = unwrapJsxExpression(rawExpr) + + // (_event, next) => onChange(next) OR (_e, v) => setQuery(v) + const arrowCall = expr.match( + /^\(\s*[_a-zA-Z][\w$]*\s*,\s*([_a-zA-Z][\w$]*)\s*\)\s*=>\s*([_a-zA-Z][\w$]*)\(\s*\1\s*\)$/, + ) + if (arrowCall?.[2]) { + return arrowCall[2] + } + + // (_event, next) => { onChange(next) } + const arrowBlock = expr.match( + /^\(\s*[_a-zA-Z][\w$]*\s*,\s*([_a-zA-Z][\w$]*)\s*\)\s*=>\s*\{\s*([_a-zA-Z][\w$]*)\(\s*\1\s*\)\s*;?\s*\}$/, + ) + if (arrowBlock?.[2]) { + return arrowBlock[2] + } + + // identifier handler — keep as-is (signature differs, but best-effort) + if (/^[_a-zA-Z][\w$]*$/.test(expr)) { + return expr + } + + return null +} + +function flattenStringOptionsLiteral(optionsRaw: string): string | null { + const expr = unwrapJsxExpression(optionsRaw) + const arrayMatch = expr.match(/^\[\s*([\s\S]*)\s*\]$/) + if (!arrayMatch) { + return null + } + const inner = arrayMatch[1] ?? '' + if (inner.trim().length === 0) { + return '[]' + } + + const stringLiterals = [...inner.matchAll(/(['"])([^'"\\]*)\1/g)].map((m) => m[2] ?? '') + // Require the array to be only string literals (best-effort flatten). + const withoutStrings = inner + .replaceAll(/(['"])([^'"\\]*)\1/g, '') + .replaceAll(',', '') + .trim() + if (withoutStrings.length > 0 || stringLiterals.length === 0) { + return null + } + + const objects = stringLiterals.map((value) => `{ value: '${value}', label: '${value}' }`) + return `[${objects.join(', ')}]` +} + +type AutocompleteKind = 'combobox' | 'search' | 'todo' + +function classifyAutocomplete(opening: SgNode): AutocompleteKind { + const hasOptions = hasProp(opening, 'options') + const hasGetOptionLabel = hasProp(opening, 'getOptionLabel') + const hasValue = hasProp(opening, 'value') + const hasOnChange = hasProp(opening, 'onChange') + const hasOnInputChange = hasProp(opening, 'onInputChange') + const hasFreeSolo = hasProp(opening, 'freeSolo') + const hasRenderOption = hasProp(opening, 'renderOption') + const hasMultiple = hasProp(opening, 'multiple') + + const placeholder = extractRenderInputFieldProp(opening, 'placeholder') + const label = extractRenderInputFieldProp(opening, 'label') + const searchLike = looksLikeSearchText(placeholder) || looksLikeSearchText(label) + + if (hasRenderOption || hasMultiple) { + return 'todo' + } + + // Clear free-text search: onInputChange + search cue, no selection value binding. + if (hasOnInputChange && searchLike && !hasValue && !hasOnChange) { + return 'search' + } + + // Ambiguous: both selection and free-text input handlers. + if (hasOnInputChange && (hasValue || hasOnChange)) { + return 'todo' + } + + // freeSolo outside the clear search path is too risky for Combobox. + if (hasFreeSolo) { + return 'todo' + } + + // Form-like option picker → Combobox only when options are a string[] literal + // we can reshape to `{ value, label }`. Object options + getOptionLabel are unsafe. + if (hasOptions && (hasGetOptionLabel || hasValue || hasOnChange)) { + const optionsRaw = getPropRawValue(opening, 'options') + if (optionsRaw && flattenStringOptionsLiteral(optionsRaw)) { + return 'combobox' + } + return 'todo' + } + + return 'todo' +} + +function buildComboboxReplacement(opening: SgNode): string | null { + const optionsRaw = getPropRawValue(opening, 'options') + if (!optionsRaw) { + return null + } + + const flattened = flattenStringOptionsLiteral(optionsRaw) + const optionsExpr = flattened ?? unwrapJsxExpression(optionsRaw) + + const props: string[] = [`options={${optionsExpr}}`] + + const valueRaw = getPropRawValue(opening, 'value') + if (valueRaw && valueRaw !== 'true') { + props.push(`value={${unwrapJsxExpression(valueRaw)}}`) + } + + const onChangeRaw = getPropRawValue(opening, 'onChange') + if (onChangeRaw && onChangeRaw !== 'true') { + const simplified = simplifySecondArgCallback(onChangeRaw) + if (!simplified) { + return null + } + props.push(`onSelectionChange={${simplified}}`) + } + + const label = extractRenderInputFieldProp(opening, 'label') + if (label) { + props.push(`label="${label}"`) + } + + const placeholder = extractRenderInputFieldProp(opening, 'placeholder') + if (placeholder) { + props.push(`placeholder="${placeholder}"`) + } + + const nameStr = getPropStringValue(opening, 'name') + if (nameStr) { + props.push(`name="${nameStr}"`) + } + + return `` +} + +function buildSearchReplacement(opening: SgNode): string | null { + const props: string[] = [] + + const inputValueRaw = getPropRawValue(opening, 'inputValue') + if (inputValueRaw && inputValueRaw !== 'true') { + props.push(`inputValue={${unwrapJsxExpression(inputValueRaw)}}`) + } + + const onInputChangeRaw = getPropRawValue(opening, 'onInputChange') + if (onInputChangeRaw && onInputChangeRaw !== 'true') { + const simplified = simplifySecondArgCallback(onInputChangeRaw) + if (!simplified) { + return null + } + props.push(`onInputChange={${simplified}}`) + } + + const placeholder = extractRenderInputFieldProp(opening, 'placeholder') + if (placeholder) { + props.push(`placeholder="${placeholder}"`) + } + + return `` +} + +function transformAutocompleteElements( + rootNode: SgNode, + localName: string, + edits: Edit[], + buiNames: Set, +): { migrated: boolean; preserveImport: boolean; migratedElementIds: Set } { + let migrated = false + let preserveImport = false + const migratedElementIds = new Set() + + 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 + } + if (getElementName(opening) !== localName) { + continue + } + + const kind = classifyAutocomplete(opening) + if (kind === 'todo') { + edits.push( + el.replace( + withTodoComment( + `{/* TODO(backstage-codemod): verify Autocomplete migration manually — ambiguous Combobox vs SearchAutocomplete */}`, + el.text(), + ), + ), + ) + preserveImport = true + migrationMetric.increment({ action: 'todo-inserted', reason: 'ambiguous-autocomplete' }) + continue + } + + if (kind === 'combobox') { + const replacement = buildComboboxReplacement(opening) + if (!replacement) { + edits.push( + el.replace( + withTodoComment( + `{/* TODO(backstage-codemod): verify Autocomplete migration manually — ambiguous Combobox vs SearchAutocomplete */}`, + el.text(), + ), + ), + ) + preserveImport = true + migrationMetric.increment({ action: 'todo-inserted', reason: 'combobox-unmapped' }) + continue + } + edits.push(el.replace(replacement)) + buiNames.add('Combobox') + migrated = true + migratedElementIds.add(el.id()) + migrationMetric.increment({ action: 'autocomplete-to-combobox' }) + continue + } + + const replacement = buildSearchReplacement(opening) + if (!replacement) { + edits.push( + el.replace( + withTodoComment( + `{/* TODO(backstage-codemod): verify Autocomplete migration manually — ambiguous Combobox vs SearchAutocomplete */}`, + el.text(), + ), + ), + ) + preserveImport = true + migrationMetric.increment({ action: 'todo-inserted', reason: 'search-unmapped' }) + continue + } + edits.push(el.replace(replacement)) + buiNames.add('SearchAutocomplete') + migrated = true + migratedElementIds.add(el.id()) + migrationMetric.increment({ action: 'autocomplete-to-search' }) + } + + return { migrated, preserveImport, migratedElementIds } +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { localName, importNodesToRemove, importSpecifiersToRemove } = collectAutocompleteImports(rootNode) + if (!localName) { + return null + } + + const buiNames = new Set() + const { migrated, preserveImport, migratedElementIds } = transformAutocompleteElements( + rootNode, + localName, + edits, + buiNames, + ) + + // Fold unused TextField imports into the same removal pass so addBuiImport + // does not append onto a TextField import that we later delete. + if (migratedElementIds.size > 0) { + const textFieldImports = collectTextFieldImports(rootNode) + if ( + textFieldImports.localName && + !textFieldUsedOutsideMigrated(rootNode, textFieldImports.localName, migratedElementIds) + ) { + importNodesToRemove.push(...textFieldImports.importNodesToRemove) + for (const [imp, info] of textFieldImports.importSpecifiersToRemove) { + const existing = importSpecifiersToRemove.get(imp) + if (existing) { + existing.names.push(...info.names) + } else { + importSpecifiersToRemove.set(imp, info) + } + } + } + } + + const handledBarrelIds = new Set() + const replacedDeepImportIds = new Set() + + if (migrated && buiNames.size > 0) { + addBuiImport( + rootNode, + [...buiNames], + importNodesToRemove, + importSpecifiersToRemove, + edits, + handledBarrelIds, + replacedDeepImportIds, + ) + } + + if (!preserveImport) { + for (const [imp, { source, names }] of importSpecifiersToRemove) { + if (handledBarrelIds.has(imp.id())) { + continue + } + edits.push(imp.replace(getPrunedBarrelImportText(imp, names, source))) + migrationMetric.increment({ action: 'import-removed' }) + } + for (const imp of importNodesToRemove) { + if (replacedDeepImportIds.has(imp.id())) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + } + + const result = await Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) + return result +} + +export default transform diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/ambiguous-todo/expected.tsx b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/ambiguous-todo/expected.tsx new file mode 100644 index 00000000..ea914b2c --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/ambiguous-todo/expected.tsx @@ -0,0 +1,23 @@ +import Autocomplete from '@material-ui/lab/Autocomplete'; +import TextField from '@material-ui/core/TextField'; + +const Ambiguous = ({ + value, + setValue, + setQuery, +}: { + value: string | null; + setValue: (value: string | null) => void; + setQuery: (value: string) => void; +}) => ( + <> + {/* TODO(backstage-codemod): verify Autocomplete migration manually — ambiguous Combobox vs SearchAutocomplete */} + setValue(next)} + onInputChange={(_event, next) => setQuery(next)} + renderInput={(params) => } + /> + +); diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/ambiguous-todo/input.tsx b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/ambiguous-todo/input.tsx new file mode 100644 index 00000000..ecb8f956 --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/ambiguous-todo/input.tsx @@ -0,0 +1,20 @@ +import Autocomplete from '@material-ui/lab/Autocomplete'; +import TextField from '@material-ui/core/TextField'; + +const Ambiguous = ({ + value, + setValue, + setQuery, +}: { + value: string | null; + setValue: (value: string | null) => void; + setQuery: (value: string) => void; +}) => ( + setValue(next)} + onInputChange={(_event, next) => setQuery(next)} + renderInput={(params) => } + /> +); diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/ambiguous-todo/metrics.json b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/ambiguous-todo/metrics.json new file mode 100644 index 00000000..56ea9f02 --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/ambiguous-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-autocomplete-to-combobox": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "ambiguous-autocomplete" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/combobox-success/expected.tsx b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/combobox-success/expected.tsx new file mode 100644 index 00000000..8dda18a3 --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/combobox-success/expected.tsx @@ -0,0 +1,12 @@ +import { Combobox } from '@backstage/ui'; + + +const FruitPicker = ({ + value, + onChange, +}: { + value: string | null; + onChange: (value: string | null) => void; +}) => ( + +); diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/combobox-success/input.tsx b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/combobox-success/input.tsx new file mode 100644 index 00000000..434494e8 --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/combobox-success/input.tsx @@ -0,0 +1,18 @@ +import Autocomplete from '@material-ui/lab/Autocomplete'; +import TextField from '@material-ui/core/TextField'; + +const FruitPicker = ({ + value, + onChange, +}: { + value: string | null; + onChange: (value: string | null) => void; +}) => ( + option} + value={value} + onChange={(_event, next) => onChange(next)} + renderInput={(params) => } + /> +); diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/combobox-success/metrics.json b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/combobox-success/metrics.json new file mode 100644 index 00000000..a3e63ef4 --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/combobox-success/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-autocomplete-to-combobox": [ + { + "cardinality": { + "action": "autocomplete-to-combobox" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 2 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/noop-no-import/expected.tsx new file mode 100644 index 00000000..618943e4 --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/noop-no-import/expected.tsx @@ -0,0 +1,3 @@ +import { Combobox } from '@backstage/ui'; + +const Already = () => ; diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/noop-no-import/input.tsx new file mode 100644 index 00000000..618943e4 --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/noop-no-import/input.tsx @@ -0,0 +1,3 @@ +import { Combobox } from '@backstage/ui'; + +const Already = () => ; diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/object-options-todo/expected.tsx b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/object-options-todo/expected.tsx new file mode 100644 index 00000000..924da09f --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/object-options-todo/expected.tsx @@ -0,0 +1,23 @@ +import Autocomplete from '@material-ui/lab/Autocomplete'; +import TextField from '@material-ui/core/TextField'; + +const EntityPicker = ({ + value, + onChange, + options, +}: { + value: { id: string; name: string } | null; + onChange: (value: { id: string; name: string } | null) => void; + options: { id: string; name: string }[]; +}) => ( + <> + {/* TODO(backstage-codemod): verify Autocomplete migration manually — ambiguous Combobox vs SearchAutocomplete */} + option.name} + value={value} + onChange={(_event, next) => onChange(next)} + renderInput={(params) => } + /> + +); diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/object-options-todo/input.tsx b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/object-options-todo/input.tsx new file mode 100644 index 00000000..30e7c7ed --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/object-options-todo/input.tsx @@ -0,0 +1,20 @@ +import Autocomplete from '@material-ui/lab/Autocomplete'; +import TextField from '@material-ui/core/TextField'; + +const EntityPicker = ({ + value, + onChange, + options, +}: { + value: { id: string; name: string } | null; + onChange: (value: { id: string; name: string } | null) => void; + options: { id: string; name: string }[]; +}) => ( + option.name} + value={value} + onChange={(_event, next) => onChange(next)} + renderInput={(params) => } + /> +); diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/object-options-todo/metrics.json b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/object-options-todo/metrics.json new file mode 100644 index 00000000..56ea9f02 --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/object-options-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-autocomplete-to-combobox": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "ambiguous-autocomplete" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/research-label-todo/expected.tsx b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/research-label-todo/expected.tsx new file mode 100644 index 00000000..66b3f08c --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/research-label-todo/expected.tsx @@ -0,0 +1,21 @@ +import Autocomplete from '@material-ui/lab/Autocomplete'; +import TextField from '@material-ui/core/TextField'; + +const ResearchBox = ({ + query, + setQuery, +}: { + query: string; + setQuery: (value: string) => void; +}) => ( + <> + {/* TODO(backstage-codemod): verify Autocomplete migration manually — ambiguous Combobox vs SearchAutocomplete */} + setQuery(next)} + renderInput={(params) => } + /> + +); diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/research-label-todo/input.tsx b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/research-label-todo/input.tsx new file mode 100644 index 00000000..55135d8d --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/research-label-todo/input.tsx @@ -0,0 +1,18 @@ +import Autocomplete from '@material-ui/lab/Autocomplete'; +import TextField from '@material-ui/core/TextField'; + +const ResearchBox = ({ + query, + setQuery, +}: { + query: string; + setQuery: (value: string) => void; +}) => ( + setQuery(next)} + renderInput={(params) => } + /> +); diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/research-label-todo/metrics.json b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/research-label-todo/metrics.json new file mode 100644 index 00000000..56ea9f02 --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/research-label-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-autocomplete-to-combobox": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "ambiguous-autocomplete" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/search-autocomplete/expected.tsx b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/search-autocomplete/expected.tsx new file mode 100644 index 00000000..648f1eee --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/search-autocomplete/expected.tsx @@ -0,0 +1,12 @@ +import { SearchAutocomplete } from '@backstage/ui'; + + +const SearchBox = ({ + query, + setQuery, +}: { + query: string; + setQuery: (value: string) => void; +}) => ( + +); diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/search-autocomplete/input.tsx b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/search-autocomplete/input.tsx new file mode 100644 index 00000000..91adefe3 --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/search-autocomplete/input.tsx @@ -0,0 +1,18 @@ +import Autocomplete from '@material-ui/lab/Autocomplete'; +import TextField from '@material-ui/core/TextField'; + +const SearchBox = ({ + query, + setQuery, +}: { + query: string; + setQuery: (value: string) => void; +}) => ( + setQuery(next)} + renderInput={(params) => } + /> +); diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/search-autocomplete/metrics.json b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/search-autocomplete/metrics.json new file mode 100644 index 00000000..4b6d1e51 --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/search-autocomplete/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-autocomplete-to-combobox": [ + { + "cardinality": { + "action": "autocomplete-to-search" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 2 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tsconfig.json b/codemods/misc/migrate-mui-autocomplete-to-combobox/tsconfig.json new file mode 100644 index 00000000..5ad4d6f0 --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/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-autocomplete-to-combobox/workflow.yaml b/codemods/misc/migrate-mui-autocomplete-to-combobox/workflow.yaml new file mode 100644 index 00000000..1c29c7bc --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/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 Autocomplete with Combobox or SearchAutocomplete' + 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-avatar-to-bui-avatar/CHANGELOG.md b/codemods/misc/migrate-mui-avatar-to-bui-avatar/CHANGELOG.md new file mode 100644 index 00000000..e3c11d2c --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/CHANGELOG.md @@ -0,0 +1,7 @@ +# @backstage/migrate-mui-avatar-to-bui-avatar + +## 0.1.0 + +### Minor Changes + +- Initial release: migrate MUI `Avatar` to Backstage UI `Avatar` during the MUI 4 to BUI migration. diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/README.md b/codemods/misc/migrate-mui-avatar-to-bui-avatar/README.md new file mode 100644 index 00000000..86ebc988 --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/README.md @@ -0,0 +1,20 @@ +# @backstage/migrate-mui-avatar-to-bui-avatar + +Migrates MUI 4 `Avatar` usage to Backstage UI `Avatar`. + +## Covers + +- Named barrel and deep `@material-ui/core/Avatar` imports +- `alt` → `name` when `name` is absent; requires `src` (empty string when missing) +- Reasonable pixel `style` width/height → BUI `size` +- Merging into an existing `@backstage/ui` import + +## TODOs left for humans + +- Missing both `name` and `alt` +- Unknown / unmapped sizes +- `variant`, `classes`, and other unsupported MUI props + +## Won't do + +- Rewriting custom children renderings beyond initials fallback via `name` diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/codemod.yaml b/codemods/misc/migrate-mui-avatar-to-bui-avatar/codemod.yaml new file mode 100644 index 00000000..f4c05eac --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-avatar-to-bui-avatar' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Avatar with BUI Avatar' +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', 'avatar'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/package.json b/codemods/misc/migrate-mui-avatar-to-bui-avatar/package.json new file mode 100644 index 00000000..9d6f89d2 --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-avatar-to-bui-avatar", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Avatar with BUI Avatar", + "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.13" + } +} diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/scripts/codemod.ts b/codemods/misc/migrate-mui-avatar-to-bui-avatar/scripts/codemod.ts new file mode 100644 index 00000000..a96cd3f2 --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/scripts/codemod.ts @@ -0,0 +1,485 @@ +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-avatar-to-bui-avatar') + +const BUI_SOURCE = '@backstage/ui' +const MUI_BARREL_SOURCE = '@material-ui/core' + +const PASSTHROUGH_PROPS = new Set(['className', 'id', 'onClick', 'onMouseEnter', 'onMouseLeave', 'title']) + +const TODO_PROPS = new Set(['variant', 'classes', 'imgProps', 'component', 'sizes', 'srcSet']) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function withTodoComment(comment: string, elementText: string): string { + return `<> + ${comment} + ${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: { + 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 collectAvatarImports(rootNode: SgNode): { + avatarLocalName: string | null + importNodesToRemove: SgNode[] + barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] +} { + let avatarLocalName: string | null = null + const importNodesToRemove: SgNode[] = [] + const barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] = [] + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Avatar')) { + avatarLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL_SOURCE)) { + const localName = getNamedImportLocalName(imp, 'Avatar') + if (localName) { + avatarLocalName = localName + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (allSpecifiers.length <= 1) { + importNodesToRemove.push(imp) + } else { + barrelImportsToPrune.push({ imp, namesToRemove: new Set(['Avatar']) }) + } + } + } + + return { avatarLocalName, importNodesToRemove, barrelImportsToPrune } +} + +function addBuiImport( + rootNode: SgNode, + importNodesToRemove: SgNode[], + barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[], + edits: Edit[], + handledBarrelIds: Set, +): boolean { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const alreadyImported = getNamedImportLocalName(existingImport, 'Avatar') !== null + if (!alreadyImported) { + 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) + names.push('Avatar') + names.sort() + edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } else { + edits.push(existingImport.replace(`${existingImport.text()}\nimport { Avatar } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + } + } + return false + } + + const skipIds = new Set([ + ...importNodesToRemove.map((imp) => imp.id()), + ...barrelImportsToPrune.map(({ imp }) => imp.id()), + ]) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null + const buiImport = `import { Avatar } from '${BUI_SOURCE}';` + + if (anchorImport) { + edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) + migrationMetric.increment({ action: 'import-added' }) + return false + } + + const [barrelToFold] = barrelImportsToPrune + if (barrelToFold) { + const pruned = rebuildImportWithout(barrelToFold.imp, barrelToFold.namesToRemove) + edits.push(barrelToFold.imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) + handledBarrelIds.add(barrelToFold.imp.id()) + migrationMetric.increment({ action: 'import-added' }) + migrationMetric.increment({ action: 'import-pruned' }) + return false + } + + if (importNodesToRemove.length >= 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(buiImport)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } + + 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 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 '' +} + +function mapPixelSize(px: number): string | null { + if (px <= 24) { + return 'x-small' + } + if (px <= 32) { + return 'small' + } + if (px <= 40) { + return 'medium' + } + if (px <= 56) { + return 'large' + } + if (px <= 80) { + return 'x-large' + } + return null +} + +/** + * Extract a numeric width from `style={{ width: N, height: N }}` when both are + * the same number literal. Returns null if style is absent or unmapped. + */ +function extractStyleSize(opening: SgNode): { size: string | null; unknown: boolean; hasStyle: boolean } { + const styleAttr = getPropAttr(opening, 'style') + if (!styleAttr) { + return { size: null, unknown: false, hasStyle: false } + } + + const expr = styleAttr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return { size: null, unknown: true, hasStyle: true } + } + + const obj = expr.find({ rule: { kind: 'object' } }) + if (!obj) { + return { size: null, unknown: true, hasStyle: true } + } + + let width: number | null = null + let height: number | null = null + + for (const pair of obj.findAll({ rule: { kind: 'pair' } })) { + const key = pair.child(0)?.text() + const valueNode = pair.child(2) ?? pair.children().at(-1) + const valueText = valueNode?.text() + if (!key || valueText === undefined) { + continue + } + if (!/^\d+(\.\d+)?$/.test(valueText)) { + continue + } + const num = Number(valueText) + if (key === 'width' || key === '"width"' || key === "'width'") { + width = num + } + if (key === 'height' || key === '"height"' || key === "'height'") { + height = num + } + } + + if (width === null && height === null) { + // style present but no numeric width/height we understand + return { size: null, unknown: true, hasStyle: true } + } + + const px = width ?? height + if (px === null) { + return { size: null, unknown: true, hasStyle: true } + } + if (width !== null && height !== null && width !== height) { + return { size: null, unknown: true, hasStyle: true } + } + + const mapped = mapPixelSize(px) + if (mapped === null) { + return { size: null, unknown: true, hasStyle: true } + } + return { size: mapped, unknown: false, hasStyle: true } +} + +function transformAvatarElements( + rootNode: SgNode, + avatarLocalName: 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 !== avatarLocalName) { + continue + } + + const todoReasons: string[] = [] + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + todoReasons.push(prop) + } + } + + const nameString = getPropStringValue(opening, 'name') + const altString = getPropStringValue(opening, 'alt') + const nameRaw = getPropRawValue(opening, 'name') + const altRaw = getPropRawValue(opening, 'alt') + + let resolvedName: string | null = nameString + if (resolvedName === null && altString !== null) { + resolvedName = altString + } + + if (resolvedName === null) { + if (nameRaw !== null || altRaw !== null) { + todoReasons.push('complex-name') + } else { + todoReasons.push('missing-name') + } + } + + const { size: mappedSize, unknown: unknownSize, hasStyle } = extractStyleSize(opening) + if (unknownSize) { + todoReasons.push('unknown-size') + } + + if (todoReasons.length > 0) { + preserveImport = true + edits.push( + el.replace( + withTodoComment( + `{/* TODO(backstage-codemod): finish avatar migration manually (${todoReasons.join(', ')}) */}`, + el.text(), + ), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: todoReasons.join(', ') }) + continue + } + + const newProps: string[] = [] + newProps.push(`name="${resolvedName}"`) + + const srcString = getPropStringValue(opening, 'src') + const srcRaw = getPropRawValue(opening, 'src') + if (srcString !== null) { + newProps.push(`src="${srcString}"`) + } else if (srcRaw !== null && srcRaw !== '') { + newProps.push(`src=${srcRaw}`) + } else { + newProps.push('src=""') + } + + if (mappedSize) { + newProps.push(`size="${mappedSize}"`) + migrationMetric.increment({ action: 'size-mapped', size: mappedSize }) + } + + for (const child of opening.children()) { + const kind = child.kind() + if (kind === 'jsx_attribute') { + const propIdent = child.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + const propName = propIdent.text() + if (propName === 'alt' || propName === 'name' || propName === 'src' || propName === 'style') { + continue + } + if (PASSTHROUGH_PROPS.has(propName) || propName.startsWith('aria-') || propName.startsWith('data-')) { + newProps.push(child.text()) + } + } else if (kind === 'jsx_expression' && child.text().startsWith('{...')) { + newProps.push(child.text()) + } + } + + // Drop children (initials) — BUI derives fallback from name + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + edits.push(el.replace(``)) + migrated = true + migrationMetric.increment({ + action: 'avatar-migrated', + droppedStyle: hasStyle && mappedSize !== null ? 'true' : 'false', + }) + } + + return { preserveImport, migrated } +} + +const transform: Codemod = (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { avatarLocalName, importNodesToRemove, barrelImportsToPrune } = collectAvatarImports(rootNode) + + if (!avatarLocalName) { + return Promise.resolve(null) + } + + const { preserveImport, migrated } = transformAvatarElements(rootNode, avatarLocalName, edits) + + let replacedImport = false + const handledBarrelIds = new Set() + if (migrated) { + replacedImport = addBuiImport(rootNode, importNodesToRemove, barrelImportsToPrune, edits, handledBarrelIds) + } + + if (!preserveImport) { + for (const { imp, namesToRemove } of barrelImportsToPrune) { + if (handledBarrelIds.has(imp.id())) { + continue + } + 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) +} + +export default transform diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/core-import/expected.tsx b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/core-import/expected.tsx new file mode 100644 index 00000000..954fdfc6 --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/core-import/expected.tsx @@ -0,0 +1,5 @@ +import { Avatar } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/core-import/input.tsx b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/core-import/input.tsx new file mode 100644 index 00000000..bfd5345e --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/core-import/input.tsx @@ -0,0 +1,5 @@ +import Avatar from '@material-ui/core/Avatar'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/core-import/metrics.json b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/core-import/metrics.json new file mode 100644 index 00000000..d90998b8 --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/core-import/metrics.json @@ -0,0 +1,30 @@ +{ + "migrate-mui-avatar-to-bui-avatar": [ + { + "cardinality": { + "action": "avatar-migrated", + "droppedStyle": "true" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "size-mapped", + "size": "x-small" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/happy-path-alt-as-name/expected.tsx b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/happy-path-alt-as-name/expected.tsx new file mode 100644 index 00000000..51dacd64 --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/happy-path-alt-as-name/expected.tsx @@ -0,0 +1,5 @@ +import { Avatar } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/happy-path-alt-as-name/input.tsx b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/happy-path-alt-as-name/input.tsx new file mode 100644 index 00000000..a93cd976 --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/happy-path-alt-as-name/input.tsx @@ -0,0 +1,5 @@ +import { Avatar } from '@material-ui/core'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/happy-path-alt-as-name/metrics.json b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/happy-path-alt-as-name/metrics.json new file mode 100644 index 00000000..9f8acdca --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/happy-path-alt-as-name/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-avatar-to-bui-avatar": [ + { + "cardinality": { + "action": "avatar-migrated", + "droppedStyle": "false" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/merge-existing-bui/expected.tsx new file mode 100644 index 00000000..4f8b54be --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + +import { Avatar, Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/merge-existing-bui/input.tsx new file mode 100644 index 00000000..962350ea --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/merge-existing-bui/input.tsx @@ -0,0 +1,9 @@ +import Avatar from '@material-ui/core/Avatar'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/merge-existing-bui/metrics.json new file mode 100644 index 00000000..5caa07b7 --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/merge-existing-bui/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-avatar-to-bui-avatar": [ + { + "cardinality": { + "action": "avatar-migrated", + "droppedStyle": "false" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/missing-name-todo/expected.tsx b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/missing-name-todo/expected.tsx new file mode 100644 index 00000000..4398b516 --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/missing-name-todo/expected.tsx @@ -0,0 +1,8 @@ +import Avatar from '@material-ui/core/Avatar'; + +const MyComponent = () => ( + <> + {/* TODO(backstage-codemod): finish avatar migration manually (missing-name) */} + + +); diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/missing-name-todo/input.tsx b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/missing-name-todo/input.tsx new file mode 100644 index 00000000..c0f83a0c --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/missing-name-todo/input.tsx @@ -0,0 +1,5 @@ +import Avatar from '@material-ui/core/Avatar'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/metrics.json b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/missing-name-todo/metrics.json similarity index 55% rename from codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/metrics.json rename to codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/missing-name-todo/metrics.json index 9cba3f22..ac1c8d8c 100644 --- a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/metrics.json +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/missing-name-todo/metrics.json @@ -1,9 +1,9 @@ { - "migrate-mui-textfield-to-bui-textfield": [ + "migrate-mui-avatar-to-bui-avatar": [ { "cardinality": { "action": "todo-inserted", - "reason": "multiline, rows" + "reason": "missing-name" }, "count": 1 } diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/named-barrel-import/expected.tsx new file mode 100644 index 00000000..e1d34cf3 --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/named-barrel-import/expected.tsx @@ -0,0 +1,5 @@ +import { Avatar } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/named-barrel-import/input.tsx new file mode 100644 index 00000000..e96f9426 --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/named-barrel-import/input.tsx @@ -0,0 +1,5 @@ +import { Avatar } from '@material-ui/core'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/named-barrel-import/metrics.json new file mode 100644 index 00000000..9f8acdca --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/named-barrel-import/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-avatar-to-bui-avatar": [ + { + "cardinality": { + "action": "avatar-migrated", + "droppedStyle": "false" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/noop-no-import/expected.tsx new file mode 100644 index 00000000..4cde9313 --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import { Avatar } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/noop-no-import/input.tsx new file mode 100644 index 00000000..4cde9313 --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tests/noop-no-import/input.tsx @@ -0,0 +1,5 @@ +import { Avatar } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-avatar-to-bui-avatar/tsconfig.json b/codemods/misc/migrate-mui-avatar-to-bui-avatar/tsconfig.json new file mode 100644 index 00000000..5ad4d6f0 --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/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-avatar-to-bui-avatar/workflow.yaml b/codemods/misc/migrate-mui-avatar-to-bui-avatar/workflow.yaml new file mode 100644 index 00000000..861c828e --- /dev/null +++ b/codemods/misc/migrate-mui-avatar-to-bui-avatar/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 Avatar with BUI Avatar' + 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-bootstrap-to-bui/README.md b/codemods/misc/migrate-mui-bootstrap-to-bui/README.md new file mode 100644 index 00000000..6179de8d --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/README.md @@ -0,0 +1,20 @@ +# @backstage/migrate-mui-bootstrap-to-bui + +Bootstraps `@backstage/ui` / `@remixicon/react` dependencies and root BUI CSS for MUI 4 → BUI migrations. + +## Covers + +- Adds BUI + Remix deps when `@material-ui/*` is present +- Ensures a root CSS import where MUI is used +- Merges with existing BUI setup when already partially present + +## TODOs / won't-do + +- No-ops when there is no MUI usage (`noop-no-import`, icons-only / multi-import guards) +- Full theme / design-token migration + +## Test + +```bash +yarn test +``` diff --git a/codemods/misc/migrate-mui-button-to-bui-button/README.md b/codemods/misc/migrate-mui-button-to-bui-button/README.md new file mode 100644 index 00000000..a2184e0f --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/README.md @@ -0,0 +1,13 @@ +# migrate-mui-button-to-bui-button + +Migrates MUI 4 `Button` to Backstage UI `Button` / `ButtonLink`. + +## Silent mappings + +- `variant="outlined"` → `variant="secondary"` (BUI secondary is the outlined visual) +- `variant="contained"` → `variant="primary"` +- `variant="text"` → `variant="tertiary"` +- omitted `variant` → `variant="tertiary"` (MUI default is text; BUI default is primary) +- `startIcon` / `endIcon` → `iconStart` / `iconEnd` +- Simple `onClick` handlers → `onPress` +- `href` present → `ButtonLink` (same variant mapping) diff --git a/codemods/misc/migrate-mui-button-to-bui-button/codemod.yaml b/codemods/misc/migrate-mui-button-to-bui-button/codemod.yaml index 4a7fb1bf..2ffdfdf6 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/codemod.yaml +++ b/codemods/misc/migrate-mui-button-to-bui-button/codemod.yaml @@ -2,7 +2,7 @@ schema_version: '1.0' name: '@backstage/migrate-mui-button-to-bui-button' version: '0.2.1' -description: 'MUI 4 to BUI: Replace MUI Button with BUI Button' +description: 'MUI 4 to BUI: Replace MUI Button with BUI Button / ButtonLink' author: 'Paul Schultz' license: 'Apache-2.0' repository: 'https://github.com/backstage/codemods' diff --git a/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts b/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts index 625ae4f4..bd40a1c0 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts @@ -13,16 +13,12 @@ const VARIANT_MAP: Record = { const BUI_SOURCE = '@backstage/ui' /** Props that need TODO markers because their semantics don't map mechanically. */ -const TODO_PROPS = new Set([ - 'startIcon', - 'endIcon', - 'href', - 'component', - 'fullWidth', - 'disableElevation', - 'disableRipple', - 'disableFocusRipple', -]) +const TODO_PROPS = new Set(['component', 'fullWidth', 'disableElevation', 'disableRipple', 'disableFocusRipple']) + +const ICON_PROP_RENAMES: Record = { + startIcon: 'iconStart', + endIcon: 'iconEnd', +} function escapeRegex(str: string): string { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') @@ -124,46 +120,65 @@ function pruneBarrelImportSpecifiers(imp: SgNode, namesToRemove: string[], migrationMetric.increment({ action: 'import-removed' }) } -function addButtonToBuiImport(rootNode: SgNode, importNodesToRemove: SgNode[], edits: Edit[]): boolean { +function addBuiImport( + rootNode: SgNode, + names: string[], + importNodesToRemove: SgNode[], + edits: Edit[], +): boolean { const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) const existingImport = existingImports[0] ?? null + const sortedNames = [...names].sort() if (existingImport) { - const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) - const hasButton = specifiers.some((spec) => getImportedName(spec) === 'Button') - if (!hasButton) { - const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) - if (namedImports) { - const names = specifiers.map((spec) => spec.text()) - names.push('Button') - names.sort() - edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } else { - edits.push(existingImport.replace(`${existingImport.text()}\nimport { Button } from '${BUI_SOURCE}';`)) - migrationMetric.increment({ action: 'import-added' }) + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) + const existingImported = new Set() + const existingTexts: string[] = [] + for (const spec of specifiers) { + const imported = getImportedName(spec) + if (imported) { + existingImported.add(imported) + } + existingTexts.push(spec.text()) + } + for (const name of names) { + if (!existingImported.has(name)) { + existingTexts.push(name) + existingImported.add(name) + } } + existingTexts.sort() + edits.push(namedImports.replace(`{ ${existingTexts.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } else { + edits.push( + existingImport.replace(`${existingImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + migrationMetric.increment({ action: 'import-added' }) } return false } + const buiImport = `import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';` 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 { Button } from '${BUI_SOURCE}';`)) + edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) } else if (importNodesToRemove.length > 0) { const [importNode] = importNodesToRemove if (importNode) { - edits.push(importNode.replace(`import { Button } from '${BUI_SOURCE}';`)) + edits.push(importNode.replace(buiImport)) 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 { Button } from '${BUI_SOURCE}';`)) + edits.push(lastImport.replace(`${lastImport.text()}\n${buiImport}`)) } } @@ -226,6 +241,20 @@ function getPropStringValue(opening: SgNode, propName: string): string | nu return null } +function getPropRawValue(opening: SgNode, propName: string): string | null { + const attr = getPropAttr(opening, propName) + if (!attr) { + return null + } + for (const child of attr.children()) { + const kind = child.kind() + if (kind === 'string' || kind === 'jsx_expression') { + return child.text() + } + } + return null +} + function isPropDynamic(opening: SgNode, propName: string): boolean { const attr = getPropAttr(opening, propName) if (!attr) { @@ -234,13 +263,43 @@ function isPropDynamic(opening: SgNode, propName: string): boolean { return attr.find({ rule: { kind: 'jsx_expression' } }) !== null } +function isSimpleOnClick(attr: SgNode): boolean { + const expr = attr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return false + } + const children: SgNode[] = [] + for (const child of expr.children()) { + if (child.kind() !== '{' && child.kind() !== '}') { + children.push(child) + } + } + if (children.length !== 1) { + return false + } + const [onlyChild] = children + if (!onlyChild) { + return false + } + // Identifier handler: onClick={handleSave} + if (onlyChild.is('identifier')) { + return true + } + // Arrow/function that does not reference the event parameter + if (onlyChild.kind() === 'arrow_function' || onlyChild.kind() === 'function_expression') { + return !onlyChild.text().includes('event') && !/\(\s*e\s*[,)]/.test(onlyChild.text()) + } + return false +} + function transformButtonElements( rootNode: SgNode, buttonLocalName: string, edits: Edit[], -): { preserveImport: boolean; migrated: boolean } { +): { preserveImport: boolean; migrated: boolean; buiNames: Set } { let preserveImport = false let migrated = false + const buiNames = new Set() const jsxElements = rootNode.findAll({ rule: { any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], @@ -287,6 +346,13 @@ function transformButtonElements( todoReasons.push('dynamic-color') } + // Complex onClick that cannot be rewritten to onPress + const onClickAttr = getPropAttr(opening, 'onClick') + if (onClickAttr && !isSimpleOnClick(onClickAttr)) { + needsTodo = true + todoReasons.push('complex-onClick') + } + if (needsTodo) { preserveImport = true edits.push( @@ -301,10 +367,14 @@ function transformButtonElements( continue } + const hasHref = hasProp(opening, 'href') + const componentName = hasHref ? 'ButtonLink' : 'Button' + // Build new props const newProps: string[] = [] - // Map variant + // Map variant (outlined → secondary is intentional and silent). + // MUI default is text; BUI default is primary — emit tertiary when omitted. const variantValue = getPropStringValue(opening, 'variant') if (variantValue) { const buiVariant = VARIANT_MAP[variantValue] @@ -324,6 +394,8 @@ function transformButtonElements( migrationMetric.increment({ action: 'todo-inserted', reason: 'unknown-variant' }) continue } + } else if (!isPropDynamic(opening, 'variant')) { + newProps.push('variant="tertiary"') } // Map disabled → isDisabled @@ -331,16 +403,31 @@ function transformButtonElements( if (disabledAttr) { const exprNode = disabledAttr.find({ rule: { kind: 'jsx_expression' } }) if (exprNode) { - // disabled={expr} → isDisabled={expr} newProps.push(`isDisabled=${exprNode.text()}`) } else { - // Boolean shorthand: disabled → isDisabled newProps.push('isDisabled') } } - // Preserve all other safe props as-is (onClick, className, etc.) - const handledProps = new Set(['variant', 'disabled', 'color']) + const handledProps = new Set(['variant', 'disabled', 'color', 'onClick', 'startIcon', 'endIcon']) + if (onClickAttr) { + const raw = getPropRawValue(opening, 'onClick') + if (raw) { + newProps.push(`onPress=${raw}`) + migrationMetric.increment({ action: 'onClick-to-onPress' }) + } + } + + for (const [from, to] of Object.entries(ICON_PROP_RENAMES)) { + const raw = getPropRawValue(opening, from) + if (raw) { + newProps.push(`${to}=${raw}`) + handledProps.add(from) + migrationMetric.increment({ action: 'prop-renamed', from, to }) + } + } + + // Preserve remaining safe props (including href for ButtonLink) const allAttrs = opening.findAll({ rule: { kind: 'jsx_attribute' } }) for (const attr of allAttrs) { const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) @@ -365,22 +452,25 @@ function transformButtonElements( const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' if (isSelfClosing) { - edits.push(el.replace(``)) + edits.push(el.replace(`<${componentName}${propsStr} />`)) } 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}`)) + edits.push(el.replace(`<${componentName}${propsStr}>${children}`)) } + buiNames.add(componentName) migrated = true - migrationMetric.increment({ action: 'button-migrated', variant: variantValue ?? 'default' }) + migrationMetric.increment({ + action: hasHref ? 'button-link-migrated' : 'button-migrated', + variant: variantValue ?? 'default', + }) } - return { preserveImport, migrated } + return { preserveImport, migrated, buiNames } } const transform: Codemod = async (root) => { @@ -393,11 +483,11 @@ const transform: Codemod = async (root) => { return null } - const { preserveImport, migrated } = transformButtonElements(rootNode, buttonLocalName, edits) + const { preserveImport, migrated, buiNames } = transformButtonElements(rootNode, buttonLocalName, edits) let replacedImport = false if (migrated) { - replacedImport = addButtonToBuiImport(rootNode, importNodesToRemove, edits) + replacedImport = addBuiImport(rootNode, [...buiNames], importNodesToRemove, edits) } if (!preserveImport) { diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/expected.tsx index 314bb3af..75f86724 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/expected.tsx @@ -1,7 +1,7 @@ import { Button } from '@backstage/ui'; const MyComponent = () => ( - ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/metrics.json index 29e0200b..227a12ca 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/metrics.json @@ -18,6 +18,12 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "onClick-to-onPress" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/expected.tsx new file mode 100644 index 00000000..dc1b4c5f --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/expected.tsx @@ -0,0 +1,7 @@ +import { ButtonLink } from '@backstage/ui'; + +const MyComponent = () => ( + } href="/docs"> + Docs + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/input.tsx new file mode 100644 index 00000000..ad207ab3 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/input.tsx @@ -0,0 +1,7 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/metrics.json new file mode 100644 index 00000000..0ac5f1d1 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/metrics.json @@ -0,0 +1,31 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-link-migrated", + "variant": "outlined" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "startIcon", + "to": "iconStart" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/expected.tsx index 2e1df73e..683f258a 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/expected.tsx @@ -4,6 +4,6 @@ import { Alert, Button } from '@backstage/ui'; const MyComponent = () => ( <> - + ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/metrics.json index 2c31eb85..24236b2f 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/metrics.json @@ -18,6 +18,12 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "onClick-to-onPress" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/expected.tsx index cf010eff..aa18be42 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/expected.tsx @@ -1,5 +1,5 @@ import { Button } from '@backstage/ui'; const MyComponent = () => ( - + ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/metrics.json index 7e7e452d..28b6a2ef 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/metrics.json @@ -18,6 +18,12 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "onClick-to-onPress" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/expected.tsx index 16c64c0c..8a3b1580 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/expected.tsx @@ -1,5 +1,5 @@ import { Button } from '@backstage/ui'; const MyComponent = () => ( - + ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/metrics.json index 5dcb702b..e7a0a27e 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/metrics.json @@ -18,6 +18,12 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "onClick-to-onPress" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/expected.tsx deleted file mode 100644 index bb97928f..00000000 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/expected.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import Button from '@material-ui/core/Button'; - -const MyComponent = () => ( - <> - {/* TODO(backstage-codemod): verify Button intent manually (startIcon) */} - - -); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon/expected.tsx new file mode 100644 index 00000000..865f092a --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon/expected.tsx @@ -0,0 +1,5 @@ +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon/input.tsx similarity index 100% rename from codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/input.tsx rename to codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon/input.tsx diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon/metrics.json new file mode 100644 index 00000000..60a3be2a --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon/metrics.json @@ -0,0 +1,31 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "contained" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "startIcon", + "to": "iconStart" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/expected.tsx index aa9ddc3d..86a8af4a 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/expected.tsx @@ -1,5 +1,5 @@ import { Button } from '@backstage/ui'; const MyComponent = () => ( - + ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/metrics.json index aedf7388..85eac670 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/metrics.json @@ -18,6 +18,12 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "onClick-to-onPress" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-card-to-bui-card/CHANGELOG.md b/codemods/misc/migrate-mui-card-to-bui-card/CHANGELOG.md new file mode 100644 index 00000000..afb57d4e --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/CHANGELOG.md @@ -0,0 +1,7 @@ +# @backstage/migrate-mui-card-to-bui-card + +## 0.1.0 + +### Minor Changes + +- Initial release: migrate MUI `Card` / `CardContent` / `CardActions` / simple `CardHeader` to Backstage UI Card family during the MUI 4 to BUI migration. diff --git a/codemods/misc/migrate-mui-card-to-bui-card/README.md b/codemods/misc/migrate-mui-card-to-bui-card/README.md new file mode 100644 index 00000000..fc8fe41a --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/README.md @@ -0,0 +1,21 @@ +# @backstage/migrate-mui-card-to-bui-card + +Migrates MUI 4 `Card` family usage to Backstage UI `Card`. + +## Covers + +- `Card` → `Card` +- `CardContent` → `CardBody` +- `CardActions` → `CardFooter` +- Simple `CardHeader` (children-only or string `title`) → `CardHeader` with children +- Deep and barrel imports from `@material-ui/core` +- Merging into an existing `@backstage/ui` import + +## TODOs left for humans + +- `CardMedia` (no BUI equivalent) +- Complex `CardHeader` with `avatar`, `action`, `subheader`, or non-string `title` + +## Won't do + +- Rewriting custom Card wrappers or theme overrides diff --git a/codemods/misc/migrate-mui-card-to-bui-card/codemod.yaml b/codemods/misc/migrate-mui-card-to-bui-card/codemod.yaml new file mode 100644 index 00000000..d8c73346 --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-card-to-bui-card' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Card family with BUI Card' +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', 'card'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-card-to-bui-card/package.json b/codemods/misc/migrate-mui-card-to-bui-card/package.json new file mode 100644 index 00000000..33119b05 --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-card-to-bui-card", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Card family with BUI Card", + "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.13" + } +} diff --git a/codemods/misc/migrate-mui-card-to-bui-card/scripts/codemod.ts b/codemods/misc/migrate-mui-card-to-bui-card/scripts/codemod.ts new file mode 100644 index 00000000..2911bf43 --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/scripts/codemod.ts @@ -0,0 +1,617 @@ +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-card-to-bui-card') + +const BUI_SOURCE = '@backstage/ui' +const MUI_BARREL = '@material-ui/core' + +const MUI_CARD_COMPONENTS = ['Card', 'CardHeader', 'CardContent', 'CardActions', 'CardMedia'] as const +type MuiCardComponent = (typeof MUI_CARD_COMPONENTS)[number] + +const COMPONENT_MAP: Partial> = { + Card: 'Card', + CardContent: 'CardBody', + CardActions: 'CardFooter', + CardHeader: 'CardHeader', +} + +const COMPLEX_HEADER_PROPS = new Set(['avatar', 'action', 'subheader']) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function withTodoComment(comment: string, elementText: string): string { + return `<> + ${comment} + ${elementText} +` +} + +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 getImportedName(spec: SgNode): string | null { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + return identifiers[0]?.text() ?? null +} + +function getPrunedBarrelImportText(imp: SgNode, namesToRemove: string[], source: string): string { + const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { + const importedName = getImportedName(spec) + return importedName !== null && !namesToRemove.includes(importedName) + }) + + if (remainingSpecs.length === 0) { + return '' + } + + const typeOnly = imp.text().trimStart().startsWith('import type') + const isMultiline = imp.text().includes('\n') + const specTexts = remainingSpecs.map((spec) => spec.text()) + const typeKw = typeOnly ? 'type ' : '' + + return isMultiline + ? `import ${typeKw}{\n ${specTexts.join(',\n ')},\n} from '${source}';` + : `import ${typeKw}{ ${specTexts.join(', ')} } from '${source}';` +} + +function pruneBarrelImportSpecifiers(imp: SgNode, source: string, namesToRemove: string[], edits: Edit[]): void { + edits.push(imp.replace(getPrunedBarrelImportText(imp, namesToRemove, source))) + migrationMetric.increment({ action: 'import-removed' }) +} + +interface CardImports { + localNames: Map + importNodesToRemove: SgNode[] + importSpecifiersToRemove: Map, { source: string; names: string[] }> + deepImportByComponent: Map> +} + +function collectCardImports(rootNode: SgNode): CardImports { + const localNames = new Map() + const importNodesToRemove: SgNode[] = [] + const importSpecifiersToRemove = new Map, { source: string; names: string[] }>() + const deepImportByComponent = new Map>() + + for (const componentName of MUI_CARD_COMPONENTS) { + for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { + const name = getDefaultImportName(imp) + if (name) { + localNames.set(name, componentName) + } + importNodesToRemove.push(imp) + deepImportByComponent.set(componentName, imp) + } + } + + for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL)) { + const namesToRemove: string[] = [] + for (const componentName of MUI_CARD_COMPONENTS) { + const localName = getNamedImportLocalName(imp, componentName) + if (localName) { + localNames.set(localName, componentName) + namesToRemove.push(componentName) + } + } + if (namesToRemove.length > 0) { + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (namesToRemove.length >= allSpecifiers.length) { + importNodesToRemove.push(imp) + } else { + importSpecifiersToRemove.set(imp, { source: MUI_BARREL, names: namesToRemove }) + } + } + } + + return { localNames, importNodesToRemove, importSpecifiersToRemove, deepImportByComponent } +} + +function addBuiImport( + rootNode: SgNode, + names: string[], + importNodesToRemove: SgNode[], + importSpecifiersToRemove: Map, { source: string; names: string[] }>, + edits: Edit[], + handledBarrelIds: Set, + replacedDeepImportIds: Set, +): void { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + const sortedNames = [...names].sort() + const buiImport = `import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';` + + if (existingImport) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) + const existingImported = new Set() + const existingTexts: string[] = [] + for (const spec of specifiers) { + const imported = getImportedName(spec) + if (imported) { + existingImported.add(imported) + } + existingTexts.push(spec.text()) + } + for (const name of names) { + if (!existingImported.has(name)) { + existingTexts.push(name) + existingImported.add(name) + } + } + existingTexts.sort() + edits.push(namedImports.replace(`{ ${existingTexts.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + return + } + + const skipIds = new Set([ + ...importNodesToRemove.map((imp) => imp.id()), + ...[...importSpecifiersToRemove.keys()].map((imp) => imp.id()), + ]) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) + migrationMetric.increment({ action: 'import-added' }) + return + } + + const [barrelToFold] = [...importSpecifiersToRemove.entries()] + if (barrelToFold) { + const [imp, { source, names: namesToRemove }] = barrelToFold + const pruned = getPrunedBarrelImportText(imp, namesToRemove, source) + edits.push(imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) + handledBarrelIds.add(imp.id()) + migrationMetric.increment({ action: 'import-added' }) + migrationMetric.increment({ action: 'import-removed' }) + return + } + + const removableDeep = importNodesToRemove.find((imp) => !replacedDeepImportIds.has(imp.id())) + if (removableDeep) { + edits.push(removableDeep.replace(buiImport)) + replacedDeepImportIds.add(removableDeep.id()) + migrationMetric.increment({ action: 'import-added' }) + return + } + + 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 getOpeningAttrs(opening: SgNode): string { + const attrs = opening + .children() + .filter( + (child) => + child.kind() === 'jsx_attribute' || (child.kind() === 'jsx_expression' && child.text().startsWith('{...')), + ) + .map((child) => child.text()) + return attrs.length > 0 ? ` ${attrs.join(' ')}` : '' +} + +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 hasNonWhitespaceChildren(element: SgNode): boolean { + return getChildContent(element).trim().length > 0 +} + +function isSimpleCardHeader(opening: SgNode, element: SgNode): { ok: true; title?: string } | { ok: false } { + for (const prop of COMPLEX_HEADER_PROPS) { + if (hasProp(opening, prop)) { + return { ok: false } + } + } + + const titleAttr = getPropAttr(opening, 'title') + const stringTitle = getPropStringValue(opening, 'title') + const hasChildren = !element.is('jsx_self_closing_element') && hasNonWhitespaceChildren(element) + + if (titleAttr && stringTitle === null) { + return { ok: false } + } + + if (titleAttr && stringTitle !== null && !hasChildren) { + return { ok: true, title: stringTitle } + } + + if (!titleAttr && hasChildren) { + return { ok: true } + } + + return { ok: false } +} + +interface TransformResult { + text: string + migrated: boolean + todo: boolean + buiNames: Set + preservedMui: Set +} + +function transformMuiJsx(el: SgNode, localNames: Map): TransformResult | null { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + return null + } + + const name = getElementName(opening) + if (!name || !localNames.has(name)) { + return null + } + + const muiName = localNames.get(name) + if (!muiName) { + return null + } + + const buiNames = new Set() + const preservedMui = new Set() + + if (muiName === 'CardMedia') { + migrationMetric.increment({ action: 'todo-inserted', reason: 'card-media' }) + preservedMui.add('CardMedia') + return { + text: withTodoComment( + `{/* TODO(backstage-codemod): CardMedia has no BUI equivalent — migrate manually */}`, + el.text(), + ), + migrated: false, + todo: true, + buiNames, + preservedMui, + } + } + + if (muiName === 'CardHeader') { + const simple = isSimpleCardHeader(opening, el) + if (!simple.ok) { + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-card-header' }) + preservedMui.add('CardHeader') + return { + text: withTodoComment( + `{/* TODO(backstage-codemod): verify complex CardHeader migration manually (avatar/action/subheader) */}`, + el.text(), + ), + migrated: false, + todo: true, + buiNames, + preservedMui, + } + } + + buiNames.add('CardHeader') + migrationMetric.increment({ action: 'card-header-migrated' }) + if (simple.title !== undefined) { + return { + text: `${simple.title}`, + migrated: true, + todo: false, + buiNames, + preservedMui, + } + } + return { + text: `${getChildContent(el)}`, + migrated: true, + todo: false, + buiNames, + preservedMui, + } + } + + const buiName = COMPONENT_MAP[muiName] + if (!buiName) { + return null + } + + buiNames.add(buiName) + + if (muiName === 'Card' && !isSelfClosing) { + const parts: string[] = [] + let childMigrated = false + for (const child of el.children()) { + const kind = child.kind() + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + if (kind === 'jsx_element' || kind === 'jsx_self_closing_element') { + const nested = transformMuiJsx(child, localNames) + if (nested) { + parts.push(nested.text) + childMigrated = childMigrated || nested.migrated + for (const n of nested.buiNames) { + buiNames.add(n) + } + for (const n of nested.preservedMui) { + preservedMui.add(n) + } + continue + } + } + parts.push(child.text()) + } + + migrationMetric.increment({ action: 'card-migrated', from: 'Card', to: 'Card' }) + return { + text: `${parts.join('')}`, + migrated: true, + todo: false, + buiNames, + preservedMui, + } + } + + migrationMetric.increment({ action: 'card-migrated', from: muiName, to: buiName }) + if (isSelfClosing) { + return { + text: `<${buiName}${getOpeningAttrs(opening)} />`, + migrated: true, + todo: false, + buiNames, + preservedMui, + } + } + + return { + text: `<${buiName}${getOpeningAttrs(opening)}>${getChildContent(el)}`, + migrated: true, + todo: false, + buiNames, + preservedMui, + } +} + +function isInsideTransformedCard(el: SgNode, cardLocalNames: Set): boolean { + for (const ancestor of el.ancestors()) { + if (!ancestor.is('jsx_element') && !ancestor.is('jsx_self_closing_element')) { + continue + } + const opening = ancestor.is('jsx_self_closing_element') ? ancestor : ancestor.child(0) + if (!opening) { + continue + } + const name = getElementName(opening) + if (name && cardLocalNames.has(name)) { + return true + } + } + return false +} + +function transformCardElements( + rootNode: SgNode, + localNames: Map, + edits: Edit[], + buiNames: Set, + preservedMui: Set, +): boolean { + let migrated = false + const cardLocalNames = new Set([...localNames.entries()].filter(([, mui]) => mui === 'Card').map(([local]) => local)) + + 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 || !localNames.has(name)) { + continue + } + + const muiName = localNames.get(name) + if (!muiName) { + continue + } + + // Children of Card are rewritten as part of the Card replacement. + if (muiName !== 'Card' && isInsideTransformedCard(el, cardLocalNames)) { + continue + } + + const result = transformMuiJsx(el, localNames) + if (!result) { + continue + } + + edits.push(el.replace(result.text)) + migrated = migrated || result.migrated + for (const n of result.buiNames) { + buiNames.add(n) + } + for (const n of result.preservedMui) { + preservedMui.add(n) + } + } + + return migrated +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { localNames, importNodesToRemove, importSpecifiersToRemove, deepImportByComponent } = + collectCardImports(rootNode) + + if (localNames.size === 0) { + return null + } + + const buiNames = new Set() + const preservedMui = new Set() + const migrated = transformCardElements(rootNode, localNames, edits, buiNames, preservedMui) + + const namesToStripFromBarrel = new Set() + for (const [, muiName] of localNames) { + if (!preservedMui.has(muiName)) { + namesToStripFromBarrel.add(muiName) + } + } + + const handledBarrelIds = new Set() + const replacedDeepImportIds = new Set() + + if (migrated && buiNames.size > 0) { + const removableDeepImports = importNodesToRemove.filter((imp) => { + for (const [component, node] of deepImportByComponent) { + if (node.id() === imp.id() && preservedMui.has(component)) { + return false + } + } + return true + }) + + const foldableBarrels = new Map, { source: string; names: string[] }>() + for (const [imp, value] of importSpecifiersToRemove) { + const names = value.names.filter((n) => namesToStripFromBarrel.has(n)) + if (names.length > 0) { + foldableBarrels.set(imp, { source: value.source, names }) + } + } + + addBuiImport( + rootNode, + [...buiNames], + removableDeepImports, + foldableBarrels, + edits, + handledBarrelIds, + replacedDeepImportIds, + ) + } + + for (const [imp, { source, names }] of importSpecifiersToRemove) { + if (handledBarrelIds.has(imp.id())) { + continue + } + const removable = names.filter((n) => namesToStripFromBarrel.has(n)) + if (removable.length === 0) { + continue + } + pruneBarrelImportSpecifiers(imp, source, removable, edits) + } + + for (const imp of importNodesToRemove) { + if (replacedDeepImportIds.has(imp.id())) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + let preserve = false + for (const [component, node] of deepImportByComponent) { + if (node.id() === imp.id() && preservedMui.has(component)) { + preserve = true + break + } + } + if (preserve) { + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + + const result = await Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) + return result +} + +export default transform diff --git a/codemods/misc/migrate-mui-card-to-bui-card/tests/complex-header-todo/expected.tsx b/codemods/misc/migrate-mui-card-to-bui-card/tests/complex-header-todo/expected.tsx new file mode 100644 index 00000000..9cc9512f --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/tests/complex-header-todo/expected.tsx @@ -0,0 +1,12 @@ + +import CardHeader from '@material-ui/core/CardHeader'; + +import Avatar from '@material-ui/core/Avatar'; +import { Card, CardBody } from '@backstage/ui'; + +const MyCard = () => ( + <> + {/* TODO(backstage-codemod): verify complex CardHeader migration manually (avatar/action/subheader) */} + R} title="Owner" subheader="Admin" /> +Hello +); diff --git a/codemods/misc/migrate-mui-card-to-bui-card/tests/complex-header-todo/input.tsx b/codemods/misc/migrate-mui-card-to-bui-card/tests/complex-header-todo/input.tsx new file mode 100644 index 00000000..d3458c61 --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/tests/complex-header-todo/input.tsx @@ -0,0 +1,11 @@ +import Card from '@material-ui/core/Card'; +import CardHeader from '@material-ui/core/CardHeader'; +import CardContent from '@material-ui/core/CardContent'; +import Avatar from '@material-ui/core/Avatar'; + +const MyCard = () => ( + + R} title="Owner" subheader="Admin" /> + Hello + +); diff --git a/codemods/misc/migrate-mui-card-to-bui-card/tests/complex-header-todo/metrics.json b/codemods/misc/migrate-mui-card-to-bui-card/tests/complex-header-todo/metrics.json new file mode 100644 index 00000000..7bcf22ae --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/tests/complex-header-todo/metrics.json @@ -0,0 +1,39 @@ +{ + "migrate-mui-card-to-bui-card": [ + { + "cardinality": { + "action": "card-migrated", + "from": "Card", + "to": "Card" + }, + "count": 1 + }, + { + "cardinality": { + "action": "card-migrated", + "from": "CardContent", + "to": "CardBody" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 2 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "complex-card-header" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-card-to-bui-card/tests/header-content-actions/expected.tsx b/codemods/misc/migrate-mui-card-to-bui-card/tests/header-content-actions/expected.tsx new file mode 100644 index 00000000..83a38c72 --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/tests/header-content-actions/expected.tsx @@ -0,0 +1,8 @@ +import { Card, CardBody, CardFooter, CardHeader } from '@backstage/ui'; + + + + +const MyCard = () => ( + DetailsBody copy +); diff --git a/codemods/misc/migrate-mui-card-to-bui-card/tests/header-content-actions/input.tsx b/codemods/misc/migrate-mui-card-to-bui-card/tests/header-content-actions/input.tsx new file mode 100644 index 00000000..1a75aa6d --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/tests/header-content-actions/input.tsx @@ -0,0 +1,14 @@ +import Card from '@material-ui/core/Card'; +import CardHeader from '@material-ui/core/CardHeader'; +import CardContent from '@material-ui/core/CardContent'; +import CardActions from '@material-ui/core/CardActions'; + +const MyCard = () => ( + + + Body copy + + + + +); diff --git a/codemods/misc/migrate-mui-card-to-bui-card/tests/header-content-actions/metrics.json b/codemods/misc/migrate-mui-card-to-bui-card/tests/header-content-actions/metrics.json new file mode 100644 index 00000000..1882fafa --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/tests/header-content-actions/metrics.json @@ -0,0 +1,46 @@ +{ + "migrate-mui-card-to-bui-card": [ + { + "cardinality": { + "action": "card-header-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "card-migrated", + "from": "Card", + "to": "Card" + }, + "count": 1 + }, + { + "cardinality": { + "action": "card-migrated", + "from": "CardActions", + "to": "CardFooter" + }, + "count": 1 + }, + { + "cardinality": { + "action": "card-migrated", + "from": "CardContent", + "to": "CardBody" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 4 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-card-to-bui-card/tests/media-todo/expected.tsx b/codemods/misc/migrate-mui-card-to-bui-card/tests/media-todo/expected.tsx new file mode 100644 index 00000000..507b4537 --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/tests/media-todo/expected.tsx @@ -0,0 +1,11 @@ + +import CardMedia from '@material-ui/core/CardMedia'; +import { Card, CardBody } from '@backstage/ui'; + + +const MyCard = () => ( + <> + {/* TODO(backstage-codemod): CardMedia has no BUI equivalent — migrate manually */} + +Hello +); diff --git a/codemods/misc/migrate-mui-card-to-bui-card/tests/media-todo/input.tsx b/codemods/misc/migrate-mui-card-to-bui-card/tests/media-todo/input.tsx new file mode 100644 index 00000000..7d351293 --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/tests/media-todo/input.tsx @@ -0,0 +1,10 @@ +import Card from '@material-ui/core/Card'; +import CardMedia from '@material-ui/core/CardMedia'; +import CardContent from '@material-ui/core/CardContent'; + +const MyCard = () => ( + + + Hello + +); diff --git a/codemods/misc/migrate-mui-card-to-bui-card/tests/media-todo/metrics.json b/codemods/misc/migrate-mui-card-to-bui-card/tests/media-todo/metrics.json new file mode 100644 index 00000000..fcd58562 --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/tests/media-todo/metrics.json @@ -0,0 +1,39 @@ +{ + "migrate-mui-card-to-bui-card": [ + { + "cardinality": { + "action": "card-migrated", + "from": "Card", + "to": "Card" + }, + "count": 1 + }, + { + "cardinality": { + "action": "card-migrated", + "from": "CardContent", + "to": "CardBody" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 2 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "card-media" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-card-to-bui-card/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-card-to-bui-card/tests/named-barrel-import/expected.tsx new file mode 100644 index 00000000..12377058 --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/tests/named-barrel-import/expected.tsx @@ -0,0 +1,6 @@ +import { Button } from '@material-ui/core'; +import { Card, CardBody } from '@backstage/ui'; + +const MyCard = () => ( + +); diff --git a/codemods/misc/migrate-mui-card-to-bui-card/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-card-to-bui-card/tests/named-barrel-import/input.tsx new file mode 100644 index 00000000..fef56851 --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/tests/named-barrel-import/input.tsx @@ -0,0 +1,9 @@ +import { Card, CardContent, Button } from '@material-ui/core'; + +const MyCard = () => ( + + + + + +); diff --git a/codemods/misc/migrate-mui-card-to-bui-card/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-card-to-bui-card/tests/named-barrel-import/metrics.json new file mode 100644 index 00000000..997af05c --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/tests/named-barrel-import/metrics.json @@ -0,0 +1,32 @@ +{ + "migrate-mui-card-to-bui-card": [ + { + "cardinality": { + "action": "card-migrated", + "from": "Card", + "to": "Card" + }, + "count": 1 + }, + { + "cardinality": { + "action": "card-migrated", + "from": "CardContent", + "to": "CardBody" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-card-to-bui-card/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-card-to-bui-card/tests/noop-no-import/expected.tsx new file mode 100644 index 00000000..031c8886 --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/tests/noop-no-import/expected.tsx @@ -0,0 +1,7 @@ +import { Card, CardBody } from '@backstage/ui'; + +const MyCard = () => ( + + Already migrated + +); diff --git a/codemods/misc/migrate-mui-card-to-bui-card/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-card-to-bui-card/tests/noop-no-import/input.tsx new file mode 100644 index 00000000..031c8886 --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/tests/noop-no-import/input.tsx @@ -0,0 +1,7 @@ +import { Card, CardBody } from '@backstage/ui'; + +const MyCard = () => ( + + Already migrated + +); diff --git a/codemods/misc/migrate-mui-card-to-bui-card/tests/simple-card/expected.tsx b/codemods/misc/migrate-mui-card-to-bui-card/tests/simple-card/expected.tsx new file mode 100644 index 00000000..323fca8e --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/tests/simple-card/expected.tsx @@ -0,0 +1,6 @@ +import { Card, CardBody } from '@backstage/ui'; + + +const MyCard = () => ( + Hello +); diff --git a/codemods/misc/migrate-mui-card-to-bui-card/tests/simple-card/input.tsx b/codemods/misc/migrate-mui-card-to-bui-card/tests/simple-card/input.tsx new file mode 100644 index 00000000..435e4b4b --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/tests/simple-card/input.tsx @@ -0,0 +1,8 @@ +import Card from '@material-ui/core/Card'; +import CardContent from '@material-ui/core/CardContent'; + +const MyCard = () => ( + + Hello + +); diff --git a/codemods/misc/migrate-mui-card-to-bui-card/tests/simple-card/metrics.json b/codemods/misc/migrate-mui-card-to-bui-card/tests/simple-card/metrics.json new file mode 100644 index 00000000..61e9abc4 --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/tests/simple-card/metrics.json @@ -0,0 +1,32 @@ +{ + "migrate-mui-card-to-bui-card": [ + { + "cardinality": { + "action": "card-migrated", + "from": "Card", + "to": "Card" + }, + "count": 1 + }, + { + "cardinality": { + "action": "card-migrated", + "from": "CardContent", + "to": "CardBody" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 2 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-card-to-bui-card/tsconfig.json b/codemods/misc/migrate-mui-card-to-bui-card/tsconfig.json new file mode 100644 index 00000000..5ad4d6f0 --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/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-card-to-bui-card/workflow.yaml b/codemods/misc/migrate-mui-card-to-bui-card/workflow.yaml new file mode 100644 index 00000000..09545fe5 --- /dev/null +++ b/codemods/misc/migrate-mui-card-to-bui-card/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 Card family with BUI Card' + 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-chip-to-tag/README.md b/codemods/misc/migrate-mui-chip-to-tag/README.md new file mode 100644 index 00000000..f1328166 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/README.md @@ -0,0 +1,20 @@ +# @backstage/migrate-mui-chip-to-tag + +Migrates MUI 4 `Chip` to Backstage UI `Tag`. + +## Covers + +- Named / partial barrel imports +- Simple display chips and dynamic string labels +- Merging into an existing `@backstage/ui` import + +## TODOs / won't-do + +- Clickable / interactive chips (`clickable-chip-todo`, `interactive-chip-todo`) +- Chip avatars, delete icons, and unsupported interactive APIs + +## Test + +```bash +yarn test +``` diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/README.md b/codemods/misc/migrate-mui-dialog-to-bui-dialog/README.md new file mode 100644 index 00000000..cc17cde5 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/README.md @@ -0,0 +1,21 @@ +# @backstage/migrate-mui-dialog-to-bui-dialog + +Migrates MUI 4 Dialog shell patterns to Backstage UI `Dialog`. + +## Covers + +- Named / partial barrel Dialog imports +- Controlled dialog open/close happy paths +- Merging into an existing `@backstage/ui` import + +## TODOs / won't-do + +- Complex `onClose` handlers (`complex-on-close-todo`) +- `fullWidth` / maxWidth sizing (`full-width-todo`) +- Custom Dialog wrappers and transition props + +## Test + +```bash +yarn test +``` diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/README.md b/codemods/misc/migrate-mui-icon-button-to-button-icon/README.md new file mode 100644 index 00000000..769f87d9 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/README.md @@ -0,0 +1,21 @@ +# @backstage/migrate-mui-icon-button-to-button-icon + +Migrates MUI 4 `IconButton` to Backstage UI `ButtonIcon`. + +## Covers + +- Named barrel imports; default `variant="tertiary"` +- Color mapping (`color-primary`, `color-secondary`); `onClick` → `onPress` +- Dropped unsupported props; merge into existing `@backstage/ui` import + +## TODOs / won't-do + +- Complex children (`complex-children-todo`) +- Missing `aria-label` (`missing-aria-label-todo`) +- Spread-prop-only call sites beyond best-effort handling + +## Test + +```bash +yarn test +``` diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts b/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts index a2c2dc11..82072eed 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts @@ -216,7 +216,48 @@ function isSingleIconChild(child: SgNode): boolean { } /** Props that are dropped silently (MUI-specific, no BUI equivalent). */ -const DROPPED_PROPS = new Set(['size', 'edge', 'color', 'disableRipple', 'disableFocusRipple']) +const DROPPED_PROPS = new Set(['size', 'edge', 'disableRipple', 'disableFocusRipple']) + +/** MUI IconButton color → BUI ButtonIcon variant. Absent/default/inherit → tertiary. */ +const COLOR_TO_VARIANT: Record = { + primary: 'primary', + secondary: 'secondary', + default: 'tertiary', + inherit: 'tertiary', +} + +function getAttrStringValue( + opening: SgNode, + propName: string, +): { value: string | null; isDynamic: boolean; attrNode: SgNode | null } { + const attr = getPropAttr(opening, propName) + if (!attr) { + return { value: null, isDynamic: false, attrNode: null } + } + + const stringNode = attr.find({ rule: { kind: 'string' } }) + if (stringNode) { + const frag = stringNode.find({ rule: { kind: 'string_fragment' } }) + return { value: frag?.text() ?? null, isDynamic: false, attrNode: attr } + } + + const exprNode = attr.find({ rule: { kind: 'jsx_expression' } }) + if (exprNode) { + return { value: exprNode.text(), isDynamic: true, attrNode: attr } + } + + return { value: '', isDynamic: false, attrNode: attr } +} + +function mapColorToVariant(colorValue: string | null, isDynamic: boolean): string | null { + if (isDynamic) { + return null + } + if (colorValue === null || colorValue === '') { + return 'tertiary' + } + return COLOR_TO_VARIANT[colorValue] ?? null +} function transformIconButtonElements( rootNode: SgNode, @@ -271,11 +312,19 @@ function transformIconButtonElements( continue } + const { value: colorValue, isDynamic: colorDynamic } = getAttrStringValue(opening, 'color') + const buiVariant = mapColorToVariant(colorValue, colorDynamic) + if (buiVariant === null) { + insertTodo(colorDynamic ? 'dynamic-color' : `unmapped-color-${colorValue}`) + continue + } + // Build new props const newProps: string[] = [] // icon prop from child newProps.push(formatIconProp(iconChild)) + newProps.push(`variant="${buiVariant}"`) // Map props from opening element const allAttrs = opening.findAll({ rule: { kind: 'jsx_attribute' } }) @@ -286,6 +335,11 @@ function transformIconButtonElements( } const propName = propIdent.text() + if (propName === 'color') { + migrationMetric.increment({ action: 'prop-dropped', prop: 'color' }) + continue + } + if (propName === 'disabled') { // Map disabled → isDisabled const exprNode = attr.find({ rule: { kind: 'jsx_expression' } }) diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/expected.tsx index 553f7add..fc57b430 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/expected.tsx @@ -1,5 +1,5 @@ import { ButtonIcon } from '@backstage/ui'; const MyComponent = () => ( - } aria-label="delete" isDisabled={!canDelete} onPress={handleDelete} /> + } variant="tertiary" aria-label="delete" isDisabled={!canDelete} onPress={handleDelete} /> ); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/expected.tsx index f631327b..16c0e8f8 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/expected.tsx @@ -1,5 +1,5 @@ import { ButtonIcon } from '@backstage/ui'; const MyComponent = () => ( - } aria-label="copy" className="custom-btn" data-testid="copy-btn" onPress={handleCopy} /> + } variant="tertiary" aria-label="copy" className="custom-btn" data-testid="copy-btn" onPress={handleCopy} /> ); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-primary/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-primary/expected.tsx new file mode 100644 index 00000000..017bb16f --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-primary/expected.tsx @@ -0,0 +1,5 @@ +import { ButtonIcon } from '@backstage/ui'; + +const MyComponent = () => ( + } variant="primary" aria-label="favorite" onPress={handleFavorite} /> +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-primary/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-primary/input.tsx new file mode 100644 index 00000000..84487a86 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-primary/input.tsx @@ -0,0 +1,7 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = () => ( + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-primary/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-primary/metrics.json new file mode 100644 index 00000000..2c056a0f --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-primary/metrics.json @@ -0,0 +1,29 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "icon-button-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-dropped", + "prop": "color" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-secondary/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-secondary/expected.tsx new file mode 100644 index 00000000..31a71cce --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-secondary/expected.tsx @@ -0,0 +1,5 @@ +import { ButtonIcon } from '@backstage/ui'; + +const MyComponent = () => ( + } variant="secondary" aria-label="settings" onPress={handleSettings} /> +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-secondary/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-secondary/input.tsx new file mode 100644 index 00000000..cff8424d --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-secondary/input.tsx @@ -0,0 +1,7 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = () => ( + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-secondary/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-secondary/metrics.json new file mode 100644 index 00000000..2c056a0f --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-secondary/metrics.json @@ -0,0 +1,29 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "icon-button-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-dropped", + "prop": "color" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/expected.tsx index 95cb4bf8..52a1d167 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/expected.tsx @@ -1,5 +1,5 @@ import { ButtonIcon } from '@backstage/ui'; const MyComponent = () => ( - } aria-label="close" isDisabled onPress={onClose} /> + } variant="tertiary" aria-label="close" isDisabled onPress={onClose} /> ); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/expected.tsx index 3f19a0fc..9937c693 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/expected.tsx @@ -1,5 +1,5 @@ import { ButtonIcon } from '@backstage/ui'; const MyComponent = () => ( - } aria-label="menu" onPress={handleMenu} /> + } variant="tertiary" aria-label="menu" onPress={handleMenu} /> ); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/expected.tsx index 9ed49953..c80f2f5d 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/expected.tsx @@ -4,6 +4,6 @@ import { Button, ButtonIcon } from '@backstage/ui'; const MyComponent = () => ( <> - } aria-label="close" onPress={handleClose} /> + } variant="tertiary" aria-label="close" onPress={handleClose} /> ); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/expected.tsx index afa4621f..25e3e857 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/expected.tsx @@ -1,5 +1,5 @@ import { ButtonIcon } from '@backstage/ui'; const MyComponent = () => ( - } aria-label="edit" onPress={handleEdit} /> + } variant="tertiary" aria-label="edit" onPress={handleEdit} /> ); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/expected.tsx index 7519265b..80e44963 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/expected.tsx @@ -1,5 +1,5 @@ import { ButtonIcon } from '@backstage/ui'; const MyComponent = (props: any) => ( - } aria-label="action" {...props} /> + } variant="tertiary" aria-label="action" {...props} /> ); diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/README.md b/codemods/misc/migrate-mui-icons-to-remix-icons/README.md new file mode 100644 index 00000000..9efc9d03 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/README.md @@ -0,0 +1,21 @@ +# @backstage/migrate-mui-icons-to-remix-icons + +Replaces `@material-ui/icons` imports with `@remixicon/react` equivalents. + +## Covers + +- Known icon name mappings (simple + multiple icons) +- Extension / slot icon patterns used by Backstage plugins +- Merging into an existing Remix import + +## TODOs / won't-do + +- Namespace imports (`namespace-import-todo`) +- Unknown / unmapped icons (`unknown-icon-todo`) +- Perfect visual parity for every MUI glyph + +## Test + +```bash +yarn test +``` diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/README.md b/codemods/misc/migrate-mui-layout-to-bui-layout/README.md new file mode 100644 index 00000000..b9e43e81 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/README.md @@ -0,0 +1,21 @@ +# @backstage/migrate-mui-layout-to-bui-layout + +Converts common MUI layout primitives (Box / Paper / Grid) toward BUI layout. + +## Covers + +- Box flex-container happy paths +- Simple Grid boolean / item patterns +- Paper heuristic: bare → `Box bg="neutral"`; card-like → `Card` (never `Surface`) + +## TODOs / won't-do + +- `box-component-todo`, `grid-todo`, `paper-elevation-todo` +- Elevation / ambiguous Paper left with `TODO(backstage-codemod)` +- Full Grid system / responsive matrix rewrites + +## Test + +```bash +yarn test +``` diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts b/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts index a65d83ce..eb4af500 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts @@ -465,18 +465,40 @@ function transformBoxElement(el: SgNode, opening: SgNode, edits: Edit[ return 'Flex' } -function transformPaperElement(el: SgNode, opening: SgNode, edits: Edit[]): string | null { - for (const prop of PAPER_TODO_PROPS) { - if (hasProp(opening, prop)) { - edits.push(el.replace(withTodoComment(LAYOUT_TODO_COMMENT, el.text()))) - migrationMetric.increment({ action: 'todo-inserted', reason: `paper-${prop}` }) - return null +/** Card-structure child tags that indicate Paper is card-like (MUI or post-Card-codemod BUI). */ +const CARD_LIKE_CHILD_NAMES = new Set([ + 'CardHeader', + 'CardContent', + 'CardActions', + 'CardBody', + 'CardFooter', + 'CardMedia', + 'CardActionArea', +]) + +function getDirectJsxChildElements(el: SgNode): SgNode[] { + return el.children().filter((child) => { + const kind = child.kind() + return kind === 'jsx_element' || kind === 'jsx_self_closing_element' + }) +} + +function isCardLikePaper(el: SgNode): boolean { + for (const child of getDirectJsxChildElements(el)) { + const opening = child.is('jsx_self_closing_element') ? child : child.child(0) + if (!opening) { + continue + } + const name = getElementName(opening) + if (name && CARD_LIKE_CHILD_NAMES.has(name)) { + return true } } + return false +} - const isSelfClosing = el.is('jsx_self_closing_element') +function collectPreservedPaperProps(opening: SgNode): string[] { const newProps: string[] = [] - const allAttrs = opening.findAll({ rule: { kind: 'jsx_attribute' } }) for (const attr of allAttrs) { const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) @@ -493,17 +515,43 @@ function transformPaperElement(el: SgNode, opening: SgNode, edits: Edi } } - const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + return newProps +} + +function transformPaperElement(el: SgNode, opening: SgNode, edits: Edit[]): string | null { + for (const prop of PAPER_TODO_PROPS) { + if (hasProp(opening, prop)) { + edits.push(el.replace(withTodoComment(LAYOUT_TODO_COMMENT, el.text()))) + migrationMetric.increment({ action: 'todo-inserted', reason: `paper-${prop}` }) + return null + } + } + + const isSelfClosing = el.is('jsx_self_closing_element') + const preservedProps = collectPreservedPaperProps(opening) + const isCardLike = !isSelfClosing && isCardLikePaper(el) + + if (isCardLike) { + const propsStr = preservedProps.length > 0 ? ` ${preservedProps.join(' ')}` : '' + const children = getChildContent(el) + edits.push(el.replace(`${children}`)) + migrationMetric.increment({ action: 'paper-to-card' }) + return 'Card' + } + + // Bare Paper → Box with neutral background (Surface was removed from BUI). + const newProps = [`bg="neutral"`, ...preservedProps] + const propsStr = ` ${newProps.join(' ')}` if (isSelfClosing) { - edits.push(el.replace(``)) + edits.push(el.replace(``)) } else { const children = getChildContent(el) - edits.push(el.replace(`${children}`)) + edits.push(el.replace(`${children}`)) } - migrationMetric.increment({ action: 'paper-to-surface' }) - return 'Surface' + migrationMetric.increment({ action: 'paper-to-box' }) + return 'Box' } const GRID_BREAKPOINTS = ['xs', 'sm', 'md', 'lg', 'xl'] as const diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like-bui-children/expected.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like-bui-children/expected.tsx new file mode 100644 index 00000000..3049e2e8 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like-bui-children/expected.tsx @@ -0,0 +1,6 @@ + +import { Card, CardBody, CardFooter, CardHeader } from '@backstage/ui'; + +const MyComponent = () => ( + TitleBodyActions +); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like-bui-children/input.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like-bui-children/input.tsx new file mode 100644 index 00000000..72e322a6 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like-bui-children/input.tsx @@ -0,0 +1,10 @@ +import Paper from '@material-ui/core/Paper'; +import { CardBody, CardFooter, CardHeader } from '@backstage/ui'; + +const MyComponent = () => ( + + Title + Body + Actions + +); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like-bui-children/metrics.json b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like-bui-children/metrics.json new file mode 100644 index 00000000..b890a526 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like-bui-children/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-layout-to-bui-layout": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "paper-to-card" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like/expected.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like/expected.tsx new file mode 100644 index 00000000..ab665dd4 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like/expected.tsx @@ -0,0 +1,8 @@ + +import CardHeader from '@material-ui/core/CardHeader'; +import CardContent from '@material-ui/core/CardContent'; +import { Card } from '@backstage/ui'; + +const MyComponent = () => ( + Body +); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like/input.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like/input.tsx new file mode 100644 index 00000000..15d17241 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like/input.tsx @@ -0,0 +1,10 @@ +import Paper from '@material-ui/core/Paper'; +import CardHeader from '@material-ui/core/CardHeader'; +import CardContent from '@material-ui/core/CardContent'; + +const MyComponent = () => ( + + + Body + +); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like/metrics.json b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like/metrics.json new file mode 100644 index 00000000..a74bcd07 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-card-like/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-layout-to-bui-layout": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "paper-to-card" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/expected.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/expected.tsx index 960f3ba5..865f936a 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/expected.tsx +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/expected.tsx @@ -1,6 +1,6 @@ -import { Surface } from '@backstage/ui'; +import { Box } from '@backstage/ui'; const MyComponent = () => ( -

Content

+

Content

); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/metrics.json b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/metrics.json index e2f1accd..01433fd6 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/metrics.json +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/metrics.json @@ -14,7 +14,7 @@ }, { "cardinality": { - "action": "paper-to-surface" + "action": "paper-to-box" }, "count": 1 } diff --git a/codemods/misc/migrate-mui-link-to-bui-link/CHANGELOG.md b/codemods/misc/migrate-mui-link-to-bui-link/CHANGELOG.md new file mode 100644 index 00000000..c9e11f46 --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/CHANGELOG.md @@ -0,0 +1,7 @@ +# @backstage/migrate-mui-link-to-bui-link + +## 0.1.0 + +### Minor Changes + +- Initial release: migrate Material-UI `Link` to Backstage UI `Link`, skipping `@backstage/core-components` Link usage. diff --git a/codemods/misc/migrate-mui-link-to-bui-link/README.md b/codemods/misc/migrate-mui-link-to-bui-link/README.md new file mode 100644 index 00000000..09f33290 --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/README.md @@ -0,0 +1,22 @@ +# @backstage/migrate-mui-link-to-bui-link + +Migrates Material-UI `Link` to Backstage UI `Link`. + +## Covers + +- Direct `@material-ui/core/Link` and barrel `@material-ui/core` `Link` imports +- Adds `standalone` for bare-looking links (no surrounding inline text siblings) +- Merging into an existing `@backstage/ui` import + +## Skips + +- Files under `packages/core-components` +- Files that import `Link` from `@backstage/core-components` + +## TODOs left for humans + +- `component` / `to` (router polymorphic links) — left as MUI with a TODO + +## Won't do + +- Rewriting `@backstage/core-components` `Link` / `LinkButton` diff --git a/codemods/misc/migrate-mui-link-to-bui-link/codemod.yaml b/codemods/misc/migrate-mui-link-to-bui-link/codemod.yaml new file mode 100644 index 00000000..7a4f56f7 --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-link-to-bui-link' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Material-UI Link with BUI Link' +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', 'link'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-link-to-bui-link/package.json b/codemods/misc/migrate-mui-link-to-bui-link/package.json new file mode 100644 index 00000000..1f9e2455 --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-link-to-bui-link", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Material-UI Link with BUI Link", + "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.13" + } +} diff --git a/codemods/misc/migrate-mui-link-to-bui-link/scripts/codemod.ts b/codemods/misc/migrate-mui-link-to-bui-link/scripts/codemod.ts new file mode 100644 index 00000000..9b806ccc --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/scripts/codemod.ts @@ -0,0 +1,386 @@ +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-link-to-bui-link') + +const BUI_SOURCE = '@backstage/ui' +const MUI_BARREL = '@material-ui/core' +const MUI_LINK = '@material-ui/core/Link' +const CORE_COMPONENTS = '@backstage/core-components' + +/** Props that need TODO markers because their semantics don't map mechanically. */ +const TODO_PROPS = new Set(['component', 'to']) + +const DROPPED_LINK_PROPS = new Set(['underline', 'color', 'variant', 'classes', 'TypographyClasses']) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function withTodoComment(comment: string, elementText: string): string { + return `<> + ${comment} + ${elementText} +` +} + +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 getImportedName(spec: SgNode): string | null { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + return identifiers[0]?.text() ?? null +} + +function getPrunedBarrelImportText(imp: SgNode, namesToRemove: string[], source: string): string { + const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { + const importedName = getImportedName(spec) + return importedName !== null && !namesToRemove.includes(importedName) + }) + + if (remainingSpecs.length === 0) { + return '' + } + + const typeOnly = imp.text().trimStart().startsWith('import type') + const isMultiline = imp.text().includes('\n') + const specTexts = remainingSpecs.map((spec) => spec.text()) + const typeKw = typeOnly ? 'type ' : '' + + return isMultiline + ? `import ${typeKw}{\n ${specTexts.join(',\n ')},\n} from '${source}';` + : `import ${typeKw}{ ${specTexts.join(', ')} } from '${source}';` +} + +function fileImportsCoreComponentsLink(rootNode: SgNode): boolean { + for (const imp of findImportStatementsFrom(rootNode, CORE_COMPONENTS)) { + if (getNamedImportLocalName(imp, 'Link')) { + return true + } + } + return false +} + +function collectMuiLinkImports(rootNode: SgNode): { + localName: string | null + importNodesToRemove: SgNode[] + importSpecifiersToRemove: Map, { source: string; names: string[] }> +} { + let localName: string | null = null + const importNodesToRemove: SgNode[] = [] + const importSpecifiersToRemove = new Map, { source: string; names: string[] }>() + + for (const imp of findImportStatementsFrom(rootNode, MUI_LINK)) { + localName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL)) { + const name = getNamedImportLocalName(imp, 'Link') + if (name) { + localName = name + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (allSpecifiers.length <= 1) { + importNodesToRemove.push(imp) + } else { + importSpecifiersToRemove.set(imp, { source: MUI_BARREL, names: ['Link'] }) + } + } + } + + return { localName, importNodesToRemove, importSpecifiersToRemove } +} + +function addBuiImport( + rootNode: SgNode, + importNodesToRemove: SgNode[], + importSpecifiersToRemove: Map, { source: string; names: string[] }>, + edits: Edit[], + handledBarrelIds: Set, + replacedDeepImportIds: Set, +): void { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + const buiImport = `import { Link } from '${BUI_SOURCE}';` + + if (existingImport) { + if (getNamedImportLocalName(existingImport, 'Link')) { + return + } + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) + const existingTexts = specifiers.map((spec) => spec.text()) + existingTexts.push('Link') + existingTexts.sort() + edits.push(namedImports.replace(`{ ${existingTexts.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + return + } + + const skipIds = new Set([ + ...importNodesToRemove.map((imp) => imp.id()), + ...[...importSpecifiersToRemove.keys()].map((imp) => imp.id()), + ]) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) + migrationMetric.increment({ action: 'import-added' }) + return + } + + const [barrelToFold] = [...importSpecifiersToRemove.entries()] + if (barrelToFold) { + const [imp, { source, names }] = barrelToFold + const pruned = getPrunedBarrelImportText(imp, names, source) + edits.push(imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) + handledBarrelIds.add(imp.id()) + migrationMetric.increment({ action: 'import-added' }) + migrationMetric.increment({ action: 'import-removed' }) + return + } + + const [deep] = importNodesToRemove + if (deep) { + edits.push(deep.replace(buiImport)) + replacedDeepImportIds.add(deep.id()) + 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 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 hasProp(opening: SgNode, propName: string): boolean { + return ( + opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) !== null + ) +} + +function isBareLookingLink(element: SgNode): boolean { + const parent = element.parent() + if (!parent) { + return true + } + + for (const sibling of parent.children()) { + if (sibling.id() === element.id()) { + continue + } + if (sibling.kind() === 'jsx_text' && sibling.text().trim().length > 0) { + return false + } + } + return true +} + +function buildBuiLink(element: SgNode, opening: SgNode): string { + const attrs: string[] = [] + for (const child of opening.children()) { + if (child.kind() === 'jsx_attribute') { + const propIdent = child.find({ rule: { kind: 'property_identifier' } }) + const propName = propIdent?.text() + if (!propName || DROPPED_LINK_PROPS.has(propName) || TODO_PROPS.has(propName)) { + continue + } + attrs.push(child.text()) + continue + } + if (child.kind() === 'jsx_expression' && child.text().startsWith('{...')) { + attrs.push(child.text()) + } + } + + if (isBareLookingLink(element) && !attrs.some((attr) => attr.startsWith('standalone'))) { + attrs.push('standalone') + } + + const propsStr = attrs.length > 0 ? ` ${attrs.join(' ')}` : '' + if (element.is('jsx_self_closing_element')) { + return `` + } + return `${getChildContent(element)}` +} + +function transformLinkElements( + rootNode: SgNode, + localName: string, + edits: Edit[], +): { migrated: boolean; preserveImport: boolean } { + let migrated = false + let preserveImport = 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 + } + if (getElementName(opening) !== localName) { + continue + } + + const todoReasons: string[] = [] + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + todoReasons.push(prop) + } + } + + if (todoReasons.length > 0) { + preserveImport = true + edits.push( + el.replace( + withTodoComment( + `{/* TODO(backstage-codemod): verify Link intent manually (${todoReasons.join(', ')}) */}`, + el.text(), + ), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: todoReasons.join(', ') }) + continue + } + + edits.push(el.replace(buildBuiLink(el, opening))) + migrated = true + migrationMetric.increment({ action: 'link-migrated' }) + } + + return { migrated, preserveImport } +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const filename = root.filename() + + if (filename.includes('packages/core-components')) { + migrationMetric.increment({ action: 'skipped', reason: 'core-components-path' }) + return null + } + + if (fileImportsCoreComponentsLink(rootNode)) { + migrationMetric.increment({ action: 'skipped', reason: 'core-components-import' }) + return null + } + + const { localName, importNodesToRemove, importSpecifiersToRemove } = collectMuiLinkImports(rootNode) + if (!localName) { + return null + } + + const edits: Edit[] = [] + const { migrated, preserveImport } = transformLinkElements(rootNode, localName, edits) + if (!migrated && !preserveImport) { + return null + } + + const handledBarrelIds = new Set() + const replacedDeepImportIds = new Set() + + if (migrated) { + addBuiImport( + rootNode, + importNodesToRemove, + importSpecifiersToRemove, + edits, + handledBarrelIds, + replacedDeepImportIds, + ) + } + + if (!preserveImport) { + for (const [imp, { source, names }] of importSpecifiersToRemove) { + if (handledBarrelIds.has(imp.id())) { + continue + } + edits.push(imp.replace(getPrunedBarrelImportText(imp, names, source))) + migrationMetric.increment({ action: 'import-removed' }) + } + for (const imp of importNodesToRemove) { + if (replacedDeepImportIds.has(imp.id())) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + } + + const result = await Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) + return result +} + +export default transform diff --git a/codemods/misc/migrate-mui-link-to-bui-link/tests/component-todo/expected.tsx b/codemods/misc/migrate-mui-link-to-bui-link/tests/component-todo/expected.tsx new file mode 100644 index 00000000..41aff03b --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/tests/component-todo/expected.tsx @@ -0,0 +1,11 @@ +import Link from '@material-ui/core/Link'; +import { Link as RouterLink } from 'react-router-dom'; + +const Nav = () => ( + <> + {/* TODO(backstage-codemod): verify Link intent manually (component, to) */} + + Catalog + + +); diff --git a/codemods/misc/migrate-mui-link-to-bui-link/tests/component-todo/input.tsx b/codemods/misc/migrate-mui-link-to-bui-link/tests/component-todo/input.tsx new file mode 100644 index 00000000..5287f91f --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/tests/component-todo/input.tsx @@ -0,0 +1,8 @@ +import Link from '@material-ui/core/Link'; +import { Link as RouterLink } from 'react-router-dom'; + +const Nav = () => ( + + Catalog + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/metrics.json b/codemods/misc/migrate-mui-link-to-bui-link/tests/component-todo/metrics.json similarity index 59% rename from codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/metrics.json rename to codemods/misc/migrate-mui-link-to-bui-link/tests/component-todo/metrics.json index 00f029c2..909d09c7 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/metrics.json +++ b/codemods/misc/migrate-mui-link-to-bui-link/tests/component-todo/metrics.json @@ -1,9 +1,9 @@ { - "migrate-mui-button-to-bui-button": [ + "migrate-mui-link-to-bui-link": [ { "cardinality": { "action": "todo-inserted", - "reason": "startIcon" + "reason": "component, to" }, "count": 1 } diff --git a/codemods/misc/migrate-mui-link-to-bui-link/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-link-to-bui-link/tests/noop-no-import/expected.tsx new file mode 100644 index 00000000..b0a57c8a --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/tests/noop-no-import/expected.tsx @@ -0,0 +1,3 @@ +import { Link } from '@backstage/ui'; + +const Already = () => X; diff --git a/codemods/misc/migrate-mui-link-to-bui-link/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-link-to-bui-link/tests/noop-no-import/input.tsx new file mode 100644 index 00000000..b0a57c8a --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/tests/noop-no-import/input.tsx @@ -0,0 +1,3 @@ +import { Link } from '@backstage/ui'; + +const Already = () => X; diff --git a/codemods/misc/migrate-mui-link-to-bui-link/tests/simple-link/expected.tsx b/codemods/misc/migrate-mui-link-to-bui-link/tests/simple-link/expected.tsx new file mode 100644 index 00000000..cae00b29 --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/tests/simple-link/expected.tsx @@ -0,0 +1,7 @@ +import { Link } from '@backstage/ui'; + +const Nav = () => ( +
+ Catalog +
+); diff --git a/codemods/misc/migrate-mui-link-to-bui-link/tests/simple-link/input.tsx b/codemods/misc/migrate-mui-link-to-bui-link/tests/simple-link/input.tsx new file mode 100644 index 00000000..a883208a --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/tests/simple-link/input.tsx @@ -0,0 +1,7 @@ +import Link from '@material-ui/core/Link'; + +const Nav = () => ( +
+ Catalog +
+); diff --git a/codemods/misc/migrate-mui-link-to-bui-link/tests/simple-link/metrics.json b/codemods/misc/migrate-mui-link-to-bui-link/tests/simple-link/metrics.json new file mode 100644 index 00000000..3b33d663 --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/tests/simple-link/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-link-to-bui-link": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "link-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-link-to-bui-link/tests/skip-core-components/expected.tsx b/codemods/misc/migrate-mui-link-to-bui-link/tests/skip-core-components/expected.tsx new file mode 100644 index 00000000..9922a57b --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/tests/skip-core-components/expected.tsx @@ -0,0 +1,9 @@ +import { Link } from '@backstage/core-components'; +import MuiLink from '@material-ui/core/Link'; + +const Mixed = () => ( + <> + Docs + Other + +); diff --git a/codemods/misc/migrate-mui-link-to-bui-link/tests/skip-core-components/input.tsx b/codemods/misc/migrate-mui-link-to-bui-link/tests/skip-core-components/input.tsx new file mode 100644 index 00000000..9922a57b --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/tests/skip-core-components/input.tsx @@ -0,0 +1,9 @@ +import { Link } from '@backstage/core-components'; +import MuiLink from '@material-ui/core/Link'; + +const Mixed = () => ( + <> + Docs + Other + +); diff --git a/codemods/misc/migrate-mui-link-to-bui-link/tests/skip-core-components/metrics.json b/codemods/misc/migrate-mui-link-to-bui-link/tests/skip-core-components/metrics.json new file mode 100644 index 00000000..430cff08 --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/tests/skip-core-components/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-link-to-bui-link": [ + { + "cardinality": { + "action": "skipped", + "reason": "core-components-import" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-link-to-bui-link/tsconfig.json b/codemods/misc/migrate-mui-link-to-bui-link/tsconfig.json new file mode 100644 index 00000000..5ad4d6f0 --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/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-link-to-bui-link/workflow.yaml b/codemods/misc/migrate-mui-link-to-bui-link/workflow.yaml new file mode 100644 index 00000000..09eb58f5 --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/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 Material-UI Link with BUI Link' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/README.md b/codemods/misc/migrate-mui-list-family-to-bui-list/README.md new file mode 100644 index 00000000..3d998463 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/README.md @@ -0,0 +1,20 @@ +# @backstage/migrate-mui-list-family-to-bui-list + +Migrates MUI List family primitives to Backstage UI List. + +## Covers + +- Named / partial barrel List imports +- Simple lists with icons and text-only items +- Merging into an existing `@backstage/ui` import + +## TODOs / won't-do + +- Interactive list patterns (`interactive-todo`) +- Dense / nested / custom ListItemSecondaryAction layouts + +## Test + +```bash +yarn test +``` diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/README.md b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/README.md new file mode 100644 index 00000000..6916c15f --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/README.md @@ -0,0 +1,21 @@ +# @backstage/migrate-mui-menu-popover-to-bui-menu + +Migrates MUI Menu **or** Popover to BUI `Menu` / `Popover`. + +## Covers + +- Simple Menu → BUI Menu with trigger +- Popover containing MenuList/MenuItem → BUI Menu +- Other Popover content → BUI Popover (with or without adjacent trigger) + +## TODOs / won't-do + +- Complex `anchorEl` wiring (`anchor-el-todo`) +- Multi-button / ambiguous trigger selection beyond fixtures +- Perfect positioning / transition parity + +## Test + +```bash +yarn test +``` diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/codemod.yaml b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/codemod.yaml index 79045bf7..6cd252ca 100644 --- a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/codemod.yaml +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/codemod.yaml @@ -2,7 +2,7 @@ schema_version: '1.0' name: '@backstage/migrate-mui-menu-popover-to-bui-menu' version: '0.2.1' -description: 'MUI 4 to BUI: Replace Menu and Popover patterns with BUI Menu' +description: 'MUI 4 to BUI: Replace Menu with BUI Menu; Popover with MenuList/MenuItem becomes Menu, otherwise BUI Popover' author: 'Paul Schultz' license: 'Apache-2.0' repository: 'https://github.com/backstage/codemods' diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts index f4202a15..b52a9bfe 100644 --- a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts @@ -490,14 +490,81 @@ function unwrapMenuList( return getChildContent(element) } +/** + * Locked heuristic: a Popover whose descendants include MenuList follows the + * Menu migration path; otherwise it becomes BUI Popover. + * + * MenuItem alone is not enough — Select/FormControl also use MenuItem, and the + * recipe runs this codemod before Select migration. + */ +function hasMenuDescendants(element: SgNode, menuListLocalName: string | null): boolean { + if (!menuListLocalName) { + return false + } + + for (const child of element.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + })) { + if (child.id() === element.id()) { + continue + } + const childOpening = child.is('jsx_self_closing_element') ? child : child.child(0) + if (!childOpening) { + continue + } + const childName = getElementName(childOpening) + if (childName === menuListLocalName) { + return true + } + } + + return false +} + +function insertHostTodo(el: SgNode, reason: string, edits: Edit[]): void { + edits.push( + el.replace( + withTodoComment(`{/* TODO(backstage-codemod): finish menu host migration manually (${reason}) */}`, el.text()), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason }) +} + +function buildTriggerWrapperProps(opening: SgNode): string[] | null { + const triggerProps: string[] = [] + const openValue = getPropRawValue(opening, 'open') + if (openValue) { + triggerProps.push(`isOpen=${openValue}`) + } + + if (hasProp(opening, 'onClose')) { + const simpleHandler = getSimpleOnCloseHandler(opening) + if (!simpleHandler) { + return null + } + triggerProps.push(`onOpenChange={isOpen => !isOpen && ${simpleHandler}()}`) + migrationMetric.increment({ action: 'onClose-rewritten' }) + } + + return triggerProps +} + function transformMenuElements( rootNode: SgNode, localNames: Map, edits: Edit[], -): { preserveImport: boolean; migrated: boolean; buiNames: Set } { +): { + preserveImport: boolean + migrated: boolean + buiNames: Set + consumedMuiNames: Set +} { let preserveImport = false let migrated = false const buiNames = new Set() + const consumedMuiNames = new Set() const menuListLocalName = [...localNames.entries()].find(([, v]) => v === 'MenuList')?.[0] ?? null const menuItemLocalName = [...localNames.entries()].find(([, v]) => v === 'MenuItem')?.[0] ?? null @@ -549,23 +616,53 @@ function transformMenuElements( if (needsTodo) { preserveImport = true - edits.push( - el.replace( - withTodoComment( - `{/* TODO(backstage-codemod): finish menu host migration manually (${todoReasons.join(', ')}) */}`, - el.text(), - ), - ), - ) - migrationMetric.increment({ action: 'todo-inserted', reason: todoReasons.join(', ') }) + insertHostTodo(el, todoReasons.join(', '), edits) continue } + const useMenuPath = muiName === 'Menu' || hasMenuDescendants(el, menuListLocalName) + if (isSelfClosing) { - edits.push(el.replace('')) - buiNames.add('Menu') + if (useMenuPath) { + edits.push(el.replace('')) + buiNames.add('Menu') + consumedMuiNames.add(muiName) + migrated = true + migrationMetric.increment({ action: 'menu-migrated', variant: 'self-closing' }) + } else { + preserveImport = true + insertHostTodo(el, 'no-trigger-element', edits) + } + continue + } + + if (!useMenuPath) { + // Plain Popover → BUI Popover via DialogTrigger. Trigger is required. + const triggerSibling = findTriggerSibling(el) + if (!triggerSibling) { + preserveImport = true + insertHostTodo(el, 'no-trigger-element', edits) + continue + } + + const triggerProps = buildTriggerWrapperProps(opening) + if (triggerProps === null) { + preserveImport = true + insertHostTodo(el, 'complex-onClose', edits) + continue + } + + const triggerPropsStr = triggerProps.length > 0 ? ` ${triggerProps.join(' ')}` : '' + const content = getChildContent(el) + const output = `${triggerSibling.text()}${content}` + edits.push(triggerSibling.replace('')) + edits.push(el.replace(output)) + buiNames.add('DialogTrigger') + buiNames.add('Popover') + consumedMuiNames.add(muiName) migrated = true - migrationMetric.increment({ action: 'menu-migrated', variant: 'self-closing' }) + migrationMetric.increment({ action: 'popover-migrated' }) + migrationMetric.increment({ action: 'dialog-trigger-wrapped' }) continue } @@ -575,29 +672,13 @@ function transformMenuElements( triggerSibling = findTriggerSibling(el) if (!triggerSibling) { preserveImport = true - edits.push( - el.replace( - withTodoComment( - `{/* TODO(backstage-codemod): finish menu host migration manually (no-trigger-element) */}`, - el.text(), - ), - ), - ) - migrationMetric.increment({ action: 'todo-inserted', reason: 'no-trigger-element' }) + insertHostTodo(el, 'no-trigger-element', edits) continue } if (hasProp(opening, 'onClose') && !getSimpleOnCloseHandler(opening)) { preserveImport = true - edits.push( - el.replace( - withTodoComment( - `{/* TODO(backstage-codemod): finish menu host migration manually (complex-onClose) */}`, - el.text(), - ), - ), - ) - migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-onClose' }) + insertHostTodo(el, 'complex-onClose', edits) continue } } @@ -607,18 +688,12 @@ function transformMenuElements( let menuOutput = `${innerContent}` if (hasControlledState && triggerSibling) { - const triggerProps: string[] = [] - const openValue = getPropRawValue(opening, 'open') - if (openValue) { - triggerProps.push(`isOpen=${openValue}`) - } - - const simpleHandler = getSimpleOnCloseHandler(opening) - if (simpleHandler) { - triggerProps.push(`onOpenChange={isOpen => !isOpen && ${simpleHandler}()}`) - migrationMetric.increment({ action: 'onClose-rewritten' }) + const triggerProps = buildTriggerWrapperProps(opening) + if (triggerProps === null) { + preserveImport = true + insertHostTodo(el, 'complex-onClose', edits) + continue } - const triggerPropsStr = triggerProps.length > 0 ? ` ${triggerProps.join(' ')}` : '' menuOutput = `${triggerSibling.text()}${menuOutput}` edits.push(triggerSibling.replace('')) @@ -627,15 +702,20 @@ function transformMenuElements( } buiNames.add('Menu') + consumedMuiNames.add(muiName) + if (menuListLocalName) { + consumedMuiNames.add('MenuList') + } if (menuItemLocalName) { buiNames.add('MenuItem') + consumedMuiNames.add('MenuItem') } edits.push(el.replace(menuOutput)) migrated = true migrationMetric.increment({ action: 'menu-migrated', variant: muiName === 'Popover' ? 'popover' : 'menu' }) } - return { preserveImport, migrated, buiNames } + return { preserveImport, migrated, buiNames, consumedMuiNames } } const transform: Codemod = async (root) => { @@ -648,7 +728,25 @@ const transform: Codemod = async (root) => { return null } - const { preserveImport, migrated, buiNames } = transformMenuElements(rootNode, localNames, edits) + const { preserveImport, migrated, buiNames, consumedMuiNames } = transformMenuElements(rootNode, localNames, edits) + + // Only drop imports for MUI symbols we actually migrated. MenuItem under Select + // must stay when a Popover takes the plain Popover path. + const filteredImportNodesToRemove = importNodesToRemove.filter((imp) => { + const defaultName = getDefaultImportName(imp) + if (!defaultName) { + return true + } + const muiName = localNames.get(defaultName) + return muiName !== undefined && consumedMuiNames.has(muiName) + }) + const filteredImportSpecifiersToRemove = new Map, { source: string; names: string[] }>() + for (const [imp, { source, names }] of importSpecifiersToRemove) { + const kept = names.filter((name) => consumedMuiNames.has(name)) + if (kept.length > 0) { + filteredImportSpecifiersToRemove.set(imp, { source, names: kept }) + } + } let replacedImport = false const handledBarrelIds = new Set() @@ -656,22 +754,22 @@ const transform: Codemod = async (root) => { replacedImport = addBuiImport( rootNode, [...buiNames], - importNodesToRemove, - importSpecifiersToRemove, + filteredImportNodesToRemove, + filteredImportSpecifiersToRemove, edits, handledBarrelIds, ) } if (!preserveImport) { - for (const [imp, { source, names }] of importSpecifiersToRemove) { + for (const [imp, { source, names }] of filteredImportSpecifiersToRemove) { if (handledBarrelIds.has(imp.id())) { continue } pruneBarrelImportSpecifiers(imp, source, names, edits) } - for (const imp of importNodesToRemove) { - if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + for (const imp of filteredImportNodesToRemove) { + if (replacedImport && imp.id() === filteredImportNodesToRemove[0]?.id()) { migrationMetric.increment({ action: 'import-removed' }) continue } diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-no-trigger/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-no-trigger/expected.tsx new file mode 100644 index 00000000..e60c54d9 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-no-trigger/expected.tsx @@ -0,0 +1,10 @@ +import Popover from '@material-ui/core/Popover'; + +const MyComponent = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + <> + {/* TODO(backstage-codemod): finish menu host migration manually (no-trigger-element) */} + +
Popover body
+
+ +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-no-trigger/input.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-no-trigger/input.tsx new file mode 100644 index 00000000..823d8c65 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-no-trigger/input.tsx @@ -0,0 +1,7 @@ +import Popover from '@material-ui/core/Popover'; + +const MyComponent = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + +
Popover body
+
+); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-no-trigger/metrics.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-no-trigger/metrics.json new file mode 100644 index 00000000..9200396c --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-no-trigger/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-menu-popover-to-bui-menu": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "no-trigger-element" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-with-trigger/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-with-trigger/expected.tsx new file mode 100644 index 00000000..30b100b7 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-with-trigger/expected.tsx @@ -0,0 +1,9 @@ + +import { Button, DialogTrigger, Popover } from '@backstage/ui'; + +const MyComponent = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + <> + + !isOpen && onClose()}>
Popover body
+ +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-with-trigger/input.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-with-trigger/input.tsx new file mode 100644 index 00000000..9e2a2a0d --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-with-trigger/input.tsx @@ -0,0 +1,11 @@ +import Popover from '@material-ui/core/Popover'; +import { Button } from '@backstage/ui'; + +const MyComponent = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + <> + + +
Popover body
+
+ +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-with-trigger/metrics.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-with-trigger/metrics.json new file mode 100644 index 00000000..fab8bdc1 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-content-with-trigger/metrics.json @@ -0,0 +1,34 @@ +{ + "migrate-mui-menu-popover-to-bui-menu": [ + { + "cardinality": { + "action": "dialog-trigger-wrapped" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onClose-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "popover-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-menu-with-trigger/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-menu-with-trigger/expected.tsx new file mode 100644 index 00000000..d922077e --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-menu-with-trigger/expected.tsx @@ -0,0 +1,11 @@ + + + +import { Button, Menu, MenuItem, MenuTrigger } from '@backstage/ui'; + +const MyComponent = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + <> + + !isOpen && onClose()}>Action + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-menu-with-trigger/input.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-menu-with-trigger/input.tsx new file mode 100644 index 00000000..a7aaa16b --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-menu-with-trigger/input.tsx @@ -0,0 +1,15 @@ +import Popover from '@material-ui/core/Popover'; +import MenuList from '@material-ui/core/MenuList'; +import MenuItem from '@material-ui/core/MenuItem'; +import { Button } from '@backstage/ui'; + +const MyComponent = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + <> + + + + Action + + + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-menu-with-trigger/metrics.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-menu-with-trigger/metrics.json new file mode 100644 index 00000000..b54dd10d --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-menu-with-trigger/metrics.json @@ -0,0 +1,53 @@ +{ + "migrate-mui-menu-popover-to-bui-menu": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "menu-item-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "menu-list-unwrapped" + }, + "count": 1 + }, + { + "cardinality": { + "action": "menu-migrated", + "variant": "popover" + }, + "count": 1 + }, + { + "cardinality": { + "action": "menu-trigger-wrapped" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onClick-to-onAction" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onClose-rewritten" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-select-menuitem-noop/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-select-menuitem-noop/expected.tsx new file mode 100644 index 00000000..6e59caef --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-select-menuitem-noop/expected.tsx @@ -0,0 +1,22 @@ + +import Select from '@material-ui/core/Select'; +import MenuItem from '@material-ui/core/MenuItem'; +import { Button, DialogTrigger, Popover } from '@backstage/ui'; + +const MyComponent = ({ + open, + onClose, + value, +}: { + open: boolean; + onClose: () => void; + value: string; +}) => ( + <> + + !isOpen && onClose()}> + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-select-menuitem-noop/input.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-select-menuitem-noop/input.tsx new file mode 100644 index 00000000..bc22e761 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-select-menuitem-noop/input.tsx @@ -0,0 +1,24 @@ +import Popover from '@material-ui/core/Popover'; +import Select from '@material-ui/core/Select'; +import MenuItem from '@material-ui/core/MenuItem'; +import { Button } from '@backstage/ui'; + +const MyComponent = ({ + open, + onClose, + value, +}: { + open: boolean; + onClose: () => void; + value: string; +}) => ( + <> + + + + + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-select-menuitem-noop/metrics.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-select-menuitem-noop/metrics.json new file mode 100644 index 00000000..fab8bdc1 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-select-menuitem-noop/metrics.json @@ -0,0 +1,34 @@ +{ + "migrate-mui-menu-popover-to-bui-menu": [ + { + "cardinality": { + "action": "dialog-trigger-wrapped" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onClose-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "popover-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-radio-checkbox-to-bui/README.md b/codemods/misc/migrate-mui-radio-checkbox-to-bui/README.md new file mode 100644 index 00000000..29c4f9a9 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-to-bui/README.md @@ -0,0 +1,20 @@ +# @backstage/migrate-mui-radio-checkbox-to-bui + +Migrates MUI radio/checkbox groups and standalone Checkbox patterns to BUI. + +## Covers + +- Simple radio and checkbox groups +- Standalone Checkbox and FormControlLabel+Checkbox +- Named / partial barrel imports; merge into existing `@backstage/ui` + +## TODOs / won't-do + +- Complex FormGroup layouts (`complex-form-group-todo`) +- Non-boolean / indeterminate checkbox APIs beyond happy path + +## Test + +```bash +yarn test +``` diff --git a/codemods/misc/migrate-mui-radio-checkbox-to-bui/codemod.yaml b/codemods/misc/migrate-mui-radio-checkbox-to-bui/codemod.yaml index 27c0fc85..3872d861 100644 --- a/codemods/misc/migrate-mui-radio-checkbox-to-bui/codemod.yaml +++ b/codemods/misc/migrate-mui-radio-checkbox-to-bui/codemod.yaml @@ -2,7 +2,7 @@ schema_version: '1.0' name: '@backstage/migrate-mui-radio-checkbox-to-bui' version: '0.2.1' -description: 'MUI 4 to BUI: Replace radio and checkbox group patterns with BUI groups' +description: 'MUI 4 to BUI: Replace radio/checkbox groups and standalone Checkbox patterns with BUI' author: 'Paul Schultz' license: 'Apache-2.0' repository: 'https://github.com/backstage/codemods' diff --git a/codemods/misc/migrate-mui-radio-checkbox-to-bui/scripts/codemod.ts b/codemods/misc/migrate-mui-radio-checkbox-to-bui/scripts/codemod.ts index 3e749290..272c8679 100644 --- a/codemods/misc/migrate-mui-radio-checkbox-to-bui/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-radio-checkbox-to-bui/scripts/codemod.ts @@ -449,6 +449,114 @@ function isCheckboxFormGroup(element: SgNode, localNames: Map, + radioGroupLocal: string | null, + formGroupLocal: string | null, +): boolean { + for (const ancestor of element.ancestors()) { + if (ancestor.kind() !== 'jsx_element') { + continue + } + const opening = ancestor.child(0) + if (!opening) { + continue + } + const ancestorName = getElementName(opening) + if (ancestorName && (ancestorName === radioGroupLocal || ancestorName === formGroupLocal)) { + return true + } + } + return false +} + +function buildStandaloneCheckboxProps(controlEl: SgNode, fclOpening?: SgNode): string[] { + const props: string[] = [] + if (hasProp(controlEl, 'checked')) { + const checkedRaw = getPropRawValue(controlEl, 'checked') + if (checkedRaw !== null && checkedRaw !== '') { + props.push(`isSelected=${checkedRaw}`) + } else { + props.push('isSelected') + } + } + 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(controlEl, 'disabled') || (fclOpening && hasProp(fclOpening, 'disabled'))) { + props.push('isDisabled') + } + + // Preserve remaining safe props from the Checkbox itself + const handled = new Set(['checked', 'onChange', 'name', 'disabled', 'color', 'size', 'indeterminate']) + for (const attr of controlEl.findAll({ rule: { kind: 'jsx_attribute' } })) { + const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + const propName = propIdent.text() + if (handled.has(propName)) { + continue + } + props.push(attr.text()) + } + + return props +} + +function tryTransformStandaloneFormControlLabel(el: SgNode, localNames: Map): string | null { + const controlType = detectControlType(el, localNames) + if (controlType !== 'Checkbox') { + return null + } + const label = getPropStringValue(el, 'label') + if (!label) { + return null + } + const controlEl = extractControlProps(el) + if (!controlEl) { + return null + } + const props = buildStandaloneCheckboxProps(controlEl, el) + const propsStr = props.length > 0 ? ` ${props.join(' ')}` : '' + return `${label}` +} + +function tryTransformStandaloneCheckbox(el: SgNode): string | null { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + return null + } + + // Skip checkboxes that are nested inside FormControlLabel control={...} + for (const ancestor of el.ancestors()) { + if (ancestor.kind() === 'jsx_attribute') { + const propIdent = ancestor.find({ rule: { kind: 'property_identifier' } }) + if (propIdent?.text() === 'control') { + return null + } + } + } + + const props = buildStandaloneCheckboxProps(opening) + const propsStr = props.length > 0 ? ` ${props.join(' ')}` : '' + + if (isSelfClosing) { + return `` + } + + const children = getJsxChildren(el) + .map((c) => c.text()) + .join('') + return `${children}` +} + function transformGroupElements( rootNode: SgNode, localNames: Map, @@ -458,6 +566,8 @@ function transformGroupElements( let preserveImport = false const radioGroupLocal = [...localNames.entries()].find(([, v]) => v === 'RadioGroup')?.[0] ?? null const formGroupLocal = [...localNames.entries()].find(([, v]) => v === 'FormGroup')?.[0] ?? null + const fclLocal = [...localNames.entries()].find(([, v]) => v === 'FormControlLabel')?.[0] ?? null + const checkboxLocal = [...localNames.entries()].find(([, v]) => v === 'Checkbox')?.[0] ?? null const jsxElements = rootNode.findAll({ rule: { @@ -537,6 +647,39 @@ function transformGroupElements( migrationMetric.increment({ action: 'checkbox-group-migrated' }) continue } + + // Standalone FormControlLabel + Checkbox (not inside a choice group) + if (name === fclLocal && isSelfClosing && !isInsideChoiceGroup(el, radioGroupLocal, formGroupLocal)) { + const output = tryTransformStandaloneFormControlLabel(el, localNames) + if (output === null) { + preserveImport = true + edits.push( + el.replace( + withTodoComment( + `{/* TODO(backstage-codemod): finish standalone checkbox migration manually */}`, + el.text(), + ), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-standalone-fcl' }) + continue + } + edits.push(el.replace(output)) + usedBuiNames.add('Checkbox') + migrationMetric.increment({ action: 'standalone-checkbox-migrated', via: 'FormControlLabel' }) + continue + } + + // Standalone Checkbox (not inside FormGroup / RadioGroup / FormControlLabel control) + if (name === checkboxLocal && !isInsideChoiceGroup(el, radioGroupLocal, formGroupLocal)) { + const output = tryTransformStandaloneCheckbox(el) + if (output === null) { + continue + } + edits.push(el.replace(output)) + usedBuiNames.add('Checkbox') + migrationMetric.increment({ action: 'standalone-checkbox-migrated', via: 'Checkbox' }) + } } return { usedBuiNames, preserveImport } @@ -549,7 +692,7 @@ const transform: Codemod = (root) => { const { localNames, importNodesToRemove, barrelImportsToPrune } = collectGroupImports(rootNode) const hasTarget = [...localNames.values()].some( - (v) => v === 'RadioGroup' || v === 'FormGroup' || v === 'FormControlLabel', + (v) => v === 'RadioGroup' || v === 'FormGroup' || v === 'FormControlLabel' || v === 'Checkbox', ) if (!hasTarget) { return Promise.resolve(null) diff --git a/codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-checkbox/expected.tsx b/codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-checkbox/expected.tsx new file mode 100644 index 00000000..54f711b5 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-checkbox/expected.tsx @@ -0,0 +1,5 @@ +import { Checkbox } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-checkbox/input.tsx b/codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-checkbox/input.tsx new file mode 100644 index 00000000..9f74294b --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-checkbox/input.tsx @@ -0,0 +1,5 @@ +import Checkbox from '@material-ui/core/Checkbox'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-checkbox/metrics.json b/codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-checkbox/metrics.json new file mode 100644 index 00000000..62d909b1 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-checkbox/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-radio-checkbox-to-bui": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "standalone-checkbox-migrated", + "via": "Checkbox" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-form-control-label-checkbox/expected.tsx b/codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-form-control-label-checkbox/expected.tsx new file mode 100644 index 00000000..1a8a8462 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-form-control-label-checkbox/expected.tsx @@ -0,0 +1,6 @@ + +import { Checkbox } from '@backstage/ui'; + +const MyComponent = () => ( + Enabled +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-form-control-label-checkbox/input.tsx b/codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-form-control-label-checkbox/input.tsx new file mode 100644 index 00000000..1d23dbde --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-form-control-label-checkbox/input.tsx @@ -0,0 +1,6 @@ +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Checkbox from '@material-ui/core/Checkbox'; + +const MyComponent = () => ( + } label="Enabled" /> +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-form-control-label-checkbox/metrics.json b/codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-form-control-label-checkbox/metrics.json new file mode 100644 index 00000000..6054ddb3 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-to-bui/tests/standalone-form-control-label-checkbox/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-radio-checkbox-to-bui": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 2 + }, + { + "cardinality": { + "action": "standalone-checkbox-migrated", + "via": "FormControlLabel" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/README.md b/codemods/misc/migrate-mui-select-family-to-bui-select/README.md new file mode 100644 index 00000000..26a7f3b6 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/README.md @@ -0,0 +1,21 @@ +# @backstage/migrate-mui-select-family-to-bui-select + +Migrates MUI Select wrapper patterns to Backstage UI Select. + +## Covers + +- Basic FormControl + Select + MenuItem patterns +- Named / partial barrel imports +- Merging into an existing `@backstage/ui` import + +## TODOs / won't-do + +- Helper text edge cases (`helper-text-todo`) +- Multi-select (`multiple-select-todo`) +- Native select / custom renderValue + +## Test + +```bash +yarn test +``` diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/CHANGELOG.md b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/CHANGELOG.md new file mode 100644 index 00000000..8c33363a --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/CHANGELOG.md @@ -0,0 +1,7 @@ +# @backstage/migrate-mui-skeleton-to-bui-skeleton + +## 0.1.0 + +### Minor Changes + +- Initial release: migrate MUI `Skeleton` (lab/core) to Backstage UI `Skeleton` during the MUI 4 to BUI migration. diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/README.md b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/README.md new file mode 100644 index 00000000..c997f680 --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/README.md @@ -0,0 +1,18 @@ +# @backstage/migrate-mui-skeleton-to-bui-skeleton + +Migrates MUI 4 `Skeleton` usage to Backstage UI `Skeleton`. + +## Covers + +- `@material-ui/lab/Skeleton` and `@material-ui/core/Skeleton` (deep + barrel) imports +- `width` / `height` passthrough +- `variant="circle"` → `rounded` +- Merging into an existing `@backstage/ui` import + +## TODOs left for humans + +- Unsupported props such as `animation` / `classes` + +## Won't do + +- Replacing `CircularProgress` / `LinearProgress` with Skeleton diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/codemod.yaml b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/codemod.yaml new file mode 100644 index 00000000..dc67f204 --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-skeleton-to-bui-skeleton' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Skeleton with BUI Skeleton' +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', 'skeleton'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/package.json b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/package.json new file mode 100644 index 00000000..4ee8df64 --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-skeleton-to-bui-skeleton", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Skeleton with BUI Skeleton", + "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.13" + } +} diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/scripts/codemod.ts b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/scripts/codemod.ts new file mode 100644 index 00000000..726a2778 --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/scripts/codemod.ts @@ -0,0 +1,367 @@ +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-skeleton-to-bui-skeleton') + +const BUI_SOURCE = '@backstage/ui' +const MUI_LAB_BARREL = '@material-ui/lab' +const MUI_CORE_BARREL = '@material-ui/core' + +const PASSTHROUGH_PROPS = new Set(['width', 'height', 'className', 'style', 'id']) + +const TODO_PROPS = new Set(['animation', 'classes', 'component']) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function withTodoComment(comment: string, elementText: string): string { + return `<> + ${comment} + ${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_LAB_BARREL}'` + + 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: { + 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 collectSkeletonImports(rootNode: SgNode): { + skeletonLocalName: string | null + importNodesToRemove: SgNode[] + barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] +} { + let skeletonLocalName: string | null = null + const importNodesToRemove: SgNode[] = [] + const barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] = [] + + for (const source of ['@material-ui/lab/Skeleton', '@material-ui/core/Skeleton']) { + for (const imp of findImportStatementsFrom(rootNode, source)) { + skeletonLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + } + + for (const barrel of [MUI_LAB_BARREL, MUI_CORE_BARREL]) { + for (const imp of findImportStatementsFrom(rootNode, barrel)) { + const localName = getNamedImportLocalName(imp, 'Skeleton') + if (localName) { + skeletonLocalName = localName + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (allSpecifiers.length <= 1) { + importNodesToRemove.push(imp) + } else { + barrelImportsToPrune.push({ imp, namesToRemove: new Set(['Skeleton']) }) + } + } + } + } + + return { skeletonLocalName, importNodesToRemove, barrelImportsToPrune } +} + +function addBuiImport( + rootNode: SgNode, + importNodesToRemove: SgNode[], + barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[], + edits: Edit[], + handledBarrelIds: Set, +): boolean { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const alreadyImported = getNamedImportLocalName(existingImport, 'Skeleton') !== null + if (!alreadyImported) { + 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) + names.push('Skeleton') + names.sort() + edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } else { + edits.push(existingImport.replace(`${existingImport.text()}\nimport { Skeleton } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + } + } + return false + } + + const skipIds = new Set([ + ...importNodesToRemove.map((imp) => imp.id()), + ...barrelImportsToPrune.map(({ imp }) => imp.id()), + ]) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null + const buiImport = `import { Skeleton } from '${BUI_SOURCE}';` + + if (anchorImport) { + edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) + migrationMetric.increment({ action: 'import-added' }) + return false + } + + const [barrelToFold] = barrelImportsToPrune + if (barrelToFold) { + const pruned = rebuildImportWithout(barrelToFold.imp, barrelToFold.namesToRemove) + edits.push(barrelToFold.imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) + handledBarrelIds.add(barrelToFold.imp.id()) + migrationMetric.increment({ action: 'import-added' }) + migrationMetric.increment({ action: 'import-pruned' }) + return false + } + + if (importNodesToRemove.length >= 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(buiImport)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } + + 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 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 transformSkeletonElements( + rootNode: SgNode, + skeletonLocalName: 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 !== skeletonLocalName) { + continue + } + + const todoReasons: string[] = [] + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + todoReasons.push(prop) + } + } + + if (todoReasons.length > 0) { + preserveImport = true + edits.push( + el.replace( + withTodoComment( + `{/* TODO(backstage-codemod): finish skeleton migration manually (${todoReasons.join(', ')}) */}`, + el.text(), + ), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: todoReasons.join(', ') }) + continue + } + + const newProps: string[] = [] + const variant = getPropStringValue(opening, 'variant') + if (variant === 'circle') { + newProps.push('rounded') + migrationMetric.increment({ action: 'variant-mapped', from: 'circle', to: 'rounded' }) + } + + for (const child of opening.children()) { + const kind = child.kind() + if (kind === 'jsx_attribute') { + const propIdent = child.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + const propName = propIdent.text() + if (propName === 'variant') { + continue + } + if (PASSTHROUGH_PROPS.has(propName) || propName.startsWith('aria-') || propName.startsWith('data-')) { + newProps.push(child.text()) + } + } else if (kind === 'jsx_expression' && child.text().startsWith('{...')) { + newProps.push(child.text()) + } + } + + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + if (isSelfClosing) { + edits.push(el.replace(``)) + } else { + 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}`)) + } + + migrated = true + migrationMetric.increment({ action: 'skeleton-migrated' }) + } + + return { preserveImport, migrated } +} + +const transform: Codemod = (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { skeletonLocalName, importNodesToRemove, barrelImportsToPrune } = collectSkeletonImports(rootNode) + + if (!skeletonLocalName) { + return Promise.resolve(null) + } + + const { preserveImport, migrated } = transformSkeletonElements(rootNode, skeletonLocalName, edits) + + let replacedImport = false + const handledBarrelIds = new Set() + if (migrated) { + replacedImport = addBuiImport(rootNode, importNodesToRemove, barrelImportsToPrune, edits, handledBarrelIds) + } + + if (!preserveImport) { + for (const { imp, namesToRemove } of barrelImportsToPrune) { + if (handledBarrelIds.has(imp.id())) { + continue + } + 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) +} + +export default transform diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/animation-todo/expected.tsx b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/animation-todo/expected.tsx new file mode 100644 index 00000000..c403e4ab --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/animation-todo/expected.tsx @@ -0,0 +1,8 @@ +import Skeleton from '@material-ui/lab/Skeleton'; + +const MyComponent = () => ( + <> + {/* TODO(backstage-codemod): finish skeleton migration manually (animation) */} + + +); diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/animation-todo/input.tsx b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/animation-todo/input.tsx new file mode 100644 index 00000000..64723e4c --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/animation-todo/input.tsx @@ -0,0 +1,5 @@ +import Skeleton from '@material-ui/lab/Skeleton'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/animation-todo/metrics.json b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/animation-todo/metrics.json new file mode 100644 index 00000000..a1d22e4e --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/animation-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-skeleton-to-bui-skeleton": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "animation" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/core-import/expected.tsx b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/core-import/expected.tsx new file mode 100644 index 00000000..834ad089 --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/core-import/expected.tsx @@ -0,0 +1,5 @@ +import { Skeleton } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/core-import/input.tsx b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/core-import/input.tsx new file mode 100644 index 00000000..762d24a7 --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/core-import/input.tsx @@ -0,0 +1,5 @@ +import Skeleton from '@material-ui/lab/Skeleton'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/core-import/metrics.json b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/core-import/metrics.json new file mode 100644 index 00000000..d09df2ca --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/core-import/metrics.json @@ -0,0 +1,30 @@ +{ + "migrate-mui-skeleton-to-bui-skeleton": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "skeleton-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "variant-mapped", + "from": "circle", + "to": "rounded" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/happy-path-rect/expected.tsx b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/happy-path-rect/expected.tsx new file mode 100644 index 00000000..467fc6b4 --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/happy-path-rect/expected.tsx @@ -0,0 +1,5 @@ +import { Skeleton } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/happy-path-rect/input.tsx b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/happy-path-rect/input.tsx new file mode 100644 index 00000000..31ee26b0 --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/happy-path-rect/input.tsx @@ -0,0 +1,5 @@ +import { Skeleton } from '@material-ui/lab'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/happy-path-rect/metrics.json b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/happy-path-rect/metrics.json new file mode 100644 index 00000000..861b4d67 --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/happy-path-rect/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-skeleton-to-bui-skeleton": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "skeleton-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/merge-existing-bui/expected.tsx new file mode 100644 index 00000000..228040a6 --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + +import { Button, Skeleton } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/merge-existing-bui/input.tsx new file mode 100644 index 00000000..0c51aed7 --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/merge-existing-bui/input.tsx @@ -0,0 +1,9 @@ +import Skeleton from '@material-ui/core/Skeleton'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/merge-existing-bui/metrics.json new file mode 100644 index 00000000..55a9fb41 --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/merge-existing-bui/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-skeleton-to-bui-skeleton": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "skeleton-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/named-barrel-import/expected.tsx new file mode 100644 index 00000000..74e2c7fe --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/named-barrel-import/expected.tsx @@ -0,0 +1,5 @@ +import { Skeleton } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/named-barrel-import/input.tsx new file mode 100644 index 00000000..1cfb5bd5 --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/named-barrel-import/input.tsx @@ -0,0 +1,5 @@ +import { Skeleton } from '@material-ui/lab'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/named-barrel-import/metrics.json new file mode 100644 index 00000000..861b4d67 --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/named-barrel-import/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-skeleton-to-bui-skeleton": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "skeleton-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/noop-no-import/expected.tsx new file mode 100644 index 00000000..ef57a142 --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/noop-no-import/expected.tsx @@ -0,0 +1,9 @@ +import { Skeleton } from '@backstage/ui'; +import CircularProgress from '@material-ui/core/CircularProgress'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/noop-no-import/input.tsx new file mode 100644 index 00000000..ef57a142 --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tests/noop-no-import/input.tsx @@ -0,0 +1,9 @@ +import { Skeleton } from '@backstage/ui'; +import CircularProgress from '@material-ui/core/CircularProgress'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tsconfig.json b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/tsconfig.json new file mode 100644 index 00000000..5ad4d6f0 --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/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-skeleton-to-bui-skeleton/workflow.yaml b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/workflow.yaml new file mode 100644 index 00000000..c77db22f --- /dev/null +++ b/codemods/misc/migrate-mui-skeleton-to-bui-skeleton/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 Skeleton with BUI Skeleton' + 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/README.md b/codemods/misc/migrate-mui-slider-to-bui-slider/README.md new file mode 100644 index 00000000..b7805e6a --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/README.md @@ -0,0 +1,21 @@ +# @backstage/migrate-mui-slider-to-bui-slider + +Migrates MUI 4 `Slider` to Backstage UI `Slider`. + +## Covers + +- Basic controlled / uncontrolled Slider +- Named / partial barrel imports +- Merging into an existing `@backstage/ui` import + +## TODOs / won't-do + +- Complex `onChange` (`complex-on-change-todo`) +- `onChangeCommitted`, marks, value labels (`*-todo` fixtures) +- Range sliders beyond happy path + +## Test + +```bash +yarn test +``` diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/CHANGELOG.md b/codemods/misc/migrate-mui-switch-to-bui-switch/CHANGELOG.md new file mode 100644 index 00000000..b35e8722 --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/CHANGELOG.md @@ -0,0 +1,7 @@ +# @backstage/migrate-mui-switch-to-bui-switch + +## 0.1.0 + +### Minor Changes + +- Initial release: migrate MUI `Switch` (and `FormControlLabel` wrapping `Switch`) to Backstage UI `Switch` during the MUI 4 to BUI migration. diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/README.md b/codemods/misc/migrate-mui-switch-to-bui-switch/README.md new file mode 100644 index 00000000..edf3436e --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/README.md @@ -0,0 +1,19 @@ +# @backstage/migrate-mui-switch-to-bui-switch + +Migrates MUI 4 `Switch` usage to Backstage UI `Switch`. + +## Covers + +- Named barrel and deep `@material-ui/core/Switch` imports +- `FormControlLabel` wrapping `Switch` → BUI `Switch` with `label` +- `disabled` → `isDisabled`, `checked` → `isSelected`, `defaultChecked` → `defaultSelected` +- Merging into an existing `@backstage/ui` import + +## TODOs left for humans + +- Non-string `FormControlLabel` labels +- Unsupported MUI Switch props (`color`, `size`, `edge`, `classes`, …) + +## Won't do + +- Non-Switch `FormControlLabel` controls (handled by radio/checkbox codemod) diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/codemod.yaml b/codemods/misc/migrate-mui-switch-to-bui-switch/codemod.yaml new file mode 100644 index 00000000..27d38a91 --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-switch-to-bui-switch' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Switch with BUI Switch' +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', 'switch'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/package.json b/codemods/misc/migrate-mui-switch-to-bui-switch/package.json new file mode 100644 index 00000000..6bc47fd3 --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-switch-to-bui-switch", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Switch with BUI Switch", + "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.13" + } +} diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/scripts/codemod.ts b/codemods/misc/migrate-mui-switch-to-bui-switch/scripts/codemod.ts new file mode 100644 index 00000000..cace9af0 --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/scripts/codemod.ts @@ -0,0 +1,559 @@ +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-switch-to-bui-switch') + +const BUI_SOURCE = '@backstage/ui' +const MUI_BARREL_SOURCE = '@material-ui/core' + +const PROP_RENAMES: Record = { + checked: 'isSelected', + defaultChecked: 'defaultSelected', + disabled: 'isDisabled', +} + +const PASSTHROUGH_PROPS = new Set([ + 'name', + 'value', + 'id', + 'className', + 'style', + 'onChange', + 'onFocus', + 'onBlur', + 'autoFocus', + 'required', + 'aria-label', + 'aria-labelledby', + 'aria-describedby', +]) + +const TODO_PROPS = new Set([ + 'color', + 'size', + 'edge', + 'classes', + 'inputProps', + 'inputRef', + 'icon', + 'checkedIcon', + 'disableRipple', + 'focusVisibleClassName', + 'centerRipple', + 'disableTouchRipple', + 'disableFocusRipple', + 'TouchRippleProps', + 'type', +]) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function withTodoComment(comment: string, elementText: string): string { + return `<> + ${comment} + ${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: { + 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 collectSwitchImports(rootNode: SgNode): { + switchLocalName: string | null + fclLocalName: string | null + importNodesToRemove: SgNode[] + barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] +} { + let switchLocalName: string | null = null + let fclLocalName: string | null = null + const importNodesToRemove: SgNode[] = [] + const barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] = [] + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Switch')) { + switchLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/FormControlLabel')) { + fclLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL_SOURCE)) { + const foundNames = new Set() + const switchName = getNamedImportLocalName(imp, 'Switch') + if (switchName) { + switchLocalName = switchName + foundNames.add('Switch') + } + const fclName = getNamedImportLocalName(imp, 'FormControlLabel') + if (fclName) { + fclLocalName = fclName + foundNames.add('FormControlLabel') + } + if (foundNames.size > 0) { + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (foundNames.size >= allSpecifiers.length) { + importNodesToRemove.push(imp) + } else { + barrelImportsToPrune.push({ imp, namesToRemove: foundNames }) + } + } + } + + return { switchLocalName, fclLocalName, importNodesToRemove, barrelImportsToPrune } +} + +function addBuiImport( + rootNode: SgNode, + importNodesToRemove: SgNode[], + barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[], + edits: Edit[], + handledBarrelIds: Set, +): boolean { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const alreadyImported = getNamedImportLocalName(existingImport, 'Switch') !== null + if (!alreadyImported) { + 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) + names.push('Switch') + names.sort() + edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } else { + edits.push(existingImport.replace(`${existingImport.text()}\nimport { Switch } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + } + } + return false + } + + const skipIds = new Set([ + ...importNodesToRemove.map((imp) => imp.id()), + ...barrelImportsToPrune.map(({ imp }) => imp.id()), + ]) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null + const buiImport = `import { Switch } from '${BUI_SOURCE}';` + + if (anchorImport) { + edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) + migrationMetric.increment({ action: 'import-added' }) + return false + } + + const [barrelToFold] = barrelImportsToPrune + if (barrelToFold) { + const pruned = rebuildImportWithout(barrelToFold.imp, barrelToFold.namesToRemove) + edits.push(barrelToFold.imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport)) + handledBarrelIds.add(barrelToFold.imp.id()) + migrationMetric.increment({ action: 'import-added' }) + migrationMetric.increment({ action: 'import-pruned' }) + return false + } + + if (importNodesToRemove.length >= 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(buiImport)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } + + 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 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() + } + } + // Boolean prop with no value + return '' +} + +function collectTodoProps(opening: SgNode): string[] { + const reasons: string[] = [] + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + reasons.push(prop) + } + } + return reasons +} + +function buildSwitchPropsFromOpening(opening: SgNode, extraProps: string[] = []): string[] { + const newProps: string[] = [...extraProps] + + for (const child of opening.children()) { + const kind = child.kind() + if (kind === 'jsx_attribute') { + const propIdent = child.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + const propName = propIdent.text() + const renamed = PROP_RENAMES[propName] + if (renamed) { + let valuePart: string | null = null + for (const attrChild of child.children()) { + const attrKind = attrChild.kind() + if (attrKind === 'string' || attrKind === 'jsx_expression') { + valuePart = attrChild.text() + break + } + } + newProps.push(valuePart !== null ? `${renamed}=${valuePart}` : renamed) + migrationMetric.increment({ action: 'prop-renamed', from: propName, to: renamed }) + continue + } + if (PASSTHROUGH_PROPS.has(propName) || propName.startsWith('aria-') || propName.startsWith('data-')) { + newProps.push(child.text()) + continue + } + // Unknown non-TODO props: passthrough cautiously + if (!TODO_PROPS.has(propName)) { + newProps.push(child.text()) + } + } else if (kind === 'jsx_expression' && child.text().startsWith('{...')) { + newProps.push(child.text()) + } + } + + return newProps +} + +function extractControlSwitch(fclOpening: SgNode, switchLocalName: string): SgNode | 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 !== switchLocalName) { + return null + } + return selfClosing +} + +function transformElements( + rootNode: SgNode, + switchLocalName: string, + fclLocalName: string | null, + edits: Edit[], +): { preserveImport: boolean; migrated: boolean; consumedSwitchIds: Set } { + let preserveImport = false + let migrated = false + const consumedSwitchIds = new Set() + + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + // First pass: FormControlLabel wrapping Switch + if (fclLocalName) { + 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 !== fclLocalName) { + continue + } + + const controlSwitch = extractControlSwitch(opening, switchLocalName) + if (!controlSwitch) { + continue + } + + const todoReasons = [ + ...collectTodoProps(controlSwitch), + ...collectTodoProps(opening).filter((p) => p !== 'control'), + ] + if (hasProp(opening, 'labelPlacement')) { + todoReasons.push('labelPlacement') + } + + const labelString = getPropStringValue(opening, 'label') + const labelRaw = getPropRawValue(opening, 'label') + if (labelString === null) { + // Dynamic / JSX / missing label + todoReasons.push(labelRaw !== null ? 'complex-label' : 'missing-label') + } + + if (todoReasons.length > 0) { + preserveImport = true + edits.push( + el.replace( + withTodoComment( + `{/* TODO(backstage-codemod): finish switch migration manually (${todoReasons.join(', ')}) */}`, + el.text(), + ), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: todoReasons.join(', ') }) + consumedSwitchIds.add(controlSwitch.id()) + continue + } + + const extraProps: string[] = [`label="${labelString}"`] + if (hasProp(opening, 'disabled') && !hasProp(controlSwitch, 'disabled')) { + extraProps.push('isDisabled') + migrationMetric.increment({ action: 'prop-renamed', from: 'disabled', to: 'isDisabled' }) + } + + const newProps = buildSwitchPropsFromOpening(controlSwitch, extraProps) + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + edits.push(el.replace(``)) + consumedSwitchIds.add(controlSwitch.id()) + migrated = true + migrationMetric.increment({ action: 'switch-migrated', via: 'form-control-label' }) + } + } + + // Second pass: standalone Switch + 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 !== switchLocalName) { + continue + } + + if (consumedSwitchIds.has(el.id()) || consumedSwitchIds.has(opening.id())) { + continue + } + + // Skip Switch nodes that live inside a FormControlLabel control= we already handled + const ancestors = el.ancestors() + const insideHandledFcl = ancestors.some((a) => { + if (!fclLocalName) { + return false + } + if (!a.is('jsx_self_closing_element') && !a.is('jsx_element')) { + return false + } + const aOpening = a.is('jsx_self_closing_element') ? a : a.child(0) + if (!aOpening) { + return false + } + return getElementName(aOpening) === fclLocalName + }) + if (insideHandledFcl) { + continue + } + + const todoReasons = collectTodoProps(opening) + if (todoReasons.length > 0) { + preserveImport = true + edits.push( + el.replace( + withTodoComment( + `{/* TODO(backstage-codemod): finish switch migration manually (${todoReasons.join(', ')}) */}`, + el.text(), + ), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: todoReasons.join(', ') }) + continue + } + + const newProps = buildSwitchPropsFromOpening(opening) + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + + if (isSelfClosing) { + edits.push(el.replace(``)) + } else { + 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}`)) + } + + migrated = true + migrationMetric.increment({ action: 'switch-migrated' }) + } + + return { preserveImport, migrated, consumedSwitchIds } +} + +const transform: Codemod = (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { switchLocalName, fclLocalName, importNodesToRemove, barrelImportsToPrune } = collectSwitchImports(rootNode) + + if (!switchLocalName) { + return Promise.resolve(null) + } + + const { preserveImport, migrated } = transformElements(rootNode, switchLocalName, fclLocalName, edits) + + let replacedImport = false + const handledBarrelIds = new Set() + if (migrated) { + replacedImport = addBuiImport(rootNode, importNodesToRemove, barrelImportsToPrune, edits, handledBarrelIds) + } + + if (!preserveImport) { + for (const { imp, namesToRemove } of barrelImportsToPrune) { + if (handledBarrelIds.has(imp.id())) { + continue + } + 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) +} + +export default transform diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/tests/color-todo/expected.tsx b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/color-todo/expected.tsx new file mode 100644 index 00000000..889a92e2 --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/color-todo/expected.tsx @@ -0,0 +1,8 @@ +import Switch from '@material-ui/core/Switch'; + +const MyComponent = () => ( + <> + {/* TODO(backstage-codemod): finish switch migration manually (color) */} + + +); diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/tests/color-todo/input.tsx b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/color-todo/input.tsx new file mode 100644 index 00000000..670e7055 --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/color-todo/input.tsx @@ -0,0 +1,5 @@ +import Switch from '@material-ui/core/Switch'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/tests/color-todo/metrics.json b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/color-todo/metrics.json new file mode 100644 index 00000000..cf90fe56 --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/color-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-switch-to-bui-switch": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "color" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/tests/core-import/expected.tsx b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/core-import/expected.tsx new file mode 100644 index 00000000..f27883aa --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/core-import/expected.tsx @@ -0,0 +1,5 @@ +import { Switch } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/tests/core-import/input.tsx b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/core-import/input.tsx new file mode 100644 index 00000000..06045061 --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/core-import/input.tsx @@ -0,0 +1,5 @@ +import Switch from '@material-ui/core/Switch'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/tests/core-import/metrics.json b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/core-import/metrics.json new file mode 100644 index 00000000..04e6d74c --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/core-import/metrics.json @@ -0,0 +1,38 @@ +{ + "migrate-mui-switch-to-bui-switch": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "checked", + "to": "isSelected" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "disabled", + "to": "isDisabled" + }, + "count": 1 + }, + { + "cardinality": { + "action": "switch-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/tests/happy-path-standalone/expected.tsx b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/happy-path-standalone/expected.tsx new file mode 100644 index 00000000..22f3417a --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/happy-path-standalone/expected.tsx @@ -0,0 +1,5 @@ +import { Switch } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/tests/happy-path-standalone/input.tsx b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/happy-path-standalone/input.tsx new file mode 100644 index 00000000..7243a36b --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/happy-path-standalone/input.tsx @@ -0,0 +1,5 @@ +import { Switch } from '@material-ui/core'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/tests/happy-path-standalone/metrics.json b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/happy-path-standalone/metrics.json new file mode 100644 index 00000000..04e6d74c --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/happy-path-standalone/metrics.json @@ -0,0 +1,38 @@ +{ + "migrate-mui-switch-to-bui-switch": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "checked", + "to": "isSelected" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "disabled", + "to": "isDisabled" + }, + "count": 1 + }, + { + "cardinality": { + "action": "switch-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/merge-existing-bui/expected.tsx new file mode 100644 index 00000000..05f6cf9e --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + +import { Button, Switch } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/merge-existing-bui/input.tsx new file mode 100644 index 00000000..a2fd4bb6 --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/merge-existing-bui/input.tsx @@ -0,0 +1,9 @@ +import Switch from '@material-ui/core/Switch'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/merge-existing-bui/metrics.json new file mode 100644 index 00000000..e562a116 --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/merge-existing-bui/metrics.json @@ -0,0 +1,30 @@ +{ + "migrate-mui-switch-to-bui-switch": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "defaultChecked", + "to": "defaultSelected" + }, + "count": 1 + }, + { + "cardinality": { + "action": "switch-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/named-barrel-import/expected.tsx new file mode 100644 index 00000000..64e4efc9 --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/named-barrel-import/expected.tsx @@ -0,0 +1,5 @@ +import { Switch } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/named-barrel-import/input.tsx new file mode 100644 index 00000000..0161a04d --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/named-barrel-import/input.tsx @@ -0,0 +1,5 @@ +import { FormControlLabel, Switch } from '@material-ui/core'; + +const MyComponent = () => ( + } label="Notifications" /> +); diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/named-barrel-import/metrics.json new file mode 100644 index 00000000..a49310ad --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/named-barrel-import/metrics.json @@ -0,0 +1,31 @@ +{ + "migrate-mui-switch-to-bui-switch": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "checked", + "to": "isSelected" + }, + "count": 1 + }, + { + "cardinality": { + "action": "switch-migrated", + "via": "form-control-label" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/noop-no-import/expected.tsx new file mode 100644 index 00000000..3e5a95a7 --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import { Switch } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/noop-no-import/input.tsx new file mode 100644 index 00000000..3e5a95a7 --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/tests/noop-no-import/input.tsx @@ -0,0 +1,5 @@ +import { Switch } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-switch-to-bui-switch/tsconfig.json b/codemods/misc/migrate-mui-switch-to-bui-switch/tsconfig.json new file mode 100644 index 00000000..5ad4d6f0 --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/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-switch-to-bui-switch/workflow.yaml b/codemods/misc/migrate-mui-switch-to-bui-switch/workflow.yaml new file mode 100644 index 00000000..c22e778e --- /dev/null +++ b/codemods/misc/migrate-mui-switch-to-bui-switch/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 Switch with BUI Switch' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/README.md b/codemods/misc/migrate-mui-tabs-to-bui-tabs/README.md new file mode 100644 index 00000000..f04e9c59 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/README.md @@ -0,0 +1,20 @@ +# @backstage/migrate-mui-tabs-to-bui-tabs + +Migrates MUI Tabs to Backstage UI Tabs. + +## Covers + +- Standalone Tabs / Tab patterns +- Named / partial barrel imports +- Merging into an existing `@backstage/ui` import + +## TODOs / won't-do + +- Vertical tabs (`vertical-tabs-todo`) +- Scrollable / centered / custom indicator APIs + +## Test + +```bash +yarn test +``` diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/README.md b/codemods/misc/migrate-mui-textfield-to-bui-textfield/README.md new file mode 100644 index 00000000..4e0e8b3b --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/README.md @@ -0,0 +1,20 @@ +# @backstage/migrate-mui-textfield-to-bui-textfield + +Migrates MUI `TextField` to BUI `TextField` / `TextAreaField` / `PasswordField` / `NumberField`. + +## Covers + +- Basic controlled TextField; `helperText` → `description` +- `multiline` → TextAreaField; password / number branching +- Named / partial barrel imports; merge into existing `@backstage/ui` + +## TODOs / won't-do + +- Complex `onChange` handlers (`complex-on-change-todo`) +- Select-mode TextField and adornment-heavy layouts + +## Test + +```bash +yarn test +``` diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/codemod.yaml b/codemods/misc/migrate-mui-textfield-to-bui-textfield/codemod.yaml index 62155e7c..c512cab5 100644 --- a/codemods/misc/migrate-mui-textfield-to-bui-textfield/codemod.yaml +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/codemod.yaml @@ -2,7 +2,7 @@ schema_version: '1.0' name: '@backstage/migrate-mui-textfield-to-bui-textfield' version: '0.2.1' -description: 'MUI 4 to BUI: Replace TextField with BUI TextField' +description: 'MUI 4 to BUI: Replace TextField with BUI TextField / TextAreaField / PasswordField / NumberField' author: 'Paul Schultz' license: 'Apache-2.0' repository: 'https://github.com/backstage/codemods' 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 d4f0bc1f..cdec0f42 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 @@ -9,8 +9,6 @@ const MUI_BARREL_SOURCE = '@material-ui/core' /** Props that trigger a TODO — not mechanically migratable. */ const TODO_PROPS = new Set([ - 'multiline', - 'rows', 'rowsMax', 'minRows', 'maxRows', @@ -20,7 +18,6 @@ const TODO_PROPS = new Set([ 'inputProps', 'InputLabelProps', 'FormHelperTextProps', - 'helperText', 'error', 'variant', 'margin', @@ -37,24 +34,6 @@ const PROP_RENAMES: Record = { disabled: 'isDisabled', } -/** Props that pass through unchanged (documented for manual review). */ -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, '\\$&') } @@ -134,6 +113,13 @@ function getNamedImportLocalName(imp: SgNode, targetName: string): string | return null } +function getImportedName(spec: SgNode): string | null { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + return identifiers[0]?.text() ?? null +} + function collectTextFieldImports(rootNode: SgNode): { textFieldLocalName: string | null importNodesToRemove: SgNode[] @@ -166,6 +152,7 @@ function collectTextFieldImports(rootNode: SgNode): { function addBuiImport( rootNode: SgNode, + names: string[], importNodesToRemove: SgNode[], barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[], edits: Edit[], @@ -173,26 +160,34 @@ function addBuiImport( ): boolean { const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) const existingImport = existingImports[0] ?? null + const sortedNames = [...names].sort() + const buiImport = `import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';` if (existingImport) { - const alreadyImported = getNamedImportLocalName(existingImport, 'TextField') !== null - if (!alreadyImported) { - 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) - names.push('TextField') - names.sort() - edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) - migrationMetric.increment({ action: 'import-merged' }) - } else { - edits.push(existingImport.replace(`${existingImport.text()}\nimport { TextField } from '${BUI_SOURCE}';`)) - migrationMetric.increment({ action: 'import-added' }) + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) + const existingImported = new Set() + const existingTexts: string[] = [] + for (const spec of specifiers) { + const imported = getImportedName(spec) + if (imported) { + existingImported.add(imported) + } + existingTexts.push(spec.text()) } + for (const name of names) { + if (!existingImported.has(name)) { + existingTexts.push(name) + existingImported.add(name) + } + } + existingTexts.sort() + edits.push(namedImports.replace(`{ ${existingTexts.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } else { + edits.push(existingImport.replace(`${existingImport.text()}\n${buiImport}`)) + migrationMetric.increment({ action: 'import-added' }) } return false } @@ -203,7 +198,6 @@ function addBuiImport( ]) const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null - const buiImport = `import { TextField } from '${BUI_SOURCE}';` if (anchorImport) { edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) @@ -259,6 +253,55 @@ 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 isSimpleHelperText(attr: SgNode): boolean { + const stringNode = attr.find({ rule: { kind: 'string' } }) + if (stringNode && attr.find({ rule: { kind: 'jsx_expression' } }) === null) { + return true + } + const expr = attr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return false + } + if (expr.find({ rule: { kind: 'jsx_element' } }) !== null) { + return false + } + if (expr.find({ rule: { kind: 'jsx_self_closing_element' } }) !== null) { + return false + } + // Fragments aren't a typed `kind` in jssg's TSX RuleConfig — detect via source. + if (expr.text().includes('<>') || expr.text().includes('')) { + return false + } + return true +} + function getParamName(paramNode: SgNode): string { const ident = paramNode.find({ rule: { kind: 'identifier' } }) return ident?.text() ?? paramNode.text().replace(/:.*$/, '').trim() @@ -341,13 +384,28 @@ function tryRewriteOnChangeHandler(attr: SgNode): string | null { return `{newValue => ${rewrittenBody}}` } +function resolveTargetComponent(opening: SgNode): string { + if (hasProp(opening, 'multiline')) { + return 'TextAreaField' + } + const typeValue = getPropStringValue(opening, 'type') + if (typeValue === 'password') { + return 'PasswordField' + } + if (typeValue === 'number') { + return 'NumberField' + } + return 'TextField' +} + function transformTextFieldElements( rootNode: SgNode, textFieldLocalName: string, edits: Edit[], -): { preserveImport: boolean; migrated: boolean } { +): { preserveImport: boolean; migrated: boolean; buiNames: Set } { let preserveImport = false let migrated = false + const buiNames = new Set() const jsxElements = rootNode.findAll({ rule: { any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], @@ -366,8 +424,10 @@ function transformTextFieldElements( continue } + const isMultiline = hasProp(opening, 'multiline') let needsTodo = false const todoReasons: string[] = [] + for (const prop of TODO_PROPS) { if (hasProp(opening, prop)) { needsTodo = true @@ -375,6 +435,18 @@ function transformTextFieldElements( } } + // rows without multiline is not a BUI TextField concept + if (hasProp(opening, 'rows') && !isMultiline) { + needsTodo = true + todoReasons.push('rows') + } + + const helperTextAttr = getPropAttr(opening, 'helperText') + if (helperTextAttr && !isSimpleHelperText(helperTextAttr)) { + needsTodo = true + todoReasons.push('helperText') + } + if (needsTodo) { preserveImport = true edits.push( @@ -389,6 +461,7 @@ function transformTextFieldElements( continue } + const componentName = resolveTargetComponent(opening) const newProps: string[] = [] let handlerTodo = false let droppedFullWidth = false @@ -401,6 +474,35 @@ function transformTextFieldElements( continue } const propName = propIdent.text() + + if (propName === 'multiline') { + migrationMetric.increment({ action: 'prop-dropped', prop: 'multiline' }) + continue + } + + if (propName === 'type' && (componentName === 'PasswordField' || componentName === 'NumberField')) { + migrationMetric.increment({ action: 'prop-dropped', prop: 'type' }) + continue + } + + if (propName === 'rows' && componentName === 'TextAreaField') { + const raw = getPropRawValue(opening, 'rows') + if (raw) { + newProps.push(`rows=${raw}`) + migrationMetric.increment({ action: 'prop-mapped', from: 'rows', to: 'rows' }) + } + continue + } + + if (propName === 'helperText') { + const raw = getPropRawValue(opening, 'helperText') + if (raw) { + newProps.push(`description=${raw}`) + migrationMetric.increment({ action: 'prop-renamed', from: 'helperText', to: 'description' }) + } + continue + } + const renamed = PROP_RENAMES[propName] if (renamed) { const exprNode = child.find({ rule: { kind: 'jsx_expression' } }) @@ -453,45 +555,35 @@ function transformTextFieldElements( } const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + let output: string if (isSelfClosing) { - if (droppedFullWidth) { - edits.push( - el.replace( - withTodoComment( - '{/* TODO(backstage-codemod): finish TextField migration manually (fullWidth) */}', - ``, - ), - ), - ) - } else { - edits.push(el.replace(``)) - } + output = `<${componentName}${propsStr} />` } else { const children = el .children() .filter((c) => c.kind() !== 'jsx_opening_element' && c.kind() !== 'jsx_closing_element') .map((c) => c.text()) .join('') - if (droppedFullWidth) { - edits.push( - el.replace( - withTodoComment( - '{/* TODO(backstage-codemod): finish TextField migration manually (fullWidth) */}', - `${children}`, - ), - ), - ) - } else { - edits.push(el.replace(`${children}`)) - } + output = `<${componentName}${propsStr}>${children}` + } + + if (droppedFullWidth) { + edits.push( + el.replace( + withTodoComment('{/* TODO(backstage-codemod): finish TextField migration manually (fullWidth) */}', output), + ), + ) + } else { + edits.push(el.replace(output)) } + buiNames.add(componentName) migrated = true - migrationMetric.increment({ action: 'textfield-migrated' }) + migrationMetric.increment({ action: 'textfield-migrated', component: componentName }) } - return { preserveImport, migrated } + return { preserveImport, migrated, buiNames } } const transform: Codemod = (root) => { @@ -504,12 +596,19 @@ const transform: Codemod = (root) => { return Promise.resolve(null) } - const { preserveImport, migrated } = transformTextFieldElements(rootNode, textFieldLocalName, edits) + const { preserveImport, migrated, buiNames } = transformTextFieldElements(rootNode, textFieldLocalName, edits) let replacedImport = false const handledBarrelIds = new Set() if (migrated) { - replacedImport = addBuiImport(rootNode, importNodesToRemove, barrelImportsToPrune, edits, handledBarrelIds) + replacedImport = addBuiImport( + rootNode, + [...buiNames], + importNodesToRemove, + barrelImportsToPrune, + edits, + handledBarrelIds, + ) } if (!preserveImport) { diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/aliased-import/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/aliased-import/metrics.json index 8dd1a819..f08a9ddd 100644 --- a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/aliased-import/metrics.json +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/aliased-import/metrics.json @@ -20,7 +20,8 @@ }, { "cardinality": { - "action": "textfield-migrated" + "action": "textfield-migrated", + "component": "TextField" }, "count": 1 } 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 index f297188c..71a32067 100644 --- 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 @@ -28,7 +28,8 @@ }, { "cardinality": { - "action": "textfield-migrated" + "action": "textfield-migrated", + "component": "TextField" }, "count": 1 } 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 index c1023585..717ed632 100644 --- 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 @@ -29,7 +29,8 @@ }, { "cardinality": { - "action": "textfield-migrated" + "action": "textfield-migrated", + "component": "TextField" }, "count": 1 }, 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 index 419552c4..49e1146f 100644 --- 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 @@ -20,7 +20,8 @@ }, { "cardinality": { - "action": "textfield-migrated" + "action": "textfield-migrated", + "component": "TextField" }, "count": 1 } 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 deleted file mode 100644 index 968d80f6..00000000 --- a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/expected.tsx +++ /dev/null @@ -1,8 +0,0 @@ -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/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline/expected.tsx new file mode 100644 index 00000000..0b70a131 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline/expected.tsx @@ -0,0 +1,5 @@ +import { TextAreaField } from '@backstage/ui'; + +const MyComponent = () => ( + setDesc(newValue)} /> +); 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/input.tsx similarity index 100% rename from codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/input.tsx rename to codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline/input.tsx diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline/metrics.json new file mode 100644 index 00000000..3ce817d5 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline/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": "onChange-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-dropped", + "prop": "multiline" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-mapped", + "from": "rows", + "to": "rows" + }, + "count": 1 + }, + { + "cardinality": { + "action": "textfield-migrated", + "component": "TextAreaField" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/named-barrel-import/metrics.json index 8dd1a819..f08a9ddd 100644 --- a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/named-barrel-import/metrics.json +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/named-barrel-import/metrics.json @@ -20,7 +20,8 @@ }, { "cardinality": { - "action": "textfield-migrated" + "action": "textfield-migrated", + "component": "TextField" }, "count": 1 } diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/number-field/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/number-field/expected.tsx new file mode 100644 index 00000000..82a93385 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/number-field/expected.tsx @@ -0,0 +1,5 @@ +import { NumberField } from '@backstage/ui'; + +const MyComponent = () => ( + setCount(newValue)} /> +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/number-field/input.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/number-field/input.tsx new file mode 100644 index 00000000..01e2e720 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/number-field/input.tsx @@ -0,0 +1,5 @@ +import TextField from '@material-ui/core/TextField'; + +const MyComponent = () => ( + setCount(e.target.value)} /> +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/number-field/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/number-field/metrics.json new file mode 100644 index 00000000..9b409374 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/number-field/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-dropped", + "prop": "type" + }, + "count": 1 + }, + { + "cardinality": { + "action": "textfield-migrated", + "component": "NumberField" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/partial-barrel-import/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/partial-barrel-import/metrics.json index 0f93fbd8..a7567586 100644 --- a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/partial-barrel-import/metrics.json +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/partial-barrel-import/metrics.json @@ -20,7 +20,8 @@ }, { "cardinality": { - "action": "textfield-migrated" + "action": "textfield-migrated", + "component": "TextField" }, "count": 1 } diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/password-field/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/password-field/expected.tsx new file mode 100644 index 00000000..0ccc2011 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/password-field/expected.tsx @@ -0,0 +1,5 @@ +import { PasswordField } from '@backstage/ui'; + +const MyComponent = () => ( + setPassword(newValue)} description="Use a strong password" /> +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/password-field/input.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/password-field/input.tsx new file mode 100644 index 00000000..c79a1e57 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/password-field/input.tsx @@ -0,0 +1,11 @@ +import TextField from '@material-ui/core/TextField'; + +const MyComponent = () => ( + setPassword(e.target.value)} + helperText="Use a strong password" + /> +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/password-field/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/password-field/metrics.json new file mode 100644 index 00000000..79c41f60 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/password-field/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": "onChange-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-dropped", + "prop": "type" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "helperText", + "to": "description" + }, + "count": 1 + }, + { + "cardinality": { + "action": "textfield-migrated", + "component": "PasswordField" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/README.md b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/README.md new file mode 100644 index 00000000..3083e3f8 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/README.md @@ -0,0 +1,20 @@ +# @backstage/migrate-mui-tooltip-to-bui-tooltip + +Migrates MUI `Tooltip` toward Backstage UI `TooltipTrigger`. + +## Covers + +- Simple wrapper + title patterns (static and dynamic title) +- Named barrel imports; merge into existing `@backstage/ui` +- Drops unsupported placement props where needed + +## TODOs / won't-do + +- Controlled open state (`controlled-todo`) +- Perfect placement / delay parity + +## Test + +```bash +yarn test +``` diff --git a/codemods/misc/migrate-mui-typography-to-text/README.md b/codemods/misc/migrate-mui-typography-to-text/README.md new file mode 100644 index 00000000..067d1bbe --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/README.md @@ -0,0 +1,20 @@ +# @backstage/migrate-mui-typography-to-text + +Migrates MUI `Typography` to Backstage UI `Text` (valid TextVariants only). + +## Covers + +- Body / heading variants mapped to BUI Text variants +- Named barrel imports; merge into existing `@backstage/ui` +- Drops `gutterBottom` where unsupported + +## TODOs / won't-do + +- Unmapped variants (`unmapped-variant-todo`) +- Theme typography overrides / custom variant maps + +## Test + +```bash +yarn test +``` diff --git a/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts b/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts index 87824ba0..7aed494e 100644 --- a/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts @@ -7,18 +7,18 @@ const migrationMetric = useMetricAtom('migrate-mui-typography-to-text') // MUI Typography variant → BUI Text variant const VARIANT_MAP: Record = { h1: 'title-large', - h2: 'title-small', - h3: 'subtitle', - h4: 'body-small', - h5: 'caption', - h6: 'caption', - subtitle1: 'subtitle', - subtitle2: 'subtitle', - body1: 'body', + h2: 'title-medium', + h3: 'title-small', + h4: 'title-x-small', + h5: 'body-small', + h6: 'body-x-small', + subtitle1: 'title-x-small', + subtitle2: 'body-medium', + body1: 'body-medium', body2: 'body-small', - caption: 'caption', - overline: 'caption', - button: 'body', + caption: 'body-x-small', + overline: 'body-x-small', + button: 'body-medium', } // MUI color prop → BUI Text color prop diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/expected.tsx index 8f07c9db..746b0fd8 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/expected.tsx +++ b/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/expected.tsx @@ -1,5 +1,5 @@ import { Text } from '@backstage/ui'; const MyComponent = () => ( - Inline text + Inline text ); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/expected.tsx index ec88b262..f6997ecf 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/expected.tsx +++ b/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/expected.tsx @@ -1,5 +1,5 @@ import { Text } from '@backstage/ui'; const MyDialog = () => ( - Overview + Overview ); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/expected.tsx index 6fe8eb6c..40a9b568 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/expected.tsx +++ b/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/expected.tsx @@ -1,5 +1,5 @@ import { Text } from '@backstage/ui'; const MyComponent = () => ( - Label + Label ); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/expected.tsx index 4dc7973c..51d1d112 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/expected.tsx +++ b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/expected.tsx @@ -3,6 +3,6 @@ import { Text } from '@backstage/ui'; const MyComponent = () => ( <> {/* TODO(backstage-codemod): verify Text variant manually (gutterBottom) */} - Section Title + Section Title ); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/expected.tsx index d37b4fee..74bb6926 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/expected.tsx +++ b/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/expected.tsx @@ -4,6 +4,6 @@ import { Button, Text } from '@backstage/ui'; const MyComponent = () => ( <> - Small text + Small text ); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/expected.tsx index 78bc5c1e..ed02782f 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/expected.tsx +++ b/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/expected.tsx @@ -3,7 +3,7 @@ import { Text } from '@backstage/ui'; const MyComponent = () => ( <> - Hello + Hello ); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/expected.tsx index ffcabf49..4f212cb0 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/expected.tsx +++ b/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/expected.tsx @@ -1,5 +1,5 @@ import { Text } from '@backstage/ui'; const MyComponent = () => ( - Already migrated + Already migrated ); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/input.tsx index ffcabf49..4f212cb0 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/input.tsx +++ b/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/input.tsx @@ -1,5 +1,5 @@ import { Text } from '@backstage/ui'; const MyComponent = () => ( - Already migrated + Already migrated ); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/expected.tsx index 4d16d587..a87e431b 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/expected.tsx +++ b/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/expected.tsx @@ -1,7 +1,7 @@ import { Text } from '@backstage/ui'; const MyComponent = () => ( - + Click here for details. ); diff --git a/codemods/misc/mui4-to-bui-migration-recipe/.npmignore b/codemods/misc/mui4-to-bui-migration-recipe/.npmignore new file mode 100644 index 00000000..c7a34d8d --- /dev/null +++ b/codemods/misc/mui4-to-bui-migration-recipe/.npmignore @@ -0,0 +1 @@ +workflow.local.yaml diff --git a/codemods/misc/mui4-to-bui-migration-recipe/README.md b/codemods/misc/mui4-to-bui-migration-recipe/README.md index ccf23c50..97d1ca6e 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/README.md +++ b/codemods/misc/mui4-to-bui-migration-recipe/README.md @@ -7,7 +7,7 @@ This recipe is **not** tied to a single Backstage release. It is the orchestrati ## Recommended order 1. **Bootstrap first** — `@backstage/migrate-mui-bootstrap-to-bui` adds `@backstage/ui` / `@remixicon/react` dependencies and the global BUI stylesheet. -2. **Transforms** — icons, styles, components, form controls, then layout, in the stable sequence below. +2. **Transforms** — icons, styles, core components, complex components, form controls, then layout, in the stable sequence below. 3. **Cleanup last** — `@backstage/remove-mui-dependencies` drops unused `@material-ui/*` packages from `package.json` only after source files no longer import them. Do **not** run `remove-mui-dependencies` before the component transforms finish, or packages that still have MUI imports may be removed incorrectly. @@ -21,24 +21,34 @@ Do **not** run `remove-mui-dependencies` before the component transforms finish, | 3 | [`@backstage/migrate-mui-styles-to-bui-css-modules`](https://go.codemod.com/registry?q=backstage+migrate-mui-styles-to-bui-css-modules) | Styling architecture | Migrates `makeStyles` / `withStyles` toward BUI CSS modules | | 4 | [`@backstage/migrate-mui-typography-to-text`](https://go.codemod.com/registry?q=backstage+migrate-mui-typography-to-text) | Typography | Replaces MUI `Typography` with BUI `Text` | | 5 | [`@backstage/migrate-mui-alert-to-bui-alert`](https://go.codemod.com/registry?q=backstage+migrate-mui-alert-to-bui-alert) | Alert banners | Migrates MUI `Alert` to BUI `Alert` | -| 6 | [`@backstage/migrate-mui-button-to-bui-button`](https://go.codemod.com/registry?q=backstage+migrate-mui-button-to-bui-button) | Standard buttons | Migrates MUI `Button` to BUI `Button` | -| 7 | [`@backstage/migrate-mui-icon-button-to-button-icon`](https://go.codemod.com/registry?q=backstage+migrate-mui-icon-button-to-button-icon) | Icon buttons | Migrates MUI `IconButton` to BUI `ButtonIcon` | -| 8 | [`@backstage/migrate-mui-tooltip-to-bui-tooltip`](https://go.codemod.com/registry?q=backstage+migrate-mui-tooltip-to-bui-tooltip) | Tooltips | Migrates MUI `Tooltip` toward BUI `TooltipTrigger` | -| 9 | [`@backstage/migrate-mui-dialog-to-bui-dialog`](https://go.codemod.com/registry?q=backstage+migrate-mui-dialog-to-bui-dialog) | Dialogs | Migrates Dialog shell patterns to BUI `Dialog` | -| 10 | [`@backstage/migrate-mui-tabs-to-bui-tabs`](https://go.codemod.com/registry?q=backstage+migrate-mui-tabs-to-bui-tabs) | Tabs | Migrates MUI Tabs to BUI Tabs | -| 11 | [`@backstage/migrate-mui-menu-popover-to-bui-menu`](https://go.codemod.com/registry?q=backstage+migrate-mui-menu-popover-to-bui-menu) | Menus / popovers | Migrates Menu / Popover patterns to BUI `Menu` | -| 12 | [`@backstage/migrate-mui-list-family-to-bui-list`](https://go.codemod.com/registry?q=backstage+migrate-mui-list-family-to-bui-list) | Lists | Migrates List family primitives to BUI List | -| 13 | [`@backstage/migrate-mui-chip-to-tag`](https://go.codemod.com/registry?q=backstage+migrate-mui-chip-to-tag) | Tags / chips | Migrates MUI `Chip` to BUI `Tag` | -| 14 | [`@backstage/migrate-mui-select-family-to-bui-select`](https://go.codemod.com/registry?q=backstage+migrate-mui-select-family-to-bui-select) | Select | Migrates Select wrapper patterns to BUI Select | -| 15 | [`@backstage/migrate-mui-textfield-to-bui-textfield`](https://go.codemod.com/registry?q=backstage+migrate-mui-textfield-to-bui-textfield) | TextField | Migrates MUI `TextField` to BUI `TextField` | -| 16 | [`@backstage/migrate-mui-accordion-to-bui-accordion`](https://go.codemod.com/registry?q=backstage+migrate-mui-accordion-to-bui-accordion) | Accordion | Migrates Accordion patterns to BUI Accordion | -| 17 | [`@backstage/migrate-mui-radio-checkbox-to-bui`](https://go.codemod.com/registry?q=backstage+migrate-mui-radio-checkbox-to-bui) | Radio / checkbox groups | Migrates radio and checkbox group patterns to BUI groups | -| 18 | [`@backstage/migrate-mui-slider-to-bui-slider`](https://go.codemod.com/registry?q=backstage+migrate-mui-slider-to-bui-slider) | Slider | Migrates MUI `Slider` to BUI `Slider` | -| 19 | [`@backstage/migrate-mui-layout-to-bui-layout`](https://go.codemod.com/registry?q=backstage+migrate-mui-layout-to-bui-layout) | Layout primitives | Converts common layout primitives (e.g. Box / Paper / Grid) toward BUI layout | -| 20 | [`@backstage/remove-mui-dependencies`](https://go.codemod.com/registry?q=backstage+remove-mui-dependencies) | Cleanup | Removes unused `@material-ui/*` dependencies from `package.json` after source migration | +| 6 | [`@backstage/migrate-mui-avatar-to-bui-avatar`](https://go.codemod.com/registry?q=backstage+migrate-mui-avatar-to-bui-avatar) | Avatars | Migrates MUI `Avatar` to BUI `Avatar` | +| 7 | [`@backstage/migrate-mui-skeleton-to-bui-skeleton`](https://go.codemod.com/registry?q=backstage+migrate-mui-skeleton-to-bui-skeleton) | Loading placeholders | Migrates MUI `Skeleton` to BUI `Skeleton` | +| 8 | [`@backstage/migrate-mui-button-to-bui-button`](https://go.codemod.com/registry?q=backstage+migrate-mui-button-to-bui-button) | Standard buttons | Migrates MUI `Button` to BUI `Button` / `ButtonLink` | +| 9 | [`@backstage/migrate-mui-icon-button-to-button-icon`](https://go.codemod.com/registry?q=backstage+migrate-mui-icon-button-to-button-icon) | Icon buttons | Migrates MUI `IconButton` to BUI `ButtonIcon` | +| 10 | [`@backstage/migrate-mui-link-to-bui-link`](https://go.codemod.com/registry?q=backstage+migrate-mui-link-to-bui-link) | Links | Migrates MUI `Link` to BUI `Link` (skips `@backstage/core-components`) | +| 11 | [`@backstage/migrate-mui-tooltip-to-bui-tooltip`](https://go.codemod.com/registry?q=backstage+migrate-mui-tooltip-to-bui-tooltip) | Tooltips | Migrates MUI `Tooltip` toward BUI `TooltipTrigger` | +| 12 | [`@backstage/migrate-mui-dialog-to-bui-dialog`](https://go.codemod.com/registry?q=backstage+migrate-mui-dialog-to-bui-dialog) | Dialogs | Migrates Dialog shell patterns to BUI `Dialog` | +| 13 | [`@backstage/migrate-mui-tabs-to-bui-tabs`](https://go.codemod.com/registry?q=backstage+migrate-mui-tabs-to-bui-tabs) | Tabs | Migrates MUI Tabs to BUI Tabs | +| 14 | [`@backstage/migrate-mui-menu-popover-to-bui-menu`](https://go.codemod.com/registry?q=backstage+migrate-mui-menu-popover-to-bui-menu) | Menus / popovers | Migrates Menu **or** Popover: MenuList/MenuItem → BUI `Menu`; otherwise BUI `Popover` | +| 15 | [`@backstage/migrate-mui-list-family-to-bui-list`](https://go.codemod.com/registry?q=backstage+migrate-mui-list-family-to-bui-list) | Lists | Migrates List family primitives to BUI List | +| 16 | [`@backstage/migrate-mui-chip-to-tag`](https://go.codemod.com/registry?q=backstage+migrate-mui-chip-to-tag) | Tags / chips | Migrates MUI `Chip` to BUI `Tag` | +| 17 | [`@backstage/migrate-mui-card-to-bui-card`](https://go.codemod.com/registry?q=backstage+migrate-mui-card-to-bui-card) | Cards | Migrates simple Card family patterns to BUI `Card` | +| 18 | [`@backstage/migrate-mui-select-family-to-bui-select`](https://go.codemod.com/registry?q=backstage+migrate-mui-select-family-to-bui-select) | Select | Migrates Select wrapper patterns to BUI Select | +| 19 | [`@backstage/migrate-mui-textfield-to-bui-textfield`](https://go.codemod.com/registry?q=backstage+migrate-mui-textfield-to-bui-textfield) | TextField | Migrates MUI `TextField` to BUI `TextField` / specialized fields | +| 20 | [`@backstage/migrate-mui-autocomplete-to-combobox`](https://go.codemod.com/registry?q=backstage+migrate-mui-autocomplete-to-combobox) | Autocomplete | Heuristically migrates Autocomplete → `Combobox` or `SearchAutocomplete` | +| 21 | [`@backstage/migrate-mui-accordion-to-bui-accordion`](https://go.codemod.com/registry?q=backstage+migrate-mui-accordion-to-bui-accordion) | Accordion | Migrates Accordion patterns to BUI Accordion | +| 22 | [`@backstage/migrate-mui-radio-checkbox-to-bui`](https://go.codemod.com/registry?q=backstage+migrate-mui-radio-checkbox-to-bui) | Radio / checkbox | Migrates radio/checkbox groups and standalone Checkbox to BUI | +| 23 | [`@backstage/migrate-mui-switch-to-bui-switch`](https://go.codemod.com/registry?q=backstage+migrate-mui-switch-to-bui-switch) | Switch | Migrates MUI `Switch` to BUI `Switch` | +| 24 | [`@backstage/migrate-mui-slider-to-bui-slider`](https://go.codemod.com/registry?q=backstage+migrate-mui-slider-to-bui-slider) | Slider | Migrates MUI `Slider` to BUI `Slider` | +| 25 | [`@backstage/migrate-mui-layout-to-bui-layout`](https://go.codemod.com/registry?q=backstage+migrate-mui-layout-to-bui-layout) | Layout primitives | Converts Box / Paper / Grid toward BUI layout (see Paper heuristic below) | +| 26 | [`@backstage/remove-mui-dependencies`](https://go.codemod.com/registry?q=backstage+remove-mui-dependencies) | Cleanup | Removes unused `@material-ui/*` dependencies from `package.json` after source migration | Also see the short ordering note in [`../README.md`](../README.md). +### Paper heuristic (layout step) + +`@backstage/migrate-mui-layout-to-bui-layout` never maps Paper to `Surface`. Bare Paper → `Box bg="neutral"`; card-like Paper → `Card`; elevation / ambiguous Paper → left with `TODO(backstage-codemod)`. + ## Package naming Most transforms use `migrate-mui--to-bui-`. A few intentionally omit `-bui-` in the target segment when the BUI export name is the product surface (not a “BUI-prefixed” alias): @@ -54,7 +64,7 @@ Most transforms use `migrate-mui--to-bui-`. A few intentionally ## Usage ```bash -# Dry-run first +# Dry-run first (published registry packages) yarn dlx codemod@latest run @backstage/mui4-to-bui-migration-recipe \ --target /path/to/backstage-app \ --dry-run @@ -64,6 +74,25 @@ yarn dlx codemod@latest run @backstage/mui4-to-bui-migration-recipe \ --target /path/to/backstage-app ``` +### Local pre-publish dry-runs (`workflow.local.yaml`) + +Before packages are published, use the sibling-path workflow to exercise unpublished transforms against a local Backstage checkout (commonly `../backstage` from the monorepo root): + +```bash +cd codemods/misc/mui4-to-bui-migration-recipe + +# Validate registry + local workflows (also: yarn test) +bash scripts/validate-workflows.sh + +# Dry-run against a sibling Backstage tree (run from this package directory). +# Pass the workflow *file* path so the CLI does not rewrite to workflow.yaml. +yarn exec codemod workflow run -w ./workflow.local.yaml \ + -t ../../../../backstage \ + --dry +``` + +`workflow.local.yaml` is a maintainer-only mirror (not the published default). It uses the same step order as `workflow.yaml`, with relative sibling sources (`source: '../migrate-mui-…'`). Published consumers should keep using the registry recipe. + ## Recipe behavior - Every step runs at **default deterministic settings**. @@ -85,12 +114,15 @@ After the recipe finishes: The following are **not** covered by this recipe (document-only / manual): -- `Timeline` and unsupported Material UI Lab widgets without a stable BUI equivalent -- `@material-table/core` and broader table-system migrations that are not a safe 1:1 replacement +- Badge overlays (no safe 1:1 BUI mapping) +- `CircularProgress` / `LinearProgress` (do not auto-map to Skeleton) +- `Drawer`, `Snackbar`, `Stepper`, and `Timeline` +- `@material-table/core` / material-table and broader table-system migrations - Perfect theme parity, dark-mode polish, and final design-token tuning after the source-level migration - Custom wrapper abstractions that hide MUI components behind project-specific APIs ## Notes -- Each step resolves from the Codemod registry (`source: '@backstage/'`), so underlying packages must be published before the recipe can run end-to-end. +- `workflow.yaml` resolves each step from the Codemod registry (`source: '@backstage/'`), so underlying packages must be published before the recipe can run end-to-end. +- Use `workflow.local.yaml` for pre-publish dry-runs against sibling packages and a local `../backstage` tree. - This recipe is orchestration only — it has no transform script of its own. Running `yarn test` validates the workflow schema via `codemod workflow validate`. diff --git a/codemods/misc/mui4-to-bui-migration-recipe/package.json b/codemods/misc/mui4-to-bui-migration-recipe/package.json index 5861291d..5b3271ea 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/package.json +++ b/codemods/misc/mui4-to-bui-migration-recipe/package.json @@ -4,7 +4,7 @@ "description": "MUI 4 to BUI: Migration recipe that runs every deterministic MUI→BUI codemod from the registry in a safe order.", "type": "module", "scripts": { - "test": "yarn exec codemod workflow validate -w workflow.yaml" + "test": "bash scripts/validate-workflows.sh" }, "devDependencies": { "codemod": "1.12.13" diff --git a/codemods/misc/mui4-to-bui-migration-recipe/scripts/validate-workflows.sh b/codemods/misc/mui4-to-bui-migration-recipe/scripts/validate-workflows.sh new file mode 100755 index 00000000..d78c9df5 --- /dev/null +++ b/codemods/misc/mui4-to-bui-migration-recipe/scripts/validate-workflows.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Validate registry workflow.yaml (via package) and workflow.local.yaml +# (copied out of the package so the CLI does not rewrite -w to workflow.yaml). +set -euo pipefail +cd "$(dirname "$0")/.." + +yarn exec codemod workflow validate -w workflow.yaml + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +cp workflow.local.yaml "$tmp/workflow.local.yaml" +yarn exec codemod workflow validate -w "$tmp/workflow.local.yaml" diff --git a/codemods/misc/mui4-to-bui-migration-recipe/workflow.local.yaml b/codemods/misc/mui4-to-bui-migration-recipe/workflow.local.yaml new file mode 100644 index 00000000..7570aa0c --- /dev/null +++ b/codemods/misc/mui4-to-bui-migration-recipe/workflow.local.yaml @@ -0,0 +1,129 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json +# +# Local-path mirror of workflow.yaml for pre-publish dry-runs against a sibling +# Backstage checkout (e.g. ../backstage). Not published — registry consumers use +# workflow.yaml with @backstage/* sources. +# +# Usage (from this package directory): +# yarn exec codemod workflow run -w workflow.local.yaml \ +# -t /absolute/path/to/backstage \ +# --dry-run --no-interactive --allow-dirty +# Sibling checkout from repo root is typically ../../backstage (or ../../../backstage from a git worktree). + +version: '1' + +nodes: + - id: apply-recipe + name: 'Run every MUI 4 to BUI codemod in order (local packages)' + type: automatic + steps: + # --- Phase 1: bootstrap / package setup (run first) + - name: 'Bootstrap BUI dependencies and root CSS' + codemod: + source: '../migrate-mui-bootstrap-to-bui' + + # --- Phase 2: icon source + styling architecture + - name: 'Replace MUI icons with Remix icons' + codemod: + source: '../migrate-mui-icons-to-remix-icons' + + - name: 'Migrate makeStyles usage to BUI CSS modules' + codemod: + source: '../migrate-mui-styles-to-bui-css-modules' + + # --- Phase 3: core components + - name: 'Migrate Typography to Text' + codemod: + source: '../migrate-mui-typography-to-text' + + - name: 'Migrate Alert to BUI Alert' + codemod: + source: '../migrate-mui-alert-to-bui-alert' + + - name: 'Migrate Avatar to BUI Avatar' + codemod: + source: '../migrate-mui-avatar-to-bui-avatar' + + - name: 'Migrate Skeleton to BUI Skeleton' + codemod: + source: '../migrate-mui-skeleton-to-bui-skeleton' + + - name: 'Migrate Button to BUI Button' + codemod: + source: '../migrate-mui-button-to-bui-button' + + - name: 'Migrate IconButton to ButtonIcon' + codemod: + source: '../migrate-mui-icon-button-to-button-icon' + + - name: 'Migrate Link to BUI Link' + codemod: + source: '../migrate-mui-link-to-bui-link' + + - name: 'Migrate Tooltip to BUI Tooltip' + codemod: + source: '../migrate-mui-tooltip-to-bui-tooltip' + + # --- Phase 4: complex components + - name: 'Migrate Dialog to BUI Dialog' + codemod: + source: '../migrate-mui-dialog-to-bui-dialog' + + - name: 'Migrate Tabs to BUI Tabs' + codemod: + source: '../migrate-mui-tabs-to-bui-tabs' + + - name: 'Migrate Menu or Popover to BUI Menu/Popover' + codemod: + source: '../migrate-mui-menu-popover-to-bui-menu' + + - name: 'Migrate List family to BUI List' + codemod: + source: '../migrate-mui-list-family-to-bui-list' + + - name: 'Migrate Chip to Tag' + codemod: + source: '../migrate-mui-chip-to-tag' + + - name: 'Migrate Card family to BUI Card' + codemod: + source: '../migrate-mui-card-to-bui-card' + + # --- Phase 5: form controls + - name: 'Migrate Select family to BUI Select' + codemod: + source: '../migrate-mui-select-family-to-bui-select' + + - name: 'Migrate TextField to BUI TextField' + codemod: + source: '../migrate-mui-textfield-to-bui-textfield' + + - name: 'Migrate Autocomplete to BUI Combobox' + codemod: + source: '../migrate-mui-autocomplete-to-combobox' + + - name: 'Migrate Accordion to BUI Accordion' + codemod: + source: '../migrate-mui-accordion-to-bui-accordion' + + - name: 'Migrate radio/checkbox groups to BUI groups' + codemod: + source: '../migrate-mui-radio-checkbox-to-bui' + + - name: 'Migrate Switch to BUI Switch' + codemod: + source: '../migrate-mui-switch-to-bui-switch' + + - name: 'Migrate Slider to BUI Slider' + codemod: + source: '../migrate-mui-slider-to-bui-slider' + + # --- Phase 6: layout primitives (after components so wrappers settle first) + - name: 'Convert MUI layout primitives to BUI layout' + codemod: + source: '../migrate-mui-layout-to-bui-layout' + + # --- Phase 7: cleanup (run last) + - name: 'Remove unused @material-ui/* dependencies' + codemod: + source: '../remove-mui-dependencies' diff --git a/codemods/misc/mui4-to-bui-migration-recipe/workflow.yaml b/codemods/misc/mui4-to-bui-migration-recipe/workflow.yaml index 86be2c02..bdb98b8a 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/workflow.yaml +++ b/codemods/misc/mui4-to-bui-migration-recipe/workflow.yaml @@ -30,6 +30,14 @@ nodes: codemod: source: '@backstage/migrate-mui-alert-to-bui-alert' + - name: 'Migrate Avatar to BUI Avatar' + codemod: + source: '@backstage/migrate-mui-avatar-to-bui-avatar' + + - name: 'Migrate Skeleton to BUI Skeleton' + codemod: + source: '@backstage/migrate-mui-skeleton-to-bui-skeleton' + - name: 'Migrate Button to BUI Button' codemod: source: '@backstage/migrate-mui-button-to-bui-button' @@ -38,6 +46,10 @@ nodes: codemod: source: '@backstage/migrate-mui-icon-button-to-button-icon' + - name: 'Migrate Link to BUI Link' + codemod: + source: '@backstage/migrate-mui-link-to-bui-link' + - name: 'Migrate Tooltip to BUI Tooltip' codemod: source: '@backstage/migrate-mui-tooltip-to-bui-tooltip' @@ -51,7 +63,7 @@ nodes: codemod: source: '@backstage/migrate-mui-tabs-to-bui-tabs' - - name: 'Migrate Menu/Popover to BUI Menu' + - name: 'Migrate Menu or Popover to BUI Menu/Popover' codemod: source: '@backstage/migrate-mui-menu-popover-to-bui-menu' @@ -63,6 +75,10 @@ nodes: codemod: source: '@backstage/migrate-mui-chip-to-tag' + - name: 'Migrate Card family to BUI Card' + codemod: + source: '@backstage/migrate-mui-card-to-bui-card' + # --- Phase 5: form controls - name: 'Migrate Select family to BUI Select' codemod: @@ -72,6 +88,10 @@ nodes: codemod: source: '@backstage/migrate-mui-textfield-to-bui-textfield' + - name: 'Migrate Autocomplete to BUI Combobox' + codemod: + source: '@backstage/migrate-mui-autocomplete-to-combobox' + - name: 'Migrate Accordion to BUI Accordion' codemod: source: '@backstage/migrate-mui-accordion-to-bui-accordion' @@ -80,6 +100,10 @@ nodes: codemod: source: '@backstage/migrate-mui-radio-checkbox-to-bui' + - name: 'Migrate Switch to BUI Switch' + codemod: + source: '@backstage/migrate-mui-switch-to-bui-switch' + - name: 'Migrate Slider to BUI Slider' codemod: source: '@backstage/migrate-mui-slider-to-bui-slider' diff --git a/codemods/misc/remove-mui-dependencies/README.md b/codemods/misc/remove-mui-dependencies/README.md new file mode 100644 index 00000000..af9aa948 --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/README.md @@ -0,0 +1,20 @@ +# @backstage/remove-mui-dependencies + +Removes unused `@material-ui/*` dependencies from `package.json` after source migration. + +## Covers + +- Scans TS/TSX for remaining Material UI imports +- Drops unused `@material-ui/*` entries from package manifests +- Intended as the **last** step in the MUI 4 → BUI recipe + +## TODOs / won't-do + +- Does not rewrite source imports (run component codemods first) +- Does not remove transitive / workspace-hoisted deps outside the scanned package + +## Test + +```bash +yarn test +``` diff --git a/yarn.lock b/yarn.lock index 855b2bd0..867a1090 100644 --- a/yarn.lock +++ b/yarn.lock @@ -190,6 +190,24 @@ __metadata: languageName: unknown linkType: soft +"@backstage/migrate-mui-autocomplete-to-combobox@workspace:codemods/misc/migrate-mui-autocomplete-to-combobox": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-autocomplete-to-combobox@workspace:codemods/misc/migrate-mui-autocomplete-to-combobox" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.13" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-avatar-to-bui-avatar@workspace:codemods/misc/migrate-mui-avatar-to-bui-avatar": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-avatar-to-bui-avatar@workspace:codemods/misc/migrate-mui-avatar-to-bui-avatar" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.13" + languageName: unknown + linkType: soft + "@backstage/migrate-mui-bootstrap-to-bui@workspace:codemods/misc/migrate-mui-bootstrap-to-bui": version: 0.0.0-use.local resolution: "@backstage/migrate-mui-bootstrap-to-bui@workspace:codemods/misc/migrate-mui-bootstrap-to-bui" @@ -208,6 +226,15 @@ __metadata: languageName: unknown linkType: soft +"@backstage/migrate-mui-card-to-bui-card@workspace:codemods/misc/migrate-mui-card-to-bui-card": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-card-to-bui-card@workspace:codemods/misc/migrate-mui-card-to-bui-card" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.13" + languageName: unknown + linkType: soft + "@backstage/migrate-mui-chip-to-tag@workspace:codemods/misc/migrate-mui-chip-to-tag": version: 0.0.0-use.local resolution: "@backstage/migrate-mui-chip-to-tag@workspace:codemods/misc/migrate-mui-chip-to-tag" @@ -253,6 +280,15 @@ __metadata: languageName: unknown linkType: soft +"@backstage/migrate-mui-link-to-bui-link@workspace:codemods/misc/migrate-mui-link-to-bui-link": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-link-to-bui-link@workspace:codemods/misc/migrate-mui-link-to-bui-link" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.13" + languageName: unknown + linkType: soft + "@backstage/migrate-mui-list-family-to-bui-list@workspace:codemods/misc/migrate-mui-list-family-to-bui-list": version: 0.0.0-use.local resolution: "@backstage/migrate-mui-list-family-to-bui-list@workspace:codemods/misc/migrate-mui-list-family-to-bui-list" @@ -289,6 +325,15 @@ __metadata: languageName: unknown linkType: soft +"@backstage/migrate-mui-skeleton-to-bui-skeleton@workspace:codemods/misc/migrate-mui-skeleton-to-bui-skeleton": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-skeleton-to-bui-skeleton@workspace:codemods/misc/migrate-mui-skeleton-to-bui-skeleton" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.13" + 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" @@ -307,6 +352,15 @@ __metadata: languageName: unknown linkType: soft +"@backstage/migrate-mui-switch-to-bui-switch@workspace:codemods/misc/migrate-mui-switch-to-bui-switch": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-switch-to-bui-switch@workspace:codemods/misc/migrate-mui-switch-to-bui-switch" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.13" + languageName: unknown + linkType: soft + "@backstage/migrate-mui-tabs-to-bui-tabs@workspace:codemods/misc/migrate-mui-tabs-to-bui-tabs": version: 0.0.0-use.local resolution: "@backstage/migrate-mui-tabs-to-bui-tabs@workspace:codemods/misc/migrate-mui-tabs-to-bui-tabs" From bd3ac57efdfe24c6e5f3a1c4a88fc33e69106ab0 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 22 Jul 2026 17:13:34 -0400 Subject: [PATCH 02/11] =?UTF-8?q?Add=20patch=20changeset=20for=20README-on?= =?UTF-8?q?ly=20MUI=E2=86=92BUI=20packages.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .changeset/docs-mui4-bui-readme-stubs.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/docs-mui4-bui-readme-stubs.md diff --git a/.changeset/docs-mui4-bui-readme-stubs.md b/.changeset/docs-mui4-bui-readme-stubs.md new file mode 100644 index 00000000..2f4be270 --- /dev/null +++ b/.changeset/docs-mui4-bui-readme-stubs.md @@ -0,0 +1,16 @@ +--- +'@backstage/migrate-mui-accordion-to-bui-accordion': patch +'@backstage/migrate-mui-alert-to-bui-alert': patch +'@backstage/migrate-mui-bootstrap-to-bui': patch +'@backstage/migrate-mui-chip-to-tag': patch +'@backstage/migrate-mui-dialog-to-bui-dialog': patch +'@backstage/migrate-mui-icons-to-remix-icons': patch +'@backstage/migrate-mui-list-family-to-bui-list': patch +'@backstage/migrate-mui-select-family-to-bui-select': patch +'@backstage/migrate-mui-slider-to-bui-slider': patch +'@backstage/migrate-mui-tabs-to-bui-tabs': patch +'@backstage/migrate-mui-tooltip-to-bui-tooltip': patch +'@backstage/remove-mui-dependencies': patch +--- + +Add thin operator README stubs (covers / TODOs / won't-do) for existing MUI 4 → BUI packages. From f2b262fc45d61d9b077ebef096031ae1302948e0 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 22 Jul 2026 17:20:46 -0400 Subject: [PATCH 03/11] =?UTF-8?q?Close=20remaining=20MUI=E2=86=92BUI=20rev?= =?UTF-8?q?iew=20gaps=20from=20hardening=20PR.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten Link coexistence/path skips, emit Paper default-elevation TODOs and outlined→secondary metrics, and document operator expectations after a full backstage dry-run. Co-authored-by: Cursor --- .changeset/followup-mui4-bui-review-gaps.md | 10 ++ .../README.md | 21 ++- .../scripts/codemod.ts | 5 +- .../tests/end-icon/expected.tsx | 5 + .../tests/end-icon/input.tsx | 5 + .../tests/end-icon/metrics.json | 31 ++++ .../tests/href-button-link/metrics.json | 6 + .../tests/named-barrel-import/metrics.json | 6 + .../tests/outlined-button/metrics.json | 6 + .../tests/self-closing/metrics.json | 6 + .../README.md | 5 +- .../scripts/codemod.ts | 13 +- .../tests/paper-simple/expected.tsx | 3 + .../tests/paper-simple/metrics.json | 7 + .../migrate-mui-link-to-bui-link/README.md | 6 +- .../scripts/codemod.ts | 135 +++++++++++------- .../tests/deep-core-link-alias/expected.tsx | 9 ++ .../tests/deep-core-link-alias/input.tsx | 9 ++ .../tests/deep-core-link-alias/metrics.json | 22 +++ .../tests/simple-link/expected.tsx | 2 +- .../tests/skip-core-components/expected.tsx | 4 +- .../tests/skip-core-components/metrics.json | 15 +- .../underline-none-standalone/expected.tsx | 9 ++ .../tests/underline-none-standalone/input.tsx | 9 ++ .../underline-none-standalone/metrics.json | 22 +++ .../README.md | 19 ++- .../migrate-mui-typography-to-text/README.md | 17 +++ .../mui4-to-bui-migration-recipe/README.md | 68 +++++---- 28 files changed, 368 insertions(+), 107 deletions(-) create mode 100644 .changeset/followup-mui4-bui-review-gaps.md create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/end-icon/expected.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/end-icon/input.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/end-icon/metrics.json create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/tests/deep-core-link-alias/expected.tsx create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/tests/deep-core-link-alias/input.tsx create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/tests/deep-core-link-alias/metrics.json create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/tests/underline-none-standalone/expected.tsx create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/tests/underline-none-standalone/input.tsx create mode 100644 codemods/misc/migrate-mui-link-to-bui-link/tests/underline-none-standalone/metrics.json diff --git a/.changeset/followup-mui4-bui-review-gaps.md b/.changeset/followup-mui4-bui-review-gaps.md new file mode 100644 index 00000000..1034d26d --- /dev/null +++ b/.changeset/followup-mui4-bui-review-gaps.md @@ -0,0 +1,10 @@ +--- +'@backstage/migrate-mui-link-to-bui-link': patch +'@backstage/migrate-mui-layout-to-bui-layout': patch +'@backstage/migrate-mui-button-to-bui-button': patch +'@backstage/migrate-mui-typography-to-text': patch +'@backstage/migrate-mui-styles-to-bui-css-modules': patch +'@backstage/mui4-to-bui-migration-recipe': patch +--- + +Close remaining MUI→BUI review gaps: Link coexistence/aliasing + path skip, Paper default-elevation TODO, outlined→secondary metric, README/recipe operator docs. diff --git a/codemods/misc/migrate-mui-button-to-bui-button/README.md b/codemods/misc/migrate-mui-button-to-bui-button/README.md index a2184e0f..5ef149b5 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/README.md +++ b/codemods/misc/migrate-mui-button-to-bui-button/README.md @@ -1,13 +1,24 @@ -# migrate-mui-button-to-bui-button +# @backstage/migrate-mui-button-to-bui-button Migrates MUI 4 `Button` to Backstage UI `Button` / `ButtonLink`. -## Silent mappings +## Covers -- `variant="outlined"` → `variant="secondary"` (BUI secondary is the outlined visual) - `variant="contained"` → `variant="primary"` +- `variant="outlined"` → `variant="secondary"` (intentional silent remap; metric `outlined-to-secondary`) - `variant="text"` → `variant="tertiary"` -- omitted `variant` → `variant="tertiary"` (MUI default is text; BUI default is primary) +- Omitted `variant` → `variant="tertiary"` (MUI default is text; BUI default is primary) - `startIcon` / `endIcon` → `iconStart` / `iconEnd` -- Simple `onClick` handlers → `onPress` +- Simple `onClick` → `onPress` - `href` present → `ButtonLink` (same variant mapping) + +## TODOs / won't-do + +- Dynamic / unknown `variant` values left with `TODO(backstage-codemod)` +- Theme-level Button overrides, `classes`, size remaps that need design review + +## Test + +```bash +yarn test +``` diff --git a/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts b/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts index bd40a1c0..496a04b5 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts @@ -373,13 +373,16 @@ function transformButtonElements( // Build new props const newProps: string[] = [] - // Map variant (outlined → secondary is intentional and silent). + // Map variant (outlined → secondary is intentional; tracked via metric + recipe docs). // MUI default is text; BUI default is primary — emit tertiary when omitted. const variantValue = getPropStringValue(opening, 'variant') if (variantValue) { const buiVariant = VARIANT_MAP[variantValue] if (buiVariant) { newProps.push(`variant="${buiVariant}"`) + if (variantValue === 'outlined') { + migrationMetric.increment({ action: 'outlined-to-secondary' }) + } } else { // Unknown static variant — keep as-is with TODO preserveImport = true diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/end-icon/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/end-icon/expected.tsx new file mode 100644 index 00000000..de01219d --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/end-icon/expected.tsx @@ -0,0 +1,5 @@ +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/end-icon/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/end-icon/input.tsx new file mode 100644 index 00000000..3948afc2 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/end-icon/input.tsx @@ -0,0 +1,5 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/end-icon/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/end-icon/metrics.json new file mode 100644 index 00000000..214e732d --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/end-icon/metrics.json @@ -0,0 +1,31 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "contained" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "endIcon", + "to": "iconEnd" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/metrics.json index 0ac5f1d1..e5b21118 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/metrics.json @@ -19,6 +19,12 @@ }, "count": 1 }, + { + "cardinality": { + "action": "outlined-to-secondary" + }, + "count": 1 + }, { "cardinality": { "action": "prop-renamed", diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/metrics.json index 5dcb702b..b9a4902b 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/metrics.json @@ -18,6 +18,12 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "outlined-to-secondary" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/metrics.json index e7a0a27e..d909ddd6 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/metrics.json @@ -24,6 +24,12 @@ "action": "onClick-to-onPress" }, "count": 1 + }, + { + "cardinality": { + "action": "outlined-to-secondary" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/metrics.json index 5dcb702b..b9a4902b 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/metrics.json @@ -18,6 +18,12 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "outlined-to-secondary" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/README.md b/codemods/misc/migrate-mui-layout-to-bui-layout/README.md index b9e43e81..3158134f 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/README.md +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/README.md @@ -6,12 +6,13 @@ Converts common MUI layout primitives (Box / Paper / Grid) toward BUI layout. - Box flex-container happy paths - Simple Grid boolean / item patterns -- Paper heuristic: bare → `Box bg="neutral"`; card-like → `Card` (never `Surface`) +- Paper heuristic: bare → `Box bg="neutral"` (+ verify TODO for dropped default elevation); card-like → `Card` (never `Surface`) ## TODOs / won't-do - `box-component-todo`, `grid-todo`, `paper-elevation-todo` -- Elevation / ambiguous Paper left with `TODO(backstage-codemod)` +- Explicit `elevation` / `variant` / ambiguous Paper left with `TODO(backstage-codemod)` +- Bare Paper without `elevation` still migrates to `Box`, but emits a default-elevation verify TODO (MUI default is `elevation={1}`) - Full Grid system / responsive matrix rewrites ## Test diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts b/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts index eb4af500..e53b61c5 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts @@ -540,17 +540,16 @@ function transformPaperElement(el: SgNode, opening: SgNode, edits: Edi } // Bare Paper → Box with neutral background (Surface was removed from BUI). + // MUI Paper defaults to elevation={1}; BUI Box has no elevation chrome — leave a verify TODO. const newProps = [`bg="neutral"`, ...preservedProps] const propsStr = ` ${newProps.join(' ')}` + const boxElement = isSelfClosing ? `` : `${getChildContent(el)}` + const paperElevationTodo = + '{/* TODO(backstage-codemod): verify Paper elevation/visual chrome (MUI default elevation dropped) */}' - if (isSelfClosing) { - edits.push(el.replace(``)) - } else { - const children = getChildContent(el) - edits.push(el.replace(`${children}`)) - } - + edits.push(el.replace(withTodoComment(paperElevationTodo, boxElement))) migrationMetric.increment({ action: 'paper-to-box' }) + migrationMetric.increment({ action: 'todo-inserted', reason: 'paper-default-elevation' }) return 'Box' } diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/expected.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/expected.tsx index 865f936a..d1ab0f65 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/expected.tsx +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/expected.tsx @@ -2,5 +2,8 @@ import { Box } from '@backstage/ui'; const MyComponent = () => ( + <> + {/* TODO(backstage-codemod): verify Paper elevation/visual chrome (MUI default elevation dropped) */}

Content

+ ); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/metrics.json b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/metrics.json index 01433fd6..fbef6f6d 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/metrics.json +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/metrics.json @@ -17,6 +17,13 @@ "action": "paper-to-box" }, "count": 1 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "paper-default-elevation" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-link-to-bui-link/README.md b/codemods/misc/migrate-mui-link-to-bui-link/README.md index 09f33290..4bbeaa18 100644 --- a/codemods/misc/migrate-mui-link-to-bui-link/README.md +++ b/codemods/misc/migrate-mui-link-to-bui-link/README.md @@ -5,13 +5,13 @@ Migrates Material-UI `Link` to Backstage UI `Link`. ## Covers - Direct `@material-ui/core/Link` and barrel `@material-ui/core` `Link` imports -- Adds `standalone` for bare-looking links (no surrounding inline text siblings) +- Coexistence with `@backstage/core-components` `Link` (including deep `/Link` imports) via `Link as BuiLink` +- `standalone` only when MUI used `underline="none"` - Merging into an existing `@backstage/ui` import ## Skips -- Files under `packages/core-components` -- Files that import `Link` from `@backstage/core-components` +- Files under `packages/core-components` (path segment match only) ## TODOs left for humans diff --git a/codemods/misc/migrate-mui-link-to-bui-link/scripts/codemod.ts b/codemods/misc/migrate-mui-link-to-bui-link/scripts/codemod.ts index 9b806ccc..3164ffe2 100644 --- a/codemods/misc/migrate-mui-link-to-bui-link/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-link-to-bui-link/scripts/codemod.ts @@ -7,7 +7,9 @@ const migrationMetric = useMetricAtom('migrate-mui-link-to-bui-link') const BUI_SOURCE = '@backstage/ui' const MUI_BARREL = '@material-ui/core' const MUI_LINK = '@material-ui/core/Link' -const CORE_COMPONENTS = '@backstage/core-components' + +/** Match the core-components package root, not incidental path substrings. */ +const CORE_COMPONENTS_PATH_RE = /(?:^|[\\/])packages[\\/]core-components(?:[\\/]|$)/ /** Props that need TODO markers because their semantics don't map mechanically. */ const TODO_PROPS = new Set(['component', 'to']) @@ -74,6 +76,26 @@ function getImportedName(spec: SgNode): string | null { return identifiers[0]?.text() ?? null } +function getLocalImportNames(rootNode: SgNode): Set { + const names = new Set() + for (const imp of rootNode.findAll({ rule: { kind: 'import_statement' } })) { + const defaultName = getDefaultImportName(imp) + if (defaultName) { + names.add(defaultName) + } + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const localNameNode = identifiers[1] ?? identifiers[0] + if (localNameNode) { + names.add(localNameNode.text()) + } + } + } + return names +} + function getPrunedBarrelImportText(imp: SgNode, namesToRemove: string[], source: string): string { const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { const importedName = getImportedName(spec) @@ -94,15 +116,6 @@ function getPrunedBarrelImportText(imp: SgNode, namesToRemove: string[], so : `import ${typeKw}{ ${specTexts.join(', ')} } from '${source}';` } -function fileImportsCoreComponentsLink(rootNode: SgNode): boolean { - for (const imp of findImportStatementsFrom(rootNode, CORE_COMPONENTS)) { - if (getNamedImportLocalName(imp, 'Link')) { - return true - } - } - return false -} - function collectMuiLinkImports(rootNode: SgNode): { localName: string | null importNodesToRemove: SgNode[] @@ -133,17 +146,38 @@ function collectMuiLinkImports(rootNode: SgNode): { return { localName, importNodesToRemove, importSpecifiersToRemove } } +function resolveBuiLinkName(rootNode: SgNode, muiLocalName: string): string { + for (const imp of findImportStatementsFrom(rootNode, BUI_SOURCE)) { + const existing = getNamedImportLocalName(imp, 'Link') + if (existing) { + return existing + } + } + + // MUI local name will be removed; ignore it when checking for `Link` collisions + // (e.g. `@backstage/core-components` Link or `@backstage/core-components/Link`). + const bound = getLocalImportNames(rootNode) + for (const name of bound) { + if (name === 'Link' && name !== muiLocalName) { + return 'BuiLink' + } + } + return 'Link' +} + function addBuiImport( rootNode: SgNode, + buiLocalName: string, importNodesToRemove: SgNode[], importSpecifiersToRemove: Map, { source: string; names: string[] }>, edits: Edit[], handledBarrelIds: Set, replacedDeepImportIds: Set, ): void { + const importSpec = buiLocalName === 'Link' ? 'Link' : 'Link as BuiLink' + const buiImport = `import { ${importSpec} } from '${BUI_SOURCE}';` const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) const existingImport = existingImports[0] ?? null - const buiImport = `import { Link } from '${BUI_SOURCE}';` if (existingImport) { if (getNamedImportLocalName(existingImport, 'Link')) { @@ -153,7 +187,7 @@ function addBuiImport( if (namedImports) { const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) const existingTexts = specifiers.map((spec) => spec.text()) - existingTexts.push('Link') + existingTexts.push(importSpec) existingTexts.sort() edits.push(namedImports.replace(`{ ${existingTexts.join(', ')} }`)) migrationMetric.increment({ action: 'import-merged' }) @@ -161,15 +195,11 @@ function addBuiImport( return } - const skipIds = new Set([ - ...importNodesToRemove.map((imp) => imp.id()), - ...[...importSpecifiersToRemove.keys()].map((imp) => imp.id()), - ]) - const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) - const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null - - if (anchorImport) { - edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) + // Prefer in-place replacement of a deep MUI Link import to avoid leftover blank lines. + const [deep] = importNodesToRemove + if (deep) { + edits.push(deep.replace(buiImport)) + replacedDeepImportIds.add(deep.id()) migrationMetric.increment({ action: 'import-added' }) return } @@ -185,10 +215,12 @@ function addBuiImport( return } - const [deep] = importNodesToRemove - if (deep) { - edits.push(deep.replace(buiImport)) - replacedDeepImportIds.add(deep.id()) + const skipIds = new Set([...importSpecifiersToRemove.keys()].map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) migrationMetric.increment({ action: 'import-added' }) } } @@ -224,24 +256,27 @@ function hasProp(opening: SgNode, propName: string): boolean { ) } -function isBareLookingLink(element: SgNode): boolean { - const parent = element.parent() - if (!parent) { - return true +function getPropStringValue(opening: SgNode, propName: string): string | null { + const attr = opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) + if (!attr) { + return null } - - for (const sibling of parent.children()) { - if (sibling.id() === element.id()) { - continue - } - if (sibling.kind() === 'jsx_text' && sibling.text().trim().length > 0) { - return false - } + const str = attr.find({ rule: { kind: 'string' } }) + if (!str) { + return null } - return true + return str.text().slice(1, -1) } -function buildBuiLink(element: SgNode, opening: SgNode): string { +function buildBuiLink(element: SgNode, opening: SgNode, buiLocalName: string): string { const attrs: string[] = [] for (const child of opening.children()) { if (child.kind() === 'jsx_attribute') { @@ -258,20 +293,23 @@ function buildBuiLink(element: SgNode, opening: SgNode): string { } } - if (isBareLookingLink(element) && !attrs.some((attr) => attr.startsWith('standalone'))) { + // Only emit standalone when MUI explicitly opted out of underline — avoid over-applying + // to sole children of layout containers (div/li/fragment). + if (getPropStringValue(opening, 'underline') === 'none' && !attrs.some((attr) => attr.startsWith('standalone'))) { attrs.push('standalone') } const propsStr = attrs.length > 0 ? ` ${attrs.join(' ')}` : '' if (element.is('jsx_self_closing_element')) { - return `` + return `<${buiLocalName}${propsStr} />` } - return `${getChildContent(element)}` + return `<${buiLocalName}${propsStr}>${getChildContent(element)}` } function transformLinkElements( rootNode: SgNode, localName: string, + buiLocalName: string, edits: Edit[], ): { migrated: boolean; preserveImport: boolean } { let migrated = false @@ -314,7 +352,7 @@ function transformLinkElements( continue } - edits.push(el.replace(buildBuiLink(el, opening))) + edits.push(el.replace(buildBuiLink(el, opening, buiLocalName))) migrated = true migrationMetric.increment({ action: 'link-migrated' }) } @@ -326,23 +364,19 @@ const transform: Codemod = async (root) => { const rootNode = root.root() const filename = root.filename() - if (filename.includes('packages/core-components')) { + if (CORE_COMPONENTS_PATH_RE.test(filename)) { migrationMetric.increment({ action: 'skipped', reason: 'core-components-path' }) return null } - if (fileImportsCoreComponentsLink(rootNode)) { - migrationMetric.increment({ action: 'skipped', reason: 'core-components-import' }) - return null - } - const { localName, importNodesToRemove, importSpecifiersToRemove } = collectMuiLinkImports(rootNode) if (!localName) { return null } + const buiLocalName = resolveBuiLinkName(rootNode, localName) const edits: Edit[] = [] - const { migrated, preserveImport } = transformLinkElements(rootNode, localName, edits) + const { migrated, preserveImport } = transformLinkElements(rootNode, localName, buiLocalName, edits) if (!migrated && !preserveImport) { return null } @@ -353,6 +387,7 @@ const transform: Codemod = async (root) => { if (migrated) { addBuiImport( rootNode, + buiLocalName, importNodesToRemove, importSpecifiersToRemove, edits, diff --git a/codemods/misc/migrate-mui-link-to-bui-link/tests/deep-core-link-alias/expected.tsx b/codemods/misc/migrate-mui-link-to-bui-link/tests/deep-core-link-alias/expected.tsx new file mode 100644 index 00000000..e34b8e12 --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/tests/deep-core-link-alias/expected.tsx @@ -0,0 +1,9 @@ +import Link from '@backstage/core-components/Link'; +import { Link as BuiLink } from '@backstage/ui'; + +const Mixed = () => ( + <> + Docs + Other + +); diff --git a/codemods/misc/migrate-mui-link-to-bui-link/tests/deep-core-link-alias/input.tsx b/codemods/misc/migrate-mui-link-to-bui-link/tests/deep-core-link-alias/input.tsx new file mode 100644 index 00000000..48f8b108 --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/tests/deep-core-link-alias/input.tsx @@ -0,0 +1,9 @@ +import Link from '@backstage/core-components/Link'; +import MuiLink from '@material-ui/core/Link'; + +const Mixed = () => ( + <> + Docs + Other + +); diff --git a/codemods/misc/migrate-mui-link-to-bui-link/tests/deep-core-link-alias/metrics.json b/codemods/misc/migrate-mui-link-to-bui-link/tests/deep-core-link-alias/metrics.json new file mode 100644 index 00000000..3b33d663 --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/tests/deep-core-link-alias/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-link-to-bui-link": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "link-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-link-to-bui-link/tests/simple-link/expected.tsx b/codemods/misc/migrate-mui-link-to-bui-link/tests/simple-link/expected.tsx index cae00b29..938034a1 100644 --- a/codemods/misc/migrate-mui-link-to-bui-link/tests/simple-link/expected.tsx +++ b/codemods/misc/migrate-mui-link-to-bui-link/tests/simple-link/expected.tsx @@ -2,6 +2,6 @@ import { Link } from '@backstage/ui'; const Nav = () => (
- Catalog + Catalog
); diff --git a/codemods/misc/migrate-mui-link-to-bui-link/tests/skip-core-components/expected.tsx b/codemods/misc/migrate-mui-link-to-bui-link/tests/skip-core-components/expected.tsx index 9922a57b..cf3484fc 100644 --- a/codemods/misc/migrate-mui-link-to-bui-link/tests/skip-core-components/expected.tsx +++ b/codemods/misc/migrate-mui-link-to-bui-link/tests/skip-core-components/expected.tsx @@ -1,9 +1,9 @@ import { Link } from '@backstage/core-components'; -import MuiLink from '@material-ui/core/Link'; +import { Link as BuiLink } from '@backstage/ui'; const Mixed = () => ( <> Docs - Other + Other ); diff --git a/codemods/misc/migrate-mui-link-to-bui-link/tests/skip-core-components/metrics.json b/codemods/misc/migrate-mui-link-to-bui-link/tests/skip-core-components/metrics.json index 430cff08..3b33d663 100644 --- a/codemods/misc/migrate-mui-link-to-bui-link/tests/skip-core-components/metrics.json +++ b/codemods/misc/migrate-mui-link-to-bui-link/tests/skip-core-components/metrics.json @@ -2,8 +2,19 @@ "migrate-mui-link-to-bui-link": [ { "cardinality": { - "action": "skipped", - "reason": "core-components-import" + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "link-migrated" }, "count": 1 } diff --git a/codemods/misc/migrate-mui-link-to-bui-link/tests/underline-none-standalone/expected.tsx b/codemods/misc/migrate-mui-link-to-bui-link/tests/underline-none-standalone/expected.tsx new file mode 100644 index 00000000..4e250a09 --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/tests/underline-none-standalone/expected.tsx @@ -0,0 +1,9 @@ +import { Link } from '@backstage/ui'; + +const Nav = () => ( +
+ + Catalog + +
+); diff --git a/codemods/misc/migrate-mui-link-to-bui-link/tests/underline-none-standalone/input.tsx b/codemods/misc/migrate-mui-link-to-bui-link/tests/underline-none-standalone/input.tsx new file mode 100644 index 00000000..133eecb0 --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/tests/underline-none-standalone/input.tsx @@ -0,0 +1,9 @@ +import Link from '@material-ui/core/Link'; + +const Nav = () => ( +
+ + Catalog + +
+); diff --git a/codemods/misc/migrate-mui-link-to-bui-link/tests/underline-none-standalone/metrics.json b/codemods/misc/migrate-mui-link-to-bui-link/tests/underline-none-standalone/metrics.json new file mode 100644 index 00000000..3b33d663 --- /dev/null +++ b/codemods/misc/migrate-mui-link-to-bui-link/tests/underline-none-standalone/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-link-to-bui-link": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "link-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/README.md b/codemods/misc/migrate-mui-styles-to-bui-css-modules/README.md index 89e2f2bb..fbeea483 100644 --- a/codemods/misc/migrate-mui-styles-to-bui-css-modules/README.md +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/README.md @@ -1,7 +1,20 @@ -# migrate-mui-styles-to-bui-css-modules +# @backstage/migrate-mui-styles-to-bui-css-modules Migrates static `makeStyles` / `withStyles` usage to adjacent CSS modules during the MUI 4 → BUI migration. -## CSS module coverage +## Covers -Fixture harness skips persisting adjacent CSS modules for `tests//input.tsx`. Fixtures that emit `css-module-file-written` keep a golden `expected.module.css`, and `scripts/assert-css-goldens.sh` applies the package workflow to assert written CSS matches the golden. +- Static `makeStyles` / `withStyles` style objects → sibling `*.module.css` +- ClassName wiring updated to CSS module imports where deterministic + +## TODOs / won't-do + +- Dynamic theme callbacks / nested selectors that need human rewrite +- Runtime style factories that cannot be serialized to CSS modules +- Fixture harness skips persisting adjacent CSS for `tests//input.tsx`; goldens use `expected.module.css` + `scripts/assert-css-goldens.sh` + +## Test + +```bash +yarn test +``` diff --git a/codemods/misc/migrate-mui-typography-to-text/README.md b/codemods/misc/migrate-mui-typography-to-text/README.md index 067d1bbe..697b97ce 100644 --- a/codemods/misc/migrate-mui-typography-to-text/README.md +++ b/codemods/misc/migrate-mui-typography-to-text/README.md @@ -8,6 +8,23 @@ Migrates MUI `Typography` to Backstage UI `Text` (valid TextVariants only). - Named barrel imports; merge into existing `@backstage/ui` - Drops `gutterBottom` where unsupported +## Intentional heading demotion + +BUI has fewer title steps than MUI headings. Smaller MUI headings intentionally map into body scale: + +| MUI | BUI Text | +| ----------- | --------------- | +| `h1` | `title-large` | +| `h2` | `title-medium` | +| `h3` | `title-small` | +| `h4` | `title-x-small` | +| `h5` | `body-small` | +| `h6` | `body-x-small` | +| `subtitle1` | `title-x-small` | +| `subtitle2` | `body-medium` | + +Review visual hierarchy after apply — especially `h5` / `h6` demotions. + ## TODOs / won't-do - Unmapped variants (`unmapped-variant-todo`) diff --git a/codemods/misc/mui4-to-bui-migration-recipe/README.md b/codemods/misc/mui4-to-bui-migration-recipe/README.md index 97d1ca6e..3428e385 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/README.md +++ b/codemods/misc/mui4-to-bui-migration-recipe/README.md @@ -14,40 +14,44 @@ Do **not** run `remove-mui-dependencies` before the component transforms finish, ## What it runs -| # | Package | Domain | Covers | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------- | -| 1 | [`@backstage/migrate-mui-bootstrap-to-bui`](https://go.codemod.com/registry?q=backstage+migrate-mui-bootstrap-to-bui) | Bootstrap / package setup | Adds BUI and Remix deps; ensures root CSS import where MUI is used | -| 2 | [`@backstage/migrate-mui-icons-to-remix-icons`](https://go.codemod.com/registry?q=backstage+migrate-mui-icons-to-remix-icons) | Icon source migration | Replaces `@material-ui/icons` imports with `@remixicon/react` equivalents | -| 3 | [`@backstage/migrate-mui-styles-to-bui-css-modules`](https://go.codemod.com/registry?q=backstage+migrate-mui-styles-to-bui-css-modules) | Styling architecture | Migrates `makeStyles` / `withStyles` toward BUI CSS modules | -| 4 | [`@backstage/migrate-mui-typography-to-text`](https://go.codemod.com/registry?q=backstage+migrate-mui-typography-to-text) | Typography | Replaces MUI `Typography` with BUI `Text` | -| 5 | [`@backstage/migrate-mui-alert-to-bui-alert`](https://go.codemod.com/registry?q=backstage+migrate-mui-alert-to-bui-alert) | Alert banners | Migrates MUI `Alert` to BUI `Alert` | -| 6 | [`@backstage/migrate-mui-avatar-to-bui-avatar`](https://go.codemod.com/registry?q=backstage+migrate-mui-avatar-to-bui-avatar) | Avatars | Migrates MUI `Avatar` to BUI `Avatar` | -| 7 | [`@backstage/migrate-mui-skeleton-to-bui-skeleton`](https://go.codemod.com/registry?q=backstage+migrate-mui-skeleton-to-bui-skeleton) | Loading placeholders | Migrates MUI `Skeleton` to BUI `Skeleton` | -| 8 | [`@backstage/migrate-mui-button-to-bui-button`](https://go.codemod.com/registry?q=backstage+migrate-mui-button-to-bui-button) | Standard buttons | Migrates MUI `Button` to BUI `Button` / `ButtonLink` | -| 9 | [`@backstage/migrate-mui-icon-button-to-button-icon`](https://go.codemod.com/registry?q=backstage+migrate-mui-icon-button-to-button-icon) | Icon buttons | Migrates MUI `IconButton` to BUI `ButtonIcon` | -| 10 | [`@backstage/migrate-mui-link-to-bui-link`](https://go.codemod.com/registry?q=backstage+migrate-mui-link-to-bui-link) | Links | Migrates MUI `Link` to BUI `Link` (skips `@backstage/core-components`) | -| 11 | [`@backstage/migrate-mui-tooltip-to-bui-tooltip`](https://go.codemod.com/registry?q=backstage+migrate-mui-tooltip-to-bui-tooltip) | Tooltips | Migrates MUI `Tooltip` toward BUI `TooltipTrigger` | -| 12 | [`@backstage/migrate-mui-dialog-to-bui-dialog`](https://go.codemod.com/registry?q=backstage+migrate-mui-dialog-to-bui-dialog) | Dialogs | Migrates Dialog shell patterns to BUI `Dialog` | -| 13 | [`@backstage/migrate-mui-tabs-to-bui-tabs`](https://go.codemod.com/registry?q=backstage+migrate-mui-tabs-to-bui-tabs) | Tabs | Migrates MUI Tabs to BUI Tabs | -| 14 | [`@backstage/migrate-mui-menu-popover-to-bui-menu`](https://go.codemod.com/registry?q=backstage+migrate-mui-menu-popover-to-bui-menu) | Menus / popovers | Migrates Menu **or** Popover: MenuList/MenuItem → BUI `Menu`; otherwise BUI `Popover` | -| 15 | [`@backstage/migrate-mui-list-family-to-bui-list`](https://go.codemod.com/registry?q=backstage+migrate-mui-list-family-to-bui-list) | Lists | Migrates List family primitives to BUI List | -| 16 | [`@backstage/migrate-mui-chip-to-tag`](https://go.codemod.com/registry?q=backstage+migrate-mui-chip-to-tag) | Tags / chips | Migrates MUI `Chip` to BUI `Tag` | -| 17 | [`@backstage/migrate-mui-card-to-bui-card`](https://go.codemod.com/registry?q=backstage+migrate-mui-card-to-bui-card) | Cards | Migrates simple Card family patterns to BUI `Card` | -| 18 | [`@backstage/migrate-mui-select-family-to-bui-select`](https://go.codemod.com/registry?q=backstage+migrate-mui-select-family-to-bui-select) | Select | Migrates Select wrapper patterns to BUI Select | -| 19 | [`@backstage/migrate-mui-textfield-to-bui-textfield`](https://go.codemod.com/registry?q=backstage+migrate-mui-textfield-to-bui-textfield) | TextField | Migrates MUI `TextField` to BUI `TextField` / specialized fields | -| 20 | [`@backstage/migrate-mui-autocomplete-to-combobox`](https://go.codemod.com/registry?q=backstage+migrate-mui-autocomplete-to-combobox) | Autocomplete | Heuristically migrates Autocomplete → `Combobox` or `SearchAutocomplete` | -| 21 | [`@backstage/migrate-mui-accordion-to-bui-accordion`](https://go.codemod.com/registry?q=backstage+migrate-mui-accordion-to-bui-accordion) | Accordion | Migrates Accordion patterns to BUI Accordion | -| 22 | [`@backstage/migrate-mui-radio-checkbox-to-bui`](https://go.codemod.com/registry?q=backstage+migrate-mui-radio-checkbox-to-bui) | Radio / checkbox | Migrates radio/checkbox groups and standalone Checkbox to BUI | -| 23 | [`@backstage/migrate-mui-switch-to-bui-switch`](https://go.codemod.com/registry?q=backstage+migrate-mui-switch-to-bui-switch) | Switch | Migrates MUI `Switch` to BUI `Switch` | -| 24 | [`@backstage/migrate-mui-slider-to-bui-slider`](https://go.codemod.com/registry?q=backstage+migrate-mui-slider-to-bui-slider) | Slider | Migrates MUI `Slider` to BUI `Slider` | -| 25 | [`@backstage/migrate-mui-layout-to-bui-layout`](https://go.codemod.com/registry?q=backstage+migrate-mui-layout-to-bui-layout) | Layout primitives | Converts Box / Paper / Grid toward BUI layout (see Paper heuristic below) | -| 26 | [`@backstage/remove-mui-dependencies`](https://go.codemod.com/registry?q=backstage+remove-mui-dependencies) | Cleanup | Removes unused `@material-ui/*` dependencies from `package.json` after source migration | +| # | Package | Domain | Covers | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------- | +| 1 | [`@backstage/migrate-mui-bootstrap-to-bui`](https://go.codemod.com/registry?q=backstage+migrate-mui-bootstrap-to-bui) | Bootstrap / package setup | Adds BUI and Remix deps; ensures root CSS import where MUI is used | +| 2 | [`@backstage/migrate-mui-icons-to-remix-icons`](https://go.codemod.com/registry?q=backstage+migrate-mui-icons-to-remix-icons) | Icon source migration | Replaces `@material-ui/icons` imports with `@remixicon/react` equivalents | +| 3 | [`@backstage/migrate-mui-styles-to-bui-css-modules`](https://go.codemod.com/registry?q=backstage+migrate-mui-styles-to-bui-css-modules) | Styling architecture | Migrates `makeStyles` / `withStyles` toward BUI CSS modules | +| 4 | [`@backstage/migrate-mui-typography-to-text`](https://go.codemod.com/registry?q=backstage+migrate-mui-typography-to-text) | Typography | Replaces MUI `Typography` with BUI `Text` | +| 5 | [`@backstage/migrate-mui-alert-to-bui-alert`](https://go.codemod.com/registry?q=backstage+migrate-mui-alert-to-bui-alert) | Alert banners | Migrates MUI `Alert` to BUI `Alert` | +| 6 | [`@backstage/migrate-mui-avatar-to-bui-avatar`](https://go.codemod.com/registry?q=backstage+migrate-mui-avatar-to-bui-avatar) | Avatars | Migrates MUI `Avatar` to BUI `Avatar` | +| 7 | [`@backstage/migrate-mui-skeleton-to-bui-skeleton`](https://go.codemod.com/registry?q=backstage+migrate-mui-skeleton-to-bui-skeleton) | Loading placeholders | Migrates MUI `Skeleton` to BUI `Skeleton` | +| 8 | [`@backstage/migrate-mui-button-to-bui-button`](https://go.codemod.com/registry?q=backstage+migrate-mui-button-to-bui-button) | Standard buttons | Migrates MUI `Button` to BUI `Button` / `ButtonLink` | +| 9 | [`@backstage/migrate-mui-icon-button-to-button-icon`](https://go.codemod.com/registry?q=backstage+migrate-mui-icon-button-to-button-icon) | Icon buttons | Migrates MUI `IconButton` to BUI `ButtonIcon` | +| 10 | [`@backstage/migrate-mui-link-to-bui-link`](https://go.codemod.com/registry?q=backstage+migrate-mui-link-to-bui-link) | Links | Migrates MUI `Link` to BUI `Link` (skips `packages/core-components` paths; aliases when core Link coexists) | +| 11 | [`@backstage/migrate-mui-tooltip-to-bui-tooltip`](https://go.codemod.com/registry?q=backstage+migrate-mui-tooltip-to-bui-tooltip) | Tooltips | Migrates MUI `Tooltip` toward BUI `TooltipTrigger` | +| 12 | [`@backstage/migrate-mui-dialog-to-bui-dialog`](https://go.codemod.com/registry?q=backstage+migrate-mui-dialog-to-bui-dialog) | Dialogs | Migrates Dialog shell patterns to BUI `Dialog` | +| 13 | [`@backstage/migrate-mui-tabs-to-bui-tabs`](https://go.codemod.com/registry?q=backstage+migrate-mui-tabs-to-bui-tabs) | Tabs | Migrates MUI Tabs to BUI Tabs | +| 14 | [`@backstage/migrate-mui-menu-popover-to-bui-menu`](https://go.codemod.com/registry?q=backstage+migrate-mui-menu-popover-to-bui-menu) | Menus / popovers | Migrates Menu **or** Popover: MenuList/MenuItem → BUI `Menu`; otherwise BUI `Popover` | +| 15 | [`@backstage/migrate-mui-list-family-to-bui-list`](https://go.codemod.com/registry?q=backstage+migrate-mui-list-family-to-bui-list) | Lists | Migrates List family primitives to BUI List | +| 16 | [`@backstage/migrate-mui-chip-to-tag`](https://go.codemod.com/registry?q=backstage+migrate-mui-chip-to-tag) | Tags / chips | Migrates MUI `Chip` to BUI `Tag` | +| 17 | [`@backstage/migrate-mui-card-to-bui-card`](https://go.codemod.com/registry?q=backstage+migrate-mui-card-to-bui-card) | Cards | Migrates simple Card family patterns to BUI `Card` | +| 18 | [`@backstage/migrate-mui-select-family-to-bui-select`](https://go.codemod.com/registry?q=backstage+migrate-mui-select-family-to-bui-select) | Select | Migrates Select wrapper patterns to BUI Select | +| 19 | [`@backstage/migrate-mui-textfield-to-bui-textfield`](https://go.codemod.com/registry?q=backstage+migrate-mui-textfield-to-bui-textfield) | TextField | Migrates MUI `TextField` to BUI `TextField` / specialized fields | +| 20 | [`@backstage/migrate-mui-autocomplete-to-combobox`](https://go.codemod.com/registry?q=backstage+migrate-mui-autocomplete-to-combobox) | Autocomplete | Heuristically migrates Autocomplete → `Combobox` or `SearchAutocomplete` | +| 21 | [`@backstage/migrate-mui-accordion-to-bui-accordion`](https://go.codemod.com/registry?q=backstage+migrate-mui-accordion-to-bui-accordion) | Accordion | Migrates Accordion patterns to BUI Accordion | +| 22 | [`@backstage/migrate-mui-radio-checkbox-to-bui`](https://go.codemod.com/registry?q=backstage+migrate-mui-radio-checkbox-to-bui) | Radio / checkbox | Migrates radio/checkbox groups and standalone Checkbox to BUI | +| 23 | [`@backstage/migrate-mui-switch-to-bui-switch`](https://go.codemod.com/registry?q=backstage+migrate-mui-switch-to-bui-switch) | Switch | Migrates MUI `Switch` to BUI `Switch` | +| 24 | [`@backstage/migrate-mui-slider-to-bui-slider`](https://go.codemod.com/registry?q=backstage+migrate-mui-slider-to-bui-slider) | Slider | Migrates MUI `Slider` to BUI `Slider` | +| 25 | [`@backstage/migrate-mui-layout-to-bui-layout`](https://go.codemod.com/registry?q=backstage+migrate-mui-layout-to-bui-layout) | Layout primitives | Converts Box / Paper / Grid toward BUI layout (see Paper heuristic below) | +| 26 | [`@backstage/remove-mui-dependencies`](https://go.codemod.com/registry?q=backstage+remove-mui-dependencies) | Cleanup | Removes unused `@material-ui/*` dependencies from `package.json` after source migration | Also see the short ordering note in [`../README.md`](../README.md). ### Paper heuristic (layout step) -`@backstage/migrate-mui-layout-to-bui-layout` never maps Paper to `Surface`. Bare Paper → `Box bg="neutral"`; card-like Paper → `Card`; elevation / ambiguous Paper → left with `TODO(backstage-codemod)`. +`@backstage/migrate-mui-layout-to-bui-layout` never maps Paper to `Surface`. Bare Paper → `Box bg="neutral"` **plus** a verify TODO that MUI’s default elevation chrome was dropped; card-like Paper → `Card`; explicit `elevation` / `variant` / ambiguous Paper → left with `TODO(backstage-codemod)`. + +### Button outlined → secondary + +`@backstage/migrate-mui-button-to-bui-button` maps `variant="outlined"` → `variant="secondary"` silently (BUI secondary is the outlined visual). The transform emits metric `outlined-to-secondary` so operators can quantify remaps in dry-run output. ## Package naming @@ -87,12 +91,14 @@ bash scripts/validate-workflows.sh # Dry-run against a sibling Backstage tree (run from this package directory). # Pass the workflow *file* path so the CLI does not rewrite to workflow.yaml. yarn exec codemod workflow run -w ./workflow.local.yaml \ - -t ../../../../backstage \ - --dry + -t /absolute/path/to/backstage \ + --dry-run --no-interactive --allow-dirty ``` `workflow.local.yaml` is a maintainer-only mirror (not the published default). It uses the same step order as `workflow.yaml`, with relative sibling sources (`source: '../migrate-mui-…'`). Published consumers should keep using the registry recipe. +A full dry-run against the Backstage monorepo (pre-merge hardening) completed cleanly: no `Surface` emissions; bare Paper sites emit default-elevation verify TODOs; Button reports `outlined-to-secondary`; Link skips only `packages/core-components` paths and migrates direct MUI `Link` elsewhere. + ## Recipe behavior - Every step runs at **default deterministic settings**. From 86e306322e7fd39d04d31bc5ff40f386f45588cb Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 22 Jul 2026 17:31:26 -0400 Subject: [PATCH 04/11] =?UTF-8?q?Rewrite=20remaining=20MUI=E2=86=92BUI=20h?= =?UTF-8?q?elpers=20to=20be=20AST-first.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace regex source rewrites in dialog, autocomplete, tabs, textfield, select, and styles CSS merge with ast-grep walks and range-based edits. Co-authored-by: Cursor --- .changeset/ast-first-mui-helpers.md | 10 + .../scripts/codemod.ts | 241 +++++++++++++----- .../scripts/codemod.ts | 106 ++++++-- .../scripts/codemod.ts | 58 ++++- .../scripts/codemod.ts | 75 +++++- .../scripts/codemod.ts | 68 ++++- .../scripts/codemod.ts | 64 ++++- 7 files changed, 486 insertions(+), 136 deletions(-) create mode 100644 .changeset/ast-first-mui-helpers.md diff --git a/.changeset/ast-first-mui-helpers.md b/.changeset/ast-first-mui-helpers.md new file mode 100644 index 00000000..43bc1398 --- /dev/null +++ b/.changeset/ast-first-mui-helpers.md @@ -0,0 +1,10 @@ +--- +'@backstage/migrate-mui-dialog-to-bui-dialog': patch +'@backstage/migrate-mui-autocomplete-to-combobox': patch +'@backstage/migrate-mui-tabs-to-bui-tabs': patch +'@backstage/migrate-mui-textfield-to-bui-textfield': patch +'@backstage/migrate-mui-select-family-to-bui-select': patch +'@backstage/migrate-mui-styles-to-bui-css-modules': patch +--- + +Rewrite remaining MUI→BUI helper transforms to be AST-first (no regex source rewriting). diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/scripts/codemod.ts b/codemods/misc/migrate-mui-autocomplete-to-combobox/scripts/codemod.ts index 71d813d9..9cee266b 100644 --- a/codemods/misc/migrate-mui-autocomplete-to-combobox/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/scripts/codemod.ts @@ -315,6 +315,149 @@ function unwrapJsxExpression(raw: string): string { return raw } +function getJsxExpressionFromAttr(attr: SgNode): SgNode | null { + return attr.find({ rule: { kind: 'jsx_expression' } }) +} + +function getJsxExpressionInnerNode(expr: SgNode): SgNode | null { + const children: SgNode[] = [] + for (const child of expr.children()) { + if (child.kind() !== '{' && child.kind() !== '}') { + children.push(child) + } + } + const [onlyChild] = children + return children.length === 1 ? (onlyChild ?? null) : null +} + +function getParamName(paramNode: SgNode): string { + const ident = paramNode.find({ rule: { kind: 'identifier' } }) + return ident?.text() ?? paramNode.text() +} + +function getCallExpressionFromArrowBody(body: SgNode): SgNode | null { + if (body.is('call_expression')) { + return body + } + if (!body.is('statement_block')) { + return null + } + const statements = body.children().filter((child) => child.kind() !== '{' && child.kind() !== '}') + if (statements.length !== 1) { + return null + } + const [statement] = statements + if (!statement?.is('expression_statement')) { + return null + } + return statement.find({ rule: { kind: 'call_expression' } }) +} + +function simplifySecondArgCallbackFromAttr(attr: SgNode): string | null { + const expr = getJsxExpressionFromAttr(attr) + if (!expr) { + return null + } + const inner = getJsxExpressionInnerNode(expr) + if (!inner) { + return null + } + + if (inner.is('identifier')) { + return inner.text() + } + + if (!inner.is('arrow_function')) { + return null + } + + const params = inner.field('parameters') + 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 [, secondParam] = paramChildren + if (!secondParam) { + return null + } + const secondParamName = getParamName(secondParam) + + const callExpr = getCallExpressionFromArrowBody(inner.field('body')) + if (!callExpr) { + return null + } + + const callee = callExpr.field('function') + if (!callee?.is('identifier')) { + return null + } + + const args = callExpr.field('arguments') + if (!args) { + return null + } + + const argChildren: SgNode[] = [] + for (const child of args.children()) { + if (child.kind() !== '(' && child.kind() !== ')' && child.kind() !== ',') { + argChildren.push(child) + } + } + if (argChildren.length !== 1) { + return null + } + + const [onlyArg] = argChildren + if (!onlyArg?.is('identifier') || onlyArg.text() !== secondParamName) { + return null + } + + return callee.text() +} + +function flattenStringOptionsFromAttr(attr: SgNode): string | null { + const expr = getJsxExpressionFromAttr(attr) + if (!expr) { + return null + } + const inner = getJsxExpressionInnerNode(expr) + if (!inner?.is('array')) { + return null + } + + const strings: string[] = [] + for (const child of inner.children()) { + if (child.kind() === '[' || child.kind() === ']' || child.kind() === ',') { + continue + } + if (!child.is('string')) { + return null + } + const frag = child.find({ rule: { kind: 'string_fragment' } }) + if (!frag) { + return null + } + strings.push(frag.text()) + } + + if (strings.length === 0) { + return '[]' + } + + const objects = strings.map((value) => `{ value: '${value}', label: '${value}' }`) + return `[${objects.join(', ')}]` +} + function looksLikeSearchText(text: string | null): boolean { if (!text) { return false @@ -345,58 +488,6 @@ function extractRenderInputFieldProp(opening: SgNode, propName: 'label' | ' return frag?.text() ?? null } -function simplifySecondArgCallback(rawExpr: string): string | null { - const expr = unwrapJsxExpression(rawExpr) - - // (_event, next) => onChange(next) OR (_e, v) => setQuery(v) - const arrowCall = expr.match( - /^\(\s*[_a-zA-Z][\w$]*\s*,\s*([_a-zA-Z][\w$]*)\s*\)\s*=>\s*([_a-zA-Z][\w$]*)\(\s*\1\s*\)$/, - ) - if (arrowCall?.[2]) { - return arrowCall[2] - } - - // (_event, next) => { onChange(next) } - const arrowBlock = expr.match( - /^\(\s*[_a-zA-Z][\w$]*\s*,\s*([_a-zA-Z][\w$]*)\s*\)\s*=>\s*\{\s*([_a-zA-Z][\w$]*)\(\s*\1\s*\)\s*;?\s*\}$/, - ) - if (arrowBlock?.[2]) { - return arrowBlock[2] - } - - // identifier handler — keep as-is (signature differs, but best-effort) - if (/^[_a-zA-Z][\w$]*$/.test(expr)) { - return expr - } - - return null -} - -function flattenStringOptionsLiteral(optionsRaw: string): string | null { - const expr = unwrapJsxExpression(optionsRaw) - const arrayMatch = expr.match(/^\[\s*([\s\S]*)\s*\]$/) - if (!arrayMatch) { - return null - } - const inner = arrayMatch[1] ?? '' - if (inner.trim().length === 0) { - return '[]' - } - - const stringLiterals = [...inner.matchAll(/(['"])([^'"\\]*)\1/g)].map((m) => m[2] ?? '') - // Require the array to be only string literals (best-effort flatten). - const withoutStrings = inner - .replaceAll(/(['"])([^'"\\]*)\1/g, '') - .replaceAll(',', '') - .trim() - if (withoutStrings.length > 0 || stringLiterals.length === 0) { - return null - } - - const objects = stringLiterals.map((value) => `{ value: '${value}', label: '${value}' }`) - return `[${objects.join(', ')}]` -} - type AutocompleteKind = 'combobox' | 'search' | 'todo' function classifyAutocomplete(opening: SgNode): AutocompleteKind { @@ -435,8 +526,8 @@ function classifyAutocomplete(opening: SgNode): AutocompleteKind { // Form-like option picker → Combobox only when options are a string[] literal // we can reshape to `{ value, label }`. Object options + getOptionLabel are unsafe. if (hasOptions && (hasGetOptionLabel || hasValue || hasOnChange)) { - const optionsRaw = getPropRawValue(opening, 'options') - if (optionsRaw && flattenStringOptionsLiteral(optionsRaw)) { + const optionsAttr = getPropAttr(opening, 'options') + if (optionsAttr && flattenStringOptionsFromAttr(optionsAttr)) { return 'combobox' } return 'todo' @@ -446,13 +537,17 @@ function classifyAutocomplete(opening: SgNode): AutocompleteKind { } function buildComboboxReplacement(opening: SgNode): string | null { - const optionsRaw = getPropRawValue(opening, 'options') - if (!optionsRaw) { + const optionsAttr = getPropAttr(opening, 'options') + if (!optionsAttr) { return null } - const flattened = flattenStringOptionsLiteral(optionsRaw) - const optionsExpr = flattened ?? unwrapJsxExpression(optionsRaw) + const flattened = flattenStringOptionsFromAttr(optionsAttr) + const optionsRaw = getPropRawValue(opening, 'options') + const optionsExpr = flattened ?? (optionsRaw ? unwrapJsxExpression(optionsRaw) : null) + if (!optionsExpr) { + return null + } const props: string[] = [`options={${optionsExpr}}`] @@ -461,13 +556,16 @@ function buildComboboxReplacement(opening: SgNode): string | null { props.push(`value={${unwrapJsxExpression(valueRaw)}}`) } - const onChangeRaw = getPropRawValue(opening, 'onChange') - if (onChangeRaw && onChangeRaw !== 'true') { - const simplified = simplifySecondArgCallback(onChangeRaw) - if (!simplified) { - return null + const onChangeAttr = getPropAttr(opening, 'onChange') + if (onChangeAttr) { + const onChangeRaw = getPropRawValue(opening, 'onChange') + if (onChangeRaw && onChangeRaw !== 'true') { + const simplified = simplifySecondArgCallbackFromAttr(onChangeAttr) + if (!simplified) { + return null + } + props.push(`onSelectionChange={${simplified}}`) } - props.push(`onSelectionChange={${simplified}}`) } const label = extractRenderInputFieldProp(opening, 'label') @@ -496,13 +594,16 @@ function buildSearchReplacement(opening: SgNode): string | null { props.push(`inputValue={${unwrapJsxExpression(inputValueRaw)}}`) } - const onInputChangeRaw = getPropRawValue(opening, 'onInputChange') - if (onInputChangeRaw && onInputChangeRaw !== 'true') { - const simplified = simplifySecondArgCallback(onInputChangeRaw) - if (!simplified) { - return null + const onInputChangeAttr = getPropAttr(opening, 'onInputChange') + if (onInputChangeAttr) { + const onInputChangeRaw = getPropRawValue(opening, 'onInputChange') + if (onInputChangeRaw && onInputChangeRaw !== 'true') { + const simplified = simplifySecondArgCallbackFromAttr(onInputChangeAttr) + if (!simplified) { + return null + } + props.push(`onInputChange={${simplified}}`) } - props.push(`onInputChange={${simplified}}`) } const placeholder = extractRenderInputFieldProp(opening, 'placeholder') diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts b/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts index 9a755c2d..61c34327 100644 --- a/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts @@ -315,23 +315,83 @@ function getChildContent(element: SgNode): string { .join('') } -function transformFooterCloseButtons(content: string, closeHandler: string | null): string { - if (!closeHandler) { - return content +function getSimpleOnClickHandler(opening: SgNode): string | null { + const attr = getPropAttr(opening, 'onClick') + if (!attr) { + return null + } + const expr = attr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return null + } + const children: SgNode[] = [] + for (const child of expr.children()) { + if (child.kind() !== '{' && child.kind() !== '}') { + children.push(child) + } + } + const [onlyChild] = children + if (children.length === 1 && onlyChild?.is('identifier')) { + return onlyChild.text() + } + return null +} + +function isCloseFooterButton(element: SgNode, closeHandler: string): boolean { + const isSelfClosing = element.is('jsx_self_closing_element') + const opening = isSelfClosing ? element : element.child(0) + if (!opening) { + return false + } + if (getElementName(opening) !== 'button') { + return false } + return getSimpleOnClickHandler(opening) === closeHandler +} - const buttonPattern = /]*)>([\s\S]*?)<\/button>/g - const onClickPattern = new RegExp(`onClick=\\{${escapeRegex(closeHandler)}\\}`) +function transformFooterCloseButton(element: SgNode, closeHandler: string): string { + const isSelfClosing = element.is('jsx_self_closing_element') + const opening = isSelfClosing ? element : element.child(0) + if (!opening) { + return element.text() + } - return content.replace(buttonPattern, (match: string, attrs: string, label: string) => { - if (!onClickPattern.test(attrs)) { - return match + const extraAttrs: string[] = [] + for (const attr of opening.findAll({ rule: { kind: 'jsx_attribute' } })) { + const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent || propIdent.text() === 'onClick') { + continue } - migrationMetric.increment({ action: 'footer-close-button-migrated' }) - const extraAttrs = attrs.replace(onClickPattern, '').trim() - const attrStr = extraAttrs.length > 0 ? ` ${extraAttrs}` : '' - return `` - }) + extraAttrs.push(attr.text()) + } + + const attrStr = extraAttrs.length > 0 ? ` ${extraAttrs.join(' ')}` : '' + migrationMetric.increment({ action: 'footer-close-button-migrated' }) + + if (isSelfClosing) { + return `` +} + +function transformDialogActionsContent(actionsElement: SgNode, closeHandler: string | null): string { + const parts: string[] = [] + + for (const child of getJsxChildren(actionsElement)) { + const kind = child.kind() + if ( + closeHandler && + (kind === 'jsx_element' || kind === 'jsx_self_closing_element') && + isCloseFooterButton(child, closeHandler) + ) { + parts.push(transformFooterCloseButton(child, closeHandler)) + continue + } + parts.push(child.text()) + } + + return parts.join('') } function transformDialogChildren( @@ -361,10 +421,8 @@ function transformDialogChildren( } const buiName = COMPONENT_MAP[muiName] if (buiName) { - let innerContent = getChildContent(child) - if (muiName === 'DialogActions') { - innerContent = transformFooterCloseButtons(innerContent, closeHandler) - } + const innerContent = + muiName === 'DialogActions' ? transformDialogActionsContent(child, closeHandler) : getChildContent(child) parts.push(`<${buiName}>${innerContent}`) migrationMetric.increment({ action: 'child-renamed', from: muiName, to: buiName }) continue @@ -500,9 +558,17 @@ function transformDialogElements( }, }) for (const actionsEl of actionsElements) { - const actionsContent = getChildContent(actionsEl) - if (actionsContent.includes(`onClick={${simpleHandler}}`)) { - usesFooterCloseButton = true + for (const child of getJsxChildren(actionsEl)) { + const kind = child.kind() + if ( + (kind === 'jsx_element' || kind === 'jsx_self_closing_element') && + isCloseFooterButton(child, simpleHandler) + ) { + usesFooterCloseButton = true + break + } + } + if (usesFooterCloseButton) { break } } 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 ad9f7e29..04daf181 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 @@ -392,8 +392,38 @@ function getArrowSingleParamName(arrow: SgNode): string | null { return paramNames[0] ?? null } -function targetValuePattern(eventName: string): RegExp { - return new RegExp(`${escapeRegex(eventName)}\\.target\\.value`, 'g') +function applyNodeTextReplacements(node: SgNode, replacements: { target: SgNode; text: string }[]): string { + const base = node.range().start.index + let text = node.text() + const sorted = [...replacements].sort((a, b) => b.target.range().start.index - a.target.range().start.index) + for (const { target, text: next } of sorted) { + const start = target.range().start.index - base + const end = target.range().end.index - base + text = `${text.slice(0, start)}${next}${text.slice(end)}` + } + return text +} + +function isEventTargetValueMember(node: SgNode, eventName: string): boolean { + // AST-selected member_expression whose source text is exactly `event.target.value`. + return node.is('member_expression') && node.text() === `${eventName}.target.value` +} + +function findEventTargetValueMembers(body: SgNode, eventName: string): SgNode[] { + return body + .findAll({ rule: { kind: 'member_expression' } }) + .filter((node) => isEventTargetValueMember(node, eventName)) +} + +function isDescendantOf(node: SgNode, ancestor: SgNode): boolean { + let current = node.parent() + while (current) { + if (current.id() === ancestor.id()) { + return true + } + current = current.parent() + } + return false } function tryRewriteOnChangeHandler(attr: SgNode): string | null { @@ -417,17 +447,27 @@ function tryRewriteOnChangeHandler(attr: SgNode): string | null { return null } - const bodyText = body.text() - const pattern = targetValuePattern(eventName) - if (!pattern.test(bodyText)) { + const targetValueNodes = findEventTargetValueMembers(body, eventName) + if (targetValueNodes.length === 0) { return null } - const rewrittenBody = bodyText.replace(targetValuePattern(eventName), 'key') - const eventRefPattern = new RegExp(`\\b${escapeRegex(eventName)}\\b`) - if (eventRefPattern.test(rewrittenBody)) { - return null + for (const ident of body.findAll({ + rule: { + kind: 'identifier', + regex: `^${escapeRegex(eventName)}$`, + }, + })) { + const insideTargetValue = targetValueNodes.some((targetValue) => isDescendantOf(ident, targetValue)) + if (!insideTargetValue) { + return null + } } + + const rewrittenBody = applyNodeTextReplacements( + body, + targetValueNodes.map((target) => ({ target, text: 'key' })), + ) return `{key => ${rewrittenBody}}` } diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts b/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts index 494919e5..6c87c054 100644 --- a/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts @@ -1,9 +1,14 @@ -import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import { parse, type Codemod, type Edit, type SgNode } from 'codemod:ast-grep' +import type CSS from 'codemod:ast-grep/langs/css' import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' const migrationMetric = useMetricAtom('migrate-mui-styles-to-bui-css-modules') +function parseCssRoot(source: string): SgNode { + return parse('css', source).root() +} + /** * Map JSS camelCase property names to CSS kebab-case. */ @@ -323,10 +328,33 @@ function hasCssModuleImport(rootNode: SgNode, cssModuleImportPath: string): return false } +function getRuleSetClassName(ruleSet: SgNode): string | null { + const classSelector = ruleSet.find({ rule: { kind: 'class_selector' } }) + if (!classSelector) { + return null + } + const classNameNode = classSelector.find({ rule: { kind: 'class_name' } }) + const ident = classNameNode?.find({ rule: { kind: 'identifier' } }) + return ident?.text() ?? null +} + +function getRuleSetBody(ruleSet: SgNode): string | null { + const block = ruleSet.find({ rule: { kind: 'block' } }) + if (!block) { + return null + } + const blockText = block.text() + if (blockText.length < 2) { + return null + } + return blockText.slice(1, -1).trim() +} + function extractClassNamesFromCss(cssContent: string): Set { const classNames = new Set() - for (const match of cssContent.matchAll(/\.([A-Za-z0-9_-]+)\s*\{/g)) { - const [, className] = match + const cssRoot = parseCssRoot(cssContent) + for (const ruleSet of cssRoot.findAll({ rule: { kind: 'rule_set' } })) { + const className = getRuleSetClassName(ruleSet) if (className) { classNames.add(className) } @@ -336,19 +364,36 @@ function extractClassNamesFromCss(cssContent: string): Set { function extractCssModuleRuleBlocks(cssContent: string): { className: string; body: string }[] { const rules: { className: string; body: string }[] = [] - for (const match of cssContent.matchAll(/\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}/g)) { - const [, className, ruleBody] = match - if (!className || ruleBody === undefined) { + const cssRoot = parseCssRoot(cssContent) + for (const ruleSet of cssRoot.findAll({ rule: { kind: 'rule_set' } })) { + const className = getRuleSetClassName(ruleSet) + if (!className) { continue } - rules.push({ - className, - body: ruleBody.trim(), - }) + const body = getRuleSetBody(ruleSet) + if (body === null) { + continue + } + rules.push({ className, body }) } return rules } +function findComponentsLayerBlock(cssRoot: SgNode): SgNode | null { + for (const atRule of cssRoot.findAll({ rule: { kind: 'at_rule' } })) { + const atKeyword = atRule.find({ rule: { kind: 'at_keyword' } }) + if (atKeyword?.text() !== '@layer') { + continue + } + const keywordQuery = atRule.find({ rule: { kind: 'keyword_query' } }) + if (keywordQuery?.text() !== 'components') { + continue + } + return atRule.find({ rule: { kind: 'block' } }) + } + return null +} + function formatLayerRules(rules: { className: string; body: string }[]): string { return rules .map((rule) => { @@ -370,6 +415,7 @@ function mergeCssModuleContent(existing: string, generated: string): string { return `${generatedTrimmed}\n` } + const existingRoot = parseCssRoot(trimmedExisting) const existingClassNames = extractClassNamesFromCss(trimmedExisting) const newRules = extractCssModuleRuleBlocks(generatedTrimmed).filter( (rule) => !existingClassNames.has(rule.className), @@ -378,10 +424,11 @@ function mergeCssModuleContent(existing: string, generated: string): string { return `${trimmedExisting}\n` } - const layerMatch = trimmedExisting.match(/^([\s\S]*@layer\s+components\s*\{)([\s\S]*?)(\}\s*)$/) - if (layerMatch) { - const [, layerOpen, layerInner, layerClose] = layerMatch - return `${layerOpen}${layerInner}\n${formatLayerRules(newRules)}\n${layerClose}\n` + const layerBlock = findComponentsLayerBlock(existingRoot) + if (layerBlock) { + const newRulesText = formatLayerRules(newRules) + const insertPos = layerBlock.range().end.index - 1 + return `${trimmedExisting.slice(0, insertPos)}\n${newRulesText}\n${trimmedExisting.slice(insertPos)}\n` } // Existing file has no @layer block — append the generated module as-is. diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts b/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts index 682d21e7..66f8b7af 100644 --- a/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts @@ -335,15 +335,62 @@ function getJsxChildren(element: SgNode): SgNode[] { function getParamName(paramNode: SgNode): string { const ident = paramNode.find({ rule: { kind: 'identifier' } }) - return ident?.text() ?? paramNode.text().replace(/:.*$/, '').trim() + return ident?.text() ?? paramNode.text() } -function identifierUsedIn(text: string, name: string): boolean { - return new RegExp(`\\b${escapeRegex(name)}\\b`).test(text) +function applyNodeTextReplacements(node: SgNode, replacements: { target: SgNode; text: string }[]): string { + const base = node.range().start.index + let text = node.text() + const sorted = [...replacements].sort((a, b) => b.target.range().start.index - a.target.range().start.index) + for (const { target, text: next } of sorted) { + const start = target.range().start.index - base + const end = target.range().end.index - base + text = `${text.slice(0, start)}${next}${text.slice(end)}` + } + return text +} + +function identifierUsedInBody(body: SgNode, name: string): boolean { + return ( + body.find({ + rule: { + kind: 'identifier', + regex: `^${escapeRegex(name)}$`, + }, + }) !== null + ) +} + +function replaceIdentifierInBody(body: SgNode, name: string, replacement: string): string { + const targets = body.findAll({ + rule: { + kind: 'identifier', + regex: `^${escapeRegex(name)}$`, + }, + }) + return applyNodeTextReplacements( + body, + targets.map((target) => ({ target, text: replacement })), + ) } -function replaceIdentifier(text: string, name: string, replacement: string): string { - return text.replaceAll(new RegExp(`\\b${escapeRegex(name)}\\b`, 'g'), replacement) +function getJsxExpressionInnerReference(expr: SgNode): SgNode | null { + const meaningful: SgNode[] = [] + for (const child of expr.children()) { + const kind = child.kind() + if (kind === '{' || kind === '}') { + continue + } + meaningful.push(child) + } + if (meaningful.length !== 1) { + return null + } + const [inner] = meaningful + if (inner?.is('identifier') || inner?.is('member_expression')) { + return inner + } + return null } /** @@ -359,9 +406,9 @@ function rewriteTabsOnChangeHandler(attr: SgNode): string | null { const arrow = expr.find({ rule: { kind: 'arrow_function' } }) if (!arrow) { - const innerText = expr.text().slice(1, -1).trim() - if (/^[\w$.]+$/.test(innerText)) { - return `{(key) => ${innerText}(undefined, key)}` + const innerRef = getJsxExpressionInnerReference(expr) + if (innerRef) { + return `{(key) => ${innerRef.text()}(undefined, key)}` } return null } @@ -395,11 +442,10 @@ function rewriteTabsOnChangeHandler(attr: SgNode): string | null { return null } - const bodyText = body.text() - const eventUsed = identifierUsedIn(bodyText, eventName) && !eventName.startsWith('_') + const eventUsed = identifierUsedInBody(body, eventName) && !eventName.startsWith('_') if (!eventUsed) { - return `{key => ${replaceIdentifier(bodyText, valueName, 'key')}}` + return `{key => ${replaceIdentifierInBody(body, valueName, 'key')}}` } // Handler references the event param — cannot safely rewrite without breaking semantics 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 cdec0f42..29061119 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 @@ -304,7 +304,41 @@ function isSimpleHelperText(attr: SgNode): boolean { function getParamName(paramNode: SgNode): string { const ident = paramNode.find({ rule: { kind: 'identifier' } }) - return ident?.text() ?? paramNode.text().replace(/:.*$/, '').trim() + return ident?.text() ?? paramNode.text() +} + +function applyNodeTextReplacements(node: SgNode, replacements: { target: SgNode; text: string }[]): string { + const base = node.range().start.index + let text = node.text() + const sorted = [...replacements].sort((a, b) => b.target.range().start.index - a.target.range().start.index) + for (const { target, text: next } of sorted) { + const start = target.range().start.index - base + const end = target.range().end.index - base + text = `${text.slice(0, start)}${next}${text.slice(end)}` + } + return text +} + +function isEventTargetValueMember(node: SgNode, eventName: string): boolean { + // AST-selected member_expression whose source text is exactly `event.target.value`. + return node.is('member_expression') && node.text() === `${eventName}.target.value` +} + +function findEventTargetValueMembers(body: SgNode, eventName: string): SgNode[] { + return body + .findAll({ rule: { kind: 'member_expression' } }) + .filter((node) => isEventTargetValueMember(node, eventName)) +} + +function isDescendantOf(node: SgNode, ancestor: SgNode): boolean { + let current = node.parent() + while (current) { + if (current.id() === ancestor.id()) { + return true + } + current = current.parent() + } + return false } function getArrowSingleParamName(arrow: SgNode): string | null { @@ -345,10 +379,6 @@ function getArrowSingleParamName(arrow: SgNode): string | null { return getParamName(param) } -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) { @@ -370,17 +400,27 @@ function tryRewriteOnChangeHandler(attr: SgNode): string | null { return null } - const bodyText = body.text() - const pattern = targetValuePattern(eventName) - if (!pattern.test(bodyText)) { + const targetValueNodes = findEventTargetValueMembers(body, eventName) + if (targetValueNodes.length === 0) { return null } - const rewrittenBody = bodyText.replace(targetValuePattern(eventName), 'newValue') - const eventRefPattern = new RegExp(`\\b${escapeRegex(eventName)}\\b`) - if (eventRefPattern.test(rewrittenBody)) { - return null + for (const ident of body.findAll({ + rule: { + kind: 'identifier', + regex: `^${escapeRegex(eventName)}$`, + }, + })) { + const insideTargetValue = targetValueNodes.some((targetValue) => isDescendantOf(ident, targetValue)) + if (!insideTargetValue) { + return null + } } + + const rewrittenBody = applyNodeTextReplacements( + body, + targetValueNodes.map((target) => ({ target, text: 'newValue' })), + ) return `{newValue => ${rewrittenBody}}` } From f67c08b057061406628da4810060348d7437ce07 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 22 Jul 2026 17:38:10 -0400 Subject: [PATCH 05/11] Fix CSS parse root typing for styles codemod lint. Co-authored-by: Cursor --- .../migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts b/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts index 6c87c054..f39bea41 100644 --- a/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts @@ -6,7 +6,8 @@ import { useMetricAtom } from 'codemod:metrics' const migrationMetric = useMetricAtom('migrate-mui-styles-to-bui-css-modules') function parseCssRoot(source: string): SgNode { - return parse('css', source).root() + // parse() is language-erased at the type level; narrow the CSS root explicitly. + return parse('css', source).root() as unknown as SgNode } /** From 060b38067ac94e20931aa91f73c674de0024eda8 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 22 Jul 2026 17:41:05 -0400 Subject: [PATCH 06/11] Use parse instead of casting the CSS root. Co-authored-by: Cursor --- .../migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts b/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts index f39bea41..a0112d05 100644 --- a/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts @@ -6,8 +6,7 @@ import { useMetricAtom } from 'codemod:metrics' const migrationMetric = useMetricAtom('migrate-mui-styles-to-bui-css-modules') function parseCssRoot(source: string): SgNode { - // parse() is language-erased at the type level; narrow the CSS root explicitly. - return parse('css', source).root() as unknown as SgNode + return parse('css', source).root() } /** From b8012857a9c1bec3afc6afce2766e3ba4b777930 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 22 Jul 2026 17:42:16 -0400 Subject: [PATCH 07/11] Use parse instead of casting SgRoot in helpers. Co-authored-by: Cursor --- .changeset/parse-generic-tsx-helpers.md | 6 ++++++ .../v1.50.0/migrate-permissioned-route/scripts/codemod.ts | 2 +- .../render-test-app-nav-migration/scripts/codemod.ts | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 .changeset/parse-generic-tsx-helpers.md diff --git a/.changeset/parse-generic-tsx-helpers.md b/.changeset/parse-generic-tsx-helpers.md new file mode 100644 index 00000000..6205853a --- /dev/null +++ b/.changeset/parse-generic-tsx-helpers.md @@ -0,0 +1,6 @@ +--- +'@backstage/migrate-permissioned-route': patch +'@backstage/render-test-app-nav-migration': patch +--- + +Use `parse(...)` instead of casting `SgRoot` when parsing synthetic TSX sources. diff --git a/codemods/v1.50.0/migrate-permissioned-route/scripts/codemod.ts b/codemods/v1.50.0/migrate-permissioned-route/scripts/codemod.ts index 1b912cca..3afb4300 100644 --- a/codemods/v1.50.0/migrate-permissioned-route/scripts/codemod.ts +++ b/codemods/v1.50.0/migrate-permissioned-route/scripts/codemod.ts @@ -7,7 +7,7 @@ import { useMetricAtom } from 'codemod:metrics' const TSX_LANG = 'tsx' function parseTsx(source: string): SgRoot { - return parse(TSX_LANG, source) as SgRoot + return parse(TSX_LANG, source) } const PERMISSION_REACT = '@backstage/plugin-permission-react' diff --git a/codemods/v1.51.0/render-test-app-nav-migration/scripts/codemod.ts b/codemods/v1.51.0/render-test-app-nav-migration/scripts/codemod.ts index e2a4d842..7760fbc3 100644 --- a/codemods/v1.51.0/render-test-app-nav-migration/scripts/codemod.ts +++ b/codemods/v1.51.0/render-test-app-nav-migration/scripts/codemod.ts @@ -13,7 +13,7 @@ const callsMigrated = useMetricAtom('render-test-app-calls-migrated') const navAssertionsFlagged = useMetricAtom('nav-assertions-flagged') function parseTsx(source: string): SgRoot { - return parse(TSX_LANG, source) as SgRoot + return parse(TSX_LANG, source) } function collapseExtraBlankLines(source: string): string { From 5556ca06f3667ec9d1d83dd1bed866f7bfbaf0e7 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 22 Jul 2026 18:00:32 -0400 Subject: [PATCH 08/11] Preserve MUI medium density when migrating to BUI size defaults. Emit size="medium" for Button, ButtonIcon, TextField, and Tag when MUI omitted size, so BUI's small default does not silently shrink controls. Co-authored-by: Cursor --- .changeset/density-mui-bui-size-medium.md | 9 +++ .../README.md | 5 +- .../scripts/codemod.ts | 34 +++++++++- .../tests/color-primary-ok/expected.tsx | 2 +- .../tests/color-primary-ok/metrics.json | 6 ++ .../tests/contained-button/expected.tsx | 2 +- .../tests/contained-button/metrics.json | 6 ++ .../tests/disabled-boolean/expected.tsx | 2 +- .../tests/disabled-boolean/metrics.json | 6 ++ .../tests/dynamic-size-todo/expected.tsx | 10 +++ .../tests/dynamic-size-todo/input.tsx | 7 +++ .../tests/dynamic-size-todo/metrics.json | 11 ++++ .../tests/end-icon/expected.tsx | 2 +- .../tests/end-icon/metrics.json | 6 ++ .../tests/href-button-link/expected.tsx | 2 +- .../tests/href-button-link/metrics.json | 6 ++ .../tests/merge-existing-bui/expected.tsx | 2 +- .../tests/merge-existing-bui/metrics.json | 6 ++ .../tests/named-barrel-import/expected.tsx | 2 +- .../tests/named-barrel-import/metrics.json | 6 ++ .../tests/no-variant/expected.tsx | 2 +- .../tests/no-variant/metrics.json | 6 ++ .../tests/outlined-button/expected.tsx | 2 +- .../tests/outlined-button/metrics.json | 6 ++ .../tests/self-closing/expected.tsx | 2 +- .../tests/self-closing/metrics.json | 6 ++ .../tests/size-large/expected.tsx | 7 +++ .../tests/size-large/input.tsx | 7 +++ .../tests/size-large/metrics.json | 35 +++++++++++ .../tests/size-small/expected.tsx | 7 +++ .../tests/size-small/input.tsx | 7 +++ .../tests/size-small/metrics.json | 36 +++++++++++ .../tests/spread-props/expected.tsx | 2 +- .../tests/spread-props/metrics.json | 6 ++ .../tests/start-icon/expected.tsx | 2 +- .../tests/start-icon/metrics.json | 6 ++ .../tests/text-button/expected.tsx | 2 +- .../tests/text-button/metrics.json | 6 ++ .../scripts/codemod.ts | 62 ++++++++++++++++++- .../tests/aliased-import/metrics.json | 7 +++ .../tests/chip-group/expected.tsx | 4 +- .../tests/chip-group/metrics.json | 6 ++ .../tests/chip-no-size/expected.tsx | 2 +- .../tests/chip-no-size/metrics.json | 6 ++ .../tests/dynamic-label/expected.tsx | 2 +- .../tests/dynamic-label/metrics.json | 6 ++ .../tests/merge-existing-bui/expected.tsx | 2 +- .../tests/merge-existing-bui/metrics.json | 6 ++ .../tests/named-barrel-import/metrics.json | 7 +++ .../tests/partial-barrel-import/metrics.json | 7 +++ .../tests/simple-display-chip/metrics.json | 7 +++ .../tests/size-large/expected.tsx | 5 ++ .../tests/size-large/input.tsx | 5 ++ .../tests/size-large/metrics.json | 28 +++++++++ .../tests/size-medium/expected.tsx | 5 ++ .../tests/size-medium/input.tsx | 5 ++ .../tests/size-medium/metrics.json | 29 +++++++++ .../scripts/codemod.ts | 32 +++++++++- .../tests/basic-migration/expected.tsx | 2 +- .../tests/basic-migration/metrics.json | 6 ++ .../tests/className-preserved/expected.tsx | 2 +- .../tests/className-preserved/metrics.json | 6 ++ .../tests/color-primary/expected.tsx | 2 +- .../tests/color-primary/metrics.json | 6 ++ .../tests/color-secondary/expected.tsx | 2 +- .../tests/color-secondary/metrics.json | 6 ++ .../tests/disabled-boolean/expected.tsx | 2 +- .../tests/disabled-boolean/metrics.json | 6 ++ .../tests/dropped-props/expected.tsx | 2 +- .../tests/dropped-props/metrics.json | 4 +- .../tests/dynamic-size-todo/expected.tsx | 10 +++ .../tests/dynamic-size-todo/input.tsx | 7 +++ .../tests/dynamic-size-todo/metrics.json | 11 ++++ .../tests/merge-existing-bui/expected.tsx | 2 +- .../tests/merge-existing-bui/metrics.json | 6 ++ .../tests/named-barrel-import/expected.tsx | 2 +- .../tests/named-barrel-import/metrics.json | 6 ++ .../tests/size-large/expected.tsx | 5 ++ .../tests/size-large/input.tsx | 7 +++ .../tests/size-large/metrics.json | 28 +++++++++ .../tests/spread-props/expected.tsx | 2 +- .../tests/spread-props/metrics.json | 6 ++ .../scripts/codemod.ts | 45 +++++++++++++- .../tests/aliased-import/expected.tsx | 2 +- .../tests/aliased-import/metrics.json | 6 ++ .../tests/basic-controlled/expected.tsx | 2 +- .../tests/basic-controlled/metrics.json | 6 ++ .../tests/disabled-and-fullwidth/expected.tsx | 2 +- .../tests/disabled-and-fullwidth/metrics.json | 6 ++ .../tests/dynamic-size-todo/expected.tsx | 8 +++ .../tests/dynamic-size-todo/input.tsx | 5 ++ .../tests/dynamic-size-todo/metrics.json | 11 ++++ .../tests/merge-existing-bui/expected.tsx | 2 +- .../tests/merge-existing-bui/metrics.json | 6 ++ .../tests/multiline/expected.tsx | 2 +- .../tests/multiline/metrics.json | 6 ++ .../tests/named-barrel-import/expected.tsx | 2 +- .../tests/named-barrel-import/metrics.json | 6 ++ .../tests/number-field/expected.tsx | 2 +- .../tests/number-field/metrics.json | 6 ++ .../tests/partial-barrel-import/expected.tsx | 2 +- .../tests/partial-barrel-import/metrics.json | 6 ++ .../tests/password-field/expected.tsx | 2 +- .../tests/password-field/metrics.json | 6 ++ .../tests/size-large/expected.tsx | 5 ++ .../tests/size-large/input.tsx | 5 ++ .../tests/size-large/metrics.json | 35 +++++++++++ .../tests/size-small/expected.tsx | 5 ++ .../tests/size-small/input.tsx | 5 ++ .../tests/size-small/metrics.json | 36 +++++++++++ .../mui4-to-bui-migration-recipe/README.md | 4 ++ 111 files changed, 840 insertions(+), 46 deletions(-) create mode 100644 .changeset/density-mui-bui-size-medium.md create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-size-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-size-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-size-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/size-large/expected.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/size-large/input.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/size-large/metrics.json create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/size-small/expected.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/size-small/input.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/size-small/metrics.json create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/size-large/expected.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/size-large/input.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/size-large/metrics.json create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/size-medium/expected.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/size-medium/input.tsx create mode 100644 codemods/misc/migrate-mui-chip-to-tag/tests/size-medium/metrics.json create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dynamic-size-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dynamic-size-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dynamic-size-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/size-large/expected.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/size-large/input.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/size-large/metrics.json create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/dynamic-size-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/dynamic-size-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/dynamic-size-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-large/expected.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-large/input.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-large/metrics.json create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-small/expected.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-small/input.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-small/metrics.json diff --git a/.changeset/density-mui-bui-size-medium.md b/.changeset/density-mui-bui-size-medium.md new file mode 100644 index 00000000..11dff5e3 --- /dev/null +++ b/.changeset/density-mui-bui-size-medium.md @@ -0,0 +1,9 @@ +--- +'@backstage/migrate-mui-button-to-bui-button': patch +'@backstage/migrate-mui-icon-button-to-button-icon': patch +'@backstage/migrate-mui-textfield-to-bui-textfield': patch +'@backstage/migrate-mui-chip-to-tag': patch +'@backstage/mui4-to-bui-migration-recipe': patch +--- + +Preserve MUI medium density on BUI migrations by emitting size="medium" when size is omitted (BUI defaults to small). diff --git a/codemods/misc/migrate-mui-button-to-bui-button/README.md b/codemods/misc/migrate-mui-button-to-bui-button/README.md index 5ef149b5..aab16fe6 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/README.md +++ b/codemods/misc/migrate-mui-button-to-bui-button/README.md @@ -11,11 +11,12 @@ Migrates MUI 4 `Button` to Backstage UI `Button` / `ButtonLink`. - `startIcon` / `endIcon` → `iconStart` / `iconEnd` - Simple `onClick` → `onPress` - `href` present → `ButtonLink` (same variant mapping) +- Density: omitted / `medium` / `large` → `size="medium"` (BUI default is `small`; MUI default is medium); `small` preserved ## TODOs / won't-do -- Dynamic / unknown `variant` values left with `TODO(backstage-codemod)` -- Theme-level Button overrides, `classes`, size remaps that need design review +- Dynamic / unknown `variant` or `size` values left with `TODO(backstage-codemod)` +- Theme-level Button overrides, `classes` ## Test diff --git a/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts b/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts index 496a04b5..35357077 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts @@ -335,6 +335,12 @@ function transformButtonElements( todoReasons.push('dynamic-variant') } + // Dynamic size — cannot map deterministically + if (isPropDynamic(opening, 'size')) { + needsTodo = true + todoReasons.push('dynamic-size') + } + // Non-default color implies semantic intent const colorValue = getPropStringValue(opening, 'color') if (colorValue && colorValue !== 'primary' && colorValue !== 'default') { @@ -412,7 +418,33 @@ function transformButtonElements( } } - const handledProps = new Set(['variant', 'disabled', 'color', 'onClick', 'startIcon', 'endIcon']) + const handledProps = new Set(['variant', 'disabled', 'color', 'onClick', 'startIcon', 'endIcon', 'size']) + + // Map size — preserve MUI medium default when BUI defaults to small + const sizeValue = getPropStringValue(opening, 'size') + if (sizeValue === null) { + newProps.push('size="medium"') + migrationMetric.increment({ action: 'size-defaulted-to-medium' }) + } else if (sizeValue === 'small') { + newProps.push('size="small"') + migrationMetric.increment({ action: 'size-mapped', size: 'small' }) + } else if (sizeValue === 'medium') { + newProps.push('size="medium"') + migrationMetric.increment({ action: 'size-mapped', size: 'medium' }) + } else if (sizeValue === 'large') { + newProps.push('size="medium"') + migrationMetric.increment({ action: 'size-large-to-medium' }) + } else { + preserveImport = true + edits.push( + el.replace( + withTodoComment('{/* TODO(backstage-codemod): verify Button intent manually (unknown-size) */}', el.text()), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'unknown-size' }) + continue + } + if (onClickAttr) { const raw = getPropRawValue(opening, 'onClick') if (raw) { diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/expected.tsx index 69d5b801..c15aa8df 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/expected.tsx @@ -1,5 +1,5 @@ import { Button } from '@backstage/ui'; const MyComponent = () => ( - + ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/metrics.json index 29e0200b..e93d9a2e 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/metrics.json @@ -18,6 +18,12 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/expected.tsx index 75f86724..36b97c3a 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/expected.tsx @@ -1,7 +1,7 @@ import { Button } from '@backstage/ui'; const MyComponent = () => ( - ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/metrics.json index 227a12ca..643a34a5 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/metrics.json @@ -24,6 +24,12 @@ "action": "onClick-to-onPress" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/expected.tsx index ceea1b4c..6f81f9fb 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/expected.tsx @@ -1,5 +1,5 @@ import { Button } from '@backstage/ui'; const MyComponent = () => ( - + ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/metrics.json index 29e0200b..e93d9a2e 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/metrics.json @@ -18,6 +18,12 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-size-todo/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-size-todo/expected.tsx new file mode 100644 index 00000000..ceeacba1 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-size-todo/expected.tsx @@ -0,0 +1,10 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = ({ size }: { size: 'small' | 'medium' | 'large' }) => ( + <> + {/* TODO(backstage-codemod): verify Button intent manually (dynamic-size) */} + + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-size-todo/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-size-todo/input.tsx new file mode 100644 index 00000000..80d7890a --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-size-todo/input.tsx @@ -0,0 +1,7 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = ({ size }: { size: 'small' | 'medium' | 'large' }) => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-size-todo/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-size-todo/metrics.json new file mode 100644 index 00000000..05e67245 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-size-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "dynamic-size" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/end-icon/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/end-icon/expected.tsx index de01219d..6c85d133 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/end-icon/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/end-icon/expected.tsx @@ -1,5 +1,5 @@ import { Button } from '@backstage/ui'; const MyComponent = () => ( - + ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/end-icon/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/end-icon/metrics.json index 214e732d..09ee07f2 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/end-icon/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/end-icon/metrics.json @@ -26,6 +26,12 @@ "to": "iconEnd" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/expected.tsx index dc1b4c5f..e9e2a398 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/expected.tsx @@ -1,7 +1,7 @@ import { ButtonLink } from '@backstage/ui'; const MyComponent = () => ( - } href="/docs"> + } href="/docs"> Docs ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/metrics.json index e5b21118..3a2eae22 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/href-button-link/metrics.json @@ -32,6 +32,12 @@ "to": "iconStart" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/expected.tsx index 683f258a..d18a1f4a 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/expected.tsx @@ -4,6 +4,6 @@ import { Alert, Button } from '@backstage/ui'; const MyComponent = () => ( <> - + ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/metrics.json index 24236b2f..6cd92e3d 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/metrics.json @@ -24,6 +24,12 @@ "action": "onClick-to-onPress" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/expected.tsx index 2decf0db..bb4add2a 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/expected.tsx @@ -1,5 +1,5 @@ import { Button } from '@backstage/ui'; const MyComponent = () => ( - + ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/metrics.json index b9a4902b..ab011521 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/metrics.json @@ -24,6 +24,12 @@ "action": "outlined-to-secondary" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/expected.tsx index aa18be42..fd3e3995 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/expected.tsx @@ -1,5 +1,5 @@ import { Button } from '@backstage/ui'; const MyComponent = () => ( - + ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/metrics.json index 28b6a2ef..cf3d0cf1 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/metrics.json @@ -24,6 +24,12 @@ "action": "onClick-to-onPress" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/expected.tsx index 8a3b1580..bbdee3ce 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/expected.tsx @@ -1,5 +1,5 @@ import { Button } from '@backstage/ui'; const MyComponent = () => ( - + ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/metrics.json index d909ddd6..f0ba211e 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/metrics.json @@ -30,6 +30,12 @@ "action": "outlined-to-secondary" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/expected.tsx index 209b8f4c..ef0b2636 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/expected.tsx @@ -1,5 +1,5 @@ import { Button } from '@backstage/ui'; const MyComponent = () => ( - +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/size-large/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/size-large/input.tsx new file mode 100644 index 00000000..29c32999 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/size-large/input.tsx @@ -0,0 +1,7 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/size-large/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/size-large/metrics.json new file mode 100644 index 00000000..0cbe6941 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/size-large/metrics.json @@ -0,0 +1,35 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "contained" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onClick-to-onPress" + }, + "count": 1 + }, + { + "cardinality": { + "action": "size-large-to-medium" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/size-small/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/size-small/expected.tsx new file mode 100644 index 00000000..02cddfae --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/size-small/expected.tsx @@ -0,0 +1,7 @@ +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/size-small/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/size-small/input.tsx new file mode 100644 index 00000000..29ade9ce --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/size-small/input.tsx @@ -0,0 +1,7 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/size-small/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/size-small/metrics.json new file mode 100644 index 00000000..c4ed8c55 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/size-small/metrics.json @@ -0,0 +1,36 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "contained" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onClick-to-onPress" + }, + "count": 1 + }, + { + "cardinality": { + "action": "size-mapped", + "size": "small" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/expected.tsx index 4f278174..01ccc29f 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/expected.tsx @@ -1,5 +1,5 @@ import { Button } from '@backstage/ui'; const MyComponent = (props: any) => ( - + ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/metrics.json index aedf7388..b5b789ec 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/metrics.json @@ -18,6 +18,12 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon/expected.tsx index 865f092a..c1f41f49 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon/expected.tsx @@ -1,5 +1,5 @@ import { Button } from '@backstage/ui'; const MyComponent = () => ( - + ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon/metrics.json index 60a3be2a..dd653ef6 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon/metrics.json @@ -26,6 +26,12 @@ "to": "iconStart" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/expected.tsx index 86a8af4a..29c6e5d3 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/expected.tsx @@ -1,5 +1,5 @@ import { Button } from '@backstage/ui'; const MyComponent = () => ( - + ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/metrics.json index 85eac670..55f472fe 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/metrics.json @@ -24,6 +24,12 @@ "action": "onClick-to-onPress" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts b/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts index a43d947b..b57f0b11 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts @@ -235,6 +235,42 @@ function getPropStringValue(opening: SgNode, propName: string): string | nu 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 isDynamicSizeProp(opening: SgNode): boolean { + const attr = getPropAttr(opening, 'size') + if (!attr) { + return false + } + return attr.find({ rule: { kind: 'string' } }) === null +} + +function recordSizeMetric(sizeValue: string | null): void { + if (sizeValue === 'small') { + migrationMetric.increment({ action: 'size-mapped', size: 'small' }) + return + } + if (sizeValue === 'medium') { + migrationMetric.increment({ action: 'size-mapped', size: 'medium' }) + return + } + if (sizeValue === 'large') { + migrationMetric.increment({ action: 'size-large-to-medium' }) + return + } + migrationMetric.increment({ action: 'size-defaulted-to-medium' }) +} + function getPropRawValue(opening: SgNode, propName: string): string | null { const attr = opening.find({ rule: { @@ -310,6 +346,8 @@ function buildTagReplacement(info: ChipInfo): string { const props: string[] = [] if (info.sizeValue === 'small') { props.push('size="small"') + } else { + props.push('size="medium"') } const propsStr = props.length > 0 ? ` ${props.join(' ')}` : '' @@ -399,8 +437,15 @@ function transformChipElements( processedParents.add(parent.id()) for (const group of findConsecutiveChipGroupsForParent(parent, chipLocalName)) { + if (group.some((c) => isDynamicSizeProp(c.opening))) { + continue + } + needsTagGroup = true - const tags = group.map((c) => buildTagReplacement(c)) + const tags = group.map((c) => { + recordSizeMetric(c.sizeValue) + return buildTagReplacement(c) + }) const tagGroupContent = tags.join('\n ') const [firstChip] = group if (!firstChip) { @@ -442,7 +487,22 @@ function transformChipElements( continue } + if (isDynamicSizeProp(info.opening)) { + preserveImport = true + edits.push( + info.element.replace( + withTodoComment( + `{/* TODO(backstage-codemod): finish Chip migration manually (size) */}`, + info.element.text(), + ), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'size' }) + continue + } + edits.push(info.element.replace(buildTagReplacement(info))) + recordSizeMetric(info.sizeValue) migrated = true migrationMetric.increment({ action: 'chip-migrated' }) } diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/aliased-import/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/aliased-import/metrics.json index 7eb48c89..93f69c82 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/aliased-import/metrics.json +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/aliased-import/metrics.json @@ -17,6 +17,13 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "size-mapped", + "size": "small" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/expected.tsx index 75e3f468..96c28e60 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/expected.tsx +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/expected.tsx @@ -3,8 +3,8 @@ import { Tag, TagGroup } from '@backstage/ui'; const Tags = () => ( <> - A - B + A + B diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/metrics.json index 4eb1d53e..58bac8a3 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/metrics.json +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/metrics.json @@ -12,6 +12,12 @@ }, "count": 1 }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 2 + }, { "cardinality": { "action": "tag-group-created", diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/expected.tsx index 0dc45247..9d0e54e9 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/expected.tsx +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/expected.tsx @@ -1,5 +1,5 @@ import { Tag } from '@backstage/ui'; const MyComponent = () => ( - Status + Status ); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/metrics.json index 7eb48c89..f52af0fc 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/metrics.json +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/metrics.json @@ -17,6 +17,12 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/expected.tsx index 26404d49..ddaa390d 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/expected.tsx +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/expected.tsx @@ -1,5 +1,5 @@ import { Tag } from '@backstage/ui'; const MyComponent = ({ name }: { name: string }) => ( - {name} + {name} ); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/metrics.json index 7eb48c89..f52af0fc 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/metrics.json +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/metrics.json @@ -17,6 +17,12 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/expected.tsx index 791e1ee3..fb9d8f53 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/expected.tsx +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/expected.tsx @@ -4,6 +4,6 @@ import { Button, Tag } from '@backstage/ui'; const MyComponent = () => ( <> - Info + Info ); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/metrics.json index 6e132099..10d0f069 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/metrics.json +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/metrics.json @@ -17,6 +17,12 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/named-barrel-import/metrics.json index 7eb48c89..93f69c82 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/named-barrel-import/metrics.json +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/named-barrel-import/metrics.json @@ -17,6 +17,13 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "size-mapped", + "size": "small" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/partial-barrel-import/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/partial-barrel-import/metrics.json index 7eb48c89..93f69c82 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/partial-barrel-import/metrics.json +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/partial-barrel-import/metrics.json @@ -17,6 +17,13 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "size-mapped", + "size": "small" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/metrics.json index 7eb48c89..93f69c82 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/metrics.json +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/metrics.json @@ -17,6 +17,13 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "size-mapped", + "size": "small" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/size-large/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/size-large/expected.tsx new file mode 100644 index 00000000..d4280be1 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/size-large/expected.tsx @@ -0,0 +1,5 @@ +import { Tag } from '@backstage/ui'; + +const MyComponent = () => ( + Legacy +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/size-large/input.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/size-large/input.tsx new file mode 100644 index 00000000..7ecb91e5 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/size-large/input.tsx @@ -0,0 +1,5 @@ +import Chip from '@material-ui/core/Chip'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/size-large/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/size-large/metrics.json new file mode 100644 index 00000000..b04d864b --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/size-large/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-chip-to-tag": [ + { + "cardinality": { + "action": "chip-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "size-large-to-medium" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/size-medium/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/size-medium/expected.tsx new file mode 100644 index 00000000..9d0e54e9 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/size-medium/expected.tsx @@ -0,0 +1,5 @@ +import { Tag } from '@backstage/ui'; + +const MyComponent = () => ( + Status +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/size-medium/input.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/size-medium/input.tsx new file mode 100644 index 00000000..32fa7652 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/size-medium/input.tsx @@ -0,0 +1,5 @@ +import Chip from '@material-ui/core/Chip'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/size-medium/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/size-medium/metrics.json new file mode 100644 index 00000000..631ba36b --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/size-medium/metrics.json @@ -0,0 +1,29 @@ +{ + "migrate-mui-chip-to-tag": [ + { + "cardinality": { + "action": "chip-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "size-mapped", + "size": "medium" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts b/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts index 82072eed..1da3a348 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts @@ -216,7 +216,7 @@ function isSingleIconChild(child: SgNode): boolean { } /** Props that are dropped silently (MUI-specific, no BUI equivalent). */ -const DROPPED_PROPS = new Set(['size', 'edge', 'disableRipple', 'disableFocusRipple']) +const DROPPED_PROPS = new Set(['edge', 'disableRipple', 'disableFocusRipple']) /** MUI IconButton color → BUI ButtonIcon variant. Absent/default/inherit → tertiary. */ const COLOR_TO_VARIANT: Record = { @@ -319,6 +319,12 @@ function transformIconButtonElements( continue } + const { value: sizeValue, isDynamic: sizeDynamic } = getAttrStringValue(opening, 'size') + if (sizeDynamic) { + insertTodo('dynamic-size') + continue + } + // Build new props const newProps: string[] = [] @@ -326,6 +332,24 @@ function transformIconButtonElements( newProps.push(formatIconProp(iconChild)) newProps.push(`variant="${buiVariant}"`) + // Map size — preserve MUI medium default when BUI defaults to small + if (sizeValue === null || sizeValue === '') { + newProps.push('size="medium"') + migrationMetric.increment({ action: 'size-defaulted-to-medium' }) + } else if (sizeValue === 'small') { + newProps.push('size="small"') + migrationMetric.increment({ action: 'size-mapped', size: 'small' }) + } else if (sizeValue === 'medium') { + newProps.push('size="medium"') + migrationMetric.increment({ action: 'size-mapped', size: 'medium' }) + } else if (sizeValue === 'large') { + newProps.push('size="medium"') + migrationMetric.increment({ action: 'size-large-to-medium' }) + } else { + insertTodo('unknown-size') + continue + } + // Map props from opening element const allAttrs = opening.findAll({ rule: { kind: 'jsx_attribute' } }) for (const attr of allAttrs) { @@ -335,8 +359,10 @@ function transformIconButtonElements( } const propName = propIdent.text() - if (propName === 'color') { - migrationMetric.increment({ action: 'prop-dropped', prop: 'color' }) + if (propName === 'color' || propName === 'size') { + if (propName === 'color') { + migrationMetric.increment({ action: 'prop-dropped', prop: 'color' }) + } continue } diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/expected.tsx index fc57b430..df928221 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/expected.tsx @@ -1,5 +1,5 @@ import { ButtonIcon } from '@backstage/ui'; const MyComponent = () => ( - } variant="tertiary" aria-label="delete" isDisabled={!canDelete} onPress={handleDelete} /> + } variant="tertiary" size="medium" aria-label="delete" isDisabled={!canDelete} onPress={handleDelete} /> ); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/metrics.json index aa362170..fa4b1234 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/metrics.json +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/metrics.json @@ -17,6 +17,12 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/expected.tsx index 16c0e8f8..b1280267 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/expected.tsx @@ -1,5 +1,5 @@ import { ButtonIcon } from '@backstage/ui'; const MyComponent = () => ( - } variant="tertiary" aria-label="copy" className="custom-btn" data-testid="copy-btn" onPress={handleCopy} /> + } variant="tertiary" size="medium" aria-label="copy" className="custom-btn" data-testid="copy-btn" onPress={handleCopy} /> ); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/metrics.json index aa362170..fa4b1234 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/metrics.json +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/metrics.json @@ -17,6 +17,12 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-primary/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-primary/expected.tsx index 017bb16f..1fab4fc8 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-primary/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-primary/expected.tsx @@ -1,5 +1,5 @@ import { ButtonIcon } from '@backstage/ui'; const MyComponent = () => ( - } variant="primary" aria-label="favorite" onPress={handleFavorite} /> + } variant="primary" size="medium" aria-label="favorite" onPress={handleFavorite} /> ); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-primary/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-primary/metrics.json index 2c056a0f..8ece0141 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-primary/metrics.json +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-primary/metrics.json @@ -24,6 +24,12 @@ "prop": "color" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-secondary/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-secondary/expected.tsx index 31a71cce..4359d02c 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-secondary/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-secondary/expected.tsx @@ -1,5 +1,5 @@ import { ButtonIcon } from '@backstage/ui'; const MyComponent = () => ( - } variant="secondary" aria-label="settings" onPress={handleSettings} /> + } variant="secondary" size="medium" aria-label="settings" onPress={handleSettings} /> ); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-secondary/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-secondary/metrics.json index 2c056a0f..8ece0141 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-secondary/metrics.json +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/color-secondary/metrics.json @@ -24,6 +24,12 @@ "prop": "color" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/expected.tsx index 52a1d167..ccd6462c 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/expected.tsx @@ -1,5 +1,5 @@ import { ButtonIcon } from '@backstage/ui'; const MyComponent = () => ( - } variant="tertiary" aria-label="close" isDisabled onPress={onClose} /> + } variant="tertiary" size="medium" aria-label="close" isDisabled onPress={onClose} /> ); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/metrics.json index aa362170..fa4b1234 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/metrics.json +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/metrics.json @@ -17,6 +17,12 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/expected.tsx index 9937c693..1a3376e3 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/expected.tsx @@ -1,5 +1,5 @@ import { ButtonIcon } from '@backstage/ui'; const MyComponent = () => ( - } variant="tertiary" aria-label="menu" onPress={handleMenu} /> + } variant="tertiary" size="small" aria-label="menu" onPress={handleMenu} /> ); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/metrics.json index f38e6179..b26dab77 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/metrics.json +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/metrics.json @@ -34,8 +34,8 @@ }, { "cardinality": { - "action": "prop-dropped", - "prop": "size" + "action": "size-mapped", + "size": "small" }, "count": 1 } diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dynamic-size-todo/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dynamic-size-todo/expected.tsx new file mode 100644 index 00000000..64128b1b --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dynamic-size-todo/expected.tsx @@ -0,0 +1,10 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = ({ size }: { size: 'small' | 'medium' | 'large' }) => ( + <> + {/* TODO(backstage-codemod): verify ButtonIcon accessibility manually */} + + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dynamic-size-todo/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dynamic-size-todo/input.tsx new file mode 100644 index 00000000..264e64eb --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dynamic-size-todo/input.tsx @@ -0,0 +1,7 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = ({ size }: { size: 'small' | 'medium' | 'large' }) => ( + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dynamic-size-todo/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dynamic-size-todo/metrics.json new file mode 100644 index 00000000..11bc1040 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dynamic-size-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "dynamic-size" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/expected.tsx index c80f2f5d..da4a59a2 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/expected.tsx @@ -4,6 +4,6 @@ import { Button, ButtonIcon } from '@backstage/ui'; const MyComponent = () => ( <> - } variant="tertiary" aria-label="close" onPress={handleClose} /> + } variant="tertiary" size="medium" aria-label="close" onPress={handleClose} /> ); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/metrics.json index 43d525fd..ebbf3f4f 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/metrics.json +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/metrics.json @@ -17,6 +17,12 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/expected.tsx index 25e3e857..4876d0f6 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/expected.tsx @@ -1,5 +1,5 @@ import { ButtonIcon } from '@backstage/ui'; const MyComponent = () => ( - } variant="tertiary" aria-label="edit" onPress={handleEdit} /> + } variant="tertiary" size="medium" aria-label="edit" onPress={handleEdit} /> ); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/metrics.json index aa362170..fa4b1234 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/metrics.json +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/metrics.json @@ -17,6 +17,12 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/size-large/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/size-large/expected.tsx new file mode 100644 index 00000000..4bc82017 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/size-large/expected.tsx @@ -0,0 +1,5 @@ +import { ButtonIcon } from '@backstage/ui'; + +const MyComponent = () => ( + } variant="tertiary" size="medium" aria-label="expand" onPress={handleExpand} /> +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/size-large/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/size-large/input.tsx new file mode 100644 index 00000000..d48aaf23 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/size-large/input.tsx @@ -0,0 +1,7 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = () => ( + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/size-large/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/size-large/metrics.json new file mode 100644 index 00000000..cc850c55 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/size-large/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "icon-button-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "size-large-to-medium" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/expected.tsx index 80e44963..c00f8d2b 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/expected.tsx @@ -1,5 +1,5 @@ import { ButtonIcon } from '@backstage/ui'; const MyComponent = (props: any) => ( - } variant="tertiary" aria-label="action" {...props} /> + } variant="tertiary" size="medium" aria-label="action" {...props} /> ); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/metrics.json index aa362170..fa4b1234 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/metrics.json +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/metrics.json @@ -17,6 +17,12 @@ "action": "import-removed" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file 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 29061119..ee9b45d4 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 @@ -21,7 +21,6 @@ const TODO_PROPS = new Set([ 'error', 'variant', 'margin', - 'size', 'color', 'classes', 'inputRef', @@ -424,6 +423,35 @@ function tryRewriteOnChangeHandler(attr: SgNode): string | null { return `{newValue => ${rewrittenBody}}` } +function isDynamicSizeProp(opening: SgNode): boolean { + const attr = getPropAttr(opening, 'size') + if (!attr) { + return false + } + return attr.find({ rule: { kind: 'string' } }) === null +} + +function mapSizeProp(opening: SgNode, newProps: string[]): void { + const sizeValue = getPropStringValue(opening, 'size') + if (sizeValue === 'small') { + newProps.push('size="small"') + migrationMetric.increment({ action: 'size-mapped', size: 'small' }) + return + } + if (sizeValue === 'medium') { + newProps.push('size="medium"') + migrationMetric.increment({ action: 'size-mapped', size: 'medium' }) + return + } + if (sizeValue === 'large') { + newProps.push('size="medium"') + migrationMetric.increment({ action: 'size-large-to-medium' }) + return + } + newProps.push('size="medium"') + migrationMetric.increment({ action: 'size-defaulted-to-medium' }) +} + function resolveTargetComponent(opening: SgNode): string { if (hasProp(opening, 'multiline')) { return 'TextAreaField' @@ -468,6 +496,11 @@ function transformTextFieldElements( let needsTodo = false const todoReasons: string[] = [] + if (isDynamicSizeProp(opening)) { + needsTodo = true + todoReasons.push('size') + } + for (const prop of TODO_PROPS) { if (hasProp(opening, prop)) { needsTodo = true @@ -505,6 +538,7 @@ function transformTextFieldElements( const newProps: string[] = [] let handlerTodo = false let droppedFullWidth = false + let sizeHandled = false for (const child of opening.children()) { const kind = child.kind() @@ -584,6 +618,11 @@ function transformTextFieldElements( migrationMetric.increment({ action: 'todo-inserted', reason: 'fullWidth' }) continue } + if (propName === 'size') { + sizeHandled = true + mapSizeProp(opening, newProps) + continue + } newProps.push(child.text()) } else if (kind === 'jsx_expression' && child.text().startsWith('{...')) { newProps.push(child.text()) @@ -594,6 +633,10 @@ function transformTextFieldElements( continue } + if (!sizeHandled) { + mapSizeProp(opening, newProps) + } + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' let output: string diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/aliased-import/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/aliased-import/expected.tsx index 23c9f229..5f4d4d58 100644 --- a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/aliased-import/expected.tsx +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/aliased-import/expected.tsx @@ -1,5 +1,5 @@ import { TextField } from '@backstage/ui'; const MyComponent = () => ( - setValue(newValue)} /> + setValue(newValue)} size="medium" /> ); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/aliased-import/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/aliased-import/metrics.json index f08a9ddd..47f6cf22 100644 --- a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/aliased-import/metrics.json +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/aliased-import/metrics.json @@ -18,6 +18,12 @@ }, "count": 1 }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 + }, { "cardinality": { "action": "textfield-migrated", 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 index fb1f7b1a..bdfcfcb8 100644 --- 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 @@ -1,5 +1,5 @@ import { TextField } from '@backstage/ui'; const MyComponent = () => ( - setValue(newValue)} /> + setValue(newValue)} size="medium" /> ); 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 index 71a32067..89136e07 100644 --- 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 @@ -26,6 +26,12 @@ }, "count": 1 }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 + }, { "cardinality": { "action": "textfield-migrated", 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 index fc1d9ce8..391fca48 100644 --- 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 @@ -3,6 +3,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/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/metrics.json index 717ed632..6993eb88 100644 --- 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 @@ -27,6 +27,12 @@ }, "count": 1 }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 + }, { "cardinality": { "action": "textfield-migrated", diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/dynamic-size-todo/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/dynamic-size-todo/expected.tsx new file mode 100644 index 00000000..1b9cc97e --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/dynamic-size-todo/expected.tsx @@ -0,0 +1,8 @@ +import TextField from '@material-ui/core/TextField'; + +const MyComponent = () => ( + <> + {/* TODO(backstage-codemod): finish TextField migration manually (size) */} + setTitle(e.target.value)} /> + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/dynamic-size-todo/input.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/dynamic-size-todo/input.tsx new file mode 100644 index 00000000..00083fb8 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/dynamic-size-todo/input.tsx @@ -0,0 +1,5 @@ +import TextField from '@material-ui/core/TextField'; + +const MyComponent = () => ( + setTitle(e.target.value)} /> +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/dynamic-size-todo/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/dynamic-size-todo/metrics.json new file mode 100644 index 00000000..7707bdfc --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/dynamic-size-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-textfield-to-bui-textfield": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "size" + }, + "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 index a3deac11..8073b73c 100644 --- 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 @@ -4,6 +4,6 @@ import { Button, TextField } from '@backstage/ui'; const MyComponent = () => ( <> - setEmail(newValue)} /> + setEmail(newValue)} size="medium" /> ); 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 index 49e1146f..c091dabc 100644 --- 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 @@ -18,6 +18,12 @@ }, "count": 1 }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 + }, { "cardinality": { "action": "textfield-migrated", diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline/expected.tsx index 0b70a131..736c9176 100644 --- a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline/expected.tsx +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline/expected.tsx @@ -1,5 +1,5 @@ import { TextAreaField } from '@backstage/ui'; const MyComponent = () => ( - setDesc(newValue)} /> + setDesc(newValue)} size="medium" /> ); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline/metrics.json index 3ce817d5..5c9c59cd 100644 --- a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline/metrics.json +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline/metrics.json @@ -33,6 +33,12 @@ }, "count": 1 }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 + }, { "cardinality": { "action": "textfield-migrated", diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/named-barrel-import/expected.tsx index 23c9f229..5f4d4d58 100644 --- a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/named-barrel-import/expected.tsx +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/named-barrel-import/expected.tsx @@ -1,5 +1,5 @@ import { TextField } from '@backstage/ui'; const MyComponent = () => ( - setValue(newValue)} /> + setValue(newValue)} size="medium" /> ); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/named-barrel-import/metrics.json index f08a9ddd..47f6cf22 100644 --- a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/named-barrel-import/metrics.json +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/named-barrel-import/metrics.json @@ -18,6 +18,12 @@ }, "count": 1 }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 + }, { "cardinality": { "action": "textfield-migrated", diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/number-field/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/number-field/expected.tsx index 82a93385..45eec1de 100644 --- a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/number-field/expected.tsx +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/number-field/expected.tsx @@ -1,5 +1,5 @@ import { NumberField } from '@backstage/ui'; const MyComponent = () => ( - setCount(newValue)} /> + setCount(newValue)} size="medium" /> ); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/number-field/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/number-field/metrics.json index 9b409374..a57fb027 100644 --- a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/number-field/metrics.json +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/number-field/metrics.json @@ -25,6 +25,12 @@ }, "count": 1 }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 + }, { "cardinality": { "action": "textfield-migrated", diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/partial-barrel-import/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/partial-barrel-import/expected.tsx index 85e94cd4..e8a8b758 100644 --- a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/partial-barrel-import/expected.tsx +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/partial-barrel-import/expected.tsx @@ -3,7 +3,7 @@ import { TextField } from '@backstage/ui'; const MyComponent = () => ( <> - setValue(newValue)} /> + setValue(newValue)} size="medium" /> ); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/partial-barrel-import/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/partial-barrel-import/metrics.json index a7567586..c614532b 100644 --- a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/partial-barrel-import/metrics.json +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/partial-barrel-import/metrics.json @@ -18,6 +18,12 @@ }, "count": 1 }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 + }, { "cardinality": { "action": "textfield-migrated", diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/password-field/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/password-field/expected.tsx index 0ccc2011..2b6593c6 100644 --- a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/password-field/expected.tsx +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/password-field/expected.tsx @@ -1,5 +1,5 @@ import { PasswordField } from '@backstage/ui'; const MyComponent = () => ( - setPassword(newValue)} description="Use a strong password" /> + setPassword(newValue)} description="Use a strong password" size="medium" /> ); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/password-field/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/password-field/metrics.json index 79c41f60..e8bd7878 100644 --- a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/password-field/metrics.json +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/password-field/metrics.json @@ -33,6 +33,12 @@ }, "count": 1 }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 + }, { "cardinality": { "action": "textfield-migrated", diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-large/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-large/expected.tsx new file mode 100644 index 00000000..9691dea6 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-large/expected.tsx @@ -0,0 +1,5 @@ +import { TextField } from '@backstage/ui'; + +const MyComponent = () => ( + setTitle(newValue)} /> +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-large/input.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-large/input.tsx new file mode 100644 index 00000000..f2bce8ad --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-large/input.tsx @@ -0,0 +1,5 @@ +import TextField from '@material-ui/core/TextField'; + +const MyComponent = () => ( + setTitle(e.target.value)} /> +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-large/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-large/metrics.json new file mode 100644 index 00000000..cabeb649 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-large/metrics.json @@ -0,0 +1,35 @@ +{ + "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": "size-large-to-medium" + }, + "count": 1 + }, + { + "cardinality": { + "action": "textfield-migrated", + "component": "TextField" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-small/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-small/expected.tsx new file mode 100644 index 00000000..009a8d70 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-small/expected.tsx @@ -0,0 +1,5 @@ +import { TextField } from '@backstage/ui'; + +const MyComponent = () => ( + setCode(newValue)} /> +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-small/input.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-small/input.tsx new file mode 100644 index 00000000..3dd4635f --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-small/input.tsx @@ -0,0 +1,5 @@ +import TextField from '@material-ui/core/TextField'; + +const MyComponent = () => ( + setCode(e.target.value)} /> +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-small/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-small/metrics.json new file mode 100644 index 00000000..567f3f01 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/size-small/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": "size-mapped", + "size": "small" + }, + "count": 1 + }, + { + "cardinality": { + "action": "textfield-migrated", + "component": "TextField" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/mui4-to-bui-migration-recipe/README.md b/codemods/misc/mui4-to-bui-migration-recipe/README.md index 3428e385..a2a199d7 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/README.md +++ b/codemods/misc/mui4-to-bui-migration-recipe/README.md @@ -53,6 +53,10 @@ Also see the short ordering note in [`../README.md`](../README.md). `@backstage/migrate-mui-button-to-bui-button` maps `variant="outlined"` → `variant="secondary"` silently (BUI secondary is the outlined visual). The transform emits metric `outlined-to-secondary` so operators can quantify remaps in dry-run output. +### Density (size defaults) + +BUI controls default to `size="small"` while MUI defaults to medium. Button, ButtonIcon, TextField family, and Tag emit `size="medium"` when MUI omitted size (or used `medium`/`large`), so migrations do not silently shrink. Explicit MUI `size="small"` is preserved. + ## Package naming Most transforms use `migrate-mui--to-bui-`. A few intentionally omit `-bui-` in the target segment when the BUI export name is the product surface (not a “BUI-prefixed” alias): From 0da8de503e8f74316fc53b75c0a2834d114ee0bb Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 22 Jul 2026 18:07:37 -0400 Subject: [PATCH 09/11] Preserve MUI density for Select and Autocomplete migrations. Emit size="medium" by default for Select/Combobox/SearchAutocomplete, with FormControl and renderInput TextField size fallbacks when the control itself omits size. Co-authored-by: Cursor --- .../density-select-autocomplete-size.md | 7 ++ .../README.md | 2 + .../scripts/codemod.ts | 96 ++++++++++++++++++- .../tests/combobox-success/expected.tsx | 2 +- .../tests/combobox-success/metrics.json | 6 ++ .../render-input-size-small/expected.tsx | 12 +++ .../tests/render-input-size-small/input.tsx | 18 ++++ .../render-input-size-small/metrics.json | 35 +++++++ .../tests/search-autocomplete/expected.tsx | 2 +- .../tests/search-autocomplete/metrics.json | 6 ++ .../tests/size-small/expected.tsx | 12 +++ .../tests/size-small/input.tsx | 19 ++++ .../tests/size-small/metrics.json | 29 ++++++ .../README.md | 1 + .../scripts/codemod.ts | 85 ++++++++++++++++ .../tests/basic-form-control/expected.tsx | 2 +- .../tests/basic-form-control/metrics.json | 6 ++ .../tests/dynamic-size-todo/expected.tsx | 17 ++++ .../tests/dynamic-size-todo/input.tsx | 14 +++ .../tests/dynamic-size-todo/metrics.json | 11 +++ .../form-control-size-small/expected.tsx | 8 ++ .../tests/form-control-size-small/input.tsx | 14 +++ .../form-control-size-small/metrics.json | 41 ++++++++ .../tests/merge-existing-bui/expected.tsx | 2 +- .../tests/merge-existing-bui/metrics.json | 6 ++ .../tests/named-barrel-import/expected.tsx | 2 +- .../tests/named-barrel-import/metrics.json | 6 ++ .../tests/partial-barrel-import/expected.tsx | 2 +- .../tests/partial-barrel-import/metrics.json | 6 ++ .../tests/size-large/expected.tsx | 8 ++ .../tests/size-large/input.tsx | 14 +++ .../tests/size-large/metrics.json | 34 +++++++ .../tests/size-small/expected.tsx | 8 ++ .../tests/size-small/input.tsx | 14 +++ .../tests/size-small/metrics.json | 35 +++++++ .../mui4-to-bui-migration-recipe/README.md | 6 +- 36 files changed, 580 insertions(+), 8 deletions(-) create mode 100644 .changeset/density-select-autocomplete-size.md create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/render-input-size-small/expected.tsx create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/render-input-size-small/input.tsx create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/render-input-size-small/metrics.json create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/size-small/expected.tsx create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/size-small/input.tsx create mode 100644 codemods/misc/migrate-mui-autocomplete-to-combobox/tests/size-small/metrics.json create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/dynamic-size-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/dynamic-size-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/dynamic-size-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-size-small/expected.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-size-small/input.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-size-small/metrics.json create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/size-large/expected.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/size-large/input.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/size-large/metrics.json create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/size-small/expected.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/size-small/input.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/size-small/metrics.json diff --git a/.changeset/density-select-autocomplete-size.md b/.changeset/density-select-autocomplete-size.md new file mode 100644 index 00000000..bbed5859 --- /dev/null +++ b/.changeset/density-select-autocomplete-size.md @@ -0,0 +1,7 @@ +--- +'@backstage/migrate-mui-select-family-to-bui-select': patch +'@backstage/migrate-mui-autocomplete-to-combobox': patch +'@backstage/mui4-to-bui-migration-recipe': patch +--- + +Preserve MUI medium density for Select and Autocomplete→Combobox/SearchAutocomplete, including FormControl and renderInput size fallbacks. diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/README.md b/codemods/misc/migrate-mui-autocomplete-to-combobox/README.md index 701aeb86..ba0fd817 100644 --- a/codemods/misc/migrate-mui-autocomplete-to-combobox/README.md +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/README.md @@ -9,6 +9,7 @@ Heuristically migrates MUI 4 `Autocomplete` to Backstage UI `Combobox` or `Searc - Deep and barrel imports from `@material-ui/lab` - Best-effort option literal flattening for string arrays - Prunes unused `TextField` imports left behind by `renderInput` +- Maps MUI `size` to BUI `size` (`small` preserved; `medium`/`large`/omitted → `medium`; falls back to static `size` on the `renderInput` `TextField`) ## TODOs left for humans @@ -16,6 +17,7 @@ Heuristically migrates MUI 4 `Autocomplete` to Backstage UI `Combobox` or `Searc - Object `options` / `getOptionLabel` shapes (not safe for BUI Combobox) - `freeSolo`, custom `renderOption`, and other complex cases - Non-trivial `onChange` / options shapes +- Dynamic `size` on Autocomplete ## Won't do diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/scripts/codemod.ts b/codemods/misc/migrate-mui-autocomplete-to-combobox/scripts/codemod.ts index 9cee266b..f0ee03d6 100644 --- a/codemods/misc/migrate-mui-autocomplete-to-combobox/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/scripts/codemod.ts @@ -276,6 +276,32 @@ function getPropAttr(opening: SgNode, propName: string): SgNode | null }) } +function getDirectPropAttr(opening: SgNode, propName: string): SgNode | null { + for (const child of opening.children()) { + if (!child.is('jsx_attribute')) { + continue + } + const propId = child.find({ rule: { kind: 'property_identifier' } }) + if (propId?.text() === propName) { + return child + } + } + return null +} + +function getDirectPropStringValue(opening: SgNode, propName: string): string | null { + const attr = getDirectPropAttr(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 hasProp(opening: SgNode, propName: string): boolean { return getPropAttr(opening, propName) !== null } @@ -466,7 +492,7 @@ function looksLikeSearchText(text: string | null): boolean { return /\b(search|find|filter)\b/i.test(text) } -function extractRenderInputFieldProp(opening: SgNode, propName: 'label' | 'placeholder'): string | null { +function extractRenderInputFieldProp(opening: SgNode, propName: 'label' | 'placeholder' | 'size'): string | null { const attr = getPropAttr(opening, 'renderInput') if (!attr) { return null @@ -488,6 +514,56 @@ function extractRenderInputFieldProp(opening: SgNode, propName: 'label' | ' return frag?.text() ?? null } +function isDynamicSizeProp(opening: SgNode): boolean { + const attr = getDirectPropAttr(opening, 'size') + if (!attr) { + return false + } + return attr.find({ rule: { kind: 'string' } }) === null +} + +function addMappedSizeProp(opening: SgNode, props: string[]): void { + const autocompleteSize = getDirectPropStringValue(opening, 'size') + if (autocompleteSize !== null) { + mapStaticSizeValue(autocompleteSize, false, props) + return + } + + const renderInputSize = extractRenderInputFieldProp(opening, 'size') + if (renderInputSize !== null) { + mapStaticSizeValue(renderInputSize, true, props) + return + } + + props.push('size="medium"') + migrationMetric.increment({ action: 'size-defaulted-to-medium' }) +} + +function mapStaticSizeValue(muiSize: string, fromRenderInput: boolean, props: string[]): void { + if (fromRenderInput) { + migrationMetric.increment({ action: 'size-from-render-input' }) + } + + if (muiSize === 'small') { + props.push('size="small"') + migrationMetric.increment({ action: 'size-mapped', size: 'small' }) + return + } + if (muiSize === 'medium') { + props.push('size="medium"') + migrationMetric.increment({ action: 'size-mapped', size: 'medium' }) + return + } + if (muiSize === 'large') { + props.push('size="medium"') + migrationMetric.increment({ action: 'size-large-to-medium' }) + return + } + + props.push('size="medium"') + migrationMetric.increment({ action: 'size-defaulted-to-medium' }) +} + type AutocompleteKind = 'combobox' | 'search' | 'todo' function classifyAutocomplete(opening: SgNode): AutocompleteKind { @@ -583,6 +659,8 @@ function buildComboboxReplacement(opening: SgNode): string | null { props.push(`name="${nameStr}"`) } + addMappedSizeProp(opening, props) + return `` } @@ -611,6 +689,8 @@ function buildSearchReplacement(opening: SgNode): string | null { props.push(`placeholder="${placeholder}"`) } + addMappedSizeProp(opening, props) + return `` } @@ -655,6 +735,20 @@ function transformAutocompleteElements( continue } + if (isDynamicSizeProp(opening)) { + edits.push( + el.replace( + withTodoComment( + `{/* TODO(backstage-codemod): verify Autocomplete migration manually — ambiguous Combobox vs SearchAutocomplete */}`, + el.text(), + ), + ), + ) + preserveImport = true + migrationMetric.increment({ action: 'todo-inserted', reason: 'size' }) + continue + } + if (kind === 'combobox') { const replacement = buildComboboxReplacement(opening) if (!replacement) { diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/combobox-success/expected.tsx b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/combobox-success/expected.tsx index 8dda18a3..17613ac6 100644 --- a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/combobox-success/expected.tsx +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/combobox-success/expected.tsx @@ -8,5 +8,5 @@ const FruitPicker = ({ value: string | null; onChange: (value: string | null) => void; }) => ( - + ); diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/combobox-success/metrics.json b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/combobox-success/metrics.json index a3e63ef4..b06e99e6 100644 --- a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/combobox-success/metrics.json +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/combobox-success/metrics.json @@ -17,6 +17,12 @@ "action": "import-removed" }, "count": 2 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/render-input-size-small/expected.tsx b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/render-input-size-small/expected.tsx new file mode 100644 index 00000000..3f98dc6b --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/render-input-size-small/expected.tsx @@ -0,0 +1,12 @@ +import { Combobox } from '@backstage/ui'; + + +const FruitPicker = ({ + value, + onChange, +}: { + value: string | null; + onChange: (value: string | null) => void; +}) => ( + +); diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/render-input-size-small/input.tsx b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/render-input-size-small/input.tsx new file mode 100644 index 00000000..5e57f093 --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/render-input-size-small/input.tsx @@ -0,0 +1,18 @@ +import Autocomplete from '@material-ui/lab/Autocomplete'; +import TextField from '@material-ui/core/TextField'; + +const FruitPicker = ({ + value, + onChange, +}: { + value: string | null; + onChange: (value: string | null) => void; +}) => ( + option} + value={value} + onChange={(_event, next) => onChange(next)} + renderInput={(params) => } + /> +); diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/render-input-size-small/metrics.json b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/render-input-size-small/metrics.json new file mode 100644 index 00000000..26a8bfd6 --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/render-input-size-small/metrics.json @@ -0,0 +1,35 @@ +{ + "migrate-mui-autocomplete-to-combobox": [ + { + "cardinality": { + "action": "autocomplete-to-combobox" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 2 + }, + { + "cardinality": { + "action": "size-from-render-input" + }, + "count": 1 + }, + { + "cardinality": { + "action": "size-mapped", + "size": "small" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/search-autocomplete/expected.tsx b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/search-autocomplete/expected.tsx index 648f1eee..79c3e3a5 100644 --- a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/search-autocomplete/expected.tsx +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/search-autocomplete/expected.tsx @@ -8,5 +8,5 @@ const SearchBox = ({ query: string; setQuery: (value: string) => void; }) => ( - + ); diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/search-autocomplete/metrics.json b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/search-autocomplete/metrics.json index 4b6d1e51..282bfd19 100644 --- a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/search-autocomplete/metrics.json +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/search-autocomplete/metrics.json @@ -17,6 +17,12 @@ "action": "import-removed" }, "count": 2 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/size-small/expected.tsx b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/size-small/expected.tsx new file mode 100644 index 00000000..3f98dc6b --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/size-small/expected.tsx @@ -0,0 +1,12 @@ +import { Combobox } from '@backstage/ui'; + + +const FruitPicker = ({ + value, + onChange, +}: { + value: string | null; + onChange: (value: string | null) => void; +}) => ( + +); diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/size-small/input.tsx b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/size-small/input.tsx new file mode 100644 index 00000000..8e5e6893 --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/size-small/input.tsx @@ -0,0 +1,19 @@ +import Autocomplete from '@material-ui/lab/Autocomplete'; +import TextField from '@material-ui/core/TextField'; + +const FruitPicker = ({ + value, + onChange, +}: { + value: string | null; + onChange: (value: string | null) => void; +}) => ( + option} + value={value} + onChange={(_event, next) => onChange(next)} + renderInput={(params) => } + /> +); diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/size-small/metrics.json b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/size-small/metrics.json new file mode 100644 index 00000000..60c95545 --- /dev/null +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/tests/size-small/metrics.json @@ -0,0 +1,29 @@ +{ + "migrate-mui-autocomplete-to-combobox": [ + { + "cardinality": { + "action": "autocomplete-to-combobox" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 2 + }, + { + "cardinality": { + "action": "size-mapped", + "size": "small" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/README.md b/codemods/misc/migrate-mui-select-family-to-bui-select/README.md index 26a7f3b6..d57e3b6e 100644 --- a/codemods/misc/migrate-mui-select-family-to-bui-select/README.md +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/README.md @@ -7,6 +7,7 @@ Migrates MUI Select wrapper patterns to Backstage UI Select. - Basic FormControl + Select + MenuItem patterns - Named / partial barrel imports - Merging into an existing `@backstage/ui` import +- MUI density/size remapping (`small` → `small`, omitted/`medium`/`large` → `medium`; FormControl fallback) ## TODOs / won't-do 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 04daf181..bb45702f 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 @@ -479,6 +479,59 @@ function formatOption(option: OptionInfo): string { return `{ id: '${escapeSingleQuotes(option.id)}', label: '${escapeSingleQuotes(option.label)}' }` } +function isDynamicSizeProp(opening: SgNode): boolean { + const attr = getPropAttr(opening, 'size') + if (!attr) { + return false + } + return attr.find({ rule: { kind: 'string' } }) === null +} + +function hasDynamicSizeOnAny(openings: SgNode[]): boolean { + return openings.some((opening) => isDynamicSizeProp(opening)) +} + +function appendSizeProp(openings: SgNode[], newProps: string[]): void { + const [primary, ...fallbacks] = openings + + let sizeValue = primary ? getPropStringValue(primary, 'size') : null + let fromFormControl = false + + if (sizeValue === null) { + for (const fallback of fallbacks) { + const fallbackSize = getPropStringValue(fallback, 'size') + if (fallbackSize !== null) { + sizeValue = fallbackSize + fromFormControl = true + break + } + } + } + + if (fromFormControl) { + migrationMetric.increment({ action: 'size-from-form-control' }) + } + + if (sizeValue === 'small') { + newProps.push('size="small"') + migrationMetric.increment({ action: 'size-mapped', size: 'small' }) + return + } + if (sizeValue === 'medium') { + newProps.push('size="medium"') + migrationMetric.increment({ action: 'size-mapped', size: 'medium' }) + return + } + if (sizeValue === 'large') { + newProps.push('size="medium"') + migrationMetric.increment({ action: 'size-large-to-medium' }) + return + } + + newProps.push('size="medium"') + migrationMetric.increment({ action: 'size-defaulted-to-medium' }) +} + function findSelectInFormControl( formControlElement: SgNode, localNames: Map, @@ -618,12 +671,28 @@ function transformSelectPatterns( continue } + if (hasDynamicSizeOnAny([selectOpening, opening])) { + preserveImport = true + edits.push( + el.replace( + withTodoComment( + `{/* TODO(backstage-codemod): finish Select migration manually (dynamic-size) */}`, + el.text(), + ), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'dynamic-size' }) + continue + } + const newProps: string[] = [] if (label) { newProps.push(`label={${JSON.stringify(label)}}`) } + appendSizeProp([selectOpening, opening], newProps) + const valueRaw = getPropRawValue(selectOpening, 'value') if (valueRaw) { newProps.push(`selectedKey=${valueRaw}`) @@ -687,8 +756,24 @@ function transformSelectPatterns( continue } + if (isDynamicSizeProp(opening)) { + preserveImport = true + edits.push( + el.replace( + withTodoComment( + `{/* TODO(backstage-codemod): finish Select migration manually (dynamic-size) */}`, + el.text(), + ), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'dynamic-size' }) + continue + } + const newProps: string[] = [] + appendSizeProp([opening], newProps) + const valueRaw = getPropRawValue(opening, 'value') if (valueRaw) { newProps.push(`selectedKey=${valueRaw}`) 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 53fc6386..ef9398ff 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 @@ -4,5 +4,5 @@ import { Select } from '@backstage/ui'; const MyComponent = () => ( - setValue(key as string)} options={[{ id: 'react', label: 'React' }, { id: 'angular', label: '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 index 5fb7f56a..abac0299 100644 --- 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 @@ -23,6 +23,12 @@ "action": "select-migrated" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/dynamic-size-todo/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/dynamic-size-todo/expected.tsx new file mode 100644 index 00000000..ceda3bdf --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/dynamic-size-todo/expected.tsx @@ -0,0 +1,17 @@ +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 = ({ density }: { density: 'small' | 'medium' }) => ( + <> + {/* TODO(backstage-codemod): finish Select migration manually (dynamic-size) */} + + Framework + + + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/dynamic-size-todo/input.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/dynamic-size-todo/input.tsx new file mode 100644 index 00000000..32d3f4fd --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/dynamic-size-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 = ({ density }: { density: 'small' | 'medium' }) => ( + + Framework + + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/dynamic-size-todo/metrics.json b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/dynamic-size-todo/metrics.json new file mode 100644 index 00000000..5ec35bfb --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/dynamic-size-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-select-family-to-bui-select": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "dynamic-size" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-size-small/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-size-small/expected.tsx new file mode 100644 index 00000000..127285dc --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-size-small/expected.tsx @@ -0,0 +1,8 @@ +import { Select } from '@backstage/ui'; + + + + +const MyComponent = () => ( + setValue(e.target.value as string)}> + React + Angular + + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-size-small/metrics.json b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-size-small/metrics.json new file mode 100644 index 00000000..250d6c96 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-size-small/metrics.json @@ -0,0 +1,41 @@ +{ + "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 + }, + { + "cardinality": { + "action": "size-from-form-control" + }, + "count": 1 + }, + { + "cardinality": { + "action": "size-mapped", + "size": "small" + }, + "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 index d35829cf..f1c33461 100644 --- 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 @@ -7,6 +7,6 @@ import { Button, Select } from '@backstage/ui'; const MyComponent = () => ( <> - setMode(key as string)} options={[{ id: 'auto', label: 'Auto' }, { id: 'manual', label: '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 index 0798bd84..d8da81a3 100644 --- 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 @@ -23,6 +23,12 @@ "action": "select-migrated" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "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 index d896009d..b63c1439 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,5 +1,5 @@ import { Select } from '@backstage/ui'; const MyComponent = () => ( - setSize(key as string)} options={[{ id: 'sm', label: 'Small' }, { id: 'lg', label: '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 index 3a52d242..58d2dd21 100644 --- 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 @@ -23,6 +23,12 @@ "action": "select-migrated" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/partial-barrel-import/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/partial-barrel-import/expected.tsx index 7325347b..9744a1c6 100644 --- a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/partial-barrel-import/expected.tsx +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/partial-barrel-import/expected.tsx @@ -3,7 +3,7 @@ import { Select } from '@backstage/ui'; const MyComponent = () => ( <> - setSize(key as string)} options={[{ id: 'sm', label: 'Small' }, { id: 'lg', label: 'Large' }]} /> ); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/partial-barrel-import/metrics.json b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/partial-barrel-import/metrics.json index 10074be6..068bc471 100644 --- a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/partial-barrel-import/metrics.json +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/partial-barrel-import/metrics.json @@ -23,6 +23,12 @@ "action": "select-migrated" }, "count": 1 + }, + { + "cardinality": { + "action": "size-defaulted-to-medium" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/size-large/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/size-large/expected.tsx new file mode 100644 index 00000000..ef9398ff --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/size-large/expected.tsx @@ -0,0 +1,8 @@ +import { Select } from '@backstage/ui'; + + + + +const MyComponent = () => ( + setValue(e.target.value as string)}> + React + Angular + + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/size-large/metrics.json b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/size-large/metrics.json new file mode 100644 index 00000000..94baf30c --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/size-large/metrics.json @@ -0,0 +1,34 @@ +{ + "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 + }, + { + "cardinality": { + "action": "size-large-to-medium" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/size-small/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/size-small/expected.tsx new file mode 100644 index 00000000..127285dc --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/size-small/expected.tsx @@ -0,0 +1,8 @@ +import { Select } from '@backstage/ui'; + + + + +const MyComponent = () => ( + setValue(e.target.value as string)}> + React + Angular + + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/size-small/metrics.json b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/size-small/metrics.json new file mode 100644 index 00000000..1391ad2c --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/size-small/metrics.json @@ -0,0 +1,35 @@ +{ + "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 + }, + { + "cardinality": { + "action": "size-mapped", + "size": "small" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/mui4-to-bui-migration-recipe/README.md b/codemods/misc/mui4-to-bui-migration-recipe/README.md index a2a199d7..6109ca08 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/README.md +++ b/codemods/misc/mui4-to-bui-migration-recipe/README.md @@ -55,7 +55,11 @@ Also see the short ordering note in [`../README.md`](../README.md). ### Density (size defaults) -BUI controls default to `size="small"` while MUI defaults to medium. Button, ButtonIcon, TextField family, and Tag emit `size="medium"` when MUI omitted size (or used `medium`/`large`), so migrations do not silently shrink. Explicit MUI `size="small"` is preserved. +BUI controls default to `size="small"` while MUI defaults to medium. These packages emit `size="medium"` when MUI omitted size (or used `medium`/`large`), and preserve explicit `size="small"`: + +- Button / ButtonIcon / TextField family / Tag +- Select (Select prop first; else FormControl `size`) +- Combobox / SearchAutocomplete (Autocomplete prop first; else `renderInput` TextField `size`) ## Package naming From 8f2d17c9d27d9b0ba8efeaccc305c5fca97fd588 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 22 Jul 2026 18:12:17 -0400 Subject: [PATCH 10/11] Close remaining density gaps for TextField FormControl size. Fall back to parent FormControl size when TextField omits it, and treat ternary/expression size props as dynamic across TextField, Select, Autocomplete, and Chip. Co-authored-by: Cursor --- .../density-textfield-form-control-size.md | 9 ++ .../scripts/codemod.ts | 38 ++++--- .../scripts/codemod.ts | 29 ++--- .../scripts/codemod.ts | 17 ++- .../expected.tsx | 17 +++ .../form-control-dynamic-size-todo/input.tsx | 14 +++ .../metrics.json | 11 ++ .../README.md | 1 + .../scripts/codemod.ts | 104 +++++++++++++++--- .../expected.tsx | 11 ++ .../form-control-dynamic-size-todo/input.tsx | 8 ++ .../metrics.json | 11 ++ .../form-control-size-small/expected.tsx | 9 ++ .../tests/form-control-size-small/input.tsx | 8 ++ .../form-control-size-small/metrics.json | 42 +++++++ .../mui4-to-bui-migration-recipe/README.md | 3 +- 16 files changed, 270 insertions(+), 62 deletions(-) create mode 100644 .changeset/density-textfield-form-control-size.md create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-dynamic-size-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-dynamic-size-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-dynamic-size-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-dynamic-size-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-dynamic-size-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-dynamic-size-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-size-small/expected.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-size-small/input.tsx create mode 100644 codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-size-small/metrics.json diff --git a/.changeset/density-textfield-form-control-size.md b/.changeset/density-textfield-form-control-size.md new file mode 100644 index 00000000..5e52e469 --- /dev/null +++ b/.changeset/density-textfield-form-control-size.md @@ -0,0 +1,9 @@ +--- +'@backstage/migrate-mui-textfield-to-bui-textfield': patch +'@backstage/migrate-mui-select-family-to-bui-select': patch +'@backstage/migrate-mui-autocomplete-to-combobox': patch +'@backstage/migrate-mui-chip-to-tag': patch +'@backstage/mui4-to-bui-migration-recipe': patch +--- + +Fall back to parent FormControl size for TextField density, and treat ternary/expression size props as dynamic across Select/Autocomplete/Chip/TextField. diff --git a/codemods/misc/migrate-mui-autocomplete-to-combobox/scripts/codemod.ts b/codemods/misc/migrate-mui-autocomplete-to-combobox/scripts/codemod.ts index f0ee03d6..d1145457 100644 --- a/codemods/misc/migrate-mui-autocomplete-to-combobox/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-autocomplete-to-combobox/scripts/codemod.ts @@ -294,10 +294,13 @@ function getDirectPropStringValue(opening: SgNode, propName: string): strin 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 + // Only accept a direct string child (size="small"). Nested strings inside + // expressions like size={cond ? 'small' : 'medium'} are dynamic, not static. + for (const child of attr.children()) { + if (child.kind() === 'string') { + const frag = child.find({ rule: { kind: 'string_fragment' } }) + return frag?.text() ?? null + } } return null } @@ -326,10 +329,13 @@ function getPropStringValue(opening: SgNode, propName: string): string | nu 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 + // Only accept a direct string child (size="small"). Nested strings inside + // expressions like size={cond ? 'small' : 'medium'} are dynamic, not static. + for (const child of attr.children()) { + if (child.kind() === 'string') { + const frag = child.find({ rule: { kind: 'string_fragment' } }) + return frag?.text() ?? null + } } return null } @@ -509,17 +515,17 @@ function extractRenderInputFieldProp(opening: SgNode, propName: 'label' | ' if (!stringAttr) { return null } - const stringNode = stringAttr.find({ rule: { kind: 'string' } }) - const frag = stringNode?.find({ rule: { kind: 'string_fragment' } }) - return frag?.text() ?? null + for (const child of stringAttr.children()) { + if (child.kind() === 'string') { + const frag = child.find({ rule: { kind: 'string_fragment' } }) + return frag?.text() ?? null + } + } + return null } function isDynamicSizeProp(opening: SgNode): boolean { - const attr = getDirectPropAttr(opening, 'size') - if (!attr) { - return false - } - return attr.find({ rule: { kind: 'string' } }) === null + return getDirectPropAttr(opening, 'size') !== null && getDirectPropStringValue(opening, 'size') === null } function addMappedSizeProp(opening: SgNode, props: string[]): void { diff --git a/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts b/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts index b57f0b11..ec0e23e7 100644 --- a/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts @@ -227,32 +227,19 @@ function getPropStringValue(opening: SgNode, propName: string): string | nu 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 + // Only accept a direct string child (size="small"). Nested strings inside + // expressions like size={cond ? 'small' : 'medium'} are dynamic, not static. + for (const child of attr.children()) { + if (child.kind() === 'string') { + const frag = child.find({ rule: { kind: 'string_fragment' } }) + return frag?.text() ?? null + } } 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 isDynamicSizeProp(opening: SgNode): boolean { - const attr = getPropAttr(opening, 'size') - if (!attr) { - return false - } - return attr.find({ rule: { kind: 'string' } }) === null + return hasProp(opening, 'size') && getPropStringValue(opening, 'size') === null } function recordSizeMetric(sizeValue: string | null): void { 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 bb45702f..de06ae87 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 @@ -246,10 +246,13 @@ function getPropStringValue(opening: SgNode, propName: string): string | nu 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 + // Only accept a direct string child (size="small"). Nested strings inside + // expressions like size={cond ? 'small' : 'medium'} are dynamic, not static. + for (const child of attr.children()) { + if (child.kind() === 'string') { + const frag = child.find({ rule: { kind: 'string_fragment' } }) + return frag?.text() ?? null + } } return null } @@ -480,11 +483,7 @@ function formatOption(option: OptionInfo): string { } function isDynamicSizeProp(opening: SgNode): boolean { - const attr = getPropAttr(opening, 'size') - if (!attr) { - return false - } - return attr.find({ rule: { kind: 'string' } }) === null + return hasProp(opening, 'size') && getPropStringValue(opening, 'size') === null } function hasDynamicSizeOnAny(openings: SgNode[]): boolean { diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-dynamic-size-todo/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-dynamic-size-todo/expected.tsx new file mode 100644 index 00000000..98d5ab0b --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-dynamic-size-todo/expected.tsx @@ -0,0 +1,17 @@ +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 (dynamic-size) */} + + Framework + + + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-dynamic-size-todo/input.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-dynamic-size-todo/input.tsx new file mode 100644 index 00000000..0777a5a8 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-dynamic-size-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 = () => ( + + Framework + + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-dynamic-size-todo/metrics.json b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-dynamic-size-todo/metrics.json new file mode 100644 index 00000000..5ec35bfb --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/form-control-dynamic-size-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-select-family-to-bui-select": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "dynamic-size" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/README.md b/codemods/misc/migrate-mui-textfield-to-bui-textfield/README.md index 4e0e8b3b..61bd41cb 100644 --- a/codemods/misc/migrate-mui-textfield-to-bui-textfield/README.md +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/README.md @@ -7,6 +7,7 @@ Migrates MUI `TextField` to BUI `TextField` / `TextAreaField` / `PasswordField` - Basic controlled TextField; `helperText` → `description` - `multiline` → TextAreaField; password / number branching - Named / partial barrel imports; merge into existing `@backstage/ui` +- MUI density/size remapping (`small` → `small`, omitted/`medium`/`large` → `medium`; parent FormControl `size` fallback) ## TODOs / won't-do 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 ee9b45d4..c8cafeff 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 @@ -149,6 +149,48 @@ function collectTextFieldImports(rootNode: SgNode): { return { textFieldLocalName, importNodesToRemove, barrelImportsToPrune } } +function collectFormControlLocalNames(rootNode: SgNode): Set { + const names = new Set() + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/FormControl')) { + const name = getDefaultImportName(imp) + if (name) { + names.add(name) + } + } + + for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL_SOURCE)) { + const localName = getNamedImportLocalName(imp, 'FormControl') + if (localName) { + names.add(localName) + } + } + + return names +} + +function findParentFormControlOpening(el: SgNode, formControlNames: Set): SgNode | null { + if (formControlNames.size === 0) { + return null + } + + for (const ancestor of el.ancestors()) { + if (ancestor.kind() !== 'jsx_element') { + continue + } + const opening = ancestor.child(0) + if (opening?.kind() !== 'jsx_opening_element') { + continue + } + const name = getElementName(opening) + if (name && formControlNames.has(name)) { + return opening + } + } + + return null +} + function addBuiImport( rootNode: SgNode, names: string[], @@ -257,10 +299,13 @@ function getPropStringValue(opening: SgNode, propName: string): string | nu 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 + // Only accept a direct string child (size="small"). Nested strings inside + // expressions like size={cond ? 'small' : 'medium'} are dynamic, not static. + for (const child of attr.children()) { + if (child.kind() === 'string') { + const frag = child.find({ rule: { kind: 'string_fragment' } }) + return frag?.text() ?? null + } } return null } @@ -424,15 +469,30 @@ function tryRewriteOnChangeHandler(attr: SgNode): string | null { } function isDynamicSizeProp(opening: SgNode): boolean { - const attr = getPropAttr(opening, 'size') - if (!attr) { - return false - } - return attr.find({ rule: { kind: 'string' } }) === null + return hasProp(opening, 'size') && getPropStringValue(opening, 'size') === null } -function mapSizeProp(opening: SgNode, newProps: string[]): void { - const sizeValue = getPropStringValue(opening, 'size') +function mapSizeProp(openings: SgNode[], newProps: string[]): void { + const [primary, ...fallbacks] = openings + + let sizeValue = primary ? getPropStringValue(primary, 'size') : null + let fromFormControl = false + + if (sizeValue === null) { + for (const fallback of fallbacks) { + const fallbackSize = getPropStringValue(fallback, 'size') + if (fallbackSize !== null) { + sizeValue = fallbackSize + fromFormControl = true + break + } + } + } + + if (fromFormControl) { + migrationMetric.increment({ action: 'size-from-form-control' }) + } + if (sizeValue === 'small') { newProps.push('size="small"') migrationMetric.increment({ action: 'size-mapped', size: 'small' }) @@ -469,6 +529,7 @@ function resolveTargetComponent(opening: SgNode): string { function transformTextFieldElements( rootNode: SgNode, textFieldLocalName: string, + formControlNames: Set, edits: Edit[], ): { preserveImport: boolean; migrated: boolean; buiNames: Set } { let preserveImport = false @@ -492,11 +553,18 @@ function transformTextFieldElements( continue } + const parentFormControl = findParentFormControlOpening(el, formControlNames) + const sizeOpenings = parentFormControl ? [opening, parentFormControl] : [opening] + const isMultiline = hasProp(opening, 'multiline') let needsTodo = false const todoReasons: string[] = [] - if (isDynamicSizeProp(opening)) { + const textFieldHasStaticSize = getPropStringValue(opening, 'size') !== null + if ( + isDynamicSizeProp(opening) || + (!textFieldHasStaticSize && parentFormControl !== null && isDynamicSizeProp(parentFormControl)) + ) { needsTodo = true todoReasons.push('size') } @@ -620,7 +688,7 @@ function transformTextFieldElements( } if (propName === 'size') { sizeHandled = true - mapSizeProp(opening, newProps) + mapSizeProp(sizeOpenings, newProps) continue } newProps.push(child.text()) @@ -634,7 +702,7 @@ function transformTextFieldElements( } if (!sizeHandled) { - mapSizeProp(opening, newProps) + mapSizeProp(sizeOpenings, newProps) } const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' @@ -679,7 +747,13 @@ const transform: Codemod = (root) => { return Promise.resolve(null) } - const { preserveImport, migrated, buiNames } = transformTextFieldElements(rootNode, textFieldLocalName, edits) + const formControlNames = collectFormControlLocalNames(rootNode) + const { preserveImport, migrated, buiNames } = transformTextFieldElements( + rootNode, + textFieldLocalName, + formControlNames, + edits, + ) let replacedImport = false const handledBarrelIds = new Set() diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-dynamic-size-todo/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-dynamic-size-todo/expected.tsx new file mode 100644 index 00000000..1e7e6aea --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-dynamic-size-todo/expected.tsx @@ -0,0 +1,11 @@ +import FormControl from '@material-ui/core/FormControl'; +import TextField from '@material-ui/core/TextField'; + +const MyComponent = () => ( + + <> + {/* TODO(backstage-codemod): finish TextField migration manually (size) */} + setValue(e.target.value)} /> + + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-dynamic-size-todo/input.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-dynamic-size-todo/input.tsx new file mode 100644 index 00000000..9904ce5d --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-dynamic-size-todo/input.tsx @@ -0,0 +1,8 @@ +import FormControl from '@material-ui/core/FormControl'; +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/form-control-dynamic-size-todo/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-dynamic-size-todo/metrics.json new file mode 100644 index 00000000..7707bdfc --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-dynamic-size-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-textfield-to-bui-textfield": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "size" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-size-small/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-size-small/expected.tsx new file mode 100644 index 00000000..15b72463 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-size-small/expected.tsx @@ -0,0 +1,9 @@ +import FormControl from '@material-ui/core/FormControl'; +import { TextField } from '@backstage/ui'; + + +const MyComponent = () => ( + + setValue(newValue)} size="small" /> + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-size-small/input.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-size-small/input.tsx new file mode 100644 index 00000000..4c91d862 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-size-small/input.tsx @@ -0,0 +1,8 @@ +import FormControl from '@material-ui/core/FormControl'; +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/form-control-size-small/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-size-small/metrics.json new file mode 100644 index 00000000..c210db2e --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/form-control-size-small/metrics.json @@ -0,0 +1,42 @@ +{ + "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": "size-from-form-control" + }, + "count": 1 + }, + { + "cardinality": { + "action": "size-mapped", + "size": "small" + }, + "count": 1 + }, + { + "cardinality": { + "action": "textfield-migrated", + "component": "TextField" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/mui4-to-bui-migration-recipe/README.md b/codemods/misc/mui4-to-bui-migration-recipe/README.md index 6109ca08..807c5237 100644 --- a/codemods/misc/mui4-to-bui-migration-recipe/README.md +++ b/codemods/misc/mui4-to-bui-migration-recipe/README.md @@ -57,7 +57,8 @@ Also see the short ordering note in [`../README.md`](../README.md). BUI controls default to `size="small"` while MUI defaults to medium. These packages emit `size="medium"` when MUI omitted size (or used `medium`/`large`), and preserve explicit `size="small"`: -- Button / ButtonIcon / TextField family / Tag +- Button / ButtonIcon / Tag +- TextField family (TextField prop first; else parent FormControl `size`) - Select (Select prop first; else FormControl `size`) - Combobox / SearchAutocomplete (Autocomplete prop first; else `renderInput` TextField `size`) From dc35e5676f9cda93dac10df1d47ef0d0bfc6fb00 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 22 Jul 2026 18:19:48 -0400 Subject: [PATCH 11/11] Keep Typography heading weight and emit block-level as. Map h5/h6 onto the BUI title scale and infer as from MUI variant defaults so Text stacks instead of collapsing to inline spans. Co-authored-by: Cursor --- .changeset/typography-heading-as.md | 6 ++ .../migrate-mui-typography-to-text/README.md | 34 +++++------ .../scripts/codemod.ts | 56 +++++++++++++++++-- .../tests/basic-body-text/expected.tsx | 2 +- .../tests/basic-body-text/metrics.json | 7 +++ .../tests/dialog-content-text/expected.tsx | 2 +- .../tests/dialog-content-text/metrics.json | 7 +++ .../tests/extra-props-preserved/expected.tsx | 2 +- .../tests/extra-props-preserved/metrics.json | 7 +++ .../tests/gutter-bottom-dropped/expected.tsx | 2 +- .../tests/gutter-bottom-dropped/metrics.json | 7 +++ .../tests/heading-h5-h6/expected.tsx | 8 +++ .../tests/heading-h5-h6/input.tsx | 8 +++ .../tests/heading-h5-h6/metrics.json | 37 ++++++++++++ .../tests/heading-variant/expected.tsx | 2 +- .../tests/heading-variant/metrics.json | 7 +++ .../tests/merge-existing-bui/expected.tsx | 2 +- .../tests/merge-existing-bui/metrics.json | 7 +++ .../tests/named-barrel-import/expected.tsx | 2 +- .../tests/named-barrel-import/metrics.json | 7 +++ .../tests/preserve-children/expected.tsx | 2 +- .../tests/preserve-children/metrics.json | 7 +++ 22 files changed, 192 insertions(+), 29 deletions(-) create mode 100644 .changeset/typography-heading-as.md create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/heading-h5-h6/expected.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/heading-h5-h6/input.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/heading-h5-h6/metrics.json diff --git a/.changeset/typography-heading-as.md b/.changeset/typography-heading-as.md new file mode 100644 index 00000000..ae5ee397 --- /dev/null +++ b/.changeset/typography-heading-as.md @@ -0,0 +1,6 @@ +--- +'@backstage/migrate-mui-typography-to-text': patch +'@backstage/mui4-to-bui-migration-recipe': patch +--- + +Keep MUI heading weight for h5/h6 (title scale) and emit matching as elements so Text stays block-level in stacks and cards. diff --git a/codemods/misc/migrate-mui-typography-to-text/README.md b/codemods/misc/migrate-mui-typography-to-text/README.md index 697b97ce..1ce3162c 100644 --- a/codemods/misc/migrate-mui-typography-to-text/README.md +++ b/codemods/misc/migrate-mui-typography-to-text/README.md @@ -5,25 +5,27 @@ Migrates MUI `Typography` to Backstage UI `Text` (valid TextVariants only). ## Covers - Body / heading variants mapped to BUI Text variants +- Emits `as` from the MUI variant default element when `component` is omitted (`h5`→`as="h5"`, `body1`→`as="p"`, …) - Named barrel imports; merge into existing `@backstage/ui` - Drops `gutterBottom` where unsupported -## Intentional heading demotion - -BUI has fewer title steps than MUI headings. Smaller MUI headings intentionally map into body scale: - -| MUI | BUI Text | -| ----------- | --------------- | -| `h1` | `title-large` | -| `h2` | `title-medium` | -| `h3` | `title-small` | -| `h4` | `title-x-small` | -| `h5` | `body-small` | -| `h6` | `body-x-small` | -| `subtitle1` | `title-x-small` | -| `subtitle2` | `body-medium` | - -Review visual hierarchy after apply — especially `h5` / `h6` demotions. +## Variant map + +| MUI | BUI Text | Default `as` | +| ----------- | --------------- | ------------ | +| `h1` | `title-large` | `h1` | +| `h2` | `title-medium` | `h2` | +| `h3` | `title-small` | `h3` | +| `h4` | `title-x-small` | `h4` | +| `h5` | `title-x-small` | `h5` | +| `h6` | `title-small` | `h6` | +| `subtitle1` | `title-x-small` | `h6` | +| `subtitle2` | `body-medium` | `h6` | +| `body1` | `body-medium` | `p` | +| `body2` | `body-small` | `p` | +| `caption` | `body-x-small` | `span` | + +Explicit `component` still wins and becomes `as`. ## TODOs / won't-do diff --git a/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts b/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts index 7aed494e..7d961f37 100644 --- a/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts @@ -10,8 +10,8 @@ const VARIANT_MAP: Record = { h2: 'title-medium', h3: 'title-small', h4: 'title-x-small', - h5: 'body-small', - h6: 'body-x-small', + h5: 'title-x-small', + h6: 'title-small', subtitle1: 'title-x-small', subtitle2: 'body-medium', body1: 'body-medium', @@ -21,6 +21,23 @@ const VARIANT_MAP: Record = { button: 'body-medium', } +/** MUI default rendered element for each variant (when `component` is omitted). */ +const AS_FROM_VARIANT: Record = { + h1: 'h1', + h2: 'h2', + h3: 'h3', + h4: 'h4', + h5: 'h5', + h6: 'h6', + subtitle1: 'h6', + subtitle2: 'h6', + body1: 'p', + body2: 'p', + caption: 'span', + overline: 'span', + button: 'span', +} + // MUI color prop → BUI Text color prop const COLOR_MAP: Record = { textPrimary: 'primary', @@ -276,12 +293,21 @@ function getElementName(opening: SgNode): string | null { return null } +function resolveAsFromVariant(variantValue: string | null): string | null { + if (variantValue) { + return AS_FROM_VARIANT[variantValue] ?? null + } + // MUI Typography default variant is body1 →

+ return 'p' +} + function buildPartialTextProps( opening: SgNode, buiVariant: string | null, buiColor: string | null, componentValue: string | null, componentDynamic: boolean, + asFromVariant: string | null, ): string[] { const newProps: string[] = [] if (buiVariant) { @@ -294,6 +320,9 @@ function buildPartialTextProps( newProps.push(`as="${componentValue}"`) } else if (componentDynamic && componentValue) { newProps.push(`as={${componentValue.slice(1, -1)}}`) + } else if (asFromVariant) { + newProps.push(`as="${asFromVariant}"`) + migrationMetric.increment({ action: 'as-from-variant', as: asFromVariant }) } const handledProps = new Set(['variant', 'color', 'component', 'gutterBottom']) @@ -359,8 +388,11 @@ function transformTypographyElements(rootNode: SgNode, localNames: Map, localNames: Map, localNames: Map 0 ? ` ${newProps.join(' ')}` : '' const gutterBottomTodo = gutterBottomAttr ? '{/* TODO(backstage-codemod): verify Text variant manually (gutterBottom) */}' diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/expected.tsx index 5070b944..89b81de3 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/expected.tsx +++ b/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/expected.tsx @@ -1,5 +1,5 @@ import { Text } from '@backstage/ui'; const MyComponent = () => ( - Details + Details ); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/metrics.json index df5928a0..7d2d5265 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/metrics.json +++ b/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/metrics.json @@ -1,5 +1,12 @@ { "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "as-from-variant", + "as": "p" + }, + "count": 1 + }, { "cardinality": { "action": "import-added" diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/expected.tsx index f6997ecf..23314a0d 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/expected.tsx +++ b/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/expected.tsx @@ -1,5 +1,5 @@ import { Text } from '@backstage/ui'; const MyDialog = () => ( - Overview + Overview ); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/metrics.json index 37cd4fba..e16dd83a 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/metrics.json +++ b/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/metrics.json @@ -1,5 +1,12 @@ { "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "as-from-variant", + "as": "h2" + }, + "count": 1 + }, { "cardinality": { "action": "import-added" diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/expected.tsx index 40a9b568..57527eb0 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/expected.tsx +++ b/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/expected.tsx @@ -1,5 +1,5 @@ import { Text } from '@backstage/ui'; const MyComponent = () => ( - Label + Label ); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/metrics.json index df5928a0..7d2d5265 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/metrics.json +++ b/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/metrics.json @@ -1,5 +1,12 @@ { "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "as-from-variant", + "as": "p" + }, + "count": 1 + }, { "cardinality": { "action": "import-added" diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/expected.tsx index 51d1d112..e342171e 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/expected.tsx +++ b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/expected.tsx @@ -3,6 +3,6 @@ import { Text } from '@backstage/ui'; const MyComponent = () => ( <> {/* TODO(backstage-codemod): verify Text variant manually (gutterBottom) */} - Section Title + Section Title ); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/metrics.json index 00e25a87..e425373f 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/metrics.json +++ b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/metrics.json @@ -1,5 +1,12 @@ { "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "as-from-variant", + "as": "h4" + }, + "count": 1 + }, { "cardinality": { "action": "gutterBottom-dropped" diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/heading-h5-h6/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/heading-h5-h6/expected.tsx new file mode 100644 index 00000000..5482cb17 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/heading-h5-h6/expected.tsx @@ -0,0 +1,8 @@ +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + <> + Heading five + Heading six + +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/heading-h5-h6/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/heading-h5-h6/input.tsx new file mode 100644 index 00000000..8ce2c316 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/heading-h5-h6/input.tsx @@ -0,0 +1,8 @@ +import Typography from '@material-ui/core/Typography'; + +const MyComponent = () => ( + <> + Heading five + Heading six + +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/heading-h5-h6/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/heading-h5-h6/metrics.json new file mode 100644 index 00000000..589aed9e --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/heading-h5-h6/metrics.json @@ -0,0 +1,37 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "as-from-variant", + "as": "h5" + }, + "count": 1 + }, + { + "cardinality": { + "action": "as-from-variant", + "as": "h6" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "Typography" + }, + "count": 2 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/expected.tsx index e3ccbb60..43573c7a 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/expected.tsx +++ b/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/expected.tsx @@ -1,5 +1,5 @@ import { Text } from '@backstage/ui'; const PageTitle = () => ( - Welcome + Welcome ); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/metrics.json index df5928a0..f618ae13 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/metrics.json +++ b/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/metrics.json @@ -1,5 +1,12 @@ { "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "as-from-variant", + "as": "h1" + }, + "count": 1 + }, { "cardinality": { "action": "import-added" diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/expected.tsx index 74bb6926..53230f54 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/expected.tsx +++ b/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/expected.tsx @@ -4,6 +4,6 @@ import { Button, Text } from '@backstage/ui'; const MyComponent = () => ( <> - Small text + Small text ); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/metrics.json index 83d6a254..6f36bd84 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/metrics.json +++ b/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/metrics.json @@ -1,5 +1,12 @@ { "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "as-from-variant", + "as": "span" + }, + "count": 1 + }, { "cardinality": { "action": "import-merged" diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/expected.tsx index ed02782f..0b7c7347 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/expected.tsx +++ b/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/expected.tsx @@ -3,7 +3,7 @@ import { Text } from '@backstage/ui'; const MyComponent = () => ( <> - Hello + Hello ); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/metrics.json index df5928a0..7d2d5265 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/metrics.json +++ b/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/metrics.json @@ -1,5 +1,12 @@ { "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "as-from-variant", + "as": "p" + }, + "count": 1 + }, { "cardinality": { "action": "import-added" diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/expected.tsx index a87e431b..07f1d8cc 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/expected.tsx +++ b/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/expected.tsx @@ -1,7 +1,7 @@ import { Text } from '@backstage/ui'; const MyComponent = () => ( - + Click here for details. ); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/metrics.json index df5928a0..7d2d5265 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/metrics.json +++ b/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/metrics.json @@ -1,5 +1,12 @@ { "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "as-from-variant", + "as": "p" + }, + "count": 1 + }, { "cardinality": { "action": "import-added"