Skip to content

Commit 5c6a871

Browse files
feat: add Wildcard Input codemod (#94)
1 parent 4cb14b1 commit 5c6a871

File tree

11 files changed

+289
-50
lines changed

11 files changed

+289
-50
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
}
3232
},
3333
"dependencies": {
34-
"@ts-morph/bootstrap": "^0.11.1",
34+
"@ts-morph/bootstrap": "^0.13.0",
3535
"camelcase": "^6.2.0",
3636
"commander": "^8.1.0",
3737
"css-modules-loader-core": "^1.1.0",
@@ -43,7 +43,7 @@
4343
"prettier-eslint": "^13.0.0",
4444
"signale": "^1.4.0",
4545
"stylelint": "^13.13.1",
46-
"ts-morph": "13.0.1",
46+
"ts-morph": "14.0.0",
4747
"ts-node": "^10.1.0",
4848
"type-fest": "^2.8.0",
4949
"typescript": "4.5.2"

packages/toolkit-packages/src/classNames/classNames.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { Node, ts, SourceFile, CallExpression } from 'ts-morph'
2-
import { Expression } from 'typescript'
32

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

65
export const CLASSNAMES_IDENTIFIER = 'classNames'
76
export const CLASSNAMES_MODULE_SPECIFIER = CLASSNAMES_IDENTIFIER.toLowerCase()
87

98
// Wraps array of arguments into a `classNames` function call.
10-
export function wrapIntoClassNamesUtility(classNames: Expression[]): ts.CallExpression {
9+
export function wrapIntoClassNamesUtility(classNames: ts.Expression[]): ts.CallExpression {
1110
return ts.factory.createCallExpression(ts.factory.createIdentifier(CLASSNAMES_IDENTIFIER), undefined, classNames)
1211
}
1312

packages/toolkit-ts/src/manipulation/jsx/__tests__/JsxAttribute.test.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,11 @@ import { removeJsxAttribute, getJsxAttributeStringValue, isJsxAttributeEmpty } f
33

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

88
removeJsxAttribute(node, 'type')
99

10-
expect(
11-
node.getAttributes().map(attribute => {
12-
return attribute.getText()
13-
})
14-
).toEqual(['disabled={true}'])
10+
expect(node.print()).toEqual('<button disabled={true} {...rest}>')
1511
})
1612
})
1713

packages/transforms/src/globalCssToCssModule/ts/getClassNameNodeReplacement.ts

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,16 @@
11
import { ts, NodeParentType, SyntaxKind } from 'ts-morph'
2-
import {
3-
Expression,
4-
StringLiteral,
5-
CallExpression,
6-
JsxExpression,
7-
ComputedPropertyName,
8-
PropertyAccessExpression,
9-
} from 'typescript'
102

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

135
export interface GetClassNameNodeReplacementOptions {
14-
parentNode: NodeParentType<StringLiteral>
6+
parentNode: NodeParentType<ts.StringLiteral>
157
leftOverClassName: string
16-
exportNameReferences: PropertyAccessExpression[]
8+
exportNameReferences: ts.PropertyAccessExpression[]
179
}
1810

1911
function getClassNameNodeReplacementWithoutBraces(
2012
options: GetClassNameNodeReplacementOptions
21-
): PropertyAccessExpression | CallExpression | Expression[] {
13+
): ts.PropertyAccessExpression | ts.CallExpression | ts.Expression[] {
2214
const { leftOverClassName, exportNameReferences, parentNode } = options
2315

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

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

4941
type GetClassNameNodeReplacementResult =
5042
| {
51-
replacement: PropertyAccessExpression | JsxExpression | ComputedPropertyName | CallExpression
43+
replacement: ts.PropertyAccessExpression | ts.JsxExpression | ts.ComputedPropertyName | ts.CallExpression
5244
isParentTransformed: false
5345
}
5446
| {
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Icon element to `<Icon />` Wildcard component codemod
2+
3+
yarn transform --write -t ./packages/transforms/src/inputToComponent/inputToComponent.ts '/sourcegraph/client/!(wildcard)/src/\*_/_.{ts,tsx}'
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { testCodemod } from '@sourcegraph/codemod-toolkit-ts'
2+
3+
import { inputToComponent } from '../inputToComponent'
4+
5+
testCodemod('inputToComponent', inputToComponent, [
6+
{
7+
label: 'case 1',
8+
initialSource: 'export const Test = <input className="hello form-control" type="text" {...rest} />',
9+
expectedSource: `
10+
import { Input } from '@sourcegraph/wildcard'
11+
12+
export const Test = <Input className="hello" {...rest} />
13+
`,
14+
},
15+
{
16+
label: 'case 2',
17+
initialSource: 'export const Test = <input className="form-control form-control-sm hello" />',
18+
expectedSource: `
19+
import { Input } from '@sourcegraph/wildcard'
20+
21+
export const Test = <Input className="hello" size="sm" />
22+
`,
23+
},
24+
{
25+
label: 'case 3',
26+
initialSource: `
27+
import classNames from 'classnames'
28+
export const Test = <input className={classNames('form-control-sm hello', styles.coolInput)} />`,
29+
expectedSource: `
30+
import classNames from 'classnames'
31+
32+
import { Input } from '@sourcegraph/wildcard'
33+
34+
export const Test = <Input className={classNames('hello', styles.coolInput)} size="sm" />
35+
`,
36+
},
37+
{
38+
label: 'case 4',
39+
initialSource: `
40+
import classNames from 'classnames'
41+
export const Test = <input aria-label="Console icon" className={classNames('form-control', styles.coolInput)} />`,
42+
expectedSource: `
43+
import { Input } from '@sourcegraph/wildcard'
44+
45+
export const Test = <Input aria-label="Console icon" className={styles.coolInput} />
46+
`,
47+
},
48+
{
49+
label: 'case 5',
50+
initialSource: 'export const Test = <input className="hello" type="text" {...rest} />',
51+
expectedSource: `
52+
import { Input } from '@sourcegraph/wildcard'
53+
54+
export const Test = <Input className="hello" {...rest} />
55+
`,
56+
},
57+
])
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './inputToComponent'
2+
export * from './validateCodemodTarget'
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { ts } from 'ts-morph'
2+
3+
export const INPUT_SIZES = ['sm'] as const
4+
5+
export interface ClassNameMapping {
6+
className: string
7+
props: {
8+
name: string
9+
value: ts.Node
10+
}[]
11+
}
12+
13+
const sizeClassNamesMapping: ClassNameMapping[] = INPUT_SIZES.map(size => {
14+
return {
15+
className: `form-control-${size}`,
16+
props: [
17+
{
18+
name: 'size',
19+
value: ts.factory.createStringLiteral(size),
20+
},
21+
],
22+
}
23+
})
24+
25+
export const inputClassNamesMapping: ClassNameMapping[] = [
26+
...sizeClassNamesMapping,
27+
{
28+
className: 'form-control',
29+
props: [],
30+
},
31+
]
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { Node, printNode } from 'ts-morph'
2+
3+
import {
4+
removeClassNameAndUpdateJsxElement,
5+
addOrUpdateSourcegraphWildcardImportIfNeeded,
6+
} from '@sourcegraph/codemod-toolkit-packages'
7+
import {
8+
runTransform,
9+
getParentUntilOrThrow,
10+
isJsxTagElement,
11+
getTagName,
12+
JsxTagElement,
13+
setOnJsxTagElement,
14+
getJsxAttributeStringValue,
15+
removeJsxAttribute,
16+
} from '@sourcegraph/codemod-toolkit-ts'
17+
18+
import { validateCodemodTarget, validateCodemodTargetOrThrow } from './validateCodemodTarget'
19+
20+
/**
21+
* Convert `<input class="form-control" />` element to the `<Input />` component.
22+
*/
23+
export const inputToComponent = runTransform(context => {
24+
const { throwManualChangeError, addManualChangeLog } = context
25+
26+
const jsxTagElementsToUpdate = new Set<JsxTagElement>()
27+
28+
return {
29+
JsxSelfClosingElement(jsxTagElement) {
30+
if (validateCodemodTarget.JsxTagElement(jsxTagElement)) {
31+
jsxTagElementsToUpdate.add(jsxTagElement)
32+
}
33+
},
34+
StringLiteral(stringLiteral) {
35+
const { classNameMappings } = validateCodemodTargetOrThrow.StringLiteral(stringLiteral)
36+
const jsxAttribute = getParentUntilOrThrow(stringLiteral, Node.isJsxAttribute)
37+
38+
if (!/classname/i.test(jsxAttribute.getName())) {
39+
return
40+
}
41+
42+
const jsxTagElement = getParentUntilOrThrow(jsxAttribute, isJsxTagElement)
43+
44+
if (!validateCodemodTarget.JsxTagElement(jsxTagElement)) {
45+
throwManualChangeError({
46+
node: jsxTagElement,
47+
message: `Class '${stringLiteral.getLiteralText()}' is used on the '${getTagName(
48+
jsxTagElement
49+
)}' element. Please update it manually.`,
50+
})
51+
}
52+
53+
for (const { className, props } of classNameMappings) {
54+
const { isRemoved, manualChangeLog } = removeClassNameAndUpdateJsxElement(stringLiteral, className)
55+
56+
if (manualChangeLog) {
57+
addManualChangeLog(manualChangeLog)
58+
}
59+
60+
if (isRemoved) {
61+
for (const { name, value } of props) {
62+
jsxTagElement.addAttribute({
63+
name,
64+
initializer: printNode(value),
65+
})
66+
}
67+
}
68+
}
69+
70+
jsxTagElementsToUpdate.add(jsxTagElement)
71+
},
72+
SourceFileExit(sourceFile) {
73+
if (jsxTagElementsToUpdate.size === 0) {
74+
return
75+
}
76+
77+
for (const jsxTagElement of jsxTagElementsToUpdate) {
78+
if (getJsxAttributeStringValue(jsxTagElement, 'type') === 'text') {
79+
removeJsxAttribute(jsxTagElement, 'type')
80+
}
81+
82+
setOnJsxTagElement(jsxTagElement, { name: 'Input' })
83+
}
84+
85+
addOrUpdateSourcegraphWildcardImportIfNeeded({
86+
sourceFile,
87+
importStructure: {
88+
namedImports: ['Input'],
89+
},
90+
})
91+
92+
sourceFile.fixUnusedIdentifiers()
93+
},
94+
}
95+
})
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { StringLiteral } from 'ts-morph'
2+
3+
import { throwFromMethodsIfUndefinedReturn } from '@sourcegraph/codemod-common'
4+
import { JsxTagElement } from '@sourcegraph/codemod-toolkit-ts'
5+
6+
import { inputClassNamesMapping, ClassNameMapping } from './inputClassNamesMapping'
7+
8+
interface StringLiteralValidatorResult {
9+
stringLiteral: StringLiteral
10+
classNameMappings: ClassNameMapping[]
11+
}
12+
13+
interface JsxTagElementValidatorResult {
14+
jsxTagElement: JsxTagElement
15+
tagName: string
16+
}
17+
18+
export const validateCodemodTarget = {
19+
/**
20+
* Returns `JsxTagElement`.
21+
*/
22+
JsxTagElement(jsxTagElement: JsxTagElement, bannedTagName = 'input'): JsxTagElementValidatorResult | void {
23+
const tagName = jsxTagElement.getTagNameNode().getText()
24+
25+
if (tagName === bannedTagName) {
26+
return { jsxTagElement, tagName }
27+
}
28+
},
29+
30+
/**
31+
* Returns non-void result if received `StringLiteral` has one of icon classes like `icon-inline`.
32+
*/
33+
StringLiteral(stringLiteral: StringLiteral): StringLiteralValidatorResult | void {
34+
const classNameMappings = inputClassNamesMapping.filter(({ className }) => {
35+
return stringLiteral
36+
.getLiteralValue()
37+
.split(' ')
38+
.some(word => {
39+
return word === className
40+
})
41+
})
42+
43+
if (classNameMappings.length !== 0) {
44+
return { classNameMappings, stringLiteral }
45+
}
46+
},
47+
}
48+
49+
export const validateCodemodTargetOrThrow = throwFromMethodsIfUndefinedReturn(validateCodemodTarget)

0 commit comments

Comments
 (0)