@planningcenter/tapestry-migration-cli 3.4.1 → 3.6.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/README.md +59 -0
- package/dist/tapestry-react-shim.cjs +5 -1
- package/package.json +3 -3
- package/src/availableComponents.ts +14 -0
- package/src/components/button/transforms/tooltipToWrapper.test.ts +50 -3
- package/src/components/button/transforms/tooltipToWrapper.ts +18 -0
- package/src/components/dropdown/index.test.ts +67 -0
- package/src/components/dropdown/index.ts +4 -0
- package/src/components/dropdown/transforms/placementIdToMenu.test.ts +6 -1
- package/src/components/dropdown/transforms/placementIdToMenu.ts +1 -1
- package/src/components/dropdown/transforms/variantToKind.test.ts +292 -0
- package/src/components/dropdown/transforms/variantToKind.ts +138 -0
- package/src/components/dropdown/transforms/wrapIconTrigger.test.ts +336 -0
- package/src/components/dropdown/transforms/wrapIconTrigger.ts +233 -0
- package/src/components/flex/index.test.ts +140 -0
- package/src/components/flex/index.ts +40 -0
- package/src/components/flex/transforms/alignmentToAlign.test.ts +185 -0
- package/src/components/flex/transforms/alignmentToAlign.ts +73 -0
- package/src/components/flex/transforms/axisToDirection.test.ts +140 -0
- package/src/components/flex/transforms/axisToDirection.ts +51 -0
- package/src/components/flex/transforms/distributionToJustify.test.ts +214 -0
- package/src/components/flex/transforms/distributionToJustify.ts +76 -0
- package/src/components/flex/transforms/innerRefToRef.test.ts +100 -0
- package/src/components/flex/transforms/innerRefToRef.ts +14 -0
- package/src/components/flex/transforms/moveFlexImport.test.ts +202 -0
- package/src/components/flex/transforms/moveFlexImport.ts +14 -0
- package/src/components/flex/transforms/setDefaultAxis.test.ts +114 -0
- package/src/components/flex/transforms/setDefaultAxis.ts +29 -0
- package/src/components/flex/transforms/spacingToGap.test.ts +176 -0
- package/src/components/flex/transforms/spacingToGap.ts +49 -0
- package/src/components/select/index.ts +2 -0
- package/src/components/select/transforms/onChangeSignature.test.ts +224 -0
- package/src/components/select/transforms/onChangeSignature.ts +172 -0
- package/src/components/shared/actions/resolveConstantAttributeValue.test.ts +122 -0
- package/src/components/shared/actions/resolveConstantAttributeValue.ts +31 -0
- package/src/components/toggle-switch/index.ts +2 -0
- package/src/components/toggle-switch/transforms/onClickToOnChange.test.ts +210 -0
- package/src/components/toggle-switch/transforms/onClickToOnChange.ts +71 -0
- package/src/index.test.ts +79 -0
- package/src/index.ts +34 -18
- package/src/migrationList.ts +22 -0
- package/src/utils/componentLabel.test.ts +61 -0
- package/src/utils/componentLabel.ts +15 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { JSXAttribute, JSXElement, Transform } from "jscodeshift"
|
|
2
|
+
|
|
3
|
+
import { addAttribute } from "../../shared/actions/addAttribute"
|
|
4
|
+
import { addComment } from "../../shared/actions/addComment"
|
|
5
|
+
import { getAttribute } from "../../shared/actions/getAttribute"
|
|
6
|
+
import { removeAttribute } from "../../shared/actions/removeAttribute"
|
|
7
|
+
import { hasAttribute } from "../../shared/conditions/hasAttribute"
|
|
8
|
+
import { orConditions } from "../../shared/conditions/orConditions"
|
|
9
|
+
import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
|
|
10
|
+
|
|
11
|
+
const THEME_KIND_MAP: Record<string, Record<string, string>> = {
|
|
12
|
+
default: {
|
|
13
|
+
default: "neutral",
|
|
14
|
+
fill: "neutral",
|
|
15
|
+
naked: "ghost",
|
|
16
|
+
outline: "secondary",
|
|
17
|
+
},
|
|
18
|
+
error: {
|
|
19
|
+
default: "delete",
|
|
20
|
+
fill: "delete",
|
|
21
|
+
naked: "ghost-delete",
|
|
22
|
+
outline: "secondary-delete",
|
|
23
|
+
},
|
|
24
|
+
neutral: {
|
|
25
|
+
default: "neutral",
|
|
26
|
+
fill: "neutral",
|
|
27
|
+
naked: "ghost",
|
|
28
|
+
outline: "secondary",
|
|
29
|
+
},
|
|
30
|
+
primary: {
|
|
31
|
+
default: "primary",
|
|
32
|
+
fill: "primary",
|
|
33
|
+
naked: "ghost-interaction",
|
|
34
|
+
outline: "secondary-interaction",
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const KNOWN_TRIGGER_BUTTONS = new Set(["DropdownButton", "DropdownIconButton"])
|
|
39
|
+
|
|
40
|
+
function normalizeTheme(value: string): string {
|
|
41
|
+
if (value === "destroy" || value === "delete") return "error"
|
|
42
|
+
if (value === "info" || value === "success" || value === "interaction")
|
|
43
|
+
return "primary"
|
|
44
|
+
return value
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeVariant(value: string): string {
|
|
48
|
+
if (value === "ghost") return "naked"
|
|
49
|
+
if (value === "solid") return "fill"
|
|
50
|
+
return value
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getStaticStringValue(
|
|
54
|
+
attr: JSXAttribute | undefined,
|
|
55
|
+
defaultValue: string
|
|
56
|
+
): string | null {
|
|
57
|
+
if (!attr || !attr.value) return defaultValue
|
|
58
|
+
if (attr.value.type === "StringLiteral") return attr.value.value
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mapToKind(theme: string, variant: string): string | null {
|
|
63
|
+
return (
|
|
64
|
+
THEME_KIND_MAP[normalizeTheme(theme)]?.[normalizeVariant(variant)] ?? null
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findDropdownTrigger(element: JSXElement): JSXElement | undefined {
|
|
69
|
+
return (element.children ?? []).find(
|
|
70
|
+
(child): child is JSXElement =>
|
|
71
|
+
child.type === "JSXElement" &&
|
|
72
|
+
child.openingElement.name.type === "JSXIdentifier" &&
|
|
73
|
+
child.openingElement.name.name === "DropdownTrigger"
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function findFirstJSXElementChild(element: JSXElement): JSXElement | undefined {
|
|
78
|
+
return (element.children ?? []).find(
|
|
79
|
+
(child): child is JSXElement => child.type === "JSXElement"
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const transform: Transform = attributeTransformFactory({
|
|
84
|
+
condition: orConditions(hasAttribute("variant"), hasAttribute("theme")),
|
|
85
|
+
targetComponent: "Dropdown",
|
|
86
|
+
targetPackage: "@planningcenter/tapestry-react",
|
|
87
|
+
transform: (element, { j, source }) => {
|
|
88
|
+
const variantAttr = getAttribute({ element, name: "variant" })
|
|
89
|
+
const themeAttr = getAttribute({ element, name: "theme" })
|
|
90
|
+
|
|
91
|
+
const variantValue = getStaticStringValue(variantAttr, "fill")
|
|
92
|
+
const themeValue = getStaticStringValue(themeAttr, "default")
|
|
93
|
+
|
|
94
|
+
if (variantValue === null || themeValue === null) return false
|
|
95
|
+
|
|
96
|
+
const mappedKind = mapToKind(themeValue, variantValue)
|
|
97
|
+
if (!mappedKind) return false
|
|
98
|
+
|
|
99
|
+
const triggerChild = findDropdownTrigger(element)
|
|
100
|
+
if (!triggerChild) return false
|
|
101
|
+
|
|
102
|
+
const buttonChild = findFirstJSXElementChild(triggerChild)
|
|
103
|
+
if (!buttonChild) return false
|
|
104
|
+
|
|
105
|
+
const buttonName =
|
|
106
|
+
buttonChild.openingElement.name.type === "JSXIdentifier"
|
|
107
|
+
? buttonChild.openingElement.name.name
|
|
108
|
+
: null
|
|
109
|
+
|
|
110
|
+
if (variantAttr) removeAttribute("variant", { element, j, source })
|
|
111
|
+
if (themeAttr) removeAttribute("theme", { element, j, source })
|
|
112
|
+
|
|
113
|
+
const scope =
|
|
114
|
+
variantAttr && themeAttr
|
|
115
|
+
? "variant/theme"
|
|
116
|
+
: themeAttr
|
|
117
|
+
? "theme"
|
|
118
|
+
: "variant"
|
|
119
|
+
|
|
120
|
+
const existingKind = getAttribute({ element: buttonChild, name: "kind" })
|
|
121
|
+
|
|
122
|
+
if (buttonName && KNOWN_TRIGGER_BUTTONS.has(buttonName) && !existingKind) {
|
|
123
|
+
addAttribute({ element: buttonChild, j, name: "kind", value: mappedKind })
|
|
124
|
+
} else {
|
|
125
|
+
addComment({
|
|
126
|
+
element,
|
|
127
|
+
j,
|
|
128
|
+
scope,
|
|
129
|
+
source,
|
|
130
|
+
text: `add kind="${mappedKind}" to the trigger button manually`,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return true
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
export default transform
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import jscodeshift from "jscodeshift"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
|
|
4
|
+
import transform from "./wrapIconTrigger"
|
|
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("wrapIconTrigger transform", () => {
|
|
18
|
+
describe("object-form icon", () => {
|
|
19
|
+
it("converts icon object + title to DropdownTrigger + DropdownIconButton", () => {
|
|
20
|
+
const input = `
|
|
21
|
+
import { Dropdown } from "@planningcenter/tapestry-react"
|
|
22
|
+
|
|
23
|
+
export default function Test() {
|
|
24
|
+
return (
|
|
25
|
+
<Dropdown icon={{ name: "general.pencil" }} title="Edit options">
|
|
26
|
+
<DropdownMenu />
|
|
27
|
+
</Dropdown>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
`.trim()
|
|
31
|
+
|
|
32
|
+
const result = applyTransform(input)
|
|
33
|
+
expect(result).toContain("<DropdownTrigger>")
|
|
34
|
+
expect(result).toContain("</DropdownTrigger>")
|
|
35
|
+
expect(result).toContain("<DropdownIconButton")
|
|
36
|
+
expect(result).toContain('aria-label="Edit options"')
|
|
37
|
+
expect(result).toContain("Symbol")
|
|
38
|
+
expect(result).toContain("#pencil")
|
|
39
|
+
expect(result).toContain("aria-hidden")
|
|
40
|
+
expect(result).not.toContain('icon={{ name: "general.pencil" }}')
|
|
41
|
+
expect(result).not.toContain('title="Edit options"')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("adds Symbol, sprite, DropdownIconButton, and DropdownTrigger imports", () => {
|
|
45
|
+
const input = `
|
|
46
|
+
import { Dropdown } from "@planningcenter/tapestry-react"
|
|
47
|
+
|
|
48
|
+
export default function Test() {
|
|
49
|
+
return <Dropdown icon={{ name: "general.pencil" }} title="Edit"><DropdownMenu /></Dropdown>
|
|
50
|
+
}
|
|
51
|
+
`.trim()
|
|
52
|
+
|
|
53
|
+
const result = applyTransform(input)
|
|
54
|
+
expect(result).toContain('from "@planningcenter/icons/symbol"')
|
|
55
|
+
expect(result).toContain(
|
|
56
|
+
'from "@planningcenter/icons/sprites/general.svg"'
|
|
57
|
+
)
|
|
58
|
+
expect(result).toContain("DropdownIconButton")
|
|
59
|
+
expect(result).toContain("DropdownTrigger")
|
|
60
|
+
expect(result).toContain('from "@planningcenter/tapestry"')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("merges DropdownIconButton and DropdownTrigger into an existing tapestry import", () => {
|
|
64
|
+
const input = `
|
|
65
|
+
import { Dropdown, DropdownMenu } from "@planningcenter/tapestry-react"
|
|
66
|
+
import { DropdownAction } from "@planningcenter/tapestry"
|
|
67
|
+
|
|
68
|
+
export default function Test() {
|
|
69
|
+
return (
|
|
70
|
+
<Dropdown icon={{ name: "general.pencil" }} title="Edit">
|
|
71
|
+
<DropdownMenu />
|
|
72
|
+
</Dropdown>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
`.trim()
|
|
76
|
+
|
|
77
|
+
const result = applyTransform(input)
|
|
78
|
+
expect(result).toContain("DropdownAction")
|
|
79
|
+
expect(result).toContain("DropdownIconButton")
|
|
80
|
+
expect(result).toContain("DropdownTrigger")
|
|
81
|
+
const tapestryImportMatches = result?.match(
|
|
82
|
+
/from "@planningcenter\/tapestry"/g
|
|
83
|
+
)
|
|
84
|
+
expect(tapestryImportMatches?.length).toBe(1)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe("string-form icon", () => {
|
|
89
|
+
it("converts string icon to Symbol with correct id", () => {
|
|
90
|
+
const input = `
|
|
91
|
+
import { Dropdown } from "@planningcenter/tapestry-react"
|
|
92
|
+
|
|
93
|
+
export default function Test() {
|
|
94
|
+
return <Dropdown icon="general.pencil" title="Edit"><DropdownMenu /></Dropdown>
|
|
95
|
+
}
|
|
96
|
+
`.trim()
|
|
97
|
+
|
|
98
|
+
const result = applyTransform(input)
|
|
99
|
+
expect(result).toContain("Symbol")
|
|
100
|
+
expect(result).toContain("#pencil")
|
|
101
|
+
expect(result).not.toContain('"general.pencil"')
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe("icon name conversion", () => {
|
|
106
|
+
it("converts camelCase icon name to kebab-case symbol id", () => {
|
|
107
|
+
const input = `
|
|
108
|
+
import { Dropdown } from "@planningcenter/tapestry-react"
|
|
109
|
+
|
|
110
|
+
export default function Test() {
|
|
111
|
+
return <Dropdown icon={{ name: "general.downCaret" }} title="Sort"><DropdownMenu /></Dropdown>
|
|
112
|
+
}
|
|
113
|
+
`.trim()
|
|
114
|
+
|
|
115
|
+
const result = applyTransform(input)
|
|
116
|
+
expect(result).toContain("#down-caret")
|
|
117
|
+
expect(result).not.toContain("downCaret")
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("converts bare icon name (no collection) to general collection", () => {
|
|
121
|
+
const input = `
|
|
122
|
+
import { Dropdown } from "@planningcenter/tapestry-react"
|
|
123
|
+
|
|
124
|
+
export default function Test() {
|
|
125
|
+
return <Dropdown icon="pencil" title="Edit"><DropdownMenu /></Dropdown>
|
|
126
|
+
}
|
|
127
|
+
`.trim()
|
|
128
|
+
|
|
129
|
+
const result = applyTransform(input)
|
|
130
|
+
expect(result).toContain("#pencil")
|
|
131
|
+
expect(result).toContain("general")
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("uses the correct sprite for a non-general collection", () => {
|
|
135
|
+
const input = `
|
|
136
|
+
import { Dropdown } from "@planningcenter/tapestry-react"
|
|
137
|
+
|
|
138
|
+
export default function Test() {
|
|
139
|
+
return <Dropdown icon={{ name: "services.pencilEdit" }} title="Edit"><DropdownMenu /></Dropdown>
|
|
140
|
+
}
|
|
141
|
+
`.trim()
|
|
142
|
+
|
|
143
|
+
const result = applyTransform(input)
|
|
144
|
+
expect(result).toContain("#pencil-edit")
|
|
145
|
+
expect(result).toContain(
|
|
146
|
+
'from "@planningcenter/icons/sprites/services.svg"'
|
|
147
|
+
)
|
|
148
|
+
expect(result).not.toContain("general.svg")
|
|
149
|
+
expect(result).not.toContain(
|
|
150
|
+
'from "@planningcenter/icons/sprites/general.svg"'
|
|
151
|
+
)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe("TODO comment on icon attribute", () => {
|
|
156
|
+
it("attaches install/verify TODO to the icon attribute", () => {
|
|
157
|
+
const input = `
|
|
158
|
+
import { Dropdown } from "@planningcenter/tapestry-react"
|
|
159
|
+
|
|
160
|
+
export default function Test() {
|
|
161
|
+
return <Dropdown icon={{ name: "general.pencil" }} title="Edit"><DropdownMenu /></Dropdown>
|
|
162
|
+
}
|
|
163
|
+
`.trim()
|
|
164
|
+
|
|
165
|
+
const result = applyTransform(input)
|
|
166
|
+
expect(result).toContain("TODO: tapestry-migration (icon):")
|
|
167
|
+
expect(result).toContain("@planningcenter/icons")
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe("dynamic icon fallback", () => {
|
|
172
|
+
it("passes dynamic icon as-is and attaches a verify TODO to the icon attribute", () => {
|
|
173
|
+
const input = `
|
|
174
|
+
import { Dropdown } from "@planningcenter/tapestry-react"
|
|
175
|
+
|
|
176
|
+
export default function Test() {
|
|
177
|
+
return <Dropdown icon={myIcon} title="Edit"><DropdownMenu /></Dropdown>
|
|
178
|
+
}
|
|
179
|
+
`.trim()
|
|
180
|
+
|
|
181
|
+
const result = applyTransform(input)
|
|
182
|
+
expect(result).toContain("<DropdownTrigger>")
|
|
183
|
+
expect(result).toContain("DropdownIconButton")
|
|
184
|
+
expect(result).toContain("myIcon")
|
|
185
|
+
expect(result).toContain("TODO: tapestry-migration (icon):")
|
|
186
|
+
expect(result).not.toContain('from "@planningcenter/icons/symbol"')
|
|
187
|
+
expect(result).not.toContain("general.svg")
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
describe("aria-label handling", () => {
|
|
192
|
+
it("uses title value as aria-label", () => {
|
|
193
|
+
const input = `
|
|
194
|
+
import { Dropdown } from "@planningcenter/tapestry-react"
|
|
195
|
+
|
|
196
|
+
export default function Test() {
|
|
197
|
+
return <Dropdown icon={{ name: "general.pencil" }} title="Bulk edit options"><DropdownMenu /></Dropdown>
|
|
198
|
+
}
|
|
199
|
+
`.trim()
|
|
200
|
+
|
|
201
|
+
const result = applyTransform(input)
|
|
202
|
+
expect(result).toContain('aria-label="Bulk edit options"')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it("uses dynamic title expression as aria-label", () => {
|
|
206
|
+
const input = `
|
|
207
|
+
import { Dropdown } from "@planningcenter/tapestry-react"
|
|
208
|
+
|
|
209
|
+
export default function Test() {
|
|
210
|
+
return <Dropdown icon={{ name: "general.pencil" }} title={menuTitle}><DropdownMenu /></Dropdown>
|
|
211
|
+
}
|
|
212
|
+
`.trim()
|
|
213
|
+
|
|
214
|
+
const result = applyTransform(input)
|
|
215
|
+
expect(result).toContain("aria-label={menuTitle}")
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it("emits TODO and omits aria-label when no title is present", () => {
|
|
219
|
+
const input = `
|
|
220
|
+
import { Dropdown } from "@planningcenter/tapestry-react"
|
|
221
|
+
|
|
222
|
+
export default function Test() {
|
|
223
|
+
return <Dropdown icon={{ name: "general.pencil" }}><DropdownMenu /></Dropdown>
|
|
224
|
+
}
|
|
225
|
+
`.trim()
|
|
226
|
+
|
|
227
|
+
const result = applyTransform(input)
|
|
228
|
+
expect(result).toContain("DropdownIconButton")
|
|
229
|
+
expect(result).toContain("TODO: tapestry-migration (icon):")
|
|
230
|
+
expect(result).toContain("aria-label")
|
|
231
|
+
expect(result).not.toContain("aria-label=")
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
describe("prop handling", () => {
|
|
236
|
+
it("preserves other Dropdown props", () => {
|
|
237
|
+
const input = `
|
|
238
|
+
import { Dropdown } from "@planningcenter/tapestry-react"
|
|
239
|
+
|
|
240
|
+
export default function Test() {
|
|
241
|
+
return (
|
|
242
|
+
<Dropdown
|
|
243
|
+
icon={{ name: "general.pencil" }}
|
|
244
|
+
title="Edit"
|
|
245
|
+
placement="bottom-start"
|
|
246
|
+
disabled={disabled}
|
|
247
|
+
>
|
|
248
|
+
<DropdownMenu />
|
|
249
|
+
</Dropdown>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
`.trim()
|
|
253
|
+
|
|
254
|
+
const result = applyTransform(input)
|
|
255
|
+
expect(result).toContain('placement="bottom-start"')
|
|
256
|
+
expect(result).toContain("disabled={disabled}")
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it("does not touch the tapestry-react import", () => {
|
|
260
|
+
const input = `
|
|
261
|
+
import { Dropdown } from "@planningcenter/tapestry-react"
|
|
262
|
+
|
|
263
|
+
export default function Test() {
|
|
264
|
+
return <Dropdown icon={{ name: "general.pencil" }} title="Edit"><DropdownMenu /></Dropdown>
|
|
265
|
+
}
|
|
266
|
+
`.trim()
|
|
267
|
+
|
|
268
|
+
const result = applyTransform(input)
|
|
269
|
+
expect(result).toContain('from "@planningcenter/tapestry-react"')
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
describe("Symbol import conflict", () => {
|
|
274
|
+
it("uses aliased name when Symbol is already imported from another package", () => {
|
|
275
|
+
const input = `
|
|
276
|
+
import { Dropdown } from "@planningcenter/tapestry-react"
|
|
277
|
+
import { Symbol } from "some-other-package"
|
|
278
|
+
|
|
279
|
+
export default function Test() {
|
|
280
|
+
return <Dropdown icon={{ name: "general.pencil" }} title="Edit"><DropdownMenu /></Dropdown>
|
|
281
|
+
}
|
|
282
|
+
`.trim()
|
|
283
|
+
|
|
284
|
+
const result = applyTransform(input)
|
|
285
|
+
expect(result).toContain("<TSymbol")
|
|
286
|
+
expect(result).toContain("Symbol as TSymbol")
|
|
287
|
+
expect(result).not.toContain("icon={<Symbol")
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
describe("no changes scenarios", () => {
|
|
292
|
+
it("returns null when icon is absent", () => {
|
|
293
|
+
const input = `
|
|
294
|
+
import { Dropdown } from "@planningcenter/tapestry-react"
|
|
295
|
+
|
|
296
|
+
export default function Test() {
|
|
297
|
+
return <Dropdown title="Actions"><DropdownMenu /></Dropdown>
|
|
298
|
+
}
|
|
299
|
+
`.trim()
|
|
300
|
+
|
|
301
|
+
expect(applyTransform(input)).toBe(null)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it("returns null when triggerElement is present (deferred to wrapTrigger)", () => {
|
|
305
|
+
const input = `
|
|
306
|
+
import { Dropdown } from "@planningcenter/tapestry-react"
|
|
307
|
+
|
|
308
|
+
export default function Test() {
|
|
309
|
+
return (
|
|
310
|
+
<Dropdown icon={{ name: "general.pencil" }} triggerElement={<MyButton />}>
|
|
311
|
+
<DropdownMenu />
|
|
312
|
+
</Dropdown>
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
`.trim()
|
|
316
|
+
|
|
317
|
+
expect(applyTransform(input)).toBe(null)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it("returns null when Dropdown is not imported from tapestry-react", () => {
|
|
321
|
+
const input = `
|
|
322
|
+
import { Dropdown } from "some-other-library"
|
|
323
|
+
|
|
324
|
+
export default function Test() {
|
|
325
|
+
return <Dropdown icon={{ name: "general.pencil" }} title="Edit"><DropdownMenu /></Dropdown>
|
|
326
|
+
}
|
|
327
|
+
`.trim()
|
|
328
|
+
|
|
329
|
+
expect(applyTransform(input)).toBe(null)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it("returns null for empty file", () => {
|
|
333
|
+
expect(applyTransform("")).toBe(null)
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
})
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { Collection, JSCodeshift, JSXAttribute, Transform } from "jscodeshift"
|
|
2
|
+
|
|
3
|
+
import { addComment } from "../../shared/actions/addComment"
|
|
4
|
+
import { addCommentToAttribute } from "../../shared/actions/addCommentToAttribute"
|
|
5
|
+
import { getAttribute } from "../../shared/actions/getAttribute"
|
|
6
|
+
import { removeAttribute } from "../../shared/actions/removeAttribute"
|
|
7
|
+
import { andConditions } from "../../shared/conditions/andConditions"
|
|
8
|
+
import { hasAttribute } from "../../shared/conditions/hasAttribute"
|
|
9
|
+
import { notCondition } from "../../shared/conditions/notCondition"
|
|
10
|
+
import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
|
|
11
|
+
import { addImport } from "../../shared/transformFactories/helpers/manageImports"
|
|
12
|
+
|
|
13
|
+
function parseIconName(name: string): { collection: string; symbolId: string } {
|
|
14
|
+
const dotIndex = name.indexOf(".")
|
|
15
|
+
if (dotIndex === -1) {
|
|
16
|
+
return {
|
|
17
|
+
collection: "general",
|
|
18
|
+
symbolId: name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(),
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
collection: name.slice(0, dotIndex),
|
|
23
|
+
symbolId: name
|
|
24
|
+
.slice(dotIndex + 1)
|
|
25
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
26
|
+
.toLowerCase(),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveStaticIconName(iconAttr: JSXAttribute): string | null {
|
|
31
|
+
const attrValue = iconAttr.value
|
|
32
|
+
if (!attrValue) return null
|
|
33
|
+
|
|
34
|
+
if (attrValue.type === "StringLiteral") {
|
|
35
|
+
return attrValue.value
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (
|
|
39
|
+
attrValue.type === "JSXExpressionContainer" &&
|
|
40
|
+
attrValue.expression.type === "ObjectExpression"
|
|
41
|
+
) {
|
|
42
|
+
const nameProp = attrValue.expression.properties.find(
|
|
43
|
+
(prop) =>
|
|
44
|
+
prop.type === "ObjectProperty" &&
|
|
45
|
+
((prop.key.type === "Identifier" && prop.key.name === "name") ||
|
|
46
|
+
(prop.key.type === "StringLiteral" && prop.key.value === "name")) &&
|
|
47
|
+
prop.value.type === "StringLiteral"
|
|
48
|
+
) as { value: { value: string } } | undefined
|
|
49
|
+
|
|
50
|
+
if (nameProp) return nameProp.value.value
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function addSpriteImport(
|
|
57
|
+
source: Collection,
|
|
58
|
+
j: JSCodeshift,
|
|
59
|
+
collection: string
|
|
60
|
+
): void {
|
|
61
|
+
const spritePath = `@planningcenter/icons/sprites/${collection}.svg`
|
|
62
|
+
const alreadyImported =
|
|
63
|
+
source
|
|
64
|
+
.find(j.ImportDeclaration, {
|
|
65
|
+
source: { value: spritePath },
|
|
66
|
+
})
|
|
67
|
+
.filter((path) =>
|
|
68
|
+
(path.value.specifiers ?? []).some(
|
|
69
|
+
(spec) => spec.type === "ImportDefaultSpecifier"
|
|
70
|
+
)
|
|
71
|
+
).length > 0
|
|
72
|
+
|
|
73
|
+
if (alreadyImported) return
|
|
74
|
+
|
|
75
|
+
const importDecl = j.importDeclaration(
|
|
76
|
+
[j.importDefaultSpecifier(j.identifier(collection))],
|
|
77
|
+
j.stringLiteral(spritePath)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const lastImport = source.find(j.ImportDeclaration).at(-1)
|
|
81
|
+
if (lastImport.length > 0) {
|
|
82
|
+
lastImport.insertAfter(importDecl)
|
|
83
|
+
} else {
|
|
84
|
+
source.find(j.Program).get("body", 0).insertBefore(importDecl)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const transform: Transform = attributeTransformFactory({
|
|
89
|
+
condition: andConditions(
|
|
90
|
+
hasAttribute("icon"),
|
|
91
|
+
notCondition(hasAttribute("triggerElement"))
|
|
92
|
+
),
|
|
93
|
+
targetComponent: "Dropdown",
|
|
94
|
+
targetPackage: "@planningcenter/tapestry-react",
|
|
95
|
+
transform: (element, { j, source }) => {
|
|
96
|
+
const iconAttr = getAttribute({ element, name: "icon" })
|
|
97
|
+
if (!iconAttr) return false
|
|
98
|
+
|
|
99
|
+
const titleAttr = getAttribute({ element, name: "title" })
|
|
100
|
+
|
|
101
|
+
const resolvedIconName = resolveStaticIconName(iconAttr)
|
|
102
|
+
const parsedIcon =
|
|
103
|
+
resolvedIconName !== null ? parseIconName(resolvedIconName) : null
|
|
104
|
+
let iconJSXAttr: JSXAttribute
|
|
105
|
+
|
|
106
|
+
if (parsedIcon !== null) {
|
|
107
|
+
const { collection, symbolId } = parsedIcon
|
|
108
|
+
|
|
109
|
+
const symbolName = addImport({
|
|
110
|
+
component: "Symbol",
|
|
111
|
+
fromPackage: "@planningcenter/tapestry-react",
|
|
112
|
+
j,
|
|
113
|
+
pkg: "@planningcenter/icons/symbol",
|
|
114
|
+
source,
|
|
115
|
+
})
|
|
116
|
+
addSpriteImport(source, j, collection)
|
|
117
|
+
|
|
118
|
+
const symbolTemplateLiteral = j.templateLiteral(
|
|
119
|
+
[
|
|
120
|
+
j.templateElement({ cooked: "", raw: "" }, false),
|
|
121
|
+
j.templateElement(
|
|
122
|
+
{ cooked: `#${symbolId}`, raw: `#${symbolId}` },
|
|
123
|
+
true
|
|
124
|
+
),
|
|
125
|
+
],
|
|
126
|
+
[j.identifier(collection)]
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const symbolElement = j.jsxElement(
|
|
130
|
+
j.jsxOpeningElement(
|
|
131
|
+
j.jsxIdentifier(symbolName),
|
|
132
|
+
[
|
|
133
|
+
j.jsxAttribute(
|
|
134
|
+
j.jsxIdentifier("symbol"),
|
|
135
|
+
j.jsxExpressionContainer(symbolTemplateLiteral)
|
|
136
|
+
),
|
|
137
|
+
j.jsxAttribute(j.jsxIdentifier("aria-hidden")),
|
|
138
|
+
],
|
|
139
|
+
true
|
|
140
|
+
),
|
|
141
|
+
null,
|
|
142
|
+
[]
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
iconJSXAttr = j.jsxAttribute(
|
|
146
|
+
j.jsxIdentifier("icon"),
|
|
147
|
+
j.jsxExpressionContainer(symbolElement)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
addCommentToAttribute({
|
|
151
|
+
attribute: iconJSXAttr,
|
|
152
|
+
j,
|
|
153
|
+
text: `install @planningcenter/icons if not already a dependency; verify the symbol id against the ${collection} sprite`,
|
|
154
|
+
})
|
|
155
|
+
} else {
|
|
156
|
+
const originalExpr =
|
|
157
|
+
iconAttr.value?.type === "JSXExpressionContainer" &&
|
|
158
|
+
iconAttr.value.expression.type !== "JSXEmptyExpression"
|
|
159
|
+
? iconAttr.value.expression
|
|
160
|
+
: j.identifier("undefined")
|
|
161
|
+
|
|
162
|
+
iconJSXAttr = j.jsxAttribute(
|
|
163
|
+
j.jsxIdentifier("icon"),
|
|
164
|
+
j.jsxExpressionContainer(originalExpr)
|
|
165
|
+
)
|
|
166
|
+
addCommentToAttribute({
|
|
167
|
+
attribute: iconJSXAttr,
|
|
168
|
+
j,
|
|
169
|
+
text: "verify this icon value and replace with <Symbol symbol={`${general}#<name>`} aria-hidden /> from @planningcenter/icons/symbol if needed",
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let ariaLabelAttr: JSXAttribute | null = null
|
|
174
|
+
if (titleAttr?.value) {
|
|
175
|
+
ariaLabelAttr = j.jsxAttribute(
|
|
176
|
+
j.jsxIdentifier("aria-label"),
|
|
177
|
+
titleAttr.value
|
|
178
|
+
)
|
|
179
|
+
} else {
|
|
180
|
+
addComment({
|
|
181
|
+
element,
|
|
182
|
+
j,
|
|
183
|
+
scope: "icon",
|
|
184
|
+
source,
|
|
185
|
+
text: "aria-label is required on DropdownIconButton — add a descriptive label",
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const iconButtonAttrs: JSXAttribute[] = []
|
|
190
|
+
if (ariaLabelAttr) iconButtonAttrs.push(ariaLabelAttr)
|
|
191
|
+
iconButtonAttrs.push(iconJSXAttr)
|
|
192
|
+
|
|
193
|
+
const iconButtonElement = j.jsxElement(
|
|
194
|
+
j.jsxOpeningElement(
|
|
195
|
+
j.jsxIdentifier("DropdownIconButton"),
|
|
196
|
+
iconButtonAttrs,
|
|
197
|
+
true
|
|
198
|
+
),
|
|
199
|
+
null,
|
|
200
|
+
[]
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
const triggerWrapper = j.jsxElement(
|
|
204
|
+
j.jsxOpeningElement(j.jsxIdentifier("DropdownTrigger"), [], false),
|
|
205
|
+
j.jsxClosingElement(j.jsxIdentifier("DropdownTrigger")),
|
|
206
|
+
[iconButtonElement]
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
removeAttribute("icon", { element, j, source })
|
|
210
|
+
removeAttribute("title", { element, j, source })
|
|
211
|
+
|
|
212
|
+
element.children = [triggerWrapper, ...(element.children ?? [])]
|
|
213
|
+
|
|
214
|
+
addImport({
|
|
215
|
+
component: "DropdownIconButton",
|
|
216
|
+
fromPackage: "@planningcenter/tapestry-react",
|
|
217
|
+
j,
|
|
218
|
+
pkg: "@planningcenter/tapestry",
|
|
219
|
+
source,
|
|
220
|
+
})
|
|
221
|
+
addImport({
|
|
222
|
+
component: "DropdownTrigger",
|
|
223
|
+
fromPackage: "@planningcenter/tapestry-react",
|
|
224
|
+
j,
|
|
225
|
+
pkg: "@planningcenter/tapestry",
|
|
226
|
+
source,
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
return true
|
|
230
|
+
},
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
export default transform
|