@planningcenter/tapestry-migration-cli 3.4.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planningcenter/tapestry-migration-cli",
3
- "version": "3.4.1",
3
+ "version": "3.5.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.4.1",
33
+ "@planningcenter/tapestry": "^3.5.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": "f005d23b54d1a95f03e0823e6ab0d139af17cead"
53
+ "gitHead": "958f1f269ca37d78e9a9c0ec66c208a7115331dc"
54
54
  }
@@ -0,0 +1,13 @@
1
+ export const AVAILABLE_CLI_COMPONENTS = new Set([
2
+ "button",
3
+ "checkbox",
4
+ "date-picker",
5
+ "dropdown",
6
+ "input",
7
+ "link",
8
+ "radio",
9
+ "select",
10
+ "text-area",
11
+ "time-field",
12
+ "toggle-switch",
13
+ ])
@@ -200,6 +200,73 @@ export default function Test() {
200
200
  expect(result).toContain("TODO: tapestry-migration (variant)")
201
201
  })
202
202
 
203
+ it("applies wrapIconTrigger through the chain", () => {
204
+ const input = `
205
+ import { Dropdown } from "@planningcenter/tapestry-react"
206
+
207
+ export default function Test() {
208
+ return (
209
+ <Dropdown icon={{ name: "general.pencil" }} title="Edit options">
210
+ <Dropdown.Item onSelect={fn}>Edit</Dropdown.Item>
211
+ </Dropdown>
212
+ )
213
+ }
214
+ `.trim()
215
+
216
+ const result = applyTransform(input)
217
+ expect(result).toContain("<DropdownTrigger>")
218
+ expect(result).toContain("<DropdownIconButton")
219
+ expect(result).toContain('aria-label="Edit options"')
220
+ expect(result).toContain("#pencil")
221
+ expect(result).not.toContain("icon={{ name:")
222
+ expect(result).not.toContain('title="Edit options"')
223
+ expect(result).not.toContain('from "@planningcenter/tapestry-react"')
224
+ })
225
+
226
+ it("applies variantToKind through the chain (variant)", () => {
227
+ const input = `
228
+ import { Dropdown } from "@planningcenter/tapestry-react"
229
+
230
+ export default function Test() {
231
+ return (
232
+ <Dropdown title="Actions" variant="outline">
233
+ <Dropdown.Item onSelect={fn}>Edit</Dropdown.Item>
234
+ </Dropdown>
235
+ )
236
+ }
237
+ `.trim()
238
+
239
+ const result = applyTransform(input)
240
+ expect(result).toContain('kind="secondary"')
241
+ expect(result).not.toContain('variant="outline"')
242
+ const dropdownButtonLine = result
243
+ ?.split("\n")
244
+ .find((l) => l.includes("<DropdownButton"))
245
+ expect(dropdownButtonLine).toContain("kind")
246
+ })
247
+
248
+ it("applies variantToKind through the chain (theme)", () => {
249
+ const input = `
250
+ import { Dropdown } from "@planningcenter/tapestry-react"
251
+
252
+ export default function Test() {
253
+ return (
254
+ <Dropdown title="Actions" theme="error">
255
+ <Dropdown.Item onSelect={fn}>Edit</Dropdown.Item>
256
+ </Dropdown>
257
+ )
258
+ }
259
+ `.trim()
260
+
261
+ const result = applyTransform(input)
262
+ expect(result).toContain('kind="delete"')
263
+ expect(result).not.toContain('theme="error"')
264
+ const dropdownButtonLine = result
265
+ ?.split("\n")
266
+ .find((l) => l.includes("<DropdownButton"))
267
+ expect(dropdownButtonLine).toContain("kind")
268
+ })
269
+
203
270
  it("applies moveDropdownImport through the chain", () => {
204
271
  const input = `
205
272
  import { Dropdown } from "@planningcenter/tapestry-react"
@@ -10,6 +10,8 @@ import unsupportedProps from "./transforms/unsupportedProps"
10
10
  import unsupportedPropsDivider from "./transforms/unsupportedPropsDivider"
11
11
  import unsupportedPropsItem from "./transforms/unsupportedPropsItem"
12
12
  import unsupportedPropsLink from "./transforms/unsupportedPropsLink"
13
+ import variantToKind from "./transforms/variantToKind"
14
+ import wrapIconTrigger from "./transforms/wrapIconTrigger"
13
15
  import wrapMenu from "./transforms/wrapMenu"
14
16
  import wrapTrigger from "./transforms/wrapTrigger"
15
17
 
@@ -25,7 +27,9 @@ const transform: Transform = (fileInfo, api, options) => {
25
27
  linkToLink,
26
28
  wrapMenu,
27
29
  placementIdToMenu,
30
+ wrapIconTrigger,
28
31
  wrapTrigger,
32
+ variantToKind,
29
33
  dividerToSeparator,
30
34
  auditSpreadProps,
31
35
  unsupportedProps,
@@ -50,12 +50,17 @@ describe("placementIdToMenu transform", () => {
50
50
  expect(result).toContain('<DropdownMenu placement={"bottom end"}')
51
51
  })
52
52
 
53
- it("moves dynamic placement expression and adds TODO comment", () => {
53
+ it("moves dynamic placement expression and adds TODO comment inside Dropdown before DropdownMenu", () => {
54
54
  const result = applyTransform(withMenu("placement={myPlacement}"))
55
55
  expect(result).toContain("<DropdownMenu placement={myPlacement}")
56
56
  expect(result).toContain(
57
57
  "TODO: tapestry-migration (placement): placement is a dynamic expression"
58
58
  )
59
+ const todoIndex = result!.indexOf("TODO: tapestry-migration (placement)")
60
+ const dropdownOpenIndex = result!.indexOf("<Dropdown ")
61
+ const dropdownMenuIndex = result!.indexOf("<DropdownMenu")
62
+ expect(todoIndex).toBeGreaterThan(dropdownOpenIndex)
63
+ expect(todoIndex).toBeLessThan(dropdownMenuIndex)
59
64
  })
60
65
  })
61
66
 
@@ -80,7 +80,7 @@ const transform: Transform = attributeTransformFactory({
80
80
  moveAttr("placement", placementAttr, element, menuChild, { j, source })
81
81
  if (isDynamic) {
82
82
  addComment({
83
- element,
83
+ element: menuChild,
84
84
  j,
85
85
  scope: "placement",
86
86
  source,
@@ -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