@planningcenter/tapestry-migration-cli 2.2.0-rc.5 → 2.2.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.
@@ -0,0 +1,18 @@
1
+ import { JSCodeshift, JSXAttribute, JSXElement } from "jscodeshift"
2
+
3
+ export function getAttribute({
4
+ element,
5
+
6
+ name,
7
+ }: {
8
+ element: JSXElement
9
+ name: string
10
+ }) {
11
+ const attributes = element.openingElement.attributes || []
12
+ const attribute = attributes.find(
13
+ (attr) => attr.type === "JSXAttribute" && attr.name.name === name
14
+ )
15
+ if (!attribute) return
16
+
17
+ return attribute as JSXAttribute
18
+ }
@@ -0,0 +1,57 @@
1
+ import {
2
+ JSCodeshift,
3
+ JSXAttribute,
4
+ JSXElement,
5
+ JSXSpreadAttribute,
6
+ } from "jscodeshift"
7
+
8
+ import { getAttribute } from "./getAttribute"
9
+
10
+ export function getAttributeValueAsProps({
11
+ j,
12
+ element,
13
+ name,
14
+ stringValueKey,
15
+ }: {
16
+ element: JSXElement
17
+ j: JSCodeshift
18
+ name: string
19
+ stringValueKey?: string
20
+ }): JSXSpreadAttribute[] {
21
+ const attribute = getAttribute({ element, name })
22
+ if (attribute && attribute.value) {
23
+ if (attribute.value.type === "StringLiteral") {
24
+ return stringToProps({ attribute, j, stringValueKey })
25
+ }
26
+ if (attribute.value.type === "JSXExpressionContainer") {
27
+ if (attribute.value.expression.type !== "JSXEmptyExpression") {
28
+ return [j.jsxSpreadAttribute(attribute.value.expression)]
29
+ }
30
+ } else {
31
+ return [j.jsxSpreadAttribute(attribute.value)]
32
+ }
33
+ }
34
+
35
+ return []
36
+ }
37
+
38
+ function stringToProps({
39
+ attribute,
40
+ j,
41
+ stringValueKey,
42
+ }: {
43
+ attribute: JSXAttribute
44
+ j: JSCodeshift
45
+ stringValueKey?: string
46
+ }): JSXSpreadAttribute[] {
47
+ if (!attribute.value) return []
48
+ if (stringValueKey === undefined) return []
49
+
50
+ return [
51
+ j.jsxSpreadAttribute(
52
+ j.objectExpression([
53
+ j.objectProperty(j.identifier(stringValueKey), attribute.value),
54
+ ])
55
+ ),
56
+ ]
57
+ }
@@ -0,0 +1,278 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import { addImport } from "./manageImports"
5
+
6
+ const j = jscodeshift.withParser("tsx")
7
+
8
+ function createSource(code: string) {
9
+ return j(code)
10
+ }
11
+
12
+ describe("addImport", () => {
13
+ describe("when component already imported from target package", () => {
14
+ it("should return existing import name", () => {
15
+ const source = createSource(
16
+ 'import { Button } from "@planningcenter/tapestry"'
17
+ )
18
+ const result = addImport({
19
+ component: "Button",
20
+ conflictAlias: "TButton",
21
+ j,
22
+ pkg: "@planningcenter/tapestry",
23
+ source,
24
+ })
25
+
26
+ expect(result).toBe("Button")
27
+ // Should not modify the source
28
+ const imports = source.find(j.ImportDeclaration)
29
+ expect(imports).toHaveLength(1)
30
+ expect(imports.at(0).get().value.source.value).toBe(
31
+ "@planningcenter/tapestry"
32
+ )
33
+ expect(imports.at(0).get().value.specifiers).toHaveLength(1)
34
+ })
35
+
36
+ it("should return existing aliased import name", () => {
37
+ const source = createSource(
38
+ 'import { Button as TapestryButton } from "@planningcenter/tapestry"'
39
+ )
40
+ const result = addImport({
41
+ component: "Button",
42
+ conflictAlias: "TButton",
43
+ j,
44
+ pkg: "@planningcenter/tapestry",
45
+ source,
46
+ })
47
+
48
+ expect(result).toBe("TapestryButton")
49
+ })
50
+ })
51
+
52
+ describe("when adding to existing import declaration", () => {
53
+ it("should add component to existing import without conflict", () => {
54
+ const source = createSource(
55
+ 'import { Link } from "@planningcenter/tapestry"'
56
+ )
57
+ const result = addImport({
58
+ component: "Button",
59
+ conflictAlias: "TButton",
60
+ j,
61
+ pkg: "@planningcenter/tapestry",
62
+ source,
63
+ })
64
+
65
+ expect(result).toBe("Button")
66
+
67
+ const imports = source.find(j.ImportDeclaration)
68
+ expect(imports).toHaveLength(1)
69
+ expect(imports.at(0).get().value.specifiers).toHaveLength(2)
70
+
71
+ const specifierNames = imports
72
+ .at(0)
73
+ .get()
74
+ .value.specifiers?.map(
75
+ (spec: { imported: { name: string }; type: string }) =>
76
+ spec.type === "ImportSpecifier" ? spec.imported?.name : null
77
+ )
78
+ expect(specifierNames).toContain("Link")
79
+ expect(specifierNames).toContain("Button")
80
+ })
81
+
82
+ it("should add component with auto-generated alias when conflict exists", () => {
83
+ const source = createSource(`
84
+ import { Button } from "@some/other-package"
85
+ import { Link } from "@planningcenter/tapestry"
86
+ `)
87
+
88
+ const result = addImport({
89
+ component: "Button",
90
+ conflictAlias: "TButton",
91
+ j,
92
+ pkg: "@planningcenter/tapestry",
93
+ source,
94
+ })
95
+
96
+ expect(result).toBe("TButton")
97
+
98
+ const tapestryImport = source.find(j.ImportDeclaration, {
99
+ source: { value: "@planningcenter/tapestry" },
100
+ })
101
+ expect(tapestryImport).toHaveLength(1)
102
+
103
+ const specifiers = tapestryImport.at(0).get().value.specifiers
104
+ const buttonSpec = specifiers?.find(
105
+ (spec: { imported: { name: string }; type: string }) =>
106
+ spec.type === "ImportSpecifier" && spec.imported?.name === "Button"
107
+ )
108
+ expect(buttonSpec?.local?.name).toBe("TButton")
109
+ })
110
+
111
+ it("should add component with custom alias when conflict exists", () => {
112
+ const source = createSource(`
113
+ import { Button } from "@some/other-package"
114
+ import { Link } from "@planningcenter/tapestry"
115
+ `)
116
+
117
+ const result = addImport({
118
+ component: "Button",
119
+ conflictAlias: "TapestryButton",
120
+ j,
121
+ pkg: "@planningcenter/tapestry",
122
+ source,
123
+ })
124
+
125
+ expect(result).toBe("TapestryButton")
126
+
127
+ const tapestryImport = source.find(j.ImportDeclaration, {
128
+ source: { value: "@planningcenter/tapestry" },
129
+ })
130
+ expect(tapestryImport).toHaveLength(1)
131
+
132
+ const specifiers = tapestryImport.at(0).get().value.specifiers
133
+ const buttonSpec = specifiers?.find(
134
+ (spec: { imported: { name: string }; type: string }) =>
135
+ spec.type === "ImportSpecifier" && spec.imported?.name === "Button"
136
+ )
137
+ expect(buttonSpec?.local?.name).toBe("TapestryButton")
138
+ })
139
+ })
140
+
141
+ describe("when creating new import declaration", () => {
142
+ it("should create new import without conflict", () => {
143
+ const source = createSource('import React from "react"')
144
+ const result = addImport({
145
+ component: "Button",
146
+ conflictAlias: "TButton",
147
+ j,
148
+ pkg: "@planningcenter/tapestry",
149
+ source,
150
+ })
151
+
152
+ expect(result).toBe("Button")
153
+
154
+ const imports = source.find(j.ImportDeclaration)
155
+ expect(imports).toHaveLength(2)
156
+
157
+ const tapestryImport = source.find(j.ImportDeclaration, {
158
+ source: { value: "@planningcenter/tapestry" },
159
+ })
160
+ expect(tapestryImport).toHaveLength(1)
161
+ expect(tapestryImport.at(0).get().value.specifiers).toHaveLength(1)
162
+ expect(
163
+ tapestryImport.at(0).get().value.specifiers?.[0].imported?.name
164
+ ).toBe("Button")
165
+ })
166
+
167
+ it("should create new import with auto-generated alias when conflict exists", () => {
168
+ const source = createSource(`
169
+ import React from "react"
170
+ import { Button } from "@some/other-package"
171
+ `)
172
+
173
+ const result = addImport({
174
+ component: "Button",
175
+ conflictAlias: "TButton",
176
+ j,
177
+ pkg: "@planningcenter/tapestry",
178
+ source,
179
+ })
180
+
181
+ expect(result).toBe("TButton")
182
+
183
+ const imports = source.find(j.ImportDeclaration)
184
+ expect(imports).toHaveLength(3)
185
+
186
+ const tapestryImport = source.find(j.ImportDeclaration, {
187
+ source: { value: "@planningcenter/tapestry" },
188
+ })
189
+ expect(tapestryImport).toHaveLength(1)
190
+
191
+ const buttonSpec = tapestryImport.at(0).get().value.specifiers?.[0]
192
+ expect(buttonSpec?.imported?.name).toBe("Button")
193
+ expect(buttonSpec?.local?.name).toBe("TButton")
194
+ })
195
+
196
+ it("should create new import with custom alias when conflict exists", () => {
197
+ const source = createSource(`
198
+ import React from "react"
199
+ import { Button } from "@some/other-package"
200
+ `)
201
+
202
+ const result = addImport({
203
+ component: "Button",
204
+ conflictAlias: "TapestryButton",
205
+ j,
206
+ pkg: "@planningcenter/tapestry",
207
+ source,
208
+ })
209
+
210
+ expect(result).toBe("TapestryButton")
211
+
212
+ const tapestryImport = source.find(j.ImportDeclaration, {
213
+ source: { value: "@planningcenter/tapestry" },
214
+ })
215
+ expect(tapestryImport).toHaveLength(1)
216
+
217
+ const buttonSpec = tapestryImport.at(0).get().value.specifiers?.[0]
218
+ expect(buttonSpec?.imported?.name).toBe("Button")
219
+ expect(buttonSpec?.local?.name).toBe("TapestryButton")
220
+ })
221
+ })
222
+
223
+ describe("edge cases", () => {
224
+ it("should handle duplicate add calls", () => {
225
+ const source = createSource('import React from "react"')
226
+
227
+ const result1 = addImport({
228
+ component: "Button",
229
+ conflictAlias: "TButton",
230
+ j,
231
+ pkg: "@planningcenter/tapestry",
232
+ source,
233
+ })
234
+
235
+ const result2 = addImport({
236
+ component: "Button",
237
+ conflictAlias: "TButton",
238
+ j,
239
+ pkg: "@planningcenter/tapestry",
240
+ source,
241
+ })
242
+
243
+ expect(result1).toBe("Button")
244
+ expect(result2).toBe("Button")
245
+
246
+ // Should still only have 2 imports (React + Button)
247
+ const imports = source.find(j.ImportDeclaration)
248
+ expect(imports).toHaveLength(2)
249
+
250
+ // Button should only appear once in the tapestry import
251
+ const tapestryImport = source.find(j.ImportDeclaration, {
252
+ source: { value: "@planningcenter/tapestry" },
253
+ })
254
+ expect(tapestryImport.at(0).get().value.specifiers).toHaveLength(1)
255
+ })
256
+
257
+ it("should not use conflictAlias when no conflict exists", () => {
258
+ const source = createSource('import React from "react"')
259
+
260
+ const result = addImport({
261
+ component: "Button",
262
+ conflictAlias: "TapestryButton",
263
+ j,
264
+ pkg: "@planningcenter/tapestry",
265
+ source,
266
+ })
267
+
268
+ expect(result).toBe("Button")
269
+
270
+ const tapestryImport = source.find(j.ImportDeclaration, {
271
+ source: { value: "@planningcenter/tapestry" },
272
+ })
273
+ const buttonSpec = tapestryImport.at(0).get().value.specifiers?.[0]
274
+ expect(buttonSpec?.imported?.name).toBe("Button")
275
+ expect(buttonSpec?.local).toBeNull()
276
+ })
277
+ })
278
+ })
@@ -145,6 +145,51 @@ export function removeImportFromDeclaration(
145
145
  return false
146
146
  }
147
147
 
148
+ /**
149
+ * Adds an import to the source, handling conflicts and existing imports
150
+ * Returns the final name to use for the component (original or aliased)
151
+ */
152
+ export function addImport({
153
+ component,
154
+ conflictAlias,
155
+ j,
156
+ pkg,
157
+ source,
158
+ }: {
159
+ component: string
160
+ conflictAlias: string
161
+ j: JSCodeshift
162
+ pkg: string
163
+ source: Collection
164
+ }): string {
165
+ // Check if component is already imported from the target package
166
+ const existingImportName = getImportName(component, pkg, { j, source })
167
+ if (existingImportName) {
168
+ return existingImportName
169
+ }
170
+
171
+ // Check for conflicts with imports from other packages
172
+ const hasConflict = hasConflictingImport(component, pkg, { j, source })
173
+ const finalComponentName = hasConflict ? conflictAlias : component
174
+ const alias =
175
+ finalComponentName !== component ? finalComponentName : undefined
176
+
177
+ // Handle target package import addition
178
+ const targetImport = source
179
+ .find(j.ImportDeclaration, {
180
+ source: { value: pkg },
181
+ })
182
+ .at(0)
183
+
184
+ if (targetImport.length > 0) {
185
+ addImportToExisting(targetImport, component, j, alias)
186
+ } else {
187
+ createNewImport(source, component, pkg, j, alias)
188
+ }
189
+
190
+ return finalComponentName
191
+ }
192
+
148
193
  /**
149
194
  * Manages imports after transformation - adds target imports and removes unused source imports
150
195
  */
@@ -189,24 +234,12 @@ export function manageImports(
189
234
  removeImportFromDeclaration(sourceImport, config.fromComponent)
190
235
  }
191
236
 
192
- // Handle target package import addition
193
- const targetImport = source
194
- .find(j.ImportDeclaration, {
195
- source: { value: config.toPackage },
196
- })
197
- .at(0)
198
-
199
- if (targetImport.length > 0) {
200
- const alias =
201
- targetComponentName !== config.toComponent
202
- ? targetComponentName
203
- : undefined
204
- addImportToExisting(targetImport, config.toComponent, j, alias)
205
- } else {
206
- const alias =
207
- targetComponentName !== config.toComponent
208
- ? targetComponentName
209
- : undefined
210
- createNewImport(source, config.toComponent, config.toPackage, j, alias)
211
- }
237
+ // Handle target package import addition using addImport
238
+ addImport({
239
+ component: config.toComponent,
240
+ conflictAlias: config.conflictAlias || targetComponentName,
241
+ j,
242
+ pkg: config.toPackage,
243
+ source,
244
+ })
212
245
  }