@planningcenter/tapestry-migration-cli 3.1.0-rc.8 → 3.1.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.
Files changed (72) hide show
  1. package/dist/tapestry-react-shim.cjs +7 -1
  2. package/package.json +3 -3
  3. package/src/components/input/transformableInput.ts +47 -6
  4. package/src/components/input/transforms/mergeFieldIntoInput.test.ts +78 -0
  5. package/src/components/input/transforms/mergeFieldIntoInput.ts +6 -212
  6. package/src/components/input/transforms/removeDuplicateKeys.test.ts +3 -3
  7. package/src/components/input/transforms/removeTypeInput.test.ts +212 -0
  8. package/src/components/input/transforms/removeTypeInput.ts +22 -0
  9. package/src/components/input/transforms/removeTypeText.ts +2 -3
  10. package/src/components/input/transforms/unsupportedProps.test.ts +20 -20
  11. package/src/components/select/index.ts +58 -0
  12. package/src/components/select/transformableSelect.ts +7 -0
  13. package/src/components/select/transforms/auditSpreadProps.test.ts +103 -0
  14. package/src/components/select/transforms/auditSpreadProps.ts +26 -0
  15. package/src/components/select/transforms/childrenToOptions.test.ts +367 -0
  16. package/src/components/select/transforms/childrenToOptions.ts +295 -0
  17. package/src/components/select/transforms/convertLegacyOptions.test.ts +150 -0
  18. package/src/components/select/transforms/convertLegacyOptions.ts +105 -0
  19. package/src/components/select/transforms/convertStyleProps.test.ts +73 -0
  20. package/src/components/select/transforms/convertStyleProps.ts +12 -0
  21. package/src/components/select/transforms/emptyValueToPlaceholder.test.ts +122 -0
  22. package/src/components/select/transforms/emptyValueToPlaceholder.ts +22 -0
  23. package/src/components/select/transforms/innerRefToRef.test.ts +89 -0
  24. package/src/components/select/transforms/innerRefToRef.ts +18 -0
  25. package/src/components/select/transforms/mapChildrenToOptions.test.ts +521 -0
  26. package/src/components/select/transforms/mapChildrenToOptions.ts +312 -0
  27. package/src/components/select/transforms/mergeFieldIntoSelect.test.ts +506 -0
  28. package/src/components/select/transforms/mergeFieldIntoSelect.ts +7 -0
  29. package/src/components/select/transforms/mergeSelectLabel.test.ts +458 -0
  30. package/src/components/select/transforms/mergeSelectLabel.ts +225 -0
  31. package/src/components/select/transforms/moveSelectImport.test.ts +148 -0
  32. package/src/components/select/transforms/moveSelectImport.ts +14 -0
  33. package/src/components/select/transforms/removeDefaultProps.test.ts +249 -0
  34. package/src/components/select/transforms/removeDefaultProps.ts +112 -0
  35. package/src/components/select/transforms/sizeMapping.test.ts +188 -0
  36. package/src/components/select/transforms/sizeMapping.ts +17 -0
  37. package/src/components/select/transforms/skipMultipleSelect.test.ts +148 -0
  38. package/src/components/select/transforms/skipMultipleSelect.ts +23 -0
  39. package/src/components/select/transforms/stateToInvalid.test.ts +217 -0
  40. package/src/components/select/transforms/stateToInvalid.ts +59 -0
  41. package/src/components/select/transforms/stateToInvalidTernary.test.ts +146 -0
  42. package/src/components/select/transforms/stateToInvalidTernary.ts +13 -0
  43. package/src/components/select/transforms/unsupportedProps.test.ts +252 -0
  44. package/src/components/select/transforms/unsupportedProps.ts +44 -0
  45. package/src/components/shared/helpers/getAttributeExpression.ts +26 -0
  46. package/src/components/shared/helpers/unsupportedPropsHelpers.ts +52 -2
  47. package/src/components/shared/transformFactories/mergeFieldFactory.ts +244 -0
  48. package/src/components/shared/transformFactories/stylePropTransformFactory.ts +2 -1
  49. package/src/components/text-area/index.ts +48 -0
  50. package/src/components/text-area/transforms/auditSpreadProps.test.ts +139 -0
  51. package/src/components/text-area/transforms/auditSpreadProps.ts +10 -0
  52. package/src/components/text-area/transforms/convertStyleProps.test.ts +158 -0
  53. package/src/components/text-area/transforms/convertStyleProps.ts +10 -0
  54. package/src/components/text-area/transforms/innerRefToRef.test.ts +206 -0
  55. package/src/components/text-area/transforms/innerRefToRef.ts +14 -0
  56. package/src/components/text-area/transforms/mergeFieldIntoTextArea.test.ts +477 -0
  57. package/src/components/text-area/transforms/mergeFieldIntoTextArea.ts +5 -0
  58. package/src/components/text-area/transforms/moveTextAreaImport.test.ts +168 -0
  59. package/src/components/text-area/transforms/moveTextAreaImport.ts +13 -0
  60. package/src/components/text-area/transforms/removeDuplicateKeys.test.ts +129 -0
  61. package/src/components/text-area/transforms/removeDuplicateKeys.ts +8 -0
  62. package/src/components/text-area/transforms/removeRedundantAriaLabel.test.ts +183 -0
  63. package/src/components/text-area/transforms/removeRedundantAriaLabel.ts +59 -0
  64. package/src/components/text-area/transforms/sizeMapping.test.ts +199 -0
  65. package/src/components/text-area/transforms/sizeMapping.ts +15 -0
  66. package/src/components/text-area/transforms/stateToInvalid.test.ts +204 -0
  67. package/src/components/text-area/transforms/stateToInvalid.ts +57 -0
  68. package/src/components/text-area/transforms/stateToInvalidTernary.test.ts +133 -0
  69. package/src/components/text-area/transforms/stateToInvalidTernary.ts +11 -0
  70. package/src/components/text-area/transforms/unsupportedProps.test.ts +275 -0
  71. package/src/components/text-area/transforms/unsupportedProps.ts +35 -0
  72. package/src/index.ts +4 -1
@@ -0,0 +1,295 @@
1
+ import {
2
+ JSCodeshift,
3
+ JSXElement,
4
+ JSXExpressionContainer,
5
+ JSXText,
6
+ Transform,
7
+ } from "jscodeshift"
8
+
9
+ import { addComment } from "../../shared/actions/addComment"
10
+ import { removeChildren } from "../../shared/actions/removeChildren"
11
+ import { andConditions } from "../../shared/conditions/andConditions"
12
+ import { hasChildren } from "../../shared/conditions/hasChildren"
13
+ import { getAttributeExpression } from "../../shared/helpers/getAttributeExpression"
14
+ import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
15
+ import { getImportName } from "../../shared/transformFactories/helpers/manageImports"
16
+ import { transformableSelect } from "../transformableSelect"
17
+
18
+ type ChildNode = JSXElement["children"][number]
19
+
20
+ /**
21
+ * Checks if a JSX element is a specific member expression like `Select.Option`
22
+ */
23
+ function isMemberExpression(
24
+ node: ChildNode,
25
+ objectName: string,
26
+ propertyName: string
27
+ ): node is JSXElement {
28
+ if (!node || node.type !== "JSXElement") return false
29
+ const opening = node.openingElement
30
+ return (
31
+ opening.name.type === "JSXMemberExpression" &&
32
+ opening.name.object.type === "JSXIdentifier" &&
33
+ opening.name.object.name === objectName &&
34
+ opening.name.property.name === propertyName
35
+ )
36
+ }
37
+
38
+ /**
39
+ * Extracts a string value from a JSX attribute.
40
+ * Returns null if the value is dynamic/complex.
41
+ */
42
+ function getStaticAttributeValue(
43
+ element: JSXElement,
44
+ attrName: string
45
+ ): string | null {
46
+ const attrs = element.openingElement.attributes || []
47
+ for (const attr of attrs) {
48
+ if (attr.type !== "JSXAttribute") continue
49
+ if (attr.name.name !== attrName) continue
50
+
51
+ if (!attr.value) return null // boolean shorthand
52
+ if (attr.value.type === "StringLiteral") return attr.value.value
53
+ if (attr.value.type === "JSXExpressionContainer") {
54
+ const expr = attr.value.expression
55
+ if (expr.type === "StringLiteral") return expr.value
56
+ }
57
+ return null
58
+ }
59
+ return null
60
+ }
61
+
62
+ /**
63
+ * Checks if a JSX attribute is a boolean shorthand (e.g. `disabled`)
64
+ */
65
+ function hasBooleanAttribute(element: JSXElement, attrName: string): boolean {
66
+ const attrs = element.openingElement.attributes || []
67
+ for (const attr of attrs) {
68
+ if (attr.type !== "JSXAttribute") continue
69
+ if (attr.name.name !== attrName) continue
70
+ // Boolean shorthand: <Option disabled />
71
+ if (!attr.value) return true
72
+ // Explicit true: <Option disabled={true} />
73
+ if (
74
+ attr.value.type === "JSXExpressionContainer" &&
75
+ attr.value.expression.type === "BooleanLiteral" &&
76
+ attr.value.expression.value === true
77
+ )
78
+ return true
79
+ }
80
+ return false
81
+ }
82
+
83
+ /**
84
+ * Extracts plain text from JSX children.
85
+ * Returns null if children are complex (JSX expressions, elements, etc.)
86
+ */
87
+ function extractSimpleTextChildren(children: ChildNode[]): string | null {
88
+ const meaningful = children.filter((child) => {
89
+ if (child.type === "JSXText") {
90
+ return child.value.trim().length > 0
91
+ }
92
+ return true
93
+ })
94
+
95
+ if (meaningful.length === 0) return null
96
+
97
+ // All meaningful children must be text
98
+ const allText = meaningful.every((child) => child.type === "JSXText")
99
+ if (!allText) return null
100
+
101
+ return meaningful.map((child) => (child as JSXText).value.trim()).join(" ")
102
+ }
103
+
104
+ /**
105
+ * Attempts to convert a Select.Option element into an options array entry AST node.
106
+ * Returns null if the option is too complex for automated conversion.
107
+ */
108
+ function convertOptionToObjectExpression(
109
+ optionElement: JSXElement,
110
+ j: JSCodeshift
111
+ ) {
112
+ const value = getStaticAttributeValue(optionElement, "value")
113
+ if (value === null) return null
114
+
115
+ const children = optionElement.children || []
116
+ const labelText = extractSimpleTextChildren(children)
117
+ if (labelText === null) return null
118
+
119
+ const properties = [
120
+ j.objectProperty(j.identifier("label"), j.stringLiteral(labelText)),
121
+ j.objectProperty(j.identifier("value"), j.stringLiteral(value)),
122
+ ]
123
+
124
+ if (hasBooleanAttribute(optionElement, "disabled")) {
125
+ properties.push(
126
+ j.objectProperty(j.identifier("disabled"), j.booleanLiteral(true))
127
+ )
128
+ }
129
+
130
+ // Preserve data-* attributes on the option object
131
+ const attrs = optionElement.openingElement.attributes || []
132
+ for (const attr of attrs) {
133
+ if (attr.type !== "JSXAttribute") continue
134
+ const name = attr.name.name as string
135
+ if (!name.startsWith("data-")) continue
136
+ const expr = getAttributeExpression(optionElement, name)
137
+ if (expr) {
138
+ properties.push(j.objectProperty(j.stringLiteral(name), expr))
139
+ }
140
+ }
141
+
142
+ return j.objectExpression(properties)
143
+ }
144
+
145
+ /**
146
+ * Attempts to convert a Select.OptionGroup element into a group object AST node.
147
+ */
148
+ function convertOptionGroupToObjectExpression(
149
+ groupElement: JSXElement,
150
+ localSelectName: string,
151
+ j: JSCodeshift
152
+ ) {
153
+ const title = getStaticAttributeValue(groupElement, "title")
154
+ if (title === null) return null
155
+
156
+ const childOptions: ReturnType<typeof convertOptionToObjectExpression>[] = []
157
+ const children = groupElement.children || []
158
+
159
+ for (const child of children) {
160
+ if (child.type === "JSXText" && child.value.trim() === "") continue
161
+ if (!isMemberExpression(child, localSelectName, "Option")) return null
162
+
163
+ const optObj = convertOptionToObjectExpression(child, j)
164
+ if (optObj === null) return null
165
+ childOptions.push(optObj)
166
+ }
167
+
168
+ if (childOptions.length === 0) return null
169
+
170
+ return j.objectExpression([
171
+ j.objectProperty(j.identifier("label"), j.stringLiteral(title)),
172
+ j.objectProperty(j.identifier("options"), j.arrayExpression(childOptions)),
173
+ ])
174
+ }
175
+
176
+ const transform: Transform = attributeTransformFactory({
177
+ condition: andConditions(transformableSelect, hasChildren),
178
+ targetComponent: "Select",
179
+ targetPackage: "@planningcenter/tapestry-react",
180
+ transform: (element, { j, source }) => {
181
+ const children = element.children || []
182
+
183
+ // Get the local name of Select for member expression matching
184
+ const localSelectName =
185
+ getImportName("Select", "@planningcenter/tapestry-react", {
186
+ j,
187
+ source,
188
+ }) || "Select"
189
+
190
+ // Check if any children are dynamic (.map expressions, ternaries, etc.)
191
+ const hasDynamicChildren = children.some(
192
+ (child: ChildNode) =>
193
+ child.type === "JSXExpressionContainer" &&
194
+ (child as JSXExpressionContainer).expression.type !==
195
+ "JSXEmptyExpression"
196
+ )
197
+
198
+ if (hasDynamicChildren) {
199
+ addComment({
200
+ element,
201
+ j,
202
+ scope: "children",
203
+ source,
204
+ text: 'Select children contain dynamic expressions. Manually convert to the options prop format: options={[{ value: "...", label: "..." }]}',
205
+ })
206
+ return true
207
+ }
208
+
209
+ // Check for Select.Value or Select.Inline children
210
+ const hasUnsupportedSubcomponents = children.some(
211
+ (child: ChildNode) =>
212
+ isMemberExpression(child, localSelectName, "Value") ||
213
+ isMemberExpression(child, localSelectName, "Inline")
214
+ )
215
+
216
+ if (hasUnsupportedSubcomponents) {
217
+ addComment({
218
+ element,
219
+ j,
220
+ scope: "children",
221
+ source,
222
+ text: "Select.Value and Select.Inline are not supported in the new Select. Manually convert children to the options prop format.",
223
+ })
224
+ return true
225
+ }
226
+
227
+ // Filter to meaningful children (skip whitespace-only JSXText)
228
+ const meaningfulChildren = children.filter((child: ChildNode) => {
229
+ if (child.type === "JSXText") return child.value.trim().length > 0
230
+ return true
231
+ })
232
+
233
+ // Attempt to convert all children to option objects
234
+ const optionObjects: ReturnType<typeof convertOptionToObjectExpression>[] =
235
+ []
236
+ let allConvertible = true
237
+
238
+ for (const child of meaningfulChildren) {
239
+ if (isMemberExpression(child, localSelectName, "Option")) {
240
+ const obj = convertOptionToObjectExpression(child, j)
241
+ if (obj === null) {
242
+ allConvertible = false
243
+ break
244
+ }
245
+ optionObjects.push(obj)
246
+ } else if (isMemberExpression(child, localSelectName, "OptionGroup")) {
247
+ const obj = convertOptionGroupToObjectExpression(
248
+ child,
249
+ localSelectName,
250
+ j
251
+ )
252
+ if (obj === null) {
253
+ allConvertible = false
254
+ break
255
+ }
256
+ optionObjects.push(obj)
257
+ } else {
258
+ // Unknown child type
259
+ allConvertible = false
260
+ break
261
+ }
262
+ }
263
+
264
+ if (!allConvertible || optionObjects.length === 0) {
265
+ addComment({
266
+ element,
267
+ j,
268
+ scope: "children",
269
+ source,
270
+ text: 'Select children could not be automatically converted. Manually convert to the options prop format: options={[{ value: "...", label: "..." }]}',
271
+ })
272
+ return true
273
+ }
274
+
275
+ // Build the options prop
276
+ const optionsAttr = j.jsxAttribute(
277
+ j.jsxIdentifier("options"),
278
+ j.jsxExpressionContainer(j.arrayExpression(optionObjects))
279
+ )
280
+
281
+ element.openingElement.attributes = element.openingElement.attributes || []
282
+ element.openingElement.attributes.push(optionsAttr)
283
+
284
+ // Remove children
285
+ removeChildren(element)
286
+
287
+ // Clean up unused Select subcomponent usage
288
+ // (the import cleanup happens in moveSelectImport or will be handled
289
+ // by removeUnusedImport if Select.Option/etc. are no longer used)
290
+
291
+ return true
292
+ },
293
+ })
294
+
295
+ export default transform
@@ -0,0 +1,150 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import transform from "./convertLegacyOptions"
5
+
6
+ const j = jscodeshift.withParser("tsx")
7
+
8
+ function applyTransform(source: string): string {
9
+ const fileInfo = { path: "test.tsx", source }
10
+ const result = transform(
11
+ fileInfo,
12
+ { j, jscodeshift: j, report: () => {}, stats: () => {} },
13
+ {}
14
+ ) as string | null
15
+ return result || source
16
+ }
17
+
18
+ describe("convertLegacyOptions transform", () => {
19
+ describe("optgroup to options key rename", () => {
20
+ it("should rename optgroup key to options in static array", () => {
21
+ const input = `
22
+ import { Select } from "@planningcenter/tapestry-react"
23
+
24
+ function Test() {
25
+ return (
26
+ <Select
27
+ emptyValue="Pick"
28
+ options={[
29
+ { label: "Group", optgroup: [{ label: "A", value: "a" }] }
30
+ ]}
31
+ />
32
+ )
33
+ }
34
+ `.trim()
35
+
36
+ const result = applyTransform(input)
37
+ expect(result).toContain("options: [")
38
+ expect(result).not.toContain("optgroup")
39
+ })
40
+
41
+ it("should handle mixed flat options and groups", () => {
42
+ const input = `
43
+ import { Select } from "@planningcenter/tapestry-react"
44
+
45
+ function Test() {
46
+ return (
47
+ <Select
48
+ emptyValue="Pick"
49
+ options={[
50
+ { label: "Standalone", value: "s" },
51
+ { label: "Group", optgroup: [{ label: "A", value: "a" }] }
52
+ ]}
53
+ />
54
+ )
55
+ }
56
+ `.trim()
57
+
58
+ const result = applyTransform(input)
59
+ expect(result).toContain("options: [")
60
+ expect(result).not.toContain("optgroup")
61
+ expect(result).toContain('value: "s"')
62
+ })
63
+
64
+ it("should handle multiple groups", () => {
65
+ const input = `
66
+ import { Select } from "@planningcenter/tapestry-react"
67
+
68
+ function Test() {
69
+ return (
70
+ <Select
71
+ emptyValue="Pick"
72
+ options={[
73
+ { label: "Group 1", optgroup: [{ label: "A", value: "a" }] },
74
+ { label: "Group 2", optgroup: [{ label: "B", value: "b" }] }
75
+ ]}
76
+ />
77
+ )
78
+ }
79
+ `.trim()
80
+
81
+ const result = applyTransform(input)
82
+ expect(result).not.toContain("optgroup")
83
+ const optionsMatches = result.match(/options: \[/g)
84
+ expect(optionsMatches).toHaveLength(2)
85
+ })
86
+ })
87
+
88
+ describe("no-op cases", () => {
89
+ it("should not transform options without optgroup key", () => {
90
+ const input = `
91
+ import { Select } from "@planningcenter/tapestry-react"
92
+
93
+ function Test() {
94
+ return (
95
+ <Select
96
+ emptyValue="Pick"
97
+ options={[
98
+ { label: "Apple", value: "apple" },
99
+ { label: "Orange", value: "orange" }
100
+ ]}
101
+ />
102
+ )
103
+ }
104
+ `.trim()
105
+
106
+ const result = applyTransform(input)
107
+ expect(result).toBe(input)
108
+ })
109
+
110
+ it("should not transform Select without options prop", () => {
111
+ const input = `
112
+ import { Select } from "@planningcenter/tapestry-react"
113
+
114
+ function Test() {
115
+ return <Select emptyValue="Pick" />
116
+ }
117
+ `.trim()
118
+
119
+ const result = applyTransform(input)
120
+ expect(result).toBe(input)
121
+ })
122
+
123
+ it("should not transform other components", () => {
124
+ const input = `
125
+ import { Button } from "@planningcenter/tapestry-react"
126
+
127
+ function Test() {
128
+ return <Button options={[{ optgroup: [] }]}>Click</Button>
129
+ }
130
+ `.trim()
131
+
132
+ const result = applyTransform(input)
133
+ expect(result).toBe(input)
134
+ })
135
+
136
+ it("should add comment for dynamic options variable", () => {
137
+ const input = `
138
+ import { Select } from "@planningcenter/tapestry-react"
139
+
140
+ function Test() {
141
+ return <Select emptyValue="Pick" options={dynamicOptions} />
142
+ }
143
+ `.trim()
144
+
145
+ const result = applyTransform(input)
146
+ expect(result).toContain("TODO: tapestry-migration")
147
+ expect(result).toContain("options format has changed")
148
+ })
149
+ })
150
+ })
@@ -0,0 +1,105 @@
1
+ import { JSXAttribute, ObjectExpression, Transform } from "jscodeshift"
2
+
3
+ import { addCommentToAttribute } from "../../shared/actions/addCommentToAttribute"
4
+ import { andConditions } from "../../shared/conditions/andConditions"
5
+ import { hasAttribute } from "../../shared/conditions/hasAttribute"
6
+ import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
7
+ import { transformableSelect } from "../transformableSelect"
8
+
9
+ /**
10
+ * Checks if an ObjectExpression has an `optgroup` property (legacy grouped options format).
11
+ */
12
+ function hasOptgroupProperty(obj: ObjectExpression): boolean {
13
+ return obj.properties.some(
14
+ (prop) =>
15
+ prop.type === "ObjectProperty" &&
16
+ prop.key.type === "Identifier" &&
17
+ prop.key.name === "optgroup"
18
+ )
19
+ }
20
+
21
+ /**
22
+ * Renames the `optgroup` key to `options` in a legacy option group object.
23
+ * Legacy: { label: "Group", optgroup: [...] }
24
+ * New: { label: "Group", options: [...] }
25
+ */
26
+ function renameOptgroupToOptions(obj: ObjectExpression): boolean {
27
+ let changed = false
28
+ for (const prop of obj.properties) {
29
+ if (
30
+ prop.type === "ObjectProperty" &&
31
+ prop.key.type === "Identifier" &&
32
+ prop.key.name === "optgroup"
33
+ ) {
34
+ prop.key.name = "options"
35
+ changed = true
36
+ }
37
+ }
38
+ return changed
39
+ }
40
+
41
+ const transform: Transform = attributeTransformFactory({
42
+ condition: andConditions(transformableSelect, hasAttribute("options")),
43
+ targetComponent: "Select",
44
+ targetPackage: "@planningcenter/tapestry-react",
45
+ transform: (element, { j }) => {
46
+ const attrs = element.openingElement.attributes || []
47
+ const optionsAttr = attrs.find(
48
+ (attr): attr is JSXAttribute =>
49
+ attr.type === "JSXAttribute" && (attr.name.name as string) === "options"
50
+ )
51
+
52
+ if (!optionsAttr || !optionsAttr.value) return false
53
+
54
+ // Only handle static array literals
55
+ let arrayExpr
56
+ if (
57
+ optionsAttr.value.type === "JSXExpressionContainer" &&
58
+ optionsAttr.value.expression.type === "ArrayExpression"
59
+ ) {
60
+ arrayExpr = optionsAttr.value.expression
61
+ } else if (optionsAttr.value.type === "JSXExpressionContainer") {
62
+ // Dynamic value — flag for manual review if it's not an array literal
63
+ const expr = optionsAttr.value.expression
64
+ if (
65
+ expr.type !== "ArrayExpression" &&
66
+ expr.type !== "JSXEmptyExpression"
67
+ ) {
68
+ addCommentToAttribute({
69
+ attribute: optionsAttr,
70
+ j,
71
+ text: "The options format has changed. Legacy optgroup arrays should use the 'options' key instead of 'optgroup'. Review and update manually.",
72
+ })
73
+ return true
74
+ }
75
+ return false
76
+ } else {
77
+ return false
78
+ }
79
+
80
+ // Check if any element in the array uses the legacy `optgroup` key
81
+ let hasLegacyFormat = false
82
+ for (const el of arrayExpr.elements) {
83
+ if (el && el.type === "ObjectExpression" && hasOptgroupProperty(el)) {
84
+ hasLegacyFormat = true
85
+ break
86
+ }
87
+ }
88
+
89
+ if (!hasLegacyFormat) return false
90
+
91
+ // Rename `optgroup` → `options` in each group object
92
+ let changed = false
93
+ for (const el of arrayExpr.elements) {
94
+ if (el && el.type === "ObjectExpression") {
95
+ if (renameOptgroupToOptions(el)) {
96
+ changed = true
97
+ }
98
+ }
99
+ }
100
+
101
+ return changed
102
+ },
103
+ })
104
+
105
+ export default transform
@@ -0,0 +1,73 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import transform from "./convertStyleProps"
5
+
6
+ const j = jscodeshift.withParser("tsx")
7
+
8
+ function applyTransform(source: string, options = {}) {
9
+ const fileInfo = { path: "test.tsx", source }
10
+ const result = transform(
11
+ fileInfo,
12
+ { j, jscodeshift: j, report: () => {}, stats: () => {} },
13
+ options
14
+ ) as string | null
15
+ return result || source
16
+ }
17
+
18
+ describe("convertStyleProps transform", () => {
19
+ it("should convert visible={false} to display: none", () => {
20
+ const source = `
21
+ import { Select } from "@planningcenter/tapestry-react"
22
+
23
+ export function TestComponent() {
24
+ return <Select visible={false} label="Test" placeholder="Pick" options={[]} />
25
+ }
26
+ `
27
+
28
+ const result = applyTransform(source)
29
+ expect(result).toContain("style={{")
30
+ expect(result).toContain('display: "none"')
31
+ expect(result).not.toContain("visible={false}")
32
+ })
33
+
34
+ it("should remove visible={true} with no style changes", () => {
35
+ const source = `
36
+ import { Select } from "@planningcenter/tapestry-react"
37
+
38
+ export function TestComponent() {
39
+ return <Select visible={true} label="Test" placeholder="Pick" options={[]} />
40
+ }
41
+ `
42
+
43
+ const result = applyTransform(source)
44
+ expect(result).not.toContain("visible={true}")
45
+ expect(result).not.toContain("style={{")
46
+ })
47
+
48
+ it("should remove style props like marginTop", () => {
49
+ const source = `
50
+ import { Select } from "@planningcenter/tapestry-react"
51
+
52
+ export function TestComponent() {
53
+ return <Select marginTop={16} label="Test" placeholder="Pick" options={[]} />
54
+ }
55
+ `
56
+
57
+ const result = applyTransform(source)
58
+ expect(result).not.toContain("marginTop=")
59
+ })
60
+
61
+ it("should not affect non-tapestry-react imports", () => {
62
+ const source = `
63
+ import { Select } from "other-library"
64
+
65
+ export function TestComponent() {
66
+ return <Select marginTop={16} label="Test" />
67
+ }
68
+ `
69
+
70
+ const result = applyTransform(source)
71
+ expect(result).toContain("marginTop={16}")
72
+ })
73
+ })
@@ -0,0 +1,12 @@
1
+ import { stackViewPlugin } from "../../../stubs/stackViewPlugin"
2
+ import { stylePropTransformFactory } from "../../shared/transformFactories/stylePropTransformFactory"
3
+ import { transformableSelect } from "../transformableSelect"
4
+
5
+ export default stylePropTransformFactory({
6
+ condition: transformableSelect,
7
+ plugin: stackViewPlugin,
8
+ stylesToKeep: ["visible"],
9
+ stylesToRemove: [],
10
+ targetComponent: "Select",
11
+ targetPackage: "@planningcenter/tapestry-react",
12
+ })