diff --git a/packages/components/codemods/README.md b/packages/components/codemods/README.md index 200bb3296a5..d640b35ef9c 100644 --- a/packages/components/codemods/README.md +++ b/packages/components/codemods/README.md @@ -103,6 +103,41 @@ Released in `1.60.0` Removes `Popover` component props `variant` and `customIcon`. +### `upgradeV1Buttons` + +Released in `TBC` + +Migrates `Button` and `IconButton` component to `Button` V3 or `LinkButton`. + +#### Props + +- `label` becomes `children` + - eg. `` becomes `` +- `onClick` becomes `onPress` +- (TO DO) Variants +- (TO DO) Sizes +- `reversed` becomes `isReversed` +- `classNameOverride` becomes `className` +- `data-automation-id` becomes `data-testid` +- `disabled` becomes `isDisabled` +- (TO DO) Only when `href` exists: + - `newTabAndIUnderstandTheAccessibilityImplications` becomes `target="_blank"` + - `rel="noopener noreferrer"` is also added +- `component` will not be removed by the codemod, but will throw a TypeScript error as the prop itself no longer exists + - (TO DO) A `@todo` comment will be prepended to the prop +- For `IconButton` only: + - `hasHiddenLabel` will be added + +#### Component transformation + +- `Button`/`IconButton` without the `href` or `component` prop will become `Button` V3 +- `Button`/`IconButton` with the `href` prop will become `LinkButton` +- `Button`/`IconButton` with the `component` prop will become `LinkButton` + +#### Imports + +All imports of V1 Buttons will now point to `@kaizen/components/v3/actions`. + ### `upgradeIconV1` Released in `1.67.0`; last updated in `1.68.1` diff --git a/packages/components/codemods/upgradeIconV1/index.ts b/packages/components/codemods/upgradeIconV1/index.ts index 24ac5ba9089..dea5c3a35de 100644 --- a/packages/components/codemods/upgradeIconV1/index.ts +++ b/packages/components/codemods/upgradeIconV1/index.ts @@ -1,6 +1,4 @@ -import fs from 'fs' -import { getKaioTagNamesByRegex, transformSource, traverseDir } from '../utils' -import { createEncodedSourceFile } from '../utils/createEncodedSourceFile' +import { transformComponentsAndImportsInDirByPattern } from '../utils' import { upgradeIconV1 } from './upgradeIconV1' const run = (): void => { @@ -11,20 +9,9 @@ const run = (): void => { process.exit(1) } - const transformFile = (componentFilePath: string, sourceCode: string): void => { - const sourceFile = createEncodedSourceFile(componentFilePath, sourceCode) - const tagNames = getKaioTagNamesByRegex(sourceFile, 'Icon$') - if (tagNames) { - const updatedSourceFile = transformSource({ - sourceFile, - transformers: [upgradeIconV1(tagNames)], - }) - - fs.writeFileSync(componentFilePath, updatedSourceFile, 'utf8') - } - } - - traverseDir(targetDir, transformFile) + transformComponentsAndImportsInDirByPattern(targetDir, 'Icon$', (tagNames) => [ + upgradeIconV1(tagNames), + ]) } run() diff --git a/packages/components/codemods/upgradeIconV1/upgradeIconV1.spec.ts b/packages/components/codemods/upgradeIconV1/upgradeIconV1.spec.ts index b23543f4e30..7277df50acc 100644 --- a/packages/components/codemods/upgradeIconV1/upgradeIconV1.spec.ts +++ b/packages/components/codemods/upgradeIconV1/upgradeIconV1.spec.ts @@ -1,20 +1,19 @@ import { parseJsx } from '../__tests__/utils' import { + getKaioTagNamesMapByPattern, printAst, transformSource, - type ImportModuleNameTagsMap, type TransformSourceArgs, } from '../utils' import { upgradeIconV1 } from './upgradeIconV1' -const transformIcons = ( - sourceFile: TransformSourceArgs['sourceFile'], - tagNames: ImportModuleNameTagsMap, -): string => - transformSource({ +const transformIcons = (sourceFile: TransformSourceArgs['sourceFile']): string => { + const kaioTagNamesMap = getKaioTagNamesMapByPattern(sourceFile, 'Icon$') + return transformSource({ sourceFile, - transformers: [upgradeIconV1(tagNames)], + transformers: [upgradeIconV1(kaioTagNamesMap!)], }) +} describe('upgradeIconV1()', () => { describe('CaMonogramIcon to Brand', () => { @@ -29,12 +28,7 @@ describe('upgradeIconV1()', () => { import { Brand } from "@kaizen/components" export const TestComponent = () => `) - expect( - transformIcons( - inputAst, - new Map([['@kaizen/components', new Map([['CaMonogramIcon', 'CaMonogramIcon']])]]), - ), - ).toEqual(printAst(outputAst)) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) }) it('updates import from CaMonogramIcon using alias to Brand', () => { @@ -46,12 +40,7 @@ describe('upgradeIconV1()', () => { import { Brand } from "@kaizen/components" export const TestComponent = () => `) - expect( - transformIcons( - inputAst, - new Map([['@kaizen/components', new Map([['LogoAlias', 'CaMonogramIcon']])]]), - ), - ).toEqual(printAst(outputAst)) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) }) it('does not add another Brand import if it is already imported', () => { @@ -63,20 +52,7 @@ describe('upgradeIconV1()', () => { import { Brand } from "@kaizen/components" export const TestComponent = () => `) - expect( - transformIcons( - inputAst, - new Map([ - [ - '@kaizen/components', - new Map([ - ['Brand', 'Brand'], - ['CaMonogramIcon', 'CaMonogramIcon'], - ]), - ], - ]), - ), - ).toEqual(printAst(outputAst)) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) }) it('uses Brand alias for an existing import', () => { @@ -88,20 +64,7 @@ describe('upgradeIconV1()', () => { import { Brand as KzBrand } from "@kaizen/components" export const TestComponent = () => `) - expect( - transformIcons( - inputAst, - new Map([ - [ - '@kaizen/components', - new Map([ - ['KzBrand', 'Brand'], - ['CaMonogramIcon', 'CaMonogramIcon'], - ]), - ], - ]), - ), - ).toEqual(printAst(outputAst)) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) }) }) @@ -117,12 +80,7 @@ describe('upgradeIconV1()', () => { import { LoadingSpinner } from "@kaizen/components" export const TestComponent = () => `) - expect( - transformIcons( - inputAst, - new Map([['@kaizen/components', new Map([['SpinnerIcon', 'SpinnerIcon']])]]), - ), - ).toEqual(printAst(outputAst)) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) }) it('updates import from SpinnerIcon using alias to LoadingSpinner', () => { @@ -134,12 +92,7 @@ describe('upgradeIconV1()', () => { import { LoadingSpinner } from "@kaizen/components" export const TestComponent = () => `) - expect( - transformIcons( - inputAst, - new Map([['@kaizen/components', new Map([['LogoAlias', 'SpinnerIcon']])]]), - ), - ).toEqual(printAst(outputAst)) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) }) it('does not add another LoadingSpinner import if it is already imported', () => { @@ -151,20 +104,7 @@ describe('upgradeIconV1()', () => { import { LoadingSpinner } from "@kaizen/components" export const TestComponent = () => `) - expect( - transformIcons( - inputAst, - new Map([ - [ - '@kaizen/components', - new Map([ - ['LoadingSpinner', 'LoadingSpinner'], - ['SpinnerIcon', 'SpinnerIcon'], - ]), - ], - ]), - ), - ).toEqual(printAst(outputAst)) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) }) it('uses LoadingSpinner alias for an existing import', () => { @@ -176,20 +116,7 @@ describe('upgradeIconV1()', () => { import { LoadingSpinner as KzLoadingSpinner } from "@kaizen/components" export const TestComponent = () => `) - expect( - transformIcons( - inputAst, - new Map([ - [ - '@kaizen/components', - new Map([ - ['KzLoadingSpinner', 'LoadingSpinner'], - ['SpinnerIcon', 'SpinnerIcon'], - ]), - ], - ]), - ), - ).toEqual(printAst(outputAst)) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) }) }) @@ -202,12 +129,7 @@ describe('upgradeIconV1()', () => { import { Icon } from "@kaizen/components/future" export const TestComponent = () => `) - expect( - transformIcons( - inputAst, - new Map([['@kaizen/components', new Map([['FlagOnIcon', 'FlagOnIcon']])]]), - ), - ).toEqual(printAst(outputAst)) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) }) it('transforms aliased old Icon', () => { @@ -219,12 +141,7 @@ describe('upgradeIconV1()', () => { import { Icon } from "@kaizen/components/future" export const TestComponent = () => `) - expect( - transformIcons( - inputAst, - new Map([['@kaizen/components', new Map([['IconAlias', 'HamburgerIcon']])]]), - ), - ).toEqual(printAst(outputAst)) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) }) describe('import statements', () => { @@ -238,12 +155,7 @@ describe('upgradeIconV1()', () => { import { Icon } from "@kaizen/components/future" export const TestComponent = () => `) - expect( - transformIcons( - inputAst, - new Map([['@kaizen/components', new Map([['AddIcon', 'AddIcon']])]]), - ), - ).toEqual(printAst(outputAst)) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) }) it('does not update import of components which are not from KAIO', () => { @@ -269,21 +181,7 @@ describe('upgradeIconV1()', () => { ) `) - expect( - transformIcons( - inputAst, - new Map([ - [ - '@kaizen/components', - new Map([ - ['AddIcon', 'AddIcon'], - ['IconAlias', 'HamburgerIcon'], - ]), - ], - ['somewhere-else', new Map([['HamHam', 'HamburgerIcon']])], - ]), - ), - ).toEqual(printAst(outputAst)) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) }) }) }) diff --git a/packages/components/codemods/upgradeIconV1/upgradeIconV1.ts b/packages/components/codemods/upgradeIconV1/upgradeIconV1.ts index c75ac521cc4..09730d21327 100644 --- a/packages/components/codemods/upgradeIconV1/upgradeIconV1.ts +++ b/packages/components/codemods/upgradeIconV1/upgradeIconV1.ts @@ -1,9 +1,10 @@ import ts from 'typescript' import { + getKaioTagName, setImportToAdd, setImportToRemove, updateKaioImports, - type ImportModuleNameTagsMap, + type TagImportAttributesMap, type UpdateKaioImportsArgs, } from '../utils' import { getNewIconPropsFromOldIconName } from './getNewIconPropsFromOldIconName' @@ -11,56 +12,51 @@ import { transformCaMonogramIconToBrand } from './transformCaMonogramIconToBrand import { transformIcon } from './transformIcon' import { transformSpinnerIconToLoadingSpinner } from './transformSpinnerIconToLoadingSpinner' -const reverseStringMap = ( - map: Map, -): Map => { - const reverseMap = new Map() - map.forEach((value, key) => reverseMap.set(value, key)) - return reverseMap -} - export const upgradeIconV1 = - (tagNames: ImportModuleNameTagsMap): ts.TransformerFactory => + (tagsMap: TagImportAttributesMap): ts.TransformerFactory => (context) => (rootNode) => { - const oldImportSource = '@kaizen/components' - - const kaioTagNames = tagNames.get(oldImportSource) - if (!kaioTagNames) return rootNode + const importedBrandTagName = getKaioTagName(rootNode, 'Brand') + const importedLoadingSpinnerTagName = getKaioTagName(rootNode, 'LoadingSpinner') - const componentToAliasMap = reverseStringMap(kaioTagNames) const importsToRemove = new Map() satisfies UpdateKaioImportsArgs['importsToRemove'] const importsToAdd = new Map() satisfies UpdateKaioImportsArgs['importsToAdd'] const visit = (node: ts.Node): ts.Node => { if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { const tagName = node.tagName.getText() - const kaioComponentName = kaioTagNames.get(tagName) + const tagImportAttributes = tagsMap.get(tagName) + + if (!tagImportAttributes) return node + + const kaioComponentName = tagImportAttributes.originalName + const oldImportSource = tagImportAttributes.importModuleName if (kaioComponentName === 'CaMonogramIcon') { setImportToRemove(importsToRemove, oldImportSource, kaioComponentName) - const alias = componentToAliasMap.get('Brand')! - if (!kaioTagNames.has(alias)) { + if (!importedBrandTagName) { setImportToAdd(importsToAdd, '@kaizen/components', { componentName: 'Brand', - alias: alias !== 'Brand' ? alias : undefined, + alias: importedBrandTagName !== 'Brand' ? importedBrandTagName : undefined, }) } - return transformCaMonogramIconToBrand(node, alias) + return transformCaMonogramIconToBrand(node, importedBrandTagName) } if (kaioComponentName === 'SpinnerIcon') { setImportToRemove(importsToRemove, oldImportSource, kaioComponentName) - const alias = componentToAliasMap.get('LoadingSpinner')! - if (!kaioTagNames.has(alias)) { + if (!importedLoadingSpinnerTagName) { setImportToAdd(importsToAdd, '@kaizen/components', { componentName: 'LoadingSpinner', - alias: alias !== 'LoadingSpinner' ? alias : undefined, + alias: + importedLoadingSpinnerTagName !== 'LoadingSpinner' + ? importedLoadingSpinnerTagName + : undefined, }) } - return transformSpinnerIconToLoadingSpinner(node, alias) + return transformSpinnerIconToLoadingSpinner(node, importedLoadingSpinnerTagName) } if (kaioComponentName) { @@ -83,8 +79,5 @@ export const upgradeIconV1 = const node = ts.visitNode(rootNode, visit) - return updateKaioImports({ - importsToRemove: importsToRemove.size > 0 ? importsToRemove : undefined, - importsToAdd: importsToAdd.size > 0 ? importsToAdd : undefined, - })(context)(node as ts.SourceFile) + return updateKaioImports({ importsToRemove, importsToAdd })(context)(node as ts.SourceFile) } diff --git a/packages/components/codemods/upgradeV1Buttons/index.ts b/packages/components/codemods/upgradeV1Buttons/index.ts new file mode 100644 index 00000000000..2e80265e769 --- /dev/null +++ b/packages/components/codemods/upgradeV1Buttons/index.ts @@ -0,0 +1,17 @@ +import { transformComponentsAndImportsInDir } from '../utils' +import { upgradeV1Buttons } from './upgradeV1Buttons' + +const run = (): void => { + console.log('~(-_- ~) Running IconButton to Button upgrade (~ -_-)~') + + const targetDir = process.argv[2] + if (!targetDir) { + process.exit(1) + } + + transformComponentsAndImportsInDir(targetDir, ['IconButton', 'Button'], (tagNames) => [ + upgradeV1Buttons(tagNames), + ]) +} + +run() diff --git a/packages/components/codemods/upgradeV1Buttons/transformV1Buttons.spec.ts b/packages/components/codemods/upgradeV1Buttons/transformV1Buttons.spec.ts new file mode 100644 index 00000000000..90a623eda94 --- /dev/null +++ b/packages/components/codemods/upgradeV1Buttons/transformV1Buttons.spec.ts @@ -0,0 +1,163 @@ +import ts from 'typescript' +import { parseJsx } from '../__tests__/utils' +import { printAst } from '../utils' +import { transformV1Buttons } from './transformV1Buttons' + +export const mockedTransformer = + (kaioComponentName: string, alias?: string) => + (context: ts.TransformationContext) => + (rootNode: ts.Node): ts.Node => { + const visit = (node: ts.Node): ts.Node => { + if (ts.isJsxSelfClosingElement(node)) { + return transformV1Buttons(node, kaioComponentName, alias) + } + return ts.visitEachChild(node, visit, context) + } + return ts.visitNode(rootNode, visit) + } + +const transformInput = ( + sourceFile: ts.SourceFile, + kaioComponentName: string = 'Button', + alias?: string, +): string => { + const result = ts.transform(sourceFile, [mockedTransformer(kaioComponentName, alias)]) + const transformedSource = result.transformed[0] as ts.SourceFile + return printAst(transformedSource) +} + +describe('transformV1Buttons()', () => { + it('changes label to children', () => { + const inputAst = parseJsx('') + const outputAst = parseJsx('') + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + + it('replaces IconButton with Button and changes label to children and adds hasHiddenLabel', () => { + const inputAst = parseJsx('') + const outputAst = parseJsx('') + expect(transformInput(inputAst, 'IconButton')).toEqual(printAst(outputAst)) + }) + + it('uses alias if it is defined', () => { + const inputAst = parseJsx('') + const outputAst = parseJsx('Pancakes') + expect(transformInput(inputAst, 'Button', 'ButtonAlias')).toEqual(printAst(outputAst)) + }) + + describe('transform existing props', () => { + it('changes onClick to onPress', () => { + const inputAst = parseJsx('') + const outputAst = parseJsx('') + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + + it('changes reversed to isReversed', () => { + const inputAst = parseJsx(` + <> + + + + `) + const outputAst = parseJsx(` + <> + + + + `) + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + + it('changes classNameOverride to className', () => { + const inputAst = parseJsx('') + const outputAst = parseJsx('') + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + + it('changes data-automation-id to data-testid', () => { + const inputAst = parseJsx('') + const outputAst = parseJsx('') + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + + it('changes disabled to isDisabled', () => { + const inputAst = parseJsx(` + <> + + + + `) + const outputAst = parseJsx(` + <> + + + + `) + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + + it('changes newTabAndIUnderstandTheAccessibilityImplications to target="_blank" and rel="noopener noreferrer"', () => { + const inputAst = parseJsx( + '', + ) + const outputAst = parseJsx( + '', + ) + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + + // @todo: Update when we know what to change variants to + // describe('transform variant', () => { + // it('changes default (undefined) to TBC', () => { + // const inputAst = parseJsx('') + // const outputAst = parseJsx('') + // expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + // }) + + // it('changes primary to TBC', () => { + // const inputAst = parseJsx('') + // const outputAst = parseJsx('') + // expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + // }) + + // it('changes secondary to TBC', () => { + // const inputAst = parseJsx('') + // const outputAst = parseJsx('') + // expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + // }) + + // it('changes destructive to TBC', () => { + // const inputAst = parseJsx('') + // const outputAst = parseJsx('') + // expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + // }) + + // it('changes secondary destructive to TBC', () => { + // const inputAst = parseJsx('') + // const outputAst = parseJsx('') + // expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + // }) + // }) + + // @todo: Update when we know what to change sizes to + // describe('transform size', () => { + // it('changes default (undefined) to TBC', () => { + // const inputAst = parseJsx('') + // const outputAst = parseJsx('') + // expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + // }) + + // it('changes small to TBC', () => { + // const inputAst = parseJsx('') + // const outputAst = parseJsx('') + // expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + // }) + + // it('changes regular to TBC', () => { + // const inputAst = parseJsx('') + // const outputAst = parseJsx('') + // expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + // }) + // }) + }) +}) diff --git a/packages/components/codemods/upgradeV1Buttons/transformV1Buttons.ts b/packages/components/codemods/upgradeV1Buttons/transformV1Buttons.ts new file mode 100644 index 00000000000..2bdbd7a606b --- /dev/null +++ b/packages/components/codemods/upgradeV1Buttons/transformV1Buttons.ts @@ -0,0 +1,71 @@ +import ts from 'typescript' +import { createJsxElementWithChildren, createProp, createStringProp } from '../utils' + +/** + * @returns + * - `ts.JsxAttribute` if the prop should be transformed + * - `null` if the prop should be removed + * - `undefined` if the prop should be kept as is + */ +const transformProp = ( + propName: string, + propValue: ts.JsxAttributeValue | undefined, +): ts.JsxAttribute | null | undefined => { + switch (propName) { + case 'onClick': + return createProp('onPress', propValue) + case 'reversed': + return createProp('isReversed', propValue) + case 'classNameOverride': + return createProp('className', propValue) + case 'data-automation-id': + return createProp('data-testid', propValue) + case 'disabled': + return createProp('isDisabled', propValue) + default: + return undefined + } +} + +export const transformV1Buttons = ( + node: ts.JsxSelfClosingElement, + kaioComponentName: string, + tagName: string = 'Button', +): ts.Node => { + let childrenValue: ts.JsxAttributeValue | undefined + + const newAttributes = node.attributes.properties.reduce((acc, attr) => { + if (ts.isJsxAttribute(attr)) { + const propName = attr.name.getText() + + if (propName === 'label') { + childrenValue = attr.initializer + return acc + } + + if (propName === 'newTabAndIUnderstandTheAccessibilityImplications') { + acc.push(createStringProp('target', '_blank')) + acc.push(createStringProp('rel', 'noopener noreferrer')) + return acc + } + + const newProp = transformProp(propName, attr.initializer) + + if (newProp === null) return acc + + if (newProp) { + acc.push(newProp) + return acc + } + } + + acc.push(attr) + return acc + }, []) + + if (kaioComponentName === 'IconButton') { + newAttributes.push(createProp('hasHiddenLabel')) + } + + return createJsxElementWithChildren(tagName, newAttributes, childrenValue) +} diff --git a/packages/components/codemods/upgradeV1Buttons/upgradeV1Buttons.spec.ts b/packages/components/codemods/upgradeV1Buttons/upgradeV1Buttons.spec.ts new file mode 100644 index 00000000000..90d619da777 --- /dev/null +++ b/packages/components/codemods/upgradeV1Buttons/upgradeV1Buttons.spec.ts @@ -0,0 +1,292 @@ +import { parseJsx } from '../__tests__/utils' +import { + getKaioTagNamesMapByComponentName, + printAst, + transformSource, + type TransformSourceArgs, +} from '../utils' +import { upgradeV1Buttons } from './upgradeV1Buttons' + +const transformIcons = (sourceFile: TransformSourceArgs['sourceFile']): string => { + const kaioTagNamesMap = getKaioTagNamesMapByComponentName(sourceFile, ['IconButton', 'Button']) + return transformSource({ + sourceFile, + transformers: [upgradeV1Buttons(kaioTagNamesMap!)], + }) +} + +describe('upgradeV1Buttons()', () => { + it('transforms both IconButton and Button v1 to Button v3 in the same iteration', () => { + const inputAst = parseJsx(` + import { Button, IconButton } from "@kaizen/components" + export const TestComponent = () => ( + <> + + + + ) + `) + + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms Button v1 to Button v3 when href and component prop are not set', () => { + const inputAst = parseJsx(` + import { Button } from "@kaizen/components" + export const TestComponent = () => + `) + + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms IconButton to Button v3 when href and component prop are not set', () => { + const inputAst = parseJsx(` + import { IconButton } from "@kaizen/components" + export const TestComponent = () => } label="More pls" onClick={handleClick} /> + `) + const outputAst = parseJsx(` + import { Button } from "@kaizen/components/v3/actions" + export const TestComponent = () => + `) + + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms aliased V1 Buttons to Button when href and component prop are not set', () => { + const inputAst = parseJsx(` + import { IconButton as KzIconButton, Button as KzButton } from "@kaizen/components" + export const TestComponent = () => ( + <> + ) + `) + const outputAst = parseJsx(` + import { Button } from "@kaizen/components/v3/actions" + export const TestComponent = () => ( + <> + ) + `) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms V1 Buttons to aliased Button', () => { + const inputAst = parseJsx(` + import { IconButton, Button } from "@kaizen/components" + import { Button as ButtonAlias } from "@kaizen/components/v3/actions" + export const TestComponent = () => ( + <> + ) + `) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + + it('updates V1 Buttons from @kaizen/components/v1/actions', () => { + const inputAst = parseJsx(` + import { Button, IconButton } from "@kaizen/components/v1/actions" + export const TestComponent = () => ( + <> + ) + `) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + + it('updates IconButton from @kaizen/components/v2/actions', () => { + const inputAst = parseJsx(` + import { Button, IconButton } from "@kaizen/components/v2/actions" + export const TestComponent = () => ( + <> + ) + `) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + + it('updates aliased V1 Buttons to Button', () => { + const inputAst = parseJsx(` + import { Button as KzButton, IconButton as KzIconButton } from "@kaizen/components" + export const TestComponent = () => ( + <> + ) + `) + const outputAst = parseJsx(` + import { Button } from "@kaizen/components/v3/actions" + export const TestComponent = () => ( + <> + ) + `) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + + it('updates imports of multiple V1 Buttons from different KAIO imports', () => { + const inputAst = parseJsx(` + import { Button as KzButton, IconButton as KzIconButton } from "@kaizen/components" + import { Button as ButtonV1, IconButton as IconButtonV1 } from "@kaizen/components/v1/actions" + export const TestComponent = () => ( + <> + + + + + + ) + `) + const outputAst = parseJsx(` + import { Button } from "@kaizen/components/v3/actions" + export const TestComponent = () => ( + <> + + + + + + ) + `) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + + it('does not duplicate Button import if it already exists', () => { + const inputAst = parseJsx(` + import { IconButton, Button as KzButton } from "@kaizen/components" + import { Button } from "@kaizen/components/v3/actions" + export const TestComponent = () => ( + <> + + + + + ) + `) + const outputAst = parseJsx(` + import { Button } from "@kaizen/components/v3/actions" + export const TestComponent = () => ( + <> + + + + + ) + `) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + + it('does not add Button if aliased Button exists', () => { + const inputAst = parseJsx(` + import { Button, IconButton } from "@kaizen/components" + import { Button as ButtonAlias } from "@kaizen/components/v3/actions" + export const TestComponent = () => ( + <> + + + + ) + `) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + }) +}) diff --git a/packages/components/codemods/upgradeV1Buttons/upgradeV1Buttons.ts b/packages/components/codemods/upgradeV1Buttons/upgradeV1Buttons.ts new file mode 100644 index 00000000000..ccb98460b74 --- /dev/null +++ b/packages/components/codemods/upgradeV1Buttons/upgradeV1Buttons.ts @@ -0,0 +1,51 @@ +import ts from 'typescript' +import { + setImportToAdd, + setImportToRemove, + updateKaioImports, + type TagImportAttributesMap, + type UpdateKaioImportsArgs, +} from '../utils' +import { transformV1Buttons } from './transformV1Buttons' + +export const upgradeV1Buttons = + (tagsMap: TagImportAttributesMap): ts.TransformerFactory => + (context) => + (rootNode) => { + const IMPORT_DESTINATION = '@kaizen/components/v3/actions' + const importsToRemove: UpdateKaioImportsArgs['importsToRemove'] = new Map() + const importsToAdd: UpdateKaioImportsArgs['importsToAdd'] = new Map() + + const importedButtonTagName = Array.from(tagsMap.values()).find( + ({ originalName, importModuleName }) => + originalName === 'Button' && importModuleName === IMPORT_DESTINATION, + )?.tagName + + const visit = (node: ts.Node): ts.Node => { + if (ts.isJsxSelfClosingElement(node)) { + const tagName = node.tagName.getText() + const tagImportAttributes = tagsMap.get(tagName) + + if (tagImportAttributes) { + setImportToRemove( + importsToRemove, + tagImportAttributes.importModuleName, + tagImportAttributes.originalName, + ) + + if (!importedButtonTagName) { + setImportToAdd(importsToAdd, IMPORT_DESTINATION, { + componentName: importedButtonTagName ?? 'Button', + }) + } + + return transformV1Buttons(node, tagImportAttributes.originalName, importedButtonTagName) + } + } + return ts.visitEachChild(node, visit, context) + } + + const node = ts.visitNode(rootNode, visit) + + return updateKaioImports({ importsToRemove, importsToAdd })(context)(node as ts.SourceFile) + } diff --git a/packages/components/codemods/utils/createJsxElementWithChildren.spec.ts b/packages/components/codemods/utils/createJsxElementWithChildren.spec.ts new file mode 100644 index 00000000000..af7b91e6951 --- /dev/null +++ b/packages/components/codemods/utils/createJsxElementWithChildren.spec.ts @@ -0,0 +1,119 @@ +import ts from 'typescript' +import { parseJsx } from '../__tests__/utils' +import { createJsxElementWithChildren } from './createJsxElementWithChildren' +import { printAst } from './printAst' +import { transformSource, type TransformSourceArgs } from './transformSource' + +export const mockedTransformer: ts.TransformerFactory = (context) => (rootNode) => { + const visit = (node: ts.Node): ts.Node => { + let childrenValue: ts.JsxAttributeValue | undefined + + if (ts.isJsxSelfClosingElement(node)) { + const tagName = node.tagName.getText() + const attributes = node.attributes.properties.reduce((acc, attr) => { + if (ts.isJsxAttribute(attr) && attr.name.getText() === 'toChildren') { + childrenValue = attr.initializer + return acc + } + + acc.push(attr) + return acc + }, []) + + return createJsxElementWithChildren(tagName, attributes, childrenValue) + } + return ts.visitEachChild(node, visit, context) + } + return ts.visitNode(rootNode, visit) as ts.SourceFile +} + +const testCreateJsxElementWithChildren = (sourceFile: TransformSourceArgs['sourceFile']): string => + transformSource({ sourceFile, transformers: [mockedTransformer] }) + +describe('createJsxElementWithChildren()', () => { + it('transforms a string value', () => { + const inputAst = parseJsx('') + expect(testCreateJsxElementWithChildren(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms a string in brackets', () => { + const inputAst = parseJsx(` + <> + + + + `) + // const outputAst = parseJsx('') + expect(testCreateJsxElementWithChildren(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms a string with comments in brackets', () => { + const inputAst = parseJsx(``) + expect(testCreateJsxElementWithChildren(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms a template literal with variables', () => { + /* eslint-disable no-template-curly-in-string */ + const inputAst = parseJsx('') + /* eslint-enable no-template-curly-in-string */ + expect(testCreateJsxElementWithChildren(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms a variable', () => { + const inputAst = parseJsx('') + expect(testCreateJsxElementWithChildren(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms a ternary', () => { + const inputAst = parseJsx( + '', + ) + expect(testCreateJsxElementWithChildren(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms a JSX element', () => { + const inputAst = parseJsx('') + expect(testCreateJsxElementWithChildren(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms a JSX self-closing element', () => { + const inputAst = parseJsx('') + expect(testCreateJsxElementWithChildren(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms a JSX fragment', () => { + const inputAst = parseJsx('') + expect(testCreateJsxElementWithChildren(inputAst)).toEqual(printAst(outputAst)) + }) + + it('adds a comment if no value for children has been passed in', () => { + const inputAst = parseJsx('`) + expect(testCreateJsxElementWithChildren(inputAst)).toEqual(printAst(outputAst)) + }) +}) diff --git a/packages/components/codemods/utils/createJsxElementWithChildren.ts b/packages/components/codemods/utils/createJsxElementWithChildren.ts new file mode 100644 index 00000000000..95579ba3cde --- /dev/null +++ b/packages/components/codemods/utils/createJsxElementWithChildren.ts @@ -0,0 +1,55 @@ +import ts from 'typescript' + +const createJsxChildren = (childrenValue: ts.JsxAttributeValue): ts.JsxChild => { + if (ts.isStringLiteral(childrenValue)) { + return ts.factory.createJsxText(childrenValue.text) + } + + if (ts.isJsxExpression(childrenValue)) { + const value = childrenValue.expression + + if (value) { + if (ts.isStringLiteral(value)) { + // Tests for {"string"}, {'string'} + const regexExpContainsOnlyQuotedString = new RegExp(/^\{(["']).*(\1)\}$/g) + + if (regexExpContainsOnlyQuotedString.test(childrenValue.getFullText())) { + return ts.factory.createJsxText(value.text) + } + } + + if (ts.isJsxElement(value) || ts.isJsxSelfClosingElement(value) || ts.isJsxFragment(value)) { + return value + } + } + + return childrenValue + } + + return childrenValue +} + +/** + * Use this to replace a self-closing JSX element to a JSX element with children + */ +export const createJsxElementWithChildren = ( + tagName: string, + attributes: ts.JsxAttributeLike[], + childrenValue: ts.JsxAttributeValue | undefined, +): ts.JsxElement => { + const tagNameId = ts.factory.createIdentifier(tagName) + const fallbackChildren = [ + ts.factory.createJsxText('\n'), + ts.factory.createJsxText( + '/* @todo Children required but a value was not found during the codemod */', + ), + ts.factory.createJsxText('\n'), + ] + const children = childrenValue ? [createJsxChildren(childrenValue)] : fallbackChildren + + return ts.factory.createJsxElement( + ts.factory.createJsxOpeningElement(tagNameId, [], ts.factory.createJsxAttributes(attributes)), + children, + ts.factory.createJsxClosingElement(tagNameId), + ) +} diff --git a/packages/components/codemods/utils/createProp.spec.ts b/packages/components/codemods/utils/createProp.spec.ts index 3a5c6ba8cd4..2cecdfd9290 100644 --- a/packages/components/codemods/utils/createProp.spec.ts +++ b/packages/components/codemods/utils/createProp.spec.ts @@ -1,32 +1,88 @@ import ts from 'typescript' import { parseJsx } from '../__tests__/utils/parseJsx' -import { createStyleProp } from './createProp' +import { createProp, createStyleProp } from './createProp' import { printAst } from './printAst' import { transformSource, type TransformSourceArgs } from './transformSource' import { updateJsxElementWithNewProps } from './updateJsxElementWithNewProps' -export const mockedTransformer: ts.TransformerFactory = (context) => (rootNode) => { +export const mockTransformer: ts.TransformerFactory = (context) => (rootNode) => { const visit = (node: ts.Node): ts.Node => { if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { - if (node.tagName.getText() === 'Pancakes') { - const newAttributes = node.attributes.properties.map((attr) => { - if (ts.isJsxAttribute(attr)) { - if (attr.name.getText() === 'replaceWithExistingValue') { - return createStyleProp({ width: attr.initializer! }) - } + const newAttributes = node.attributes.properties.map((attr) => { + if (ts.isJsxAttribute(attr)) { + return createProp(`${attr.name.getText()}New`, attr.initializer) + } + return attr + }) + return updateJsxElementWithNewProps(node, newAttributes) + } + return ts.visitEachChild(node, visit, context) + } + return ts.visitNode(rootNode, visit) as ts.SourceFile +} + +const testCreateProp = (sourceFile: TransformSourceArgs['sourceFile']): string => + transformSource({ + sourceFile, + transformers: [mockTransformer], + }) + +describe('createProp()', () => { + it('creates a prop with the pre-existing value', () => { + const inputAst = parseJsx('') + const outputAst = parseJsx('') + expect(testCreateProp(inputAst)).toEqual(printAst(outputAst)) + }) - if (attr.name.getText() === 'replaceWithStringValue') { - return createStyleProp({ width: '100px' }) - } + it('creates a prop and transforms true to undefined', () => { + const inputAst = parseJsx(` + export const TestComponent = () => ( + <> + + + + + + + + ) + `) + const outputAst = parseJsx(` + export const TestComponent = () => ( + <> + + + + + + + + ) + `) + expect(testCreateProp(inputAst)).toEqual(printAst(outputAst)) + }) +}) + +export const styleTransformer: ts.TransformerFactory = (context) => (rootNode) => { + const visit = (node: ts.Node): ts.Node => { + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { + const newAttributes = node.attributes.properties.map((attr) => { + if (ts.isJsxAttribute(attr)) { + if (attr.name.getText() === 'replaceWithExistingValue') { + return createStyleProp({ width: attr.initializer! }) + } + + if (attr.name.getText() === 'replaceWithStringValue') { + return createStyleProp({ width: '100px' }) + } - if (attr.name.getText() === 'replaceWithNumberValue') { - return createStyleProp({ width: 100 }) - } + if (attr.name.getText() === 'replaceWithNumberValue') { + return createStyleProp({ width: 100 }) } - return attr - }) - return updateJsxElementWithNewProps(node, newAttributes) - } + } + return attr + }) + return updateJsxElementWithNewProps(node, newAttributes) } return ts.visitEachChild(node, visit, context) } @@ -36,7 +92,7 @@ export const mockedTransformer: ts.TransformerFactory = (context) const testCreateStyleProp = (sourceFile: TransformSourceArgs['sourceFile']): string => transformSource({ sourceFile, - transformers: [mockedTransformer], + transformers: [styleTransformer], }) describe('createStyleProp()', () => { diff --git a/packages/components/codemods/utils/createProp.ts b/packages/components/codemods/utils/createProp.ts index a11fcbe1522..413e1104dd3 100644 --- a/packages/components/codemods/utils/createProp.ts +++ b/packages/components/codemods/utils/createProp.ts @@ -3,7 +3,14 @@ import ts from 'typescript' export const createProp = ( name: string, value?: ts.JsxAttributeValue | undefined, -): ts.JsxAttribute => ts.factory.createJsxAttribute(ts.factory.createIdentifier(name), value) +): ts.JsxAttribute => { + // Transforms `propName={true}` to `propName` + if (value && ts.isJsxExpression(value) && value.expression?.kind === ts.SyntaxKind.TrueKeyword) { + return ts.factory.createJsxAttribute(ts.factory.createIdentifier(name), undefined) + } + + return ts.factory.createJsxAttribute(ts.factory.createIdentifier(name), value) +} export const createStringProp = (name: string, value: string): ts.JsxAttribute => createProp(name, ts.factory.createStringLiteral(value)) diff --git a/packages/components/codemods/utils/getKaioTagName.spec.ts b/packages/components/codemods/utils/getKaioTagName.spec.ts index 4baa85e875c..b4efd5d939f 100644 --- a/packages/components/codemods/utils/getKaioTagName.spec.ts +++ b/packages/components/codemods/utils/getKaioTagName.spec.ts @@ -1,7 +1,11 @@ import { parseJsx } from '../__tests__/utils' -import { getKaioTagName, getKaioTagNamesByRegex } from './getKaioTagName' +import { + getKaioTagName, + getKaioTagNamesMapByComponentName, + getKaioTagNamesMapByPattern, +} from './getKaioTagName' -describe('getKaioTagName', () => { +describe('getKaioTagName()', () => { it('returns the import name if it matches the target specifier', () => { const input = parseJsx('import { Well } from "@kaizen/components"') const tagName = getKaioTagName(input, 'Well') @@ -21,18 +25,135 @@ describe('getKaioTagName', () => { }) }) -describe('getKaioTagNamesByRegex', () => { +describe('getKaioTagNamesMapByComponentName()', () => { + it('returns the import names if it matches the string target specifier', () => { + const input = parseJsx('import { Button } from "@kaizen/components"') + const tagNames = getKaioTagNamesMapByComponentName(input, ['Button']) + expect(tagNames).toEqual( + new Map([ + [ + 'Button', + { + importModuleName: '@kaizen/components', + tagName: 'Button', + originalName: 'Button', + }, + ], + ]), + ) + }) + + it('returns the import alias if it matches the target specifier', () => { + const input = parseJsx('import { Button as KzButton } from "@kaizen/components"') + const tagNames = getKaioTagNamesMapByComponentName(input, ['Button']) + expect(tagNames).toEqual( + new Map([ + [ + 'KzButton', + { + importModuleName: '@kaizen/components', + tagName: 'KzButton', + originalName: 'Button', + }, + ], + ]), + ) + }) + + it('returns matching import names from different KAIO imports', () => { + const input = parseJsx(` + import { Button as KzButton } from "@kaizen/components" + import { Button as FutureButton } from "@kaizen/components/future" + `) + const tagNames = getKaioTagNamesMapByComponentName(input, ['Button']) + expect(tagNames).toEqual( + new Map([ + [ + 'KzButton', + { + importModuleName: '@kaizen/components', + tagName: 'KzButton', + originalName: 'Button', + }, + ], + [ + 'FutureButton', + { + importModuleName: '@kaizen/components/future', + tagName: 'FutureButton', + originalName: 'Button', + }, + ], + ]), + ) + }) + + it('returns matching import names for multiple components', () => { + const input = parseJsx(` + import { Button, IconButton } from "@kaizen/components" + `) + const tagNames = getKaioTagNamesMapByComponentName(input, ['Button', 'IconButton']) + expect(tagNames).toEqual( + new Map([ + [ + 'Button', + { + importModuleName: '@kaizen/components', + tagName: 'Button', + originalName: 'Button', + }, + ], + [ + 'IconButton', + { + importModuleName: '@kaizen/components', + tagName: 'IconButton', + originalName: 'IconButton', + }, + ], + ]), + ) + }) + + it('returns undefined if there is no exact match to the target specifier', () => { + const input = parseJsx(` + import { IconButton } from "@kaizen/components" + `) + const tagNames = getKaioTagNamesMapByComponentName(input, ['Button']) + expect(tagNames).toBe(undefined) + }) + + it('returns undefined if there is no match in KAIO', () => { + const input = parseJsx(` + import { Well } from "@kaizen/components" + import { Button } from "@kaizen/button" + `) + const tagNames = getKaioTagNamesMapByComponentName(input, ['Button']) + expect(tagNames).toBe(undefined) + }) +}) + +describe('getKaioTagNamesMapByPattern()', () => { it('returns the import names if it matches the regex target specifier', () => { const input = parseJsx('import { AddIcon, ArrowDownIcon, Well } from "@kaizen/components"') - const tagNames = getKaioTagNamesByRegex(input, 'Icon') + const tagNames = getKaioTagNamesMapByPattern(input, 'Icon') expect(tagNames).toEqual( new Map([ [ - '@kaizen/components', - new Map([ - ['AddIcon', 'AddIcon'], - ['ArrowDownIcon', 'ArrowDownIcon'], - ]), + 'AddIcon', + { + importModuleName: '@kaizen/components', + tagName: 'AddIcon', + originalName: 'AddIcon', + }, + ], + [ + 'ArrowDownIcon', + { + importModuleName: '@kaizen/components', + tagName: 'ArrowDownIcon', + originalName: 'ArrowDownIcon', + }, ], ]), ) @@ -42,15 +163,24 @@ describe('getKaioTagNamesByRegex', () => { const input = parseJsx( 'import { AddIcon as KzAddIcon, ArrowDownIcon, Well } from "@kaizen/components"', ) - const tagNames = getKaioTagNamesByRegex(input, 'Icon') + const tagNames = getKaioTagNamesMapByPattern(input, 'Icon') expect(tagNames).toEqual( new Map([ [ - '@kaizen/components', - new Map([ - ['KzAddIcon', 'AddIcon'], - ['ArrowDownIcon', 'ArrowDownIcon'], - ]), + 'KzAddIcon', + { + importModuleName: '@kaizen/components', + tagName: 'KzAddIcon', + originalName: 'AddIcon', + }, + ], + [ + 'ArrowDownIcon', + { + importModuleName: '@kaizen/components', + tagName: 'ArrowDownIcon', + originalName: 'ArrowDownIcon', + }, ], ]), ) @@ -61,11 +191,25 @@ describe('getKaioTagNamesByRegex', () => { import { AddIcon, Well } from "@kaizen/components" import { Icon } from "@kaizen/components/future" `) - const tagNames = getKaioTagNamesByRegex(input, 'Icon$') + const tagNames = getKaioTagNamesMapByPattern(input, 'Icon$') expect(tagNames).toEqual( new Map([ - ['@kaizen/components', new Map([['AddIcon', 'AddIcon']])], - ['@kaizen/components/future', new Map([['Icon', 'Icon']])], + [ + 'AddIcon', + { + importModuleName: '@kaizen/components', + tagName: 'AddIcon', + originalName: 'AddIcon', + }, + ], + [ + 'Icon', + { + importModuleName: '@kaizen/components/future', + tagName: 'Icon', + originalName: 'Icon', + }, + ], ]), ) }) @@ -75,7 +219,7 @@ describe('getKaioTagNamesByRegex', () => { import { Well } from "@kaizen/components" import { AddIcon } from "@kaizen/icons" `) - const tagNames = getKaioTagNamesByRegex(input, 'Icon') + const tagNames = getKaioTagNamesMapByPattern(input, 'Icon') expect(tagNames).toBe(undefined) }) }) diff --git a/packages/components/codemods/utils/getKaioTagName.ts b/packages/components/codemods/utils/getKaioTagName.ts index 5392d7fb20c..e5bf3038789 100644 --- a/packages/components/codemods/utils/getKaioTagName.ts +++ b/packages/components/codemods/utils/getKaioTagName.ts @@ -1,13 +1,11 @@ import ts from 'typescript' -const getKaioNamedImports = ( - visitedNode: ts.Node, -): - | { - importModuleName: string - namedImports: ts.NodeArray - } - | undefined => { +type ImportModuleNamedImports = { + importModuleName: string + namedImports: ts.NodeArray +} + +const getKaioNamedImports = (visitedNode: ts.Node): ImportModuleNamedImports | undefined => { if (ts.isImportDeclaration(visitedNode)) { const moduleSpecifier = (visitedNode.moduleSpecifier as ts.StringLiteral).text if (moduleSpecifier.includes('@kaizen/components')) { @@ -72,14 +70,60 @@ export const getKaioTagName = ( return visitNode(node) } -// Key is the tag name (component name or alias) -// Value is the original component name -type TagNamesMap = Map -// Key is the import module name (eg. `@kaizen/components/future`) -export type ImportModuleNameTagsMap = Map +type TagImportAttributes = { + // Import module name (eg. `@kaizen/components/future`) + importModuleName: string + // Component name or alias + tagName: string + // Original component name + originalName: string +} +/** Key is the tag name (component name or alias) */ +export type TagImportAttributesMap = Map + +/** + * Recurses through AST to find all the import names or aliases in KAIO that exactly match the provided strings. + */ +export const getKaioTagNamesMapByComponentName = ( + node: ts.Node, + importSpecifiers: string[], +): TagImportAttributesMap | undefined => { + const tagsMap = new Map() as TagImportAttributesMap + + const visitNode = (visitedNode: ts.Node): ts.Node | undefined => { + const kaioNamedImports = getKaioNamedImports(visitedNode) + + if (!kaioNamedImports) { + return ts.forEachChild(visitedNode, visitNode) + } + + importSpecifiers.forEach((importSpecifier) => { + kaioNamedImports.namedImports.find((namedImport) => { + const { originalName, tagName } = getNamesFromSpecifier(namedImport) + + if (importSpecifier === originalName) { + tagsMap.set(tagName, { + importModuleName: kaioNamedImports.importModuleName, + tagName, + originalName, + }) + return true + } + + return false + }) + }) + + return ts.forEachChild(visitedNode, visitNode) + } + + visitNode(node) + + return tagsMap.size === 0 ? undefined : tagsMap +} /** - * Recurses through AST to find all the import names or aliases in KAIO that match the provided regex. + * Recurses through AST to find all the import names or aliases in KAIO that match the provided regex pattern. * * @returns Map> | undefined * - `Map>` = Map> @@ -88,11 +132,11 @@ export type ImportModuleNameTagsMap = Map * - `originalName` = the original component name (eg. `Well`) * - `undefined` no imports that match the target */ -export const getKaioTagNamesByRegex = ( +export const getKaioTagNamesMapByPattern = ( node: ts.Node, importSpecifierPattern: RegExp | string, -): ImportModuleNameTagsMap | undefined => { - const tagsByImportModuleName = new Map() as ImportModuleNameTagsMap +): TagImportAttributesMap | undefined => { + const tagsMap = new Map() as TagImportAttributesMap const visitNode = (visitedNode: ts.Node): ts.Node | undefined => { const kaioNamedImports = getKaioNamedImports(visitedNode) @@ -101,23 +145,25 @@ export const getKaioTagNamesByRegex = ( return ts.forEachChild(visitedNode, visitNode) } - const tags = new Map() as TagNamesMap - kaioNamedImports.namedImports.forEach((importSpecifier) => { - const { tagName, originalName } = getNamesFromSpecifier(importSpecifier) + kaioNamedImports.namedImports.forEach((namedImport) => { + const { originalName, tagName } = getNamesFromSpecifier(namedImport) if (new RegExp(importSpecifierPattern).test(originalName)) { - tags.set(tagName, originalName) + tagsMap.set(tagName, { + importModuleName: kaioNamedImports.importModuleName, + tagName, + originalName, + }) + return true } - }) - if (tags.size > 0) { - tagsByImportModuleName.set(kaioNamedImports.importModuleName, new Map(tags)) - } + return false + }) return ts.forEachChild(visitedNode, visitNode) } visitNode(node) - return tagsByImportModuleName.size === 0 ? undefined : tagsByImportModuleName + return tagsMap.size === 0 ? undefined : tagsMap } diff --git a/packages/components/codemods/utils/index.ts b/packages/components/codemods/utils/index.ts index 0e3d698e010..ebb02188357 100644 --- a/packages/components/codemods/utils/index.ts +++ b/packages/components/codemods/utils/index.ts @@ -1,3 +1,4 @@ +export * from './createJsxElementWithChildren' export * from './createProp' export * from './getPropValueText' export * from './getKaioTagName' diff --git a/packages/components/codemods/utils/transformComponentsInDir.ts b/packages/components/codemods/utils/transformComponentsInDir.ts index 9520c0846ba..76758416b80 100644 --- a/packages/components/codemods/utils/transformComponentsInDir.ts +++ b/packages/components/codemods/utils/transformComponentsInDir.ts @@ -1,16 +1,24 @@ import fs from 'fs' import path from 'path' import { createEncodedSourceFile } from './createEncodedSourceFile' -import { getKaioTagName } from './getKaioTagName' -import { transformSourceForTagName, type TransformSourceForTagNameArgs } from './transformSource' +import { + getKaioTagName, + getKaioTagNamesMapByComponentName, + getKaioTagNamesMapByPattern, + type TagImportAttributesMap, +} from './getKaioTagName' +import { + transformSource, + transformSourceForTagName, + type TransformSourceArgs, + type TransformSourceForTagNameArgs, +} from './transformSource' export const traverseDir = ( dir: string, transformFile: (componentFilePath: string, sourceCode: string) => void, ): void => { - if (dir.includes('node_modules')) { - return - } + if (dir.includes('node_modules')) return const files = fs.readdirSync(dir) @@ -26,7 +34,59 @@ export const traverseDir = ( }) } -/** Walks the directory and runs the AST transformer on the given component name */ +/** + * Walks the directory and runs the AST transformers on the given component name + */ +export const transformComponentsAndImportsInDir = ( + dir: string, + componentNames: string[], + transformers: (kaioTagNamesMap: TagImportAttributesMap) => TransformSourceArgs['transformers'], +): void => { + const transformFile = (componentFilePath: string, sourceCode: string): void => { + const sourceFile = createEncodedSourceFile(componentFilePath, sourceCode) + const kaioTagNamesMap = getKaioTagNamesMapByComponentName(sourceFile, componentNames) + if (kaioTagNamesMap) { + const updatedSourceFile = transformSource({ + sourceFile, + transformers: transformers(kaioTagNamesMap), + }) + + fs.writeFileSync(componentFilePath, updatedSourceFile, 'utf8') + } + } + + traverseDir(dir, transformFile) +} + +/** + * Walks the directory and runs the AST transformers on the given component name regex pattern + * eg. "Icon$" will match all components that end with `Icon` + */ +export const transformComponentsAndImportsInDirByPattern = ( + dir: string, + componentNamePattern: RegExp | string, + transformers: (kaioTagNamesMap: TagImportAttributesMap) => TransformSourceArgs['transformers'], +): void => { + const transformFile = (componentFilePath: string, sourceCode: string): void => { + const sourceFile = createEncodedSourceFile(componentFilePath, sourceCode) + const kaioTagNamesMap = getKaioTagNamesMapByPattern(sourceFile, componentNamePattern) + if (kaioTagNamesMap) { + const updatedSourceFile = transformSource({ + sourceFile, + transformers: transformers(kaioTagNamesMap), + }) + + fs.writeFileSync(componentFilePath, updatedSourceFile, 'utf8') + } + } + + traverseDir(dir, transformFile) +} + +/** + * @deprecated Use `transformComponentsAndImportsInDir` or `transformComponentsAndImportsInDirByPattern` instead + * Walks the directory and runs the AST transformer on the given component name + */ export const transformComponentsInDir = ( dir: string, transformer: TransformSourceForTagNameArgs['astTransformer'], diff --git a/packages/components/codemods/utils/updateKaioImports.ts b/packages/components/codemods/utils/updateKaioImports.ts index 1ae09ea87de..98482c8d544 100644 --- a/packages/components/codemods/utils/updateKaioImports.ts +++ b/packages/components/codemods/utils/updateKaioImports.ts @@ -121,6 +121,8 @@ export const updateKaioImports = if (!ts.isSourceFile(rootNode)) return rootNode if (!importsToRemove && !importsToAdd) return rootNode + if (importsToRemove && importsToRemove.size === 0 && importsToAdd && importsToAdd.size === 0) + return rootNode const { factory } = context