@planningcenter/tapestry-migration-cli 3.4.1-rc.9 → 3.5.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 (39) hide show
  1. package/README.md +22 -0
  2. package/dist/tapestry-react-shim.cjs +5 -1
  3. package/package.json +3 -3
  4. package/src/availableComponents.ts +13 -0
  5. package/src/components/dropdown/index.test.ts +244 -4
  6. package/src/components/dropdown/index.ts +28 -2
  7. package/src/components/dropdown/transforms/auditSpreadProps.test.ts +110 -0
  8. package/src/components/dropdown/transforms/auditSpreadProps.ts +10 -0
  9. package/src/components/dropdown/transforms/dividerToSeparator.test.ts +134 -0
  10. package/src/components/dropdown/transforms/dividerToSeparator.ts +67 -0
  11. package/src/components/dropdown/transforms/itemToAction.test.ts +19 -1
  12. package/src/components/dropdown/transforms/itemToAction.ts +14 -2
  13. package/src/components/dropdown/transforms/linkToLink.test.ts +19 -1
  14. package/src/components/dropdown/transforms/linkToLink.ts +13 -2
  15. package/src/components/dropdown/transforms/moveDropdownImport.test.ts +93 -0
  16. package/src/components/dropdown/transforms/moveDropdownImport.ts +13 -0
  17. package/src/components/dropdown/transforms/placementIdToMenu.test.ts +140 -0
  18. package/src/components/dropdown/transforms/placementIdToMenu.ts +96 -0
  19. package/src/components/dropdown/transforms/unsupportedProps.test.ts +145 -0
  20. package/src/components/dropdown/transforms/unsupportedProps.ts +12 -0
  21. package/src/components/dropdown/transforms/unsupportedPropsDivider.test.ts +143 -0
  22. package/src/components/dropdown/transforms/unsupportedPropsDivider.ts +26 -0
  23. package/src/components/dropdown/transforms/unsupportedPropsItem.test.ts +123 -0
  24. package/src/components/dropdown/transforms/unsupportedPropsItem.ts +12 -0
  25. package/src/components/dropdown/transforms/unsupportedPropsLink.test.ts +107 -0
  26. package/src/components/dropdown/transforms/unsupportedPropsLink.ts +12 -0
  27. package/src/components/dropdown/transforms/variantToKind.test.ts +292 -0
  28. package/src/components/dropdown/transforms/variantToKind.ts +138 -0
  29. package/src/components/dropdown/transforms/wrapIconTrigger.test.ts +336 -0
  30. package/src/components/dropdown/transforms/wrapIconTrigger.ts +233 -0
  31. package/src/components/dropdown/transforms/wrapMenu.test.ts +153 -0
  32. package/src/components/dropdown/transforms/wrapMenu.ts +54 -0
  33. package/src/components/dropdown/transforms/wrapTrigger.test.ts +283 -0
  34. package/src/components/dropdown/transforms/wrapTrigger.ts +98 -0
  35. package/src/components/input/transforms/unsupportedProps.test.ts +14 -13
  36. package/src/components/shared/conditions/isChildOf.test.ts +89 -0
  37. package/src/components/shared/conditions/isChildOf.ts +43 -0
  38. package/src/components/shared/helpers/unsupportedPropsHelpers.ts +36 -0
  39. package/src/index.ts +5 -18
@@ -0,0 +1,292 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import transform from "./variantToKind"
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
+ function wrappedInput(
18
+ attrs: string,
19
+ buttonElement = '<DropdownButton label="Actions" />'
20
+ ) {
21
+ return `
22
+ import { Dropdown } from "@planningcenter/tapestry-react"
23
+ import { DropdownButton, DropdownMenu, DropdownTrigger } from "@planningcenter/tapestry"
24
+
25
+ export default function Test() {
26
+ return (
27
+ <Dropdown ${attrs} onClose={fn}>
28
+ <DropdownTrigger>
29
+ ${buttonElement}
30
+ </DropdownTrigger>
31
+ <DropdownMenu>
32
+ <DropdownAction id="edit" onAction={fn}>Edit</DropdownAction>
33
+ </DropdownMenu>
34
+ </Dropdown>
35
+ )
36
+ }
37
+ `.trim()
38
+ }
39
+
40
+ describe("variantToKind transform", () => {
41
+ describe("variant-only mappings (default theme)", () => {
42
+ it('maps variant="outline" to kind="secondary"', () => {
43
+ const result = applyTransform(wrappedInput('variant="outline"'))
44
+ expect(result).toContain('kind="secondary"')
45
+ expect(result).not.toContain('variant="outline"')
46
+ })
47
+
48
+ it('maps variant="fill" to kind="neutral"', () => {
49
+ const result = applyTransform(wrappedInput('variant="fill"'))
50
+ expect(result).toContain('kind="neutral"')
51
+ expect(result).not.toContain('variant="fill"')
52
+ })
53
+
54
+ it('maps variant="naked" to kind="ghost"', () => {
55
+ const result = applyTransform(wrappedInput('variant="naked"'))
56
+ expect(result).toContain('kind="ghost"')
57
+ expect(result).not.toContain('variant="naked"')
58
+ })
59
+
60
+ it('normalizes variant="ghost" to kind="ghost"', () => {
61
+ const result = applyTransform(wrappedInput('variant="ghost"'))
62
+ expect(result).toContain('kind="ghost"')
63
+ expect(result).not.toContain('variant="ghost"')
64
+ })
65
+
66
+ it('normalizes variant="solid" to kind="neutral" (solid→fill→neutral)', () => {
67
+ const result = applyTransform(wrappedInput('variant="solid"'))
68
+ expect(result).toContain('kind="neutral"')
69
+ expect(result).not.toContain('variant="solid"')
70
+ })
71
+ })
72
+
73
+ describe("theme-only mappings (default variant)", () => {
74
+ it('maps theme="error" to kind="delete"', () => {
75
+ const result = applyTransform(wrappedInput('theme="error"'))
76
+ expect(result).toContain('kind="delete"')
77
+ expect(result).not.toContain('theme="error"')
78
+ })
79
+
80
+ it('maps theme="primary" to kind="primary"', () => {
81
+ const result = applyTransform(wrappedInput('theme="primary"'))
82
+ expect(result).toContain('kind="primary"')
83
+ expect(result).not.toContain('theme="primary"')
84
+ })
85
+
86
+ it('normalizes theme="destroy" to kind="delete"', () => {
87
+ const result = applyTransform(wrappedInput('theme="destroy"'))
88
+ expect(result).toContain('kind="delete"')
89
+ expect(result).not.toContain('theme="destroy"')
90
+ })
91
+
92
+ it('normalizes theme="interaction" to kind="primary"', () => {
93
+ const result = applyTransform(wrappedInput('theme="interaction"'))
94
+ expect(result).toContain('kind="primary"')
95
+ expect(result).not.toContain('theme="interaction"')
96
+ })
97
+ })
98
+
99
+ describe("theme + variant combined", () => {
100
+ it('maps theme="error" variant="outline" to kind="secondary-delete"', () => {
101
+ const result = applyTransform(
102
+ wrappedInput('theme="error" variant="outline"')
103
+ )
104
+ expect(result).toContain('kind="secondary-delete"')
105
+ expect(result).not.toContain('theme="error"')
106
+ expect(result).not.toContain('variant="outline"')
107
+ })
108
+
109
+ it('maps theme="error" variant="naked" to kind="ghost-delete"', () => {
110
+ const result = applyTransform(
111
+ wrappedInput('theme="error" variant="naked"')
112
+ )
113
+ expect(result).toContain('kind="ghost-delete"')
114
+ })
115
+
116
+ it('maps theme="primary" variant="outline" to kind="secondary-interaction"', () => {
117
+ const result = applyTransform(
118
+ wrappedInput('theme="primary" variant="outline"')
119
+ )
120
+ expect(result).toContain('kind="secondary-interaction"')
121
+ })
122
+ })
123
+
124
+ describe("kind placement", () => {
125
+ it("adds kind to the DropdownButton, not to Dropdown", () => {
126
+ const result = applyTransform(wrappedInput('variant="outline"'))
127
+ const dropdownButtonLine = result
128
+ ?.split("\n")
129
+ .find((l) => l.includes("<DropdownButton"))
130
+ expect(dropdownButtonLine).toContain("kind")
131
+ })
132
+
133
+ it("emits TODO and does not duplicate kind when button already has kind", () => {
134
+ const result = applyTransform(
135
+ wrappedInput(
136
+ 'variant="outline"',
137
+ '<DropdownButton label="Actions" kind="primary" />'
138
+ )
139
+ )
140
+ expect(result).not.toContain('variant="outline"')
141
+ expect(result).toContain("TODO: tapestry-migration (variant):")
142
+ expect(result).toContain("secondary")
143
+ const buttonLine = result
144
+ ?.split("\n")
145
+ .find((l) => l.includes("<DropdownButton"))
146
+ expect(buttonLine).toContain('kind="primary"')
147
+ expect(buttonLine).not.toContain('kind="secondary"')
148
+ })
149
+
150
+ it('maps variant="outline" to kind="secondary" on DropdownIconButton', () => {
151
+ const result = applyTransform(
152
+ wrappedInput(
153
+ 'variant="outline"',
154
+ '<DropdownIconButton aria-label="Edit" icon={icon} />'
155
+ )
156
+ )
157
+ expect(result).toContain('kind="secondary"')
158
+ expect(result).not.toContain('variant="outline"')
159
+ })
160
+ })
161
+
162
+ describe("complex/custom trigger child", () => {
163
+ it("removes variant and emits TODO for unknown trigger button", () => {
164
+ const input = `
165
+ import { Dropdown } from "@planningcenter/tapestry-react"
166
+ import { DropdownMenu, DropdownTrigger } from "@planningcenter/tapestry"
167
+
168
+ export default function Test() {
169
+ return (
170
+ <Dropdown variant="outline" onClose={fn}>
171
+ <DropdownTrigger>
172
+ <MyCustomButton label="Actions" />
173
+ </DropdownTrigger>
174
+ <DropdownMenu />
175
+ </Dropdown>
176
+ )
177
+ }
178
+ `.trim()
179
+
180
+ const result = applyTransform(input)
181
+ expect(result).not.toContain('variant="outline"')
182
+ expect(result).toContain("TODO: tapestry-migration (variant):")
183
+ expect(result).toContain("secondary")
184
+ const customButtonLine = result
185
+ ?.split("\n")
186
+ .find((l) => l.includes("<MyCustomButton"))
187
+ expect(customButtonLine).not.toContain("kind")
188
+ })
189
+
190
+ it("uses scope=theme when only theme is present on custom trigger", () => {
191
+ const input = `
192
+ import { Dropdown } from "@planningcenter/tapestry-react"
193
+ import { DropdownMenu, DropdownTrigger } from "@planningcenter/tapestry"
194
+
195
+ export default function Test() {
196
+ return (
197
+ <Dropdown theme="error" onClose={fn}>
198
+ <DropdownTrigger>
199
+ <MyCustomButton label="Actions" />
200
+ </DropdownTrigger>
201
+ <DropdownMenu />
202
+ </Dropdown>
203
+ )
204
+ }
205
+ `.trim()
206
+
207
+ const result = applyTransform(input)
208
+ expect(result).toContain("TODO: tapestry-migration (theme):")
209
+ expect(result).not.toContain("TODO: tapestry-migration (variant):")
210
+ })
211
+
212
+ it("uses scope=variant/theme when both are present on custom trigger", () => {
213
+ const input = `
214
+ import { Dropdown } from "@planningcenter/tapestry-react"
215
+ import { DropdownMenu, DropdownTrigger } from "@planningcenter/tapestry"
216
+
217
+ export default function Test() {
218
+ return (
219
+ <Dropdown theme="error" variant="outline" onClose={fn}>
220
+ <DropdownTrigger>
221
+ <MyCustomButton label="Actions" />
222
+ </DropdownTrigger>
223
+ <DropdownMenu />
224
+ </Dropdown>
225
+ )
226
+ }
227
+ `.trim()
228
+
229
+ const result = applyTransform(input)
230
+ expect(result).toContain("TODO: tapestry-migration (variant/theme):")
231
+ })
232
+ })
233
+
234
+ describe("no changes scenarios", () => {
235
+ it("returns null for dynamic variant expression", () => {
236
+ expect(applyTransform(wrappedInput("variant={buttonVariant}"))).toBe(null)
237
+ })
238
+
239
+ it("returns null for dynamic theme expression", () => {
240
+ expect(applyTransform(wrappedInput("theme={buttonTheme}"))).toBe(null)
241
+ })
242
+
243
+ it("returns null for an unmapped variant value", () => {
244
+ expect(applyTransform(wrappedInput('variant="extra-large"'))).toBe(null)
245
+ })
246
+
247
+ it("returns null for an unmapped theme value", () => {
248
+ expect(applyTransform(wrappedInput('theme="unknown-theme"'))).toBe(null)
249
+ })
250
+
251
+ it("returns null when no DropdownTrigger child exists", () => {
252
+ const input = `
253
+ import { Dropdown } from "@planningcenter/tapestry-react"
254
+
255
+ export default function Test() {
256
+ return (
257
+ <Dropdown variant="outline" onClose={fn}>
258
+ <DropdownMenu />
259
+ </Dropdown>
260
+ )
261
+ }
262
+ `.trim()
263
+
264
+ expect(applyTransform(input)).toBe(null)
265
+ })
266
+
267
+ it("returns null when neither variant nor theme is present", () => {
268
+ expect(applyTransform(wrappedInput(""))).toBe(null)
269
+ })
270
+
271
+ it("returns null when Dropdown is not imported from tapestry-react", () => {
272
+ const input = `
273
+ import { Dropdown } from "@planningcenter/tapestry"
274
+
275
+ export default function Test() {
276
+ return (
277
+ <Dropdown variant="outline">
278
+ <DropdownTrigger><DropdownButton label="Actions" /></DropdownTrigger>
279
+ <DropdownMenu />
280
+ </Dropdown>
281
+ )
282
+ }
283
+ `.trim()
284
+
285
+ expect(applyTransform(input)).toBe(null)
286
+ })
287
+
288
+ it("returns null for empty file", () => {
289
+ expect(applyTransform("")).toBe(null)
290
+ })
291
+ })
292
+ })
@@ -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