@planningcenter/tapestry-migration-cli 3.1.0-rc.7 → 3.1.0-rc.9

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 (29) hide show
  1. package/package.json +3 -3
  2. package/src/components/input/transforms/removeTypeInput.test.ts +212 -0
  3. package/src/components/input/transforms/removeTypeInput.ts +22 -0
  4. package/src/components/shared/helpers/unsupportedPropsHelpers.ts +33 -0
  5. package/src/components/shared/transformFactories/stylePropTransformFactory.ts +2 -1
  6. package/src/components/text-area/index.ts +48 -0
  7. package/src/components/text-area/transforms/auditSpreadProps.test.ts +139 -0
  8. package/src/components/text-area/transforms/auditSpreadProps.ts +10 -0
  9. package/src/components/text-area/transforms/convertStyleProps.test.ts +158 -0
  10. package/src/components/text-area/transforms/convertStyleProps.ts +10 -0
  11. package/src/components/text-area/transforms/innerRefToRef.test.ts +206 -0
  12. package/src/components/text-area/transforms/innerRefToRef.ts +14 -0
  13. package/src/components/text-area/transforms/mergeFieldIntoTextArea.test.ts +477 -0
  14. package/src/components/text-area/transforms/mergeFieldIntoTextArea.ts +227 -0
  15. package/src/components/text-area/transforms/moveTextAreaImport.test.ts +168 -0
  16. package/src/components/text-area/transforms/moveTextAreaImport.ts +13 -0
  17. package/src/components/text-area/transforms/removeDuplicateKeys.test.ts +129 -0
  18. package/src/components/text-area/transforms/removeDuplicateKeys.ts +8 -0
  19. package/src/components/text-area/transforms/removeRedundantAriaLabel.test.ts +183 -0
  20. package/src/components/text-area/transforms/removeRedundantAriaLabel.ts +59 -0
  21. package/src/components/text-area/transforms/sizeMapping.test.ts +199 -0
  22. package/src/components/text-area/transforms/sizeMapping.ts +15 -0
  23. package/src/components/text-area/transforms/stateToInvalid.test.ts +204 -0
  24. package/src/components/text-area/transforms/stateToInvalid.ts +57 -0
  25. package/src/components/text-area/transforms/stateToInvalidTernary.test.ts +133 -0
  26. package/src/components/text-area/transforms/stateToInvalidTernary.ts +11 -0
  27. package/src/components/text-area/transforms/unsupportedProps.test.ts +275 -0
  28. package/src/components/text-area/transforms/unsupportedProps.ts +35 -0
  29. package/src/index.ts +3 -1
@@ -0,0 +1,227 @@
1
+ import { JSXElement, JSXText, Transform } from "jscodeshift"
2
+
3
+ import { addComment } from "../../shared/actions/addComment"
4
+ import {
5
+ getImportName,
6
+ removeImportFromDeclaration,
7
+ } from "../../shared/transformFactories/helpers/manageImports"
8
+
9
+ const SCOPE = "mergeFieldIntoTextArea"
10
+
11
+ const transform: Transform = (fileInfo, api) => {
12
+ const j = api.jscodeshift
13
+ const source = j(fileInfo.source)
14
+
15
+ const fieldLocalName = getImportName(
16
+ "Field",
17
+ "@planningcenter/tapestry-react",
18
+ { j, source }
19
+ )
20
+ if (!fieldLocalName) return null
21
+
22
+ const textAreaLocalName = getImportName(
23
+ "TextArea",
24
+ "@planningcenter/tapestry-react",
25
+ { j, source }
26
+ )
27
+ if (!textAreaLocalName) return null
28
+
29
+ let hasChanges = false
30
+ let anyFieldRemoved = false
31
+
32
+ source.find(j.JSXElement).forEach((path) => {
33
+ const el = path.value
34
+ const opening = el.openingElement
35
+
36
+ if (opening.name.type !== "JSXIdentifier") return
37
+ if (opening.name.name !== fieldLocalName) return
38
+
39
+ const elementChildren = (el.children || []).filter(
40
+ (child) =>
41
+ child.type !== "JSXText" || (child as JSXText).value.trim() !== ""
42
+ )
43
+
44
+ const textAreaChildren = elementChildren.filter((child) => {
45
+ if (child.type !== "JSXElement") return false
46
+ const childOpening = (child as JSXElement).openingElement
47
+ return (
48
+ childOpening.name.type === "JSXIdentifier" &&
49
+ childOpening.name.name === textAreaLocalName
50
+ )
51
+ }) as JSXElement[]
52
+
53
+ // Case: exactly 1 child and it is a TextArea — merge props and unwrap
54
+ if (elementChildren.length === 1 && textAreaChildren.length === 1) {
55
+ const textAreaEl = textAreaChildren[0]
56
+ const fieldAttrs = opening.attributes || []
57
+
58
+ // Bail out if Field has spread props — we can't know what they contain
59
+ const hasFieldSpreads = fieldAttrs.some(
60
+ (attr) => attr.type === "JSXSpreadAttribute"
61
+ )
62
+ if (hasFieldSpreads) {
63
+ addComment({
64
+ element: textAreaEl,
65
+ j,
66
+ scope: SCOPE,
67
+ source,
68
+ text: "Field has spread props that cannot be auto-merged into TextArea. Please migrate manually.",
69
+ })
70
+ hasChanges = true
71
+ return
72
+ }
73
+
74
+ for (const attr of fieldAttrs) {
75
+ if (attr.type !== "JSXAttribute") continue
76
+ if (attr.name.type !== "JSXIdentifier") continue
77
+
78
+ const attrName = attr.name.name
79
+ const textAreaAttrs = textAreaEl.openingElement.attributes || []
80
+
81
+ if (attrName === "label") {
82
+ const hasLabel = textAreaAttrs.some(
83
+ (a) =>
84
+ a.type === "JSXAttribute" &&
85
+ a.name?.type === "JSXIdentifier" &&
86
+ a.name.name === "label"
87
+ )
88
+ if (hasLabel) {
89
+ addComment({
90
+ element: textAreaEl,
91
+ j,
92
+ scope: SCOPE,
93
+ source,
94
+ text: "Field had label prop but TextArea already has label. Please migrate manually.",
95
+ })
96
+ } else {
97
+ textAreaEl.openingElement.attributes.push(attr)
98
+ }
99
+ } else if (attrName === "feedbackText") {
100
+ const hasDescription = textAreaAttrs.some(
101
+ (a) =>
102
+ a.type === "JSXAttribute" &&
103
+ a.name?.type === "JSXIdentifier" &&
104
+ a.name.name === "description"
105
+ )
106
+ if (hasDescription) {
107
+ addComment({
108
+ element: textAreaEl,
109
+ j,
110
+ scope: SCOPE,
111
+ source,
112
+ text: "Field had feedbackText prop but TextArea already has description. Please migrate manually.",
113
+ })
114
+ } else {
115
+ const newAttr = j.jsxAttribute(
116
+ j.jsxIdentifier("description"),
117
+ attr.value
118
+ )
119
+ textAreaEl.openingElement.attributes.push(newAttr)
120
+ }
121
+ } else if (attrName === "state") {
122
+ const hasState = textAreaAttrs.some(
123
+ (a) =>
124
+ a.type === "JSXAttribute" &&
125
+ a.name?.type === "JSXIdentifier" &&
126
+ a.name.name === "state"
127
+ )
128
+ if (hasState) {
129
+ addComment({
130
+ element: textAreaEl,
131
+ j,
132
+ scope: SCOPE,
133
+ source,
134
+ text: "Field had state prop but TextArea already has state. Please migrate manually.",
135
+ })
136
+ } else {
137
+ textAreaEl.openingElement.attributes.push(attr)
138
+ }
139
+ } else if (attrName === "key") {
140
+ const hasKey = textAreaAttrs.some(
141
+ (a) =>
142
+ a.type === "JSXAttribute" &&
143
+ a.name?.type === "JSXIdentifier" &&
144
+ a.name.name === "key"
145
+ )
146
+ if (!hasKey) {
147
+ textAreaEl.openingElement.attributes.push(attr)
148
+ }
149
+ } else {
150
+ // Unsupported prop — add comment to TextArea
151
+ addComment({
152
+ element: textAreaEl,
153
+ j,
154
+ scope: SCOPE,
155
+ source,
156
+ text: `Field prop '${attrName}' is not supported by TextArea. Please migrate manually.`,
157
+ })
158
+ }
159
+ }
160
+
161
+ const parent = path.parent?.value
162
+ if (parent?.children) {
163
+ const idx = parent.children.indexOf(el)
164
+ if (idx === -1) return
165
+ parent.children.splice(idx, 1, ...(el.children || []))
166
+ } else {
167
+ // Root JSX (e.g. directly inside return parens) — use path.replace
168
+ const nonWsChildren = (el.children || []).filter(
169
+ (child) =>
170
+ child.type !== "JSXText" || (child as JSXText).value.trim() !== ""
171
+ )
172
+ if (nonWsChildren.length === 1) {
173
+ path.replace(nonWsChildren[0])
174
+ } else {
175
+ path.replace(
176
+ j.jsxFragment(
177
+ j.jsxOpeningFragment(),
178
+ j.jsxClosingFragment(),
179
+ el.children || []
180
+ )
181
+ )
182
+ }
183
+ }
184
+ hasChanges = true
185
+ anyFieldRemoved = true
186
+ return
187
+ }
188
+
189
+ // Case: more than 1 non-whitespace child — comment each TextArea child, leave Field
190
+ if (elementChildren.length > 1) {
191
+ for (const child of textAreaChildren) {
192
+ addComment({
193
+ element: child,
194
+ j,
195
+ scope: SCOPE,
196
+ source,
197
+ text: "Field has multiple children and cannot be auto-merged into TextArea. Please migrate manually.",
198
+ })
199
+ hasChanges = true
200
+ }
201
+ return
202
+ }
203
+
204
+ // Case: exactly 1 child but not a TextArea — skip without comment
205
+ })
206
+
207
+ // Remove Field from imports only if all Field usages were converted
208
+ if (anyFieldRemoved) {
209
+ const stillUsesField =
210
+ source.find(j.JSXOpeningElement, {
211
+ name: { name: fieldLocalName },
212
+ }).length > 0
213
+
214
+ if (!stillUsesField) {
215
+ const fieldImports = source.find(j.ImportDeclaration, {
216
+ source: { value: "@planningcenter/tapestry-react" },
217
+ })
218
+ for (let i = 0; i < fieldImports.length; i++) {
219
+ removeImportFromDeclaration(fieldImports.at(i), "Field")
220
+ }
221
+ }
222
+ }
223
+
224
+ return hasChanges ? source.toSource() : null
225
+ }
226
+
227
+ export default transform
@@ -0,0 +1,168 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import transform from "./moveTextAreaImport"
5
+
6
+ const j = jscodeshift.withParser("tsx")
7
+
8
+ function applyTransform(source: string): string {
9
+ const fileInfo = { path: "test.tsx", source }
10
+ const result = transform(
11
+ fileInfo,
12
+ { j, jscodeshift: j, report: () => {}, stats: () => {} },
13
+ {}
14
+ ) as string | null
15
+ return result || source
16
+ }
17
+
18
+ describe("moveTextAreaImport transform", () => {
19
+ describe("import migration", () => {
20
+ it("should change import from tapestry-react to tapestry", () => {
21
+ const input = `
22
+ import { TextArea } from "@planningcenter/tapestry-react"
23
+
24
+ function Test() {
25
+ return <TextArea label="Notes" />
26
+ }
27
+ `.trim()
28
+
29
+ const result = applyTransform(input)
30
+ expect(result).toContain(
31
+ 'import { TextArea } from "@planningcenter/tapestry"'
32
+ )
33
+ expect(result).not.toContain("@planningcenter/tapestry-react")
34
+ })
35
+
36
+ it("should only move TextArea, leaving other imports in place", () => {
37
+ const input = `
38
+ import { Button, TextArea } from "@planningcenter/tapestry-react"
39
+
40
+ function Test() {
41
+ return (
42
+ <div>
43
+ <Button>Click</Button>
44
+ <TextArea label="Notes" />
45
+ </div>
46
+ )
47
+ }
48
+ `.trim()
49
+
50
+ const result = applyTransform(input)
51
+ expect(result).toContain(
52
+ 'import { Button } from "@planningcenter/tapestry-react"'
53
+ )
54
+ expect(result).toContain(
55
+ 'import { TextArea } from "@planningcenter/tapestry"'
56
+ )
57
+ })
58
+
59
+ it("should handle TextArea as sole import in declaration", () => {
60
+ const input = `
61
+ import { Button } from "@planningcenter/tapestry-react"
62
+ import { TextArea } from "@planningcenter/tapestry-react"
63
+
64
+ function Test() {
65
+ return (
66
+ <div>
67
+ <Button>Click</Button>
68
+ <TextArea label="Notes" />
69
+ </div>
70
+ )
71
+ }
72
+ `.trim()
73
+
74
+ const result = applyTransform(input)
75
+ expect(result).toContain(
76
+ 'import { Button } from "@planningcenter/tapestry-react"'
77
+ )
78
+ expect(result).toContain(
79
+ 'import { TextArea } from "@planningcenter/tapestry"'
80
+ )
81
+ })
82
+
83
+ it("should not affect other components", () => {
84
+ const input = `
85
+ import { Button } from "@planningcenter/tapestry-react"
86
+
87
+ function Test() {
88
+ return <Button>Click me</Button>
89
+ }
90
+ `.trim()
91
+
92
+ const result = applyTransform(input)
93
+ expect(result).toContain(
94
+ 'import { Button } from "@planningcenter/tapestry-react"'
95
+ )
96
+ })
97
+ })
98
+
99
+ describe("edge cases", () => {
100
+ it("should handle already migrated imports", () => {
101
+ const input = `
102
+ import { TextArea } from "@planningcenter/tapestry"
103
+
104
+ function Test() {
105
+ return <TextArea label="Notes" />
106
+ }
107
+ `.trim()
108
+
109
+ const result = applyTransform(input)
110
+ expect(result).toContain(
111
+ 'import { TextArea } from "@planningcenter/tapestry"'
112
+ )
113
+ })
114
+
115
+ it("should handle no imports", () => {
116
+ const input = `
117
+ function Test() {
118
+ return <div>No imports</div>
119
+ }
120
+ `.trim()
121
+
122
+ const result = applyTransform(input)
123
+ expect(result).toBe(input)
124
+ })
125
+
126
+ it("should preserve all attributes", () => {
127
+ const input = `
128
+ import { TextArea } from "@planningcenter/tapestry-react"
129
+
130
+ function Test() {
131
+ return (
132
+ <TextArea
133
+ label="Notes"
134
+ placeholder="Enter notes"
135
+ rows={5}
136
+ disabled
137
+ onChange={() => {}}
138
+ />
139
+ )
140
+ }
141
+ `.trim()
142
+
143
+ const result = applyTransform(input)
144
+ expect(result).toContain(
145
+ 'import { TextArea } from "@planningcenter/tapestry"'
146
+ )
147
+ expect(result).toContain('label="Notes"')
148
+ expect(result).toContain('placeholder="Enter notes"')
149
+ expect(result).toContain("rows={5}")
150
+ expect(result).toContain("disabled")
151
+ expect(result).toContain("onChange={() => {}}")
152
+ })
153
+
154
+ it("should handle alias import", () => {
155
+ const input = `
156
+ import { TextArea as MyTextArea } from "@planningcenter/tapestry-react"
157
+
158
+ function Test() {
159
+ return <MyTextArea label="Notes" />
160
+ }
161
+ `.trim()
162
+
163
+ const result = applyTransform(input)
164
+ expect(result).toContain("@planningcenter/tapestry")
165
+ expect(result).not.toContain("@planningcenter/tapestry-react")
166
+ })
167
+ })
168
+ })
@@ -0,0 +1,13 @@
1
+ import { Transform } from "jscodeshift"
2
+
3
+ import { componentTransformFactory } from "../../shared/transformFactories/componentTransformFactory"
4
+
5
+ const transform: Transform = componentTransformFactory({
6
+ condition: () => true,
7
+ fromComponent: "TextArea",
8
+ fromPackage: "@planningcenter/tapestry-react",
9
+ toComponent: "TextArea",
10
+ toPackage: "@planningcenter/tapestry",
11
+ })
12
+
13
+ export default transform
@@ -0,0 +1,129 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import transform from "./removeDuplicateKeys"
5
+
6
+ const j = jscodeshift.withParser("tsx")
7
+
8
+ function applyTransform(source: string) {
9
+ const fileInfo = { path: "test.tsx", source }
10
+ return transform(
11
+ fileInfo,
12
+ { j, jscodeshift: j, report: () => {}, stats: () => {} },
13
+ {}
14
+ ) as string | null
15
+ }
16
+
17
+ describe("removeDuplicateKeys transform", () => {
18
+ describe("TextArea elements with duplicate attributes", () => {
19
+ it("should remove duplicate attributes from TextArea elements", () => {
20
+ const source = `
21
+ import { TextArea } from "@planningcenter/tapestry-react"
22
+
23
+ export function TestComponent() {
24
+ return <TextArea label="First" disabled label="Second">Save</TextArea>
25
+ }
26
+ `
27
+
28
+ const result = applyTransform(source)
29
+
30
+ expect(result).not.toBeNull()
31
+ expect(result).toContain('label="First"')
32
+ expect(result).not.toContain('label="Second"')
33
+ expect(result).toContain("disabled")
34
+ })
35
+
36
+ it("should preserve first occurrence of duplicate attributes", () => {
37
+ const source = `
38
+ import { TextArea } from "@planningcenter/tapestry-react"
39
+
40
+ export function TestComponent() {
41
+ return <TextArea onClick={handleFirst} aria-label="First" onClick={handleSecond} aria-label="Second" />
42
+ }
43
+ `
44
+
45
+ const result = applyTransform(source)
46
+
47
+ expect(result).not.toBeNull()
48
+ expect(result).toContain("onClick={handleFirst}")
49
+ expect(result).not.toContain("onClick={handleSecond}")
50
+ expect(result).toContain('aria-label="First"')
51
+ expect(result).not.toContain('aria-label="Second"')
52
+ })
53
+ })
54
+
55
+ describe("TextArea elements without duplicate attributes", () => {
56
+ it("should return null when no duplicates exist", () => {
57
+ const source = `
58
+ import { TextArea } from "@planningcenter/tapestry-react"
59
+
60
+ export function TestComponent() {
61
+ return <TextArea label="Name" disabled />
62
+ }
63
+ `
64
+
65
+ const result = applyTransform(source)
66
+
67
+ expect(result).toBeNull()
68
+ })
69
+
70
+ it("should return null for TextArea with no attributes", () => {
71
+ const source = `
72
+ import { TextArea } from "@planningcenter/tapestry-react"
73
+
74
+ export function TestComponent() {
75
+ return <TextArea />
76
+ }
77
+ `
78
+
79
+ const result = applyTransform(source)
80
+
81
+ expect(result).toBeNull()
82
+ })
83
+ })
84
+
85
+ describe("import handling", () => {
86
+ it("should only process files that import TextArea from tapestry-react", () => {
87
+ const source = `
88
+ import { TextArea } from "some-other-library"
89
+
90
+ export function TestComponent() {
91
+ return <TextArea label="First" label="Second" />
92
+ }
93
+ `
94
+
95
+ const result = applyTransform(source)
96
+
97
+ expect(result).toBeNull()
98
+ })
99
+ })
100
+
101
+ describe("mixed elements", () => {
102
+ it("should only process TextArea elements, not other elements", () => {
103
+ const source = `
104
+ import { TextArea } from "@planningcenter/tapestry-react"
105
+
106
+ export function TestComponent() {
107
+ return (
108
+ <div>
109
+ <TextArea label="First" label="Second" />
110
+ <div className="container" className="wrapper">Content</div>
111
+ </div>
112
+ )
113
+ }
114
+ `
115
+
116
+ const result = applyTransform(source)
117
+
118
+ expect(result).not.toBeNull()
119
+
120
+ // TextArea duplicates should be removed
121
+ expect(result).toContain('label="First"')
122
+ expect(result).not.toContain('label="Second"')
123
+
124
+ // Other elements should remain unchanged
125
+ expect(result).toContain('className="container"')
126
+ expect(result).toContain('className="wrapper"')
127
+ })
128
+ })
129
+ })
@@ -0,0 +1,8 @@
1
+ import { removeDuplicateKeys } from "../../shared/actions/removeDuplicateKeys"
2
+ import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
3
+
4
+ export default attributeTransformFactory({
5
+ targetComponent: "TextArea",
6
+ targetPackage: "@planningcenter/tapestry-react",
7
+ transform: removeDuplicateKeys,
8
+ })