diff --git a/.changeset/mui-to-bui-form-controls.md b/.changeset/mui-to-bui-form-controls.md new file mode 100644 index 0000000..339436a --- /dev/null +++ b/.changeset/mui-to-bui-form-controls.md @@ -0,0 +1,9 @@ +--- +'@backstage/migrate-mui-select-family-to-bui-select': minor +'@backstage/migrate-mui-textfield-to-bui-textfield': minor +'@backstage/migrate-mui-accordion-to-bui-accordion': minor +'@backstage/migrate-mui-radio-checkbox-to-bui': minor +'@backstage/migrate-mui-slider-to-bui-slider': minor +--- + +Add form control codemods for the MUI 4 to BUI migration: Select, TextField, Accordion, radio/checkbox groups, and Slider. diff --git a/.lintstagedrc.json b/.lintstagedrc.json index a324132..082efb9 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,5 +1,5 @@ { "*.{ts,js,mts,mjs}": ["yarn format", "yarn lint:fix"], - "*.{json,yaml,yml,md}": ["yarn format"], + "!(**/tests/**)*.{json,yaml,yml,md}": ["yarn format"], "codemods/**/scripts/*.ts": "bash scripts/test-staged.sh" } diff --git a/README.md b/README.md index b200fef..8b6ab11 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,16 @@ Run the [`migration-recipe`](./codemods/v1.51.0/migration-recipe) to apply every Older versions are available in the [`codemods/`](./codemods) directory. +### misc + +| Codemod | Description | +| ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | +| [migrate-mui-accordion-to-bui-accordion](./codemods/misc/migrate-mui-accordion-to-bui-accordion) | MUI 4 to BUI: Replace Accordion with BUI Accordion | +| [migrate-mui-radio-checkbox-groups-to-bui-groups](./codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups) | MUI 4 to BUI: Replace radio and checkbox group patterns with BUI groups | +| [migrate-mui-select-family-to-bui-select](./codemods/misc/migrate-mui-select-family-to-bui-select) | MUI 4 to BUI: Replace Select wrapper patterns with BUI Select | +| [migrate-mui-slider-to-bui-slider](./codemods/misc/migrate-mui-slider-to-bui-slider) | MUI 4 to BUI: Replace Slider with BUI Slider | +| [migrate-mui-textfield-to-bui-textfield](./codemods/misc/migrate-mui-textfield-to-bui-textfield) | MUI 4 to BUI: Replace TextField with BUI TextField | + ## Usage diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/CHANGELOG.md b/codemods/misc/migrate-mui-accordion-to-bui-accordion/CHANGELOG.md new file mode 100644 index 0000000..2c35592 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-accordion-to-bui-accordion diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/codemod.yaml b/codemods/misc/migrate-mui-accordion-to-bui-accordion/codemod.yaml new file mode 100644 index 0000000..c179032 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-accordion-to-bui-accordion' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Accordion with BUI Accordion' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'accordion'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/package.json b/codemods/misc/migrate-mui-accordion-to-bui-accordion/package.json new file mode 100644 index 0000000..23aa92c --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-accordion-to-bui-accordion", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Accordion with BUI Accordion", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/scripts/codemod.ts b/codemods/misc/migrate-mui-accordion-to-bui-accordion/scripts/codemod.ts new file mode 100644 index 0000000..7f11f52 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/scripts/codemod.ts @@ -0,0 +1,517 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-accordion-to-bui-accordion') + +const BUI_SOURCE = '@backstage/ui' +const MUI_BARREL_SOURCE = '@material-ui/core' + +const MUI_ACCORDION_COMPONENTS = ['Accordion', 'AccordionSummary', 'AccordionDetails', 'AccordionActions'] + +/** Props on Accordion that trigger a TODO — controlled state or complex behavior. */ +const ACCORDION_TODO_PROPS = new Set([ + 'expanded', + 'onChange', + 'defaultExpanded', + 'TransitionComponent', + 'TransitionProps', + 'classes', + 'square', +]) + +/** Props on AccordionSummary that trigger a TODO. */ +const SUMMARY_TODO_PROPS = new Set(['classes', 'IconButtonProps']) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function wrapWithTodo(todoComment: string, elementText: string): string { + return `<> +${todoComment} +${elementText} +` +} + +function rebuildImportWithout(importStmt: SgNode, specifiersToRemove: Set): string { + const specifiers = importStmt.findAll({ rule: { kind: 'import_specifier' } }) + const remaining: string[] = [] + for (const spec of specifiers) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const importedName = identifiers[0]?.text() + if (importedName && !specifiersToRemove.has(importedName)) { + remaining.push(spec.text()) + } + } + + if (remaining.length === 0) { + return '' + } + + const sourceNode = importStmt.find({ rule: { kind: 'string' } }) + const sourceText = sourceNode?.text() ?? `'${MUI_BARREL_SOURCE}'` + + if (remaining.length <= 2) { + return `import { ${remaining.join(', ')} } from ${sourceText};` + } + return `import {\n ${remaining.join(',\n ')},\n} from ${sourceText};` +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): string | null { + const clause = imp.find({ rule: { kind: 'import_clause' } }) + if (!clause) { + return null + } + for (const child of clause.children()) { + if (child.is('identifier')) { + return child.text() + } + } + return null +} + +function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (importedNameNode?.text() === targetName) { + const localNameNode = identifiers[1] ?? importedNameNode + return localNameNode.text() + } + } + return null +} + +interface AccordionImports { + localNames: Map + importNodesToRemove: SgNode[] + barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] +} + +function collectAccordionImports(rootNode: SgNode): AccordionImports { + const localNames = new Map() + const importNodesToRemove: SgNode[] = [] + const barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] = [] + + for (const componentName of MUI_ACCORDION_COMPONENTS) { + for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { + const name = getDefaultImportName(imp) + if (name) { + localNames.set(name, componentName) + } + importNodesToRemove.push(imp) + } + } + + for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL_SOURCE)) { + const foundNames = new Set() + for (const componentName of MUI_ACCORDION_COMPONENTS) { + const localName = getNamedImportLocalName(imp, componentName) + if (localName) { + localNames.set(localName, componentName) + foundNames.add(componentName) + } + } + if (foundNames.size > 0) { + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (foundNames.size >= allSpecifiers.length) { + importNodesToRemove.push(imp) + } else { + barrelImportsToPrune.push({ imp, namesToRemove: foundNames }) + } + } + } + + return { localNames, importNodesToRemove, barrelImportsToPrune } +} + +function addBuiImport( + rootNode: SgNode, + importNodesToRemove: SgNode[], + names: string[], + edits: Edit[], +): boolean { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const 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 sortedNames = [...names].sort() + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push( + anchorImport.replace(`${anchorImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + } else if (importNodesToRemove.length >= 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } + 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 getChildContent(element: SgNode): string { + return element + .children() + .filter((child) => child.kind() !== 'jsx_opening_element' && child.kind() !== 'jsx_closing_element') + .map((child) => child.text()) + .join('') +} + +function getNonWhitespaceChildren(element: SgNode): SgNode[] { + const children: SgNode[] = [] + for (const child of element.children()) { + const kind = child.kind() + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + if (kind === 'jsx_text' && child.text().trim().length === 0) { + continue + } + children.push(child) + } + return children +} + +function getJsxChildren(element: SgNode): SgNode[] { + const children: SgNode[] = [] + for (const child of element.children()) { + const kind = child.kind() + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + children.push(child) + } + return children +} + +/** + * Extract the plain-text title from an AccordionSummary element. + * Returns the title string if simple text, or null if complex content. + */ +function extractSummaryTitle(summaryElement: SgNode): { title: string | null; isComplex: boolean } { + const children = getNonWhitespaceChildren(summaryElement) + + if (children.length === 0) { + return { title: null, isComplex: false } + } + + // Single text node + const [firstChild] = children + if (children.length === 1 && firstChild?.kind() === 'jsx_text') { + const text = firstChild.text().trim() + return { title: text.length > 0 ? text : null, isComplex: false } + } + + // Multiple text-only nodes (whitespace-separated) + if (children.every((c) => c.kind() === 'jsx_text')) { + const text = children + .map((c) => c.text().trim()) + .filter(Boolean) + .join(' ') + return { title: text.length > 0 ? text : null, isComplex: false } + } + + // Single Typography element wrapping text + if (children.length === 1) { + const child = firstChild + if (child?.kind() === 'jsx_element') { + const innerChildren = getNonWhitespaceChildren(child) + const [innerChild] = innerChildren + if (innerChildren.length === 1 && innerChild?.kind() === 'jsx_text') { + const text = innerChild.text().trim() + if (text.length > 0) { + return { title: text, isComplex: false } + } + } + } + } + + // Anything else is complex + return { title: null, isComplex: true } +} + +function transformAccordionChildren(accordionElement: SgNode, localNames: Map): string | null { + const children = getJsxChildren(accordionElement) + const parts: string[] = [] + + const summaryLocalName = [...localNames.entries()].find(([, v]) => v === 'AccordionSummary')?.[0] ?? null + const detailsLocalName = [...localNames.entries()].find(([, v]) => v === 'AccordionDetails')?.[0] ?? null + const actionsLocalName = [...localNames.entries()].find(([, v]) => v === 'AccordionActions')?.[0] ?? null + + for (const child of children) { + const kind = child.kind() + + if (kind === 'jsx_text') { + parts.push(child.text()) + continue + } + + if (kind === 'jsx_element') { + const childOpening = child.child(0) + if (!childOpening) { + parts.push(child.text()) + continue + } + + const childName = getElementName(childOpening) + + // AccordionSummary → AccordionTrigger + if (childName && childName === summaryLocalName) { + // Check for TODO-triggering summary props + let summaryNeedsTodo = false + for (const prop of SUMMARY_TODO_PROPS) { + if (hasProp(childOpening, prop)) { + summaryNeedsTodo = true + break + } + } + + if (summaryNeedsTodo) { + return null // signal parent to TODO the whole accordion + } + + const { title, isComplex } = extractSummaryTitle(child) + + if (isComplex || !title) { + return null // signal parent to TODO the whole accordion + } + + // Drop expandIcon — BUI handles its own icon + if (hasProp(childOpening, 'expandIcon')) { + migrationMetric.increment({ action: 'expandIcon-dropped' }) + } + + parts.push(``) + migrationMetric.increment({ action: 'summary-migrated' }) + continue + } + + // AccordionDetails → AccordionPanel + if (childName && childName === detailsLocalName) { + const innerContent = getChildContent(child) + parts.push(`${innerContent}`) + migrationMetric.increment({ action: 'details-migrated' }) + continue + } + + // AccordionActions → TODO (no BUI equivalent) + if (childName && childName === actionsLocalName) { + return null // signal parent to TODO the whole accordion + } + } + + // Preserve anything else as-is + parts.push(child.text()) + } + + return parts.join('') +} + +function transformAccordionElements( + rootNode: SgNode, + localNames: Map, + edits: Edit[], +): { preserveImport: boolean; migrated: boolean } { + let preserveImport = false + let migrated = false + const accordionLocalName = [...localNames.entries()].find(([, v]) => v === 'Accordion')?.[0] + if (!accordionLocalName) { + return { preserveImport: false, migrated: false } + } + + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + for (const el of jsxElements) { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + continue + } + + const name = getElementName(opening) + if (name !== accordionLocalName) { + continue + } + + // Check for TODO-triggering props on Accordion + let needsTodo = false + const todoReasons: string[] = [] + for (const prop of ACCORDION_TODO_PROPS) { + if (hasProp(opening, prop)) { + needsTodo = true + todoReasons.push(prop) + } + } + + if (needsTodo) { + preserveImport = true + edits.push( + el.replace( + wrapWithTodo( + `{/* TODO(backstage-codemod): finish accordion migration manually (${todoReasons.join(', ')}) */}`, + el.text(), + ), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: todoReasons.join(', ') }) + continue + } + + if (isSelfClosing) { + edits.push(el.replace('')) + migrated = true + migrationMetric.increment({ action: 'accordion-migrated' }) + continue + } + + // Transform children + const transformedChildren = transformAccordionChildren(el, localNames) + + if (transformedChildren === null) { + // Complex content — TODO the whole thing + preserveImport = true + edits.push( + el.replace( + wrapWithTodo( + `{/* TODO(backstage-codemod): finish accordion migration manually (complex-summary) */}`, + el.text(), + ), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-summary' }) + continue + } + + edits.push(el.replace(`${transformedChildren}`)) + migrated = true + migrationMetric.increment({ action: 'accordion-migrated' }) + } + + return { preserveImport, migrated } +} + +const transform: Codemod = (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { localNames, importNodesToRemove, barrelImportsToPrune } = collectAccordionImports(rootNode) + + if (localNames.size === 0) { + return Promise.resolve(null) + } + + const { preserveImport, migrated } = transformAccordionElements(rootNode, localNames, edits) + + const buiNames = new Set() + if (migrated) { + buiNames.add('Accordion') + for (const [, muiName] of localNames) { + if (muiName === 'AccordionSummary') { + buiNames.add('AccordionTrigger') + } + if (muiName === 'AccordionDetails') { + buiNames.add('AccordionPanel') + } + } + } + + let replacedImport = false + if (buiNames.size > 0) { + replacedImport = addBuiImport(rootNode, importNodesToRemove, [...buiNames], edits) + } + + if (!preserveImport) { + for (const { imp, namesToRemove } of barrelImportsToPrune) { + edits.push(imp.replace(rebuildImportWithout(imp, namesToRemove))) + migrationMetric.increment({ action: 'import-pruned' }) + } + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + } + + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) +} + +export default transform diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/expected.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/expected.tsx new file mode 100644 index 0000000..47e2e6e --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/expected.tsx @@ -0,0 +1,16 @@ +import Accordion from '@material-ui/core/Accordion'; +import AccordionSummary from '@material-ui/core/AccordionSummary'; +import AccordionDetails from '@material-ui/core/AccordionDetails'; + +const MyComponent = () => ( + <> +{/* TODO(backstage-codemod): finish accordion migration manually (complex-summary) */} + + + Title + Subtitle + + Body + + +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/input.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/input.tsx new file mode 100644 index 0000000..3edd45c --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/input.tsx @@ -0,0 +1,13 @@ +import Accordion from '@material-ui/core/Accordion'; +import AccordionSummary from '@material-ui/core/AccordionSummary'; +import AccordionDetails from '@material-ui/core/AccordionDetails'; + +const MyComponent = () => ( + + + Title + Subtitle + + Body + +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/metrics.json b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/metrics.json new file mode 100644 index 0000000..bef93c3 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/complex-summary-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-accordion-to-bui-accordion": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "complex-summary" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/expected.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/expected.tsx new file mode 100644 index 0000000..0f961e1 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/expected.tsx @@ -0,0 +1,13 @@ +import Accordion from '@material-ui/core/Accordion'; +import AccordionSummary from '@material-ui/core/AccordionSummary'; +import AccordionDetails from '@material-ui/core/AccordionDetails'; + +const MyComponent = ({ expanded, onChange }: any) => ( + <> +{/* TODO(backstage-codemod): finish accordion migration manually (expanded, onChange) */} + + Settings + Content + + +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/input.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/input.tsx new file mode 100644 index 0000000..4479e6b --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/input.tsx @@ -0,0 +1,10 @@ +import Accordion from '@material-ui/core/Accordion'; +import AccordionSummary from '@material-ui/core/AccordionSummary'; +import AccordionDetails from '@material-ui/core/AccordionDetails'; + +const MyComponent = ({ expanded, onChange }: any) => ( + + Settings + Content + +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/metrics.json b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/metrics.json new file mode 100644 index 0000000..9f05bcb --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/controlled-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-accordion-to-bui-accordion": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "expanded, onChange" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..03121d3 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,11 @@ + + + +import { Accordion, AccordionPanel, AccordionTrigger, Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + Details + +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..49e4020 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/input.tsx @@ -0,0 +1,14 @@ +import Accordion from '@material-ui/core/Accordion'; +import AccordionSummary from '@material-ui/core/AccordionSummary'; +import AccordionDetails from '@material-ui/core/AccordionDetails'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + Info + Details + + +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..8da74ad --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/merge-existing-bui/metrics.json @@ -0,0 +1,34 @@ +{ + "migrate-mui-accordion-to-bui-accordion": [ + { + "cardinality": { + "action": "accordion-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "details-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "summary-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..87b6913 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/expected.tsx @@ -0,0 +1,5 @@ +import { Accordion, AccordionPanel, AccordionTrigger } from '@backstage/ui'; + +const MyComponent = () => ( + Answers here +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..a759db7 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/input.tsx @@ -0,0 +1,8 @@ +import { Accordion, AccordionSummary, AccordionDetails } from '@material-ui/core'; + +const MyComponent = () => ( + + FAQ + Answers here + +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..665a47d --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/named-barrel-import/metrics.json @@ -0,0 +1,34 @@ +{ + "migrate-mui-accordion-to-bui-accordion": [ + { + "cardinality": { + "action": "accordion-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "details-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "summary-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..04e934e --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/noop-no-import/expected.tsx @@ -0,0 +1,8 @@ +import { Accordion, AccordionTrigger, AccordionPanel } from '@backstage/ui'; + +const MyComponent = () => ( + + + Content + +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..04e934e --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/noop-no-import/input.tsx @@ -0,0 +1,8 @@ +import { Accordion, AccordionTrigger, AccordionPanel } from '@backstage/ui'; + +const MyComponent = () => ( + + + Content + +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/expected.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/expected.tsx new file mode 100644 index 0000000..a79bcad --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/expected.tsx @@ -0,0 +1,7 @@ +import { Accordion, AccordionPanel, AccordionTrigger } from '@backstage/ui'; + + + +const MyComponent = () => ( + Body content here +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/input.tsx b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/input.tsx new file mode 100644 index 0000000..d7a669c --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/input.tsx @@ -0,0 +1,12 @@ +import Accordion from '@material-ui/core/Accordion'; +import AccordionSummary from '@material-ui/core/AccordionSummary'; +import AccordionDetails from '@material-ui/core/AccordionDetails'; + +const MyComponent = () => ( + + }> + Section title + + Body content here + +); diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/metrics.json b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/metrics.json new file mode 100644 index 0000000..903d2b4 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tests/simple-accordion/metrics.json @@ -0,0 +1,40 @@ +{ + "migrate-mui-accordion-to-bui-accordion": [ + { + "cardinality": { + "action": "accordion-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "details-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "expandIcon-dropped" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "summary-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/tsconfig.json b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-accordion-to-bui-accordion/workflow.yaml b/codemods/misc/migrate-mui-accordion-to-bui-accordion/workflow.yaml new file mode 100644 index 0000000..59c1712 --- /dev/null +++ b/codemods/misc/migrate-mui-accordion-to-bui-accordion/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace Accordion with BUI Accordion' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/CHANGELOG.md b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/CHANGELOG.md new file mode 100644 index 0000000..7010e1a --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-radio-checkbox-to-bui diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/codemod.yaml b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/codemod.yaml new file mode 100644 index 0000000..36406ea --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-radio-checkbox-to-bui' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace radio and checkbox group patterns with BUI groups' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'radio', 'checkbox', 'groups'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/package.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/package.json new file mode 100644 index 0000000..97692be --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-radio-checkbox-to-bui", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace radio and checkbox group patterns with BUI groups", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/scripts/codemod.ts b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/scripts/codemod.ts new file mode 100644 index 0000000..2a88df3 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/scripts/codemod.ts @@ -0,0 +1,564 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-radio-checkbox-to-bui') + +const BUI_SOURCE = '@backstage/ui' +const MUI_BARREL_SOURCE = '@material-ui/core' + +const MUI_COMPONENTS = ['RadioGroup', 'Radio', 'Checkbox', 'FormControlLabel', 'FormGroup', 'FormControl', 'FormLabel'] + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function wrapWithTodo(todoComment: string, elementText: string): string { + return `<> +${todoComment} +${elementText} +` +} + +function rebuildImportWithout(importStmt: SgNode, specifiersToRemove: Set): string { + const specifiers = importStmt.findAll({ rule: { kind: 'import_specifier' } }) + const remaining: string[] = [] + for (const spec of specifiers) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const importedName = identifiers[0]?.text() + if (importedName && !specifiersToRemove.has(importedName)) { + remaining.push(spec.text()) + } + } + + if (remaining.length === 0) { + return '' + } + + const sourceNode = importStmt.find({ rule: { kind: 'string' } }) + const sourceText = sourceNode?.text() ?? `'${MUI_BARREL_SOURCE}'` + + if (remaining.length <= 2) { + return `import { ${remaining.join(', ')} } from ${sourceText};` + } + return `import {\n ${remaining.join(',\n ')},\n} from ${sourceText};` +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): string | null { + const clause = imp.find({ rule: { kind: 'import_clause' } }) + if (!clause) { + return null + } + for (const child of clause.children()) { + if (child.is('identifier')) { + return child.text() + } + } + return null +} + +function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (importedNameNode?.text() === targetName) { + const localNameNode = identifiers[1] ?? importedNameNode + return localNameNode.text() + } + } + return null +} + +interface GroupImports { + localNames: Map + importNodesToRemove: SgNode[] + barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] +} + +function collectGroupImports(rootNode: SgNode): GroupImports { + const localNames = new Map() + const importNodesToRemove: SgNode[] = [] + const barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] = [] + + for (const componentName of MUI_COMPONENTS) { + for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { + const name = getDefaultImportName(imp) + if (name) { + localNames.set(name, componentName) + } + importNodesToRemove.push(imp) + } + } + + for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL_SOURCE)) { + const foundNames = new Set() + for (const componentName of MUI_COMPONENTS) { + const localName = getNamedImportLocalName(imp, componentName) + if (localName) { + localNames.set(localName, componentName) + foundNames.add(componentName) + } + } + if (foundNames.size > 0) { + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (foundNames.size >= allSpecifiers.length) { + importNodesToRemove.push(imp) + } else { + barrelImportsToPrune.push({ imp, namesToRemove: foundNames }) + } + } + } + + return { localNames, importNodesToRemove, barrelImportsToPrune } +} + +function addBuiImport( + rootNode: SgNode, + importNodesToRemove: SgNode[], + names: string[], + edits: Edit[], +): boolean { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const 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 sortedNames = [...names].sort() + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push( + anchorImport.replace(`${anchorImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + } else if (importNodesToRemove.length >= 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } + 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 getJsxChildren(element: SgNode): SgNode[] { + const children: SgNode[] = [] + for (const child of element.children()) { + const kind = child.kind() + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + children.push(child) + } + return children +} + +function getNonWhitespaceChildren(element: SgNode): SgNode[] { + const children: SgNode[] = [] + for (const child of element.children()) { + const kind = child.kind() + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + if (kind === 'jsx_text' && child.text().trim().length === 0) { + continue + } + children.push(child) + } + return children +} + +function detectControlType(fclOpening: SgNode, localNames: Map): 'Radio' | 'Checkbox' | null { + const controlAttr = getPropAttr(fclOpening, 'control') + if (!controlAttr) { + return null + } + const expr = controlAttr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return null + } + const selfClosing = expr.find({ rule: { kind: 'jsx_self_closing_element' } }) + if (!selfClosing) { + return null + } + const controlName = getElementName(selfClosing) + if (!controlName) { + return null + } + const muiName = localNames.get(controlName) + if (muiName === 'Radio') { + return 'Radio' + } + if (muiName === 'Checkbox') { + return 'Checkbox' + } + return null +} + +function extractControlProps(fclOpening: SgNode): SgNode | null { + const controlAttr = getPropAttr(fclOpening, 'control') + if (!controlAttr) { + return null + } + const expr = controlAttr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return null + } + return expr.find({ rule: { kind: 'jsx_self_closing_element' } }) +} + +function transformRadioGroupChildren(groupElement: SgNode, localNames: Map): string | null { + const fclLocalName = [...localNames.entries()].find(([, v]) => v === 'FormControlLabel')?.[0] ?? null + const children = getJsxChildren(groupElement) + const parts: string[] = [] + + for (const child of children) { + const kind = child.kind() + if (kind === 'jsx_text') { + parts.push(child.text()) + continue + } + if (kind === 'jsx_self_closing_element' && fclLocalName) { + const childName = getElementName(child) + if (childName === fclLocalName) { + const controlType = detectControlType(child, localNames) + if (controlType !== 'Radio') { + return null + } + const label = getPropStringValue(child, 'label') + if (!label) { + return null + } + const valueStr = getPropStringValue(child, 'value') + const valueRaw = getPropRawValue(child, 'value') + const props: string[] = [] + if (valueStr !== null) { + props.push(`value="${valueStr}"`) + } else if (valueRaw !== null) { + props.push(`value=${valueRaw}`) + } + if (hasProp(child, 'disabled')) { + props.push('isDisabled') + } + const propsStr = props.length > 0 ? ` ${props.join(' ')}` : '' + parts.push(`${label}`) + migrationMetric.increment({ action: 'radio-option-migrated' }) + continue + } + } + parts.push(child.text()) + } + + return parts.join('') +} + +function transformCheckboxGroupChildren(groupElement: SgNode, localNames: Map): string | null { + const fclLocalName = [...localNames.entries()].find(([, v]) => v === 'FormControlLabel')?.[0] ?? null + const children = getJsxChildren(groupElement) + const parts: string[] = [] + + for (const child of children) { + const kind = child.kind() + if (kind === 'jsx_text') { + parts.push(child.text()) + continue + } + if (kind === 'jsx_self_closing_element' && fclLocalName) { + const childName = getElementName(child) + if (childName === fclLocalName) { + const controlType = detectControlType(child, localNames) + if (controlType !== 'Checkbox') { + return null + } + const label = getPropStringValue(child, 'label') + if (!label) { + return null + } + const controlEl = extractControlProps(child) + if (!controlEl) { + return null + } + const props: string[] = [] + if (hasProp(controlEl, 'checked')) { + const checkedRaw = getPropRawValue(controlEl, 'checked') + if (checkedRaw !== null && checkedRaw !== '') { + props.push(`isSelected=${checkedRaw}`) + } else { + props.push('isSelected') + } + } + const onChangeRaw = getPropRawValue(controlEl, 'onChange') + if (onChangeRaw !== null) { + props.push(`onChange=${onChangeRaw}`) + } + const nameRaw = getPropRawValue(controlEl, 'name') + if (nameRaw !== null) { + props.push(`name=${nameRaw}`) + } + if (hasProp(child, 'disabled') || hasProp(controlEl, 'disabled')) { + props.push('isDisabled') + } + const propsStr = props.length > 0 ? ` ${props.join(' ')}` : '' + parts.push(`${label}`) + migrationMetric.increment({ action: 'checkbox-option-migrated' }) + continue + } + } + parts.push(child.text()) + } + + return parts.join('') +} + +function isCheckboxFormGroup(element: SgNode, localNames: Map): boolean { + const fclLocalName = [...localNames.entries()].find(([, v]) => v === 'FormControlLabel')?.[0] ?? null + if (!fclLocalName) { + return false + } + const meaningfulChildren = getNonWhitespaceChildren(element) + if (meaningfulChildren.length === 0) { + return false + } + let hasCheckbox = false + for (const child of meaningfulChildren) { + if (!child.is('jsx_self_closing_element')) { + return false + } + const childName = getElementName(child) + if (childName !== fclLocalName) { + return false + } + const controlType = detectControlType(child, localNames) + if (controlType !== 'Checkbox') { + return false + } + hasCheckbox = true + } + return hasCheckbox +} + +function transformGroupElements( + rootNode: SgNode, + localNames: Map, + edits: Edit[], +): { usedBuiNames: Set; preserveImport: boolean } { + const usedBuiNames = new Set() + let preserveImport = false + const radioGroupLocal = [...localNames.entries()].find(([, v]) => v === 'RadioGroup')?.[0] ?? null + const formGroupLocal = [...localNames.entries()].find(([, v]) => v === 'FormGroup')?.[0] ?? null + + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + for (const el of jsxElements) { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + continue + } + const name = getElementName(opening) + if (!name) { + continue + } + + if (name === radioGroupLocal && !isSelfClosing) { + const transformedChildren = transformRadioGroupChildren(el, localNames) + if (transformedChildren === null) { + preserveImport = true + edits.push( + el.replace( + wrapWithTodo(`{/* TODO(backstage-codemod): finish choice-group migration manually */}`, el.text()), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-radio-group' }) + continue + } + const newProps: string[] = [] + const valueRaw = getPropRawValue(opening, 'value') + if (valueRaw !== null) { + newProps.push(`value=${valueRaw}`) + } + const onChangeRaw = getPropRawValue(opening, 'onChange') + if (onChangeRaw !== null) { + newProps.push(`onChange=${onChangeRaw}`) + } + const nameRaw = getPropRawValue(opening, 'name') + if (nameRaw !== null) { + newProps.push(`name=${nameRaw}`) + } + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + edits.push(el.replace(`${transformedChildren}`)) + usedBuiNames.add('RadioGroup') + usedBuiNames.add('Radio') + migrationMetric.increment({ action: 'radio-group-migrated' }) + continue + } + + if (name === formGroupLocal && !isSelfClosing) { + if (!isCheckboxFormGroup(el, localNames)) { + preserveImport = true + edits.push( + el.replace( + wrapWithTodo(`{/* TODO(backstage-codemod): finish choice-group migration manually */}`, el.text()), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-form-group' }) + continue + } + const transformedChildren = transformCheckboxGroupChildren(el, localNames) + if (transformedChildren === null) { + preserveImport = true + edits.push( + el.replace( + wrapWithTodo(`{/* TODO(backstage-codemod): finish choice-group migration manually */}`, el.text()), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-checkbox-group' }) + continue + } + edits.push(el.replace(`${transformedChildren}`)) + usedBuiNames.add('CheckboxGroup') + usedBuiNames.add('Checkbox') + migrationMetric.increment({ action: 'checkbox-group-migrated' }) + continue + } + } + + return { usedBuiNames, preserveImport } +} + +const transform: Codemod = (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { localNames, importNodesToRemove, barrelImportsToPrune } = collectGroupImports(rootNode) + + const hasTarget = [...localNames.values()].some( + (v) => v === 'RadioGroup' || v === 'FormGroup' || v === 'FormControlLabel', + ) + if (!hasTarget) { + return Promise.resolve(null) + } + + const { usedBuiNames, preserveImport } = transformGroupElements(rootNode, localNames, edits) + + let replacedImport = false + if (usedBuiNames.size > 0) { + replacedImport = addBuiImport(rootNode, importNodesToRemove, [...usedBuiNames], edits) + } + + if (!preserveImport) { + for (const { imp, namesToRemove } of barrelImportsToPrune) { + edits.push(imp.replace(rebuildImportWithout(imp, namesToRemove))) + migrationMetric.increment({ action: 'import-pruned' }) + } + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + } + + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) +} + +export default transform diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/expected.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/expected.tsx new file mode 100644 index 0000000..ecff772 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/expected.tsx @@ -0,0 +1,13 @@ +import FormGroup from '@material-ui/core/FormGroup'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Checkbox from '@material-ui/core/Checkbox'; + +const MyComponent = () => ( + <> +{/* TODO(backstage-codemod): finish choice-group migration manually */} + + } label="A" /> + Custom separator + + +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/input.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/input.tsx new file mode 100644 index 0000000..3cd3401 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/input.tsx @@ -0,0 +1,10 @@ +import FormGroup from '@material-ui/core/FormGroup'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Checkbox from '@material-ui/core/Checkbox'; + +const MyComponent = () => ( + + } label="A" /> + Custom separator + +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/metrics.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/metrics.json new file mode 100644 index 0000000..1e76e2f --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/complex-form-group-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-radio-checkbox-to-bui": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "complex-form-group" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..068e39a --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,11 @@ + + + +import { Button, Radio, RadioGroup } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + OnOff + +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..9eb8a03 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/input.tsx @@ -0,0 +1,14 @@ +import RadioGroup from '@material-ui/core/RadioGroup'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Radio from '@material-ui/core/Radio'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + } label="On" /> + } label="Off" /> + + +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..7a73b16 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/merge-existing-bui/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-radio-checkbox-to-bui": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "radio-group-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "radio-option-migrated" + }, + "count": 2 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..c291676 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/expected.tsx @@ -0,0 +1,5 @@ +import { Radio, RadioGroup } from '@backstage/ui'; + +const MyComponent = () => ( + XY +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..8b24474 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/input.tsx @@ -0,0 +1,8 @@ +import { RadioGroup, Radio, FormControlLabel } from '@material-ui/core'; + +const MyComponent = () => ( + + } label="X" /> + } label="Y" /> + +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..0df8159 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/named-barrel-import/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-radio-checkbox-to-bui": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "radio-group-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "radio-option-migrated" + }, + "count": 2 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..96ee1e6 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/noop-no-import/expected.tsx @@ -0,0 +1,7 @@ +import { RadioGroup, Radio } from '@backstage/ui'; + +const MyComponent = () => ( + + Already migrated + +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..96ee1e6 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/noop-no-import/input.tsx @@ -0,0 +1,7 @@ +import { RadioGroup, Radio } from '@backstage/ui'; + +const MyComponent = () => ( + + Already migrated + +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/expected.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/expected.tsx new file mode 100644 index 0000000..e81dd06 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/expected.tsx @@ -0,0 +1,7 @@ + + +import { Checkbox, CheckboxGroup } from '@backstage/ui'; + +const MyComponent = () => ( + EnabledVisible +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/input.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/input.tsx new file mode 100644 index 0000000..aadeebe --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/input.tsx @@ -0,0 +1,10 @@ +import FormGroup from '@material-ui/core/FormGroup'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Checkbox from '@material-ui/core/Checkbox'; + +const MyComponent = () => ( + + } label="Enabled" /> + } label="Visible" /> + +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/metrics.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/metrics.json new file mode 100644 index 0000000..df0a8b0 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-checkbox-group/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-radio-checkbox-to-bui": [ + { + "cardinality": { + "action": "checkbox-group-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "checkbox-option-migrated" + }, + "count": 2 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/expected.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/expected.tsx new file mode 100644 index 0000000..957ef51 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/expected.tsx @@ -0,0 +1,7 @@ +import { Radio, RadioGroup } from '@backstage/ui'; + + + +const MyComponent = () => ( + Option AOption B +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/input.tsx b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/input.tsx new file mode 100644 index 0000000..87f0b0c --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/input.tsx @@ -0,0 +1,10 @@ +import RadioGroup from '@material-ui/core/RadioGroup'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Radio from '@material-ui/core/Radio'; + +const MyComponent = () => ( + + } label="Option A" /> + } label="Option B" /> + +); diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/metrics.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/metrics.json new file mode 100644 index 0000000..5ccb759 --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tests/simple-radio-group/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-radio-checkbox-to-bui": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 3 + }, + { + "cardinality": { + "action": "radio-group-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "radio-option-migrated" + }, + "count": 2 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tsconfig.json b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/workflow.yaml b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/workflow.yaml new file mode 100644 index 0000000..50ded5d --- /dev/null +++ b/codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace radio and checkbox group patterns with BUI groups' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/CHANGELOG.md b/codemods/misc/migrate-mui-select-family-to-bui-select/CHANGELOG.md new file mode 100644 index 0000000..0168303 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-select-family-to-bui-select diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/codemod.yaml b/codemods/misc/migrate-mui-select-family-to-bui-select/codemod.yaml new file mode 100644 index 0000000..82cb6a3 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-select-family-to-bui-select' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Select wrapper patterns with BUI Select' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'select', 'family'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/package.json b/codemods/misc/migrate-mui-select-family-to-bui-select/package.json new file mode 100644 index 0000000..9aa58d4 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-select-family-to-bui-select", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Select wrapper patterns with BUI Select", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/scripts/codemod.ts b/codemods/misc/migrate-mui-select-family-to-bui-select/scripts/codemod.ts new file mode 100644 index 0000000..3eea56d --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/scripts/codemod.ts @@ -0,0 +1,704 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-select-family-to-bui-select') + +const BUI_SOURCE = '@backstage/ui' +const MUI_BARREL_SOURCE = '@material-ui/core' + +const MUI_SELECT_COMPONENTS = ['FormControl', 'InputLabel', 'Select', 'MenuItem', 'FormHelperText'] + +/** Props on Select that trigger a TODO. */ +const TODO_PROPS = new Set([ + 'multiple', + 'native', + 'renderValue', + 'displayEmpty', + 'autoWidth', + 'MenuProps', + 'input', + 'inputProps', + 'variant', + 'classes', + 'IconComponent', +]) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function wrapWithTodo(todoComment: string, elementText: string): string { + return `<> +${todoComment} +${elementText} +` +} + +function rebuildImportWithout(importStmt: SgNode, specifiersToRemove: Set): string { + const specifiers = importStmt.findAll({ rule: { kind: 'import_specifier' } }) + const remaining: string[] = [] + for (const spec of specifiers) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const importedName = identifiers[0]?.text() + if (importedName && !specifiersToRemove.has(importedName)) { + remaining.push(spec.text()) + } + } + + if (remaining.length === 0) { + return '' + } + + const sourceNode = importStmt.find({ rule: { kind: 'string' } }) + const sourceText = sourceNode?.text() ?? `'${MUI_BARREL_SOURCE}'` + + if (remaining.length <= 2) { + return `import { ${remaining.join(', ')} } from ${sourceText};` + } + return `import {\n ${remaining.join(',\n ')},\n} from ${sourceText};` +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): string | null { + const clause = imp.find({ rule: { kind: 'import_clause' } }) + if (!clause) { + return null + } + for (const child of clause.children()) { + if (child.is('identifier')) { + return child.text() + } + } + return null +} + +function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (importedNameNode?.text() === targetName) { + const localNameNode = identifiers[1] ?? importedNameNode + return localNameNode.text() + } + } + return null +} + +interface SelectImports { + localNames: Map + importNodesToRemove: SgNode[] + barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] +} + +function collectSelectImports(rootNode: SgNode): SelectImports { + const localNames = new Map() + const importNodesToRemove: SgNode[] = [] + const barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] = [] + + for (const componentName of MUI_SELECT_COMPONENTS) { + for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { + const name = getDefaultImportName(imp) + if (name) { + localNames.set(name, componentName) + } + importNodesToRemove.push(imp) + } + } + + for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL_SOURCE)) { + const foundNames = new Set() + for (const componentName of MUI_SELECT_COMPONENTS) { + const localName = getNamedImportLocalName(imp, componentName) + if (localName) { + localNames.set(localName, componentName) + foundNames.add(componentName) + } + } + if (foundNames.size > 0) { + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (foundNames.size >= allSpecifiers.length) { + importNodesToRemove.push(imp) + } else { + barrelImportsToPrune.push({ imp, namesToRemove: foundNames }) + } + } + } + + return { localNames, importNodesToRemove, barrelImportsToPrune } +} + +function addBuiImport( + rootNode: SgNode, + importNodesToRemove: SgNode[], + names: string[], + edits: Edit[], +): boolean { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const 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 sortedNames = [...names].sort() + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push( + anchorImport.replace(`${anchorImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + } else if (importNodesToRemove.length >= 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } + 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 getTextContent(element: SgNode): string | null { + const parts: string[] = [] + for (const child of element.children()) { + if (child.kind() === 'jsx_opening_element' || child.kind() === 'jsx_closing_element') { + continue + } + if (child.kind() === 'jsx_text') { + const trimmed = child.text().trim() + if (trimmed.length > 0) { + parts.push(trimmed) + } + } else { + return null + } + } + return parts.length > 0 ? parts.join(' ') : null +} + +interface OptionInfo { + id: string + label: string +} + +function extractMenuItemOptions(selectElement: SgNode, menuItemLocalName: string): OptionInfo[] | null { + const options: OptionInfo[] = [] + const children = getNonWhitespaceChildren(selectElement) + + for (const child of children) { + const kind = child.kind() + + if (kind === 'jsx_element') { + const childOpening = child.child(0) + if (!childOpening) { + return null + } + const childName = getElementName(childOpening) + if (childName !== menuItemLocalName) { + return null + } + + const valueStr = getPropStringValue(childOpening, 'value') + if (!valueStr) { + return null + } + + const label = getTextContent(child) + if (!label) { + return null + } + + options.push({ id: valueStr, label }) + continue + } + + if (kind === 'jsx_self_closing_element') { + const childName = getElementName(child) + if (childName !== menuItemLocalName) { + return null + } + const valueStr = getPropStringValue(child, 'value') + if (!valueStr) { + return null + } + options.push({ id: valueStr, label: valueStr }) + continue + } + + if (kind !== 'jsx_text') { + return null + } + } + + return options.length > 0 ? options : null +} + +function getArrowSingleParamName(arrow: SgNode): string | null { + const paramNode = arrow.field('parameter') + if (paramNode?.is('identifier')) { + return paramNode.text() + } + + const params = arrow.field('parameters') + if (!params) { + return null + } + + if (params.is('identifier')) { + return params.text() + } + + const paramNames: string[] = [] + for (const child of params.children()) { + if (child.is('identifier')) { + paramNames.push(child.text()) + } else if (child.is('required_parameter')) { + const ident = child.find({ rule: { kind: 'identifier' } }) + if (ident) { + paramNames.push(ident.text()) + } + } + } + + if (paramNames.length !== 1) { + return null + } + + return paramNames[0] ?? null +} + +function targetValuePattern(eventName: string): RegExp { + return new RegExp(`${escapeRegex(eventName)}\\.target\\.value`, 'g') +} + +function tryRewriteOnChangeHandler(attr: SgNode): string | null { + const expr = attr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return null + } + + const arrow = expr.find({ rule: { kind: 'arrow_function' } }) + if (!arrow) { + return null + } + + const eventName = getArrowSingleParamName(arrow) + if (!eventName) { + return null + } + + const body = arrow.field('body') + if (!body) { + return null + } + + const bodyText = body.text() + const pattern = targetValuePattern(eventName) + if (!pattern.test(bodyText)) { + return null + } + + const rewrittenBody = bodyText.replace(targetValuePattern(eventName), 'key') + const eventRefPattern = new RegExp(`\\b${escapeRegex(eventName)}\\b`) + if (eventRefPattern.test(rewrittenBody)) { + return null + } + return `{key => ${rewrittenBody}}` +} + +function escapeSingleQuotes(value: string): string { + return value.replaceAll('\\', '\\\\').replaceAll("'", "\\'") +} + +function formatOption(option: OptionInfo): string { + return `{ id: '${escapeSingleQuotes(option.id)}', label: '${escapeSingleQuotes(option.label)}' }` +} + +function findSelectInFormControl( + formControlElement: SgNode, + localNames: Map, +): { + label: string | null + selectEl: SgNode | null + selectOpening: SgNode | null + hasHelperText: boolean +} { + const result = { + label: null as string | null, + selectEl: null as SgNode | null, + selectOpening: null as SgNode | null, + hasHelperText: false, + } + + const inputLabelLocal = [...localNames.entries()].find(([, v]) => v === 'InputLabel')?.[0] ?? null + const selectLocal = [...localNames.entries()].find(([, v]) => v === 'Select')?.[0] ?? null + const helperTextLocal = [...localNames.entries()].find(([, v]) => v === 'FormHelperText')?.[0] ?? null + + const children = getNonWhitespaceChildren(formControlElement) + + for (const child of children) { + const kind = child.kind() + + if (kind === 'jsx_element' || kind === 'jsx_self_closing_element') { + const childOpening = kind === 'jsx_self_closing_element' ? child : child.child(0) + if (!childOpening) { + continue + } + const childName = getElementName(childOpening) + + if (childName && childName === inputLabelLocal) { + if (kind === 'jsx_element') { + result.label = getTextContent(child) + } + continue + } + + if (childName && childName === selectLocal) { + result.selectEl = child + result.selectOpening = childOpening + continue + } + + if (childName && childName === helperTextLocal) { + result.hasHelperText = true + continue + } + } + } + + return result +} + +function transformSelectPatterns( + rootNode: SgNode, + localNames: Map, + edits: Edit[], +): { preserveImport: boolean; migrated: boolean } { + let preserveImport = false + let migrated = false + const formControlLocal = [...localNames.entries()].find(([, v]) => v === 'FormControl')?.[0] ?? null + const selectLocal = [...localNames.entries()].find(([, v]) => v === 'Select')?.[0] ?? null + const menuItemLocal = [...localNames.entries()].find(([, v]) => v === 'MenuItem')?.[0] ?? null + + if (!selectLocal) { + return { preserveImport: false, migrated: false } + } + + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + const processedSelectIds = new Set() + + for (const el of jsxElements) { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + continue + } + + const name = getElementName(opening) + if (!name) { + continue + } + + if (name === formControlLocal && !isSelfClosing) { + const { label, selectEl, selectOpening, hasHelperText } = findSelectInFormControl(el, localNames) + + if (!selectEl || !selectOpening) { + continue + } + + processedSelectIds.add(selectEl.id()) + + if (hasHelperText) { + preserveImport = true + edits.push( + el.replace(wrapWithTodo(`{/* TODO(backstage-codemod): finish Select migration manually */}`, el.text())), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'helper-text' }) + continue + } + + let needsTodo = false + for (const prop of TODO_PROPS) { + if (hasProp(selectOpening, prop)) { + needsTodo = true + break + } + } + + if (needsTodo) { + preserveImport = true + edits.push( + el.replace(wrapWithTodo(`{/* TODO(backstage-codemod): finish Select migration manually */}`, el.text())), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-select-props' }) + continue + } + + let options: OptionInfo[] | null = null + if (menuItemLocal && !selectEl.is('jsx_self_closing_element')) { + options = extractMenuItemOptions(selectEl, menuItemLocal) + } + + if (!options) { + preserveImport = true + edits.push( + el.replace(wrapWithTodo(`{/* TODO(backstage-codemod): finish Select migration manually */}`, el.text())), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-options' }) + continue + } + + const newProps: string[] = [] + + if (label) { + newProps.push(`label={${JSON.stringify(label)}}`) + } + + const valueRaw = getPropRawValue(selectOpening, 'value') + if (valueRaw) { + newProps.push(`selectedKey=${valueRaw}`) + } + + const onChangeAttr = getPropAttr(selectOpening, 'onChange') + if (onChangeAttr) { + const rewritten = tryRewriteOnChangeHandler(onChangeAttr) + if (rewritten) { + newProps.push(`onSelectionChange=${rewritten}`) + migrationMetric.increment({ action: 'onChange-rewritten' }) + } else { + preserveImport = true + edits.push( + el.replace(wrapWithTodo(`{/* TODO(backstage-codemod): finish Select migration manually */}`, el.text())), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-onChange' }) + continue + } + } + + const optionsStr = options.map(formatOption).join(', ') + newProps.push(`options={[${optionsStr}]}`) + + const propsStr = newProps.join(' ') + edits.push(el.replace(``)) + migrated = true + migrationMetric.increment({ action: 'select-migrated' }) + continue + } + } + + return { preserveImport, migrated } +} + +const transform: Codemod = (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { localNames, importNodesToRemove, barrelImportsToPrune } = collectSelectImports(rootNode) + + const hasSelect = [...localNames.values()].includes('Select') + if (!hasSelect) { + return Promise.resolve(null) + } + + const { preserveImport, migrated } = transformSelectPatterns(rootNode, localNames, edits) + + let replacedImport = false + if (migrated) { + replacedImport = addBuiImport(rootNode, importNodesToRemove, ['Select'], edits) + } + + if (!preserveImport) { + for (const { imp, namesToRemove } of barrelImportsToPrune) { + edits.push(imp.replace(rebuildImportWithout(imp, namesToRemove))) + migrationMetric.increment({ action: 'import-pruned' }) + } + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + } + + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) +} + +export default transform diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/basic-form-control/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/basic-form-control/expected.tsx new file mode 100644 index 0000000..53fc638 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/basic-form-control/expected.tsx @@ -0,0 +1,8 @@ +import { Select } from '@backstage/ui'; + + + + +const MyComponent = () => ( + setValue(e.target.value as string)}> + React + Angular + + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/basic-form-control/metrics.json b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/basic-form-control/metrics.json new file mode 100644 index 0000000..5fb7f56 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/basic-form-control/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-select-family-to-bui-select": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 4 + }, + { + "cardinality": { + "action": "onChange-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "select-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/expected.tsx new file mode 100644 index 0000000..28aef66 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/expected.tsx @@ -0,0 +1,19 @@ +import FormControl from '@material-ui/core/FormControl'; +import InputLabel from '@material-ui/core/InputLabel'; +import Select from '@material-ui/core/Select'; +import MenuItem from '@material-ui/core/MenuItem'; +import FormHelperText from '@material-ui/core/FormHelperText'; + +const MyComponent = () => ( + <> +{/* TODO(backstage-codemod): finish Select migration manually */} + + Color + + Pick a color + + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/input.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/input.tsx new file mode 100644 index 0000000..d3bc43b --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/input.tsx @@ -0,0 +1,16 @@ +import FormControl from '@material-ui/core/FormControl'; +import InputLabel from '@material-ui/core/InputLabel'; +import Select from '@material-ui/core/Select'; +import MenuItem from '@material-ui/core/MenuItem'; +import FormHelperText from '@material-ui/core/FormHelperText'; + +const MyComponent = () => ( + + Color + + Pick a color + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/metrics.json b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/metrics.json new file mode 100644 index 0000000..8166561 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/helper-text-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-select-family-to-bui-select": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "helper-text" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..d35829c --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,12 @@ + + + + +import { Button, Select } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + setMode(e.target.value as string)}> + Auto + Manual + + + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..0798bd8 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/merge-existing-bui/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-select-family-to-bui-select": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 4 + }, + { + "cardinality": { + "action": "onChange-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "select-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/expected.tsx new file mode 100644 index 0000000..eb94551 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/expected.tsx @@ -0,0 +1,17 @@ +import FormControl from '@material-ui/core/FormControl'; +import InputLabel from '@material-ui/core/InputLabel'; +import Select from '@material-ui/core/Select'; +import MenuItem from '@material-ui/core/MenuItem'; + +const MyComponent = () => ( + <> +{/* TODO(backstage-codemod): finish Select migration manually */} + + Tags + + + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/input.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/input.tsx new file mode 100644 index 0000000..7196bfb --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/input.tsx @@ -0,0 +1,14 @@ +import FormControl from '@material-ui/core/FormControl'; +import InputLabel from '@material-ui/core/InputLabel'; +import Select from '@material-ui/core/Select'; +import MenuItem from '@material-ui/core/MenuItem'; + +const MyComponent = () => ( + + Tags + + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/metrics.json b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/metrics.json new file mode 100644 index 0000000..d2bb654 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/multiple-select-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-select-family-to-bui-select": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "complex-select-props" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..d896009 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/named-barrel-import/expected.tsx @@ -0,0 +1,5 @@ +import { Select } from '@backstage/ui'; + +const MyComponent = () => ( + setSize(e.target.value as string)}> + Small + Large + + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..3a52d24 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/named-barrel-import/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-select-family-to-bui-select": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onChange-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "select-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..68102c6 --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import { Select } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/tsconfig.json b/codemods/misc/migrate-mui-select-family-to-bui-select/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-select-family-to-bui-select/workflow.yaml b/codemods/misc/migrate-mui-select-family-to-bui-select/workflow.yaml new file mode 100644 index 0000000..edcbfcb --- /dev/null +++ b/codemods/misc/migrate-mui-select-family-to-bui-select/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace Select wrapper patterns with BUI Select' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/CHANGELOG.md b/codemods/misc/migrate-mui-slider-to-bui-slider/CHANGELOG.md new file mode 100644 index 0000000..c602f6d --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-slider-to-bui-slider diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/codemod.yaml b/codemods/misc/migrate-mui-slider-to-bui-slider/codemod.yaml new file mode 100644 index 0000000..0c9cfc9 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-slider-to-bui-slider' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Slider with BUI Slider' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'slider'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/package.json b/codemods/misc/migrate-mui-slider-to-bui-slider/package.json new file mode 100644 index 0000000..2bed7ad --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-slider-to-bui-slider", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Slider with BUI Slider", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/scripts/codemod.ts b/codemods/misc/migrate-mui-slider-to-bui-slider/scripts/codemod.ts new file mode 100644 index 0000000..beccd08 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/scripts/codemod.ts @@ -0,0 +1,475 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-slider-to-bui-slider') + +const BUI_SOURCE = '@backstage/ui' +const MUI_BARREL_SOURCE = '@material-ui/core' + +/** Props that rename mechanically. */ +const PROP_RENAMES: Record = { + min: 'minValue', + max: 'maxValue', + disabled: 'isDisabled', +} + +/** Props that pass through unchanged. */ +const PASSTHROUGH_PROPS = new Set([ + 'step', + 'value', + 'defaultValue', + 'name', + 'id', + 'className', + 'style', + 'aria-label', + 'aria-labelledby', + 'aria-valuetext', +]) + +/** Props that trigger a TODO — not mechanically migratable. */ +const TODO_PROPS = new Set([ + 'marks', + 'track', + 'orientation', + 'scale', + 'getAriaLabel', + 'getAriaValueText', + 'valueLabelDisplay', + 'valueLabelFormat', + 'ValueLabelComponent', + 'ThumbComponent', + 'classes', + 'color', +]) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function wrapWithTodo(todoComment: string, elementText: string): string { + return `<> +${todoComment} +${elementText} +` +} + +function rebuildImportWithout(importStmt: SgNode, specifiersToRemove: Set): string { + const specifiers = importStmt.findAll({ rule: { kind: 'import_specifier' } }) + const remaining: string[] = [] + for (const spec of specifiers) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const importedName = identifiers[0]?.text() + if (importedName && !specifiersToRemove.has(importedName)) { + remaining.push(spec.text()) + } + } + + if (remaining.length === 0) { + return '' + } + + const sourceNode = importStmt.find({ rule: { kind: 'string' } }) + const sourceText = sourceNode?.text() ?? `'${MUI_BARREL_SOURCE}'` + + if (remaining.length <= 2) { + return `import { ${remaining.join(', ')} } from ${sourceText};` + } + return `import {\n ${remaining.join(',\n ')},\n} from ${sourceText};` +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): string | null { + const clause = imp.find({ rule: { kind: 'import_clause' } }) + if (!clause) { + return null + } + for (const child of clause.children()) { + if (child.is('identifier')) { + return child.text() + } + } + return null +} + +function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (importedNameNode?.text() === targetName) { + const localNameNode = identifiers[1] ?? importedNameNode + return localNameNode.text() + } + } + return null +} + +function collectSliderImports(rootNode: SgNode): { + sliderLocalName: string | null + importNodesToRemove: SgNode[] + barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] +} { + let sliderLocalName: string | null = null + const importNodesToRemove: SgNode[] = [] + const barrelImportsToPrune: { imp: SgNode; namesToRemove: Set }[] = [] + + // Default import: import Slider from '@material-ui/core/Slider' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Slider')) { + sliderLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + // Named import from barrel: import { Slider } from '@material-ui/core' + for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL_SOURCE)) { + const localName = getNamedImportLocalName(imp, 'Slider') + if (localName) { + sliderLocalName = localName + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (allSpecifiers.length <= 1) { + importNodesToRemove.push(imp) + } else { + barrelImportsToPrune.push({ imp, namesToRemove: new Set(['Slider']) }) + } + } + } + + return { sliderLocalName, importNodesToRemove, barrelImportsToPrune } +} + +function addSliderToBuiImport(rootNode: SgNode, importNodesToRemove: SgNode[], edits: Edit[]): boolean { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const names = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + if (!names.includes('Slider')) { + names.push('Slider') + } + names.sort() + edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + return false + } + + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push(anchorImport.replace(`${anchorImport.text()}\nimport { Slider } from '${BUI_SOURCE}';`)) + } else if (importNodesToRemove.length >= 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { Slider } 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 getParamName(paramNode: SgNode): string { + const ident = paramNode.find({ rule: { kind: 'identifier' } }) + return ident?.text() ?? paramNode.text().replace(/:.*$/, '').trim() +} + +/** + * Check if the onChange handler is a trivial arrow `(_e, val) => ...` + * where the event param is unused (starts with _). + * Returns the rewritten handler text without the event param, or null if complex. + */ +function tryRewriteOnChangeHandler(attr: SgNode): string | null { + const expr = attr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return null + } + + // Look for arrow function: (_e, val) => body + const arrow = expr.find({ rule: { kind: 'arrow_function' } }) + if (!arrow) { + return null + } + + const params = arrow.field('parameters') + if (!params) { + return null + } + + // Must be a formal_parameters node with exactly 2 params + if (params.kind() !== 'formal_parameters') { + return null + } + + const paramChildren: SgNode[] = [] + for (const child of params.children()) { + if (child.is('required_parameter') || child.is('identifier')) { + paramChildren.push(child) + } + } + + if (paramChildren.length !== 2) { + return null + } + + const [eventParam, valueParam] = paramChildren + if (!eventParam || !valueParam) { + return null + } + + // Event param must start with _ to indicate unused + const eventName = getParamName(eventParam) + if (!eventName.startsWith('_')) { + return null + } + + const valueText = getParamName(valueParam) + const body = arrow.field('body') + if (!body) { + return null + } + + const bodyText = body.text() + return `{${valueText} => ${bodyText}}` +} + +function transformSliderElements( + rootNode: SgNode, + sliderLocalName: string, + edits: Edit[], +): { preserveImport: boolean; migrated: boolean } { + let preserveImport = false + let migrated = false + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + for (const el of jsxElements) { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + continue + } + + const name = getElementName(opening) + if (name !== sliderLocalName) { + continue + } + + // Check for TODO-triggering props + let needsTodo = false + const todoReasons: string[] = [] + + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + needsTodo = true + todoReasons.push(prop) + } + } + + if (needsTodo) { + preserveImport = true + edits.push( + el.replace( + wrapWithTodo( + `{/* TODO(backstage-codemod): finish slider migration manually (${todoReasons.join(', ')}) */}`, + el.text(), + ), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: todoReasons.join(', ') }) + continue + } + + const newProps: string[] = [] + let handlerTodo = false + + for (const child of opening.children()) { + const kind = child.kind() + if (kind === 'jsx_attribute') { + const propIdent = child.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + const propName = propIdent.text() + const renamed = PROP_RENAMES[propName] + if (renamed) { + let valuePart: string | null = null + for (const attrChild of child.children()) { + const attrKind = attrChild.kind() + if (attrKind === 'string' || attrKind === 'jsx_expression') { + valuePart = attrChild.text() + break + } + } + newProps.push(valuePart !== null ? `${renamed}=${valuePart}` : renamed) + migrationMetric.increment({ action: 'prop-renamed', from: propName, to: renamed }) + continue + } + if (propName === 'onChange') { + const rewritten = tryRewriteOnChangeHandler(child) + if (rewritten !== null) { + newProps.push(`onChange=${rewritten}`) + migrationMetric.increment({ action: 'onChange-rewritten' }) + } else { + preserveImport = true + edits.push( + el.replace( + wrapWithTodo( + `{/* TODO(backstage-codemod): finish slider migration manually (complex-onChange) */}`, + el.text(), + ), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-onChange' }) + handlerTodo = true + break + } + continue + } + if (propName === 'onChangeCommitted') { + const rewritten = tryRewriteOnChangeHandler(child) + if (rewritten !== null) { + newProps.push(`onChangeEnd=${rewritten}`) + migrationMetric.increment({ action: 'onChangeCommitted-rewritten' }) + } else { + preserveImport = true + edits.push( + el.replace( + wrapWithTodo( + `{/* TODO(backstage-codemod): finish slider migration manually (onChangeCommitted) */}`, + el.text(), + ), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'onChangeCommitted' }) + handlerTodo = true + } + continue + } + if (PASSTHROUGH_PROPS.has(propName)) { + newProps.push(child.text()) + continue + } + newProps.push(child.text()) + } else if (kind === 'jsx_expression' && child.text().startsWith('{...')) { + newProps.push(child.text()) + } + } + + if (handlerTodo) { + continue + } + + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + + if (isSelfClosing) { + edits.push(el.replace(``)) + } else { + // Preserve children via AST traversal + const children = el + .children() + .filter((c) => c.kind() !== 'jsx_opening_element' && c.kind() !== 'jsx_closing_element') + .map((c) => c.text()) + .join('') + edits.push(el.replace(`${children}`)) + } + + migrated = true + migrationMetric.increment({ action: 'slider-migrated' }) + } + + return { preserveImport, migrated } +} + +const transform: Codemod = (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { sliderLocalName, importNodesToRemove, barrelImportsToPrune } = collectSliderImports(rootNode) + + if (!sliderLocalName) { + return Promise.resolve(null) + } + + const { preserveImport, migrated } = transformSliderElements(rootNode, sliderLocalName, edits) + + let replacedImport = false + if (migrated) { + replacedImport = addSliderToBuiImport(rootNode, importNodesToRemove, edits) + } + + if (!preserveImport) { + for (const { imp, namesToRemove } of barrelImportsToPrune) { + edits.push(imp.replace(rebuildImportWithout(imp, namesToRemove))) + migrationMetric.increment({ action: 'import-pruned' }) + } + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + } + + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) +} + +export default transform diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/expected.tsx new file mode 100644 index 0000000..73e510d --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/expected.tsx @@ -0,0 +1,5 @@ +import { Slider } from '@backstage/ui'; + +const MyComponent = () => ( + setValue(next as number)} /> +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/input.tsx new file mode 100644 index 0000000..90af9ec --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/input.tsx @@ -0,0 +1,5 @@ +import Slider from '@material-ui/core/Slider'; + +const MyComponent = () => ( + setValue(next as number)} /> +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/metrics.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/metrics.json new file mode 100644 index 0000000..19ba817 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/basic-slider/metrics.json @@ -0,0 +1,44 @@ +{ + "migrate-mui-slider-to-bui-slider": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onChange-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "max", + "to": "maxValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "min", + "to": "minValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "slider-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/expected.tsx new file mode 100644 index 0000000..fee4fc8 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/expected.tsx @@ -0,0 +1,8 @@ +import Slider from '@material-ui/core/Slider'; + +const MyComponent = () => ( + <> +{/* TODO(backstage-codemod): finish slider migration manually (complex-onChange) */} + + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/input.tsx new file mode 100644 index 0000000..bf19d84 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/input.tsx @@ -0,0 +1,5 @@ +import Slider from '@material-ui/core/Slider'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/metrics.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/metrics.json new file mode 100644 index 0000000..8783248 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/complex-on-change-todo/metrics.json @@ -0,0 +1,27 @@ +{ + "migrate-mui-slider-to-bui-slider": [ + { + "cardinality": { + "action": "prop-renamed", + "from": "max", + "to": "maxValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "min", + "to": "minValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "complex-onChange" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/expected.tsx new file mode 100644 index 0000000..83b6795 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/expected.tsx @@ -0,0 +1,5 @@ +import { Slider } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/input.tsx new file mode 100644 index 0000000..4784c20 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/input.tsx @@ -0,0 +1,5 @@ +import Slider from '@material-ui/core/Slider'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/metrics.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/metrics.json new file mode 100644 index 0000000..fe41229 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/disabled-and-aria/metrics.json @@ -0,0 +1,46 @@ +{ + "migrate-mui-slider-to-bui-slider": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "disabled", + "to": "isDisabled" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "max", + "to": "maxValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "min", + "to": "minValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "slider-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/expected.tsx new file mode 100644 index 0000000..f3f69ea --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/expected.tsx @@ -0,0 +1,8 @@ +import Slider from '@material-ui/core/Slider'; + +const MyComponent = () => ( + <> +{/* TODO(backstage-codemod): finish slider migration manually (marks) */} + + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/input.tsx new file mode 100644 index 0000000..997ba66 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/input.tsx @@ -0,0 +1,5 @@ +import Slider from '@material-ui/core/Slider'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/metrics.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/metrics.json new file mode 100644 index 0000000..9e7aa26 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/marks-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-slider-to-bui-slider": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "marks" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..0aa8670 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + +import { Button, Slider } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..ba51e43 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/input.tsx @@ -0,0 +1,9 @@ +import Slider from '@material-ui/core/Slider'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..d864256 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/merge-existing-bui/metrics.json @@ -0,0 +1,38 @@ +{ + "migrate-mui-slider-to-bui-slider": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "max", + "to": "maxValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "min", + "to": "minValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "slider-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..9730aed --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/expected.tsx @@ -0,0 +1,5 @@ +import { Slider } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..257029c --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/input.tsx @@ -0,0 +1,5 @@ +import { Slider } from '@material-ui/core'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..8a25112 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/named-barrel-import/metrics.json @@ -0,0 +1,38 @@ +{ + "migrate-mui-slider-to-bui-slider": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "max", + "to": "maxValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "min", + "to": "minValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "slider-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/expected.tsx new file mode 100644 index 0000000..de40dc0 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/expected.tsx @@ -0,0 +1,5 @@ +import { Slider } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/input.tsx new file mode 100644 index 0000000..446b9df --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/input.tsx @@ -0,0 +1,5 @@ +import Slider from '@material-ui/core/Slider'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/metrics.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/metrics.json new file mode 100644 index 0000000..6493d1e --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/no-min-max/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-slider-to-bui-slider": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "slider-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..0750ff6 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import { Slider } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..0750ff6 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/noop-no-import/input.tsx @@ -0,0 +1,5 @@ +import { Slider } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/expected.tsx new file mode 100644 index 0000000..25c54a4 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/expected.tsx @@ -0,0 +1,5 @@ +import { Slider } from '@backstage/ui'; + +const MyComponent = () => ( + save(val)} /> +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/input.tsx new file mode 100644 index 0000000..c22ec86 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/input.tsx @@ -0,0 +1,5 @@ +import Slider from '@material-ui/core/Slider'; + +const MyComponent = () => ( + save(val)} /> +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/metrics.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/metrics.json new file mode 100644 index 0000000..3bed2e3 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/on-change-committed-todo/metrics.json @@ -0,0 +1,44 @@ +{ + "migrate-mui-slider-to-bui-slider": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onChangeCommitted-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "max", + "to": "maxValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "min", + "to": "minValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "slider-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/expected.tsx new file mode 100644 index 0000000..9450229 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/expected.tsx @@ -0,0 +1,5 @@ +import { Slider } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/input.tsx new file mode 100644 index 0000000..f82ecd2 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/input.tsx @@ -0,0 +1,5 @@ +import Slider from '@material-ui/core/Slider'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/metrics.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/metrics.json new file mode 100644 index 0000000..8a25112 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/step-and-default-value/metrics.json @@ -0,0 +1,38 @@ +{ + "migrate-mui-slider-to-bui-slider": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "max", + "to": "maxValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "min", + "to": "minValue" + }, + "count": 1 + }, + { + "cardinality": { + "action": "slider-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/expected.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/expected.tsx new file mode 100644 index 0000000..290de74 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/expected.tsx @@ -0,0 +1,8 @@ +import Slider from '@material-ui/core/Slider'; + +const MyComponent = () => ( + <> +{/* TODO(backstage-codemod): finish slider migration manually (valueLabelDisplay) */} + + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/input.tsx b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/input.tsx new file mode 100644 index 0000000..2bc1d81 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/input.tsx @@ -0,0 +1,5 @@ +import Slider from '@material-ui/core/Slider'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/metrics.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/metrics.json new file mode 100644 index 0000000..eb334e6 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tests/value-label-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-slider-to-bui-slider": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "valueLabelDisplay" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/tsconfig.json b/codemods/misc/migrate-mui-slider-to-bui-slider/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-slider-to-bui-slider/workflow.yaml b/codemods/misc/migrate-mui-slider-to-bui-slider/workflow.yaml new file mode 100644 index 0000000..a3fa3a9 --- /dev/null +++ b/codemods/misc/migrate-mui-slider-to-bui-slider/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace Slider with BUI Slider' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/CHANGELOG.md b/codemods/misc/migrate-mui-textfield-to-bui-textfield/CHANGELOG.md new file mode 100644 index 0000000..60c09c9 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-textfield-to-bui-textfield diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/codemod.yaml b/codemods/misc/migrate-mui-textfield-to-bui-textfield/codemod.yaml new file mode 100644 index 0000000..aceb31f --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-textfield-to-bui-textfield' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace TextField with BUI TextField' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'textfield'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/package.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/package.json new file mode 100644 index 0000000..08729ca --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-textfield-to-bui-textfield", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace TextField with BUI TextField", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/scripts/codemod.ts b/codemods/misc/migrate-mui-textfield-to-bui-textfield/scripts/codemod.ts new file mode 100644 index 0000000..9e0a3d4 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/scripts/codemod.ts @@ -0,0 +1,469 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-textfield-to-bui-textfield') + +const BUI_SOURCE = '@backstage/ui' + +/** Props that trigger a TODO — not mechanically migratable. */ +const TODO_PROPS = new Set([ + 'multiline', + 'rows', + 'rowsMax', + 'minRows', + 'maxRows', + 'select', + 'SelectProps', + 'InputProps', + 'inputProps', + 'InputLabelProps', + 'FormHelperTextProps', + 'helperText', + 'error', + 'variant', + 'margin', + 'size', + 'color', + 'classes', + 'inputRef', + 'InputAdornment', +]) + +/** Props that rename mechanically. */ +const PROP_RENAMES: Record = { + required: 'isRequired', + disabled: 'isDisabled', +} + +/** Props that pass through unchanged (documented for manual review). */ +const _PASSTHROUGH_PROPS = new Set([ + 'label', + 'value', + 'defaultValue', + 'placeholder', + 'name', + 'id', + 'type', + 'autoFocus', + 'autoComplete', + 'className', + 'style', + 'aria-label', + 'aria-labelledby', + 'data-testid', +]) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function wrapWithTodo(todoComment: string, elementText: string): string { + return `<> +${todoComment} +${elementText} +` +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): string | null { + const clause = imp.find({ rule: { kind: 'import_clause' } }) + if (!clause) { + return null + } + for (const child of clause.children()) { + if (child.is('identifier')) { + return child.text() + } + } + return null +} + +function getNamedImportLocalName(imp: SgNode, targetName: string): string | null { + for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (importedNameNode?.text() === targetName) { + const localNameNode = identifiers[1] ?? importedNameNode + return localNameNode.text() + } + } + return null +} + +function collectTextFieldImports(rootNode: SgNode): { + textFieldLocalName: string | null + importNodesToRemove: SgNode[] +} { + let textFieldLocalName: string | null = null + const importNodesToRemove: SgNode[] = [] + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/TextField')) { + textFieldLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + const localName = getNamedImportLocalName(imp, 'TextField') + if (localName) { + textFieldLocalName = localName + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (allSpecifiers.length <= 1) { + importNodesToRemove.push(imp) + } + } + } + + return { textFieldLocalName, importNodesToRemove } +} + +function addTextFieldToBuiImport(rootNode: SgNode, importNodesToRemove: SgNode[], edits: Edit[]): boolean { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const names = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + if (!names.includes('TextField')) { + names.push('TextField') + } + names.sort() + edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + return false + } + + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push(anchorImport.replace(`${anchorImport.text()}\nimport { TextField } from '${BUI_SOURCE}';`)) + } else if (importNodesToRemove.length >= 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { TextField } 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 getParamName(paramNode: SgNode): string { + const ident = paramNode.find({ rule: { kind: 'identifier' } }) + return ident?.text() ?? paramNode.text().replace(/:.*$/, '').trim() +} + +function getArrowSingleParamName(arrow: SgNode): string | null { + const parameter = arrow.field('parameter') + if (parameter) { + return getParamName(parameter) + } + + const params = arrow.field('parameters') + if (!params) { + return null + } + + if (params.is('identifier')) { + return params.text() + } + + if (params.kind() !== 'formal_parameters') { + return null + } + + const paramChildren: SgNode[] = [] + for (const child of params.children()) { + if (child.is('required_parameter') || child.is('identifier')) { + paramChildren.push(child) + } + } + + if (paramChildren.length !== 1) { + return null + } + + const [param] = paramChildren + if (!param) { + return null + } + + return getParamName(param) +} + +function targetValuePattern(eventName: string): RegExp { + return new RegExp(`${escapeRegex(eventName)}\\.target\\.value`, 'g') +} + +function tryRewriteOnChangeHandler(attr: SgNode): string | null { + const expr = attr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return null + } + + const arrow = expr.find({ rule: { kind: 'arrow_function' } }) + if (!arrow) { + return null + } + + const eventName = getArrowSingleParamName(arrow) + if (!eventName) { + return null + } + + const body = arrow.field('body') + if (!body) { + return null + } + + const bodyText = body.text() + const pattern = targetValuePattern(eventName) + if (!pattern.test(bodyText)) { + return null + } + + const rewrittenBody = bodyText.replace(targetValuePattern(eventName), 'newValue') + const eventRefPattern = new RegExp(`\\b${escapeRegex(eventName)}\\b`) + if (eventRefPattern.test(rewrittenBody)) { + return null + } + return `{newValue => ${rewrittenBody}}` +} + +function transformTextFieldElements( + rootNode: SgNode, + textFieldLocalName: string, + edits: Edit[], +): { preserveImport: boolean; migrated: boolean } { + let preserveImport = false + let migrated = false + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + for (const el of jsxElements) { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + continue + } + + const name = getElementName(opening) + if (name !== textFieldLocalName) { + continue + } + + let needsTodo = false + const todoReasons: string[] = [] + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + needsTodo = true + todoReasons.push(prop) + } + } + + if (needsTodo) { + preserveImport = true + edits.push( + el.replace( + wrapWithTodo( + `{/* TODO(backstage-codemod): finish TextField migration manually (${todoReasons.join(', ')}) */}`, + el.text(), + ), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: todoReasons.join(', ') }) + continue + } + + const newProps: string[] = [] + let handlerTodo = false + let droppedFullWidth = false + + for (const child of opening.children()) { + const kind = child.kind() + if (kind === 'jsx_attribute') { + const propIdent = child.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + const propName = propIdent.text() + const renamed = PROP_RENAMES[propName] + if (renamed) { + const exprNode = child.find({ rule: { kind: 'jsx_expression' } }) + const strNode = child.find({ rule: { kind: 'string' } }) + if (exprNode) { + newProps.push(`${renamed}=${exprNode.text()}`) + } else if (strNode) { + newProps.push(`${renamed}=${strNode.text()}`) + } else { + newProps.push(renamed) + } + migrationMetric.increment({ action: 'prop-renamed', from: propName, to: renamed }) + continue + } + if (propName === 'onChange') { + const rewritten = tryRewriteOnChangeHandler(child) + if (rewritten !== null) { + newProps.push(`onChange=${rewritten}`) + migrationMetric.increment({ action: 'onChange-rewritten' }) + } else { + preserveImport = true + edits.push( + el.replace( + wrapWithTodo( + `{/* TODO(backstage-codemod): finish TextField migration manually (complex-onChange) */}`, + el.text(), + ), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-onChange' }) + handlerTodo = true + break + } + continue + } + if (propName === 'fullWidth') { + droppedFullWidth = true + migrationMetric.increment({ action: 'prop-dropped', prop: 'fullWidth' }) + migrationMetric.increment({ action: 'todo-inserted', reason: 'fullWidth' }) + continue + } + newProps.push(child.text()) + } else if (kind === 'jsx_expression' && child.text().startsWith('{...')) { + newProps.push(child.text()) + } + } + + if (handlerTodo) { + continue + } + + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + + if (isSelfClosing) { + if (droppedFullWidth) { + edits.push( + el.replace( + wrapWithTodo( + '{/* TODO(backstage-codemod): finish TextField migration manually (fullWidth) */}', + ``, + ), + ), + ) + } else { + edits.push(el.replace(``)) + } + } else { + const children = el + .children() + .filter((c) => c.kind() !== 'jsx_opening_element' && c.kind() !== 'jsx_closing_element') + .map((c) => c.text()) + .join('') + if (droppedFullWidth) { + edits.push( + el.replace( + wrapWithTodo( + '{/* TODO(backstage-codemod): finish TextField migration manually (fullWidth) */}', + `${children}`, + ), + ), + ) + } else { + edits.push(el.replace(`${children}`)) + } + } + + migrated = true + migrationMetric.increment({ action: 'textfield-migrated' }) + } + + return { preserveImport, migrated } +} + +const transform: Codemod = (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { textFieldLocalName, importNodesToRemove } = collectTextFieldImports(rootNode) + + if (!textFieldLocalName) { + return Promise.resolve(null) + } + + const { preserveImport, migrated } = transformTextFieldElements(rootNode, textFieldLocalName, edits) + + let replacedImport = false + if (migrated) { + replacedImport = addTextFieldToBuiImport(rootNode, importNodesToRemove, edits) + } + + if (!preserveImport) { + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + } + + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) +} + +export default transform diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/expected.tsx new file mode 100644 index 0000000..fb1f7b1 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/expected.tsx @@ -0,0 +1,5 @@ +import { TextField } from '@backstage/ui'; + +const MyComponent = () => ( + setValue(newValue)} /> +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/input.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/input.tsx new file mode 100644 index 0000000..d203c23 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/input.tsx @@ -0,0 +1,10 @@ +import TextField from '@material-ui/core/TextField'; + +const MyComponent = () => ( + setValue(e.target.value)} + /> +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/metrics.json new file mode 100644 index 0000000..f297188 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/basic-controlled/metrics.json @@ -0,0 +1,36 @@ +{ + "migrate-mui-textfield-to-bui-textfield": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onChange-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "required", + "to": "isRequired" + }, + "count": 1 + }, + { + "cardinality": { + "action": "textfield-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/expected.tsx new file mode 100644 index 0000000..90fb07b --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/expected.tsx @@ -0,0 +1,8 @@ +import TextField from '@material-ui/core/TextField'; + +const MyComponent = () => ( + <> +{/* TODO(backstage-codemod): finish TextField migration manually (complex-onChange) */} + + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/input.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/input.tsx new file mode 100644 index 0000000..23a61e9 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/input.tsx @@ -0,0 +1,5 @@ +import TextField from '@material-ui/core/TextField'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/metrics.json new file mode 100644 index 0000000..f8d5369 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/complex-on-change-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-textfield-to-bui-textfield": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "complex-onChange" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/expected.tsx new file mode 100644 index 0000000..6425a8c --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/expected.tsx @@ -0,0 +1,8 @@ +import { TextField } from '@backstage/ui'; + +const MyComponent = () => ( + <> +{/* TODO(backstage-codemod): finish TextField migration manually (fullWidth) */} + + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/input.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/input.tsx new file mode 100644 index 0000000..12ad529 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/input.tsx @@ -0,0 +1,5 @@ +import TextField from '@material-ui/core/TextField'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/metrics.json new file mode 100644 index 0000000..c102358 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/disabled-and-fullwidth/metrics.json @@ -0,0 +1,44 @@ +{ + "migrate-mui-textfield-to-bui-textfield": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-dropped", + "prop": "fullWidth" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-renamed", + "from": "disabled", + "to": "isDisabled" + }, + "count": 1 + }, + { + "cardinality": { + "action": "textfield-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "fullWidth" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..a3deac1 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + +import { Button, TextField } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + setEmail(newValue)} /> + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..68e507e --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/input.tsx @@ -0,0 +1,9 @@ +import TextField from '@material-ui/core/TextField'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + setEmail(e.target.value)} /> + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..419552c --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/merge-existing-bui/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-textfield-to-bui-textfield": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onChange-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "textfield-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/expected.tsx new file mode 100644 index 0000000..7b79f17 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/expected.tsx @@ -0,0 +1,8 @@ +import TextField from '@material-ui/core/TextField'; + +const MyComponent = () => ( + <> +{/* TODO(backstage-codemod): finish TextField migration manually (multiline, rows) */} + setDesc(e.target.value)} /> + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/input.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/input.tsx new file mode 100644 index 0000000..8ce95a8 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/input.tsx @@ -0,0 +1,5 @@ +import TextField from '@material-ui/core/TextField'; + +const MyComponent = () => ( + setDesc(e.target.value)} /> +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/metrics.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/metrics.json new file mode 100644 index 0000000..9cba3f2 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/multiline-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-textfield-to-bui-textfield": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "multiline, rows" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..d4be079 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import { TextField } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..d4be079 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tests/noop-no-import/input.tsx @@ -0,0 +1,5 @@ +import { TextField } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/tsconfig.json b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-textfield-to-bui-textfield/workflow.yaml b/codemods/misc/migrate-mui-textfield-to-bui-textfield/workflow.yaml new file mode 100644 index 0000000..d027ff6 --- /dev/null +++ b/codemods/misc/migrate-mui-textfield-to-bui-textfield/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace TextField with BUI TextField' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/yarn.lock b/yarn.lock index afce4d8..e4cecd4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -172,6 +172,51 @@ __metadata: languageName: unknown linkType: soft +"@backstage/migrate-mui-accordion-to-bui-accordion@workspace:codemods/misc/migrate-mui-accordion-to-bui-accordion": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-accordion-to-bui-accordion@workspace:codemods/misc/migrate-mui-accordion-to-bui-accordion" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-radio-checkbox-to-bui@workspace:codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-radio-checkbox-to-bui@workspace:codemods/misc/migrate-mui-radio-checkbox-groups-to-bui-groups" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-select-family-to-bui-select@workspace:codemods/misc/migrate-mui-select-family-to-bui-select": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-select-family-to-bui-select@workspace:codemods/misc/migrate-mui-select-family-to-bui-select" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-slider-to-bui-slider@workspace:codemods/misc/migrate-mui-slider-to-bui-slider": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-slider-to-bui-slider@workspace:codemods/misc/migrate-mui-slider-to-bui-slider" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-textfield-to-bui-textfield@workspace:codemods/misc/migrate-mui-textfield-to-bui-textfield": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-textfield-to-bui-textfield@workspace:codemods/misc/migrate-mui-textfield-to-bui-textfield" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + "@backstage/migrate-nav-item-to-page@workspace:codemods/v1.51.0/migrate-nav-item-to-page": version: 0.0.0-use.local resolution: "@backstage/migrate-nav-item-to-page@workspace:codemods/v1.51.0/migrate-nav-item-to-page"