@planningcenter/tapestry-migration-cli 3.1.0-rc.2 → 3.1.0-rc.21

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 (115) hide show
  1. package/dist/tapestry-react-shim.cjs +7 -1
  2. package/package.json +3 -3
  3. package/src/components/button/transforms/convertStyleProps.test.ts +97 -0
  4. package/src/components/button/transforms/removeTypeButton.test.ts +0 -1
  5. package/src/components/checkbox/transforms/moveCheckboxImport.test.ts +3 -0
  6. package/src/components/input/index.ts +66 -0
  7. package/src/components/input/transformableInput.ts +49 -0
  8. package/src/components/input/transforms/auditSpreadProps.test.ts +192 -0
  9. package/src/components/input/transforms/auditSpreadProps.ts +26 -0
  10. package/src/components/input/transforms/autoWidthTransform.test.ts +172 -0
  11. package/src/components/input/transforms/autoWidthTransform.ts +41 -0
  12. package/src/components/input/transforms/convertStyleProps.test.ts +128 -0
  13. package/src/components/input/transforms/convertStyleProps.ts +12 -0
  14. package/src/components/input/transforms/highlightOnInteractionToSelectTextOnFocus.test.ts +186 -0
  15. package/src/components/input/transforms/highlightOnInteractionToSelectTextOnFocus.ts +27 -0
  16. package/src/components/input/transforms/inputLabelToLabelProp.test.ts +319 -0
  17. package/src/components/input/transforms/inputLabelToLabelProp.ts +203 -0
  18. package/src/components/input/transforms/mergeFieldIntoInput.test.ts +469 -0
  19. package/src/components/input/transforms/mergeFieldIntoInput.ts +7 -0
  20. package/src/components/input/transforms/mergeInputLabel.test.ts +458 -0
  21. package/src/components/input/transforms/mergeInputLabel.ts +204 -0
  22. package/src/components/input/transforms/moveInputImport.test.ts +166 -0
  23. package/src/components/input/transforms/moveInputImport.ts +14 -0
  24. package/src/components/input/transforms/numberFieldAddTypeNumber.test.ts +92 -0
  25. package/src/components/input/transforms/numberFieldAddTypeNumber.ts +14 -0
  26. package/src/components/input/transforms/numberFieldRenameToInput.test.ts +126 -0
  27. package/src/components/input/transforms/numberFieldRenameToInput.ts +9 -0
  28. package/src/components/input/transforms/removeAsInput.test.ts +139 -0
  29. package/src/components/input/transforms/removeAsInput.ts +20 -0
  30. package/src/components/input/transforms/removeDuplicateKeys.test.ts +302 -0
  31. package/src/components/input/transforms/removeDuplicateKeys.ts +10 -0
  32. package/src/components/input/transforms/removeInputBox.test.ts +352 -0
  33. package/src/components/input/transforms/removeInputBox.ts +109 -0
  34. package/src/components/input/transforms/removeRedundantAriaLabel.test.ts +128 -0
  35. package/src/components/input/transforms/removeRedundantAriaLabel.ts +21 -0
  36. package/src/components/input/transforms/removeTypeInput.test.ts +212 -0
  37. package/src/components/input/transforms/removeTypeInput.ts +22 -0
  38. package/src/components/input/transforms/removeTypeText.test.ts +160 -0
  39. package/src/components/input/transforms/removeTypeText.ts +17 -0
  40. package/src/components/input/transforms/sizeMapping.test.ts +198 -0
  41. package/src/components/input/transforms/sizeMapping.ts +17 -0
  42. package/src/components/input/transforms/skipRenderSideProps.test.ts +236 -0
  43. package/src/components/input/transforms/skipRenderSideProps.ts +27 -0
  44. package/src/components/input/transforms/stateToInvalid.test.ts +208 -0
  45. package/src/components/input/transforms/stateToInvalid.ts +59 -0
  46. package/src/components/input/transforms/stateToInvalidTernary.test.ts +159 -0
  47. package/src/components/input/transforms/stateToInvalidTernary.ts +13 -0
  48. package/src/components/input/transforms/unsupportedProps.test.ts +566 -0
  49. package/src/components/input/transforms/unsupportedProps.ts +84 -0
  50. package/src/components/link/transforms/reviewStyles.test.ts +0 -1
  51. package/src/components/select/index.ts +58 -0
  52. package/src/components/select/transformableSelect.ts +7 -0
  53. package/src/components/select/transforms/auditSpreadProps.test.ts +103 -0
  54. package/src/components/select/transforms/auditSpreadProps.ts +26 -0
  55. package/src/components/select/transforms/childrenToOptions.test.ts +367 -0
  56. package/src/components/select/transforms/childrenToOptions.ts +295 -0
  57. package/src/components/select/transforms/convertLegacyOptions.test.ts +150 -0
  58. package/src/components/select/transforms/convertLegacyOptions.ts +105 -0
  59. package/src/components/select/transforms/convertStyleProps.test.ts +73 -0
  60. package/src/components/select/transforms/convertStyleProps.ts +12 -0
  61. package/src/components/select/transforms/emptyValueToPlaceholder.test.ts +122 -0
  62. package/src/components/select/transforms/emptyValueToPlaceholder.ts +22 -0
  63. package/src/components/select/transforms/innerRefToRef.test.ts +89 -0
  64. package/src/components/select/transforms/innerRefToRef.ts +18 -0
  65. package/src/components/select/transforms/mapChildrenToOptions.test.ts +521 -0
  66. package/src/components/select/transforms/mapChildrenToOptions.ts +312 -0
  67. package/src/components/select/transforms/mergeFieldIntoSelect.test.ts +506 -0
  68. package/src/components/select/transforms/mergeFieldIntoSelect.ts +7 -0
  69. package/src/components/select/transforms/mergeSelectLabel.test.ts +458 -0
  70. package/src/components/select/transforms/mergeSelectLabel.ts +225 -0
  71. package/src/components/select/transforms/moveSelectImport.test.ts +148 -0
  72. package/src/components/select/transforms/moveSelectImport.ts +14 -0
  73. package/src/components/select/transforms/removeDefaultProps.test.ts +249 -0
  74. package/src/components/select/transforms/removeDefaultProps.ts +112 -0
  75. package/src/components/select/transforms/sizeMapping.test.ts +188 -0
  76. package/src/components/select/transforms/sizeMapping.ts +17 -0
  77. package/src/components/select/transforms/skipMultipleSelect.test.ts +148 -0
  78. package/src/components/select/transforms/skipMultipleSelect.ts +23 -0
  79. package/src/components/select/transforms/stateToInvalid.test.ts +217 -0
  80. package/src/components/select/transforms/stateToInvalid.ts +59 -0
  81. package/src/components/select/transforms/stateToInvalidTernary.test.ts +146 -0
  82. package/src/components/select/transforms/stateToInvalidTernary.ts +13 -0
  83. package/src/components/select/transforms/unsupportedProps.test.ts +252 -0
  84. package/src/components/select/transforms/unsupportedProps.ts +44 -0
  85. package/src/components/shared/helpers/getAttributeExpression.ts +26 -0
  86. package/src/components/shared/helpers/unsupportedPropsHelpers.ts +102 -0
  87. package/src/components/shared/transformFactories/helpers/manageImports.ts +14 -12
  88. package/src/components/shared/transformFactories/mergeFieldFactory.ts +244 -0
  89. package/src/components/shared/transformFactories/sizeMappingFactory.ts +9 -2
  90. package/src/components/shared/transformFactories/stylePropTransformFactory.ts +56 -17
  91. package/src/components/shared/transformFactories/ternaryConditionalToPropFactory.ts +65 -0
  92. package/src/components/text-area/index.ts +48 -0
  93. package/src/components/text-area/transforms/auditSpreadProps.test.ts +139 -0
  94. package/src/components/text-area/transforms/auditSpreadProps.ts +10 -0
  95. package/src/components/text-area/transforms/convertStyleProps.test.ts +158 -0
  96. package/src/components/text-area/transforms/convertStyleProps.ts +10 -0
  97. package/src/components/text-area/transforms/innerRefToRef.test.ts +206 -0
  98. package/src/components/text-area/transforms/innerRefToRef.ts +14 -0
  99. package/src/components/text-area/transforms/mergeFieldIntoTextArea.test.ts +477 -0
  100. package/src/components/text-area/transforms/mergeFieldIntoTextArea.ts +5 -0
  101. package/src/components/text-area/transforms/moveTextAreaImport.test.ts +168 -0
  102. package/src/components/text-area/transforms/moveTextAreaImport.ts +13 -0
  103. package/src/components/text-area/transforms/removeDuplicateKeys.test.ts +129 -0
  104. package/src/components/text-area/transforms/removeDuplicateKeys.ts +8 -0
  105. package/src/components/text-area/transforms/removeRedundantAriaLabel.test.ts +183 -0
  106. package/src/components/text-area/transforms/removeRedundantAriaLabel.ts +59 -0
  107. package/src/components/text-area/transforms/sizeMapping.test.ts +199 -0
  108. package/src/components/text-area/transforms/sizeMapping.ts +15 -0
  109. package/src/components/text-area/transforms/stateToInvalid.test.ts +204 -0
  110. package/src/components/text-area/transforms/stateToInvalid.ts +57 -0
  111. package/src/components/text-area/transforms/stateToInvalidTernary.test.ts +133 -0
  112. package/src/components/text-area/transforms/stateToInvalidTernary.ts +11 -0
  113. package/src/components/text-area/transforms/unsupportedProps.test.ts +275 -0
  114. package/src/components/text-area/transforms/unsupportedProps.ts +35 -0
  115. package/src/index.ts +2 -1
@@ -0,0 +1,244 @@
1
+ import { JSXElement, JSXText, Transform } from "jscodeshift"
2
+
3
+ import { addComment } from "../actions/addComment"
4
+ import { TransformCondition } from "../types"
5
+ import {
6
+ getImportName,
7
+ removeImportFromDeclaration,
8
+ } from "./helpers/manageImports"
9
+
10
+ interface MergeFieldFactoryOptions {
11
+ condition?: TransformCondition
12
+ targetComponent: string
13
+ }
14
+
15
+ export function mergeFieldFactory({
16
+ condition,
17
+ targetComponent,
18
+ }: MergeFieldFactoryOptions): Transform {
19
+ const SCOPE = `mergeFieldInto${targetComponent}`
20
+
21
+ const transform: Transform = (fileInfo, api) => {
22
+ const j = api.jscodeshift
23
+ const source = j(fileInfo.source)
24
+
25
+ const fieldLocalName = getImportName(
26
+ "Field",
27
+ "@planningcenter/tapestry-react",
28
+ { j, source }
29
+ )
30
+ if (!fieldLocalName) return null
31
+
32
+ const targetLocalName = getImportName(
33
+ targetComponent,
34
+ "@planningcenter/tapestry-react",
35
+ { j, source }
36
+ )
37
+ if (!targetLocalName) return null
38
+
39
+ let hasChanges = false
40
+ let anyFieldRemoved = false
41
+
42
+ source.find(j.JSXElement).forEach((path) => {
43
+ const el = path.value
44
+ const opening = el.openingElement
45
+
46
+ if (opening.name.type !== "JSXIdentifier") return
47
+ if (opening.name.name !== fieldLocalName) return
48
+
49
+ const elementChildren = (el.children || []).filter(
50
+ (child) =>
51
+ child.type !== "JSXText" || (child as JSXText).value.trim() !== ""
52
+ )
53
+
54
+ const targetChildren = elementChildren.filter((child) => {
55
+ if (child.type !== "JSXElement") return false
56
+ const childOpening = (child as JSXElement).openingElement
57
+ return (
58
+ childOpening.name.type === "JSXIdentifier" &&
59
+ childOpening.name.name === targetLocalName
60
+ )
61
+ }) as JSXElement[]
62
+
63
+ // Case: exactly 1 child and it is the target — merge props and unwrap
64
+ if (elementChildren.length === 1 && targetChildren.length === 1) {
65
+ const targetEl = targetChildren[0]
66
+
67
+ // Skip if condition fails (e.g. Input with renderLeft/renderRight, Select with multiple)
68
+ if (condition && !condition(targetEl)) return
69
+
70
+ const fieldAttrs = opening.attributes || []
71
+
72
+ // Bail out if Field has spread props — we can't know what they contain
73
+ const hasFieldSpreads = fieldAttrs.some(
74
+ (attr) => attr.type === "JSXSpreadAttribute"
75
+ )
76
+ if (hasFieldSpreads) {
77
+ addComment({
78
+ element: targetEl,
79
+ j,
80
+ scope: SCOPE,
81
+ source,
82
+ text: `Field has spread props that cannot be auto-merged into ${targetComponent}. Please migrate manually.`,
83
+ })
84
+ hasChanges = true
85
+ return
86
+ }
87
+
88
+ for (const attr of fieldAttrs) {
89
+ if (attr.type !== "JSXAttribute") continue
90
+ if (attr.name.type !== "JSXIdentifier") continue
91
+
92
+ const attrName = attr.name.name
93
+ const targetAttrs = targetEl.openingElement.attributes || []
94
+
95
+ if (attrName === "label") {
96
+ const hasLabel = targetAttrs.some(
97
+ (a) =>
98
+ a.type === "JSXAttribute" &&
99
+ a.name?.type === "JSXIdentifier" &&
100
+ a.name.name === "label"
101
+ )
102
+ if (hasLabel) {
103
+ addComment({
104
+ element: targetEl,
105
+ j,
106
+ scope: SCOPE,
107
+ source,
108
+ text: `Field had label prop but ${targetComponent} already has label. Please migrate manually.`,
109
+ })
110
+ } else {
111
+ targetEl.openingElement.attributes.push(attr)
112
+ }
113
+ } else if (attrName === "feedbackText") {
114
+ const hasDescription = targetAttrs.some(
115
+ (a) =>
116
+ a.type === "JSXAttribute" &&
117
+ a.name?.type === "JSXIdentifier" &&
118
+ a.name.name === "description"
119
+ )
120
+ if (hasDescription) {
121
+ addComment({
122
+ element: targetEl,
123
+ j,
124
+ scope: SCOPE,
125
+ source,
126
+ text: `Field had feedbackText prop but ${targetComponent} already has description. Please migrate manually.`,
127
+ })
128
+ } else {
129
+ const newAttr = j.jsxAttribute(
130
+ j.jsxIdentifier("description"),
131
+ attr.value
132
+ )
133
+ targetEl.openingElement.attributes.push(newAttr)
134
+ }
135
+ } else if (attrName === "state") {
136
+ const hasState = targetAttrs.some(
137
+ (a) =>
138
+ a.type === "JSXAttribute" &&
139
+ a.name?.type === "JSXIdentifier" &&
140
+ a.name.name === "state"
141
+ )
142
+ if (hasState) {
143
+ addComment({
144
+ element: targetEl,
145
+ j,
146
+ scope: SCOPE,
147
+ source,
148
+ text: `Field had state prop but ${targetComponent} already has state. Please migrate manually.`,
149
+ })
150
+ } else {
151
+ targetEl.openingElement.attributes.push(attr)
152
+ }
153
+ } else if (attrName === "key") {
154
+ const hasKey = targetAttrs.some(
155
+ (a) =>
156
+ a.type === "JSXAttribute" &&
157
+ a.name?.type === "JSXIdentifier" &&
158
+ a.name.name === "key"
159
+ )
160
+ if (!hasKey) {
161
+ targetEl.openingElement.attributes.push(attr)
162
+ }
163
+ } else {
164
+ // Unsupported prop — add comment to target
165
+ addComment({
166
+ element: targetEl,
167
+ j,
168
+ scope: SCOPE,
169
+ source,
170
+ text: `Field prop '${attrName}' is not supported by ${targetComponent}. Please migrate manually.`,
171
+ })
172
+ }
173
+ }
174
+
175
+ const parent = path.parent?.value
176
+ if (parent?.children) {
177
+ const idx = parent.children.indexOf(el)
178
+ if (idx === -1) return
179
+ parent.children.splice(idx, 1, ...(el.children || []))
180
+ } else {
181
+ // Root JSX (e.g. directly inside return parens) — use path.replace
182
+ const nonWsChildren = (el.children || []).filter(
183
+ (child) =>
184
+ child.type !== "JSXText" || (child as JSXText).value.trim() !== ""
185
+ )
186
+ if (nonWsChildren.length === 1) {
187
+ path.replace(nonWsChildren[0])
188
+ } else {
189
+ path.replace(
190
+ j.jsxFragment(
191
+ j.jsxOpeningFragment(),
192
+ j.jsxClosingFragment(),
193
+ el.children || []
194
+ )
195
+ )
196
+ }
197
+ }
198
+ hasChanges = true
199
+ anyFieldRemoved = true
200
+ return
201
+ }
202
+
203
+ // Case: more than 1 non-whitespace child — comment each target child, leave Field
204
+ if (elementChildren.length > 1) {
205
+ for (const child of targetChildren) {
206
+ // Skip if condition fails
207
+ if (condition && !condition(child)) continue
208
+ addComment({
209
+ element: child,
210
+ j,
211
+ scope: SCOPE,
212
+ source,
213
+ text: `Field has multiple children and cannot be auto-merged into ${targetComponent}. Please migrate manually.`,
214
+ })
215
+ hasChanges = true
216
+ }
217
+ return
218
+ }
219
+
220
+ // Case: exactly 1 child but not the target — skip without comment
221
+ })
222
+
223
+ // Remove Field from imports only if all Field usages were converted
224
+ if (anyFieldRemoved) {
225
+ const stillUsesField =
226
+ source.find(j.JSXOpeningElement, {
227
+ name: { name: fieldLocalName },
228
+ }).length > 0
229
+
230
+ if (!stillUsesField) {
231
+ const fieldImports = source.find(j.ImportDeclaration, {
232
+ source: { value: "@planningcenter/tapestry-react" },
233
+ })
234
+ for (let i = 0; i < fieldImports.length; i++) {
235
+ removeImportFromDeclaration(fieldImports.at(i), "Field")
236
+ }
237
+ }
238
+ }
239
+
240
+ return hasChanges ? source.toSource() : null
241
+ }
242
+
243
+ return transform
244
+ }
@@ -2,7 +2,9 @@ import { Transform } from "jscodeshift"
2
2
 
3
3
  import { addCommentToAttribute } from "../actions/addCommentToAttribute"
4
4
  import { getAttributeValue } from "../actions/getAttributeValue"
5
+ import { andConditions } from "../conditions/andConditions"
5
6
  import { hasAttribute } from "../conditions/hasAttribute"
7
+ import { TransformCondition } from "../types"
6
8
  import { attributeTransformFactory } from "./attributeTransformFactory"
7
9
 
8
10
  /**
@@ -13,16 +15,21 @@ import { attributeTransformFactory } from "./attributeTransformFactory"
13
15
  * @param options.targetComponent - The component name to target (e.g., "ToggleSwitch", "Radio")
14
16
  * @param options.targetPackage - The package the component is imported from
15
17
  * @param options.sizeMapping - Object mapping old size values to new size values (e.g., { xs: "sm", lg: "md" })
18
+ * @param options.condition - Optional additional condition beyond hasAttribute("size")
16
19
  * @returns A Transform function that maps size attributes
17
20
  */
18
21
  export function sizeMappingFactory(options: {
22
+ condition?: TransformCondition
19
23
  sizeMapping: Record<string, string>
20
24
  targetComponent: string
21
25
  targetPackage: string
22
26
  }): Transform {
23
- const { sizeMapping, ...restOptions } = options
27
+ const { condition, sizeMapping, ...restOptions } = options
28
+ const baseCondition = hasAttribute("size")
24
29
  return attributeTransformFactory({
25
- condition: hasAttribute("size"),
30
+ condition: condition
31
+ ? andConditions(baseCondition, condition)
32
+ : baseCondition,
26
33
  ...restOptions,
27
34
  transform: (element, { j }) => {
28
35
  let hasChanges = false
@@ -16,6 +16,7 @@ import { addComment } from "../../shared/actions/addComment"
16
16
  import { addCommentToAttribute } from "../../shared/actions/addCommentToAttribute"
17
17
  import { getAttribute } from "../../shared/actions/getAttribute"
18
18
  import { removeAttribute } from "../../shared/actions/removeAttribute"
19
+ import { TransformCondition } from "../types"
19
20
  import { attributeTransformFactory } from "./attributeTransformFactory"
20
21
 
21
22
  type StylePropMapping = Record<
@@ -26,16 +27,12 @@ type StylePropMapping = Record<
26
27
  }
27
28
  >
28
29
 
29
- // Helper function to extract prop value from JSX attribute
30
30
  function extractPropValue(attr: JSXAttribute, j: JSCodeshift) {
31
31
  if (!attr.value) {
32
- // Boolean prop like <Button disabled />
33
32
  return true
34
33
  } else if (attr.value.type === "StringLiteral") {
35
- // String literal like color="blue"
36
34
  return attr.value.value
37
35
  } else if (attr.value.type === "JSXExpressionContainer") {
38
- // Expression like color={someVar} or color={5}
39
36
  const expression = attr.value.expression
40
37
  if (expression.type === "StringLiteral") {
41
38
  return expression.value
@@ -44,14 +41,11 @@ function extractPropValue(attr: JSXAttribute, j: JSCodeshift) {
44
41
  } else if (expression.type === "BooleanLiteral") {
45
42
  return expression.value
46
43
  } else if (expression.type === "Identifier") {
47
- // Variable reference - we'll use the variable name as a placeholder
48
44
  return `{${expression.name}}`
49
45
  } else {
50
- // Complex expression - convert back to string representation
51
46
  return `{${j(expression).toSource()}}`
52
47
  }
53
48
  } else {
54
- // Fallback for other types
55
49
  return j(attr.value).toSource()
56
50
  }
57
51
  }
@@ -77,7 +71,6 @@ function processKeepStyleProps({
77
71
  const propName = attr.name.name as string
78
72
  const propValue = extractPropValue(attr, j)
79
73
 
80
- // If it's a complex expression (starts and ends with braces), handle it directly
81
74
  if (
82
75
  typeof propValue === "string" &&
83
76
  propValue.startsWith("{") &&
@@ -118,7 +111,6 @@ function processRemoveStyleProps({
118
111
  })
119
112
  }
120
113
 
121
- // Process props that need style property mappings
122
114
  function processStylePropMappings({
123
115
  attributes,
124
116
  j,
@@ -175,6 +167,7 @@ function applyStylesToComponent({
175
167
  }: {
176
168
  element: JSXElement
177
169
  j: JSCodeshift
170
+ source: Collection
178
171
  styles: Record<string, unknown>
179
172
  }) {
180
173
  const styleAttr = getAttribute({ element, name: "style" })
@@ -187,11 +180,9 @@ function applyStylesToComponent({
187
180
  value.startsWith("{") &&
188
181
  value.endsWith("}")
189
182
  ) {
190
- // This is a complex expression wrapped in braces - parse it as JS
191
- const expressionCode = value.slice(1, -1) // Remove surrounding braces
183
+ const expressionCode = value.slice(1, -1)
192
184
  try {
193
185
  const parsed = j(expressionCode)
194
- // Get the first expression from the program body
195
186
  const firstStatement = parsed.find(j.Program).get("body", 0).value
196
187
  if (firstStatement?.type === "ExpressionStatement") {
197
188
  valueNode = firstStatement.expression
@@ -199,7 +190,6 @@ function applyStylesToComponent({
199
190
  valueNode = j.stringLiteral(value)
200
191
  }
201
192
  } catch {
202
- // If parsing fails, fall back to string literal
203
193
  valueNode = j.stringLiteral(value)
204
194
  }
205
195
  } else if (typeof value === "string") {
@@ -214,8 +204,56 @@ function applyStylesToComponent({
214
204
  )
215
205
 
216
206
  if (styleAttr && styleAttr.type === "JSXAttribute") {
217
- // TODO: ensure this adds to exisiting style object rather than replacing
218
- styleAttr.value = styleValue
207
+ const existingValue = styleAttr.value
208
+ if (
209
+ existingValue?.type === "JSXExpressionContainer" &&
210
+ existingValue.expression.type === "ObjectExpression"
211
+ ) {
212
+ // Case 1: Existing style is a static object literal — merge, new props win
213
+ const newProps = (
214
+ styleValue.expression as ReturnType<typeof j.objectExpression>
215
+ ).properties
216
+ const newKeys = new Set(
217
+ newProps
218
+ .filter((p) => p.type === "ObjectProperty")
219
+ .map((p) => {
220
+ const prop = p as ReturnType<typeof j.objectProperty>
221
+ if (prop.key.type === "Identifier") return prop.key.name
222
+ if (prop.key.type === "StringLiteral") return prop.key.value
223
+ return null
224
+ })
225
+ .filter(Boolean)
226
+ )
227
+ const survivingExisting = existingValue.expression.properties.filter(
228
+ (p) => {
229
+ if (p.type === "SpreadElement" || p.type === "RestElement")
230
+ return true
231
+ const prop = p as ReturnType<typeof j.objectProperty>
232
+ if (prop.key.type === "Identifier") return !newKeys.has(prop.key.name)
233
+ if (prop.key.type === "StringLiteral")
234
+ return !newKeys.has(prop.key.value)
235
+ return true
236
+ }
237
+ )
238
+ const mergedObject = j.objectExpression([
239
+ ...survivingExisting,
240
+ ...newProps,
241
+ ])
242
+ styleAttr.value = j.jsxExpressionContainer(mergedObject)
243
+ } else if (
244
+ existingValue?.type === "JSXExpressionContainer" &&
245
+ existingValue.expression.type !== "JSXEmptyExpression"
246
+ ) {
247
+ // Case 2: Dynamic expression — merge via spread: { ...existing, ...computed }
248
+ const newProps = (
249
+ styleValue.expression as ReturnType<typeof j.objectExpression>
250
+ ).properties
251
+ const mergedObject = j.objectExpression([
252
+ j.spreadElement(existingValue.expression),
253
+ ...newProps,
254
+ ])
255
+ styleAttr.value = j.jsxExpressionContainer(mergedObject)
256
+ }
219
257
  } else {
220
258
  const styleAttr = j.jsxAttribute(j.jsxIdentifier("style"), styleValue)
221
259
 
@@ -225,6 +263,7 @@ function applyStylesToComponent({
225
263
  }
226
264
 
227
265
  export function stylePropTransformFactory(config: {
266
+ condition?: TransformCondition
228
267
  plugin?: {
229
268
  getStyles: (props: Record<string, unknown>) => Record<string, unknown>
230
269
  styleProps: string[]
@@ -321,9 +360,8 @@ export function stylePropTransformFactory(config: {
321
360
  styles = { ...styles, ...directStyleProps }
322
361
  if (options.verbose) console.log("Final generated styles:", styles)
323
362
 
324
- // Only apply styles if there are actual CSS properties to add
325
363
  if (Object.keys(styles).length > 0) {
326
- applyStylesToComponent({ element, j, styles })
364
+ applyStylesToComponent({ element, j, source, styles })
327
365
 
328
366
  if (options.verbose) {
329
367
  const styleAttr = getAttribute({ element, name: "style" })
@@ -354,6 +392,7 @@ export function stylePropTransformFactory(config: {
354
392
  }
355
393
 
356
394
  return attributeTransformFactory({
395
+ condition: config.condition,
357
396
  targetComponent: config.targetComponent,
358
397
  targetPackage: config.targetPackage,
359
398
  transform,
@@ -0,0 +1,65 @@
1
+ import { Expression, Transform } from "jscodeshift"
2
+
3
+ import { getAttribute } from "../actions/getAttribute"
4
+ import { removeAttribute } from "../actions/removeAttribute"
5
+ import { TransformCondition } from "../types"
6
+ import { attributeTransformFactory } from "./attributeTransformFactory"
7
+
8
+ interface TernaryConditionalToPropConfig {
9
+ condition?: TransformCondition
10
+ fromProp: string
11
+ matchValue: string
12
+ targetComponent: string
13
+ targetPackage: string
14
+ toProp: string
15
+ }
16
+
17
+ export function ternaryConditionalToPropFactory({
18
+ condition,
19
+ fromProp,
20
+ matchValue,
21
+ targetComponent,
22
+ targetPackage,
23
+ toProp,
24
+ }: TernaryConditionalToPropConfig): Transform {
25
+ return attributeTransformFactory({
26
+ condition,
27
+ targetComponent,
28
+ targetPackage,
29
+ transform: (element, { j, source }) => {
30
+ const attr = getAttribute({ element, name: fromProp })
31
+ if (!attr) return false
32
+
33
+ if (attr.value?.type !== "JSXExpressionContainer") return false
34
+
35
+ const expr = attr.value.expression
36
+ if (expr.type !== "ConditionalExpression") return false
37
+
38
+ if (
39
+ expr.consequent.type !== "StringLiteral" ||
40
+ expr.consequent.value !== matchValue
41
+ ) {
42
+ return false
43
+ }
44
+
45
+ const isNullAlternate = expr.alternate.type === "NullLiteral"
46
+ const isUndefinedAlternate =
47
+ expr.alternate.type === "Identifier" &&
48
+ expr.alternate.name === "undefined"
49
+
50
+ if (!isNullAlternate && !isUndefinedAlternate) return false
51
+
52
+ const testExpr = expr.test as Expression
53
+
54
+ removeAttribute(fromProp, { element, j, source })
55
+ element.openingElement.attributes.push(
56
+ j.jsxAttribute(
57
+ j.jsxIdentifier(toProp),
58
+ j.jsxExpressionContainer(testExpr)
59
+ )
60
+ )
61
+
62
+ return true
63
+ },
64
+ })
65
+ }
@@ -0,0 +1,48 @@
1
+ import { Transform } from "jscodeshift"
2
+
3
+ import auditSpreadProps from "./transforms/auditSpreadProps"
4
+ import convertStyleProps from "./transforms/convertStyleProps"
5
+ import innerRefToRef from "./transforms/innerRefToRef"
6
+ import mergeFieldIntoTextArea from "./transforms/mergeFieldIntoTextArea"
7
+ import moveTextAreaImport from "./transforms/moveTextAreaImport"
8
+ import removeDuplicateKeys from "./transforms/removeDuplicateKeys"
9
+ import removeRedundantAriaLabel from "./transforms/removeRedundantAriaLabel"
10
+ import sizeMapping from "./transforms/sizeMapping"
11
+ import stateToInvalid from "./transforms/stateToInvalid"
12
+ import stateToInvalidTernary from "./transforms/stateToInvalidTernary"
13
+ import unsupportedProps from "./transforms/unsupportedProps"
14
+
15
+ const transform: Transform = (fileInfo, api, options) => {
16
+ let currentSource = fileInfo.source
17
+ let hasAnyChanges = false
18
+
19
+ const transforms = [
20
+ mergeFieldIntoTextArea,
21
+ auditSpreadProps,
22
+ innerRefToRef,
23
+ stateToInvalidTernary,
24
+ stateToInvalid,
25
+ sizeMapping,
26
+ convertStyleProps,
27
+ removeDuplicateKeys,
28
+ removeRedundantAriaLabel,
29
+ unsupportedProps,
30
+ moveTextAreaImport,
31
+ ]
32
+
33
+ for (const individualTransform of transforms) {
34
+ const result = individualTransform(
35
+ { ...fileInfo, source: currentSource },
36
+ api,
37
+ options
38
+ )
39
+ if (result && result !== currentSource) {
40
+ currentSource = result as string
41
+ hasAnyChanges = true
42
+ }
43
+ }
44
+
45
+ return hasAnyChanges ? currentSource : null
46
+ }
47
+
48
+ export default transform
@@ -0,0 +1,139 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import transform from "./auditSpreadProps"
5
+
6
+ const j = jscodeshift.withParser("tsx")
7
+
8
+ const AUDIT_COMMENT =
9
+ "TODO: tapestry-migration (spreadAttribute): Spread props can contain unsupported props, please explore usages and migrate as needed."
10
+
11
+ function applyTransform(source: string) {
12
+ const fileInfo = { path: "test.tsx", source }
13
+ return transform(
14
+ fileInfo,
15
+ { j, jscodeshift: j, report: () => {}, stats: () => {} },
16
+ {}
17
+ ) as string | null
18
+ }
19
+
20
+ describe("auditSpreadProps transform", () => {
21
+ describe("basic transformations", () => {
22
+ it("should add comment to TextArea with single spread prop", () => {
23
+ const input = `
24
+ import { TextArea } from "@planningcenter/tapestry-react"
25
+
26
+ export default function Test() {
27
+ const props = { onChange: handleChange }
28
+ return <TextArea {...props} label="Notes" />
29
+ }
30
+ `.trim()
31
+
32
+ const result = applyTransform(input)
33
+ expect(result).toContain(AUDIT_COMMENT)
34
+ expect(result).toContain("{...props}")
35
+ })
36
+
37
+ it("should add comment to TextArea with multiple spread props", () => {
38
+ const input = `
39
+ import { TextArea } from "@planningcenter/tapestry-react"
40
+
41
+ export default function Test() {
42
+ const baseProps = { onChange: handleChange }
43
+ const styleProps = { className: "textarea" }
44
+ return <TextArea {...baseProps} {...styleProps} label="Notes" />
45
+ }
46
+ `.trim()
47
+
48
+ const result = applyTransform(input)
49
+ expect(result).toContain(AUDIT_COMMENT)
50
+ expect(result).toContain("{...baseProps}")
51
+ expect(result).toContain("{...styleProps}")
52
+ })
53
+
54
+ it("should handle multiple TextArea components with spread props", () => {
55
+ const input = `
56
+ import { TextArea } from "@planningcenter/tapestry-react"
57
+
58
+ export default function Test() {
59
+ const props1 = { onChange: handleChange1 }
60
+ const props2 = { onChange: handleChange2 }
61
+ return (
62
+ <div>
63
+ <TextArea {...props1} label="Notes" />
64
+ <TextArea {...props2} label="Comments" />
65
+ </div>
66
+ )
67
+ }
68
+ `.trim()
69
+
70
+ const result = applyTransform(input)
71
+ expect(result).toContain(AUDIT_COMMENT)
72
+ expect(result).toContain("{...props1}")
73
+ expect(result).toContain("{...props2}")
74
+ })
75
+ })
76
+
77
+ describe("edge cases", () => {
78
+ it("should not transform TextArea without spread props", () => {
79
+ const input = `
80
+ import { TextArea } from "@planningcenter/tapestry-react"
81
+
82
+ export default function Test() {
83
+ return <TextArea onChange={handleChange} label="Notes" />
84
+ }
85
+ `.trim()
86
+
87
+ const result = applyTransform(input)
88
+ expect(result).toBe(null)
89
+ })
90
+
91
+ it("should not transform if TextArea is not imported from @planningcenter/tapestry-react", () => {
92
+ const input = `
93
+ import { TextArea } from "other-library"
94
+
95
+ export default function Test() {
96
+ const props = { onChange: handleChange }
97
+ return <TextArea {...props} label="Notes" />
98
+ }
99
+ `.trim()
100
+
101
+ const result = applyTransform(input)
102
+ expect(result).toBe(null)
103
+ })
104
+
105
+ it("should handle TextArea with alias import", () => {
106
+ const input = `
107
+ import { TextArea as MyTextArea } from "@planningcenter/tapestry-react"
108
+
109
+ export default function Test() {
110
+ const props = { onChange: handleChange }
111
+ return <MyTextArea {...props} label="Notes" />
112
+ }
113
+ `.trim()
114
+
115
+ const result = applyTransform(input)
116
+ expect(result).toContain(AUDIT_COMMENT)
117
+ expect(result).toContain("{...props}")
118
+ })
119
+
120
+ it("should return null when no TextArea imports exist", () => {
121
+ const input = `
122
+ import { Button } from "@planningcenter/tapestry-react"
123
+
124
+ export default function Test() {
125
+ const props = { onClick: handleClick }
126
+ return <Button {...props}>Save</Button>
127
+ }
128
+ `.trim()
129
+
130
+ const result = applyTransform(input)
131
+ expect(result).toBe(null)
132
+ })
133
+
134
+ it("should return null for empty file", () => {
135
+ const result = applyTransform("")
136
+ expect(result).toBe(null)
137
+ })
138
+ })
139
+ })