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

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 (52) hide show
  1. package/package.json +3 -3
  2. package/src/components/button/transforms/convertStyleProps.test.ts +97 -0
  3. package/src/components/button/transforms/removeTypeButton.test.ts +0 -1
  4. package/src/components/checkbox/transforms/moveCheckboxImport.test.ts +3 -0
  5. package/src/components/input/index.ts +66 -0
  6. package/src/components/input/transformableInput.ts +8 -0
  7. package/src/components/input/transforms/auditSpreadProps.test.ts +192 -0
  8. package/src/components/input/transforms/auditSpreadProps.ts +26 -0
  9. package/src/components/input/transforms/autoWidthTransform.test.ts +172 -0
  10. package/src/components/input/transforms/autoWidthTransform.ts +41 -0
  11. package/src/components/input/transforms/convertStyleProps.test.ts +128 -0
  12. package/src/components/input/transforms/convertStyleProps.ts +12 -0
  13. package/src/components/input/transforms/highlightOnInteractionToSelectTextOnFocus.test.ts +186 -0
  14. package/src/components/input/transforms/highlightOnInteractionToSelectTextOnFocus.ts +27 -0
  15. package/src/components/input/transforms/inputLabelToLabelProp.test.ts +319 -0
  16. package/src/components/input/transforms/inputLabelToLabelProp.ts +203 -0
  17. package/src/components/input/transforms/mergeFieldIntoInput.test.ts +391 -0
  18. package/src/components/input/transforms/mergeFieldIntoInput.ts +213 -0
  19. package/src/components/input/transforms/mergeInputLabel.test.ts +458 -0
  20. package/src/components/input/transforms/mergeInputLabel.ts +204 -0
  21. package/src/components/input/transforms/moveInputImport.test.ts +166 -0
  22. package/src/components/input/transforms/moveInputImport.ts +14 -0
  23. package/src/components/input/transforms/numberFieldAddTypeNumber.test.ts +92 -0
  24. package/src/components/input/transforms/numberFieldAddTypeNumber.ts +14 -0
  25. package/src/components/input/transforms/numberFieldRenameToInput.test.ts +126 -0
  26. package/src/components/input/transforms/numberFieldRenameToInput.ts +9 -0
  27. package/src/components/input/transforms/removeAsInput.test.ts +139 -0
  28. package/src/components/input/transforms/removeAsInput.ts +20 -0
  29. package/src/components/input/transforms/removeDuplicateKeys.test.ts +302 -0
  30. package/src/components/input/transforms/removeDuplicateKeys.ts +10 -0
  31. package/src/components/input/transforms/removeInputBox.test.ts +352 -0
  32. package/src/components/input/transforms/removeInputBox.ts +109 -0
  33. package/src/components/input/transforms/removeRedundantAriaLabel.test.ts +128 -0
  34. package/src/components/input/transforms/removeRedundantAriaLabel.ts +21 -0
  35. package/src/components/input/transforms/removeTypeText.test.ts +160 -0
  36. package/src/components/input/transforms/removeTypeText.ts +18 -0
  37. package/src/components/input/transforms/sizeMapping.test.ts +198 -0
  38. package/src/components/input/transforms/sizeMapping.ts +17 -0
  39. package/src/components/input/transforms/skipRenderSideProps.test.ts +236 -0
  40. package/src/components/input/transforms/skipRenderSideProps.ts +27 -0
  41. package/src/components/input/transforms/stateToInvalid.test.ts +208 -0
  42. package/src/components/input/transforms/stateToInvalid.ts +59 -0
  43. package/src/components/input/transforms/stateToInvalidTernary.test.ts +159 -0
  44. package/src/components/input/transforms/stateToInvalidTernary.ts +13 -0
  45. package/src/components/input/transforms/unsupportedProps.test.ts +566 -0
  46. package/src/components/input/transforms/unsupportedProps.ts +84 -0
  47. package/src/components/link/transforms/reviewStyles.test.ts +0 -1
  48. package/src/components/shared/helpers/unsupportedPropsHelpers.ts +52 -0
  49. package/src/components/shared/transformFactories/helpers/manageImports.ts +14 -12
  50. package/src/components/shared/transformFactories/sizeMappingFactory.ts +9 -2
  51. package/src/components/shared/transformFactories/stylePropTransformFactory.ts +54 -16
  52. package/src/components/shared/transformFactories/ternaryConditionalToPropFactory.ts +65 -0
@@ -0,0 +1,203 @@
1
+ import { JSXElement, JSXText, Transform } from "jscodeshift"
2
+
3
+ type JSXChild = NonNullable<JSXElement["children"]>[number]
4
+
5
+ import { addAttribute } from "../../shared/actions/addAttribute"
6
+ import { addComment } from "../../shared/actions/addComment"
7
+ import { getAttribute } from "../../shared/actions/getAttribute"
8
+ import { extractTextContent } from "../../shared/helpers/childrenToLabelHelpers"
9
+ import { getImportName } from "../../shared/transformFactories/helpers/manageImports"
10
+
11
+ const SCOPE = "inputLabelToLabelProp"
12
+
13
+ function isInputLabelElement(node: JSXChild, localName: string): boolean {
14
+ return (
15
+ node.type === "JSXElement" &&
16
+ node.openingElement.name.type === "JSXMemberExpression" &&
17
+ node.openingElement.name.object.type === "JSXIdentifier" &&
18
+ node.openingElement.name.object.name === localName &&
19
+ node.openingElement.name.property.name === "InputLabel"
20
+ )
21
+ }
22
+
23
+ function isInputElement(node: JSXChild, localName: string): boolean {
24
+ return (
25
+ node.type === "JSXElement" &&
26
+ node.openingElement.name.type === "JSXIdentifier" &&
27
+ node.openingElement.name.name === localName
28
+ )
29
+ }
30
+
31
+ function isHorizontalAxis(stackViewElement: JSXElement): boolean {
32
+ const axisAttr = getAttribute({ element: stackViewElement, name: "axis" })
33
+ if (!axisAttr) return false
34
+ const value = axisAttr.value
35
+ if (!value) return false
36
+ if (value.type === "StringLiteral" && value.value === "horizontal")
37
+ return true
38
+ if (
39
+ value.type === "JSXExpressionContainer" &&
40
+ value.expression.type === "StringLiteral" &&
41
+ value.expression.value === "horizontal"
42
+ )
43
+ return true
44
+ return false
45
+ }
46
+
47
+ function hasRenderSideProp(inputElement: JSXElement): boolean {
48
+ const attrs = inputElement.openingElement.attributes || []
49
+ return attrs.some(
50
+ (attr) =>
51
+ attr.type === "JSXAttribute" &&
52
+ attr.name.type === "JSXIdentifier" &&
53
+ (attr.name.name === "renderLeft" || attr.name.name === "renderRight")
54
+ )
55
+ }
56
+
57
+ const transform: Transform = (fileInfo, api) => {
58
+ const j = api.jscodeshift
59
+ const source = j(fileInfo.source)
60
+
61
+ const inputLocalName = getImportName(
62
+ "Input",
63
+ "@planningcenter/tapestry-react",
64
+ {
65
+ j,
66
+ source,
67
+ }
68
+ )
69
+ if (!inputLocalName) return null
70
+
71
+ const stackViewLocalName = getImportName(
72
+ "StackView",
73
+ "@planningcenter/tapestry-react",
74
+ {
75
+ j,
76
+ source,
77
+ }
78
+ )
79
+ if (!stackViewLocalName) return null
80
+
81
+ let hasChanges = false
82
+
83
+ source.find(j.JSXElement).forEach((path) => {
84
+ const el = path.value
85
+ const opening = el.openingElement
86
+
87
+ if (opening.name.type !== "JSXIdentifier") return
88
+ if (opening.name.name !== stackViewLocalName) return
89
+
90
+ if (isHorizontalAxis(el)) return
91
+
92
+ const children = el.children || []
93
+ let i = 0
94
+
95
+ while (i < children.length) {
96
+ const child = children[i]
97
+
98
+ // Skip whitespace JSXText nodes
99
+ if (child.type === "JSXText" && child.value.trim() === "") {
100
+ i++
101
+ continue
102
+ }
103
+
104
+ // Find next non-whitespace sibling
105
+ let nextIdx = i + 1
106
+ while (
107
+ nextIdx < children.length &&
108
+ children[nextIdx].type === "JSXText" &&
109
+ (children[nextIdx] as JSXText).value.trim() === ""
110
+ ) {
111
+ nextIdx++
112
+ }
113
+
114
+ if (nextIdx >= children.length) {
115
+ i++
116
+ continue
117
+ }
118
+
119
+ const nextChild = children[nextIdx]
120
+
121
+ let inputLabelEl: JSXElement | null = null
122
+ let inputEl: JSXElement | null = null
123
+ let labelFirst = false
124
+
125
+ if (
126
+ isInputLabelElement(child, inputLocalName) &&
127
+ isInputElement(nextChild, inputLocalName)
128
+ ) {
129
+ inputLabelEl = child as JSXElement
130
+ inputEl = nextChild as JSXElement
131
+ labelFirst = true
132
+ } else if (
133
+ isInputElement(child, inputLocalName) &&
134
+ isInputLabelElement(nextChild, inputLocalName)
135
+ ) {
136
+ inputEl = child as JSXElement
137
+ inputLabelEl = nextChild as JSXElement
138
+ labelFirst = false
139
+ }
140
+
141
+ if (!inputLabelEl || !inputEl) {
142
+ i++
143
+ continue
144
+ }
145
+
146
+ // Skip if Input has renderLeft or renderRight
147
+ if (hasRenderSideProp(inputEl)) {
148
+ i++
149
+ continue
150
+ }
151
+
152
+ const { isSimpleText, textContent } = extractTextContent(
153
+ inputLabelEl.children || []
154
+ )
155
+
156
+ if (!isSimpleText) {
157
+ addComment({
158
+ element: inputEl,
159
+ j,
160
+ scope: SCOPE,
161
+ source,
162
+ text: "InputLabel children are complex and cannot be auto-converted to a label prop. Please migrate manually.",
163
+ })
164
+ hasChanges = true
165
+ i = nextIdx + 1
166
+ continue
167
+ }
168
+
169
+ const existingLabel = getAttribute({ element: inputEl, name: "label" })
170
+ if (existingLabel) {
171
+ addComment({
172
+ element: inputEl,
173
+ j,
174
+ scope: SCOPE,
175
+ source,
176
+ text: `Input already has a label prop. The InputLabel had text: "${textContent}". Please review manually.`,
177
+ })
178
+ hasChanges = true
179
+ i = nextIdx + 1
180
+ continue
181
+ }
182
+
183
+ addAttribute({ element: inputEl, j, name: "label", value: textContent })
184
+
185
+ if (labelFirst) {
186
+ // InputLabel is at i, Input is at nextIdx — remove InputLabel + whitespace between
187
+ children.splice(i, nextIdx - i)
188
+ // Input is now at i; advance past it
189
+ } else {
190
+ // Input is at i, InputLabel is at nextIdx — remove whitespace + InputLabel after Input
191
+ children.splice(i + 1, nextIdx - i)
192
+ // Input is still at i; advance past it
193
+ }
194
+
195
+ hasChanges = true
196
+ i++
197
+ }
198
+ })
199
+
200
+ return hasChanges ? source.toSource() : null
201
+ }
202
+
203
+ export default transform
@@ -0,0 +1,391 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import transform from "./mergeFieldIntoInput"
5
+
6
+ const j = jscodeshift.withParser("tsx")
7
+
8
+ function applyTransform(source: string): string | null {
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("mergeFieldIntoInput transform", () => {
18
+ describe("basic prop merging", () => {
19
+ it("merges label prop from Field into Input", () => {
20
+ const input = `
21
+ import { Field, Input } from "@planningcenter/tapestry-react"
22
+
23
+ function Test() {
24
+ return (
25
+ <Box>
26
+ <Field label="Name"><Input /></Field>
27
+ </Box>
28
+ )
29
+ }
30
+ `.trim()
31
+
32
+ const result = applyTransform(input)
33
+ expect(result).not.toBeNull()
34
+ expect(result).not.toContain("<Field")
35
+ expect(result).toContain('label="Name"')
36
+ expect(result).toContain("<Input")
37
+ })
38
+
39
+ it("renames feedbackText to description on Input", () => {
40
+ const input = `
41
+ import { Field, Input } from "@planningcenter/tapestry-react"
42
+
43
+ function Test() {
44
+ return (
45
+ <Field feedbackText="Required"><Input /></Field>
46
+ )
47
+ }
48
+ `.trim()
49
+
50
+ const result = applyTransform(input)
51
+ expect(result).not.toBeNull()
52
+ expect(result).not.toContain("<Field")
53
+ expect(result).not.toContain("feedbackText")
54
+ expect(result).toContain('description="Required"')
55
+ })
56
+
57
+ it("copies state prop as-is to Input", () => {
58
+ const input = `
59
+ import { Field, Input } from "@planningcenter/tapestry-react"
60
+
61
+ function Test() {
62
+ return (
63
+ <Field state="error"><Input /></Field>
64
+ )
65
+ }
66
+ `.trim()
67
+
68
+ const result = applyTransform(input)
69
+ expect(result).not.toBeNull()
70
+ expect(result).not.toContain("<Field")
71
+ expect(result).toContain('state="error"')
72
+ })
73
+
74
+ it("preserves dynamic label value", () => {
75
+ const input = `
76
+ import { Field, Input } from "@planningcenter/tapestry-react"
77
+
78
+ function Test() {
79
+ return (
80
+ <Field label={labelText}><Input /></Field>
81
+ )
82
+ }
83
+ `.trim()
84
+
85
+ const result = applyTransform(input)
86
+ expect(result).not.toBeNull()
87
+ expect(result).not.toContain("<Field")
88
+ expect(result).toContain("label={labelText}")
89
+ })
90
+ })
91
+
92
+ describe("unsupported props", () => {
93
+ it("adds TODO comment for helpContent and removes Field", () => {
94
+ const input = `
95
+ import { Field, Input } from "@planningcenter/tapestry-react"
96
+
97
+ function Test() {
98
+ return (
99
+ <Field helpContent="Help"><Input /></Field>
100
+ )
101
+ }
102
+ `.trim()
103
+
104
+ const result = applyTransform(input)
105
+ expect(result).not.toBeNull()
106
+ expect(result).not.toContain("<Field")
107
+ expect(result).toContain("TODO: tapestry-migration (mergeFieldIntoInput)")
108
+ expect(result).toContain("helpContent")
109
+ expect(result).toContain("not supported by Input")
110
+ })
111
+
112
+ it("adds a comment per unsupported prop when multiple unsupported props are present", () => {
113
+ const input = `
114
+ import { Field, Input } from "@planningcenter/tapestry-react"
115
+
116
+ function Test() {
117
+ return (
118
+ <Field inline compact spacing={8}><Input /></Field>
119
+ )
120
+ }
121
+ `.trim()
122
+
123
+ const result = applyTransform(input)
124
+ expect(result).not.toBeNull()
125
+ expect(result).not.toContain("<Field")
126
+ expect(result).toContain("inline")
127
+ expect(result).toContain("compact")
128
+ expect(result).toContain("spacing")
129
+ })
130
+ })
131
+
132
+ describe("Field with no props", () => {
133
+ it("unwraps Field with no props cleanly", () => {
134
+ const input = `
135
+ import { Field, Input } from "@planningcenter/tapestry-react"
136
+
137
+ function Test() {
138
+ return (
139
+ <Field><Input /></Field>
140
+ )
141
+ }
142
+ `.trim()
143
+
144
+ const result = applyTransform(input)
145
+ expect(result).not.toBeNull()
146
+ expect(result).not.toContain("<Field")
147
+ expect(result).toContain("<Input")
148
+ expect(result).not.toContain("TODO")
149
+ })
150
+ })
151
+
152
+ describe("conflict detection", () => {
153
+ it("adds warning comment when Input already has label and keeps Input's existing label", () => {
154
+ const input = `
155
+ import { Field, Input } from "@planningcenter/tapestry-react"
156
+
157
+ function Test() {
158
+ return (
159
+ <Field label="Name"><Input label="Other" /></Field>
160
+ )
161
+ }
162
+ `.trim()
163
+
164
+ const result = applyTransform(input)
165
+ expect(result).not.toBeNull()
166
+ expect(result).not.toContain("<Field")
167
+ expect(result).toContain('label="Other"')
168
+ expect(result).toContain("TODO: tapestry-migration (mergeFieldIntoInput)")
169
+ expect(result).toContain("already has label")
170
+ })
171
+
172
+ it("merges label from Field when Input has description but not label", () => {
173
+ const input = `
174
+ import { Field, Input } from "@planningcenter/tapestry-react"
175
+
176
+ function Test() {
177
+ return (
178
+ <Field label="Name"><Input description="x" /></Field>
179
+ )
180
+ }
181
+ `.trim()
182
+
183
+ const result = applyTransform(input)
184
+ expect(result).not.toBeNull()
185
+ expect(result).not.toContain("<Field")
186
+ expect(result).toContain('label="Name"')
187
+ expect(result).toContain('description="x"')
188
+ })
189
+ })
190
+
191
+ describe("multiple children", () => {
192
+ it("adds comment to Input and leaves Field when Field has multiple children", () => {
193
+ const input = `
194
+ import { Field, Input } from "@planningcenter/tapestry-react"
195
+
196
+ function Test() {
197
+ return (
198
+ <Field><Input /><button /></Field>
199
+ )
200
+ }
201
+ `.trim()
202
+
203
+ const result = applyTransform(input)
204
+ expect(result).not.toBeNull()
205
+ expect(result).toContain("<Field")
206
+ expect(result).toContain("TODO: tapestry-migration (mergeFieldIntoInput)")
207
+ expect(result).toContain("multiple children")
208
+ })
209
+ })
210
+
211
+ describe("non-Input child", () => {
212
+ it("returns null when Field contains only a non-Input child", () => {
213
+ const input = `
214
+ import { Field, Input } from "@planningcenter/tapestry-react"
215
+
216
+ function Test() {
217
+ return (
218
+ <Field><textarea /></Field>
219
+ )
220
+ }
221
+ `.trim()
222
+
223
+ const result = applyTransform(input)
224
+ expect(result).toBeNull()
225
+ })
226
+ })
227
+
228
+ describe("import cleanup", () => {
229
+ it("removes Field from import specifiers when all Fields are converted", () => {
230
+ const input = `
231
+ import { Field, Input } from "@planningcenter/tapestry-react"
232
+
233
+ function Test() {
234
+ return (
235
+ <Field label="Name"><Input /></Field>
236
+ )
237
+ }
238
+ `.trim()
239
+
240
+ const result = applyTransform(input)
241
+ expect(result).not.toBeNull()
242
+ expect(result).not.toContain("Field")
243
+ expect(result).toContain("Input")
244
+ })
245
+
246
+ it("removes entire import declaration when Field was the only specifier", () => {
247
+ const input = `
248
+ import { Field } from "@planningcenter/tapestry-react"
249
+ import { Input } from "@planningcenter/tapestry-react"
250
+
251
+ function Test() {
252
+ return (
253
+ <Field><Input /></Field>
254
+ )
255
+ }
256
+ `.trim()
257
+
258
+ const result = applyTransform(input)
259
+ expect(result).not.toBeNull()
260
+ expect(result).not.toContain("{ Field }")
261
+ })
262
+
263
+ it("keeps Field in import when some Fields cannot be converted", () => {
264
+ const input = `
265
+ import { Field, Input } from "@planningcenter/tapestry-react"
266
+
267
+ function Test() {
268
+ return (
269
+ <div>
270
+ <Field label="Name"><Input /></Field>
271
+ <Field><Input /><button /></Field>
272
+ </div>
273
+ )
274
+ }
275
+ `.trim()
276
+
277
+ const result = applyTransform(input)
278
+ expect(result).not.toBeNull()
279
+ expect(result).toContain("Field")
280
+ })
281
+ })
282
+
283
+ describe("full prop set", () => {
284
+ it("merges label, feedbackText, state and flags helpContent as unsupported", () => {
285
+ const input = `
286
+ import { Field, Input } from "@planningcenter/tapestry-react"
287
+
288
+ function Test() {
289
+ return (
290
+ <Field label="Name" feedbackText="Required" state="error" helpContent="Help">
291
+ <Input />
292
+ </Field>
293
+ )
294
+ }
295
+ `.trim()
296
+
297
+ const result = applyTransform(input)
298
+ expect(result).not.toBeNull()
299
+ expect(result).not.toContain("<Field")
300
+ expect(result).toContain('label="Name"')
301
+ expect(result).toContain('description="Required"')
302
+ expect(result).toContain('state="error"')
303
+ expect(result).toContain("helpContent")
304
+ expect(result).toContain("not supported by Input")
305
+ })
306
+ })
307
+
308
+ describe("not from tapestry-react", () => {
309
+ it("returns null when Field is not imported from tapestry-react", () => {
310
+ const input = `
311
+ import { Field } from "some-other-library"
312
+ import { Input } from "@planningcenter/tapestry-react"
313
+
314
+ function Test() {
315
+ return (
316
+ <Field label="Name"><Input /></Field>
317
+ )
318
+ }
319
+ `.trim()
320
+
321
+ const result = applyTransform(input)
322
+ expect(result).toBeNull()
323
+ })
324
+
325
+ it("returns null when Input is not imported from tapestry-react", () => {
326
+ const input = `
327
+ import { Field } from "@planningcenter/tapestry-react"
328
+ import { Input } from "some-other-library"
329
+
330
+ function Test() {
331
+ return (
332
+ <Field label="Name"><Input /></Field>
333
+ )
334
+ }
335
+ `.trim()
336
+
337
+ const result = applyTransform(input)
338
+ expect(result).toBeNull()
339
+ })
340
+
341
+ it("returns null when neither import exists", () => {
342
+ const input = `
343
+ import { Button } from "@planningcenter/tapestry-react"
344
+
345
+ function Test() {
346
+ return <Button label="Click" />
347
+ }
348
+ `.trim()
349
+
350
+ const result = applyTransform(input)
351
+ expect(result).toBeNull()
352
+ })
353
+ })
354
+
355
+ describe("aliased imports", () => {
356
+ it("handles aliased Field import", () => {
357
+ const input = `
358
+ import { Field as TapField, Input } from "@planningcenter/tapestry-react"
359
+
360
+ function Test() {
361
+ return (
362
+ <TapField label="Name"><Input /></TapField>
363
+ )
364
+ }
365
+ `.trim()
366
+
367
+ const result = applyTransform(input)
368
+ expect(result).not.toBeNull()
369
+ expect(result).not.toContain("<TapField")
370
+ expect(result).toContain('label="Name"')
371
+ })
372
+
373
+ it("handles aliased Input import", () => {
374
+ const input = `
375
+ import { Field, Input as TapInput } from "@planningcenter/tapestry-react"
376
+
377
+ function Test() {
378
+ return (
379
+ <Field label="Name"><TapInput /></Field>
380
+ )
381
+ }
382
+ `.trim()
383
+
384
+ const result = applyTransform(input)
385
+ expect(result).not.toBeNull()
386
+ expect(result).not.toContain("<Field")
387
+ expect(result).toContain('label="Name"')
388
+ expect(result).toContain("<TapInput")
389
+ })
390
+ })
391
+ })