diff --git a/.changeset/mui-to-bui-complex-components.md b/.changeset/mui-to-bui-complex-components.md new file mode 100644 index 0000000..a837378 --- /dev/null +++ b/.changeset/mui-to-bui-complex-components.md @@ -0,0 +1,9 @@ +--- +'@backstage/migrate-mui-dialog-to-bui-dialog': minor +'@backstage/migrate-mui-tabs-to-bui-tabs': minor +'@backstage/migrate-mui-menu-popover-to-bui-menu': minor +'@backstage/migrate-mui-list-family-to-bui-list': minor +'@backstage/migrate-mui-chip-to-tag': minor +--- + +Add complex component codemods for the MUI 4 to BUI migration: Dialog, Tabs, Menu/Popover, List family, and Chip to Tag. diff --git a/README.md b/README.md index b200fef..f4f2b9e 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,16 @@ Run the [`migration-recipe`](./codemods/v1.51.0/migration-recipe) to apply every Older versions are available in the [`codemods/`](./codemods) directory. +### misc + +| Codemod | Description | +| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| [migrate-mui-chip-to-tag](./codemods/misc/migrate-mui-chip-to-tag) | MUI 4 to BUI: Replace Chip with Tag | +| [migrate-mui-dialog-to-bui-dialog](./codemods/misc/migrate-mui-dialog-to-bui-dialog) | MUI 4 to BUI: Replace Dialog shell with BUI Dialog | +| [migrate-mui-list-family-to-bui-list](./codemods/misc/migrate-mui-list-family-to-bui-list) | MUI 4 to BUI: Replace List family primitives with BUI List | +| [migrate-mui-menu-popover-to-bui-menu](./codemods/misc/migrate-mui-menu-popover-to-bui-menu) | MUI 4 to BUI: Replace Menu and Popover patterns with BUI Menu | +| [migrate-mui-tabs-to-bui-tabs](./codemods/misc/migrate-mui-tabs-to-bui-tabs) | MUI 4 to BUI: Replace MUI Tabs with BUI Tabs | + ## Usage diff --git a/codemods/misc/migrate-mui-chip-to-tag/CHANGELOG.md b/codemods/misc/migrate-mui-chip-to-tag/CHANGELOG.md new file mode 100644 index 0000000..d93d678 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-chip-to-tag diff --git a/codemods/misc/migrate-mui-chip-to-tag/codemod.yaml b/codemods/misc/migrate-mui-chip-to-tag/codemod.yaml new file mode 100644 index 0000000..ad01b87 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-chip-to-tag' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Chip with Tag' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'chip', 'tag'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-chip-to-tag/package.json b/codemods/misc/migrate-mui-chip-to-tag/package.json new file mode 100644 index 0000000..36191df --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-chip-to-tag", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Chip with Tag", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts b/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts new file mode 100644 index 0000000..c064513 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/scripts/codemod.ts @@ -0,0 +1,427 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-chip-to-tag') + +const BUI_SOURCE = '@backstage/ui' + +/** Props that indicate an interactive chip — not safe to auto-migrate. */ +const INTERACTIVE_PROPS = new Set(['onDelete', 'clickable', 'avatar', 'deleteIcon', 'onClick']) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): string | null { + const clause = imp.find({ rule: { kind: 'import_clause' } }) + if (!clause) { + return null + } + for (const child of clause.children()) { + if (child.is('identifier')) { + return child.text() + } + } + return null +} + +function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (importedNameNode?.text() === targetName) { + const localNameNode = identifiers[1] ?? importedNameNode + return localNameNode.text() + } + } + return null +} + +function collectChipImports(rootNode: SgNode): { + chipLocalName: string | null + importNodesToRemove: SgNode[] +} { + let chipLocalName: string | null = null + const importNodesToRemove: SgNode[] = [] + + // Default import: import Chip from '@material-ui/core/Chip' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Chip')) { + chipLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + // Named import from barrel: import { Chip } from '@material-ui/core' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + const localName = getNamedImportLocalName(imp, 'Chip') + if (localName) { + chipLocalName = localName + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (allSpecifiers.length <= 1) { + importNodesToRemove.push(imp) + } + } + } + + return { chipLocalName, importNodesToRemove } +} + +function addBuiImport( + rootNode: SgNode, + names: string[], + importNodesToRemove: SgNode[], + edits: Edit[], +): boolean { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + const sortedNames = [...names].sort() + + if (existingImport) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const existing = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + for (const name of names) { + if (!existing.includes(name)) { + existing.push(name) + } + } + existing.sort() + edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + return false + } + + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push( + anchorImport.replace(`${anchorImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + } else if (importNodesToRemove.length === 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } else if (importNodesToRemove.length > 0) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } + + migrationMetric.increment({ action: 'import-added' }) + return false +} + +function hasProp(opening: SgNode, propName: string): boolean { + return ( + opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) !== null + ) +} + +function getPropStringValue(opening: SgNode, propName: string): string | null { + const attr = opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) + if (!attr) { + return null + } + const stringNode = attr.find({ rule: { kind: 'string' } }) + if (stringNode) { + const frag = stringNode.find({ rule: { kind: 'string_fragment' } }) + return frag?.text() ?? null + } + return null +} + +function getPropRawValue(opening: SgNode, propName: string): string | null { + const attr = opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) + if (!attr) { + return null + } + for (const child of attr.children()) { + const kind = child.kind() + if (kind === 'string' || kind === 'jsx_expression') { + return child.text() + } + } + return null +} + +function isInteractiveChip(opening: SgNode): boolean { + for (const propName of INTERACTIVE_PROPS) { + if (hasProp(opening, propName)) { + return true + } + } + return false +} + +function getElementName(opening: SgNode): string | null { + for (const child of opening.children()) { + if (child.is('identifier') || child.is('member_expression')) { + return child.text() + } + } + return null +} + +interface ChipInfo { + element: SgNode + opening: SgNode + isInteractive: boolean + labelStr: string | null + labelRaw: string | null + sizeValue: string | null +} + +function analyzeChipElement(el: SgNode, chipLocalName: string): ChipInfo | null { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + return null + } + + const nameNode = getElementName(opening) + if (nameNode !== chipLocalName) { + return null + } + + return { + element: el, + opening, + isInteractive: isInteractiveChip(opening), + labelStr: getPropStringValue(opening, 'label'), + labelRaw: getPropRawValue(opening, 'label'), + sizeValue: getPropStringValue(opening, 'size'), + } +} + +function buildTagReplacement(info: ChipInfo): string { + const props: string[] = [] + if (info.sizeValue === 'small') { + props.push('size="small"') + } + const propsStr = props.length > 0 ? ` ${props.join(' ')}` : '' + + // Static string label + if (info.labelStr !== null) { + return `${info.labelStr}` + } + + // Dynamic label (JSX expression like {variable}) + if (info.labelRaw !== null) { + return `${info.labelRaw}` + } + + // No label prop — self-closing Tag + return `` +} + +function getNonWhitespaceJsxSiblings(parent: SgNode): SgNode[] { + const children: SgNode[] = [] + for (const child of parent.children()) { + const kind = child.kind() + if (kind === 'jsx_text' && child.text().trim().length === 0) { + continue + } + children.push(child) + } + return children +} + +function findConsecutiveChipGroupsForParent(parent: SgNode, chipLocalName: string): ChipInfo[][] { + const groups: ChipInfo[][] = [] + let current: ChipInfo[] = [] + + for (const sibling of getNonWhitespaceJsxSiblings(parent)) { + const info = analyzeChipElement(sibling, chipLocalName) + if (info && !info.isInteractive) { + current.push(info) + continue + } + + if (current.length >= 2) { + groups.push(current) + } + current = [] + } + + if (current.length >= 2) { + groups.push(current) + } + + return groups +} + +function transformChipElements( + rootNode: SgNode, + chipLocalName: string, + edits: Edit[], +): { needsTagGroup: boolean; preserveImport: boolean; migrated: boolean } { + let needsTagGroup = false + let preserveImport = false + let migrated = false + + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + // Collect chip info + const chipInfos: ChipInfo[] = [] + for (const el of jsxElements) { + const info = analyzeChipElement(el, chipLocalName) + if (info) { + chipInfos.push(info) + } + } + + // Group consecutive non-interactive chip siblings for TagGroup + const groupedElements = new Set() + const processedParents = new Set() + + for (const info of chipInfos) { + const parent = info.element.parent() + if (!parent || processedParents.has(parent.id())) { + continue + } + processedParents.add(parent.id()) + + for (const group of findConsecutiveChipGroupsForParent(parent, chipLocalName)) { + needsTagGroup = true + const tags = group.map((c) => buildTagReplacement(c)) + const tagGroupContent = tags.join('\n ') + const [firstChip] = group + if (!firstChip) { + continue + } + edits.push(firstChip.element.replace(`\n ${tagGroupContent}\n`)) + migrated = true + migrationMetric.increment({ action: 'tag-group-created', count: `${group.length}` }) + + for (let i = 1; i < group.length; i++) { + const chip = group[i] + if (chip) { + edits.push(chip.element.replace('')) + } + } + for (const c of group) { + groupedElements.add(c.element.id()) + } + } + } + + // Process remaining individual chips + for (const info of chipInfos) { + if (groupedElements.has(info.element.id())) { + continue + } + + if (info.isInteractive) { + preserveImport = true + edits.push( + info.element.replace( + `<>{/* TODO(backstage-codemod): verify interactive chip migration manually */}\n${info.element.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'interactive-chip' }) + continue + } + + edits.push(info.element.replace(buildTagReplacement(info))) + migrated = true + migrationMetric.increment({ action: 'chip-migrated' }) + } + + return { needsTagGroup, preserveImport, migrated } +} + +const transform: Codemod = (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { chipLocalName, importNodesToRemove } = collectChipImports(rootNode) + + if (!chipLocalName) { + return Promise.resolve(null) + } + + const { needsTagGroup, preserveImport, migrated } = transformChipElements(rootNode, chipLocalName, edits) + + let replacedImport = false + if (migrated) { + const importNames = ['Tag'] + if (needsTagGroup) { + importNames.push('TagGroup') + } + replacedImport = addBuiImport(rootNode, importNames, importNodesToRemove, edits) + } + + if (!preserveImport) { + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + } + + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) +} + +export default transform diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/expected.tsx new file mode 100644 index 0000000..75e3f46 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/expected.tsx @@ -0,0 +1,11 @@ +import { Tag, TagGroup } from '@backstage/ui'; + +const Tags = () => ( + <> + + A + B + + + +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/input.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/input.tsx new file mode 100644 index 0000000..f22d832 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/input.tsx @@ -0,0 +1,8 @@ +import Chip from '@material-ui/core/Chip'; + +const Tags = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/metrics.json new file mode 100644 index 0000000..4eb1d53 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-group/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-chip-to-tag": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tag-group-created", + "count": "2" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/expected.tsx new file mode 100644 index 0000000..0dc4524 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/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/chip-no-size/input.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/input.tsx new file mode 100644 index 0000000..be5fca8 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/input.tsx @@ -0,0 +1,5 @@ +import Chip from '@material-ui/core/Chip'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/metrics.json new file mode 100644 index 0000000..7eb48c8 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/chip-no-size/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-chip-to-tag": [ + { + "cardinality": { + "action": "chip-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/expected.tsx new file mode 100644 index 0000000..c13deb4 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/expected.tsx @@ -0,0 +1,6 @@ +import Chip from '@material-ui/core/Chip'; + +const MyComponent = () => ( + <>{/* TODO(backstage-codemod): verify interactive chip migration manually */} + navigate('/page')} /> +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/input.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/input.tsx new file mode 100644 index 0000000..8d9e104 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/input.tsx @@ -0,0 +1,5 @@ +import Chip from '@material-ui/core/Chip'; + +const MyComponent = () => ( + navigate('/page')} /> +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/metrics.json new file mode 100644 index 0000000..a4c7737 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/clickable-chip-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-chip-to-tag": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "interactive-chip" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/expected.tsx new file mode 100644 index 0000000..26404d4 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/expected.tsx @@ -0,0 +1,5 @@ +import { Tag } from '@backstage/ui'; + +const MyComponent = ({ name }: { name: string }) => ( + {name} +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/input.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/input.tsx new file mode 100644 index 0000000..abf3393 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/input.tsx @@ -0,0 +1,5 @@ +import Chip from '@material-ui/core/Chip'; + +const MyComponent = ({ name }: { name: string }) => ( + +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/metrics.json new file mode 100644 index 0000000..7eb48c8 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/dynamic-label/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-chip-to-tag": [ + { + "cardinality": { + "action": "chip-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/expected.tsx new file mode 100644 index 0000000..c763750 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/expected.tsx @@ -0,0 +1,6 @@ +import Chip from '@material-ui/core/Chip'; + +const MyComponent = () => ( + <>{/* TODO(backstage-codemod): verify interactive chip migration manually */} + handleDelete()} /> +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/input.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/input.tsx new file mode 100644 index 0000000..15b454c --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/input.tsx @@ -0,0 +1,5 @@ +import Chip from '@material-ui/core/Chip'; + +const MyComponent = () => ( + handleDelete()} /> +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/metrics.json new file mode 100644 index 0000000..a4c7737 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/interactive-chip-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-chip-to-tag": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "interactive-chip" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..791e1ee --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + +import { Button, Tag } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + Info + +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..d403957 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/input.tsx @@ -0,0 +1,9 @@ +import Chip from '@material-ui/core/Chip'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..6e13209 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/merge-existing-bui/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-chip-to-tag": [ + { + "cardinality": { + "action": "chip-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/expected.tsx new file mode 100644 index 0000000..523a91d --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/expected.tsx @@ -0,0 +1,5 @@ +import { Tag } from '@backstage/ui'; + +const MyComponent = () => ( + Tag +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/input.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/input.tsx new file mode 100644 index 0000000..2470087 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/input.tsx @@ -0,0 +1,5 @@ +import { Chip } from '@material-ui/core'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/metrics.json new file mode 100644 index 0000000..7eb48c8 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/named-import-barrel/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-chip-to-tag": [ + { + "cardinality": { + "action": "chip-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..0facfd0 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import { Tag } from '@backstage/ui'; + +const MyComponent = () => ( + Already migrated +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..0facfd0 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/noop-no-import/input.tsx @@ -0,0 +1,5 @@ +import { Tag } from '@backstage/ui'; + +const MyComponent = () => ( + Already migrated +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/expected.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/expected.tsx new file mode 100644 index 0000000..1740fe6 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/expected.tsx @@ -0,0 +1,5 @@ +import { Tag } from '@backstage/ui'; + +const MyComponent = () => ( + Category +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/input.tsx b/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/input.tsx new file mode 100644 index 0000000..f42114e --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/input.tsx @@ -0,0 +1,5 @@ +import Chip from '@material-ui/core/Chip'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/metrics.json b/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/metrics.json new file mode 100644 index 0000000..7eb48c8 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tests/simple-display-chip/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-chip-to-tag": [ + { + "cardinality": { + "action": "chip-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-chip-to-tag/tsconfig.json b/codemods/misc/migrate-mui-chip-to-tag/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-chip-to-tag/workflow.yaml b/codemods/misc/migrate-mui-chip-to-tag/workflow.yaml new file mode 100644 index 0000000..125bb48 --- /dev/null +++ b/codemods/misc/migrate-mui-chip-to-tag/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace Chip with Tag' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/CHANGELOG.md b/codemods/misc/migrate-mui-dialog-to-bui-dialog/CHANGELOG.md new file mode 100644 index 0000000..5542f2d --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-dialog-to-bui-dialog diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/codemod.yaml b/codemods/misc/migrate-mui-dialog-to-bui-dialog/codemod.yaml new file mode 100644 index 0000000..a726c05 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-dialog-to-bui-dialog' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Dialog shell with BUI Dialog' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'dialog', 'bui', 'dialog'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/package.json b/codemods/misc/migrate-mui-dialog-to-bui-dialog/package.json new file mode 100644 index 0000000..4466011 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-dialog-to-bui-dialog", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Dialog shell with BUI Dialog", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts b/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts new file mode 100644 index 0000000..6cca432 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/scripts/codemod.ts @@ -0,0 +1,502 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-dialog-to-bui-dialog') + +const BUI_SOURCE = '@backstage/ui' + +const COMPONENT_MAP: Record = { + DialogTitle: 'DialogHeader', + DialogContent: 'DialogBody', + DialogActions: 'DialogFooter', +} + +const TODO_PROPS = new Set([ + 'maxWidth', + 'fullWidth', + 'fullScreen', + 'scroll', + 'TransitionComponent', + 'TransitionProps', + 'transitionDuration', + 'PaperComponent', + 'PaperProps', + 'BackdropComponent', + 'BackdropProps', + 'classes', + 'disableEscapeKeyDown', + 'disableBackdropClick', + 'keepMounted', +]) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): string | null { + const clause = imp.find({ rule: { kind: 'import_clause' } }) + if (!clause) { + return null + } + for (const child of clause.children()) { + if (child.is('identifier')) { + return child.text() + } + } + return null +} + +function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (importedNameNode?.text() === targetName) { + const localNameNode = identifiers[1] ?? importedNameNode + return localNameNode.text() + } + } + return null +} + +const MUI_DIALOG_COMPONENTS = ['Dialog', 'DialogTitle', 'DialogContent', 'DialogActions'] + +interface DialogImports { + localNames: Map + importNodesToRemove: SgNode[] +} + +function collectDialogImports(rootNode: SgNode): DialogImports { + const localNames = new Map() + const importNodesToRemove: SgNode[] = [] + + for (const componentName of MUI_DIALOG_COMPONENTS) { + for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { + const name = getDefaultImportName(imp) + if (name) { + localNames.set(name, componentName) + } + importNodesToRemove.push(imp) + } + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + let foundCount = 0 + for (const componentName of MUI_DIALOG_COMPONENTS) { + const localName = getNamedImportLocalName(imp, componentName) + if (localName) { + localNames.set(localName, componentName) + foundCount++ + } + } + if (foundCount > 0) { + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (foundCount >= allSpecifiers.length) { + importNodesToRemove.push(imp) + } + } + } + + return { localNames, importNodesToRemove } +} + +function addBuiImport( + rootNode: SgNode, + names: string[], + importNodesToRemove: SgNode[], + edits: Edit[], +): boolean { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + const sortedNames = [...names].sort() + + if (existingImport) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const existing = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + for (const name of names) { + if (!existing.includes(name)) { + existing.push(name) + } + } + existing.sort() + edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + return false + } + + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push( + anchorImport.replace(`${anchorImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + } else if (importNodesToRemove.length === 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } else if (importNodesToRemove.length > 0) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } + + 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 getPropRawValue(opening: SgNode, propName: string): string | null { + const attr = getPropAttr(opening, propName) + if (!attr) { + return null + } + for (const child of attr.children()) { + const kind = child.kind() + if (kind === 'string' || kind === 'jsx_expression') { + return child.text() + } + } + return null +} + +function getSimpleOnCloseHandler(opening: SgNode): string | null { + const attr = getPropAttr(opening, 'onClose') + if (!attr) { + return null + } + const expr = attr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return null + } + const children: SgNode[] = [] + for (const child of expr.children()) { + if (child.kind() !== '{' && child.kind() !== '}') { + children.push(child) + } + } + const [onlyChild] = children + if (children.length === 1 && onlyChild?.is('identifier')) { + return onlyChild.text() + } + return null +} + +function getJsxChildren(element: SgNode): SgNode[] { + const children: SgNode[] = [] + for (const child of element.children()) { + const kind = child.kind() + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + children.push(child) + } + return children +} + +function getChildContent(element: SgNode): string { + return element + .children() + .filter((child) => child.kind() !== 'jsx_opening_element' && child.kind() !== 'jsx_closing_element') + .map((child) => child.text()) + .join('') +} + +function transformFooterCloseButtons(content: string, closeHandler: string | null): string { + if (!closeHandler) { + return content + } + + const buttonPattern = /]*)>([\s\S]*?)<\/button>/g + const onClickPattern = new RegExp(`onClick=\\{${escapeRegex(closeHandler)}\\}`) + + return content.replace(buttonPattern, (match: string, attrs: string, label: string) => { + if (!onClickPattern.test(attrs)) { + return match + } + migrationMetric.increment({ action: 'footer-close-button-migrated' }) + const extraAttrs = attrs.replace(onClickPattern, '').trim() + const attrStr = extraAttrs.length > 0 ? ` ${extraAttrs}` : '' + return `` + }) +} + +function transformDialogChildren( + dialogElement: SgNode, + localNames: Map, + closeHandler: string | null, +): string { + const children = getJsxChildren(dialogElement) + const parts: string[] = [] + + for (const child of children) { + const kind = child.kind() + + if (kind === 'jsx_text') { + parts.push(child.text()) + continue + } + + if (kind === 'jsx_element') { + const childOpening = child.child(0) + if (childOpening) { + const childName = getElementName(childOpening) + if (childName && localNames.has(childName)) { + const muiName = localNames.get(childName) + if (!muiName) { + continue + } + const buiName = COMPONENT_MAP[muiName] + if (buiName) { + let innerContent = getChildContent(child) + if (muiName === 'DialogActions') { + innerContent = transformFooterCloseButtons(innerContent, closeHandler) + } + parts.push(`<${buiName}>${innerContent}`) + migrationMetric.increment({ action: 'child-renamed', from: muiName, to: buiName }) + continue + } + } + } + } + + parts.push(child.text()) + } + + return parts.join('') +} + +function transformDialogElements( + rootNode: SgNode, + localNames: Map, + edits: Edit[], + buiNames: Set, +): { preserveImport: boolean; migrated: boolean } { + let preserveImport = false + let migrated = false + const dialogLocalName = [...localNames.entries()].find(([, v]) => v === 'Dialog')?.[0] + if (!dialogLocalName) { + return { preserveImport, migrated } + } + + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + for (const el of jsxElements) { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + continue + } + + const name = getElementName(opening) + if (name !== dialogLocalName) { + continue + } + + let needsTodo = false + const todoReasons: string[] = [] + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + needsTodo = true + todoReasons.push(prop) + } + } + + if (needsTodo) { + preserveImport = true + edits.push( + el.replace( + `<>{/* TODO(backstage-codemod): verify dialog width, dismiss behavior, or custom close logic manually (${todoReasons.join(', ')}) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: todoReasons.join(', ') }) + continue + } + + const newProps: string[] = [] + + const openValue = getPropRawValue(opening, 'open') + if (openValue) { + newProps.push(`isOpen=${openValue}`) + } + + const simpleHandler = getSimpleOnCloseHandler(opening) + let usesFooterCloseButton = false + if (simpleHandler) { + newProps.push(`onOpenChange={isOpen => !isOpen && ${simpleHandler}()}`) + migrationMetric.increment({ action: 'onClose-rewritten' }) + } else if (hasProp(opening, 'onClose')) { + preserveImport = true + edits.push( + el.replace( + `<>{/* TODO(backstage-codemod): verify dialog width, dismiss behavior, or custom close logic manually (complex-onClose) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-onClose' }) + continue + } + + const handledProps = new Set(['open', 'onClose']) + const allAttrs = opening.findAll({ rule: { kind: 'jsx_attribute' } }) + for (const attr of allAttrs) { + const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + const propName = propIdent.text() + if (handledProps.has(propName)) { + continue + } + newProps.push(attr.text()) + } + + const spreadAttrs = opening.findAll({ rule: { kind: 'jsx_expression' } }) + for (const spread of spreadAttrs) { + if (spread.text().startsWith('{...')) { + newProps.push(spread.text()) + } + } + + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + + if (isSelfClosing) { + edits.push(el.replace(``)) + } else { + const dialogActionsLocal = [...localNames.entries()].find(([, v]) => v === 'DialogActions')?.[0] + if (simpleHandler && dialogActionsLocal) { + const actionsElements = el.findAll({ + rule: { + kind: 'jsx_element', + has: { + kind: 'jsx_opening_element', + has: { + kind: 'identifier', + regex: `^${escapeRegex(dialogActionsLocal)}$`, + }, + }, + }, + }) + for (const actionsEl of actionsElements) { + const actionsContent = getChildContent(actionsEl) + if (actionsContent.includes(`onClick={${simpleHandler}}`)) { + usesFooterCloseButton = true + break + } + } + } + + const transformedChildren = transformDialogChildren(el, localNames, simpleHandler) + edits.push(el.replace(`${transformedChildren}`)) + + if (usesFooterCloseButton) { + buiNames.add('Button') + } + } + + migrationMetric.increment({ action: 'dialog-migrated' }) + migrated = true + } + + return { preserveImport, migrated } +} + +const transform: Codemod = (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { localNames, importNodesToRemove } = collectDialogImports(rootNode) + + if (localNames.size === 0) { + return Promise.resolve(null) + } + + const buiNames = new Set() + buiNames.add('Dialog') + for (const [, muiName] of localNames) { + const buiName = COMPONENT_MAP[muiName] + if (buiName) { + buiNames.add(buiName) + } + } + + const { preserveImport, migrated } = transformDialogElements(rootNode, localNames, edits, buiNames) + + let replacedImport = false + if (migrated) { + replacedImport = addBuiImport(rootNode, [...buiNames], importNodesToRemove, edits) + } + + if (!preserveImport) { + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + } + + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) +} + +export default transform diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/expected.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/expected.tsx new file mode 100644 index 0000000..c713c40 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/expected.tsx @@ -0,0 +1,11 @@ +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; + +const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + <>{/* TODO(backstage-codemod): verify dialog width, dismiss behavior, or custom close logic manually (complex-onClose) */} + { cleanup(); onClose(); }}> + Complex + Body + +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/input.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/input.tsx new file mode 100644 index 0000000..7e6e13c --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/input.tsx @@ -0,0 +1,10 @@ +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; + +const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + { cleanup(); onClose(); }}> + Complex + Body + +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/metrics.json b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/metrics.json new file mode 100644 index 0000000..116db1e --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/complex-on-close-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-dialog-to-bui-dialog": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "complex-onClose" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/expected.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/expected.tsx new file mode 100644 index 0000000..3ba106d --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/expected.tsx @@ -0,0 +1,8 @@ +import { Button, Dialog, DialogBody, DialogFooter, DialogHeader } from '@backstage/ui'; + + + + +const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + !isOpen && onClose()}>ConfirmAre you sure? +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/input.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/input.tsx new file mode 100644 index 0000000..5109073 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/input.tsx @@ -0,0 +1,14 @@ +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogActions from '@material-ui/core/DialogActions'; + +const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + + Confirm + Are you sure? + + + + +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/metrics.json b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/metrics.json new file mode 100644 index 0000000..c41d48b --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/controlled-dialog/metrics.json @@ -0,0 +1,58 @@ +{ + "migrate-mui-dialog-to-bui-dialog": [ + { + "cardinality": { + "action": "child-renamed", + "from": "DialogActions", + "to": "DialogFooter" + }, + "count": 1 + }, + { + "cardinality": { + "action": "child-renamed", + "from": "DialogContent", + "to": "DialogBody" + }, + "count": 1 + }, + { + "cardinality": { + "action": "child-renamed", + "from": "DialogTitle", + "to": "DialogHeader" + }, + "count": 1 + }, + { + "cardinality": { + "action": "dialog-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "footer-close-button-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 4 + }, + { + "cardinality": { + "action": "onClose-rewritten" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/expected.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/expected.tsx new file mode 100644 index 0000000..2723a07 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/expected.tsx @@ -0,0 +1,11 @@ +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; + +const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + <>{/* TODO(backstage-codemod): verify dialog width, dismiss behavior, or custom close logic manually (maxWidth, fullWidth) */} + + Wide + Content + +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/input.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/input.tsx new file mode 100644 index 0000000..8c5ae79 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/input.tsx @@ -0,0 +1,10 @@ +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; + +const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + + Wide + Content + +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/metrics.json b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/metrics.json new file mode 100644 index 0000000..ac15c0a --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/full-width-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-dialog-to-bui-dialog": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "maxWidth, fullWidth" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..a09d747 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,8 @@ + + + +import { Button, Dialog, DialogBody, DialogHeader } from '@backstage/ui'; + +const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + !isOpen && onClose()}>Action +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..6670583 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/input.tsx @@ -0,0 +1,11 @@ +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; +import { Button } from '@backstage/ui'; + +const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + + Action + + +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..049a717 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/merge-existing-bui/metrics.json @@ -0,0 +1,44 @@ +{ + "migrate-mui-dialog-to-bui-dialog": [ + { + "cardinality": { + "action": "child-renamed", + "from": "DialogContent", + "to": "DialogBody" + }, + "count": 1 + }, + { + "cardinality": { + "action": "child-renamed", + "from": "DialogTitle", + "to": "DialogHeader" + }, + "count": 1 + }, + { + "cardinality": { + "action": "dialog-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "onClose-rewritten" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..30d66ac --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/expected.tsx @@ -0,0 +1,5 @@ +import { Dialog, DialogBody, DialogHeader } from '@backstage/ui'; + +const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + !isOpen && onClose()}>InfoDetails here +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..193d2cf --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/input.tsx @@ -0,0 +1,8 @@ +import { Dialog, DialogTitle, DialogContent } from '@material-ui/core'; + +const MyDialog = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + + Info + Details here + +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..de9d374 --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/named-barrel-import/metrics.json @@ -0,0 +1,44 @@ +{ + "migrate-mui-dialog-to-bui-dialog": [ + { + "cardinality": { + "action": "child-renamed", + "from": "DialogContent", + "to": "DialogBody" + }, + "count": 1 + }, + { + "cardinality": { + "action": "child-renamed", + "from": "DialogTitle", + "to": "DialogHeader" + }, + "count": 1 + }, + { + "cardinality": { + "action": "dialog-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onClose-rewritten" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..3092c4f --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/noop-no-import/expected.tsx @@ -0,0 +1,7 @@ +import { Dialog, DialogHeader } from '@backstage/ui'; + +const MyDialog = () => ( + + Already migrated + +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..3092c4f --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tests/noop-no-import/input.tsx @@ -0,0 +1,7 @@ +import { Dialog, DialogHeader } from '@backstage/ui'; + +const MyDialog = () => ( + + Already migrated + +); diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/tsconfig.json b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-dialog-to-bui-dialog/workflow.yaml b/codemods/misc/migrate-mui-dialog-to-bui-dialog/workflow.yaml new file mode 100644 index 0000000..4c7ce3e --- /dev/null +++ b/codemods/misc/migrate-mui-dialog-to-bui-dialog/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace Dialog shell with BUI Dialog' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/CHANGELOG.md b/codemods/misc/migrate-mui-list-family-to-bui-list/CHANGELOG.md new file mode 100644 index 0000000..725959b --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-list-family-to-bui-list diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/codemod.yaml b/codemods/misc/migrate-mui-list-family-to-bui-list/codemod.yaml new file mode 100644 index 0000000..ab002e6 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-list-family-to-bui-list' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace List family primitives with BUI List' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'list', 'family', 'bui', 'list'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/package.json b/codemods/misc/migrate-mui-list-family-to-bui-list/package.json new file mode 100644 index 0000000..0540b6f --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-list-family-to-bui-list", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace List family primitives with BUI List", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/scripts/codemod.ts b/codemods/misc/migrate-mui-list-family-to-bui-list/scripts/codemod.ts new file mode 100644 index 0000000..610beca --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/scripts/codemod.ts @@ -0,0 +1,528 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-list-family-to-bui-list') + +const BUI_SOURCE = '@backstage/ui' + +const MUI_LIST_COMPONENTS = [ + 'List', + 'ListItem', + 'ListItemIcon', + 'ListItemText', + 'ListItemAvatar', + 'ListItemSecondaryAction', + 'ListSubheader', +] + +/** Props on ListItem that indicate complexity beyond a simple row. */ +const TODO_PROPS = new Set([ + 'button', + 'selected', + 'dense', + 'disableGutters', + 'divider', + 'alignItems', + 'ContainerComponent', + 'ContainerProps', + 'component', + 'classes', +]) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): string | null { + const clause = imp.find({ rule: { kind: 'import_clause' } }) + if (!clause) { + return null + } + for (const child of clause.children()) { + if (child.is('identifier')) { + return child.text() + } + } + return null +} + +function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (importedNameNode?.text() === targetName) { + const localNameNode = identifiers[1] ?? importedNameNode + return localNameNode.text() + } + } + return null +} + +interface ListImports { + localNames: Map + importNodesToRemove: SgNode[] +} + +function collectListImports(rootNode: SgNode): ListImports { + const localNames = new Map() + const importNodesToRemove: SgNode[] = [] + + for (const componentName of MUI_LIST_COMPONENTS) { + for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { + const name = getDefaultImportName(imp) + if (name) { + localNames.set(name, componentName) + } + importNodesToRemove.push(imp) + } + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + let foundCount = 0 + for (const componentName of MUI_LIST_COMPONENTS) { + const localName = getNamedImportLocalName(imp, componentName) + if (localName) { + localNames.set(localName, componentName) + foundCount++ + } + } + if (foundCount > 0) { + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (foundCount >= allSpecifiers.length) { + importNodesToRemove.push(imp) + } + } + } + + return { localNames, importNodesToRemove } +} + +function addBuiImport( + rootNode: SgNode, + names: string[], + importNodesToRemove: SgNode[], + edits: Edit[], +): boolean { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + const sortedNames = [...names].sort() + + if (existingImport) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const existing = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + for (const name of names) { + if (!existing.includes(name)) { + existing.push(name) + } + } + existing.sort() + edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + return false + } + + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push( + anchorImport.replace(`${anchorImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + } else if (importNodesToRemove.length === 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } else if (importNodesToRemove.length > 0) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } + + 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 null +} + +function getNonWhitespaceChildren(element: SgNode): SgNode[] { + const children: SgNode[] = [] + for (const child of element.children()) { + const kind = child.kind() + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + if (kind === 'jsx_text' && child.text().trim().length === 0) { + continue + } + children.push(child) + } + return children +} + +function formatIconProp(iconContent: string): string { + const trimmed = iconContent.trim() + if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + return `icon=${trimmed}` + } + return `icon={${trimmed}}` +} + +interface ListItemAnalysis { + iconContent: string | null + primaryText: string | null + primaryRaw: string | null + secondaryText: string | null + secondaryRaw: string | null + hasComplexContent: boolean + hasTodoProps: boolean +} + +function analyzeListItem(el: SgNode, localNames: Map): ListItemAnalysis { + const result: ListItemAnalysis = { + iconContent: null, + primaryText: null, + primaryRaw: null, + secondaryText: null, + secondaryRaw: null, + hasComplexContent: false, + hasTodoProps: false, + } + + const opening = el.child(0) + if (!opening) { + return result + } + + // Check for TODO-triggering props on ListItem + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + result.hasTodoProps = true + return result + } + } + + // Check for onClick — interactive list items need TODO + if (hasProp(opening, 'onClick')) { + result.hasTodoProps = true + return result + } + + const children = getNonWhitespaceChildren(el) + + const listItemIconLocal = [...localNames.entries()].find(([, v]) => v === 'ListItemIcon')?.[0] ?? null + const listItemTextLocal = [...localNames.entries()].find(([, v]) => v === 'ListItemText')?.[0] ?? null + const listItemAvatarLocal = [...localNames.entries()].find(([, v]) => v === 'ListItemAvatar')?.[0] ?? null + const listItemSecondaryActionLocal = + [...localNames.entries()].find(([, v]) => v === 'ListItemSecondaryAction')?.[0] ?? null + + for (const child of children) { + const kind = child.kind() + + if (kind === 'jsx_text') { + const trimmed = child.text().trim() + if (trimmed.length > 0) { + result.hasComplexContent = true + } + continue + } + + if (kind === 'jsx_element' || kind === 'jsx_self_closing_element') { + const childOpening = kind === 'jsx_self_closing_element' ? child : child.child(0) + if (!childOpening) { + result.hasComplexContent = true + continue + } + + const childName = getElementName(childOpening) + + // ListItemIcon + if (childName && childName === listItemIconLocal) { + if (kind === 'jsx_element') { + const iconChildren = getNonWhitespaceChildren(child) + const [iconChild] = iconChildren + if (iconChild) { + result.iconContent = iconChild.text() + } else { + result.hasComplexContent = true + } + } + continue + } + + // ListItemAvatar — complex, TODO + if (childName && childName === listItemAvatarLocal) { + result.hasComplexContent = true + continue + } + + // ListItemSecondaryAction — complex, TODO + if (childName && childName === listItemSecondaryActionLocal) { + result.hasComplexContent = true + continue + } + + // ListItemText + if (childName && childName === listItemTextLocal) { + const primaryStr = getPropStringValue(childOpening, 'primary') + const primaryRaw = getPropRawValue(childOpening, 'primary') + const secondaryStr = getPropStringValue(childOpening, 'secondary') + const secondaryRaw = getPropRawValue(childOpening, 'secondary') + + if (primaryStr !== null) { + result.primaryText = primaryStr + } else if (primaryRaw !== null) { + result.primaryRaw = primaryRaw + } + + if (secondaryStr !== null) { + result.secondaryText = secondaryStr + } else if (secondaryRaw !== null) { + result.secondaryRaw = secondaryRaw + } + + // If ListItemText has children instead of primary prop, it's complex + if (primaryStr === null && primaryRaw === null && kind === 'jsx_element') { + const textChildren = getNonWhitespaceChildren(child) + if (textChildren.length > 0) { + result.hasComplexContent = true + } + } + continue + } + + // Unknown child element — mark complex + result.hasComplexContent = true + continue + } + + if (kind === 'jsx_expression') { + result.hasComplexContent = true + continue + } + } + + return result +} + +function transformListElements( + rootNode: SgNode, + localNames: Map, + edits: Edit[], +): { preserveImport: boolean; migrated: boolean; buiNames: Set } { + let preserveImport = false + let migrated = false + const buiNames = new Set() + + const listLocalName = [...localNames.entries()].find(([, v]) => v === 'List')?.[0] ?? null + const listItemLocalName = [...localNames.entries()].find(([, v]) => v === 'ListItem')?.[0] ?? null + + if (!listLocalName && !listItemLocalName) { + return { preserveImport, migrated, buiNames } + } + + const jsxElements = rootNode + .findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + .sort((a, b) => { + const rangeA = a.range() + const rangeB = b.range() + return rangeA.end.index - rangeA.start.index - (rangeB.end.index - rangeB.start.index) + }) + + for (const el of jsxElements) { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + continue + } + + const name = getElementName(opening) + if (!name) { + continue + } + + const muiName = localNames.get(name) + + // Transform ListItem → ListRow (BUI also uses — leave List wrapper unchanged) + if (muiName === 'ListItem') { + if (isSelfClosing) { + edits.push(el.replace('')) + buiNames.add('ListRow') + if (listLocalName) { + buiNames.add('List') + } + migrated = true + migrationMetric.increment({ action: 'list-item-migrated' }) + continue + } + + const analysis = analyzeListItem(el, localNames) + + if (analysis.hasTodoProps || analysis.hasComplexContent) { + preserveImport = true + edits.push( + el.replace(`<>{/* TODO(backstage-codemod): verify nonstandard list row manually */}\n${el.text()}`), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-list-item' }) + continue + } + + // Build ListRow props + const props: string[] = [] + + if (analysis.iconContent) { + props.push(formatIconProp(analysis.iconContent)) + } + + if (analysis.secondaryText !== null) { + props.push(`description="${analysis.secondaryText}"`) + } else if (analysis.secondaryRaw !== null) { + props.push(`description=${analysis.secondaryRaw}`) + } + + const propsStr = props.length > 0 ? ` ${props.join(' ')}` : '' + + // Primary text becomes children + let children = '' + if (analysis.primaryText !== null) { + children = analysis.primaryText + } else if (analysis.primaryRaw !== null) { + children = analysis.primaryRaw + } + + if (children) { + edits.push(el.replace(`${children}`)) + } else { + edits.push(el.replace(``)) + } + + buiNames.add('ListRow') + if (listLocalName) { + buiNames.add('List') + } + migrated = true + migrationMetric.increment({ action: 'list-item-migrated' }) + continue + } + } + + return { preserveImport, migrated, buiNames } +} + +const transform: Codemod = (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { localNames, importNodesToRemove } = collectListImports(rootNode) + + if (localNames.size === 0) { + return Promise.resolve(null) + } + + const { preserveImport, migrated, buiNames } = transformListElements(rootNode, localNames, edits) + + let replacedImport = false + if (migrated) { + replacedImport = addBuiImport(rootNode, [...buiNames], importNodesToRemove, edits) + } + + if (!preserveImport) { + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + } + + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) +} + +export default transform diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/expected.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/expected.tsx new file mode 100644 index 0000000..75336d7 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/expected.tsx @@ -0,0 +1,12 @@ +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemText from '@material-ui/core/ListItemText'; + +const MyComponent = () => ( + + <>{/* TODO(backstage-codemod): verify nonstandard list row manually */} + + + + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/input.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/input.tsx new file mode 100644 index 0000000..29939a3 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/input.tsx @@ -0,0 +1,11 @@ +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemText from '@material-ui/core/ListItemText'; + +const MyComponent = () => ( + + + + + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/metrics.json b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/metrics.json new file mode 100644 index 0000000..3f2c61a --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/interactive-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-list-family-to-bui-list": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "complex-list-item" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..07d9b54 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,13 @@ + + + +import { Button, List, ListRow } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + Item + + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..e535feb --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/input.tsx @@ -0,0 +1,15 @@ +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemText from '@material-ui/core/ListItemText'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + + + + + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..8dfc4e8 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/merge-existing-bui/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-list-family-to-bui-list": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "list-item-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..72da391 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/expected.tsx @@ -0,0 +1,7 @@ +import { List, ListRow } from '@backstage/ui'; + +const MyComponent = () => ( + + } description="Your favorites">Starred + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..13b6ad8 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/input.tsx @@ -0,0 +1,10 @@ +import { List, ListItem, ListItemIcon, ListItemText } from '@material-ui/core'; + +const MyComponent = () => ( + + + + + + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..9124d8b --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/named-barrel-import/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-list-family-to-bui-list": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "list-item-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..cf6e9fd --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/noop-no-import/expected.tsx @@ -0,0 +1,7 @@ +import { List, ListRow } from '@backstage/ui'; + +const MyComponent = () => ( + + Already migrated + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..cf6e9fd --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/noop-no-import/input.tsx @@ -0,0 +1,7 @@ +import { List, ListRow } from '@backstage/ui'; + +const MyComponent = () => ( + + Already migrated + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/expected.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/expected.tsx new file mode 100644 index 0000000..1a01d9e --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/expected.tsx @@ -0,0 +1,10 @@ +import { List, ListRow } from '@backstage/ui'; + + + + +const MyComponent = () => ( + + } description="Read the docs">Docs + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/input.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/input.tsx new file mode 100644 index 0000000..60c2df2 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/input.tsx @@ -0,0 +1,13 @@ +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; + +const MyComponent = () => ( + + + + + + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/metrics.json b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/metrics.json new file mode 100644 index 0000000..39ee381 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/simple-list-with-icon/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-list-family-to-bui-list": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 4 + }, + { + "cardinality": { + "action": "list-item-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/expected.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/expected.tsx new file mode 100644 index 0000000..2f870aa --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/expected.tsx @@ -0,0 +1,9 @@ +import { List, ListRow } from '@backstage/ui'; + + + +const MyComponent = () => ( + + Settings + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/input.tsx b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/input.tsx new file mode 100644 index 0000000..60f213f --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/input.tsx @@ -0,0 +1,11 @@ +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemText from '@material-ui/core/ListItemText'; + +const MyComponent = () => ( + + + + + +); diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/metrics.json b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/metrics.json new file mode 100644 index 0000000..41f3640 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tests/text-only-no-icon/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-list-family-to-bui-list": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "list-item-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/tsconfig.json b/codemods/misc/migrate-mui-list-family-to-bui-list/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-list-family-to-bui-list/workflow.yaml b/codemods/misc/migrate-mui-list-family-to-bui-list/workflow.yaml new file mode 100644 index 0000000..0ee2211 --- /dev/null +++ b/codemods/misc/migrate-mui-list-family-to-bui-list/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace List family primitives with BUI List' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/CHANGELOG.md b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/CHANGELOG.md new file mode 100644 index 0000000..a1a7eb4 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-menu-popover-to-bui-menu diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/codemod.yaml b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/codemod.yaml new file mode 100644 index 0000000..fefb020 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-menu-popover-to-bui-menu' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Menu and Popover patterns with BUI Menu' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'menu', 'popover', 'bui', 'menu'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/package.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/package.json new file mode 100644 index 0000000..775cb2c --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-menu-popover-to-bui-menu", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Menu and Popover patterns with BUI Menu", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts new file mode 100644 index 0000000..315204e --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/scripts/codemod.ts @@ -0,0 +1,602 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-menu-popover-to-bui-menu') + +const BUI_SOURCE = '@backstage/ui' + +const MUI_MENU_COMPONENTS = ['Menu', 'MenuItem', 'MenuList', 'Popover'] + +/** Props on Menu/Popover that trigger a TODO — not mechanically migratable. */ +const TODO_PROPS = new Set([ + 'anchorOrigin', + 'transformOrigin', + 'getContentAnchorEl', + 'TransitionComponent', + 'TransitionProps', + 'transitionDuration', + 'PaperProps', + 'PopoverClasses', + 'classes', + 'disableAutoFocusItem', + 'MenuListProps', + 'elevation', + 'marginThreshold', + 'container', + 'disablePortal', + 'disableScrollLock', +]) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): string | null { + const clause = imp.find({ rule: { kind: 'import_clause' } }) + if (!clause) { + return null + } + for (const child of clause.children()) { + if (child.is('identifier')) { + return child.text() + } + } + return null +} + +function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (importedNameNode?.text() === targetName) { + const localNameNode = identifiers[1] ?? importedNameNode + return localNameNode.text() + } + } + return null +} + +interface MenuImports { + localNames: Map + importNodesToRemove: SgNode[] +} + +function collectMenuImports(rootNode: SgNode): MenuImports { + const localNames = new Map() + const importNodesToRemove: SgNode[] = [] + + for (const componentName of MUI_MENU_COMPONENTS) { + for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { + const name = getDefaultImportName(imp) + if (name) { + localNames.set(name, componentName) + } + importNodesToRemove.push(imp) + } + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + let foundCount = 0 + for (const componentName of MUI_MENU_COMPONENTS) { + const localName = getNamedImportLocalName(imp, componentName) + if (localName) { + localNames.set(localName, componentName) + foundCount++ + } + } + if (foundCount > 0) { + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (foundCount >= allSpecifiers.length) { + importNodesToRemove.push(imp) + } + } + } + + return { localNames, importNodesToRemove } +} + +function addBuiImport( + rootNode: SgNode, + names: string[], + importNodesToRemove: SgNode[], + edits: Edit[], +): boolean { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + const sortedNames = [...names].sort() + + if (existingImport) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const existing = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + for (const name of names) { + if (!existing.includes(name)) { + existing.push(name) + } + } + existing.sort() + edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + return false + } + + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push( + anchorImport.replace(`${anchorImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + } else if (importNodesToRemove.length === 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } else if (importNodesToRemove.length > 0) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } + + 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 getPropRawValue(opening: SgNode, propName: string): string | null { + const attr = getPropAttr(opening, propName) + if (!attr) { + return null + } + for (const child of attr.children()) { + const kind = child.kind() + if (kind === 'string' || kind === 'jsx_expression') { + return child.text() + } + } + return null +} + +function getSimpleOnCloseHandler(opening: SgNode): string | null { + const attr = getPropAttr(opening, 'onClose') + if (!attr) { + return null + } + const expr = attr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return null + } + const children: SgNode[] = [] + for (const child of expr.children()) { + if (child.kind() !== '{' && child.kind() !== '}') { + children.push(child) + } + } + const [onlyChild] = children + if (children.length === 1 && onlyChild?.is('identifier')) { + return onlyChild.text() + } + return null +} + +function getChildContent(element: SgNode): string { + return element + .children() + .filter((child) => child.kind() !== 'jsx_opening_element' && child.kind() !== 'jsx_closing_element') + .map((child) => child.text()) + .join('') +} + +function getJsxChildren(element: SgNode): SgNode[] { + const children: SgNode[] = [] + for (const child of element.children()) { + const kind = child.kind() + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + children.push(child) + } + return children +} + +function getNonWhitespaceChildren(element: SgNode): SgNode[] { + const children: SgNode[] = [] + for (const child of element.children()) { + const kind = child.kind() + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + if (kind === 'jsx_text' && child.text().trim().length === 0) { + continue + } + children.push(child) + } + return children +} + +function isTriggerElement(node: SgNode): boolean { + const kind = node.kind() + if (kind !== 'jsx_element' && kind !== 'jsx_self_closing_element') { + return false + } + + const opening = kind === 'jsx_self_closing_element' ? node : node.child(0) + if (!opening) { + return false + } + + const name = getElementName(opening) + if (!name) { + return false + } + + if (/Button$/i.test(name) || name === 'button') { + return true + } + + return hasProp(opening, 'onClick') +} + +function findTriggerSibling(menuEl: SgNode): SgNode | null { + const parent = menuEl.parent() + if (!parent) { + return null + } + + const parentKind = parent.kind() + if (parentKind !== 'jsx_element' && parentKind !== 'jsx_fragment') { + return null + } + + let previousTrigger: SgNode | null = null + for (const sibling of getNonWhitespaceChildren(parent)) { + if (sibling.id() === menuEl.id()) { + return previousTrigger + } + if (isTriggerElement(sibling)) { + previousTrigger = sibling + } + } + + return null +} + +function transformMenuItemChildren(element: SgNode, menuItemLocalName: string): string { + const children = getJsxChildren(element) + const parts: string[] = [] + + for (const child of children) { + const kind = child.kind() + + if (kind === 'jsx_text') { + parts.push(child.text()) + continue + } + + if (kind === 'jsx_element' || kind === 'jsx_self_closing_element') { + const childOpening = kind === 'jsx_self_closing_element' ? child : child.child(0) + if (!childOpening) { + parts.push(child.text()) + continue + } + + const childName = getElementName(childOpening) + if (childName === menuItemLocalName) { + const onClickAttr = getPropAttr(childOpening, 'onClick') + + const newProps: string[] = [] + + // Map onClick → onAction + if (onClickAttr) { + for (const attrChild of onClickAttr.children()) { + const attrKind = attrChild.kind() + if (attrKind === 'string' || attrKind === 'jsx_expression') { + newProps.push(`onAction=${attrChild.text()}`) + break + } + } + migrationMetric.increment({ action: 'onClick-to-onAction' }) + } + + // Preserve other safe props + const handledProps = new Set(['onClick']) + const allAttrs = childOpening.findAll({ rule: { kind: 'jsx_attribute' } }) + for (const attr of allAttrs) { + const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + if (handledProps.has(propIdent.text())) { + continue + } + newProps.push(attr.text()) + } + + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + + if (kind === 'jsx_self_closing_element') { + parts.push(``) + } else { + const innerContent = getChildContent(child) + parts.push(`${innerContent}`) + } + migrationMetric.increment({ action: 'menu-item-migrated' }) + continue + } + } + + // Preserve anything else as-is + parts.push(child.text()) + } + + return parts.join('') +} + +/** + * Unwrap MenuList if it's the only structural child inside a Popover/Menu. + * Returns the inner content of the MenuList, or the original content if no MenuList wrapper. + */ +function unwrapMenuList( + element: SgNode, + menuListLocalName: string | null, + menuItemLocalName: string | null, +): string { + if (!menuListLocalName) { + if (menuItemLocalName) { + return transformMenuItemChildren(element, menuItemLocalName) + } + return getChildContent(element) + } + + const meaningfulChildren = getNonWhitespaceChildren(element) + + // If the only meaningful child is a MenuList, unwrap it + if (meaningfulChildren.length === 1) { + const [onlyChild] = meaningfulChildren + if (onlyChild?.kind() === 'jsx_element') { + const childOpening = onlyChild.child(0) + if (childOpening) { + const childName = getElementName(childOpening) + if (childName === menuListLocalName) { + migrationMetric.increment({ action: 'menu-list-unwrapped' }) + if (menuItemLocalName) { + return transformMenuItemChildren(onlyChild, menuItemLocalName) + } + return getChildContent(onlyChild) + } + } + } + } + + // No unwrapping needed — transform menu items directly + if (menuItemLocalName) { + return transformMenuItemChildren(element, menuItemLocalName) + } + return getChildContent(element) +} + +function transformMenuElements( + rootNode: SgNode, + localNames: Map, + edits: Edit[], +): { preserveImport: boolean; migrated: boolean; buiNames: Set } { + let preserveImport = false + let migrated = false + const buiNames = new Set() + const menuListLocalName = [...localNames.entries()].find(([, v]) => v === 'MenuList')?.[0] ?? null + const menuItemLocalName = [...localNames.entries()].find(([, v]) => v === 'MenuItem')?.[0] ?? null + + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + for (const el of jsxElements) { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + continue + } + + const name = getElementName(opening) + if (!name) { + continue + } + + const muiName = localNames.get(name) + if (!muiName) { + continue + } + + // Only process top-level Menu / Popover elements (skip MenuItem — handled inline) + if (muiName !== 'Menu' && muiName !== 'Popover') { + continue + } + + // Check for TODO-triggering props + let needsTodo = false + const todoReasons: string[] = [] + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + needsTodo = true + todoReasons.push(prop) + } + } + + // anchorEl implies positional control — always TODO + if (hasProp(opening, 'anchorEl')) { + needsTodo = true + if (!todoReasons.includes('anchorEl')) { + todoReasons.push('anchorEl') + } + } + + if (needsTodo) { + preserveImport = true + edits.push( + el.replace( + `<>{/* TODO(backstage-codemod): finish menu host migration manually (${todoReasons.join(', ')}) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: todoReasons.join(', ') }) + continue + } + + if (isSelfClosing) { + edits.push(el.replace('')) + buiNames.add('Menu') + migrated = true + migrationMetric.increment({ action: 'menu-migrated', variant: 'self-closing' }) + continue + } + + const hasControlledState = hasProp(opening, 'open') || hasProp(opening, 'onClose') + let triggerSibling: SgNode | null = null + if (hasControlledState) { + triggerSibling = findTriggerSibling(el) + if (!triggerSibling) { + preserveImport = true + edits.push( + el.replace( + `<>{/* TODO(backstage-codemod): finish menu host migration manually (no-trigger-element) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'no-trigger-element' }) + continue + } + + if (hasProp(opening, 'onClose') && !getSimpleOnCloseHandler(opening)) { + preserveImport = true + edits.push( + el.replace( + `<>{/* TODO(backstage-codemod): finish menu host migration manually (complex-onClose) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-onClose' }) + continue + } + } + + // Transform children: unwrap MenuList, convert MenuItems + const innerContent = unwrapMenuList(el, menuListLocalName, menuItemLocalName) + let menuOutput = `${innerContent}` + + 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 triggerPropsStr = triggerProps.length > 0 ? ` ${triggerProps.join(' ')}` : '' + menuOutput = `${triggerSibling.text()}${menuOutput}` + edits.push(triggerSibling.replace('')) + buiNames.add('MenuTrigger') + migrationMetric.increment({ action: 'menu-trigger-wrapped' }) + } + + buiNames.add('Menu') + if (menuItemLocalName) { + buiNames.add('MenuItem') + } + edits.push(el.replace(menuOutput)) + migrated = true + migrationMetric.increment({ action: 'menu-migrated', variant: muiName === 'Popover' ? 'popover' : 'menu' }) + } + + return { preserveImport, migrated, buiNames } +} + +const transform: Codemod = (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { localNames, importNodesToRemove } = collectMenuImports(rootNode) + + if (localNames.size === 0) { + return Promise.resolve(null) + } + + const { preserveImport, migrated, buiNames } = transformMenuElements(rootNode, localNames, edits) + + let replacedImport = false + if (migrated) { + replacedImport = addBuiImport(rootNode, [...buiNames], importNodesToRemove, edits) + } + + if (!preserveImport) { + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + } + + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) +} + +export default transform diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/expected.tsx new file mode 100644 index 0000000..8061dcc --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/expected.tsx @@ -0,0 +1,9 @@ +import Menu from '@material-ui/core/Menu'; +import MenuItem from '@material-ui/core/MenuItem'; + +const MyComponent = ({ anchorEl, open, onClose }: any) => ( + <>{/* TODO(backstage-codemod): finish menu host migration manually (anchorEl) */} + + Action + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/input.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/input.tsx new file mode 100644 index 0000000..9aa630e --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/input.tsx @@ -0,0 +1,8 @@ +import Menu from '@material-ui/core/Menu'; +import MenuItem from '@material-ui/core/MenuItem'; + +const MyComponent = ({ anchorEl, open, onClose }: any) => ( + + Action + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/metrics.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/metrics.json new file mode 100644 index 0000000..775b2bc --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/anchor-el-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-menu-popover-to-bui-menu": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "anchorEl" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..f81b64e --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,10 @@ + + +import { Button, Menu, MenuItem, MenuTrigger } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + Action + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..e90a8f2 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/input.tsx @@ -0,0 +1,12 @@ +import Menu from '@material-ui/core/Menu'; +import MenuItem from '@material-ui/core/MenuItem'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + Action + + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..1e3c553 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/merge-existing-bui/metrics.json @@ -0,0 +1,41 @@ +{ + "migrate-mui-menu-popover-to-bui-menu": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 2 + }, + { + "cardinality": { + "action": "menu-item-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "menu-migrated", + "variant": "menu" + }, + "count": 1 + }, + { + "cardinality": { + "action": "menu-trigger-wrapped" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onClick-to-onAction" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..969c004 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/expected.tsx @@ -0,0 +1,8 @@ +import { Menu, MenuItem } from '@material-ui/core'; + +const MyComponent = () => ( + <>{/* TODO(backstage-codemod): finish menu host migration manually (no-trigger-element) */} + + Do stuff + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..b1f447b --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/input.tsx @@ -0,0 +1,7 @@ +import { Menu, MenuItem } from '@material-ui/core'; + +const MyComponent = () => ( + + Do stuff + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..9200396 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/named-barrel-import/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/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..0a6943a --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/noop-no-import/expected.tsx @@ -0,0 +1,7 @@ +import { Menu, MenuItem } from '@backstage/ui'; + +const MyComponent = () => ( + + Action + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..0a6943a --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/noop-no-import/input.tsx @@ -0,0 +1,7 @@ +import { Menu, MenuItem } from '@backstage/ui'; + +const MyComponent = () => ( + + Action + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/expected.tsx new file mode 100644 index 0000000..2829c01 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/expected.tsx @@ -0,0 +1,12 @@ +import Popover from '@material-ui/core/Popover'; +import MenuList from '@material-ui/core/MenuList'; +import MenuItem from '@material-ui/core/MenuItem'; + +const MyComponent = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + <>{/* TODO(backstage-codemod): finish menu host migration manually (no-trigger-element) */} + + + Action + + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/input.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/input.tsx new file mode 100644 index 0000000..f15fd9a --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/input.tsx @@ -0,0 +1,11 @@ +import Popover from '@material-ui/core/Popover'; +import MenuList from '@material-ui/core/MenuList'; +import MenuItem from '@material-ui/core/MenuItem'; + +const MyComponent = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + + + Action + + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/metrics.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/metrics.json new file mode 100644 index 0000000..9200396 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/popover-with-menu-list/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/simple-menu/expected.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/expected.tsx new file mode 100644 index 0000000..aac9718 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/expected.tsx @@ -0,0 +1,10 @@ +import Menu from '@material-ui/core/Menu'; +import MenuItem from '@material-ui/core/MenuItem'; + +const MyComponent = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + <>{/* TODO(backstage-codemod): finish menu host migration manually (no-trigger-element) */} + + Edit + Delete + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/input.tsx b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/input.tsx new file mode 100644 index 0000000..853eb36 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/input.tsx @@ -0,0 +1,9 @@ +import Menu from '@material-ui/core/Menu'; +import MenuItem from '@material-ui/core/MenuItem'; + +const MyComponent = ({ open, onClose }: { open: boolean; onClose: () => void }) => ( + + Edit + Delete + +); diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/metrics.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/metrics.json new file mode 100644 index 0000000..9200396 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tests/simple-menu/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/tsconfig.json b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-menu-popover-to-bui-menu/workflow.yaml b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/workflow.yaml new file mode 100644 index 0000000..f5ae9d5 --- /dev/null +++ b/codemods/misc/migrate-mui-menu-popover-to-bui-menu/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace Menu and Popover patterns with BUI Menu' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/CHANGELOG.md b/codemods/misc/migrate-mui-tabs-to-bui-tabs/CHANGELOG.md new file mode 100644 index 0000000..e116713 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-tabs-to-bui-tabs diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/codemod.yaml b/codemods/misc/migrate-mui-tabs-to-bui-tabs/codemod.yaml new file mode 100644 index 0000000..6e43625 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-tabs-to-bui-tabs' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace MUI Tabs with BUI Tabs' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'tabs', 'bui', 'tabs'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/package.json b/codemods/misc/migrate-mui-tabs-to-bui-tabs/package.json new file mode 100644 index 0000000..2c6c07d --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-tabs-to-bui-tabs", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace MUI Tabs with BUI Tabs", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts b/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts new file mode 100644 index 0000000..70b05e1 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/scripts/codemod.ts @@ -0,0 +1,773 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-tabs-to-bui-tabs') + +const BUI_SOURCE = '@backstage/ui' + +const TODO_PROPS = new Set([ + 'orientation', + 'variant', + 'scrollButtons', + 'centered', + 'indicatorColor', + 'textColor', + 'classes', + 'TabIndicatorProps', + 'TabScrollButtonProps', +]) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): string | null { + const clause = imp.find({ rule: { kind: 'import_clause' } }) + if (!clause) { + return null + } + for (const child of clause.children()) { + if (child.is('identifier')) { + return child.text() + } + } + return null +} + +function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (importedNameNode?.text() === targetName) { + const localNameNode = identifiers[1] ?? importedNameNode + return localNameNode.text() + } + } + return null +} + +interface TabImports { + localNames: Map + importNodesToRemove: SgNode[] + barrelImportsToPrune: { imp: SgNode; source: string; migratedNames: string[] }[] +} + +function getImportedName(spec: SgNode): string | null { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + return identifiers[0]?.text() ?? null +} + +function pruneBarrelImport(imp: SgNode, migratedNames: string[], source: string, edits: Edit[]): void { + const specifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + const remainingSpecs = specifiers.filter((spec) => { + const importedName = getImportedName(spec) + return importedName !== null && !migratedNames.includes(importedName) + }) + + if (remainingSpecs.length === 0) { + edits.push(imp.replace('')) + } else { + const specTexts = remainingSpecs.map((spec) => spec.text()).join(', ') + edits.push(imp.replace(`import { ${specTexts} } from '${source}';`)) + } + migrationMetric.increment({ action: 'import-removed' }) +} + +function collectTabImports(rootNode: SgNode): TabImports { + const localNames = new Map() + const importNodesToRemove: SgNode[] = [] + const barrelImportsToPrune: { imp: SgNode; source: string; migratedNames: string[] }[] = [] + + const corePaths = ['Tabs', 'Tab'] + for (const componentName of corePaths) { + for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { + const name = getDefaultImportName(imp) + if (name) { + localNames.set(name, componentName) + } + importNodesToRemove.push(imp) + } + } + + const labPaths = ['TabContext', 'TabList', 'TabPanel'] + for (const componentName of labPaths) { + for (const imp of findImportStatementsFrom(rootNode, `@material-ui/lab/${componentName}`)) { + const name = getDefaultImportName(imp) + if (name) { + localNames.set(name, componentName) + } + importNodesToRemove.push(imp) + } + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + const migratedNames: string[] = [] + for (const componentName of corePaths) { + const localName = getNamedImportLocalName(imp, componentName) + if (localName) { + localNames.set(localName, componentName) + migratedNames.push(componentName) + } + } + if (migratedNames.length > 0) { + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (migratedNames.length >= allSpecifiers.length) { + importNodesToRemove.push(imp) + } else { + barrelImportsToPrune.push({ imp, source: '@material-ui/core', migratedNames }) + } + } + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/lab')) { + const migratedNames: string[] = [] + for (const componentName of labPaths) { + const localName = getNamedImportLocalName(imp, componentName) + if (localName) { + localNames.set(localName, componentName) + migratedNames.push(componentName) + } + } + if (migratedNames.length > 0) { + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (migratedNames.length >= allSpecifiers.length) { + importNodesToRemove.push(imp) + } else { + barrelImportsToPrune.push({ imp, source: '@material-ui/lab', migratedNames }) + } + } + } + + return { localNames, importNodesToRemove, barrelImportsToPrune } +} + +function addBuiImport( + rootNode: SgNode, + names: string[], + importNodesToRemove: SgNode[], + edits: Edit[], +): boolean { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + const sortedNames = [...names].sort() + + if (existingImport) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const existing = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + for (const name of names) { + if (!existing.includes(name)) { + existing.push(name) + } + } + existing.sort() + edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + return false + } + + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push( + anchorImport.replace(`${anchorImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + } else if (importNodesToRemove.length === 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } else if (importNodesToRemove.length > 0) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } + + 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 null +} + +function isPropDynamic(opening: SgNode, propName: string): boolean { + const attr = getPropAttr(opening, propName) + if (!attr) { + return false + } + return attr.find({ rule: { kind: 'jsx_expression' } }) !== null +} + +function getChildContent(element: SgNode): string { + return element + .children() + .filter((child) => child.kind() !== 'jsx_opening_element' && child.kind() !== 'jsx_closing_element') + .map((child) => child.text()) + .join('') +} + +function getJsxChildren(element: SgNode): SgNode[] { + const children: SgNode[] = [] + for (const child of element.children()) { + const kind = child.kind() + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + children.push(child) + } + return children +} + +function getParamName(paramNode: SgNode): string { + const ident = paramNode.find({ rule: { kind: 'identifier' } }) + return ident?.text() ?? paramNode.text().replace(/:.*$/, '').trim() +} + +function identifierUsedIn(text: string, name: string): boolean { + return new RegExp(`\\b${escapeRegex(name)}\\b`).test(text) +} + +function replaceIdentifier(text: string, name: string, replacement: string): string { + return text.replaceAll(new RegExp(`\\b${escapeRegex(name)}\\b`, 'g'), replacement) +} + +/** + * Rewrite MUI Tabs/TabList onChange to BUI Tabs onSelectionChange. + * MUI: (event, value) => ... or handleChange reference + * BUI: (key) => ... + */ +function rewriteTabsOnChangeHandler(attr: SgNode): string | null { + const expr = attr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return null + } + + const arrow = expr.find({ rule: { kind: 'arrow_function' } }) + if (!arrow) { + const innerText = expr.text().slice(1, -1).trim() + if (/^[\w$.]+$/.test(innerText)) { + return `{(key) => ${innerText}(undefined, key)}` + } + return null + } + + const params = arrow.field('parameters') + if (params?.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 [firstParam, secondParam] = paramChildren + if (!firstParam || !secondParam) { + return null + } + + const eventName = getParamName(firstParam) + const valueName = getParamName(secondParam) + + const body = arrow.field('body') + if (!body) { + return null + } + + const bodyText = body.text() + const eventUsed = identifierUsedIn(bodyText, eventName) && !eventName.startsWith('_') + + if (!eventUsed) { + return `{key => ${replaceIdentifier(bodyText, valueName, 'key')}}` + } + + // Handler references the event param — cannot safely rewrite without breaking semantics + return null +} + +function findTabListOnChange(element: SgNode, tabListLocalName: string | undefined): SgNode | null { + if (!tabListLocalName) { + return null + } + + for (const child of getJsxChildren(element)) { + if (child.kind() !== 'jsx_element') { + continue + } + const opening = child.child(0) + if (!opening) { + continue + } + if (getElementName(opening) === tabListLocalName) { + return getPropAttr(opening, 'onChange') + } + } + + return null +} + +function transformTabListElement(opening: SgNode, innerContent: string): string { + const newProps: string[] = [] + const handledProps = new Set(['onChange']) + + const allAttrs = opening.findAll({ rule: { kind: 'jsx_attribute' } }) + for (const attr of allAttrs) { + const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent || handledProps.has(propIdent.text())) { + continue + } + newProps.push(attr.text()) + } + + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + return `${innerContent}` +} + +function transformTabElement(opening: SgNode): string { + const labelStr = getPropStringValue(opening, 'label') + const labelRaw = getPropRawValue(opening, 'label') + const valueStr = getPropStringValue(opening, 'value') + const valueRaw = getPropRawValue(opening, 'value') + + const newProps: string[] = [] + + if (valueStr !== null) { + newProps.push(`id="${valueStr}"`) + } else if (valueRaw !== null) { + newProps.push(`id=${valueRaw}`) + } + + const handledProps = new Set(['label', 'value', 'wrapped', 'disableRipple', 'classes', 'icon']) + const allAttrs = opening.findAll({ rule: { kind: 'jsx_attribute' } }) + for (const attr of allAttrs) { + const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + if (handledProps.has(propIdent.text())) { + continue + } + newProps.push(attr.text()) + } + + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + + let labelContent = '' + if (labelStr !== null) { + labelContent = labelStr + } else if (labelRaw !== null) { + labelContent = labelRaw + } + + if (labelContent) { + return `${labelContent}` + } + return `` +} + +function transformTabPanelElement(el: SgNode, opening: SgNode): string { + const valueStr = getPropStringValue(opening, 'value') + const valueRaw = getPropRawValue(opening, 'value') + + const newProps: string[] = [] + + if (valueStr !== null) { + newProps.push(`id="${valueStr}"`) + } else if (valueRaw !== null) { + newProps.push(`id=${valueRaw}`) + } + + const handledProps = new Set(['value', 'classes']) + const allAttrs = opening.findAll({ rule: { kind: 'jsx_attribute' } }) + for (const attr of allAttrs) { + const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + if (handledProps.has(propIdent.text())) { + continue + } + newProps.push(attr.text()) + } + + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + + if (el.is('jsx_self_closing_element')) { + return `` + } + + const children = getChildContent(el) + return `${children}` +} + +function transformChildren(element: SgNode, localNames: Map): string { + const children = getJsxChildren(element) + const parts: string[] = [] + + for (const child of children) { + const kind = child.kind() + + if (kind === 'jsx_text') { + parts.push(child.text()) + continue + } + + if (kind === 'jsx_self_closing_element' || kind === 'jsx_element') { + const childOpening = kind === 'jsx_self_closing_element' ? child : child.child(0) + if (childOpening) { + const childName = getElementName(childOpening) + if (childName && localNames.has(childName)) { + const muiName = localNames.get(childName) + if (!muiName) { + continue + } + + if (muiName === 'Tab') { + parts.push(transformTabElement(childOpening)) + migrationMetric.increment({ action: 'tab-migrated' }) + continue + } + + if (muiName === 'TabPanel') { + parts.push(transformTabPanelElement(child, childOpening)) + migrationMetric.increment({ action: 'tab-panel-migrated' }) + continue + } + + if (muiName === 'TabList') { + const innerContent = transformChildren(child, localNames) + parts.push(transformTabListElement(childOpening, innerContent)) + migrationMetric.increment({ action: 'tab-list-migrated' }) + continue + } + + if (muiName === 'Tabs') { + const innerContent = transformChildren(child, localNames) + parts.push(`${innerContent}`) + migrationMetric.increment({ action: 'tabs-to-tab-list' }) + continue + } + } + } + } + + parts.push(child.text()) + } + + return parts.join('') +} + +function transformTabElements( + rootNode: SgNode, + localNames: Map, + edits: Edit[], +): { usedBuiNames: Set; preserveImport: boolean; migrated: boolean } { + const usedBuiNames = new Set() + let preserveImport = false + let migrated = false + + const tabContextLocalName = [...localNames.entries()].find(([, v]) => v === 'TabContext')?.[0] + const tabListLocalName = [...localNames.entries()].find(([, v]) => v === 'TabList')?.[0] + const tabsLocalName = [...localNames.entries()].find(([, v]) => v === 'Tabs')?.[0] + + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + for (const el of jsxElements) { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + continue + } + + const name = getElementName(opening) + + if (name && name === tabContextLocalName) { + let needsTodo = false + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + needsTodo = true + break + } + } + + if (needsTodo) { + preserveImport = true + edits.push( + el.replace( + `<>{/* TODO(backstage-codemod): verify custom tab orientation or selection logic manually */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-props' }) + continue + } + + const valueRaw = getPropRawValue(opening, 'value') + const isDynamic = isPropDynamic(opening, 'value') + + const newProps: string[] = [] + if (valueRaw !== null) { + if (isDynamic) { + newProps.push(`selectedKey=${valueRaw}`) + } else { + newProps.push(`defaultSelectedKey=${valueRaw}`) + } + } + + const tabListOnChange = findTabListOnChange(el, tabListLocalName) + if (tabListOnChange) { + const rewritten = rewriteTabsOnChangeHandler(tabListOnChange) + if (rewritten === null) { + preserveImport = true + edits.push( + el.replace( + `<>{/* TODO(backstage-codemod): verify custom tab orientation or selection logic manually (event-referenced-onChange) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'event-referenced-onChange' }) + continue + } + newProps.push(`onSelectionChange=${rewritten}`) + migrationMetric.increment({ action: 'onChange-rewritten' }) + } + + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + + usedBuiNames.add('Tabs') + migrated = true + + if (isSelfClosing) { + edits.push(el.replace(``)) + } else { + const transformedChildren = transformChildren(el, localNames) + edits.push(el.replace(`${transformedChildren}`)) + } + + migrationMetric.increment({ action: 'tab-context-migrated' }) + continue + } + + if (name && name === tabsLocalName) { + const parent = el.parent() + if (parent) { + const parentOpening = parent.child(0) + if (parentOpening) { + const parentName = getElementName(parentOpening) + if (parentName && parentName === tabContextLocalName) { + continue + } + } + } + + let needsTodo = false + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + needsTodo = true + break + } + } + + if (needsTodo) { + preserveImport = true + edits.push( + el.replace( + `<>{/* TODO(backstage-codemod): verify custom tab orientation or selection logic manually */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-props' }) + continue + } + + const valueRaw = getPropRawValue(opening, 'value') + const isDynamic = isPropDynamic(opening, 'value') + + const newTabsProps: string[] = [] + if (valueRaw !== null) { + if (isDynamic) { + newTabsProps.push(`selectedKey=${valueRaw}`) + } else { + newTabsProps.push(`defaultSelectedKey=${valueRaw}`) + } + } + + const onChangeAttr = getPropAttr(opening, 'onChange') + if (onChangeAttr) { + const rewritten = rewriteTabsOnChangeHandler(onChangeAttr) + if (rewritten === null) { + preserveImport = true + edits.push( + el.replace( + `<>{/* TODO(backstage-codemod): verify custom tab orientation or selection logic manually (event-referenced-onChange) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'event-referenced-onChange' }) + continue + } + newTabsProps.push(`onSelectionChange=${rewritten}`) + migrationMetric.increment({ action: 'onChange-rewritten' }) + } + + const tabsPropsStr = newTabsProps.length > 0 ? ` ${newTabsProps.join(' ')}` : '' + + usedBuiNames.add('Tabs') + migrated = true + + if (isSelfClosing) { + edits.push(el.replace(``)) + } else { + const innerContent = transformChildren(el, localNames) + edits.push(el.replace(`${innerContent}`)) + usedBuiNames.add('TabList') + } + + migrationMetric.increment({ action: 'tabs-migrated' }) + continue + } + } + + for (const [, muiName] of localNames) { + if (migrated) { + if (muiName === 'TabContext' || muiName === 'Tabs') { + usedBuiNames.add('Tabs') + } + if (muiName === 'TabList' || muiName === 'Tabs') { + usedBuiNames.add('TabList') + } + if (muiName === 'Tab') { + usedBuiNames.add('Tab') + } + if (muiName === 'TabPanel') { + usedBuiNames.add('TabPanel') + } + } + } + + return { usedBuiNames, preserveImport, migrated } +} + +const transform: Codemod = (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { localNames, importNodesToRemove, barrelImportsToPrune } = collectTabImports(rootNode) + + if (localNames.size === 0) { + return Promise.resolve(null) + } + + const { usedBuiNames, preserveImport, migrated } = transformTabElements(rootNode, localNames, edits) + + let replacedImport = false + if (migrated) { + replacedImport = addBuiImport(rootNode, [...usedBuiNames], importNodesToRemove, edits) + } + + if (!preserveImport) { + for (const { imp, source, migratedNames } of barrelImportsToPrune) { + pruneBarrelImport(imp, migratedNames, source, edits) + } + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + 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-tabs-to-bui-tabs/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..0110032 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + + + + +import { Button, Tab, TabList, TabPanel, Tabs } from '@backstage/ui'; + +const MyComponent = () => ( + handleChange(undefined, key)}>Info +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..13ffaa8 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/merge-existing-bui/input.tsx @@ -0,0 +1,14 @@ +import TabContext from '@material-ui/lab/TabContext'; +import TabList from '@material-ui/lab/TabList'; +import Tab from '@material-ui/core/Tab'; +import TabPanel from '@material-ui/lab/TabPanel'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + + + + + + +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..39e878a --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/merge-existing-bui/metrics.json @@ -0,0 +1,46 @@ +{ + "migrate-mui-tabs-to-bui-tabs": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 4 + }, + { + "cardinality": { + "action": "onChange-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tab-context-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tab-list-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tab-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tab-panel-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..f791da0 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/expected.tsx @@ -0,0 +1,5 @@ +import { Tab, TabList, Tabs } from '@backstage/ui'; + +const MyComponent = () => ( + FirstSecond +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..8948ee1 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/input.tsx @@ -0,0 +1,8 @@ +import { Tabs, Tab } from '@material-ui/core'; + +const MyComponent = () => ( + + + + +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..f9d3a06 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/named-barrel-import/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-tabs-to-bui-tabs": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tab-migrated" + }, + "count": 2 + }, + { + "cardinality": { + "action": "tabs-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..7169f89 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/noop-no-import/expected.tsx @@ -0,0 +1,9 @@ +import { Tabs, Tab, TabList } from '@backstage/ui'; + +const MyComponent = () => ( + + + First + + +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..7169f89 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/noop-no-import/input.tsx @@ -0,0 +1,9 @@ +import { Tabs, Tab, TabList } from '@backstage/ui'; + +const MyComponent = () => ( + + + First + + +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/expected.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/expected.tsx new file mode 100644 index 0000000..af51382 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/expected.tsx @@ -0,0 +1,6 @@ +import { Tab, TabList, Tabs } from '@backstage/ui'; + + +const MyComponent = () => ( + handleChange(undefined, key)}>FirstSecond +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/input.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/input.tsx new file mode 100644 index 0000000..858514d --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/input.tsx @@ -0,0 +1,9 @@ +import Tabs from '@material-ui/core/Tabs'; +import Tab from '@material-ui/core/Tab'; + +const MyComponent = () => ( + + + + +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/metrics.json b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/metrics.json new file mode 100644 index 0000000..ac8e146 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/standalone-tabs/metrics.json @@ -0,0 +1,34 @@ +{ + "migrate-mui-tabs-to-bui-tabs": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 2 + }, + { + "cardinality": { + "action": "onChange-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tab-migrated" + }, + "count": 2 + }, + { + "cardinality": { + "action": "tabs-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/expected.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/expected.tsx new file mode 100644 index 0000000..caf832e --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/expected.tsx @@ -0,0 +1,8 @@ + + +import { Tab, TabList, TabPanel, Tabs } from '@backstage/ui'; + + +const MyComponent = () => ( + handleChange(undefined, key)}>OverviewDetailsContent AContent B +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/input.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/input.tsx new file mode 100644 index 0000000..6bb8a89 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/input.tsx @@ -0,0 +1,15 @@ +import TabContext from '@material-ui/lab/TabContext'; +import TabList from '@material-ui/lab/TabList'; +import Tab from '@material-ui/core/Tab'; +import TabPanel from '@material-ui/lab/TabPanel'; + +const MyComponent = () => ( + + + + + + Content A + Content B + +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/metrics.json b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/metrics.json new file mode 100644 index 0000000..928667b --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/tab-context-pattern/metrics.json @@ -0,0 +1,46 @@ +{ + "migrate-mui-tabs-to-bui-tabs": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 4 + }, + { + "cardinality": { + "action": "onChange-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tab-context-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tab-list-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tab-migrated" + }, + "count": 2 + }, + { + "cardinality": { + "action": "tab-panel-migrated" + }, + "count": 2 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/expected.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/expected.tsx new file mode 100644 index 0000000..65c9fc3 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/expected.tsx @@ -0,0 +1,10 @@ +import Tabs from '@material-ui/core/Tabs'; +import Tab from '@material-ui/core/Tab'; + +const MyComponent = () => ( + <>{/* TODO(backstage-codemod): verify custom tab orientation or selection logic manually */} + + + + +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/input.tsx b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/input.tsx new file mode 100644 index 0000000..037f742 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/input.tsx @@ -0,0 +1,9 @@ +import Tabs from '@material-ui/core/Tabs'; +import Tab from '@material-ui/core/Tab'; + +const MyComponent = () => ( + + + + +); diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/metrics.json b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/metrics.json new file mode 100644 index 0000000..3115005 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tests/vertical-tabs-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-tabs-to-bui-tabs": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "complex-props" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/tsconfig.json b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-tabs-to-bui-tabs/workflow.yaml b/codemods/misc/migrate-mui-tabs-to-bui-tabs/workflow.yaml new file mode 100644 index 0000000..c6a7a64 --- /dev/null +++ b/codemods/misc/migrate-mui-tabs-to-bui-tabs/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace MUI Tabs with BUI Tabs' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/yarn.lock b/yarn.lock index afce4d8..f15a132 100644 --- a/yarn.lock +++ b/yarn.lock @@ -172,6 +172,51 @@ __metadata: languageName: unknown linkType: soft +"@backstage/migrate-mui-chip-to-tag@workspace:codemods/misc/migrate-mui-chip-to-tag": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-chip-to-tag@workspace:codemods/misc/migrate-mui-chip-to-tag" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-dialog-to-bui-dialog@workspace:codemods/misc/migrate-mui-dialog-to-bui-dialog": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-dialog-to-bui-dialog@workspace:codemods/misc/migrate-mui-dialog-to-bui-dialog" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-list-family-to-bui-list@workspace:codemods/misc/migrate-mui-list-family-to-bui-list": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-list-family-to-bui-list@workspace:codemods/misc/migrate-mui-list-family-to-bui-list" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-menu-popover-to-bui-menu@workspace:codemods/misc/migrate-mui-menu-popover-to-bui-menu": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-menu-popover-to-bui-menu@workspace:codemods/misc/migrate-mui-menu-popover-to-bui-menu" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-tabs-to-bui-tabs@workspace:codemods/misc/migrate-mui-tabs-to-bui-tabs": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-tabs-to-bui-tabs@workspace:codemods/misc/migrate-mui-tabs-to-bui-tabs" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + "@backstage/migrate-nav-item-to-page@workspace:codemods/v1.51.0/migrate-nav-item-to-page": version: 0.0.0-use.local resolution: "@backstage/migrate-nav-item-to-page@workspace:codemods/v1.51.0/migrate-nav-item-to-page"