@planningcenter/tapestry-migration-cli 3.5.0 → 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.
Files changed (34) hide show
  1. package/README.md +37 -0
  2. package/package.json +3 -3
  3. package/src/availableComponents.ts +1 -0
  4. package/src/components/button/transforms/tooltipToWrapper.test.ts +50 -3
  5. package/src/components/button/transforms/tooltipToWrapper.ts +18 -0
  6. package/src/components/flex/index.test.ts +140 -0
  7. package/src/components/flex/index.ts +40 -0
  8. package/src/components/flex/transforms/alignmentToAlign.test.ts +185 -0
  9. package/src/components/flex/transforms/alignmentToAlign.ts +73 -0
  10. package/src/components/flex/transforms/axisToDirection.test.ts +140 -0
  11. package/src/components/flex/transforms/axisToDirection.ts +51 -0
  12. package/src/components/flex/transforms/distributionToJustify.test.ts +214 -0
  13. package/src/components/flex/transforms/distributionToJustify.ts +76 -0
  14. package/src/components/flex/transforms/innerRefToRef.test.ts +100 -0
  15. package/src/components/flex/transforms/innerRefToRef.ts +14 -0
  16. package/src/components/flex/transforms/moveFlexImport.test.ts +202 -0
  17. package/src/components/flex/transforms/moveFlexImport.ts +14 -0
  18. package/src/components/flex/transforms/setDefaultAxis.test.ts +114 -0
  19. package/src/components/flex/transforms/setDefaultAxis.ts +29 -0
  20. package/src/components/flex/transforms/spacingToGap.test.ts +176 -0
  21. package/src/components/flex/transforms/spacingToGap.ts +49 -0
  22. package/src/components/select/index.ts +2 -0
  23. package/src/components/select/transforms/onChangeSignature.test.ts +224 -0
  24. package/src/components/select/transforms/onChangeSignature.ts +172 -0
  25. package/src/components/shared/actions/resolveConstantAttributeValue.test.ts +122 -0
  26. package/src/components/shared/actions/resolveConstantAttributeValue.ts +31 -0
  27. package/src/components/toggle-switch/index.ts +2 -0
  28. package/src/components/toggle-switch/transforms/onClickToOnChange.test.ts +210 -0
  29. package/src/components/toggle-switch/transforms/onClickToOnChange.ts +71 -0
  30. package/src/index.test.ts +79 -0
  31. package/src/index.ts +29 -0
  32. package/src/migrationList.ts +22 -0
  33. package/src/utils/componentLabel.test.ts +61 -0
  34. package/src/utils/componentLabel.ts +15 -0
@@ -0,0 +1,210 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import transform from "./onClickToOnChange"
5
+
6
+ const j = jscodeshift.withParser("tsx")
7
+
8
+ function applyTransform(source: string, verbose = false): string {
9
+ const fileInfo = { path: "test.tsx", source }
10
+ const result = transform(
11
+ fileInfo,
12
+ { j, jscodeshift: j, report: () => {}, stats: () => {} },
13
+ { verbose }
14
+ ) as string | null
15
+ return result || source
16
+ }
17
+
18
+ describe("onClickToOnChange transform", () => {
19
+ describe("onClick to onChange conversion", () => {
20
+ it("should rename onClick to onChange", () => {
21
+ const input = `
22
+ import { ToggleSwitch } from "@planningcenter/tapestry-react"
23
+
24
+ function Test() {
25
+ return <ToggleSwitch checked={true} onClick={() => setChecked(!checked)} label="Toggle" />
26
+ }
27
+ `.trim()
28
+
29
+ const result = applyTransform(input)
30
+ expect(result).toContain("onChange={() => setChecked(!checked)}")
31
+ expect(result).not.toContain("onClick=")
32
+ })
33
+
34
+ it("should rename onClick with named function reference", () => {
35
+ const input = `
36
+ import { ToggleSwitch } from "@planningcenter/tapestry-react"
37
+
38
+ function Test() {
39
+ return <ToggleSwitch checked={true} onClick={handleChange} label="Toggle" />
40
+ }
41
+ `.trim()
42
+
43
+ const result = applyTransform(input)
44
+ expect(result).toContain("onChange={handleChange}")
45
+ expect(result).not.toContain("onClick=")
46
+ })
47
+
48
+ it("should handle multiple ToggleSwitch components", () => {
49
+ const input = `
50
+ import { ToggleSwitch } from "@planningcenter/tapestry-react"
51
+
52
+ function Test() {
53
+ return (
54
+ <div>
55
+ <ToggleSwitch checked={true} onClick={handleA} label="First" />
56
+ <ToggleSwitch checked={false} onClick={handleB} label="Second" />
57
+ </div>
58
+ )
59
+ }
60
+ `.trim()
61
+
62
+ const result = applyTransform(input)
63
+ expect(result).toContain("onChange={handleA}")
64
+ expect(result).toContain("onChange={handleB}")
65
+ expect(result).not.toContain("onClick=")
66
+ })
67
+
68
+ it("should preserve other props", () => {
69
+ const input = `
70
+ import { ToggleSwitch } from "@planningcenter/tapestry-react"
71
+
72
+ function Test() {
73
+ return (
74
+ <ToggleSwitch
75
+ checked={true}
76
+ label="Toggle"
77
+ disabled
78
+ onClick={() => {}}
79
+ />
80
+ )
81
+ }
82
+ `.trim()
83
+
84
+ const result = applyTransform(input)
85
+ expect(result).toContain("onChange={() => {}}")
86
+ expect(result).toContain('label="Toggle"')
87
+ expect(result).toContain("checked={true}")
88
+ expect(result).toContain("disabled")
89
+ expect(result).not.toContain("onClick=")
90
+ })
91
+
92
+ it("should add CHANGED comment when verbose is enabled", () => {
93
+ const input = `
94
+ import { ToggleSwitch } from "@planningcenter/tapestry-react"
95
+
96
+ function Test() {
97
+ return <ToggleSwitch checked={true} onClick={() => {}} label="Toggle" />
98
+ }
99
+ `.trim()
100
+
101
+ const result = applyTransform(input, true)
102
+ expect(result).toContain("onChange={() => {}}")
103
+ expect(result).not.toContain("onClick=")
104
+ expect(result).toContain(
105
+ "CHANGED: tapestry-migration (onChange): renamed from onClick"
106
+ )
107
+ })
108
+ })
109
+
110
+ describe("edge cases", () => {
111
+ it("should not affect ToggleSwitch without onClick", () => {
112
+ const input = `
113
+ import { ToggleSwitch } from "@planningcenter/tapestry-react"
114
+
115
+ function Test() {
116
+ return <ToggleSwitch checked={true} label="Toggle" />
117
+ }
118
+ `.trim()
119
+
120
+ const result = applyTransform(input)
121
+ expect(result).toBe(input)
122
+ })
123
+
124
+ it("should not affect other components with onClick", () => {
125
+ const input = `
126
+ import { Button, ToggleSwitch } from "@planningcenter/tapestry-react"
127
+
128
+ function Test() {
129
+ return (
130
+ <div>
131
+ <Button onClick={() => {}}>Click me</Button>
132
+ <ToggleSwitch checked={true} onClick={handleChange} label="Toggle" />
133
+ </div>
134
+ )
135
+ }
136
+ `.trim()
137
+
138
+ const result = applyTransform(input)
139
+ expect(result).toContain("<Button onClick={() => {}}>Click me</Button>")
140
+ expect(result).toContain("onChange={handleChange}")
141
+ })
142
+
143
+ it("should rename onClick and add TODO comment when handler takes an argument", () => {
144
+ const input = `
145
+ import { ToggleSwitch } from "@planningcenter/tapestry-react"
146
+
147
+ function Test() {
148
+ return <ToggleSwitch checked={true} onClick={(e) => handle(e)} label="Toggle" />
149
+ }
150
+ `.trim()
151
+
152
+ const result = applyTransform(input)
153
+ expect(result).toContain("onChange={(e) => handle(e)}")
154
+ expect(result).not.toContain("onClick=")
155
+ expect(result).toContain("TODO")
156
+ expect(result).toContain("ChangeEvent<HTMLInputElement>")
157
+ })
158
+
159
+ it("should not add TODO when handler takes no arguments", () => {
160
+ const input = `
161
+ import { ToggleSwitch } from "@planningcenter/tapestry-react"
162
+
163
+ function Test() {
164
+ return <ToggleSwitch checked={true} onClick={() => toggle()} label="Toggle" />
165
+ }
166
+ `.trim()
167
+
168
+ const result = applyTransform(input)
169
+ expect(result).toContain("onChange={() => toggle()}")
170
+ expect(result).not.toContain("onClick=")
171
+ expect(result).not.toContain("TODO")
172
+ })
173
+
174
+ it("should handle aliased ToggleSwitch import", () => {
175
+ const input = `
176
+ import { ToggleSwitch as TapestryToggleSwitch } from "@planningcenter/tapestry-react"
177
+
178
+ function Test() {
179
+ return <TapestryToggleSwitch checked={true} onClick={handleChange} label="Toggle" />
180
+ }
181
+ `.trim()
182
+
183
+ const result = applyTransform(input)
184
+ expect(result).toContain("onChange={handleChange}")
185
+ expect(result).not.toContain("onClick=")
186
+ })
187
+
188
+ it("should leave onClick and add TODO when both onClick and onChange are present", () => {
189
+ const input = `
190
+ import { ToggleSwitch } from "@planningcenter/tapestry-react"
191
+
192
+ function Test() {
193
+ return (
194
+ <ToggleSwitch
195
+ checked={true}
196
+ onClick={() => handleClick()}
197
+ onChange={(e) => handleChange(e)}
198
+ label="Toggle"
199
+ />
200
+ )
201
+ }
202
+ `.trim()
203
+
204
+ const result = applyTransform(input)
205
+ expect(result).toContain("onClick={() => handleClick()}")
206
+ expect(result).toContain("onChange={(e) => handleChange(e)}")
207
+ expect(result).toContain("TODO")
208
+ })
209
+ })
210
+ })
@@ -0,0 +1,71 @@
1
+ import { Transform } from "jscodeshift"
2
+
3
+ import { addCommentToAttribute } from "../../shared/actions/addCommentToAttribute"
4
+ import { transformAttributeName } from "../../shared/actions/transformAttributeName"
5
+ import { hasAttribute } from "../../shared/conditions/hasAttribute"
6
+ import { findAttribute } from "../../shared/findAttribute"
7
+ import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
8
+
9
+ const transform: Transform = attributeTransformFactory({
10
+ condition: hasAttribute("onClick"),
11
+ targetComponent: "ToggleSwitch",
12
+ targetPackage: "@planningcenter/tapestry-react",
13
+ transform: (element, { j, options }) => {
14
+ const attributes = element.openingElement.attributes || []
15
+
16
+ // When both onClick and onChange are present, don't rename (would clobber
17
+ // the existing onChange). Instead, leave onClick in place with a TODO so a
18
+ // developer can resolve the conflict manually.
19
+ if (hasAttribute("onChange")(element)) {
20
+ const onClickAttr = findAttribute(attributes, "onClick")
21
+ if (onClickAttr) {
22
+ addCommentToAttribute({
23
+ attribute: onClickAttr,
24
+ commentKind: "todo",
25
+ j,
26
+ text: "ToggleSwitch uses onChange, not onClick. Both are present — remove onClick and verify the onChange handler is correct (event: ChangeEvent<HTMLInputElement>, read new state via e.target.checked)",
27
+ })
28
+ return true
29
+ }
30
+ return false
31
+ }
32
+
33
+ // Rename onClick → onChange. transformAttributeName also adds the CHANGED
34
+ // comment when options.verbose is enabled.
35
+ const renamed = transformAttributeName("onClick", "onChange", {
36
+ element,
37
+ j,
38
+ options,
39
+ })
40
+
41
+ if (renamed) {
42
+ // If the handler references an argument, its event type has changed from
43
+ // MouseEvent (button) to ChangeEvent<HTMLInputElement> (checkbox). Flag
44
+ // it so the developer can verify the handler logic.
45
+ const onChangeAttr = findAttribute(attributes, "onChange")
46
+ if (onChangeAttr) {
47
+ const value = onChangeAttr.value
48
+ if (value?.type === "JSXExpressionContainer") {
49
+ const expr = value.expression
50
+ if (
51
+ (expr.type === "ArrowFunctionExpression" ||
52
+ expr.type === "FunctionExpression") &&
53
+ expr.params.length > 0
54
+ ) {
55
+ addCommentToAttribute({
56
+ attribute: onChangeAttr,
57
+ commentKind: "todo",
58
+ j,
59
+ text: "renamed from onClick: the event type has changed from MouseEvent to ChangeEvent<HTMLInputElement> — verify the handler uses e.target.checked if needed",
60
+ })
61
+ }
62
+ }
63
+ }
64
+ return true
65
+ }
66
+
67
+ return false
68
+ },
69
+ })
70
+
71
+ export default transform
@@ -0,0 +1,79 @@
1
+ /**
2
+ * E2E regression test for `list --json`.
3
+ *
4
+ * Spawns the CLI exactly as consumers do — `npx tsx src/index.ts list --json` —
5
+ * and asserts the full stdout/stderr/exit-code contract.
6
+ */
7
+
8
+ import { spawnSync } from "node:child_process"
9
+ import { fileURLToPath } from "node:url"
10
+
11
+ import { describe, expect, it } from "vitest"
12
+
13
+ import { buildMigrationList } from "./migrationList"
14
+
15
+ const CLI_PATH = fileURLToPath(new URL("./index.ts", import.meta.url))
16
+
17
+ function runCli(args: string[]) {
18
+ return spawnSync("npx", ["tsx", CLI_PATH, ...args], {
19
+ encoding: "utf8",
20
+ // tsx cold-start can take a few seconds; allow up to 30 s.
21
+ timeout: 30_000,
22
+ })
23
+ }
24
+
25
+ describe("list --json (stdout/exit-code contract)", () => {
26
+ it("exits with code 0", () => {
27
+ const result = runCli(["list", "--json"])
28
+ expect(result.status).toBe(0)
29
+ })
30
+
31
+ it("emits valid JSON as the entire stdout (no stray lines)", () => {
32
+ const result = runCli(["list", "--json"])
33
+ // JSON.parse throws if there is ANY non-JSON content on stdout.
34
+ expect(() => JSON.parse(result.stdout)).not.toThrow()
35
+ })
36
+
37
+ it("payload matches buildMigrationList() (schemaVersion + migrations)", () => {
38
+ const result = runCli(["list", "--json"])
39
+ const payload = JSON.parse(result.stdout)
40
+ expect(payload).toEqual(buildMigrationList())
41
+ })
42
+
43
+ it("schemaVersion is exactly the integer 1", () => {
44
+ const result = runCli(["list", "--json"])
45
+ const { schemaVersion } = JSON.parse(result.stdout)
46
+ expect(schemaVersion).toBe(1)
47
+ expect(Number.isInteger(schemaVersion)).toBe(true)
48
+ })
49
+
50
+ it("stderr is empty on success", () => {
51
+ const result = runCli(["list", "--json"])
52
+ expect(result.stderr).toBe("")
53
+ })
54
+
55
+ it("does not require --path and makes no filesystem writes", () => {
56
+ // If --path were required, commander would write an error to stderr and exit 1.
57
+ // We assert the happy path — exit 0 with no --path — proving the flag is absent.
58
+ const result = runCli(["list", "--json"])
59
+ expect(result.status).toBe(0)
60
+ })
61
+ })
62
+
63
+ describe("list (human-readable, non-contractual)", () => {
64
+ it("exits with code 0 and writes component ids to stdout", () => {
65
+ const result = runCli(["list"])
66
+ expect(result.status).toBe(0)
67
+ expect(result.stdout).toContain("button")
68
+ expect(result.stdout).toContain("toggle-switch")
69
+ })
70
+ })
71
+
72
+ describe("diagnostics contract (nonzero exit → stderr)", () => {
73
+ it("exits nonzero and writes to stderr for an unknown subcommand", () => {
74
+ const result = runCli(["unknown-subcommand"])
75
+ // Commander writes "error: unknown command 'unknown-subcommand'" to stderr.
76
+ expect(result.status).not.toBe(0)
77
+ expect(result.stderr.length).toBeGreaterThan(0)
78
+ })
79
+ })
package/src/index.ts CHANGED
@@ -4,6 +4,8 @@ import { Command } from "commander"
4
4
 
5
5
  import { AVAILABLE_CLI_COMPONENTS } from "./availableComponents"
6
6
  import { runTransforms } from "./jscodeshiftRunner"
7
+ import { buildMigrationList } from "./migrationList"
8
+ import { formatComponentLabel } from "./utils/componentLabel"
7
9
  import { normalizeComponentName } from "./utils/componentNameNormalizer"
8
10
 
9
11
  const program = new Command()
@@ -92,6 +94,33 @@ program
92
94
  runTransforms("preflight", options)
93
95
  })
94
96
 
97
+ program
98
+ .command("list")
99
+ .description(
100
+ "List available migrations. Pass --json for a stable, versioned machine-readable contract."
101
+ )
102
+ .option(
103
+ "--json",
104
+ "Output migrations as JSON to stdout (diagnostics go to stderr; exits nonzero on failure)"
105
+ )
106
+ .action((options) => {
107
+ try {
108
+ if (options.json) {
109
+ // JSON ONLY on stdout — no emoji, no progress lines, no trailing text.
110
+ process.stdout.write(
111
+ JSON.stringify(buildMigrationList(), null, 2) + "\n"
112
+ )
113
+ } else {
114
+ for (const id of AVAILABLE_CLI_COMPONENTS) {
115
+ console.log(`${id} — ${formatComponentLabel(id)}`)
116
+ }
117
+ }
118
+ } catch (error) {
119
+ console.error(`❌ Failed to list migrations: ${(error as Error).message}`)
120
+ process.exit(1)
121
+ }
122
+ })
123
+
95
124
  program
96
125
  .command("help")
97
126
  .description("Show help information")
@@ -0,0 +1,22 @@
1
+ import { AVAILABLE_CLI_COMPONENTS } from "./availableComponents"
2
+ import { formatComponentLabel } from "./utils/componentLabel"
3
+
4
+ export const LIST_SCHEMA_VERSION = 1
5
+
6
+ /**
7
+ * Builds the versioned migration list payload used by `list --json`.
8
+ * Pure — no I/O, no side effects.
9
+ *
10
+ * @example
11
+ * buildMigrationList()
12
+ * // { schemaVersion: 1, migrations: [{ id: "button", label: "Button" }, ...] }
13
+ */
14
+ export function buildMigrationList() {
15
+ return {
16
+ migrations: [...AVAILABLE_CLI_COMPONENTS].map((id) => ({
17
+ id,
18
+ label: formatComponentLabel(id),
19
+ })),
20
+ schemaVersion: LIST_SCHEMA_VERSION,
21
+ }
22
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { AVAILABLE_CLI_COMPONENTS } from "../availableComponents"
4
+ import { buildMigrationList } from "../migrationList"
5
+ import { formatComponentLabel } from "./componentLabel"
6
+ import { normalizeComponentName } from "./componentNameNormalizer"
7
+
8
+ describe("formatComponentLabel", () => {
9
+ it("should title-case a single-word id", () => {
10
+ expect(formatComponentLabel("button")).toBe("Button")
11
+ expect(formatComponentLabel("input")).toBe("Input")
12
+ expect(formatComponentLabel("radio")).toBe("Radio")
13
+ })
14
+
15
+ it("should produce Spaced Title Case for multi-word ids", () => {
16
+ expect(formatComponentLabel("date-picker")).toBe("Date Picker")
17
+ expect(formatComponentLabel("text-area")).toBe("Text Area")
18
+ expect(formatComponentLabel("time-field")).toBe("Time Field")
19
+ expect(formatComponentLabel("toggle-switch")).toBe("Toggle Switch")
20
+ })
21
+ })
22
+
23
+ describe("buildMigrationList", () => {
24
+ it("should return schemaVersion 1 as an integer", () => {
25
+ const result = buildMigrationList()
26
+ expect(result.schemaVersion).toBe(1)
27
+ expect(Number.isInteger(result.schemaVersion)).toBe(true)
28
+ })
29
+
30
+ it("should return a nonempty migrations array", () => {
31
+ const { migrations } = buildMigrationList()
32
+ expect(migrations.length).toBeGreaterThan(0)
33
+ })
34
+
35
+ it("should give every migration a nonempty string id and label", () => {
36
+ for (const { id, label } of buildMigrationList().migrations) {
37
+ expect(typeof id).toBe("string")
38
+ expect(id.length).toBeGreaterThan(0)
39
+ expect(typeof label).toBe("string")
40
+ expect(label.length).toBeGreaterThan(0)
41
+ }
42
+ })
43
+
44
+ it("should have unique ids", () => {
45
+ const ids = buildMigrationList().migrations.map((m) => m.id)
46
+ expect(new Set(ids).size).toBe(ids.length)
47
+ })
48
+
49
+ it("should emit ids that are exactly accepted by `run` (present in AVAILABLE_CLI_COMPONENTS)", () => {
50
+ for (const { id } of buildMigrationList().migrations) {
51
+ expect(AVAILABLE_CLI_COMPONENTS.has(id)).toBe(true)
52
+ // Also verify the normalizer round-trips it — proving run would accept it
53
+ expect(normalizeComponentName(id, AVAILABLE_CLI_COMPONENTS)).toBe(id)
54
+ }
55
+ })
56
+
57
+ it("should include every id in AVAILABLE_CLI_COMPONENTS with no extras", () => {
58
+ const emittedIds = buildMigrationList().migrations.map((m) => m.id)
59
+ expect(emittedIds).toEqual([...AVAILABLE_CLI_COMPONENTS])
60
+ })
61
+ })
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Derives a human-readable display label from a kebab-case component id.
3
+ * Splits on hyphens and title-cases each word.
4
+ *
5
+ * @example
6
+ * formatComponentLabel("button") // "Button"
7
+ * formatComponentLabel("date-picker") // "Date Picker"
8
+ * formatComponentLabel("toggle-switch") // "Toggle Switch"
9
+ */
10
+ export function formatComponentLabel(id: string): string {
11
+ return id
12
+ .split("-")
13
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
14
+ .join(" ")
15
+ }