@planningcenter/tapestry-migration-cli 3.4.1 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +59 -0
  2. package/dist/tapestry-react-shim.cjs +5 -1
  3. package/package.json +3 -3
  4. package/src/availableComponents.ts +14 -0
  5. package/src/components/button/transforms/tooltipToWrapper.test.ts +50 -3
  6. package/src/components/button/transforms/tooltipToWrapper.ts +18 -0
  7. package/src/components/dropdown/index.test.ts +67 -0
  8. package/src/components/dropdown/index.ts +4 -0
  9. package/src/components/dropdown/transforms/placementIdToMenu.test.ts +6 -1
  10. package/src/components/dropdown/transforms/placementIdToMenu.ts +1 -1
  11. package/src/components/dropdown/transforms/variantToKind.test.ts +292 -0
  12. package/src/components/dropdown/transforms/variantToKind.ts +138 -0
  13. package/src/components/dropdown/transforms/wrapIconTrigger.test.ts +336 -0
  14. package/src/components/dropdown/transforms/wrapIconTrigger.ts +233 -0
  15. package/src/components/flex/index.test.ts +140 -0
  16. package/src/components/flex/index.ts +40 -0
  17. package/src/components/flex/transforms/alignmentToAlign.test.ts +185 -0
  18. package/src/components/flex/transforms/alignmentToAlign.ts +73 -0
  19. package/src/components/flex/transforms/axisToDirection.test.ts +140 -0
  20. package/src/components/flex/transforms/axisToDirection.ts +51 -0
  21. package/src/components/flex/transforms/distributionToJustify.test.ts +214 -0
  22. package/src/components/flex/transforms/distributionToJustify.ts +76 -0
  23. package/src/components/flex/transforms/innerRefToRef.test.ts +100 -0
  24. package/src/components/flex/transforms/innerRefToRef.ts +14 -0
  25. package/src/components/flex/transforms/moveFlexImport.test.ts +202 -0
  26. package/src/components/flex/transforms/moveFlexImport.ts +14 -0
  27. package/src/components/flex/transforms/setDefaultAxis.test.ts +114 -0
  28. package/src/components/flex/transforms/setDefaultAxis.ts +29 -0
  29. package/src/components/flex/transforms/spacingToGap.test.ts +176 -0
  30. package/src/components/flex/transforms/spacingToGap.ts +49 -0
  31. package/src/components/select/index.ts +2 -0
  32. package/src/components/select/transforms/onChangeSignature.test.ts +224 -0
  33. package/src/components/select/transforms/onChangeSignature.ts +172 -0
  34. package/src/components/shared/actions/resolveConstantAttributeValue.test.ts +122 -0
  35. package/src/components/shared/actions/resolveConstantAttributeValue.ts +31 -0
  36. package/src/components/toggle-switch/index.ts +2 -0
  37. package/src/components/toggle-switch/transforms/onClickToOnChange.test.ts +210 -0
  38. package/src/components/toggle-switch/transforms/onClickToOnChange.ts +71 -0
  39. package/src/index.test.ts +79 -0
  40. package/src/index.ts +34 -18
  41. package/src/migrationList.ts +22 -0
  42. package/src/utils/componentLabel.test.ts +61 -0
  43. package/src/utils/componentLabel.ts +15 -0
@@ -0,0 +1,172 @@
1
+ import { Transform } from "jscodeshift"
2
+
3
+ import { addCommentToAttribute } from "../../shared/actions/addCommentToAttribute"
4
+ import { andConditions } from "../../shared/conditions/andConditions"
5
+ import { hasAttribute } from "../../shared/conditions/hasAttribute"
6
+ import { findAttribute } from "../../shared/findAttribute"
7
+ import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
8
+ import { transformableSelect } from "../transformableSelect"
9
+
10
+ const TODO_TEXT =
11
+ "onChange now receives a React.ChangeEvent<HTMLSelectElement>; read the value via e.target.value instead of the old { selectedValue, value } argument"
12
+
13
+ /**
14
+ * Returns true if a parameter pattern mentions a `value` identifier, e.g.
15
+ * `value`, `{ value }`, `[value]`. Any occurrence is treated conservatively as
16
+ * a potential binding so we bail to a TODO rather than risk corrupting it.
17
+ */
18
+ function paramBindsValue(
19
+ param: import("jscodeshift").ASTNode,
20
+ j: import("jscodeshift").JSCodeshift
21
+ ): boolean {
22
+ // `find` only searches descendants, so check the param node itself first
23
+ // (a bare `value` param) before scanning destructuring patterns.
24
+ if (param.type === "Identifier" && param.name === "value") return true
25
+ return j(param).find(j.Identifier, { name: "value" }).size() > 0
26
+ }
27
+
28
+ /**
29
+ * Returns true if a nested function/arrow inside the body binds its own `value`
30
+ * parameter, which would shadow the destructured param. Rewriting these would
31
+ * corrupt the inner binding, so they fall through to a TODO comment instead.
32
+ *
33
+ * e.g. `({ value }) => list.map(value => value)` → ambiguous, leave for the dev
34
+ */
35
+ function bodyShadowsValue(
36
+ body: import("jscodeshift").ASTNode,
37
+ j: import("jscodeshift").JSCodeshift
38
+ ): boolean {
39
+ let shadows = false
40
+ const visit = (collection: ReturnType<typeof j>) =>
41
+ collection.forEach((path) => {
42
+ if (
43
+ path.node.params.some((p: import("jscodeshift").ASTNode) =>
44
+ paramBindsValue(p, j)
45
+ )
46
+ )
47
+ shadows = true
48
+ })
49
+ visit(j(body).find(j.ArrowFunctionExpression))
50
+ visit(j(body).find(j.FunctionExpression))
51
+ return shadows
52
+ }
53
+
54
+ /**
55
+ * Returns true if the onChange attribute is an expression-bodied arrow function
56
+ * with exactly one `{ value }` destructuring param and no other keys.
57
+ *
58
+ * e.g. `onChange={({ value }) => setX(value)}` → auto-migratable
59
+ * e.g. `onChange={handler}` or `onChange={({ selectedValue }) => …}` → TODO comment
60
+ */
61
+ function isSimpleValueDestructure(
62
+ attribute: ReturnType<typeof findAttribute>,
63
+ j: import("jscodeshift").JSCodeshift
64
+ ): boolean {
65
+ if (!attribute || attribute.type !== "JSXAttribute") return false
66
+
67
+ const container = attribute.value
68
+ if (!container || container.type !== "JSXExpressionContainer") return false
69
+
70
+ const arrow = container.expression
71
+ if (!j.ArrowFunctionExpression.check(arrow)) return false
72
+
73
+ // Block bodies are too complex to auto-rewrite safely
74
+ if (arrow.body.type === "BlockStatement") return false
75
+
76
+ const params = arrow.params
77
+ if (params.length !== 1) return false
78
+
79
+ const param = params[0]
80
+ if (param.type !== "ObjectPattern") return false
81
+
82
+ const props = param.properties
83
+ if (props.length !== 1) return false
84
+
85
+ const prop = props[0]
86
+ if (prop.type !== "ObjectProperty" && prop.type !== "Property") return false
87
+ if (!("shorthand" in prop) || !prop.shorthand) return false
88
+ if (prop.key.type !== "Identifier" || prop.key.name !== "value") return false
89
+
90
+ // A nested function that rebinds `value` would be corrupted by the rewrite.
91
+ if (bodyShadowsValue(arrow.body, j)) return false
92
+
93
+ return true
94
+ }
95
+
96
+ const transform: Transform = attributeTransformFactory({
97
+ condition: andConditions(transformableSelect, hasAttribute("onChange")),
98
+ targetComponent: "Select",
99
+ targetPackage: "@planningcenter/tapestry-react",
100
+ transform: (element, { j }) => {
101
+ const attributes = element.openingElement.attributes || []
102
+ const attribute = findAttribute(attributes, "onChange")
103
+ if (!attribute) return false
104
+
105
+ if (isSimpleValueDestructure(attribute, j)) {
106
+ // Safe to auto-rewrite: ({ value }) => expr → (e) => expr[value→e.target.value]
107
+ const container =
108
+ attribute.value as import("jscodeshift").JSXExpressionContainer
109
+ const arrow =
110
+ container.expression as import("jscodeshift").ArrowFunctionExpression
111
+
112
+ // Replace the param with identifier `e`
113
+ arrow.params = [j.identifier("e")]
114
+
115
+ // Replace all `value` identifier references in the expression body
116
+ // (skip object key / member-property positions to avoid false positives)
117
+ const body = arrow.body as import("jscodeshift").Expression
118
+ j(body)
119
+ .find(j.Identifier, { name: "value" })
120
+ .filter((path) => {
121
+ const parent = path.parent.node
122
+ // Skip member-property positions (`obj.value`, `obj?.value`) — these
123
+ // are property names, not references to the destructured param.
124
+ if (
125
+ (parent.type === "MemberExpression" ||
126
+ parent.type === "OptionalMemberExpression") &&
127
+ parent.property === path.node &&
128
+ !parent.computed
129
+ ) {
130
+ return false
131
+ }
132
+ // Skip object property keys (`{ value: … }`)
133
+ if (
134
+ (parent.type === "Property" || parent.type === "ObjectProperty") &&
135
+ parent.key === path.node &&
136
+ !parent.computed
137
+ ) {
138
+ return false
139
+ }
140
+ return true
141
+ })
142
+ .replaceWith((path) => {
143
+ // Object shorthand (`{ value }`) must be expanded to `{ value: … }`,
144
+ // otherwise the printed property loses its value.
145
+ const parent = path.parent.node
146
+ if (
147
+ (parent.type === "Property" || parent.type === "ObjectProperty") &&
148
+ "shorthand" in parent
149
+ ) {
150
+ parent.shorthand = false
151
+ }
152
+ return j.memberExpression(
153
+ j.memberExpression(j.identifier("e"), j.identifier("target")),
154
+ j.identifier("value")
155
+ )
156
+ })
157
+
158
+ return true
159
+ }
160
+
161
+ // Not auto-migratable — add a TODO comment so the developer handles it
162
+ addCommentToAttribute({
163
+ attribute,
164
+ commentKind: "todo",
165
+ j,
166
+ text: TODO_TEXT,
167
+ })
168
+ return true
169
+ },
170
+ })
171
+
172
+ export default transform
@@ -0,0 +1,122 @@
1
+ import jscodeshift, {
2
+ JSXAttribute,
3
+ JSXElement,
4
+ JSXIdentifier,
5
+ } from "jscodeshift"
6
+ import { describe, expect, it } from "vitest"
7
+
8
+ import { resolveConstantAttributeValue } from "./resolveConstantAttributeValue"
9
+
10
+ const j = jscodeshift.withParser("tsx")
11
+
12
+ const CONSTANT_MAP: Record<string, string> = {
13
+ CENTER: "center",
14
+ FILL: "fill",
15
+ START: "start",
16
+ }
17
+
18
+ function createElementFromCode(code: string): JSXElement {
19
+ const source = j(`<div>${code}</div>`)
20
+ return source.find(j.JSXElement).at(0).get().value.children?.[0] as JSXElement
21
+ }
22
+
23
+ function getAttributeFromElement(
24
+ element: JSXElement,
25
+ name: string
26
+ ): JSXAttribute {
27
+ const attributes = element.openingElement.attributes || []
28
+ return attributes.find(
29
+ (attr) =>
30
+ attr.type === "JSXAttribute" &&
31
+ (attr.name as JSXIdentifier)?.name === name
32
+ ) as JSXAttribute
33
+ }
34
+
35
+ describe("resolveConstantAttributeValue", () => {
36
+ it("resolves a string literal", () => {
37
+ const element = createElementFromCode('<StackView distribution="center" />')
38
+ const attr = getAttributeFromElement(element, "distribution")
39
+ expect(
40
+ resolveConstantAttributeValue({
41
+ attribute: attr,
42
+ constantMap: CONSTANT_MAP,
43
+ })
44
+ ).toBe("center")
45
+ })
46
+
47
+ it("resolves an expression-wrapped string literal", () => {
48
+ const element = createElementFromCode(
49
+ '<StackView distribution={"center"} />'
50
+ )
51
+ const attr = getAttributeFromElement(element, "distribution")
52
+ expect(
53
+ resolveConstantAttributeValue({
54
+ attribute: attr,
55
+ constantMap: CONSTANT_MAP,
56
+ })
57
+ ).toBe("center")
58
+ })
59
+
60
+ it("resolves a MemberExpression constant present in the map", () => {
61
+ const element = createElementFromCode(
62
+ "<StackView distribution={StackView.CENTER} />"
63
+ )
64
+ const attr = getAttributeFromElement(element, "distribution")
65
+ expect(
66
+ resolveConstantAttributeValue({
67
+ attribute: attr,
68
+ constantMap: CONSTANT_MAP,
69
+ })
70
+ ).toBe("center")
71
+ })
72
+
73
+ it("resolves a MemberExpression constant that maps to fill", () => {
74
+ const element = createElementFromCode(
75
+ "<StackView distribution={StackView.FILL} />"
76
+ )
77
+ const attr = getAttributeFromElement(element, "distribution")
78
+ expect(
79
+ resolveConstantAttributeValue({
80
+ attribute: attr,
81
+ constantMap: CONSTANT_MAP,
82
+ })
83
+ ).toBe("fill")
84
+ })
85
+
86
+ it("returns null for a MemberExpression constant not in the map", () => {
87
+ const element = createElementFromCode(
88
+ "<StackView distribution={StackView.UNKNOWN} />"
89
+ )
90
+ const attr = getAttributeFromElement(element, "distribution")
91
+ expect(
92
+ resolveConstantAttributeValue({
93
+ attribute: attr,
94
+ constantMap: CONSTANT_MAP,
95
+ })
96
+ ).toBeNull()
97
+ })
98
+
99
+ it("returns null for a dynamic variable", () => {
100
+ const element = createElementFromCode(
101
+ "<StackView distribution={someVar} />"
102
+ )
103
+ const attr = getAttributeFromElement(element, "distribution")
104
+ expect(
105
+ resolveConstantAttributeValue({
106
+ attribute: attr,
107
+ constantMap: CONSTANT_MAP,
108
+ })
109
+ ).toBeNull()
110
+ })
111
+
112
+ it("returns null for a boolean (valueless) attribute", () => {
113
+ const element = createElementFromCode("<StackView distribution />")
114
+ const attr = getAttributeFromElement(element, "distribution")
115
+ expect(
116
+ resolveConstantAttributeValue({
117
+ attribute: attr,
118
+ constantMap: CONSTANT_MAP,
119
+ })
120
+ ).toBeNull()
121
+ })
122
+ })
@@ -0,0 +1,31 @@
1
+ import { JSXAttribute } from "jscodeshift"
2
+
3
+ export function resolveConstantAttributeValue({
4
+ attribute,
5
+ constantMap,
6
+ }: {
7
+ attribute: JSXAttribute
8
+ constantMap: Record<string, string>
9
+ }): string | null {
10
+ const value = attribute.value
11
+ if (!value) return null
12
+
13
+ if (value.type === "StringLiteral") {
14
+ return value.value
15
+ }
16
+
17
+ if (value.type === "JSXExpressionContainer") {
18
+ if (value.expression.type === "StringLiteral") {
19
+ return value.expression.value
20
+ }
21
+
22
+ if (
23
+ value.expression.type === "MemberExpression" &&
24
+ value.expression.property.type === "Identifier"
25
+ ) {
26
+ return constantMap[value.expression.property.name] ?? null
27
+ }
28
+ }
29
+
30
+ return null
31
+ }
@@ -5,6 +5,7 @@ import auditSpreadProps from "./transforms/auditSpreadProps"
5
5
  import convertStyleProps from "./transforms/convertStyleProps"
6
6
  import isCheckedToChecked from "./transforms/isCheckedToChecked"
7
7
  import moveToggleSwitchImport from "./transforms/moveToggleSwitchImport"
8
+ import onClickToOnChange from "./transforms/onClickToOnChange"
8
9
  import sizeMapping from "./transforms/sizeMapping"
9
10
  import unsupportedProps from "./transforms/unsupportedProps"
10
11
 
@@ -17,6 +18,7 @@ const transform: Transform = (fileInfo, api, options) => {
17
18
  sizeMapping,
18
19
  ariaLabelToLabel,
19
20
  isCheckedToChecked,
21
+ onClickToOnChange,
20
22
  convertStyleProps,
21
23
  unsupportedProps,
22
24
  moveToggleSwitchImport,
@@ -0,0 +1,210 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import transform from "./onClickToOnChange"
5
+
6
+ const j = jscodeshift.withParser("tsx")
7
+
8
+ function applyTransform(source: string, verbose = false): string {
9
+ const fileInfo = { path: "test.tsx", source }
10
+ const result = transform(
11
+ fileInfo,
12
+ { j, jscodeshift: j, report: () => {}, stats: () => {} },
13
+ { verbose }
14
+ ) as string | null
15
+ return result || source
16
+ }
17
+
18
+ describe("onClickToOnChange transform", () => {
19
+ describe("onClick to onChange conversion", () => {
20
+ it("should rename onClick to onChange", () => {
21
+ const input = `
22
+ import { ToggleSwitch } from "@planningcenter/tapestry-react"
23
+
24
+ function Test() {
25
+ return <ToggleSwitch checked={true} onClick={() => setChecked(!checked)} label="Toggle" />
26
+ }
27
+ `.trim()
28
+
29
+ const result = applyTransform(input)
30
+ expect(result).toContain("onChange={() => setChecked(!checked)}")
31
+ expect(result).not.toContain("onClick=")
32
+ })
33
+
34
+ it("should rename onClick with named function reference", () => {
35
+ const input = `
36
+ import { ToggleSwitch } from "@planningcenter/tapestry-react"
37
+
38
+ function Test() {
39
+ return <ToggleSwitch checked={true} onClick={handleChange} label="Toggle" />
40
+ }
41
+ `.trim()
42
+
43
+ const result = applyTransform(input)
44
+ expect(result).toContain("onChange={handleChange}")
45
+ expect(result).not.toContain("onClick=")
46
+ })
47
+
48
+ it("should handle multiple ToggleSwitch components", () => {
49
+ const input = `
50
+ import { ToggleSwitch } from "@planningcenter/tapestry-react"
51
+
52
+ function Test() {
53
+ return (
54
+ <div>
55
+ <ToggleSwitch checked={true} onClick={handleA} label="First" />
56
+ <ToggleSwitch checked={false} onClick={handleB} label="Second" />
57
+ </div>
58
+ )
59
+ }
60
+ `.trim()
61
+
62
+ const result = applyTransform(input)
63
+ expect(result).toContain("onChange={handleA}")
64
+ expect(result).toContain("onChange={handleB}")
65
+ expect(result).not.toContain("onClick=")
66
+ })
67
+
68
+ it("should preserve other props", () => {
69
+ const input = `
70
+ import { ToggleSwitch } from "@planningcenter/tapestry-react"
71
+
72
+ function Test() {
73
+ return (
74
+ <ToggleSwitch
75
+ checked={true}
76
+ label="Toggle"
77
+ disabled
78
+ onClick={() => {}}
79
+ />
80
+ )
81
+ }
82
+ `.trim()
83
+
84
+ const result = applyTransform(input)
85
+ expect(result).toContain("onChange={() => {}}")
86
+ expect(result).toContain('label="Toggle"')
87
+ expect(result).toContain("checked={true}")
88
+ expect(result).toContain("disabled")
89
+ expect(result).not.toContain("onClick=")
90
+ })
91
+
92
+ it("should add CHANGED comment when verbose is enabled", () => {
93
+ const input = `
94
+ import { ToggleSwitch } from "@planningcenter/tapestry-react"
95
+
96
+ function Test() {
97
+ return <ToggleSwitch checked={true} onClick={() => {}} label="Toggle" />
98
+ }
99
+ `.trim()
100
+
101
+ const result = applyTransform(input, true)
102
+ expect(result).toContain("onChange={() => {}}")
103
+ expect(result).not.toContain("onClick=")
104
+ expect(result).toContain(
105
+ "CHANGED: tapestry-migration (onChange): renamed from onClick"
106
+ )
107
+ })
108
+ })
109
+
110
+ describe("edge cases", () => {
111
+ it("should not affect ToggleSwitch without onClick", () => {
112
+ const input = `
113
+ import { ToggleSwitch } from "@planningcenter/tapestry-react"
114
+
115
+ function Test() {
116
+ return <ToggleSwitch checked={true} label="Toggle" />
117
+ }
118
+ `.trim()
119
+
120
+ const result = applyTransform(input)
121
+ expect(result).toBe(input)
122
+ })
123
+
124
+ it("should not affect other components with onClick", () => {
125
+ const input = `
126
+ import { Button, ToggleSwitch } from "@planningcenter/tapestry-react"
127
+
128
+ function Test() {
129
+ return (
130
+ <div>
131
+ <Button onClick={() => {}}>Click me</Button>
132
+ <ToggleSwitch checked={true} onClick={handleChange} label="Toggle" />
133
+ </div>
134
+ )
135
+ }
136
+ `.trim()
137
+
138
+ const result = applyTransform(input)
139
+ expect(result).toContain("<Button onClick={() => {}}>Click me</Button>")
140
+ expect(result).toContain("onChange={handleChange}")
141
+ })
142
+
143
+ it("should rename onClick and add TODO comment when handler takes an argument", () => {
144
+ const input = `
145
+ import { ToggleSwitch } from "@planningcenter/tapestry-react"
146
+
147
+ function Test() {
148
+ return <ToggleSwitch checked={true} onClick={(e) => handle(e)} label="Toggle" />
149
+ }
150
+ `.trim()
151
+
152
+ const result = applyTransform(input)
153
+ expect(result).toContain("onChange={(e) => handle(e)}")
154
+ expect(result).not.toContain("onClick=")
155
+ expect(result).toContain("TODO")
156
+ expect(result).toContain("ChangeEvent<HTMLInputElement>")
157
+ })
158
+
159
+ it("should not add TODO when handler takes no arguments", () => {
160
+ const input = `
161
+ import { ToggleSwitch } from "@planningcenter/tapestry-react"
162
+
163
+ function Test() {
164
+ return <ToggleSwitch checked={true} onClick={() => toggle()} label="Toggle" />
165
+ }
166
+ `.trim()
167
+
168
+ const result = applyTransform(input)
169
+ expect(result).toContain("onChange={() => toggle()}")
170
+ expect(result).not.toContain("onClick=")
171
+ expect(result).not.toContain("TODO")
172
+ })
173
+
174
+ it("should handle aliased ToggleSwitch import", () => {
175
+ const input = `
176
+ import { ToggleSwitch as TapestryToggleSwitch } from "@planningcenter/tapestry-react"
177
+
178
+ function Test() {
179
+ return <TapestryToggleSwitch checked={true} onClick={handleChange} label="Toggle" />
180
+ }
181
+ `.trim()
182
+
183
+ const result = applyTransform(input)
184
+ expect(result).toContain("onChange={handleChange}")
185
+ expect(result).not.toContain("onClick=")
186
+ })
187
+
188
+ it("should leave onClick and add TODO when both onClick and onChange are present", () => {
189
+ const input = `
190
+ import { ToggleSwitch } from "@planningcenter/tapestry-react"
191
+
192
+ function Test() {
193
+ return (
194
+ <ToggleSwitch
195
+ checked={true}
196
+ onClick={() => handleClick()}
197
+ onChange={(e) => handleChange(e)}
198
+ label="Toggle"
199
+ />
200
+ )
201
+ }
202
+ `.trim()
203
+
204
+ const result = applyTransform(input)
205
+ expect(result).toContain("onClick={() => handleClick()}")
206
+ expect(result).toContain("onChange={(e) => handleChange(e)}")
207
+ expect(result).toContain("TODO")
208
+ })
209
+ })
210
+ })
@@ -0,0 +1,71 @@
1
+ import { Transform } from "jscodeshift"
2
+
3
+ import { addCommentToAttribute } from "../../shared/actions/addCommentToAttribute"
4
+ import { transformAttributeName } from "../../shared/actions/transformAttributeName"
5
+ import { hasAttribute } from "../../shared/conditions/hasAttribute"
6
+ import { findAttribute } from "../../shared/findAttribute"
7
+ import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
8
+
9
+ const transform: Transform = attributeTransformFactory({
10
+ condition: hasAttribute("onClick"),
11
+ targetComponent: "ToggleSwitch",
12
+ targetPackage: "@planningcenter/tapestry-react",
13
+ transform: (element, { j, options }) => {
14
+ const attributes = element.openingElement.attributes || []
15
+
16
+ // When both onClick and onChange are present, don't rename (would clobber
17
+ // the existing onChange). Instead, leave onClick in place with a TODO so a
18
+ // developer can resolve the conflict manually.
19
+ if (hasAttribute("onChange")(element)) {
20
+ const onClickAttr = findAttribute(attributes, "onClick")
21
+ if (onClickAttr) {
22
+ addCommentToAttribute({
23
+ attribute: onClickAttr,
24
+ commentKind: "todo",
25
+ j,
26
+ text: "ToggleSwitch uses onChange, not onClick. Both are present — remove onClick and verify the onChange handler is correct (event: ChangeEvent<HTMLInputElement>, read new state via e.target.checked)",
27
+ })
28
+ return true
29
+ }
30
+ return false
31
+ }
32
+
33
+ // Rename onClick → onChange. transformAttributeName also adds the CHANGED
34
+ // comment when options.verbose is enabled.
35
+ const renamed = transformAttributeName("onClick", "onChange", {
36
+ element,
37
+ j,
38
+ options,
39
+ })
40
+
41
+ if (renamed) {
42
+ // If the handler references an argument, its event type has changed from
43
+ // MouseEvent (button) to ChangeEvent<HTMLInputElement> (checkbox). Flag
44
+ // it so the developer can verify the handler logic.
45
+ const onChangeAttr = findAttribute(attributes, "onChange")
46
+ if (onChangeAttr) {
47
+ const value = onChangeAttr.value
48
+ if (value?.type === "JSXExpressionContainer") {
49
+ const expr = value.expression
50
+ if (
51
+ (expr.type === "ArrowFunctionExpression" ||
52
+ expr.type === "FunctionExpression") &&
53
+ expr.params.length > 0
54
+ ) {
55
+ addCommentToAttribute({
56
+ attribute: onChangeAttr,
57
+ commentKind: "todo",
58
+ j,
59
+ text: "renamed from onClick: the event type has changed from MouseEvent to ChangeEvent<HTMLInputElement> — verify the handler uses e.target.checked if needed",
60
+ })
61
+ }
62
+ }
63
+ }
64
+ return true
65
+ }
66
+
67
+ return false
68
+ },
69
+ })
70
+
71
+ export default transform