@planningcenter/tapestry-migration-cli 2.2.1-qa-362.0 → 2.3.0-rc.2

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.
@@ -0,0 +1,392 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import transform from "./tooltipToWrapper"
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("tooltipToWrapper transform", () => {
18
+ describe("basic transformations", () => {
19
+ it("should wrap Button with tooltip string in Tooltip component", () => {
20
+ const input = `
21
+ import { Button } from "@planningcenter/tapestry-react"
22
+
23
+ export default function Test() {
24
+ return <Button tooltip={{ title: "Save this item" }}>Save</Button>
25
+ }
26
+ `.trim()
27
+
28
+ const result = applyTransform(input)
29
+
30
+ expect(result).toContain(
31
+ 'import { Button, Tooltip } from "@planningcenter/tapestry-react"'
32
+ )
33
+ expect(result).toContain('<Tooltip {...{ title: "Save this item" }}>')
34
+ expect(result).toContain("<Button>Save</Button>")
35
+ expect(result).toContain("</Tooltip>")
36
+ expect(result).not.toContain("tooltip=")
37
+ })
38
+
39
+ it("should handle tooltip with JSX expression containing string literal", () => {
40
+ const input = `
41
+ import { Button } from "@planningcenter/tapestry-react"
42
+
43
+ export default function Test() {
44
+ return <Button tooltip={{ title: "Save this item" }}>Save</Button>
45
+ }
46
+ `.trim()
47
+
48
+ const result = applyTransform(input)
49
+
50
+ expect(result).toContain('<Tooltip {...{ title: "Save this item" }}>')
51
+ expect(result).toContain("<Button>Save</Button>")
52
+ expect(result).not.toContain("tooltip=")
53
+ })
54
+
55
+ it("should handle tooltip with object expression as spread", () => {
56
+ const input = `
57
+ import { Button } from "@planningcenter/tapestry-react"
58
+
59
+ export default function Test() {
60
+ return <Button tooltip={{text: "Save", placement: "top"}}>Save</Button>
61
+ }
62
+ `.trim()
63
+
64
+ const result = applyTransform(input)
65
+
66
+ expect(result).toContain(
67
+ '<Tooltip {...{text: "Save", placement: "top"}}>'
68
+ )
69
+ expect(result).toContain("<Button>Save</Button>")
70
+ expect(result).not.toContain("tooltip=")
71
+ })
72
+
73
+ it("should preserve other Button attributes", () => {
74
+ const input = `
75
+ import { Button } from "@planningcenter/tapestry-react"
76
+
77
+ export default function Test() {
78
+ return <Button tooltip={{title: "Help"}} variant="primary" onClick={handler} disabled>Save</Button>
79
+ }
80
+ `.trim()
81
+
82
+ const result = applyTransform(input)
83
+
84
+ expect(result).toContain('<Tooltip {...{title: "Help"}}>')
85
+ expect(result).toContain(
86
+ '<Button variant="primary" onClick={handler} disabled>Save</Button>'
87
+ )
88
+ expect(result).not.toContain("tooltip=")
89
+ })
90
+
91
+ it("should handle self-closing Button with tooltip", () => {
92
+ const input = `
93
+ import { Button } from "@planningcenter/tapestry-react"
94
+
95
+ export default function Test() {
96
+ return <Button tooltip={{title: "Icon button"}} />
97
+ }
98
+ `.trim()
99
+
100
+ const result = applyTransform(input)
101
+
102
+ expect(result).toContain('<Tooltip {...{title: "Icon button"}}>')
103
+ expect(result).toContain("<Button />")
104
+ expect(result).toContain("</Tooltip>")
105
+ expect(result).not.toContain("tooltip=")
106
+ })
107
+ })
108
+
109
+ describe("import management", () => {
110
+ it("should add Tooltip to existing tapestry-react import", () => {
111
+ const input = `
112
+ import { Button, Link } from "@planningcenter/tapestry-react"
113
+
114
+ export default function Test() {
115
+ return <Button tooltip={{title: "Save"}}>Save</Button>
116
+ }
117
+ `.trim()
118
+
119
+ const result = applyTransform(input)
120
+
121
+ expect(result).toContain(
122
+ 'import { Button, Link, Tooltip } from "@planningcenter/tapestry-react"'
123
+ )
124
+ expect(result).toContain('<Tooltip {...{title: "Save"}}>')
125
+ })
126
+
127
+ it("should not add duplicate Tooltip import", () => {
128
+ const input = `
129
+ import { Button, Tooltip } from "@planningcenter/tapestry-react"
130
+
131
+ export default function Test() {
132
+ return <Button tooltip={{title: "Save"}}>Save</Button>
133
+ }
134
+ `.trim()
135
+
136
+ const result = applyTransform(input)
137
+
138
+ // Should not have duplicate Tooltip imports
139
+ const tooltipImports = (result?.match(/import.*Tooltip.*from/g) || [])
140
+ .length
141
+ expect(tooltipImports).toBe(1)
142
+ expect(result).toContain(
143
+ 'import { Button, Tooltip } from "@planningcenter/tapestry-react"'
144
+ )
145
+ })
146
+
147
+ it("should create new tapestry-react import when none exists", () => {
148
+ const input = `
149
+ import React from "react"
150
+ import { Button } from "@planningcenter/tapestry-react"
151
+
152
+ export default function Test() {
153
+ return <Button tooltip={{title: "Save"}}>Save</Button>
154
+ }
155
+ `.trim()
156
+
157
+ const result = applyTransform(input)
158
+
159
+ expect(result).toContain(
160
+ 'import { Button, Tooltip } from "@planningcenter/tapestry-react"'
161
+ )
162
+ })
163
+
164
+ it("should handle empty imports scenario", () => {
165
+ const input = `
166
+ import { Button } from "@planningcenter/tapestry-react"
167
+ export default function Test() {
168
+ return <Button tooltip={{title: "Save"}}>Save</Button>
169
+ }
170
+ `.trim()
171
+
172
+ const result = applyTransform(input)
173
+
174
+ expect(result).toContain(
175
+ 'import { Button, Tooltip } from "@planningcenter/tapestry-react"'
176
+ )
177
+ })
178
+ })
179
+
180
+ describe("multiple elements", () => {
181
+ it("should handle multiple Buttons with tooltips", () => {
182
+ const input = `
183
+ import { Button } from "@planningcenter/tapestry-react"
184
+
185
+ export default function Test() {
186
+ return (
187
+ <div>
188
+ <Button tooltip={{title: "Save the document"}}>Save</Button>
189
+ <Button tooltip={{title: "Cancel operation"}}>Cancel</Button>
190
+ </div>
191
+ )
192
+ }
193
+ `.trim()
194
+
195
+ const result = applyTransform(input)
196
+
197
+ expect(result).toContain(
198
+ 'import { Button, Tooltip } from "@planningcenter/tapestry-react"'
199
+ )
200
+ expect(result).toContain('<Tooltip {...{title: "Save the document"}}>')
201
+ expect(result).toContain('<Tooltip {...{title: "Cancel operation"}}>')
202
+ expect(result).not.toContain("tooltip=")
203
+ })
204
+
205
+ it("should only transform Buttons with tooltip attribute", () => {
206
+ const input = `
207
+ import { Button } from "@planningcenter/tapestry-react"
208
+
209
+ export default function Test() {
210
+ return (
211
+ <div>
212
+ <Button tooltip={{title: "Has tooltip"}}>With Tooltip</Button>
213
+ <Button>Without Tooltip</Button>
214
+ </div>
215
+ )
216
+ }
217
+ `.trim()
218
+
219
+ const result = applyTransform(input)
220
+
221
+ expect(result).toContain('<Tooltip {...{title: "Has tooltip"}}>')
222
+ expect(result).toContain("<Button>With Tooltip</Button>")
223
+ expect(result).toContain("<Button>Without Tooltip</Button>")
224
+ // Only one Tooltip wrapper should be created
225
+ const tooltipCount = (result?.match(/<Tooltip/g) || []).length
226
+ expect(tooltipCount).toBe(1)
227
+ })
228
+ })
229
+
230
+ describe("edge cases", () => {
231
+ it("should return null when Button is not imported from tapestry-react", () => {
232
+ const input = `
233
+ import { Link } from "@planningcenter/tapestry-react"
234
+ import { Button } from "other-library"
235
+
236
+ export default function Test() {
237
+ return <Button tooltip={{title: "Save"}}>Save</Button>
238
+ }
239
+ `.trim()
240
+
241
+ const result = applyTransform(input)
242
+ expect(result).toBe(null)
243
+ })
244
+
245
+ it("should return null when no Button is imported", () => {
246
+ const input = `
247
+ import React from "react"
248
+
249
+ export default function Test() {
250
+ return <div>No buttons here</div>
251
+ }
252
+ `.trim()
253
+
254
+ const result = applyTransform(input)
255
+ expect(result).toBe(null)
256
+ })
257
+
258
+ it("should return null when no tooltips are present", () => {
259
+ const input = `
260
+ import { Button } from "@planningcenter/tapestry-react"
261
+
262
+ export default function Test() {
263
+ return <Button variant="primary">Save</Button>
264
+ }
265
+ `.trim()
266
+
267
+ const result = applyTransform(input)
268
+ expect(result).toBe(null)
269
+ })
270
+
271
+ it("should handle aliased Button import", () => {
272
+ const input = `
273
+ import { Button as TapestryButton } from "@planningcenter/tapestry-react"
274
+
275
+ export default function Test() {
276
+ return <TapestryButton tooltip={{title: "Save item"}}>Save</TapestryButton>
277
+ }
278
+ `.trim()
279
+
280
+ const result = applyTransform(input)
281
+
282
+ expect(result).toContain(
283
+ 'import { Button as TapestryButton, Tooltip } from "@planningcenter/tapestry-react"'
284
+ )
285
+ expect(result).toContain('<Tooltip {...{title: "Save item"}}>')
286
+ expect(result).toContain("<TapestryButton>Save</TapestryButton>")
287
+ expect(result).not.toContain("tooltip=")
288
+ })
289
+
290
+ it("should handle complex JSX expressions", () => {
291
+ const input = `
292
+ import { Button } from "@planningcenter/tapestry-react"
293
+
294
+ export default function Test({ items }) {
295
+ return (
296
+ <div>
297
+ {items.map(item => (
298
+ <Button key={item.id} tooltip={\`Save \${item.name}\`} onClick={() => save(item)}>
299
+ {item.name}
300
+ </Button>
301
+ ))}
302
+ </div>
303
+ )
304
+ }
305
+ `.trim()
306
+
307
+ const result = applyTransform(input)
308
+
309
+ expect(result).toContain(
310
+ "<Tooltip key={item.id} {...`Save ${item.name}`}>"
311
+ )
312
+ expect(result).toContain("<Button onClick={() => save(item)}>")
313
+ expect(result).not.toContain("tooltip=")
314
+ })
315
+
316
+ it("should handle empty tooltip attribute", () => {
317
+ const input = `
318
+ import { Button } from "@planningcenter/tapestry-react"
319
+
320
+ export default function Test() {
321
+ return <Button tooltip>Save</Button>
322
+ }
323
+ `.trim()
324
+
325
+ const result = applyTransform(input)
326
+
327
+ expect(result).toContain("<Tooltip>")
328
+ expect(result).toContain("<Button>Save</Button>")
329
+ expect(result).toContain("</Tooltip>")
330
+ expect(result).not.toContain("tooltip")
331
+ })
332
+ })
333
+
334
+ describe("complex scenarios", () => {
335
+ it("should handle nested JSX and mixed components", () => {
336
+ const input = `
337
+ import React from "react"
338
+ import { Button, Link } from "@planningcenter/tapestry-react"
339
+
340
+ export default function Test() {
341
+ return (
342
+ <form>
343
+ <div className="actions">
344
+ <Button tooltip={{title: "Submit the form"}} type="submit" variant="primary">
345
+ Submit
346
+ </Button>
347
+ <Link href="/cancel">Cancel</Link>
348
+ <Button tooltip={{text: "Reset form", placement: "bottom"}} type="reset">
349
+ Reset
350
+ </Button>
351
+ </div>
352
+ </form>
353
+ )
354
+ }
355
+ `.trim()
356
+
357
+ const result = applyTransform(input)
358
+
359
+ expect(result).toContain(
360
+ 'import { Button, Link, Tooltip } from "@planningcenter/tapestry-react"'
361
+ )
362
+ expect(result).toContain('<Tooltip {...{title: "Submit the form"}}>')
363
+ expect(result).toContain('type="submit" variant="primary"')
364
+ expect(result).toContain("Submit")
365
+ expect(result).toContain(
366
+ '<Tooltip {...{text: "Reset form", placement: "bottom"}}>'
367
+ )
368
+ expect(result).toContain('type="reset"')
369
+ expect(result).toContain("Reset")
370
+ expect(result).toContain('<Link href="/cancel">Cancel</Link>')
371
+ expect(result).not.toContain("tooltip=")
372
+ })
373
+ })
374
+
375
+ it("converts tooltip with string literal to title prop", () => {
376
+ const input = `
377
+ import { Button } from "@planningcenter/tapestry-react"
378
+
379
+ export default function Test() {
380
+ return <Button tooltip="Click me">Click</Button>
381
+ }
382
+ `.trim()
383
+
384
+ const result = applyTransform(input)!
385
+
386
+ expect(result.replace(/(\s*)?\n(\s*)?/gm, " ")).toContain(
387
+ '<Tooltip {...{ title: "Click me" }}>'
388
+ )
389
+ expect(result).toContain("<Button>Click</Button>")
390
+ expect(result).not.toContain("tooltip=")
391
+ })
392
+ })
@@ -0,0 +1,35 @@
1
+ import { createWrapper } from "../../shared/actions/createWrapper"
2
+ import { getAttributeValueAsProps } from "../../shared/actions/getAttributeValueAsProps"
3
+ import { removeAttribute } from "../../shared/actions/removeAttribute"
4
+ import { hasAttribute } from "../../shared/conditions/hasAttribute"
5
+ import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
6
+
7
+ const transform = attributeTransformFactory({
8
+ condition: hasAttribute("tooltip"),
9
+ targetComponent: "Button",
10
+ targetPackage: "@planningcenter/tapestry-react",
11
+ transform: (element, { j, source }) => {
12
+ const wrapperProps = getAttributeValueAsProps({
13
+ element,
14
+ j,
15
+ name: "tooltip",
16
+ stringValueKey: "title",
17
+ })
18
+
19
+ removeAttribute("tooltip", { element, j, source })
20
+
21
+ createWrapper({
22
+ conflictAlias: "TRTooltip",
23
+ element,
24
+ j,
25
+ source,
26
+ wrapperName: "Tooltip",
27
+ wrapperPackage: "@planningcenter/tapestry-react",
28
+ wrapperProps,
29
+ })
30
+
31
+ return true
32
+ },
33
+ })
34
+
35
+ export default transform
@@ -0,0 +1,139 @@
1
+ import jscodeshift, {
2
+ JSXAttribute,
3
+ JSXElement,
4
+ JSXExpressionContainer,
5
+ JSXIdentifier,
6
+ } from "jscodeshift"
7
+ import { describe, expect, it } from "vitest"
8
+
9
+ import { convertAttributeFromObjectToJSXElement } from "./convertAttributeFromObjectToJSXElement"
10
+
11
+ const j = jscodeshift.withParser("tsx")
12
+
13
+ function createSource(code: string) {
14
+ return j(code)
15
+ }
16
+
17
+ function createElementFromCode(code: string): JSXElement {
18
+ const source = createSource(`<div>${code}</div>`)
19
+ return source.find(j.JSXElement).at(0).get().value.children?.[0] as JSXElement
20
+ }
21
+
22
+ describe("convertAttributeFromObjectToJSXElement", () => {
23
+ it("should convert object attribute to JSX element", () => {
24
+ const element = createElementFromCode(
25
+ '<Button iconLeft={{ name: "star", size: 16 }} />'
26
+ )
27
+
28
+ const result = convertAttributeFromObjectToJSXElement({
29
+ attributeName: "iconLeft",
30
+ element,
31
+ elementName: "Icon",
32
+ j,
33
+ })
34
+
35
+ expect(result).not.toBeNull()
36
+ expect(result?.type).toBe("JSXElement")
37
+ expect((result?.openingElement.name as JSXIdentifier).name).toBe("Icon")
38
+ expect(result?.openingElement.selfClosing).toBe(true)
39
+ expect(result?.openingElement.attributes).toHaveLength(1)
40
+ expect(result?.openingElement.attributes?.[0].type).toBe(
41
+ "JSXSpreadAttribute"
42
+ )
43
+
44
+ // Check that the attribute still exists but now has JSX element value
45
+ const iconLeftAttr = element.openingElement.attributes?.find(
46
+ (attr) => attr.type === "JSXAttribute" && attr.name?.name === "iconLeft"
47
+ ) as JSXAttribute
48
+ expect(iconLeftAttr).toBeDefined()
49
+ expect(iconLeftAttr?.value?.type).toBe("JSXExpressionContainer")
50
+ expect(
51
+ (iconLeftAttr?.value as JSXExpressionContainer)?.expression?.type
52
+ ).toBe("JSXElement")
53
+ })
54
+
55
+ it("should handle expression container attributes", () => {
56
+ const element = createElementFromCode("<Button iconLeft={iconProps} />")
57
+
58
+ const result = convertAttributeFromObjectToJSXElement({
59
+ attributeName: "iconLeft",
60
+ element,
61
+ elementName: "Icon",
62
+ j,
63
+ })
64
+
65
+ expect(result).not.toBeNull()
66
+ expect(result?.openingElement.attributes).toHaveLength(1)
67
+ expect(result?.openingElement.attributes?.[0].type).toBe(
68
+ "JSXSpreadAttribute"
69
+ )
70
+
71
+ // Check that the attribute still exists but now has JSX element value
72
+ const iconLeftAttr = element.openingElement.attributes?.find(
73
+ (attr) => attr.type === "JSXAttribute" && attr.name?.name === "iconLeft"
74
+ ) as JSXAttribute
75
+ expect(iconLeftAttr).toBeDefined()
76
+ expect(iconLeftAttr?.value?.type).toBe("JSXExpressionContainer")
77
+ expect(
78
+ (iconLeftAttr?.value as JSXExpressionContainer)?.expression?.type
79
+ ).toBe("JSXElement")
80
+ })
81
+
82
+ it("should return null for empty expression container", () => {
83
+ const element = createElementFromCode("<Button iconLeft={{}} />")
84
+
85
+ const result = convertAttributeFromObjectToJSXElement({
86
+ attributeName: "iconLeft",
87
+ element,
88
+ elementName: "Icon",
89
+ j,
90
+ })
91
+
92
+ // Should return null since empty object has no props to spread
93
+ expect(result).toBeNull()
94
+
95
+ // Check that the attribute was not modified since no conversion occurred
96
+ const iconLeftAttr = element.openingElement.attributes?.find(
97
+ (attr) => attr.type === "JSXAttribute" && attr.name?.name === "iconLeft"
98
+ ) as JSXAttribute
99
+ expect(iconLeftAttr).toBeDefined()
100
+ expect(iconLeftAttr?.value?.type).toBe("JSXExpressionContainer")
101
+ expect(
102
+ (iconLeftAttr?.value as JSXExpressionContainer)?.expression?.type
103
+ ).toBe("ObjectExpression")
104
+ })
105
+
106
+ it("should return null for missing attribute", () => {
107
+ const element = createElementFromCode("<Button>Save</Button>")
108
+
109
+ const result = convertAttributeFromObjectToJSXElement({
110
+ attributeName: "iconLeft",
111
+ element,
112
+ elementName: "Icon",
113
+ j,
114
+ })
115
+
116
+ expect(result).toBeNull()
117
+ })
118
+
119
+ it("should return null for attribute with no value", () => {
120
+ const element = j.jsxElement(
121
+ j.jsxOpeningElement(
122
+ j.jsxIdentifier("Button"),
123
+ [j.jsxAttribute(j.jsxIdentifier("iconLeft"), null)],
124
+ false
125
+ ),
126
+ j.jsxClosingElement(j.jsxIdentifier("Button")),
127
+ [j.jsxText("Save")]
128
+ )
129
+
130
+ const result = convertAttributeFromObjectToJSXElement({
131
+ attributeName: "iconLeft",
132
+ element,
133
+ elementName: "Icon",
134
+ j,
135
+ })
136
+
137
+ expect(result).toBeNull()
138
+ })
139
+ })
@@ -0,0 +1,81 @@
1
+ import {
2
+ JSCodeshift,
3
+ JSXAttribute,
4
+ JSXElement,
5
+ JSXSpreadAttribute,
6
+ } from "jscodeshift"
7
+
8
+ import { getAttribute } from "./getAttribute"
9
+
10
+ export function convertAttributeFromObjectToJSXElement({
11
+ attributeName,
12
+ element,
13
+ elementName,
14
+ j,
15
+ stringValueKey,
16
+ }: {
17
+ attributeName: string
18
+ element: JSXElement
19
+ elementName: string
20
+ j: JSCodeshift
21
+ stringValueKey?: string
22
+ }): JSXElement | null {
23
+ const attribute = getAttribute({ element, name: attributeName })
24
+ if (!attribute || !attribute.value) return null
25
+
26
+ const elementProps = buildProps({ attribute, j, stringValueKey })
27
+ if (!elementProps) return null
28
+
29
+ const newElement = j.jsxElement(
30
+ j.jsxOpeningElement(j.jsxIdentifier(elementName), elementProps, true),
31
+ null,
32
+ []
33
+ )
34
+
35
+ attribute.value = j.jsxExpressionContainer(newElement)
36
+
37
+ return newElement
38
+ }
39
+
40
+ function buildProps({
41
+ attribute,
42
+ j,
43
+ stringValueKey,
44
+ }: {
45
+ attribute: JSXAttribute
46
+ j: JSCodeshift
47
+ stringValueKey?: string
48
+ }): JSXSpreadAttribute[] | null {
49
+ if (!attribute.value) return null
50
+ if (attribute.value.type == "StringLiteral")
51
+ return stringToProps({ attribute, j, stringValueKey })
52
+ if (attribute.value.type !== "JSXExpressionContainer") return null
53
+ if (attribute.value.expression.type === "JSXEmptyExpression") return null
54
+ if (
55
+ attribute.value.expression.type === "ObjectExpression" &&
56
+ attribute.value.expression.properties.length === 0
57
+ )
58
+ return null
59
+ return [j.jsxSpreadAttribute(attribute.value.expression)]
60
+ }
61
+
62
+ function stringToProps({
63
+ attribute,
64
+ j,
65
+ stringValueKey,
66
+ }: {
67
+ attribute: JSXAttribute
68
+ j: JSCodeshift
69
+ stringValueKey?: string
70
+ }): JSXSpreadAttribute[] | null {
71
+ if (!attribute.value) return null
72
+ if (stringValueKey === undefined) return null
73
+
74
+ return [
75
+ j.jsxSpreadAttribute(
76
+ j.objectExpression([
77
+ j.objectProperty(j.identifier(stringValueKey), attribute.value),
78
+ ])
79
+ ),
80
+ ]
81
+ }