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

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 (64) hide show
  1. package/dist/tapestry-react-shim.cjs +5065 -0
  2. package/package.json +9 -5
  3. package/src/components/button/index.ts +53 -3
  4. package/src/components/button/transforms/auditSpreadProps.test.ts +352 -0
  5. package/src/components/button/transforms/auditSpreadProps.ts +24 -0
  6. package/src/components/button/transforms/childrenToLabel.test.ts +363 -0
  7. package/src/components/button/transforms/childrenToLabel.ts +84 -0
  8. package/src/components/button/transforms/commentOnVisualKindDifference.ts +24 -0
  9. package/src/components/button/transforms/convertStyleProps.test.ts +464 -0
  10. package/src/components/button/transforms/convertStyleProps.ts +16 -0
  11. package/src/components/button/transforms/iconLeftToPrefix.test.ts +432 -0
  12. package/src/components/button/transforms/iconLeftToPrefix.ts +33 -0
  13. package/src/components/button/transforms/iconRightToSuffix.test.ts +407 -0
  14. package/src/components/button/transforms/iconRightToSuffix.ts +33 -0
  15. package/src/components/button/transforms/iconToIconButton.test.ts +377 -0
  16. package/src/components/button/transforms/iconToIconButton.ts +53 -0
  17. package/src/components/button/transforms/removeAsButton.ts +15 -0
  18. package/src/components/button/transforms/removeDuplicateKeys.test.ts +302 -0
  19. package/src/components/button/transforms/removeDuplicateKeys.ts +8 -0
  20. package/src/components/button/transforms/reviewStyles.ts +17 -0
  21. package/src/components/button/transforms/spinnerToLoadingButton.test.ts +165 -0
  22. package/src/components/button/transforms/spinnerToLoadingButton.ts +14 -0
  23. package/src/components/button/transforms/themeVariantToKind.test.ts +401 -0
  24. package/src/components/button/transforms/themeVariantToKind.ts +90 -0
  25. package/src/components/button/transforms/tooltipToWrapper.test.ts +392 -0
  26. package/src/components/button/transforms/tooltipToWrapper.ts +35 -0
  27. package/src/components/button/transforms/unsupportedProps.ts +73 -0
  28. package/src/components/shared/actions/addAttribute.test.ts +300 -0
  29. package/src/components/shared/actions/addAttribute.ts +65 -0
  30. package/src/components/shared/actions/addComment.test.ts +1 -1
  31. package/src/components/shared/actions/addCommentToAttribute.test.ts +45 -0
  32. package/src/components/shared/actions/addCommentToAttribute.ts +28 -0
  33. package/src/components/shared/actions/addCommentToUnsupportedProps.ts +29 -0
  34. package/src/components/shared/actions/convertAttributeFromObjectToJSXElement.test.ts +139 -0
  35. package/src/components/shared/actions/convertAttributeFromObjectToJSXElement.ts +81 -0
  36. package/src/components/shared/actions/createWrapper.test.ts +642 -0
  37. package/src/components/shared/actions/createWrapper.ts +70 -0
  38. package/src/components/shared/actions/getAttribute.ts +18 -0
  39. package/src/components/shared/actions/getAttributeValue.test.ts +261 -0
  40. package/src/components/shared/actions/getAttributeValue.ts +15 -0
  41. package/src/components/shared/actions/getAttributeValueAsProps.ts +57 -0
  42. package/src/components/shared/actions/getSpreadProps.ts +7 -0
  43. package/src/components/shared/actions/hasSpreadProps.ts +7 -0
  44. package/src/components/shared/actions/removeChildren.ts +7 -0
  45. package/src/components/shared/actions/removeDuplicateKeys.test.ts +280 -0
  46. package/src/components/shared/actions/removeDuplicateKeys.ts +45 -0
  47. package/src/components/shared/actions/removeUnusedImport.test.ts +302 -0
  48. package/src/components/shared/actions/removeUnusedImport.ts +81 -0
  49. package/src/components/shared/actions/transformElementName.test.ts +9 -9
  50. package/src/components/shared/actions/transformElementName.ts +13 -16
  51. package/src/components/shared/conditions/hasChildren.ts +5 -0
  52. package/src/components/shared/getJavaScriptTheme.ts +68 -0
  53. package/src/components/shared/jsThemeLoader.ts +85 -0
  54. package/src/components/shared/transformFactories/attributeCombineFactory.test.ts +374 -0
  55. package/src/components/shared/transformFactories/attributeCombineFactory.ts +300 -0
  56. package/src/components/shared/transformFactories/attributeTransformFactory.ts +14 -6
  57. package/src/components/shared/transformFactories/componentTransformFactory.ts +1 -1
  58. package/src/components/shared/transformFactories/helpers/addImport.test.ts +278 -0
  59. package/src/components/shared/transformFactories/helpers/manageImports.ts +53 -20
  60. package/src/components/shared/transformFactories/stylePropTransformFactory.ts +362 -0
  61. package/src/index.ts +4 -0
  62. package/src/stubs/stackViewPlugin.ts +33 -0
  63. package/src/stubs/tapestry-stub.ts +16 -0
  64. package/src/tapestry-react-shim.ts +7 -0
@@ -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,261 @@
1
+ import jscodeshift, {
2
+ JSXAttribute,
3
+ JSXElement,
4
+ JSXIdentifier,
5
+ } from "jscodeshift"
6
+ import { describe, expect, it } from "vitest"
7
+
8
+ import { getAttributeValue } from "./getAttributeValue"
9
+
10
+ const j = jscodeshift.withParser("tsx")
11
+
12
+ function createElementFromCode(code: string): JSXElement {
13
+ const source = j(`<div>${code}</div>`)
14
+ return source.find(j.JSXElement).at(0).get().value.children?.[0] as JSXElement
15
+ }
16
+
17
+ function getAttributeFromElement(
18
+ element: JSXElement,
19
+ name: string
20
+ ): JSXAttribute | null {
21
+ const attributes = element.openingElement.attributes || []
22
+ return (
23
+ (attributes.find(
24
+ (attr) =>
25
+ attr.type === "JSXAttribute" &&
26
+ (attr.name as JSXIdentifier)?.name === name
27
+ ) as JSXAttribute) || null
28
+ )
29
+ }
30
+
31
+ describe("getAttributeValue", () => {
32
+ describe("string literal values", () => {
33
+ it("should extract string literal value", () => {
34
+ const element = createElementFromCode(
35
+ '<Button kind="primary">Save</Button>'
36
+ )
37
+ const kindAttribute = getAttributeFromElement(element, "kind")
38
+
39
+ const value = getAttributeValue({ attribute: kindAttribute, j })
40
+
41
+ expect(value).toBe("primary")
42
+ })
43
+
44
+ it("should handle empty string literal", () => {
45
+ const element = createElementFromCode('<Button title="">Save</Button>')
46
+ const titleAttribute = getAttributeFromElement(element, "title")
47
+
48
+ const value = getAttributeValue({ attribute: titleAttribute, j })
49
+
50
+ expect(value).toBe("")
51
+ })
52
+
53
+ it("should handle strings with special characters", () => {
54
+ const element = createElementFromCode(
55
+ '<Button aria-label="Save & Continue">Save</Button>'
56
+ )
57
+ const ariaAttribute = getAttributeFromElement(element, "aria-label")
58
+
59
+ const value = getAttributeValue({ attribute: ariaAttribute, j })
60
+
61
+ expect(value).toBe("Save & Continue")
62
+ })
63
+
64
+ it("should handle strings with quotes", () => {
65
+ const element = createElementFromCode(
66
+ "<Button title='Say \"Hello\"'>Save</Button>"
67
+ )
68
+ const titleAttribute = getAttributeFromElement(element, "title")
69
+
70
+ const value = getAttributeValue({ attribute: titleAttribute, j })
71
+
72
+ expect(value).toBe('Say "Hello"')
73
+ })
74
+ })
75
+
76
+ describe("expression container values", () => {
77
+ it("should convert boolean expression to source code", () => {
78
+ const element = createElementFromCode(
79
+ "<Button disabled={true}>Save</Button>"
80
+ )
81
+ const disabledAttribute = getAttributeFromElement(element, "disabled")
82
+
83
+ const value = getAttributeValue({ attribute: disabledAttribute, j })
84
+
85
+ expect(value).toBe("{true}")
86
+ })
87
+
88
+ it("should convert variable expression to source code", () => {
89
+ const element = createElementFromCode(
90
+ "<Button onClick={handleClick}>Save</Button>"
91
+ )
92
+ const onClickAttribute = getAttributeFromElement(element, "onClick")
93
+
94
+ const value = getAttributeValue({ attribute: onClickAttribute, j })
95
+
96
+ expect(value).toBe("{handleClick}")
97
+ })
98
+
99
+ it("should convert complex expression to source code", () => {
100
+ const element = createElementFromCode(
101
+ '<Button style={{ color: "red", fontSize: 16 }}>Save</Button>'
102
+ )
103
+ const styleAttribute = getAttributeFromElement(element, "style")
104
+
105
+ const value = getAttributeValue({ attribute: styleAttribute, j })
106
+
107
+ expect(value).toContain("color")
108
+ expect(value).toContain("red")
109
+ expect(value).toContain("fontSize")
110
+ expect(value).toContain("16")
111
+ })
112
+
113
+ it("should handle function call expressions", () => {
114
+ const element = createElementFromCode(
115
+ '<Button className={getButtonClass("primary")}>Save</Button>'
116
+ )
117
+ const classAttribute = getAttributeFromElement(element, "className")
118
+
119
+ const value = getAttributeValue({ attribute: classAttribute, j })
120
+
121
+ expect(value).toBe('{getButtonClass("primary")}')
122
+ })
123
+
124
+ it("should handle template literal expressions", () => {
125
+ const element = createElementFromCode(
126
+ "<Button className={`btn-${variant}`}>Save</Button>"
127
+ )
128
+ const classAttribute = getAttributeFromElement(element, "className")
129
+
130
+ const value = getAttributeValue({ attribute: classAttribute, j })
131
+
132
+ expect(value).toBe("{`btn-${variant}`}")
133
+ })
134
+
135
+ it("should handle ternary expressions", () => {
136
+ const element = createElementFromCode(
137
+ "<Button disabled={loading ? true : false}>Save</Button>"
138
+ )
139
+ const disabledAttribute = getAttributeFromElement(element, "disabled")
140
+
141
+ const value = getAttributeValue({ attribute: disabledAttribute, j })
142
+
143
+ expect(value).toBe("{loading ? true : false}")
144
+ })
145
+ })
146
+
147
+ describe("null and undefined attributes", () => {
148
+ it("should return null for null attribute", () => {
149
+ const value = getAttributeValue({ attribute: null, j })
150
+
151
+ expect(value).toBeNull()
152
+ })
153
+
154
+ it("should return null for undefined attribute", () => {
155
+ const value = getAttributeValue({ attribute: undefined, j })
156
+
157
+ expect(value).toBeNull()
158
+ })
159
+ })
160
+
161
+ describe("attributes without values", () => {
162
+ it("should return null for boolean attribute without value", () => {
163
+ const element = createElementFromCode("<Button disabled>Save</Button>")
164
+ const disabledAttribute = getAttributeFromElement(element, "disabled")
165
+
166
+ const value = getAttributeValue({ attribute: disabledAttribute, j })
167
+
168
+ expect(value).toBeNull()
169
+ })
170
+
171
+ it("should handle attribute with null value", () => {
172
+ const mockAttribute = {
173
+ name: { name: "test", type: "JSXIdentifier" },
174
+ type: "JSXAttribute",
175
+ value: null,
176
+ } as JSXAttribute
177
+
178
+ const value = getAttributeValue({ attribute: mockAttribute, j })
179
+
180
+ expect(value).toBeNull()
181
+ })
182
+ })
183
+
184
+ describe("edge cases", () => {
185
+ it("should handle numeric expressions", () => {
186
+ const element = createElementFromCode(
187
+ "<Button tabIndex={0}>Save</Button>"
188
+ )
189
+ const tabIndexAttribute = getAttributeFromElement(element, "tabIndex")
190
+
191
+ const value = getAttributeValue({ attribute: tabIndexAttribute, j })
192
+
193
+ expect(value).toBe("{0}")
194
+ })
195
+
196
+ it("should handle array expressions", () => {
197
+ const element = createElementFromCode(
198
+ "<Button data={[1, 2, 3]}>Save</Button>"
199
+ )
200
+ const dataAttribute = getAttributeFromElement(element, "data")
201
+
202
+ const value = getAttributeValue({ attribute: dataAttribute, j })
203
+
204
+ expect(value).toBe("{[1, 2, 3]}")
205
+ })
206
+
207
+ it("should handle nested object expressions", () => {
208
+ const element = createElementFromCode(
209
+ '<Button config={{ theme: { primary: "blue" } }}>Save</Button>'
210
+ )
211
+ const configAttribute = getAttributeFromElement(element, "config")
212
+
213
+ const value = getAttributeValue({ attribute: configAttribute, j })
214
+
215
+ expect(value).toContain("theme")
216
+ expect(value).toContain("primary")
217
+ expect(value).toContain("blue")
218
+ })
219
+
220
+ it("should handle JSX element expressions", () => {
221
+ const element = createElementFromCode(
222
+ '<Button icon={<Icon name="save" />}>Save</Button>'
223
+ )
224
+ const iconAttribute = getAttributeFromElement(element, "icon")
225
+
226
+ const value = getAttributeValue({ attribute: iconAttribute, j })
227
+
228
+ expect(value).toBe('{<Icon name="save" />}')
229
+ })
230
+ })
231
+
232
+ describe("integration scenarios", () => {
233
+ it("should handle mixed attribute types on same element", () => {
234
+ const element = createElementFromCode(
235
+ '<Button kind="primary" disabled={true} onClick={handleClick} data-testid="save-btn">Save</Button>'
236
+ )
237
+
238
+ const kindValue = getAttributeValue({
239
+ attribute: getAttributeFromElement(element, "kind"),
240
+ j,
241
+ })
242
+ const disabledValue = getAttributeValue({
243
+ attribute: getAttributeFromElement(element, "disabled"),
244
+ j,
245
+ })
246
+ const onClickValue = getAttributeValue({
247
+ attribute: getAttributeFromElement(element, "onClick"),
248
+ j,
249
+ })
250
+ const testIdValue = getAttributeValue({
251
+ attribute: getAttributeFromElement(element, "data-testid"),
252
+ j,
253
+ })
254
+
255
+ expect(kindValue).toBe("primary")
256
+ expect(disabledValue).toBe("{true}")
257
+ expect(onClickValue).toBe("{handleClick}")
258
+ expect(testIdValue).toBe("save-btn")
259
+ })
260
+ })
261
+ })
@@ -0,0 +1,15 @@
1
+ import { JSCodeshift, JSXAttribute } from "jscodeshift"
2
+
3
+ export function getAttributeValue({
4
+ attribute,
5
+ j,
6
+ }: {
7
+ attribute: JSXAttribute | null | undefined
8
+ j: JSCodeshift
9
+ }) {
10
+ return attribute && attribute.value
11
+ ? attribute?.value?.type === "StringLiteral"
12
+ ? attribute.value.value
13
+ : j(attribute.value).toSource()
14
+ : null
15
+ }
@@ -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,7 @@
1
+ import { JSXElement, JSXSpreadAttribute } from "jscodeshift"
2
+
3
+ export function getSpreadProps(element: JSXElement): JSXSpreadAttribute[] {
4
+ return (element.openingElement.attributes || []).filter(
5
+ (attr) => attr.type === "JSXSpreadAttribute"
6
+ )
7
+ }
@@ -0,0 +1,7 @@
1
+ import { JSXElement } from "jscodeshift"
2
+
3
+ export function hasSpreadProps(element: JSXElement): boolean {
4
+ return (element.openingElement.attributes || []).some(
5
+ (attr) => attr.type === "JSXSpreadAttribute"
6
+ )
7
+ }
@@ -0,0 +1,7 @@
1
+ import { JSXElement } from "jscodeshift"
2
+
3
+ export function removeChildren(element: JSXElement) {
4
+ element.children = []
5
+ element.closingElement = null
6
+ element.openingElement.selfClosing = true
7
+ }
@@ -0,0 +1,280 @@
1
+ import jscodeshift, {
2
+ JSXAttribute,
3
+ JSXElement,
4
+ JSXIdentifier,
5
+ StringLiteral,
6
+ } from "jscodeshift"
7
+ import { describe, expect, it } from "vitest"
8
+
9
+ import { removeDuplicateKeys } from "./removeDuplicateKeys"
10
+
11
+ const j = jscodeshift.withParser("tsx")
12
+
13
+ function buildSourceAndElement(code: string) {
14
+ const source = j(`<div>${code}</div>`)
15
+ const element = source.find(j.JSXElement).at(0).get().value
16
+ .children?.[0] as JSXElement
17
+ return { element, source }
18
+ }
19
+
20
+ function getAttributeFromElement(
21
+ element: JSXElement,
22
+ name: string
23
+ ): JSXAttribute | null {
24
+ const attributes = element.openingElement.attributes || []
25
+ return (
26
+ (attributes.find(
27
+ (attr) =>
28
+ attr.type === "JSXAttribute" &&
29
+ (attr.name as JSXIdentifier)?.name === name
30
+ ) as JSXAttribute) || null
31
+ )
32
+ }
33
+
34
+ function getAttributeNames(element: JSXElement): string[] {
35
+ const attributes = element.openingElement.attributes || []
36
+ return attributes
37
+ .filter((attr) => attr.type === "JSXAttribute")
38
+ .map((attr) => (attr as JSXAttribute).name?.name || "")
39
+ .filter(Boolean) as string[]
40
+ }
41
+
42
+ describe("removeDuplicateKeys", () => {
43
+ describe("elements with duplicate attributes", () => {
44
+ it("should remove duplicate string attributes, keeping the first occurrence", () => {
45
+ const { source, element } = buildSourceAndElement(
46
+ '<Button kind="primary" disabled kind="secondary">Save</Button>'
47
+ )
48
+
49
+ const result = removeDuplicateKeys(element, { j, source })
50
+
51
+ expect(result).toBe(true)
52
+
53
+ const attributes = getAttributeNames(element)
54
+ expect(attributes).toEqual(["kind", "disabled"])
55
+
56
+ const kindAttr = getAttributeFromElement(element, "kind")
57
+ expect(kindAttr?.value?.type).toBe("StringLiteral")
58
+ expect((kindAttr?.value as StringLiteral)?.value).toBe("primary")
59
+ })
60
+
61
+ it("should handle multiple sets of duplicate attributes", () => {
62
+ const { source, element } = buildSourceAndElement(
63
+ '<Button kind="primary" size="large" kind="secondary" disabled size="small">Save</Button>'
64
+ )
65
+
66
+ const result = removeDuplicateKeys(element, { j, source })
67
+
68
+ expect(result).toBe(true)
69
+
70
+ const attributes = getAttributeNames(element)
71
+ expect(attributes).toEqual(["kind", "size", "disabled"])
72
+
73
+ const kindAttr = getAttributeFromElement(element, "kind")
74
+ const sizeAttr = getAttributeFromElement(element, "size")
75
+ expect((kindAttr?.value as StringLiteral)?.value).toBe("primary")
76
+ expect((sizeAttr?.value as StringLiteral)?.value).toBe("large")
77
+ })
78
+
79
+ it("should handle duplicate boolean attributes", () => {
80
+ const { source, element } = buildSourceAndElement(
81
+ "<Button disabled loading disabled>Save</Button>"
82
+ )
83
+
84
+ const result = removeDuplicateKeys(element, { j, source })
85
+
86
+ expect(result).toBe(true)
87
+
88
+ const attributes = getAttributeNames(element)
89
+ expect(attributes).toEqual(["disabled", "loading"])
90
+ })
91
+
92
+ it("should handle duplicate expression attributes", () => {
93
+ const { source, element } = buildSourceAndElement(
94
+ "<Button onClick={handleClick} disabled={true} onClick={handleOtherClick}>Save</Button>"
95
+ )
96
+
97
+ const result = removeDuplicateKeys(element, { j, source })
98
+
99
+ expect(result).toBe(true)
100
+
101
+ const attributes = getAttributeNames(element)
102
+ expect(attributes).toEqual(["onClick", "disabled"])
103
+
104
+ const onClickAttr = getAttributeFromElement(element, "onClick")
105
+ expect(onClickAttr?.value?.type).toBe("JSXExpressionContainer")
106
+ })
107
+
108
+ it("should preserve attribute order for non-duplicates", () => {
109
+ const { source, element } = buildSourceAndElement(
110
+ '<Button aria-label="Save" kind="primary" data-testid="btn" kind="secondary" className="btn">Save</Button>'
111
+ )
112
+
113
+ const result = removeDuplicateKeys(element, { j, source })
114
+
115
+ expect(result).toBe(true)
116
+
117
+ const attributes = getAttributeNames(element)
118
+ expect(attributes).toEqual([
119
+ "aria-label",
120
+ "kind",
121
+ "data-testid",
122
+ "className",
123
+ ])
124
+ })
125
+ })
126
+
127
+ describe("elements without duplicate attributes", () => {
128
+ it("should return false when no duplicates exist", () => {
129
+ const { source, element } = buildSourceAndElement(
130
+ '<Button kind="primary" size="large" disabled>Save</Button>'
131
+ )
132
+ const initialAttributeCount =
133
+ element.openingElement.attributes?.length || 0
134
+
135
+ const result = removeDuplicateKeys(element, { j, source })
136
+
137
+ expect(result).toBe(false)
138
+ expect(element.openingElement.attributes).toHaveLength(
139
+ initialAttributeCount
140
+ )
141
+
142
+ const attributes = getAttributeNames(element)
143
+ expect(attributes).toEqual(["kind", "size", "disabled"])
144
+ })
145
+
146
+ it("should handle single attribute elements", () => {
147
+ const { source, element } = buildSourceAndElement(
148
+ '<Button kind="primary">Save</Button>'
149
+ )
150
+
151
+ const result = removeDuplicateKeys(element, { j, source })
152
+
153
+ expect(result).toBe(false)
154
+
155
+ const attributes = getAttributeNames(element)
156
+ expect(attributes).toEqual(["kind"])
157
+ })
158
+
159
+ it("should handle elements with no attributes", () => {
160
+ const { source, element } = buildSourceAndElement("<Button>Save</Button>")
161
+
162
+ const result = removeDuplicateKeys(element, { j, source })
163
+
164
+ expect(result).toBe(false)
165
+
166
+ const attributes = getAttributeNames(element)
167
+ expect(attributes).toEqual([])
168
+ })
169
+ })
170
+
171
+ describe("edge cases", () => {
172
+ it("should handle JSX spread attributes (ignore them)", () => {
173
+ const { source, element } = buildSourceAndElement(
174
+ '<Button kind="primary" kind="secondary">Save</Button>'
175
+ )
176
+ const spreadAttr = j.jsxSpreadAttribute(j.identifier("props"))
177
+ element.openingElement.attributes?.push(spreadAttr)
178
+
179
+ const result = removeDuplicateKeys(element, { j, source })
180
+
181
+ expect(result).toBe(true)
182
+ expect(element.openingElement.attributes).toHaveLength(2)
183
+ const attributes = getAttributeNames(element)
184
+ expect(attributes).toEqual(["kind"])
185
+ })
186
+
187
+ it("should handle elements with only spread attributes", () => {
188
+ const { source, element } = buildSourceAndElement("<Button>Save</Button>")
189
+ const spreadAttr1 = j.jsxSpreadAttribute(j.identifier("props1"))
190
+ const spreadAttr2 = j.jsxSpreadAttribute(j.identifier("props2"))
191
+ element.openingElement.attributes = [spreadAttr1, spreadAttr2]
192
+
193
+ const result = removeDuplicateKeys(element, { j, source })
194
+
195
+ expect(result).toBe(false)
196
+ expect(element.openingElement.attributes).toHaveLength(2)
197
+ })
198
+
199
+ it("should handle elements with mixed attribute types", () => {
200
+ const { source, element } = buildSourceAndElement(
201
+ '<Button kind="primary">Save</Button>'
202
+ )
203
+
204
+ const spreadAttr = j.jsxSpreadAttribute(j.identifier("otherProps"))
205
+ element.openingElement.attributes?.push(spreadAttr)
206
+
207
+ const result = removeDuplicateKeys(element, { j, source })
208
+
209
+ expect(result).toBe(false)
210
+ expect(element.openingElement.attributes).toHaveLength(2)
211
+
212
+ const attributes = getAttributeNames(element)
213
+ expect(attributes).toEqual(["kind"])
214
+ })
215
+
216
+ it("should handle many duplicate attributes efficiently", () => {
217
+ const { source, element } = buildSourceAndElement(
218
+ '<Button kind="1" kind="2" kind="3" kind="4" kind="5" size="1" size="2" size="3">Save</Button>'
219
+ )
220
+ const initialCount = element.openingElement.attributes?.length || 0
221
+
222
+ const result = removeDuplicateKeys(element, { j, source })
223
+
224
+ expect(result).toBe(true)
225
+
226
+ const attributes = getAttributeNames(element)
227
+ expect(attributes).toEqual(["kind", "size"])
228
+ expect(element.openingElement.attributes?.length).toBe(2)
229
+ expect(initialCount).toBeGreaterThan(2)
230
+ })
231
+ })
232
+
233
+ describe("integration with JSCodeshift", () => {
234
+ it("should work correctly when element is part of a larger AST", () => {
235
+ const source = j(`
236
+ <div>
237
+ <Button kind="primary" onClick={handleClick} kind="secondary">Save</Button>
238
+ <Button size="large">Cancel</Button>
239
+ </div>
240
+ `)
241
+
242
+ const buttonElements = source
243
+ .find(j.JSXElement)
244
+ .filter(
245
+ (path) =>
246
+ (path.value.openingElement.name as JSXIdentifier)?.name === "Button"
247
+ )
248
+
249
+ const firstButton = buttonElements.at(0).get().value as JSXElement
250
+ const result = removeDuplicateKeys(firstButton, { j, source })
251
+
252
+ expect(result).toBe(true)
253
+
254
+ const attributes = getAttributeNames(firstButton)
255
+ expect(attributes).toEqual(["kind", "onClick"])
256
+
257
+ const rendered = source.toSource()
258
+ expect(rendered).toContain('kind="primary"')
259
+ expect(rendered).not.toContain('kind="secondary"')
260
+ expect(rendered).toContain("onClick={handleClick}")
261
+ expect(rendered).toContain('size="large"')
262
+ })
263
+
264
+ it("should maintain proper JSX structure after removing duplicates", () => {
265
+ const { source, element } = buildSourceAndElement(
266
+ '<Button kind="primary" disabled kind="secondary" loading>Text</Button>'
267
+ )
268
+ removeDuplicateKeys(element, { j, source })
269
+
270
+ const result = source.toSource()
271
+
272
+ expect(result).toContain("<Button")
273
+ expect(result).toContain('kind="primary"')
274
+ expect(result).toContain("disabled")
275
+ expect(result).toContain("loading")
276
+ expect(result).toContain(">Text</Button>")
277
+ expect(result).not.toContain('kind="secondary"')
278
+ })
279
+ })
280
+ })