Skip to content

Commit

Permalink
feat: add Wildcard Input codemod (#94)
Browse files Browse the repository at this point in the history
  • Loading branch information
valerybugakov authored Mar 31, 2022
1 parent 4cb14b1 commit 5c6a871
Show file tree
Hide file tree
Showing 11 changed files with 289 additions and 50 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
}
},
"dependencies": {
"@ts-morph/bootstrap": "^0.11.1",
"@ts-morph/bootstrap": "^0.13.0",
"camelcase": "^6.2.0",
"commander": "^8.1.0",
"css-modules-loader-core": "^1.1.0",
Expand All @@ -43,7 +43,7 @@
"prettier-eslint": "^13.0.0",
"signale": "^1.4.0",
"stylelint": "^13.13.1",
"ts-morph": "13.0.1",
"ts-morph": "14.0.0",
"ts-node": "^10.1.0",
"type-fest": "^2.8.0",
"typescript": "4.5.2"
Expand Down
3 changes: 1 addition & 2 deletions packages/toolkit-packages/src/classNames/classNames.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { Node, ts, SourceFile, CallExpression } from 'ts-morph'
import { Expression } from 'typescript'

import { addOrUpdateImportIfIdentifierIsUsed, isImportedFromModule } from '@sourcegraph/codemod-toolkit-ts'

export const CLASSNAMES_IDENTIFIER = 'classNames'
export const CLASSNAMES_MODULE_SPECIFIER = CLASSNAMES_IDENTIFIER.toLowerCase()

// Wraps array of arguments into a `classNames` function call.
export function wrapIntoClassNamesUtility(classNames: Expression[]): ts.CallExpression {
export function wrapIntoClassNamesUtility(classNames: ts.Expression[]): ts.CallExpression {
return ts.factory.createCallExpression(ts.factory.createIdentifier(CLASSNAMES_IDENTIFIER), undefined, classNames)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,11 @@ import { removeJsxAttribute, getJsxAttributeStringValue, isJsxAttributeEmpty } f

describe('removeJsxAttribute', () => {
it('removes Jsx attribute', () => {
const node = createJsxOpeningElement('const x = <button type="button" disabled={true}>hey</button>')
const node = createJsxOpeningElement('const x = <button type="button" disabled={true} {...rest}>hey</button>')

removeJsxAttribute(node, 'type')

expect(
node.getAttributes().map(attribute => {
return attribute.getText()
})
).toEqual(['disabled={true}'])
expect(node.print()).toEqual('<button disabled={true} {...rest}>')
})
})

Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,16 @@
import { ts, NodeParentType, SyntaxKind } from 'ts-morph'
import {
Expression,
StringLiteral,
CallExpression,
JsxExpression,
ComputedPropertyName,
PropertyAccessExpression,
} from 'typescript'

import { wrapIntoClassNamesUtility, CLASSNAMES_IDENTIFIER } from '@sourcegraph/codemod-toolkit-packages'

export interface GetClassNameNodeReplacementOptions {
parentNode: NodeParentType<StringLiteral>
parentNode: NodeParentType<ts.StringLiteral>
leftOverClassName: string
exportNameReferences: PropertyAccessExpression[]
exportNameReferences: ts.PropertyAccessExpression[]
}

function getClassNameNodeReplacementWithoutBraces(
options: GetClassNameNodeReplacementOptions
): PropertyAccessExpression | CallExpression | Expression[] {
): ts.PropertyAccessExpression | ts.CallExpression | ts.Expression[] {
const { leftOverClassName, exportNameReferences, parentNode } = options

const isInClassnamesCall =
Expand All @@ -28,7 +20,7 @@ function getClassNameNodeReplacementWithoutBraces(
// We need to use `classNames` utility for multiple `exportNames` or for a combination of the `exportName` and `StringLiteral`.
// className={classNames('d-flex mr-1 kek kek--primary')} -> className={classNames('d-flex mr-1', styles.kek, styles.kekPrimary)}
if (leftOverClassName || exportNameReferences.length > 1) {
const classNamesCallArguments: Expression[] = [...exportNameReferences]
const classNamesCallArguments: ts.Expression[] = [...exportNameReferences]

if (leftOverClassName) {
classNamesCallArguments.unshift(ts.factory.createStringLiteral(leftOverClassName))
Expand All @@ -48,7 +40,7 @@ function getClassNameNodeReplacementWithoutBraces(

type GetClassNameNodeReplacementResult =
| {
replacement: PropertyAccessExpression | JsxExpression | ComputedPropertyName | CallExpression
replacement: ts.PropertyAccessExpression | ts.JsxExpression | ts.ComputedPropertyName | ts.CallExpression
isParentTransformed: false
}
| {
Expand Down
3 changes: 3 additions & 0 deletions packages/transforms/src/inputToComponent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Icon element to `<Icon />` Wildcard component codemod

yarn transform --write -t ./packages/transforms/src/inputToComponent/inputToComponent.ts '/sourcegraph/client/!(wildcard)/src/\*_/_.{ts,tsx}'
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { testCodemod } from '@sourcegraph/codemod-toolkit-ts'

import { inputToComponent } from '../inputToComponent'

testCodemod('inputToComponent', inputToComponent, [
{
label: 'case 1',
initialSource: 'export const Test = <input className="hello form-control" type="text" {...rest} />',
expectedSource: `
import { Input } from '@sourcegraph/wildcard'
export const Test = <Input className="hello" {...rest} />
`,
},
{
label: 'case 2',
initialSource: 'export const Test = <input className="form-control form-control-sm hello" />',
expectedSource: `
import { Input } from '@sourcegraph/wildcard'
export const Test = <Input className="hello" size="sm" />
`,
},
{
label: 'case 3',
initialSource: `
import classNames from 'classnames'
export const Test = <input className={classNames('form-control-sm hello', styles.coolInput)} />`,
expectedSource: `
import classNames from 'classnames'
import { Input } from '@sourcegraph/wildcard'
export const Test = <Input className={classNames('hello', styles.coolInput)} size="sm" />
`,
},
{
label: 'case 4',
initialSource: `
import classNames from 'classnames'
export const Test = <input aria-label="Console icon" className={classNames('form-control', styles.coolInput)} />`,
expectedSource: `
import { Input } from '@sourcegraph/wildcard'
export const Test = <Input aria-label="Console icon" className={styles.coolInput} />
`,
},
{
label: 'case 5',
initialSource: 'export const Test = <input className="hello" type="text" {...rest} />',
expectedSource: `
import { Input } from '@sourcegraph/wildcard'
export const Test = <Input className="hello" {...rest} />
`,
},
])
2 changes: 2 additions & 0 deletions packages/transforms/src/inputToComponent/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './inputToComponent'
export * from './validateCodemodTarget'
31 changes: 31 additions & 0 deletions packages/transforms/src/inputToComponent/inputClassNamesMapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ts } from 'ts-morph'

export const INPUT_SIZES = ['sm'] as const

export interface ClassNameMapping {
className: string
props: {
name: string
value: ts.Node
}[]
}

const sizeClassNamesMapping: ClassNameMapping[] = INPUT_SIZES.map(size => {
return {
className: `form-control-${size}`,
props: [
{
name: 'size',
value: ts.factory.createStringLiteral(size),
},
],
}
})

export const inputClassNamesMapping: ClassNameMapping[] = [
...sizeClassNamesMapping,
{
className: 'form-control',
props: [],
},
]
95 changes: 95 additions & 0 deletions packages/transforms/src/inputToComponent/inputToComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Node, printNode } from 'ts-morph'

import {
removeClassNameAndUpdateJsxElement,
addOrUpdateSourcegraphWildcardImportIfNeeded,
} from '@sourcegraph/codemod-toolkit-packages'
import {
runTransform,
getParentUntilOrThrow,
isJsxTagElement,
getTagName,
JsxTagElement,
setOnJsxTagElement,
getJsxAttributeStringValue,
removeJsxAttribute,
} from '@sourcegraph/codemod-toolkit-ts'

import { validateCodemodTarget, validateCodemodTargetOrThrow } from './validateCodemodTarget'

/**
* Convert `<input class="form-control" />` element to the `<Input />` component.
*/
export const inputToComponent = runTransform(context => {
const { throwManualChangeError, addManualChangeLog } = context

const jsxTagElementsToUpdate = new Set<JsxTagElement>()

return {
JsxSelfClosingElement(jsxTagElement) {
if (validateCodemodTarget.JsxTagElement(jsxTagElement)) {
jsxTagElementsToUpdate.add(jsxTagElement)
}
},
StringLiteral(stringLiteral) {
const { classNameMappings } = validateCodemodTargetOrThrow.StringLiteral(stringLiteral)
const jsxAttribute = getParentUntilOrThrow(stringLiteral, Node.isJsxAttribute)

if (!/classname/i.test(jsxAttribute.getName())) {
return
}

const jsxTagElement = getParentUntilOrThrow(jsxAttribute, isJsxTagElement)

if (!validateCodemodTarget.JsxTagElement(jsxTagElement)) {
throwManualChangeError({
node: jsxTagElement,
message: `Class '${stringLiteral.getLiteralText()}' is used on the '${getTagName(
jsxTagElement
)}' element. Please update it manually.`,
})
}

for (const { className, props } of classNameMappings) {
const { isRemoved, manualChangeLog } = removeClassNameAndUpdateJsxElement(stringLiteral, className)

if (manualChangeLog) {
addManualChangeLog(manualChangeLog)
}

if (isRemoved) {
for (const { name, value } of props) {
jsxTagElement.addAttribute({
name,
initializer: printNode(value),
})
}
}
}

jsxTagElementsToUpdate.add(jsxTagElement)
},
SourceFileExit(sourceFile) {
if (jsxTagElementsToUpdate.size === 0) {
return
}

for (const jsxTagElement of jsxTagElementsToUpdate) {
if (getJsxAttributeStringValue(jsxTagElement, 'type') === 'text') {
removeJsxAttribute(jsxTagElement, 'type')
}

setOnJsxTagElement(jsxTagElement, { name: 'Input' })
}

addOrUpdateSourcegraphWildcardImportIfNeeded({
sourceFile,
importStructure: {
namedImports: ['Input'],
},
})

sourceFile.fixUnusedIdentifiers()
},
}
})
49 changes: 49 additions & 0 deletions packages/transforms/src/inputToComponent/validateCodemodTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { StringLiteral } from 'ts-morph'

import { throwFromMethodsIfUndefinedReturn } from '@sourcegraph/codemod-common'
import { JsxTagElement } from '@sourcegraph/codemod-toolkit-ts'

import { inputClassNamesMapping, ClassNameMapping } from './inputClassNamesMapping'

interface StringLiteralValidatorResult {
stringLiteral: StringLiteral
classNameMappings: ClassNameMapping[]
}

interface JsxTagElementValidatorResult {
jsxTagElement: JsxTagElement
tagName: string
}

export const validateCodemodTarget = {
/**
* Returns `JsxTagElement`.
*/
JsxTagElement(jsxTagElement: JsxTagElement, bannedTagName = 'input'): JsxTagElementValidatorResult | void {
const tagName = jsxTagElement.getTagNameNode().getText()

if (tagName === bannedTagName) {
return { jsxTagElement, tagName }
}
},

/**
* Returns non-void result if received `StringLiteral` has one of icon classes like `icon-inline`.
*/
StringLiteral(stringLiteral: StringLiteral): StringLiteralValidatorResult | void {
const classNameMappings = inputClassNamesMapping.filter(({ className }) => {
return stringLiteral
.getLiteralValue()
.split(' ')
.some(word => {
return word === className
})
})

if (classNameMappings.length !== 0) {
return { classNameMappings, stringLiteral }
}
},
}

export const validateCodemodTargetOrThrow = throwFromMethodsIfUndefinedReturn(validateCodemodTarget)
Loading

0 comments on commit 5c6a871

Please sign in to comment.