@planningcenter/tapestry-migration-cli 3.1.0-rc.9 → 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 (48) 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.ts +3 -3
  8. package/src/components/input/transforms/removeTypeText.ts +2 -3
  9. package/src/components/input/transforms/unsupportedProps.test.ts +20 -20
  10. package/src/components/select/index.ts +58 -0
  11. package/src/components/select/transformableSelect.ts +7 -0
  12. package/src/components/select/transforms/auditSpreadProps.test.ts +103 -0
  13. package/src/components/select/transforms/auditSpreadProps.ts +26 -0
  14. package/src/components/select/transforms/childrenToOptions.test.ts +367 -0
  15. package/src/components/select/transforms/childrenToOptions.ts +295 -0
  16. package/src/components/select/transforms/convertLegacyOptions.test.ts +150 -0
  17. package/src/components/select/transforms/convertLegacyOptions.ts +105 -0
  18. package/src/components/select/transforms/convertStyleProps.test.ts +73 -0
  19. package/src/components/select/transforms/convertStyleProps.ts +12 -0
  20. package/src/components/select/transforms/emptyValueToPlaceholder.test.ts +122 -0
  21. package/src/components/select/transforms/emptyValueToPlaceholder.ts +22 -0
  22. package/src/components/select/transforms/innerRefToRef.test.ts +89 -0
  23. package/src/components/select/transforms/innerRefToRef.ts +18 -0
  24. package/src/components/select/transforms/mapChildrenToOptions.test.ts +521 -0
  25. package/src/components/select/transforms/mapChildrenToOptions.ts +312 -0
  26. package/src/components/select/transforms/mergeFieldIntoSelect.test.ts +506 -0
  27. package/src/components/select/transforms/mergeFieldIntoSelect.ts +7 -0
  28. package/src/components/select/transforms/mergeSelectLabel.test.ts +458 -0
  29. package/src/components/select/transforms/mergeSelectLabel.ts +225 -0
  30. package/src/components/select/transforms/moveSelectImport.test.ts +148 -0
  31. package/src/components/select/transforms/moveSelectImport.ts +14 -0
  32. package/src/components/select/transforms/removeDefaultProps.test.ts +249 -0
  33. package/src/components/select/transforms/removeDefaultProps.ts +112 -0
  34. package/src/components/select/transforms/sizeMapping.test.ts +188 -0
  35. package/src/components/select/transforms/sizeMapping.ts +17 -0
  36. package/src/components/select/transforms/skipMultipleSelect.test.ts +148 -0
  37. package/src/components/select/transforms/skipMultipleSelect.ts +23 -0
  38. package/src/components/select/transforms/stateToInvalid.test.ts +217 -0
  39. package/src/components/select/transforms/stateToInvalid.ts +59 -0
  40. package/src/components/select/transforms/stateToInvalidTernary.test.ts +146 -0
  41. package/src/components/select/transforms/stateToInvalidTernary.ts +13 -0
  42. package/src/components/select/transforms/unsupportedProps.test.ts +252 -0
  43. package/src/components/select/transforms/unsupportedProps.ts +44 -0
  44. package/src/components/shared/helpers/getAttributeExpression.ts +26 -0
  45. package/src/components/shared/helpers/unsupportedPropsHelpers.ts +19 -2
  46. package/src/components/shared/transformFactories/mergeFieldFactory.ts +244 -0
  47. package/src/components/text-area/transforms/mergeFieldIntoTextArea.ts +4 -226
  48. package/src/index.ts +2 -1
@@ -0,0 +1,252 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import transform from "./unsupportedProps"
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("unsupportedProps transform", () => {
19
+ describe("unsupported prop detection", () => {
20
+ it("should add comment for css prop with specific message", () => {
21
+ const input = `
22
+ import { Select } from "@planningcenter/tapestry-react"
23
+
24
+ function Test() {
25
+ return <Select css={{ color: 'red' }} emptyValue="Pick" />
26
+ }
27
+ `.trim()
28
+
29
+ const result = applyTransform(input)
30
+ expect(result).toContain("TODO: tapestry-migration (css)")
31
+ expect(result).toContain(
32
+ "CSS prop is not supported. Use className or style prop instead."
33
+ )
34
+ })
35
+
36
+ it("should add comment for tooltip prop with specific message", () => {
37
+ const input = `
38
+ import { Select } from "@planningcenter/tapestry-react"
39
+
40
+ function Test() {
41
+ return <Select tooltip={{ content: "Help" }} emptyValue="Pick" />
42
+ }
43
+ `.trim()
44
+
45
+ const result = applyTransform(input)
46
+ expect(result).toContain("TODO: tapestry-migration (tooltip)")
47
+ expect(result).toContain("unsupported anti-pattern")
48
+ })
49
+
50
+ it("should skip Select with multiple prop (handled by skipMultipleSelect)", () => {
51
+ const input = `
52
+ import { Select } from "@planningcenter/tapestry-react"
53
+
54
+ function Test() {
55
+ return <Select multiple emptyValue="Pick" />
56
+ }
57
+ `.trim()
58
+
59
+ const result = applyTransform(input)
60
+ expect(result).not.toContain("TODO: tapestry-migration")
61
+ expect(result).toContain("multiple")
62
+ expect(result).toContain('emptyValue="Pick"')
63
+ })
64
+
65
+ it("should add comment for renderValue prop", () => {
66
+ const input = `
67
+ import { Select } from "@planningcenter/tapestry-react"
68
+
69
+ function Test() {
70
+ return <Select renderValue={(opts) => opts.map(o => o.label)} emptyValue="Pick" />
71
+ }
72
+ `.trim()
73
+
74
+ const result = applyTransform(input)
75
+ expect(result).toContain("TODO: tapestry-migration (renderValue)")
76
+ expect(result).toContain("renderValue is not supported")
77
+ })
78
+
79
+ it("should add comments for removed props", () => {
80
+ const input = `
81
+ import { Select } from "@planningcenter/tapestry-react"
82
+
83
+ function Test() {
84
+ return (
85
+ <Select
86
+ keepInView
87
+ lockScrollWhileOpen
88
+ popoverProps={{ maxHeight: 300 }}
89
+ renderTo="body"
90
+ placeholder="Pick"
91
+ options={[]}
92
+ label="Test"
93
+ />
94
+ )
95
+ }
96
+ `.trim()
97
+
98
+ const result = applyTransform(input)
99
+ expect(result).toContain("TODO: tapestry-migration (keepInView)")
100
+ expect(result).toContain("TODO: tapestry-migration (lockScrollWhileOpen)")
101
+ expect(result).toContain("TODO: tapestry-migration (popoverProps)")
102
+ expect(result).toContain("TODO: tapestry-migration (renderTo)")
103
+ })
104
+ })
105
+
106
+ describe("supported props", () => {
107
+ it("should not add comments for supported select props", () => {
108
+ const input = `
109
+ import { Select } from "@planningcenter/tapestry-react"
110
+
111
+ function Test() {
112
+ return (
113
+ <Select
114
+ disabled
115
+ name="select"
116
+ onChange={() => {}}
117
+ value="test"
118
+ placeholder="Pick one"
119
+ options={options}
120
+ />
121
+ )
122
+ }
123
+ `.trim()
124
+
125
+ const result = applyTransform(input)
126
+ expect(result).not.toContain("TODO: tapestry-migration")
127
+ })
128
+
129
+ it("should not add comments for common props", () => {
130
+ const input = `
131
+ import { Select } from "@planningcenter/tapestry-react"
132
+
133
+ function Test() {
134
+ return (
135
+ <Select
136
+ className="test"
137
+ id="select"
138
+ style={{ color: 'red' }}
139
+ tabIndex={0}
140
+ label="Test"
141
+ placeholder="Pick"
142
+ options={[]}
143
+ />
144
+ )
145
+ }
146
+ `.trim()
147
+
148
+ const result = applyTransform(input)
149
+ expect(result).not.toContain("TODO: tapestry-migration")
150
+ })
151
+
152
+ it("should not add comments for aria props", () => {
153
+ const input = `
154
+ import { Select } from "@planningcenter/tapestry-react"
155
+
156
+ function Test() {
157
+ return (
158
+ <Select
159
+ aria-label="Test select"
160
+ aria-describedby="description"
161
+ placeholder="Pick"
162
+ options={[]}
163
+ label="Test"
164
+ />
165
+ )
166
+ }
167
+ `.trim()
168
+
169
+ const result = applyTransform(input)
170
+ expect(result).not.toContain("TODO: tapestry-migration")
171
+ })
172
+
173
+ it("should not add comments for data props", () => {
174
+ const input = `
175
+ import { Select } from "@planningcenter/tapestry-react"
176
+
177
+ function Test() {
178
+ return (
179
+ <Select
180
+ data-testid="select"
181
+ data-cy="test-select"
182
+ placeholder="Pick"
183
+ options={[]}
184
+ label="Test"
185
+ />
186
+ )
187
+ }
188
+ `.trim()
189
+
190
+ const result = applyTransform(input)
191
+ expect(result).not.toContain("TODO: tapestry-migration")
192
+ })
193
+ })
194
+
195
+ describe("edge cases", () => {
196
+ it("should not affect Select without unsupported props", () => {
197
+ const input = `
198
+ import { Select } from "@planningcenter/tapestry-react"
199
+
200
+ function Test() {
201
+ return <Select label="Test" placeholder="Pick" options={[]} />
202
+ }
203
+ `.trim()
204
+
205
+ const result = applyTransform(input)
206
+ expect(result).toBe(input)
207
+ })
208
+
209
+ it("should not affect other components", () => {
210
+ const input = `
211
+ import { Button, Select } from "@planningcenter/tapestry-react"
212
+
213
+ function Test() {
214
+ return (
215
+ <div>
216
+ <Button css={{ color: 'red' }}>Click me</Button>
217
+ <Select label="Test" placeholder="Pick" options={[]} />
218
+ </div>
219
+ )
220
+ }
221
+ `.trim()
222
+
223
+ const result = applyTransform(input)
224
+ expect(result).not.toContain("TODO: tapestry-migration")
225
+ })
226
+
227
+ it("should handle mixed supported and unsupported props", () => {
228
+ const input = `
229
+ import { Select } from "@planningcenter/tapestry-react"
230
+
231
+ function Test() {
232
+ return (
233
+ <Select
234
+ disabled
235
+ css={{ color: 'red' }}
236
+ tooltip={{ content: "help" }}
237
+ label="Test"
238
+ placeholder="Pick"
239
+ options={[]}
240
+ />
241
+ )
242
+ }
243
+ `.trim()
244
+
245
+ const result = applyTransform(input)
246
+ expect(result).toContain("TODO: tapestry-migration (css)")
247
+ expect(result).toContain("TODO: tapestry-migration (tooltip)")
248
+ expect(result).toContain("disabled")
249
+ expect(result).toContain('label="Test"')
250
+ })
251
+ })
252
+ })
@@ -0,0 +1,44 @@
1
+ import { JSXAttribute, Transform } from "jscodeshift"
2
+
3
+ import { addCommentToUnsupportedProps } from "../../shared/actions/addCommentToUnsupportedProps"
4
+ import { SELECT_SUPPORTED_PROPS } from "../../shared/helpers/unsupportedPropsHelpers"
5
+ import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
6
+ import { transformableSelect } from "../transformableSelect"
7
+
8
+ const transform: Transform = attributeTransformFactory({
9
+ condition: transformableSelect,
10
+ targetComponent: "Select",
11
+ targetPackage: "@planningcenter/tapestry-react",
12
+ transform: (element, { j }) => {
13
+ const UNSUPPORTED_PROPS = (element.openingElement.attributes || [])
14
+ .filter(
15
+ (attr) =>
16
+ attr.type === "JSXAttribute" &&
17
+ attr.name.type === "JSXIdentifier" &&
18
+ !SELECT_SUPPORTED_PROPS.includes(attr.name.name as string) &&
19
+ !(attr.name.name as string).startsWith("aria-") &&
20
+ !(attr.name.name as string).startsWith("data-")
21
+ )
22
+ .map((attr) => (attr as JSXAttribute).name.name as string)
23
+
24
+ return addCommentToUnsupportedProps({
25
+ element,
26
+ j,
27
+ messageSuffix: (prop) => {
28
+ if (prop === "css") {
29
+ return "\n * CSS prop is not supported. Use className or style prop instead.\n"
30
+ }
31
+ if (prop === "tooltip") {
32
+ return "\n * Wrapping a select in a tooltip is an unsupported anti-pattern.\n"
33
+ }
34
+ if (prop === "renderValue") {
35
+ return "\n * renderValue is not supported. The select displays the selected option label.\n"
36
+ }
37
+ return ""
38
+ },
39
+ props: UNSUPPORTED_PROPS,
40
+ })
41
+ },
42
+ })
43
+
44
+ export default transform
@@ -0,0 +1,26 @@
1
+ import { Expression, JSXElement } from "jscodeshift"
2
+
3
+ /**
4
+ * Gets the expression AST node for a JSX attribute value.
5
+ * Returns the expression node for dynamic values, or a StringLiteral for static ones.
6
+ */
7
+ export function getAttributeExpression(
8
+ element: JSXElement,
9
+ attrName: string
10
+ ): Expression | null {
11
+ const attrs = element.openingElement.attributes || []
12
+ for (const attr of attrs) {
13
+ if (attr.type !== "JSXAttribute") continue
14
+ if (attr.name.name !== attrName) continue
15
+
16
+ if (!attr.value) return null // boolean shorthand has no expression
17
+ if (attr.value.type === "StringLiteral") return attr.value
18
+ if (attr.value.type === "JSXExpressionContainer") {
19
+ const expr = attr.value.expression
20
+ if (expr.type === "JSXEmptyExpression") return null
21
+ return expr as Expression
22
+ }
23
+ return null
24
+ }
25
+ return null
26
+ }
@@ -96,9 +96,26 @@ export const TYPE_SPECIFIC_PROPS: Record<string, string[]> = {
96
96
  url: ["dirname", "inputMode", "list", "pattern"],
97
97
  }
98
98
 
99
- export const INPUT_SUPPORTED_PROPS = [
99
+ export const INPUT_SUPPORTED_PROPS = [...COMMON_PROPS, ...INPUT_SPECIFIC_PROPS]
100
+
101
+ export const SELECT_SPECIFIC_PROPS = [
102
+ "complex",
103
+ "defaultValue",
104
+ "description",
105
+ "disabled",
106
+ "form",
107
+ "invalid",
108
+ "name",
109
+ "onChange",
110
+ "options",
111
+ "placeholder",
112
+ "required",
113
+ "value",
114
+ ]
115
+
116
+ export const SELECT_SUPPORTED_PROPS = [
100
117
  ...COMMON_PROPS,
101
- ...INPUT_SPECIFIC_PROPS,
118
+ ...SELECT_SPECIFIC_PROPS,
102
119
  ...STYLE_PROP_NAMES_WITHOUT_CSS,
103
120
  ]
104
121
 
@@ -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
+ }