@planningcenter/tapestry-migration-cli 3.6.1-qa-1035.0 → 3.7.0-rc.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.
- package/package.json +3 -3
- package/src/components/flex/index.test.ts +30 -0
- package/src/components/flex/index.ts +2 -0
- package/src/components/flex/transforms/alignmentToAlign.test.ts +101 -0
- package/src/components/flex/transforms/alignmentToAlign.ts +74 -20
- package/src/components/flex/transforms/constants.ts +4 -0
- package/src/components/flex/transforms/cssFlexAliasesToProps.ts +1 -5
- package/src/components/flex/transforms/dedupeAttributes.test.ts +120 -0
- package/src/components/flex/transforms/dedupeAttributes.ts +49 -0
- package/src/components/flex/transforms/distributionToJustify.test.ts +59 -0
- package/src/components/flex/transforms/distributionToJustify.ts +69 -20
- package/src/components/flex/transforms/flexPropToExpand.test.ts +16 -0
- package/src/components/flex/transforms/flexPropToExpand.ts +4 -4
- package/src/components/flex/transforms/mediaQueriesToResponsive.test.ts +82 -1
- package/src/components/flex/transforms/mediaQueriesToResponsive.ts +61 -22
- package/src/components/flex/transforms/spacingToGap.test.ts +87 -0
- package/src/components/flex/transforms/spacingToGap.ts +28 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@planningcenter/tapestry-migration-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.0-rc.0",
|
|
4
4
|
"description": "CLI tool for Tapestry migrations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@emotion/react": "^11.14.0",
|
|
33
|
-
"@planningcenter/tapestry": "^3.
|
|
33
|
+
"@planningcenter/tapestry": "^3.7.0-rc.0",
|
|
34
34
|
"@planningcenter/tapestry-react": "^4.11.5",
|
|
35
35
|
"@types/jscodeshift": "^17.3.0",
|
|
36
36
|
"@types/node": "^20.0.0",
|
|
@@ -50,5 +50,5 @@
|
|
|
50
50
|
"publishConfig": {
|
|
51
51
|
"access": "public"
|
|
52
52
|
},
|
|
53
|
-
"gitHead": "
|
|
53
|
+
"gitHead": "4f3ec50db17c53189754207e74dca9c71f57d0f5"
|
|
54
54
|
}
|
|
@@ -145,6 +145,21 @@ export default function Test() {
|
|
|
145
145
|
expect(result).not.toContain("flex=")
|
|
146
146
|
})
|
|
147
147
|
|
|
148
|
+
it("applies flexPropToExpand through the chain — unmappable flex stays as JSX prop with TODO, not in style={{}}", () => {
|
|
149
|
+
const input = `
|
|
150
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
151
|
+
|
|
152
|
+
export default function Test({ dynamicValue }) {
|
|
153
|
+
return <StackView flex={dynamicValue}>Test</StackView>
|
|
154
|
+
}
|
|
155
|
+
`.trim()
|
|
156
|
+
|
|
157
|
+
const result = applyTransform(input)
|
|
158
|
+
expect(result).toContain("flex={dynamicValue}")
|
|
159
|
+
expect(result).not.toContain("style={{")
|
|
160
|
+
expect(result?.match(/TODO/g)).toHaveLength(1)
|
|
161
|
+
})
|
|
162
|
+
|
|
148
163
|
it("applies mediaQueriesToResponsive through the chain", () => {
|
|
149
164
|
const input = `
|
|
150
165
|
import { StackView } from "@planningcenter/tapestry-react"
|
|
@@ -174,6 +189,21 @@ export default function Test() {
|
|
|
174
189
|
expect(result?.match(/direction=/g)).toHaveLength(1)
|
|
175
190
|
})
|
|
176
191
|
|
|
192
|
+
it("applies dedupeAttributes through the chain — axis and flexDirection both mapping to direction collapse to one prop", () => {
|
|
193
|
+
const input = `
|
|
194
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
195
|
+
|
|
196
|
+
export default function Test() {
|
|
197
|
+
return <StackView axis="horizontal" flexDirection="column">Test</StackView>
|
|
198
|
+
}
|
|
199
|
+
`.trim()
|
|
200
|
+
|
|
201
|
+
const result = applyTransform(input)
|
|
202
|
+
expect(result?.match(/direction=/g)).toHaveLength(1)
|
|
203
|
+
expect(result).toContain('direction="column"')
|
|
204
|
+
expect(result).toContain("TODO: tapestry-migration (direction)")
|
|
205
|
+
})
|
|
206
|
+
|
|
177
207
|
it("applies cssFlexAliasesToProps through the chain — unmappable justifyContent stays as JSX prop with TODO, not in style={{}}", () => {
|
|
178
208
|
const input = `
|
|
179
209
|
import { StackView } from "@planningcenter/tapestry-react"
|
|
@@ -5,6 +5,7 @@ import auditSpreadProps from "./transforms/auditSpreadProps"
|
|
|
5
5
|
import axisToDirection from "./transforms/axisToDirection"
|
|
6
6
|
import convertStyleProps from "./transforms/convertStyleProps"
|
|
7
7
|
import cssFlexAliasesToProps from "./transforms/cssFlexAliasesToProps"
|
|
8
|
+
import dedupeAttributes from "./transforms/dedupeAttributes"
|
|
8
9
|
import distributionToJustify from "./transforms/distributionToJustify"
|
|
9
10
|
import flexPropToExpand from "./transforms/flexPropToExpand"
|
|
10
11
|
import innerRefToRef from "./transforms/innerRefToRef"
|
|
@@ -33,6 +34,7 @@ const transform: Transform = (fileInfo, api, options) => {
|
|
|
33
34
|
convertStyleProps,
|
|
34
35
|
unsupportedProps,
|
|
35
36
|
auditSpreadProps,
|
|
37
|
+
dedupeAttributes,
|
|
36
38
|
moveFlexImport,
|
|
37
39
|
]
|
|
38
40
|
|
|
@@ -100,6 +100,107 @@ export default function Test() {
|
|
|
100
100
|
expect(result).not.toContain("StackView.BASELINE")
|
|
101
101
|
})
|
|
102
102
|
|
|
103
|
+
it('normalizes alignment="flex-start" to align="start"', () => {
|
|
104
|
+
const input = `
|
|
105
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
106
|
+
|
|
107
|
+
export default function Test() {
|
|
108
|
+
return <StackView alignment="flex-start">Test</StackView>
|
|
109
|
+
}
|
|
110
|
+
`.trim()
|
|
111
|
+
|
|
112
|
+
const result = applyTransform(input)
|
|
113
|
+
expect(result).toContain('align="start"')
|
|
114
|
+
expect(result).not.toContain("alignment")
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('normalizes alignment="flex-end" to align="end"', () => {
|
|
118
|
+
const input = `
|
|
119
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
120
|
+
|
|
121
|
+
export default function Test() {
|
|
122
|
+
return <StackView alignment="flex-end">Test</StackView>
|
|
123
|
+
}
|
|
124
|
+
`.trim()
|
|
125
|
+
|
|
126
|
+
const result = applyTransform(input)
|
|
127
|
+
expect(result).toContain('align="end"')
|
|
128
|
+
expect(result).not.toContain("alignment")
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("migrates a ternary with a valid string branch and an undefined pass-through branch", () => {
|
|
132
|
+
const input = `
|
|
133
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
134
|
+
|
|
135
|
+
export default function Test({ compact }) {
|
|
136
|
+
return <StackView alignment={compact ? 'center' : undefined}>Test</StackView>
|
|
137
|
+
}
|
|
138
|
+
`.trim()
|
|
139
|
+
|
|
140
|
+
const result = applyTransform(input)
|
|
141
|
+
expect(result).toContain('align={compact ? "center" : undefined}')
|
|
142
|
+
expect(result).not.toContain("alignment")
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("migrates a ternary with two valid string branches", () => {
|
|
146
|
+
const input = `
|
|
147
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
148
|
+
|
|
149
|
+
export default function Test({ compact }) {
|
|
150
|
+
return <StackView alignment={compact ? 'center' : 'end'}>Test</StackView>
|
|
151
|
+
}
|
|
152
|
+
`.trim()
|
|
153
|
+
|
|
154
|
+
const result = applyTransform(input)
|
|
155
|
+
expect(result).toContain('align={compact ? "center" : "end"}')
|
|
156
|
+
expect(result).not.toContain("alignment")
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("normalizes flex-start/flex-end within a ternary", () => {
|
|
160
|
+
const input = `
|
|
161
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
162
|
+
|
|
163
|
+
export default function Test({ compact }) {
|
|
164
|
+
return <StackView alignment={compact ? 'flex-start' : 'flex-end'}>Test</StackView>
|
|
165
|
+
}
|
|
166
|
+
`.trim()
|
|
167
|
+
|
|
168
|
+
const result = applyTransform(input)
|
|
169
|
+
expect(result).toContain('align={compact ? "start" : "end"}')
|
|
170
|
+
expect(result).not.toContain("alignment")
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("migrates a ternary with a dynamic branch, trusting it at runtime", () => {
|
|
174
|
+
const input = `
|
|
175
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
176
|
+
|
|
177
|
+
export default function Test({ compact, dynamicValue }) {
|
|
178
|
+
return <StackView alignment={compact ? 'center' : dynamicValue}>Test</StackView>
|
|
179
|
+
}
|
|
180
|
+
`.trim()
|
|
181
|
+
|
|
182
|
+
const result = applyTransform(input)
|
|
183
|
+
expect(result).toContain('align={compact ? "center" : dynamicValue}')
|
|
184
|
+
expect(result).not.toContain("alignment")
|
|
185
|
+
expect(result).not.toContain("TODO")
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it("flags a ternary with a fill branch and leaves it in place", () => {
|
|
189
|
+
const input = `
|
|
190
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
191
|
+
|
|
192
|
+
export default function Test({ compact }) {
|
|
193
|
+
return <StackView alignment={compact ? 'center' : 'fill'}>Test</StackView>
|
|
194
|
+
}
|
|
195
|
+
`.trim()
|
|
196
|
+
|
|
197
|
+
const result = applyTransform(input)
|
|
198
|
+
expect(result).toContain("alignment={compact ? 'center' : 'fill'}")
|
|
199
|
+
expect(result).toContain("TODO: tapestry-migration (alignment)")
|
|
200
|
+
expect(result).not.toContain('align="')
|
|
201
|
+
expect(result).not.toContain("align={")
|
|
202
|
+
})
|
|
203
|
+
|
|
103
204
|
it("flags alignment={StackView.FILL} with a TODO and leaves it in place", () => {
|
|
104
205
|
const input = `
|
|
105
206
|
import { StackView } from "@planningcenter/tapestry-react"
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { Transform } from "jscodeshift"
|
|
1
|
+
import { Expression, Transform } from "jscodeshift"
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { addCommentToAttribute } from "../../shared/actions/addCommentToAttribute"
|
|
4
4
|
import { getAttribute } from "../../shared/actions/getAttribute"
|
|
5
5
|
import { resolveConstantAttributeValue } from "../../shared/actions/resolveConstantAttributeValue"
|
|
6
6
|
import { hasAttribute } from "../../shared/conditions/hasAttribute"
|
|
7
7
|
import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
|
|
8
|
+
import { FLEX_START_END_MAP } from "./constants"
|
|
8
9
|
|
|
9
10
|
const VALID_ALIGN_VALUES = new Set([
|
|
10
11
|
"start",
|
|
@@ -23,42 +24,95 @@ const CONSTANT_ALIGN_MAP: Record<string, string> = {
|
|
|
23
24
|
STRETCH: "stretch",
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
const TODO_TEXT =
|
|
28
|
+
"could not migrate alignment to align automatically; convert this value to start, center, end, stretch, or baseline manually"
|
|
29
|
+
|
|
30
|
+
function normalizeAlignValue(raw: string): string | null {
|
|
31
|
+
const normalized = FLEX_START_END_MAP[raw] ?? raw
|
|
32
|
+
return VALID_ALIGN_VALUES.has(normalized) ? normalized : null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type BranchResolution =
|
|
36
|
+
| { kind: "invalid" }
|
|
37
|
+
| { kind: "passthrough" }
|
|
38
|
+
| { kind: "valid"; value: string }
|
|
39
|
+
|
|
40
|
+
function resolveBranch(node: Expression): BranchResolution {
|
|
41
|
+
if (node.type === "StringLiteral") {
|
|
42
|
+
const normalized = normalizeAlignValue(node.value)
|
|
43
|
+
return normalized !== null
|
|
44
|
+
? { kind: "valid", value: normalized }
|
|
45
|
+
: { kind: "invalid" }
|
|
46
|
+
}
|
|
47
|
+
if (node.type === "MemberExpression" && node.property.type === "Identifier") {
|
|
48
|
+
const mapped = CONSTANT_ALIGN_MAP[node.property.name]
|
|
49
|
+
if (mapped === undefined) return { kind: "passthrough" }
|
|
50
|
+
const normalized = normalizeAlignValue(mapped)
|
|
51
|
+
return normalized !== null
|
|
52
|
+
? { kind: "valid", value: normalized }
|
|
53
|
+
: { kind: "invalid" }
|
|
54
|
+
}
|
|
55
|
+
return { kind: "passthrough" }
|
|
56
|
+
}
|
|
57
|
+
|
|
26
58
|
const transform: Transform = attributeTransformFactory({
|
|
27
59
|
condition: hasAttribute("alignment"),
|
|
28
60
|
targetComponent: "StackView",
|
|
29
61
|
targetPackage: "@planningcenter/tapestry-react",
|
|
30
|
-
transform: (element, { j
|
|
62
|
+
transform: (element, { j }) => {
|
|
31
63
|
const alignmentAttribute = getAttribute({ element, name: "alignment" })
|
|
32
64
|
if (!alignmentAttribute) return false
|
|
33
65
|
|
|
66
|
+
const value = alignmentAttribute.value
|
|
67
|
+
|
|
68
|
+
if (
|
|
69
|
+
value?.type === "JSXExpressionContainer" &&
|
|
70
|
+
value.expression.type === "ConditionalExpression"
|
|
71
|
+
) {
|
|
72
|
+
const conditional = value.expression
|
|
73
|
+
const consequent = resolveBranch(conditional.consequent as Expression)
|
|
74
|
+
const alternate = resolveBranch(conditional.alternate as Expression)
|
|
75
|
+
|
|
76
|
+
if (consequent.kind !== "invalid" && alternate.kind !== "invalid") {
|
|
77
|
+
if (consequent.kind === "valid") {
|
|
78
|
+
conditional.consequent = j.stringLiteral(consequent.value)
|
|
79
|
+
}
|
|
80
|
+
if (alternate.kind === "valid") {
|
|
81
|
+
conditional.alternate = j.stringLiteral(alternate.value)
|
|
82
|
+
}
|
|
83
|
+
alignmentAttribute.name.name = "align"
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
addCommentToAttribute({
|
|
88
|
+
attribute: alignmentAttribute,
|
|
89
|
+
j,
|
|
90
|
+
text: TODO_TEXT,
|
|
91
|
+
})
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
|
|
34
95
|
const resolved = resolveConstantAttributeValue({
|
|
35
96
|
attribute: alignmentAttribute,
|
|
36
97
|
constantMap: CONSTANT_ALIGN_MAP,
|
|
37
98
|
})
|
|
38
|
-
const alignValue =
|
|
39
|
-
resolved !== null &&
|
|
40
|
-
(VALID_ALIGN_VALUES.has(resolved) || resolved === "fill")
|
|
41
|
-
? resolved
|
|
42
|
-
: null
|
|
43
99
|
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
100
|
+
if (resolved === "fill") {
|
|
101
|
+
addCommentToAttribute({
|
|
102
|
+
attribute: alignmentAttribute,
|
|
47
103
|
j,
|
|
48
|
-
|
|
49
|
-
source,
|
|
50
|
-
text: "could not migrate alignment to align automatically; convert this value to start, center, end, stretch, or baseline manually",
|
|
104
|
+
text: 'alignment="fill" has no Flex equivalent; remove this prop and use expand on the child element instead',
|
|
51
105
|
})
|
|
52
106
|
return true
|
|
53
107
|
}
|
|
54
108
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
109
|
+
const alignValue = resolved !== null ? normalizeAlignValue(resolved) : null
|
|
110
|
+
|
|
111
|
+
if (alignValue === null) {
|
|
112
|
+
addCommentToAttribute({
|
|
113
|
+
attribute: alignmentAttribute,
|
|
58
114
|
j,
|
|
59
|
-
|
|
60
|
-
source,
|
|
61
|
-
text: 'alignment="fill" has no Flex equivalent; remove this prop and use expand on the child element instead',
|
|
115
|
+
text: TODO_TEXT,
|
|
62
116
|
})
|
|
63
117
|
return true
|
|
64
118
|
}
|
|
@@ -6,11 +6,7 @@ import { transformAttributeName } from "../../shared/actions/transformAttributeN
|
|
|
6
6
|
import { hasAttribute } from "../../shared/conditions/hasAttribute"
|
|
7
7
|
import { orConditions } from "../../shared/conditions/orConditions"
|
|
8
8
|
import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
|
|
9
|
-
|
|
10
|
-
const FLEX_START_END_MAP: Record<string, string> = {
|
|
11
|
-
"flex-end": "end",
|
|
12
|
-
"flex-start": "start",
|
|
13
|
-
}
|
|
9
|
+
import { FLEX_START_END_MAP } from "./constants"
|
|
14
10
|
|
|
15
11
|
const VALID_JUSTIFY_VALUES = new Set([
|
|
16
12
|
"start",
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import jscodeshift from "jscodeshift"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
|
|
4
|
+
import transform from "./dedupeAttributes"
|
|
5
|
+
|
|
6
|
+
const j = jscodeshift.withParser("tsx")
|
|
7
|
+
|
|
8
|
+
function applyTransform(source: string): string | null {
|
|
9
|
+
const fileInfo = { path: "test.tsx", source }
|
|
10
|
+
return transform(
|
|
11
|
+
fileInfo,
|
|
12
|
+
{ j, jscodeshift: j, report: () => {}, stats: () => {} },
|
|
13
|
+
{}
|
|
14
|
+
) as string | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("dedupeAttributes transform", () => {
|
|
18
|
+
it("keeps the last of two duplicate attributes and drops the earlier one", () => {
|
|
19
|
+
const input = `
|
|
20
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
21
|
+
|
|
22
|
+
export default function Test() {
|
|
23
|
+
return <StackView direction="row" direction="column">Test</StackView>
|
|
24
|
+
}
|
|
25
|
+
`.trim()
|
|
26
|
+
|
|
27
|
+
const result = applyTransform(input)
|
|
28
|
+
expect(result).toContain('direction="column"')
|
|
29
|
+
expect(result?.match(/direction=/g)).toHaveLength(1)
|
|
30
|
+
expect(result).toContain("TODO: tapestry-migration (direction)")
|
|
31
|
+
expect(result).toContain("duplicate direction prop")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("keeps the last of three duplicate attributes and drops the earlier ones", () => {
|
|
35
|
+
const input = `
|
|
36
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
37
|
+
|
|
38
|
+
export default function Test() {
|
|
39
|
+
return <StackView align="start" align="center" align="end">Test</StackView>
|
|
40
|
+
}
|
|
41
|
+
`.trim()
|
|
42
|
+
|
|
43
|
+
const result = applyTransform(input)
|
|
44
|
+
expect(result).toContain('align="end"')
|
|
45
|
+
expect(result?.match(/align=/g)).toHaveLength(1)
|
|
46
|
+
expect(result).toContain("TODO: tapestry-migration (align)")
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("dedupes multiple different duplicated attribute names independently", () => {
|
|
50
|
+
const input = `
|
|
51
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
52
|
+
|
|
53
|
+
export default function Test() {
|
|
54
|
+
return <StackView direction="row" direction="column" align="start" align="end">Test</StackView>
|
|
55
|
+
}
|
|
56
|
+
`.trim()
|
|
57
|
+
|
|
58
|
+
const result = applyTransform(input)
|
|
59
|
+
expect(result?.match(/direction=/g)).toHaveLength(1)
|
|
60
|
+
expect(result?.match(/align=/g)).toHaveLength(1)
|
|
61
|
+
expect(result).toContain('direction="column"')
|
|
62
|
+
expect(result).toContain('align="end"')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("preserves other non-duplicate attributes untouched", () => {
|
|
66
|
+
const input = `
|
|
67
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
68
|
+
|
|
69
|
+
export default function Test() {
|
|
70
|
+
return <StackView direction="row" direction="column" gap={2}>Test</StackView>
|
|
71
|
+
}
|
|
72
|
+
`.trim()
|
|
73
|
+
|
|
74
|
+
const result = applyTransform(input)
|
|
75
|
+
expect(result).toContain("gap={2}")
|
|
76
|
+
expect(result?.match(/direction=/g)).toHaveLength(1)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("ignores spread attributes", () => {
|
|
80
|
+
const input = `
|
|
81
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
82
|
+
|
|
83
|
+
export default function Test() {
|
|
84
|
+
return <StackView {...props} direction="row" direction="column">Test</StackView>
|
|
85
|
+
}
|
|
86
|
+
`.trim()
|
|
87
|
+
|
|
88
|
+
const result = applyTransform(input)
|
|
89
|
+
expect(result).toContain("{...props}")
|
|
90
|
+
expect(result?.match(/direction=/g)).toHaveLength(1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("returns null when there are no duplicate attributes", () => {
|
|
94
|
+
const input = `
|
|
95
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
96
|
+
|
|
97
|
+
export default function Test() {
|
|
98
|
+
return <StackView direction="row" gap={2}>Test</StackView>
|
|
99
|
+
}
|
|
100
|
+
`.trim()
|
|
101
|
+
|
|
102
|
+
expect(applyTransform(input)).toBe(null)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("returns null when StackView is not imported from tapestry-react", () => {
|
|
106
|
+
const input = `
|
|
107
|
+
import { StackView } from "some-other-package"
|
|
108
|
+
|
|
109
|
+
export default function Test() {
|
|
110
|
+
return <StackView direction="row" direction="column">Test</StackView>
|
|
111
|
+
}
|
|
112
|
+
`.trim()
|
|
113
|
+
|
|
114
|
+
expect(applyTransform(input)).toBe(null)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("returns null for an empty file", () => {
|
|
118
|
+
expect(applyTransform("")).toBe(null)
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { JSXAttribute } from "jscodeshift"
|
|
2
|
+
|
|
3
|
+
import { addCommentToAttribute } from "../../shared/actions/addCommentToAttribute"
|
|
4
|
+
import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
|
|
5
|
+
|
|
6
|
+
export default attributeTransformFactory({
|
|
7
|
+
targetComponent: "StackView",
|
|
8
|
+
targetPackage: "@planningcenter/tapestry-react",
|
|
9
|
+
transform: (element, { j }) => {
|
|
10
|
+
const attrs = element.openingElement.attributes ?? []
|
|
11
|
+
const byName = new Map<string, JSXAttribute[]>()
|
|
12
|
+
|
|
13
|
+
for (const attr of attrs) {
|
|
14
|
+
if (attr.type !== "JSXAttribute") continue
|
|
15
|
+
if (attr.name.type !== "JSXIdentifier") continue
|
|
16
|
+
const name = attr.name.name
|
|
17
|
+
const group = byName.get(name)
|
|
18
|
+
if (group) {
|
|
19
|
+
group.push(attr)
|
|
20
|
+
} else {
|
|
21
|
+
byName.set(name, [attr])
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let changed = false
|
|
26
|
+
|
|
27
|
+
for (const [name, group] of byName) {
|
|
28
|
+
if (group.length < 2) continue
|
|
29
|
+
|
|
30
|
+
const kept = group[group.length - 1]
|
|
31
|
+
const dropped = group.slice(0, -1)
|
|
32
|
+
|
|
33
|
+
for (const attr of dropped) {
|
|
34
|
+
const idx = attrs.indexOf(attr)
|
|
35
|
+
attrs.splice(idx, 1)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
addCommentToAttribute({
|
|
39
|
+
attribute: kept,
|
|
40
|
+
j,
|
|
41
|
+
text: `duplicate ${name} prop after migration — an earlier ${name} value was dropped in favor of this one; verify this is correct`,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
changed = true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return changed
|
|
48
|
+
},
|
|
49
|
+
})
|
|
@@ -114,6 +114,65 @@ export default function Test() {
|
|
|
114
114
|
expect(result).not.toContain("StackView.SPACE_EVENLY")
|
|
115
115
|
})
|
|
116
116
|
|
|
117
|
+
it("migrates a ternary with a valid string branch and an undefined pass-through branch", () => {
|
|
118
|
+
const input = `
|
|
119
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
120
|
+
|
|
121
|
+
export default function Test({ compact }) {
|
|
122
|
+
return <StackView distribution={compact ? 'center' : undefined}>Test</StackView>
|
|
123
|
+
}
|
|
124
|
+
`.trim()
|
|
125
|
+
|
|
126
|
+
const result = applyTransform(input)
|
|
127
|
+
expect(result).toContain('justify={compact ? "center" : undefined}')
|
|
128
|
+
expect(result).not.toContain("distribution")
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("migrates a ternary with two valid string branches", () => {
|
|
132
|
+
const input = `
|
|
133
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
134
|
+
|
|
135
|
+
export default function Test({ compact }) {
|
|
136
|
+
return <StackView distribution={compact ? 'center' : 'start'}>Test</StackView>
|
|
137
|
+
}
|
|
138
|
+
`.trim()
|
|
139
|
+
|
|
140
|
+
const result = applyTransform(input)
|
|
141
|
+
expect(result).toContain('justify={compact ? "center" : "start"}')
|
|
142
|
+
expect(result).not.toContain("distribution")
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("migrates a ternary with a dynamic branch, trusting it at runtime", () => {
|
|
146
|
+
const input = `
|
|
147
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
148
|
+
|
|
149
|
+
export default function Test({ compact, dynamicValue }) {
|
|
150
|
+
return <StackView distribution={compact ? 'center' : dynamicValue}>Test</StackView>
|
|
151
|
+
}
|
|
152
|
+
`.trim()
|
|
153
|
+
|
|
154
|
+
const result = applyTransform(input)
|
|
155
|
+
expect(result).toContain('justify={compact ? "center" : dynamicValue}')
|
|
156
|
+
expect(result).not.toContain("distribution")
|
|
157
|
+
expect(result).not.toContain("TODO")
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it("flags a ternary with a fill branch and leaves it in place", () => {
|
|
161
|
+
const input = `
|
|
162
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
163
|
+
|
|
164
|
+
export default function Test({ compact }) {
|
|
165
|
+
return <StackView distribution={compact ? 'center' : 'fill'}>Test</StackView>
|
|
166
|
+
}
|
|
167
|
+
`.trim()
|
|
168
|
+
|
|
169
|
+
const result = applyTransform(input)
|
|
170
|
+
expect(result).toContain("distribution={compact ? 'center' : 'fill'}")
|
|
171
|
+
expect(result).toContain("TODO: tapestry-migration (distribution)")
|
|
172
|
+
expect(result).not.toContain('justify="')
|
|
173
|
+
expect(result).not.toContain("justify={")
|
|
174
|
+
})
|
|
175
|
+
|
|
117
176
|
it("flags distribution={StackView.FILL} with a TODO and leaves it in place", () => {
|
|
118
177
|
const input = `
|
|
119
178
|
import { StackView } from "@planningcenter/tapestry-react"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { Transform } from "jscodeshift"
|
|
1
|
+
import { Expression, Transform } from "jscodeshift"
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { addCommentToAttribute } from "../../shared/actions/addCommentToAttribute"
|
|
4
4
|
import { getAttribute } from "../../shared/actions/getAttribute"
|
|
5
5
|
import { resolveConstantAttributeValue } from "../../shared/actions/resolveConstantAttributeValue"
|
|
6
6
|
import { hasAttribute } from "../../shared/conditions/hasAttribute"
|
|
@@ -23,45 +23,94 @@ const CONSTANT_DISTRIBUTION_MAP: Record<string, string> = {
|
|
|
23
23
|
START: "start",
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
const TODO_TEXT =
|
|
27
|
+
"could not migrate distribution to justify automatically; convert this value to start, center, end, space-between, or space-evenly manually"
|
|
28
|
+
|
|
29
|
+
function resolveJustifyValue(raw: string): string | null {
|
|
30
|
+
return VALID_JUSTIFY_VALUES.has(raw) ? raw : null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type BranchResolution =
|
|
34
|
+
| { kind: "invalid" }
|
|
35
|
+
| { kind: "passthrough" }
|
|
36
|
+
| { kind: "valid"; value: string }
|
|
37
|
+
|
|
38
|
+
function resolveBranch(node: Expression): BranchResolution {
|
|
39
|
+
if (node.type === "StringLiteral") {
|
|
40
|
+
const value = resolveJustifyValue(node.value)
|
|
41
|
+
return value !== null ? { kind: "valid", value } : { kind: "invalid" }
|
|
42
|
+
}
|
|
43
|
+
if (node.type === "MemberExpression" && node.property.type === "Identifier") {
|
|
44
|
+
const mapped = CONSTANT_DISTRIBUTION_MAP[node.property.name]
|
|
45
|
+
if (mapped === undefined) return { kind: "passthrough" }
|
|
46
|
+
const value = resolveJustifyValue(mapped)
|
|
47
|
+
return value !== null ? { kind: "valid", value } : { kind: "invalid" }
|
|
48
|
+
}
|
|
49
|
+
return { kind: "passthrough" }
|
|
50
|
+
}
|
|
51
|
+
|
|
26
52
|
const transform: Transform = attributeTransformFactory({
|
|
27
53
|
condition: hasAttribute("distribution"),
|
|
28
54
|
targetComponent: "StackView",
|
|
29
55
|
targetPackage: "@planningcenter/tapestry-react",
|
|
30
|
-
transform: (element, { j
|
|
56
|
+
transform: (element, { j }) => {
|
|
31
57
|
const distributionAttribute = getAttribute({
|
|
32
58
|
element,
|
|
33
59
|
name: "distribution",
|
|
34
60
|
})
|
|
35
61
|
if (!distributionAttribute) return false
|
|
36
62
|
|
|
63
|
+
const value = distributionAttribute.value
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
value?.type === "JSXExpressionContainer" &&
|
|
67
|
+
value.expression.type === "ConditionalExpression"
|
|
68
|
+
) {
|
|
69
|
+
const conditional = value.expression
|
|
70
|
+
const consequent = resolveBranch(conditional.consequent as Expression)
|
|
71
|
+
const alternate = resolveBranch(conditional.alternate as Expression)
|
|
72
|
+
|
|
73
|
+
if (consequent.kind !== "invalid" && alternate.kind !== "invalid") {
|
|
74
|
+
if (consequent.kind === "valid") {
|
|
75
|
+
conditional.consequent = j.stringLiteral(consequent.value)
|
|
76
|
+
}
|
|
77
|
+
if (alternate.kind === "valid") {
|
|
78
|
+
conditional.alternate = j.stringLiteral(alternate.value)
|
|
79
|
+
}
|
|
80
|
+
distributionAttribute.name.name = "justify"
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
addCommentToAttribute({
|
|
85
|
+
attribute: distributionAttribute,
|
|
86
|
+
j,
|
|
87
|
+
text: TODO_TEXT,
|
|
88
|
+
})
|
|
89
|
+
return true
|
|
90
|
+
}
|
|
91
|
+
|
|
37
92
|
const resolved = resolveConstantAttributeValue({
|
|
38
93
|
attribute: distributionAttribute,
|
|
39
94
|
constantMap: CONSTANT_DISTRIBUTION_MAP,
|
|
40
95
|
})
|
|
41
|
-
const distributionValue =
|
|
42
|
-
resolved !== null &&
|
|
43
|
-
(VALID_JUSTIFY_VALUES.has(resolved) || resolved === "fill")
|
|
44
|
-
? resolved
|
|
45
|
-
: null
|
|
46
96
|
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
97
|
+
if (resolved === "fill") {
|
|
98
|
+
addCommentToAttribute({
|
|
99
|
+
attribute: distributionAttribute,
|
|
50
100
|
j,
|
|
51
|
-
|
|
52
|
-
source,
|
|
53
|
-
text: "could not migrate distribution to justify automatically; convert this value to start, center, end, space-between, or space-evenly manually",
|
|
101
|
+
text: 'distribution="fill" has no Flex equivalent; remove this prop and use expand on the child elements instead',
|
|
54
102
|
})
|
|
55
103
|
return true
|
|
56
104
|
}
|
|
57
105
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
106
|
+
const distributionValue =
|
|
107
|
+
resolved !== null ? resolveJustifyValue(resolved) : null
|
|
108
|
+
|
|
109
|
+
if (distributionValue === null) {
|
|
110
|
+
addCommentToAttribute({
|
|
111
|
+
attribute: distributionAttribute,
|
|
61
112
|
j,
|
|
62
|
-
|
|
63
|
-
source,
|
|
64
|
-
text: 'distribution="fill" has no Flex equivalent; remove this prop and use expand on the child elements instead',
|
|
113
|
+
text: TODO_TEXT,
|
|
65
114
|
})
|
|
66
115
|
return true
|
|
67
116
|
}
|
|
@@ -107,6 +107,22 @@ export default function Test() {
|
|
|
107
107
|
expect(result).not.toContain("TODO")
|
|
108
108
|
})
|
|
109
109
|
|
|
110
|
+
it("expands flex={2} into grow={2} shrink={1} basis='0'", () => {
|
|
111
|
+
const input = `
|
|
112
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
113
|
+
export default function Test() {
|
|
114
|
+
return <StackView flex={2}>Test</StackView>
|
|
115
|
+
}
|
|
116
|
+
`.trim()
|
|
117
|
+
|
|
118
|
+
const result = applyTransform(input)
|
|
119
|
+
expect(result).toContain("grow={2}")
|
|
120
|
+
expect(result).toContain("shrink={1}")
|
|
121
|
+
expect(result).toContain('basis="0"')
|
|
122
|
+
expect(result).not.toContain("flex=")
|
|
123
|
+
expect(result).not.toContain("TODO")
|
|
124
|
+
})
|
|
125
|
+
|
|
110
126
|
it("adds a TODO for a dynamic flex value", () => {
|
|
111
127
|
const input = `
|
|
112
128
|
import { StackView } from "@planningcenter/tapestry-react"
|
|
@@ -46,19 +46,19 @@ export default attributeTransformFactory({
|
|
|
46
46
|
return true
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
// flex={
|
|
49
|
+
// flex={N} (N !== 1) → grow={N} shrink={1} basis="0"
|
|
50
50
|
if (
|
|
51
51
|
value?.type === "JSXExpressionContainer" &&
|
|
52
|
-
value.expression.type === "NumericLiteral"
|
|
53
|
-
(value.expression as { value: number }).value === 0
|
|
52
|
+
value.expression.type === "NumericLiteral"
|
|
54
53
|
) {
|
|
54
|
+
const grow = (value.expression as { value: number }).value
|
|
55
55
|
const idx = attrs.indexOf(flexAttr)
|
|
56
56
|
attrs.splice(
|
|
57
57
|
idx,
|
|
58
58
|
1,
|
|
59
59
|
j.jsxAttribute(
|
|
60
60
|
j.jsxIdentifier("grow"),
|
|
61
|
-
j.jsxExpressionContainer(j.numericLiteral(
|
|
61
|
+
j.jsxExpressionContainer(j.numericLiteral(grow))
|
|
62
62
|
),
|
|
63
63
|
j.jsxAttribute(
|
|
64
64
|
j.jsxIdentifier("shrink"),
|
|
@@ -293,6 +293,22 @@ export default function Test() {
|
|
|
293
293
|
expect(result).not.toContain("TODO")
|
|
294
294
|
})
|
|
295
295
|
|
|
296
|
+
it('flags flex="N N basis" inside a breakpoint with a TODO instead of duplicating an existing grow key', () => {
|
|
297
|
+
const input = `
|
|
298
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
299
|
+
|
|
300
|
+
export default function Test() {
|
|
301
|
+
return <StackView mediaQueries={{ md: { flex: "1 0 0", grow: 5 } }}>Test</StackView>
|
|
302
|
+
}
|
|
303
|
+
`.trim()
|
|
304
|
+
|
|
305
|
+
const result = applyTransform(input)
|
|
306
|
+
expect(result).toContain("responsive=")
|
|
307
|
+
expect(result).toContain("TODO")
|
|
308
|
+
expect(result).toContain("grow: 5")
|
|
309
|
+
expect(result?.match(/grow:/g)).toHaveLength(1)
|
|
310
|
+
})
|
|
311
|
+
|
|
296
312
|
it('flags flex="inherit" inside a breakpoint with a generic TODO, not an expand-specific one', () => {
|
|
297
313
|
const input = `
|
|
298
314
|
import { StackView } from "@planningcenter/tapestry-react"
|
|
@@ -318,7 +334,7 @@ export default function Test() {
|
|
|
318
334
|
|
|
319
335
|
const result = applyTransform(input)
|
|
320
336
|
expect(result).toContain("TODO")
|
|
321
|
-
expect(result).
|
|
337
|
+
expect(result).toContain("expand is not supported")
|
|
322
338
|
})
|
|
323
339
|
|
|
324
340
|
it("flags flex={1} inside a breakpoint with a TODO (expand not supported in responsive)", () => {
|
|
@@ -353,6 +369,71 @@ export default function Test() {
|
|
|
353
369
|
expect(result).not.toContain("TODO")
|
|
354
370
|
})
|
|
355
371
|
|
|
372
|
+
it("flags flex={0} inside a breakpoint with a TODO instead of duplicating an existing grow key", () => {
|
|
373
|
+
const input = `
|
|
374
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
375
|
+
|
|
376
|
+
export default function Test() {
|
|
377
|
+
return <StackView mediaQueries={{ md: { flex: 0, grow: 5 } }}>Test</StackView>
|
|
378
|
+
}
|
|
379
|
+
`.trim()
|
|
380
|
+
|
|
381
|
+
const result = applyTransform(input)
|
|
382
|
+
expect(result).toContain("responsive=")
|
|
383
|
+
expect(result).toContain("TODO")
|
|
384
|
+
expect(result).toContain("grow: 5")
|
|
385
|
+
expect(result?.match(/grow:/g)).toHaveLength(1)
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it("flags axis inside a breakpoint with a TODO instead of duplicating an existing direction key", () => {
|
|
389
|
+
const input = `
|
|
390
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
391
|
+
|
|
392
|
+
export default function Test() {
|
|
393
|
+
return <StackView mediaQueries={{ md: { axis: "horizontal", direction: "column" } }}>Test</StackView>
|
|
394
|
+
}
|
|
395
|
+
`.trim()
|
|
396
|
+
|
|
397
|
+
const result = applyTransform(input)
|
|
398
|
+
expect(result).toContain("responsive=")
|
|
399
|
+
expect(result).toContain("TODO")
|
|
400
|
+
expect(result).toContain('direction: "column"')
|
|
401
|
+
expect(result?.match(/direction:/g)).toHaveLength(1)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it("flags both axis and flexDirection with a TODO when they both target direction in the same breakpoint", () => {
|
|
405
|
+
const input = `
|
|
406
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
407
|
+
|
|
408
|
+
export default function Test() {
|
|
409
|
+
return <StackView mediaQueries={{ md: { axis: "horizontal", flexDirection: "column" } }}>Test</StackView>
|
|
410
|
+
}
|
|
411
|
+
`.trim()
|
|
412
|
+
|
|
413
|
+
const result = applyTransform(input)
|
|
414
|
+
expect(result).toContain("responsive=")
|
|
415
|
+
expect(result).toContain("TODO")
|
|
416
|
+
expect(result).toContain('axis: "horizontal"')
|
|
417
|
+
expect(result).toContain('flexDirection: "column"')
|
|
418
|
+
expect(result?.match(/direction:/g)).toBeNull()
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it("flags alignItems inside a breakpoint with a TODO instead of duplicating an existing align key", () => {
|
|
422
|
+
const input = `
|
|
423
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
424
|
+
|
|
425
|
+
export default function Test() {
|
|
426
|
+
return <StackView mediaQueries={{ md: { alignItems: "center", align: "start" } }}>Test</StackView>
|
|
427
|
+
}
|
|
428
|
+
`.trim()
|
|
429
|
+
|
|
430
|
+
const result = applyTransform(input)
|
|
431
|
+
expect(result).toContain("responsive=")
|
|
432
|
+
expect(result).toContain("TODO")
|
|
433
|
+
expect(result).toContain('align: "start"')
|
|
434
|
+
expect(result?.match(/align:/g)).toHaveLength(1)
|
|
435
|
+
})
|
|
436
|
+
|
|
356
437
|
it("flags an unsupported breakpoint (xxs) with a TODO", () => {
|
|
357
438
|
const input = `
|
|
358
439
|
import { StackView } from "@planningcenter/tapestry-react"
|
|
@@ -5,6 +5,7 @@ import { addCommentToAttribute } from "../../shared/actions/addCommentToAttribut
|
|
|
5
5
|
import { getAttribute } from "../../shared/actions/getAttribute"
|
|
6
6
|
import { hasAttribute } from "../../shared/conditions/hasAttribute"
|
|
7
7
|
import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
|
|
8
|
+
import { FLEX_START_END_MAP } from "./constants"
|
|
8
9
|
|
|
9
10
|
function commentOnAttr(
|
|
10
11
|
attr: JSXAttribute,
|
|
@@ -88,11 +89,6 @@ const PADDING_TODO =
|
|
|
88
89
|
`use a Tapestry spacing scale number (${PADDING_SCALE_LIST}) ` +
|
|
89
90
|
"for Flex padding, or use style={{ padding: ... }} for arbitrary CSS values"
|
|
90
91
|
|
|
91
|
-
const FLEX_START_END_MAP: Record<string, string> = {
|
|
92
|
-
"flex-end": "end",
|
|
93
|
-
"flex-start": "start",
|
|
94
|
-
}
|
|
95
|
-
|
|
96
92
|
const FLEX_RESPONSIVE_PASSTHROUGH = new Set([
|
|
97
93
|
"align",
|
|
98
94
|
"alignSelf",
|
|
@@ -331,7 +327,8 @@ function planInnerProp(
|
|
|
331
327
|
|
|
332
328
|
if (propName === "flex") {
|
|
333
329
|
if (node.type === "StringLiteral" && typeof node.value === "string") {
|
|
334
|
-
const
|
|
330
|
+
const raw = (node.value as string).trim()
|
|
331
|
+
const parts = raw.split(/\s+/)
|
|
335
332
|
if (parts.length === 3) {
|
|
336
333
|
const grow = Number(parts[0])
|
|
337
334
|
const shrink = Number(parts[1])
|
|
@@ -346,6 +343,11 @@ function planInnerProp(
|
|
|
346
343
|
}
|
|
347
344
|
}
|
|
348
345
|
}
|
|
346
|
+
if (raw === "1") {
|
|
347
|
+
return {
|
|
348
|
+
todo: "expand is not supported in responsive; use grow, shrink, and basis individually",
|
|
349
|
+
}
|
|
350
|
+
}
|
|
349
351
|
return {
|
|
350
352
|
todo: "could not migrate flex automatically; use grow, shrink, and basis individually",
|
|
351
353
|
}
|
|
@@ -442,6 +444,15 @@ const transform: Transform = attributeTransformFactory({
|
|
|
442
444
|
return true
|
|
443
445
|
}
|
|
444
446
|
|
|
447
|
+
const pendingRenames: Array<{
|
|
448
|
+
apply: () => void
|
|
449
|
+
name: string
|
|
450
|
+
newKeys: string[]
|
|
451
|
+
}> = []
|
|
452
|
+
const finalKeyCounts = new Map<string, number>()
|
|
453
|
+
const countKey = (key: string) =>
|
|
454
|
+
finalKeyCounts.set(key, (finalKeyCounts.get(key) ?? 0) + 1)
|
|
455
|
+
|
|
445
456
|
for (const innerProp of outerVal.properties) {
|
|
446
457
|
if (innerProp.type !== "ObjectProperty") {
|
|
447
458
|
innerTodos.push({
|
|
@@ -463,30 +474,58 @@ const transform: Transform = attributeTransformFactory({
|
|
|
463
474
|
}
|
|
464
475
|
|
|
465
476
|
const result = planInnerProp(innerKey.name, innerProp.value, j)
|
|
466
|
-
if (result === null)
|
|
477
|
+
if (result === null) {
|
|
478
|
+
countKey(innerKey.name)
|
|
479
|
+
continue
|
|
480
|
+
}
|
|
467
481
|
if ("todo" in result) {
|
|
482
|
+
countKey(innerKey.name)
|
|
468
483
|
innerTodos.push({ key: innerKey.name, message: result.todo })
|
|
469
484
|
continue
|
|
470
485
|
}
|
|
471
486
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
487
|
+
const newKeys =
|
|
488
|
+
"newEntries" in result
|
|
489
|
+
? result.newEntries.map(({ newKey }) => newKey)
|
|
490
|
+
: [result.newKey]
|
|
491
|
+
newKeys.forEach(countKey)
|
|
492
|
+
|
|
493
|
+
pendingRenames.push({
|
|
494
|
+
apply: () => {
|
|
495
|
+
if ("newEntries" in result) {
|
|
496
|
+
const entries = result.newEntries
|
|
497
|
+
const idx = outerVal.properties.indexOf(innerProp)
|
|
498
|
+
outerVal.properties.splice(
|
|
499
|
+
idx,
|
|
500
|
+
1,
|
|
501
|
+
...entries.map(({ newKey, newValue }) =>
|
|
502
|
+
j.objectProperty(j.identifier(newKey), newValue)
|
|
503
|
+
)
|
|
481
504
|
)
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
505
|
+
} else {
|
|
506
|
+
innerKey.name = result.newKey
|
|
507
|
+
innerProp.value = result.newValue
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
name: innerKey.name,
|
|
511
|
+
newKeys,
|
|
512
|
+
})
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
for (const plan of pendingRenames) {
|
|
516
|
+
const collidingKeys = plan.newKeys.filter(
|
|
517
|
+
(key) => (finalKeyCounts.get(key) ?? 0) > 1
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
if (collidingKeys.length > 0) {
|
|
521
|
+
innerTodos.push({
|
|
522
|
+
key: plan.name,
|
|
523
|
+
message: `could not migrate ${plan.name} automatically — it would duplicate the ${collidingKeys.join(", ")} key already present (or produced by another prop) in this breakpoint; set the target prop(s) individually instead`,
|
|
488
524
|
})
|
|
525
|
+
continue
|
|
489
526
|
}
|
|
527
|
+
|
|
528
|
+
mutations.push(plan.apply)
|
|
490
529
|
}
|
|
491
530
|
}
|
|
492
531
|
|
|
@@ -136,6 +136,93 @@ export default function Test({ spacingProp }) {
|
|
|
136
136
|
expect(result).not.toContain("gap=")
|
|
137
137
|
})
|
|
138
138
|
|
|
139
|
+
it("renames spacing to gap for a ternary with two in-scale numeric branches", () => {
|
|
140
|
+
const input = `
|
|
141
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
142
|
+
|
|
143
|
+
export default function Test({ compact }) {
|
|
144
|
+
return <StackView spacing={compact ? 1.5 : 2}>Test</StackView>
|
|
145
|
+
}
|
|
146
|
+
`.trim()
|
|
147
|
+
|
|
148
|
+
const result = applyTransform(input)
|
|
149
|
+
expect(result).toContain("gap={compact ? 1.5 : 2}")
|
|
150
|
+
expect(result).not.toContain("spacing")
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("renames spacing to gap for a ternary with an undefined pass-through branch", () => {
|
|
154
|
+
const input = `
|
|
155
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
156
|
+
|
|
157
|
+
export default function Test({ compact }) {
|
|
158
|
+
return <StackView spacing={compact ? 2 : undefined}>Test</StackView>
|
|
159
|
+
}
|
|
160
|
+
`.trim()
|
|
161
|
+
|
|
162
|
+
const result = applyTransform(input)
|
|
163
|
+
expect(result).toContain("gap={compact ? 2 : undefined}")
|
|
164
|
+
expect(result).not.toContain("spacing")
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it("renames spacing to gap for a ternary with a null pass-through branch", () => {
|
|
168
|
+
const input = `
|
|
169
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
170
|
+
|
|
171
|
+
export default function Test({ compact }) {
|
|
172
|
+
return <StackView spacing={compact ? 2 : null}>Test</StackView>
|
|
173
|
+
}
|
|
174
|
+
`.trim()
|
|
175
|
+
|
|
176
|
+
const result = applyTransform(input)
|
|
177
|
+
expect(result).toContain("gap={compact ? 2 : null}")
|
|
178
|
+
expect(result).not.toContain("spacing")
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it("flags a ternary with one off-scale branch with a TODO", () => {
|
|
182
|
+
const input = `
|
|
183
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
184
|
+
|
|
185
|
+
export default function Test({ compact }) {
|
|
186
|
+
return <StackView spacing={compact ? 1.5 : 10}>Test</StackView>
|
|
187
|
+
}
|
|
188
|
+
`.trim()
|
|
189
|
+
|
|
190
|
+
const result = applyTransform(input)
|
|
191
|
+
expect(result).toContain("spacing={compact ? 1.5 : 10}")
|
|
192
|
+
expect(result).toContain("TODO: tapestry-migration (spacing)")
|
|
193
|
+
expect(result).not.toContain("gap=")
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it("flags a ternary with a ReactNode branch with a TODO", () => {
|
|
197
|
+
const input = `
|
|
198
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
199
|
+
|
|
200
|
+
export default function Test({ compact }) {
|
|
201
|
+
return <StackView spacing={compact ? 1.5 : <Divider />}>Test</StackView>
|
|
202
|
+
}
|
|
203
|
+
`.trim()
|
|
204
|
+
|
|
205
|
+
const result = applyTransform(input)
|
|
206
|
+
expect(result).toContain("spacing={compact ? 1.5 : <Divider />}")
|
|
207
|
+
expect(result).toContain("TODO: tapestry-migration (spacing)")
|
|
208
|
+
expect(result).not.toContain("gap=")
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it("renames spacing to gap for a ternary with a dynamic branch, trusting it at runtime", () => {
|
|
212
|
+
const input = `
|
|
213
|
+
import { StackView } from "@planningcenter/tapestry-react"
|
|
214
|
+
|
|
215
|
+
export default function Test({ compact, dynamicValue }) {
|
|
216
|
+
return <StackView spacing={compact ? 1.5 : dynamicValue}>Test</StackView>
|
|
217
|
+
}
|
|
218
|
+
`.trim()
|
|
219
|
+
|
|
220
|
+
const result = applyTransform(input)
|
|
221
|
+
expect(result).toContain("gap={compact ? 1.5 : dynamicValue}")
|
|
222
|
+
expect(result).not.toContain("spacing")
|
|
223
|
+
expect(result).not.toContain("TODO")
|
|
224
|
+
})
|
|
225
|
+
|
|
139
226
|
it("respects an aliased StackView import", () => {
|
|
140
227
|
const input = `
|
|
141
228
|
import { StackView as Stack } from "@planningcenter/tapestry-react"
|
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
import { JSXAttribute, Transform } from "jscodeshift"
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { addCommentToAttribute } from "../../shared/actions/addCommentToAttribute"
|
|
4
4
|
import { getAttribute } from "../../shared/actions/getAttribute"
|
|
5
5
|
import { hasAttribute } from "../../shared/conditions/hasAttribute"
|
|
6
6
|
import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
|
|
7
7
|
|
|
8
8
|
const VALID_GAP_VALUES = new Set([0, 0.25, 0.5, 1, 1.5, 2, 3, 4, 5, 6, 7])
|
|
9
9
|
|
|
10
|
+
const TODO_TEXT =
|
|
11
|
+
"could not migrate spacing to gap automatically; use a Tapestry spacing scale number (0, 0.25, 0.5, 1, 1.5, 2, 3, 4, 5, 6, 7) for gap, or for ReactNode spacers add explicit margin to the spacer element instead"
|
|
12
|
+
|
|
13
|
+
function isKnownInvalidBranch(node: unknown): boolean {
|
|
14
|
+
if (typeof node !== "object" || node === null) return false
|
|
15
|
+
const typed = node as { type: string; value?: unknown }
|
|
16
|
+
if (typed.type === "NumericLiteral" && typeof typed.value === "number") {
|
|
17
|
+
return !VALID_GAP_VALUES.has(typed.value)
|
|
18
|
+
}
|
|
19
|
+
if (typed.type === "StringLiteral") return true
|
|
20
|
+
return typed.type === "JSXElement" || typed.type === "JSXFragment"
|
|
21
|
+
}
|
|
22
|
+
|
|
10
23
|
function resolveSpacingValue(attribute: JSXAttribute): number | null {
|
|
11
24
|
const value = attribute.value
|
|
12
25
|
if (
|
|
@@ -19,24 +32,29 @@ function resolveSpacingValue(attribute: JSXAttribute): number | null {
|
|
|
19
32
|
return null
|
|
20
33
|
}
|
|
21
34
|
|
|
35
|
+
function isAlreadyValidTernary(attribute: JSXAttribute): boolean {
|
|
36
|
+
const value = attribute.value
|
|
37
|
+
if (value?.type !== "JSXExpressionContainer") return false
|
|
38
|
+
const expr = value.expression
|
|
39
|
+
if (expr.type !== "ConditionalExpression") return false
|
|
40
|
+
return (
|
|
41
|
+
!isKnownInvalidBranch(expr.consequent) &&
|
|
42
|
+
!isKnownInvalidBranch(expr.alternate)
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
22
46
|
const transform: Transform = attributeTransformFactory({
|
|
23
47
|
condition: hasAttribute("spacing"),
|
|
24
48
|
targetComponent: "StackView",
|
|
25
49
|
targetPackage: "@planningcenter/tapestry-react",
|
|
26
|
-
transform: (element, { j
|
|
50
|
+
transform: (element, { j }) => {
|
|
27
51
|
const spacingAttribute = getAttribute({ element, name: "spacing" })
|
|
28
52
|
if (!spacingAttribute) return false
|
|
29
53
|
|
|
30
54
|
const spacingValue = resolveSpacingValue(spacingAttribute)
|
|
31
55
|
|
|
32
|
-
if (spacingValue === null) {
|
|
33
|
-
|
|
34
|
-
element,
|
|
35
|
-
j,
|
|
36
|
-
scope: "spacing",
|
|
37
|
-
source,
|
|
38
|
-
text: "could not migrate spacing to gap automatically; use a Tapestry spacing scale number (0, 0.25, 0.5, 1, 1.5, 2, 3, 4, 5, 6, 7) for gap, or for ReactNode spacers add explicit margin to the spacer element instead",
|
|
39
|
-
})
|
|
56
|
+
if (spacingValue === null && !isAlreadyValidTernary(spacingAttribute)) {
|
|
57
|
+
addCommentToAttribute({ attribute: spacingAttribute, j, text: TODO_TEXT })
|
|
40
58
|
return true
|
|
41
59
|
}
|
|
42
60
|
|