@planningcenter/tapestry-migration-cli 2.1.1-rc.2 → 2.1.1-rc.3

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 +2 -2
  2. package/src/components/button/index.ts +2 -1
  3. package/src/components/button/transforms/linkToButton.ts +4 -9
  4. package/src/components/button/transforms/titleToLabel.test.ts +418 -0
  5. package/src/components/button/transforms/titleToLabel.ts +19 -0
  6. package/src/components/shared/actions/transformAttributeName.ts +20 -0
  7. package/src/components/shared/actions/transformElementName.test.ts +59 -0
  8. package/src/components/shared/actions/transformElementName.ts +27 -0
  9. package/src/components/shared/conditions/andConditions.test.ts +65 -0
  10. package/src/components/shared/conditions/andConditions.ts +13 -0
  11. package/src/components/shared/conditions/hasAttribute.test.ts +43 -0
  12. package/src/components/shared/conditions/hasAttribute.ts +18 -0
  13. package/src/components/shared/conditions/hasAttributeValue.test.ts +48 -0
  14. package/src/components/shared/conditions/hasAttributeValue.ts +23 -0
  15. package/src/components/shared/conditions/helpers/createJSXElement.ts +9 -0
  16. package/src/components/shared/conditions/index.test.ts +63 -0
  17. package/src/components/shared/conditions/orConditions.test.ts +76 -0
  18. package/src/components/shared/conditions/orConditions.ts +13 -0
  19. package/src/components/shared/findAttribute.ts +15 -0
  20. package/src/components/shared/transformFactories/attributeTransformFactory.test.ts +88 -0
  21. package/src/components/shared/transformFactories/attributeTransformFactory.ts +51 -0
  22. package/src/components/shared/transformFactories/componentTransformFactory.test.ts +7 -0
  23. package/src/components/shared/transformFactories/componentTransformFactory.ts +77 -0
  24. package/src/components/shared/{componentTransformUtilities.test.ts → transformFactories/helpers/manageImports.test.ts} +1 -55
  25. package/src/components/shared/{componentTransformUtilities.ts → transformFactories/helpers/manageImports.ts} +17 -93
  26. package/src/components/shared/types.ts +6 -0
  27. package/src/components/shared/getTapestryReactImportName.ts +0 -35
  28. package/src/components/shared/transformConfig.test.ts +0 -288
  29. package/src/components/shared/transformConfig.ts +0 -79
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planningcenter/tapestry-migration-cli",
3
- "version": "2.1.1-rc.2",
3
+ "version": "2.1.1-rc.3",
4
4
  "description": "CLI tool for Tapestry migrations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -47,5 +47,5 @@
47
47
  "publishConfig": {
48
48
  "access": "public"
49
49
  },
50
- "gitHead": "5b408e235d4603be13e17be3117fb2ca454078f7"
50
+ "gitHead": "3efd313bf986f8a7d54b6abc633bbc8f91f4fe11"
51
51
  }
@@ -1,12 +1,13 @@
1
1
  import { Transform } from "jscodeshift"
2
2
 
3
3
  import linkToButton from "./transforms/linkToButton"
4
+ import titleToLabel from "./transforms/titleToLabel"
4
5
 
5
6
  const transform: Transform = (fileInfo, api, options) => {
6
7
  let currentSource = fileInfo.source
7
8
  let hasAnyChanges = false
8
9
 
9
- const transforms: Transform[] = [linkToButton]
10
+ const transforms: Transform[] = [linkToButton, titleToLabel]
10
11
 
11
12
  for (const individualTransform of transforms) {
12
13
  const result = individualTransform(
@@ -1,20 +1,15 @@
1
1
  import { Transform } from "jscodeshift"
2
2
 
3
- import { createComponentTransform } from "../../shared/componentTransformUtilities"
4
- import {
5
- ComponentTransformConfig,
6
- hasAttribute,
7
- } from "../../shared/transformConfig"
3
+ import { hasAttribute } from "../../shared/conditions/hasAttribute"
4
+ import { componentTransformFactory } from "../../shared/transformFactories/componentTransformFactory"
8
5
 
9
- const config: ComponentTransformConfig = {
6
+ const transform: Transform = componentTransformFactory({
10
7
  condition: hasAttribute("onClick"),
11
8
  conflictAlias: "TRButton",
12
9
  fromComponent: "Link",
13
10
  fromPackage: "@planningcenter/tapestry-react",
14
11
  toComponent: "Button",
15
12
  toPackage: "@planningcenter/tapestry-react",
16
- }
17
-
18
- const transform: Transform = createComponentTransform(config)
13
+ })
19
14
 
20
15
  export default transform
@@ -0,0 +1,418 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import transform from "./titleToLabel"
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("titleToLabel transform", () => {
18
+ describe("basic transformations", () => {
19
+ it("should transform Button title to label when no icon prop", () => {
20
+ const input = `
21
+ import { Button } from "@planningcenter/tapestry-react"
22
+
23
+ export default function Test() {
24
+ return <Button title="Save"></Button>
25
+ }
26
+ `.trim()
27
+
28
+ const expected = `
29
+ import { Button } from "@planningcenter/tapestry-react"
30
+
31
+ export default function Test() {
32
+ return <Button label="Save"></Button>;
33
+ }
34
+ `.trim()
35
+
36
+ const result = applyTransform(input)
37
+ expect(result?.trim()).toBe(expected)
38
+ })
39
+
40
+ it("should transform Button title to aria-label when icon prop is present", () => {
41
+ const input = `
42
+ import { Button } from "@planningcenter/tapestry-react"
43
+
44
+ export default function Test() {
45
+ return <Button title="Save" icon="save"></Button>
46
+ }
47
+ `.trim()
48
+
49
+ const expected = `
50
+ import { Button } from "@planningcenter/tapestry-react"
51
+
52
+ export default function Test() {
53
+ return <Button aria-label="Save" icon="save"></Button>;
54
+ }
55
+ `.trim()
56
+
57
+ const result = applyTransform(input)
58
+ expect(result?.trim()).toBe(expected)
59
+ })
60
+
61
+ it("should handle multiple Button components", () => {
62
+ const input = `
63
+ import { Button } from "@planningcenter/tapestry-react"
64
+
65
+ export default function Test() {
66
+ return (
67
+ <div>
68
+ <Button title="Save" />
69
+ <Button title="Delete" icon="trash" />
70
+ </div>
71
+ )
72
+ }
73
+ `.trim()
74
+
75
+ const expected = `
76
+ import { Button } from "@planningcenter/tapestry-react"
77
+
78
+ export default function Test() {
79
+ return (
80
+ <div>
81
+ <Button label="Save" />
82
+ <Button aria-label="Delete" icon="trash" />
83
+ </div>
84
+ );
85
+ }
86
+ `.trim()
87
+
88
+ const result = applyTransform(input)
89
+ expect(result?.trim()).toBe(expected)
90
+ })
91
+ })
92
+
93
+ describe("edge cases", () => {
94
+ it("should not transform Button without title attribute", () => {
95
+ const input = `
96
+ import { Button } from "@planningcenter/tapestry-react"
97
+
98
+ export default function Test() {
99
+ return <Button>Save</Button>
100
+ }
101
+ `.trim()
102
+
103
+ const result = applyTransform(input)
104
+ expect(result).toBe(null)
105
+ })
106
+
107
+ it("should not transform if Button is not imported from @planningcenter/tapestry-react", () => {
108
+ const input = `
109
+ import { Button } from "other-library"
110
+
111
+ export default function Test() {
112
+ return <Button title="Save" />
113
+ }
114
+ `.trim()
115
+
116
+ const result = applyTransform(input)
117
+ expect(result).toBe(null)
118
+ })
119
+
120
+ it("should handle Button with alias import", () => {
121
+ const input = `
122
+ import { Button as TapestryButton } from "@planningcenter/tapestry-react"
123
+
124
+ export default function Test() {
125
+ return <TapestryButton title="Save" />;
126
+ }
127
+ `.trim()
128
+
129
+ const expected = `
130
+ import { Button as TapestryButton } from "@planningcenter/tapestry-react"
131
+
132
+ export default function Test() {
133
+ return <TapestryButton label="Save" />;
134
+ }
135
+ `.trim()
136
+
137
+ const result = applyTransform(input)
138
+ expect(result?.trim()).toBe(expected)
139
+ })
140
+
141
+ it("should handle mixed Button components (with and without title)", () => {
142
+ const input = `
143
+ import { Button } from "@planningcenter/tapestry-react"
144
+
145
+ export default function Test() {
146
+ return (
147
+ <div>
148
+ <Button title="Save" />
149
+ <Button>Cancel</Button>
150
+ <Button title="Delete" icon="trash" />
151
+ </div>
152
+ )
153
+ }
154
+ `.trim()
155
+
156
+ const expected = `
157
+ import { Button } from "@planningcenter/tapestry-react"
158
+
159
+ export default function Test() {
160
+ return (
161
+ <div>
162
+ <Button label="Save" />
163
+ <Button>Cancel</Button>
164
+ <Button aria-label="Delete" icon="trash" />
165
+ </div>
166
+ );
167
+ }
168
+ `.trim()
169
+
170
+ const result = applyTransform(input)
171
+ expect(result?.trim()).toBe(expected)
172
+ })
173
+ })
174
+
175
+ describe("complex attribute scenarios", () => {
176
+ it("should handle Button with multiple attributes including icon", () => {
177
+ const input = `
178
+ import { Button } from "@planningcenter/tapestry-react"
179
+
180
+ export default function Test() {
181
+ return (
182
+ <Button
183
+ title="Save Document"
184
+ icon="save"
185
+ className="primary"
186
+ disabled={false}
187
+ onClick={handleSave}
188
+ />
189
+ )
190
+ }
191
+ `.trim()
192
+
193
+ const expected = `
194
+ import { Button } from "@planningcenter/tapestry-react"
195
+
196
+ export default function Test() {
197
+ return (
198
+ <Button
199
+ aria-label="Save Document"
200
+ icon="save"
201
+ className="primary"
202
+ disabled={false}
203
+ onClick={handleSave}
204
+ />
205
+ );
206
+ }
207
+ `.trim()
208
+
209
+ const result = applyTransform(input)
210
+ expect(result?.trim()).toBe(expected)
211
+ })
212
+
213
+ it("should handle Button with multiple attributes without icon", () => {
214
+ const input = `
215
+ import { Button } from "@planningcenter/tapestry-react"
216
+
217
+ export default function Test() {
218
+ return (
219
+ <Button
220
+ title="Save Document"
221
+ className="primary"
222
+ disabled={false}
223
+ onClick={handleSave}
224
+ />
225
+ )
226
+ }
227
+ `.trim()
228
+
229
+ const expected = `
230
+ import { Button } from "@planningcenter/tapestry-react"
231
+
232
+ export default function Test() {
233
+ return (
234
+ <Button
235
+ label="Save Document"
236
+ className="primary"
237
+ disabled={false}
238
+ onClick={handleSave}
239
+ />
240
+ );
241
+ }
242
+ `.trim()
243
+
244
+ const result = applyTransform(input)
245
+ expect(result?.trim()).toBe(expected)
246
+ })
247
+
248
+ it("should handle title with expression value", () => {
249
+ const input = `
250
+ import { Button } from "@planningcenter/tapestry-react"
251
+
252
+ export default function Test() {
253
+ const saveText = "Save Document"
254
+ return <Button title={saveText} />;
255
+ }
256
+ `.trim()
257
+
258
+ const expected = `
259
+ import { Button } from "@planningcenter/tapestry-react"
260
+
261
+ export default function Test() {
262
+ const saveText = "Save Document"
263
+ return <Button label={saveText} />;
264
+ }
265
+ `.trim()
266
+
267
+ const result = applyTransform(input)
268
+ expect(result?.trim()).toBe(expected)
269
+ })
270
+
271
+ it("should handle title with expression and icon", () => {
272
+ const input = `
273
+ import { Button } from "@planningcenter/tapestry-react"
274
+
275
+ export default function Test() {
276
+ const deleteText = "Delete Item"
277
+ return <Button title={deleteText} icon="trash" />
278
+ }
279
+ `.trim()
280
+
281
+ const expected = `
282
+ import { Button } from "@planningcenter/tapestry-react"
283
+
284
+ export default function Test() {
285
+ const deleteText = "Delete Item"
286
+ return <Button aria-label={deleteText} icon="trash" />;
287
+ }
288
+ `.trim()
289
+
290
+ const result = applyTransform(input)
291
+ expect(result?.trim()).toBe(expected)
292
+ })
293
+ })
294
+
295
+ describe("different icon attribute formats", () => {
296
+ it("should detect icon prop with string value", () => {
297
+ const input = `
298
+ import { Button } from "@planningcenter/tapestry-react"
299
+
300
+ export default function Test() {
301
+ return <Button title="Settings" icon="gear" />;
302
+ }
303
+ `.trim()
304
+
305
+ const result = applyTransform(input)
306
+ expect(result).toContain('aria-label="Settings"')
307
+ })
308
+
309
+ it("should detect icon prop with expression value", () => {
310
+ const input = `
311
+ import { Button } from "@planningcenter/tapestry-react"
312
+
313
+ export default function Test() {
314
+ const iconName = "gear"
315
+ return <Button title="Settings" icon={iconName} />
316
+ }
317
+ `.trim()
318
+
319
+ const result = applyTransform(input)
320
+ expect(result).toContain('aria-label="Settings"')
321
+ })
322
+
323
+ it("should detect icon prop without value", () => {
324
+ const input = `
325
+ import { Button } from "@planningcenter/tapestry-react"
326
+
327
+ export default function Test() {
328
+ return <Button title="Settings" icon />;
329
+ }
330
+ `.trim()
331
+
332
+ const result = applyTransform(input)
333
+ expect(result).toContain('aria-label="Settings"')
334
+ })
335
+ })
336
+
337
+ describe("no changes scenarios", () => {
338
+ it("should return null when no Button components have title", () => {
339
+ const input = `
340
+ import { Button } from "@planningcenter/tapestry-react"
341
+
342
+ export default function Test() {
343
+ return (
344
+ <div>
345
+ <Button>Save</Button>
346
+ <Button icon="trash" />
347
+ </div>
348
+ )
349
+ }
350
+ `.trim()
351
+
352
+ const result = applyTransform(input)
353
+ expect(result).toBe(null)
354
+ })
355
+
356
+ it("should return null when no Button imports exist", () => {
357
+ const input = `
358
+ import { Link } from "@planningcenter/tapestry-react"
359
+
360
+ export default function Test() {
361
+ return <Link href="/test">Go</Link>
362
+ }
363
+ `.trim()
364
+
365
+ const result = applyTransform(input)
366
+ expect(result).toBe(null)
367
+ })
368
+
369
+ it("should return null for empty file", () => {
370
+ const result = applyTransform("")
371
+ expect(result).toBe(null)
372
+ })
373
+ })
374
+
375
+ describe("self-closing Button components", () => {
376
+ it("should handle self-closing Button without icon", () => {
377
+ const input = `
378
+ import { Button } from "@planningcenter/tapestry-react"
379
+
380
+ export default function Test() {
381
+ return <Button title="Close" />
382
+ }
383
+ `.trim()
384
+
385
+ const expected = `
386
+ import { Button } from "@planningcenter/tapestry-react"
387
+
388
+ export default function Test() {
389
+ return <Button label="Close" />;
390
+ }
391
+ `.trim()
392
+
393
+ const result = applyTransform(input)
394
+ expect(result?.trim()).toBe(expected)
395
+ })
396
+
397
+ it("should handle self-closing Button with icon", () => {
398
+ const input = `
399
+ import { Button } from "@planningcenter/tapestry-react"
400
+
401
+ export default function Test() {
402
+ return <Button title="Close" icon="x" />
403
+ }
404
+ `.trim()
405
+
406
+ const expected = `
407
+ import { Button } from "@planningcenter/tapestry-react"
408
+
409
+ export default function Test() {
410
+ return <Button aria-label="Close" icon="x" />;
411
+ }
412
+ `.trim()
413
+
414
+ const result = applyTransform(input)
415
+ expect(result?.trim()).toBe(expected)
416
+ })
417
+ })
418
+ })
@@ -0,0 +1,19 @@
1
+ import { JSXElement, Transform } from "jscodeshift"
2
+
3
+ import { transformAttributeName } from "../../shared/actions/transformAttributeName"
4
+ import { hasAttribute } from "../../shared/conditions/hasAttribute"
5
+ import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
6
+
7
+ const transform: Transform = attributeTransformFactory({
8
+ condition: hasAttribute("title"),
9
+ targetComponent: "Button",
10
+ targetPackage: "@planningcenter/tapestry-react",
11
+ transform: (element: JSXElement) =>
12
+ transformAttributeName(
13
+ "title",
14
+ () => (hasAttribute("icon")(element) ? "aria-label" : "label"),
15
+ { element }
16
+ ),
17
+ })
18
+
19
+ export default transform
@@ -0,0 +1,20 @@
1
+ import { JSXElement } from "jscodeshift"
2
+
3
+ import { findAttribute } from "../findAttribute"
4
+
5
+ export function transformAttributeName(
6
+ name: string,
7
+ nameTransform: string | ((element: JSXElement) => string),
8
+ { element }: { element: JSXElement }
9
+ ): boolean {
10
+ if (!nameTransform) return false
11
+ const attributes = element.openingElement.attributes || []
12
+ const attribute = findAttribute(attributes, name)
13
+ if (!attribute) return false
14
+
15
+ const resolvedName =
16
+ typeof nameTransform === "string" ? nameTransform : nameTransform(element)
17
+ attribute.name.name = resolvedName
18
+
19
+ return true
20
+ }
@@ -0,0 +1,59 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import { transformElementName } from "./transformElementName"
5
+
6
+ const j = jscodeshift.withParser("tsx")
7
+
8
+ describe("transformElementName", () => {
9
+ it("should transform JSX element name", () => {
10
+ const code = `<Button>Click me</Button>`
11
+ const source = j(code)
12
+ const elementPath = source.find(j.JSXOpeningElement).at(0)
13
+
14
+ const result = transformElementName(elementPath.get(), "Link")
15
+
16
+ expect(result).toBe(true)
17
+ expect(source.toSource()).toContain("<Link>Click me</Link>")
18
+ })
19
+
20
+ it("should transform both opening and closing tags", () => {
21
+ const code = `<Button className="test">Content</Button>`
22
+ const source = j(code)
23
+ const elementPath = source.find(j.JSXOpeningElement).at(0)
24
+
25
+ const result = transformElementName(elementPath.get(), "Link")
26
+
27
+ expect(result).toBe(true)
28
+ const output = source.toSource()
29
+ expect(output).toContain('<Link className="test">')
30
+ expect(output).toContain("</Link>")
31
+ })
32
+
33
+ it("should handle self-closing tags", () => {
34
+ const code = `<Button />`
35
+ const source = j(code)
36
+ const elementPath = source.find(j.JSXOpeningElement).at(0)
37
+
38
+ const result = transformElementName(elementPath.get(), "Link")
39
+
40
+ expect(result).toBe(true)
41
+ expect(source.toSource()).toContain("<Link />")
42
+ })
43
+
44
+ it("should preserve all attributes", () => {
45
+ const code = `<Button className="test" onClick={handler} disabled>Content</Button>`
46
+ const source = j(code)
47
+ const elementPath = source.find(j.JSXOpeningElement).at(0)
48
+
49
+ const result = transformElementName(elementPath.get(), "OtherButton")
50
+
51
+ expect(result).toBe(true)
52
+ const output = source.toSource()
53
+ expect(output).toContain('className="test"')
54
+ expect(output).toContain("onClick={handler}")
55
+ expect(output).toContain("disabled")
56
+ expect(output).toContain("<OtherButton")
57
+ expect(output).toContain("</OtherButton>")
58
+ })
59
+ })
@@ -0,0 +1,27 @@
1
+ import { ASTPath, JSXOpeningElement } from "jscodeshift"
2
+
3
+ /**
4
+ * Transforms JSX element names (both opening and closing tags)
5
+ */
6
+ export function transformElementName(
7
+ elementPath: ASTPath<JSXOpeningElement>,
8
+ newName: string
9
+ ): boolean {
10
+ if (elementPath.value.name.type === "JSXIdentifier") {
11
+ elementPath.value.name.name = newName
12
+
13
+ // Update closing tag if it exists
14
+ const parent = elementPath.parent
15
+ if (
16
+ parent &&
17
+ parent.value.type === "JSXElement" &&
18
+ parent.value.closingElement
19
+ ) {
20
+ if (parent.value.closingElement.name.type === "JSXIdentifier") {
21
+ parent.value.closingElement.name.name = newName
22
+ }
23
+ }
24
+ return true
25
+ }
26
+ return false
27
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { andConditions } from "./andConditions"
4
+ import { hasAttribute } from "./hasAttribute"
5
+ import { hasAttributeValue } from "./hasAttributeValue"
6
+ import { createJSXElement } from "./helpers/createJSXElement"
7
+
8
+ describe("andConditions", () => {
9
+ it("should return true when all conditions are met", () => {
10
+ const condition = andConditions(
11
+ hasAttribute("href"),
12
+ hasAttributeValue("target", "_blank")
13
+ )
14
+ const element = createJSXElement(' href="/test" target="_blank"')
15
+
16
+ expect(condition(element)).toBe(true)
17
+ })
18
+
19
+ it("should return false when one condition fails", () => {
20
+ const condition = andConditions(
21
+ hasAttribute("href"),
22
+ hasAttributeValue("target", "_blank")
23
+ )
24
+ const element = createJSXElement(' href="/test" target="_self"')
25
+
26
+ expect(condition(element)).toBe(false)
27
+ })
28
+
29
+ it("should return false when all conditions fail", () => {
30
+ const condition = andConditions(
31
+ hasAttribute("href"),
32
+ hasAttributeValue("target", "_blank")
33
+ )
34
+ const element = createJSXElement(' className="test"')
35
+
36
+ expect(condition(element)).toBe(false)
37
+ })
38
+
39
+ it("should handle single condition", () => {
40
+ const condition = andConditions(hasAttribute("disabled"))
41
+ const element = createJSXElement(" disabled")
42
+
43
+ expect(condition(element)).toBe(true)
44
+ })
45
+
46
+ it("should handle empty conditions (return true)", () => {
47
+ const condition = andConditions()
48
+ const element = createJSXElement("")
49
+
50
+ expect(condition(element)).toBe(true)
51
+ })
52
+
53
+ it("should handle complex combinations", () => {
54
+ const condition = andConditions(
55
+ hasAttribute("href"),
56
+ hasAttribute("className"),
57
+ hasAttributeValue("role", "button")
58
+ )
59
+ const element = createJSXElement(
60
+ ' href="/test" className="btn" role="button"'
61
+ )
62
+
63
+ expect(condition(element)).toBe(true)
64
+ })
65
+ })
@@ -0,0 +1,13 @@
1
+ import { JSXElement } from "jscodeshift"
2
+
3
+ import { TransformCondition } from "../types"
4
+
5
+ /**
6
+ * Helper function to combine multiple conditions with AND logic
7
+ */
8
+ export function andConditions(
9
+ ...conditions: TransformCondition[]
10
+ ): TransformCondition {
11
+ return (element: JSXElement) =>
12
+ conditions.every((condition) => condition(element))
13
+ }