@planningcenter/tapestry-migration-cli 3.5.0 → 3.6.1-qa-1035.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 (52) hide show
  1. package/README.md +37 -0
  2. package/dist/tapestry-react-shim.cjs +195 -11
  3. package/package.json +3 -3
  4. package/src/availableComponents.ts +1 -0
  5. package/src/components/button/transforms/tooltipToWrapper.test.ts +50 -3
  6. package/src/components/button/transforms/tooltipToWrapper.ts +18 -0
  7. package/src/components/flex/index.test.ts +291 -0
  8. package/src/components/flex/index.ts +54 -0
  9. package/src/components/flex/transforms/alignmentToAlign.test.ts +185 -0
  10. package/src/components/flex/transforms/alignmentToAlign.ts +73 -0
  11. package/src/components/flex/transforms/auditSpreadProps.test.ts +93 -0
  12. package/src/components/flex/transforms/auditSpreadProps.ts +6 -0
  13. package/src/components/flex/transforms/axisToDirection.test.ts +155 -0
  14. package/src/components/flex/transforms/axisToDirection.ts +77 -0
  15. package/src/components/flex/transforms/convertStyleProps.test.ts +147 -0
  16. package/src/components/flex/transforms/convertStyleProps.ts +23 -0
  17. package/src/components/flex/transforms/cssFlexAliasesToProps.test.ts +202 -0
  18. package/src/components/flex/transforms/cssFlexAliasesToProps.ts +110 -0
  19. package/src/components/flex/transforms/distributionToJustify.test.ts +214 -0
  20. package/src/components/flex/transforms/distributionToJustify.ts +76 -0
  21. package/src/components/flex/transforms/flexPropToExpand.test.ts +156 -0
  22. package/src/components/flex/transforms/flexPropToExpand.ts +100 -0
  23. package/src/components/flex/transforms/innerRefToRef.test.ts +100 -0
  24. package/src/components/flex/transforms/innerRefToRef.ts +14 -0
  25. package/src/components/flex/transforms/mediaQueriesToResponsive.test.ts +714 -0
  26. package/src/components/flex/transforms/mediaQueriesToResponsive.ts +523 -0
  27. package/src/components/flex/transforms/moveFlexImport.test.ts +202 -0
  28. package/src/components/flex/transforms/moveFlexImport.ts +14 -0
  29. package/src/components/flex/transforms/paddingPropsToFlex.test.ts +252 -0
  30. package/src/components/flex/transforms/paddingPropsToFlex.ts +150 -0
  31. package/src/components/flex/transforms/setDefaultAxis.test.ts +126 -0
  32. package/src/components/flex/transforms/setDefaultAxis.ts +30 -0
  33. package/src/components/flex/transforms/spacingToGap.test.ts +176 -0
  34. package/src/components/flex/transforms/spacingToGap.ts +49 -0
  35. package/src/components/flex/transforms/unsupportedProps.test.ts +141 -0
  36. package/src/components/flex/transforms/unsupportedProps.ts +17 -0
  37. package/src/components/select/index.ts +2 -0
  38. package/src/components/select/transforms/onChangeSignature.test.ts +224 -0
  39. package/src/components/select/transforms/onChangeSignature.ts +172 -0
  40. package/src/components/shared/actions/resolveConstantAttributeValue.test.ts +122 -0
  41. package/src/components/shared/actions/resolveConstantAttributeValue.ts +31 -0
  42. package/src/components/shared/helpers/unsupportedPropsHelpers.ts +41 -0
  43. package/src/components/shared/transformFactories/stylePropTransformFactory.test.ts +47 -0
  44. package/src/components/shared/transformFactories/stylePropTransformFactory.ts +16 -0
  45. package/src/components/toggle-switch/index.ts +2 -0
  46. package/src/components/toggle-switch/transforms/onClickToOnChange.test.ts +210 -0
  47. package/src/components/toggle-switch/transforms/onClickToOnChange.ts +71 -0
  48. package/src/index.test.ts +79 -0
  49. package/src/index.ts +29 -0
  50. package/src/migrationList.ts +22 -0
  51. package/src/utils/componentLabel.test.ts +61 -0
  52. package/src/utils/componentLabel.ts +15 -0
@@ -0,0 +1,224 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import transform from "./onChangeSignature"
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("onChangeSignature transform", () => {
19
+ it("auto-rewrites ({ value }) => expr to (e) => e.target.value", () => {
20
+ const input = `
21
+ import { Select } from "@planningcenter/tapestry-react"
22
+
23
+ function Test() {
24
+ return <Select onChange={({ value }) => setX(value)} emptyValue="Pick" />
25
+ }
26
+ `.trim()
27
+
28
+ const result = applyTransform(input)
29
+ // jscodeshift omits parens for a single identifier param: `e =>` not `(e) =>`
30
+ expect(result).toContain("e =>")
31
+ expect(result).toContain("e.target.value")
32
+ expect(result).not.toContain("({ value })")
33
+ expect(result).not.toContain("TODO")
34
+ })
35
+
36
+ it("replaces all value references in the expression body", () => {
37
+ const input = `
38
+ import { Select } from "@planningcenter/tapestry-react"
39
+
40
+ function Test() {
41
+ return <Select onChange={({ value }) => fn(value, value)} emptyValue="Pick" />
42
+ }
43
+ `.trim()
44
+
45
+ const result = applyTransform(input)
46
+ expect(result).toContain("fn(e.target.value, e.target.value)")
47
+ expect(result).not.toContain("({ value })")
48
+ })
49
+
50
+ it("adds a TODO comment for a function reference", () => {
51
+ const input = `
52
+ import { Select } from "@planningcenter/tapestry-react"
53
+
54
+ function Test() {
55
+ return <Select onChange={handler} emptyValue="Pick" />
56
+ }
57
+ `.trim()
58
+
59
+ const result = applyTransform(input)
60
+ expect(result).toContain("onChange={handler}")
61
+ expect(result).toContain("TODO: tapestry-migration (onChange)")
62
+ })
63
+
64
+ it("adds a TODO comment when ({ selectedValue }) is destructured", () => {
65
+ const input = `
66
+ import { Select } from "@planningcenter/tapestry-react"
67
+
68
+ function Test() {
69
+ return <Select onChange={({ selectedValue }) => setX(selectedValue)} emptyValue="Pick" />
70
+ }
71
+ `.trim()
72
+
73
+ const result = applyTransform(input)
74
+ expect(result).toContain("({ selectedValue })")
75
+ expect(result).toContain("TODO: tapestry-migration (onChange)")
76
+ })
77
+
78
+ it("adds a TODO comment for a block-body arrow (too complex to auto-rewrite)", () => {
79
+ const input = `
80
+ import { Select } from "@planningcenter/tapestry-react"
81
+
82
+ function Test() {
83
+ return <Select onChange={({ value }) => { setX(value) }} emptyValue="Pick" />
84
+ }
85
+ `.trim()
86
+
87
+ const result = applyTransform(input)
88
+ expect(result).toContain("{ setX(value) }")
89
+ expect(result).toContain("TODO: tapestry-migration (onChange)")
90
+ })
91
+
92
+ it("expands object-literal shorthand return ({ value }) to ({ value: e.target.value })", () => {
93
+ const input = `
94
+ import { Select } from "@planningcenter/tapestry-react"
95
+
96
+ function Test() {
97
+ return <Select onChange={({ value }) => ({ value })} emptyValue="Pick" />
98
+ }
99
+ `.trim()
100
+
101
+ const result = applyTransform(input)
102
+ expect(result).toContain("value: e.target.value")
103
+ expect(result).not.toContain("TODO")
104
+ })
105
+
106
+ it("leaves member-property `value` access untouched (obj.value and obj?.value)", () => {
107
+ const dot = `
108
+ import { Select } from "@planningcenter/tapestry-react"
109
+
110
+ function Test() {
111
+ return <Select onChange={({ value }) => setX(obj.value)} emptyValue="Pick" />
112
+ }
113
+ `.trim()
114
+ const dotResult = applyTransform(dot)
115
+ expect(dotResult).toContain("setX(obj.value)")
116
+ expect(dotResult).not.toContain("e.target.value")
117
+
118
+ const optional = `
119
+ import { Select } from "@planningcenter/tapestry-react"
120
+
121
+ function Test() {
122
+ return <Select onChange={({ value }) => setX(obj?.value)} emptyValue="Pick" />
123
+ }
124
+ `.trim()
125
+ const optionalResult = applyTransform(optional)
126
+ expect(optionalResult).toContain("obj?.value")
127
+ expect(optionalResult).not.toContain("obj?.e.target.value")
128
+ })
129
+
130
+ it("adds a TODO when a nested callback shadows `value` (would corrupt the binding)", () => {
131
+ const input = `
132
+ import { Select } from "@planningcenter/tapestry-react"
133
+
134
+ function Test() {
135
+ return <Select onChange={({ value }) => list.map(value => value)} emptyValue="Pick" />
136
+ }
137
+ `.trim()
138
+
139
+ const result = applyTransform(input)
140
+ expect(result).toContain("list.map(value => value)")
141
+ expect(result).toContain("TODO: tapestry-migration (onChange)")
142
+ })
143
+
144
+ it("adds a TODO when a nested callback destructures its own `value`", () => {
145
+ const input = `
146
+ import { Select } from "@planningcenter/tapestry-react"
147
+
148
+ function Test() {
149
+ return <Select onChange={({ value }) => list.map(({ value }) => value)} emptyValue="Pick" />
150
+ }
151
+ `.trim()
152
+
153
+ const result = applyTransform(input)
154
+ expect(result).toContain("TODO: tapestry-migration (onChange)")
155
+ })
156
+
157
+ it("rewrites references inside a nested callback that does NOT shadow `value`", () => {
158
+ const input = `
159
+ import { Select } from "@planningcenter/tapestry-react"
160
+
161
+ function Test() {
162
+ return <Select onChange={({ value }) => arr.filter(x => x === value)} emptyValue="Pick" />
163
+ }
164
+ `.trim()
165
+
166
+ const result = applyTransform(input)
167
+ expect(result).toContain("x === e.target.value")
168
+ expect(result).not.toContain("TODO")
169
+ })
170
+
171
+ it("does not transform Select with no onChange", () => {
172
+ const input = `
173
+ import { Select } from "@planningcenter/tapestry-react"
174
+
175
+ function Test() {
176
+ return <Select emptyValue="Pick" />
177
+ }
178
+ `.trim()
179
+
180
+ const result = applyTransform(input)
181
+ expect(result).toBe(input)
182
+ })
183
+
184
+ it("does not transform other components", () => {
185
+ const input = `
186
+ import { Button } from "@planningcenter/tapestry-react"
187
+
188
+ function Test() {
189
+ return <Button onChange={({ value }) => setX(value)}>Click</Button>
190
+ }
191
+ `.trim()
192
+
193
+ const result = applyTransform(input)
194
+ expect(result).toBe(input)
195
+ })
196
+
197
+ it("does not transform a multiple Select (skipped by transformableSelect)", () => {
198
+ const input = `
199
+ import { Select } from "@planningcenter/tapestry-react"
200
+
201
+ function Test() {
202
+ return <Select multiple onChange={({ value }) => setX(value)} emptyValue="Pick" />
203
+ }
204
+ `.trim()
205
+
206
+ const result = applyTransform(input)
207
+ expect(result).toBe(input)
208
+ })
209
+
210
+ it("preserves other props when auto-rewriting", () => {
211
+ const input = `
212
+ import { Select } from "@planningcenter/tapestry-react"
213
+
214
+ function Test() {
215
+ return <Select disabled emptyValue="Pick" onChange={({ value }) => setX(value)} />
216
+ }
217
+ `.trim()
218
+
219
+ const result = applyTransform(input)
220
+ expect(result).toContain("disabled")
221
+ expect(result).toContain("emptyValue")
222
+ expect(result).toContain("e.target.value")
223
+ })
224
+ })
@@ -0,0 +1,172 @@
1
+ import { 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 { findAttribute } from "../../shared/findAttribute"
7
+ import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
8
+ import { transformableSelect } from "../transformableSelect"
9
+
10
+ const TODO_TEXT =
11
+ "onChange now receives a React.ChangeEvent<HTMLSelectElement>; read the value via e.target.value instead of the old { selectedValue, value } argument"
12
+
13
+ /**
14
+ * Returns true if a parameter pattern mentions a `value` identifier, e.g.
15
+ * `value`, `{ value }`, `[value]`. Any occurrence is treated conservatively as
16
+ * a potential binding so we bail to a TODO rather than risk corrupting it.
17
+ */
18
+ function paramBindsValue(
19
+ param: import("jscodeshift").ASTNode,
20
+ j: import("jscodeshift").JSCodeshift
21
+ ): boolean {
22
+ // `find` only searches descendants, so check the param node itself first
23
+ // (a bare `value` param) before scanning destructuring patterns.
24
+ if (param.type === "Identifier" && param.name === "value") return true
25
+ return j(param).find(j.Identifier, { name: "value" }).size() > 0
26
+ }
27
+
28
+ /**
29
+ * Returns true if a nested function/arrow inside the body binds its own `value`
30
+ * parameter, which would shadow the destructured param. Rewriting these would
31
+ * corrupt the inner binding, so they fall through to a TODO comment instead.
32
+ *
33
+ * e.g. `({ value }) => list.map(value => value)` → ambiguous, leave for the dev
34
+ */
35
+ function bodyShadowsValue(
36
+ body: import("jscodeshift").ASTNode,
37
+ j: import("jscodeshift").JSCodeshift
38
+ ): boolean {
39
+ let shadows = false
40
+ const visit = (collection: ReturnType<typeof j>) =>
41
+ collection.forEach((path) => {
42
+ if (
43
+ path.node.params.some((p: import("jscodeshift").ASTNode) =>
44
+ paramBindsValue(p, j)
45
+ )
46
+ )
47
+ shadows = true
48
+ })
49
+ visit(j(body).find(j.ArrowFunctionExpression))
50
+ visit(j(body).find(j.FunctionExpression))
51
+ return shadows
52
+ }
53
+
54
+ /**
55
+ * Returns true if the onChange attribute is an expression-bodied arrow function
56
+ * with exactly one `{ value }` destructuring param and no other keys.
57
+ *
58
+ * e.g. `onChange={({ value }) => setX(value)}` → auto-migratable
59
+ * e.g. `onChange={handler}` or `onChange={({ selectedValue }) => …}` → TODO comment
60
+ */
61
+ function isSimpleValueDestructure(
62
+ attribute: ReturnType<typeof findAttribute>,
63
+ j: import("jscodeshift").JSCodeshift
64
+ ): boolean {
65
+ if (!attribute || attribute.type !== "JSXAttribute") return false
66
+
67
+ const container = attribute.value
68
+ if (!container || container.type !== "JSXExpressionContainer") return false
69
+
70
+ const arrow = container.expression
71
+ if (!j.ArrowFunctionExpression.check(arrow)) return false
72
+
73
+ // Block bodies are too complex to auto-rewrite safely
74
+ if (arrow.body.type === "BlockStatement") return false
75
+
76
+ const params = arrow.params
77
+ if (params.length !== 1) return false
78
+
79
+ const param = params[0]
80
+ if (param.type !== "ObjectPattern") return false
81
+
82
+ const props = param.properties
83
+ if (props.length !== 1) return false
84
+
85
+ const prop = props[0]
86
+ if (prop.type !== "ObjectProperty" && prop.type !== "Property") return false
87
+ if (!("shorthand" in prop) || !prop.shorthand) return false
88
+ if (prop.key.type !== "Identifier" || prop.key.name !== "value") return false
89
+
90
+ // A nested function that rebinds `value` would be corrupted by the rewrite.
91
+ if (bodyShadowsValue(arrow.body, j)) return false
92
+
93
+ return true
94
+ }
95
+
96
+ const transform: Transform = attributeTransformFactory({
97
+ condition: andConditions(transformableSelect, hasAttribute("onChange")),
98
+ targetComponent: "Select",
99
+ targetPackage: "@planningcenter/tapestry-react",
100
+ transform: (element, { j }) => {
101
+ const attributes = element.openingElement.attributes || []
102
+ const attribute = findAttribute(attributes, "onChange")
103
+ if (!attribute) return false
104
+
105
+ if (isSimpleValueDestructure(attribute, j)) {
106
+ // Safe to auto-rewrite: ({ value }) => expr → (e) => expr[value→e.target.value]
107
+ const container =
108
+ attribute.value as import("jscodeshift").JSXExpressionContainer
109
+ const arrow =
110
+ container.expression as import("jscodeshift").ArrowFunctionExpression
111
+
112
+ // Replace the param with identifier `e`
113
+ arrow.params = [j.identifier("e")]
114
+
115
+ // Replace all `value` identifier references in the expression body
116
+ // (skip object key / member-property positions to avoid false positives)
117
+ const body = arrow.body as import("jscodeshift").Expression
118
+ j(body)
119
+ .find(j.Identifier, { name: "value" })
120
+ .filter((path) => {
121
+ const parent = path.parent.node
122
+ // Skip member-property positions (`obj.value`, `obj?.value`) — these
123
+ // are property names, not references to the destructured param.
124
+ if (
125
+ (parent.type === "MemberExpression" ||
126
+ parent.type === "OptionalMemberExpression") &&
127
+ parent.property === path.node &&
128
+ !parent.computed
129
+ ) {
130
+ return false
131
+ }
132
+ // Skip object property keys (`{ value: … }`)
133
+ if (
134
+ (parent.type === "Property" || parent.type === "ObjectProperty") &&
135
+ parent.key === path.node &&
136
+ !parent.computed
137
+ ) {
138
+ return false
139
+ }
140
+ return true
141
+ })
142
+ .replaceWith((path) => {
143
+ // Object shorthand (`{ value }`) must be expanded to `{ value: … }`,
144
+ // otherwise the printed property loses its value.
145
+ const parent = path.parent.node
146
+ if (
147
+ (parent.type === "Property" || parent.type === "ObjectProperty") &&
148
+ "shorthand" in parent
149
+ ) {
150
+ parent.shorthand = false
151
+ }
152
+ return j.memberExpression(
153
+ j.memberExpression(j.identifier("e"), j.identifier("target")),
154
+ j.identifier("value")
155
+ )
156
+ })
157
+
158
+ return true
159
+ }
160
+
161
+ // Not auto-migratable — add a TODO comment so the developer handles it
162
+ addCommentToAttribute({
163
+ attribute,
164
+ commentKind: "todo",
165
+ j,
166
+ text: TODO_TEXT,
167
+ })
168
+ return true
169
+ },
170
+ })
171
+
172
+ export default transform
@@ -0,0 +1,122 @@
1
+ import jscodeshift, {
2
+ JSXAttribute,
3
+ JSXElement,
4
+ JSXIdentifier,
5
+ } from "jscodeshift"
6
+ import { describe, expect, it } from "vitest"
7
+
8
+ import { resolveConstantAttributeValue } from "./resolveConstantAttributeValue"
9
+
10
+ const j = jscodeshift.withParser("tsx")
11
+
12
+ const CONSTANT_MAP: Record<string, string> = {
13
+ CENTER: "center",
14
+ FILL: "fill",
15
+ START: "start",
16
+ }
17
+
18
+ function createElementFromCode(code: string): JSXElement {
19
+ const source = j(`<div>${code}</div>`)
20
+ return source.find(j.JSXElement).at(0).get().value.children?.[0] as JSXElement
21
+ }
22
+
23
+ function getAttributeFromElement(
24
+ element: JSXElement,
25
+ name: string
26
+ ): JSXAttribute {
27
+ const attributes = element.openingElement.attributes || []
28
+ return attributes.find(
29
+ (attr) =>
30
+ attr.type === "JSXAttribute" &&
31
+ (attr.name as JSXIdentifier)?.name === name
32
+ ) as JSXAttribute
33
+ }
34
+
35
+ describe("resolveConstantAttributeValue", () => {
36
+ it("resolves a string literal", () => {
37
+ const element = createElementFromCode('<StackView distribution="center" />')
38
+ const attr = getAttributeFromElement(element, "distribution")
39
+ expect(
40
+ resolveConstantAttributeValue({
41
+ attribute: attr,
42
+ constantMap: CONSTANT_MAP,
43
+ })
44
+ ).toBe("center")
45
+ })
46
+
47
+ it("resolves an expression-wrapped string literal", () => {
48
+ const element = createElementFromCode(
49
+ '<StackView distribution={"center"} />'
50
+ )
51
+ const attr = getAttributeFromElement(element, "distribution")
52
+ expect(
53
+ resolveConstantAttributeValue({
54
+ attribute: attr,
55
+ constantMap: CONSTANT_MAP,
56
+ })
57
+ ).toBe("center")
58
+ })
59
+
60
+ it("resolves a MemberExpression constant present in the map", () => {
61
+ const element = createElementFromCode(
62
+ "<StackView distribution={StackView.CENTER} />"
63
+ )
64
+ const attr = getAttributeFromElement(element, "distribution")
65
+ expect(
66
+ resolveConstantAttributeValue({
67
+ attribute: attr,
68
+ constantMap: CONSTANT_MAP,
69
+ })
70
+ ).toBe("center")
71
+ })
72
+
73
+ it("resolves a MemberExpression constant that maps to fill", () => {
74
+ const element = createElementFromCode(
75
+ "<StackView distribution={StackView.FILL} />"
76
+ )
77
+ const attr = getAttributeFromElement(element, "distribution")
78
+ expect(
79
+ resolveConstantAttributeValue({
80
+ attribute: attr,
81
+ constantMap: CONSTANT_MAP,
82
+ })
83
+ ).toBe("fill")
84
+ })
85
+
86
+ it("returns null for a MemberExpression constant not in the map", () => {
87
+ const element = createElementFromCode(
88
+ "<StackView distribution={StackView.UNKNOWN} />"
89
+ )
90
+ const attr = getAttributeFromElement(element, "distribution")
91
+ expect(
92
+ resolveConstantAttributeValue({
93
+ attribute: attr,
94
+ constantMap: CONSTANT_MAP,
95
+ })
96
+ ).toBeNull()
97
+ })
98
+
99
+ it("returns null for a dynamic variable", () => {
100
+ const element = createElementFromCode(
101
+ "<StackView distribution={someVar} />"
102
+ )
103
+ const attr = getAttributeFromElement(element, "distribution")
104
+ expect(
105
+ resolveConstantAttributeValue({
106
+ attribute: attr,
107
+ constantMap: CONSTANT_MAP,
108
+ })
109
+ ).toBeNull()
110
+ })
111
+
112
+ it("returns null for a boolean (valueless) attribute", () => {
113
+ const element = createElementFromCode("<StackView distribution />")
114
+ const attr = getAttributeFromElement(element, "distribution")
115
+ expect(
116
+ resolveConstantAttributeValue({
117
+ attribute: attr,
118
+ constantMap: CONSTANT_MAP,
119
+ })
120
+ ).toBeNull()
121
+ })
122
+ })
@@ -0,0 +1,31 @@
1
+ import { JSXAttribute } from "jscodeshift"
2
+
3
+ export function resolveConstantAttributeValue({
4
+ attribute,
5
+ constantMap,
6
+ }: {
7
+ attribute: JSXAttribute
8
+ constantMap: Record<string, string>
9
+ }): string | null {
10
+ const value = attribute.value
11
+ if (!value) return null
12
+
13
+ if (value.type === "StringLiteral") {
14
+ return value.value
15
+ }
16
+
17
+ if (value.type === "JSXExpressionContainer") {
18
+ if (value.expression.type === "StringLiteral") {
19
+ return value.expression.value
20
+ }
21
+
22
+ if (
23
+ value.expression.type === "MemberExpression" &&
24
+ value.expression.property.type === "Identifier"
25
+ ) {
26
+ return constantMap[value.expression.property.name] ?? null
27
+ }
28
+ }
29
+
30
+ return null
31
+ }
@@ -233,3 +233,44 @@ export const DROPDOWN_LINK_SUPPORTED_PROPS = [
233
233
  "href",
234
234
  "to",
235
235
  ]
236
+
237
+ // StackView props that have dedicated transforms. When a transform cannot resolve
238
+ // a value at codemod time, it leaves the prop name unchanged and adds a TODO.
239
+ // Listing them here prevents unsupportedPropsFactory from adding a second TODO.
240
+ export const FLEX_IGNORE_PROPS = [
241
+ "alignItems",
242
+ "alignment",
243
+ "axis",
244
+ "distribution",
245
+ "flex",
246
+ "justifyContent",
247
+ "mediaQueries",
248
+ "spacing",
249
+ ]
250
+
251
+ export const FLEX_SPECIFIC_PROPS = [
252
+ "align",
253
+ "alignSelf",
254
+ "as",
255
+ "basis",
256
+ "columnGap",
257
+ "direction",
258
+ "expand",
259
+ "gap",
260
+ "grow",
261
+ "inline",
262
+ "justify",
263
+ "padding",
264
+ "paddingBlock",
265
+ "paddingInline",
266
+ "responsive",
267
+ "rowGap",
268
+ "shrink",
269
+ "wrap",
270
+ ]
271
+
272
+ export const FLEX_SUPPORTED_PROPS = [
273
+ ...COMMON_PROPS,
274
+ ...FLEX_IGNORE_PROPS,
275
+ ...FLEX_SPECIFIC_PROPS,
276
+ ]
@@ -120,6 +120,53 @@ describe("stylePropTransformFactory", () => {
120
120
  }} />"
121
121
  `)
122
122
  })
123
+
124
+ it("converts negative numeric margin props through the spacing scale", () => {
125
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box margin={-1} marginTop={-2} marginLeft={-0.5} />`
126
+ const result = applyTransform(transform, input)
127
+ expect(result).toMatchInlineSnapshot(`
128
+ "import { Box } from "@planningcenter/tapestry-react";<Box
129
+ style={{
130
+ marginTop: "-16px",
131
+ marginRight: "-8px",
132
+ marginBottom: "-8px",
133
+ marginLeft: "-4px"
134
+ }} />"
135
+ `)
136
+ })
137
+
138
+ it("converts negative numeric margin longhand props through the spacing scale", () => {
139
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box marginTop={-1} marginBottom={-2} marginLeft={-3} marginRight={-4} />`
140
+ const result = applyTransform(transform, input)
141
+ expect(result).toMatchInlineSnapshot(`
142
+ "import { Box } from "@planningcenter/tapestry-react";<Box
143
+ style={{
144
+ marginTop: "-8px",
145
+ marginRight: "-32px",
146
+ marginBottom: "-16px",
147
+ marginLeft: "-24px"
148
+ }} />"
149
+ `)
150
+ })
151
+ })
152
+
153
+ describe("stylesToExclude", () => {
154
+ const transform = stylePropTransformFactory({
155
+ stylesToExclude: ["gap"],
156
+ stylesToRemove: [],
157
+ targetComponent: "Box",
158
+ targetPackage: "@planningcenter/tapestry-react",
159
+ })
160
+
161
+ it("leaves excluded props untouched while migrating others", () => {
162
+ const input = `import { Box } from "@planningcenter/tapestry-react";<Box gap={2} marginTop={2} />`
163
+ const result = applyTransform(transform, input)
164
+ expect(result).toMatchInlineSnapshot(`
165
+ "import { Box } from "@planningcenter/tapestry-react";<Box gap={2} style={{
166
+ marginTop: "16px"
167
+ }} />"
168
+ `)
169
+ })
123
170
  })
124
171
 
125
172
  describe("value-transforming aliases", () => {
@@ -81,6 +81,19 @@ function extractPropValue(attr: JSXAttribute, j: JSCodeshift) {
81
81
  return expression.value
82
82
  } else if (expression.type === "NumericLiteral") {
83
83
  return expression.value
84
+ } else if (expression.type === "UnaryExpression") {
85
+ const unary = expression as unknown as {
86
+ argument?: { type?: string; value?: unknown }
87
+ operator: string
88
+ }
89
+ if (
90
+ unary.operator === "-" &&
91
+ unary.argument?.type === "NumericLiteral" &&
92
+ typeof unary.argument.value === "number"
93
+ ) {
94
+ return -unary.argument.value
95
+ }
96
+ return `{${j(expression).toSource()}}`
84
97
  } else if (expression.type === "BooleanLiteral") {
85
98
  return expression.value
86
99
  } else if (expression.type === "Identifier") {
@@ -363,6 +376,7 @@ export function stylePropTransformFactory(config: {
363
376
  styleProps: string[]
364
377
  }
365
378
  stylePropMapping?: StylePropMapping
379
+ stylesToExclude?: string[]
366
380
  stylesToKeep?: string[]
367
381
  stylesToRemove: string[]
368
382
  targetComponent: string
@@ -370,6 +384,7 @@ export function stylePropTransformFactory(config: {
370
384
  }): Transform {
371
385
  const {
372
386
  stylePropMapping = {} as StylePropMapping,
387
+ stylesToExclude = [],
373
388
  stylesToKeep = [],
374
389
  stylesToRemove,
375
390
  } = config
@@ -387,6 +402,7 @@ export function stylePropTransformFactory(config: {
387
402
  return (
388
403
  name &&
389
404
  name !== "css" &&
405
+ !stylesToExclude.includes(name) &&
390
406
  (stylePropNames.includes(name) ||
391
407
  name in stylePropMapping ||
392
408
  stylesToKeep.includes(name) ||