From 9911b184741e97e3bc6354e1067451f0bf25f7ad Mon Sep 17 00:00:00 2001 From: figma-bot Date: Wed, 10 Jul 2024 12:55:12 +0000 Subject: [PATCH] Code Connect v1.0.2 --- CHANGELOG.md | 17 ++ README.md | 2 +- cli/README.md | 25 +- cli/package.json | 6 +- .../dummy_api_response_for_wizard.json | 1 + .../react_wizard/components/PrimaryButton.tsx | 16 ++ .../react_wizard/package.json | 15 ++ .../react_wizard/tsconfig.json | 11 + cli/src/__test__/e2e_wizard.test.ts | 70 +++++ cli/src/cli.ts | 1 + cli/src/connect/__test__/project.test.ts | 76 ++++++ cli/src/connect/project.ts | 48 +++- .../connect/wizard/__test__/helpers.test.ts | 26 ++ .../wizard/__test__/run_wizard.test.ts | 195 ++++++++++++++ cli/src/connect/wizard/helpers.ts | 47 +++- cli/src/connect/wizard/run_wizard.ts | 239 ++++++++++++------ cli/src/index.ts | 23 +- .../parser_scripts/get_swift_parser_dir.ts | 2 +- cli/src/react/__test__/create.test.ts | 94 +++++++ cli/src/react/create.ts | 6 +- cli/src/react/parser.ts | 30 +-- cli/src/react/parser_template_helpers.ts | 13 +- compose/README.md | 2 +- .../code/connect/FigmaCodeConnectPlugin.kt | 6 + 24 files changed, 846 insertions(+), 125 deletions(-) create mode 100644 cli/src/__test__/e2e_connect_command/dummy_api_response_for_wizard.json create mode 100644 cli/src/__test__/e2e_connect_command/react_wizard/components/PrimaryButton.tsx create mode 100644 cli/src/__test__/e2e_connect_command/react_wizard/package.json create mode 100644 cli/src/__test__/e2e_connect_command/react_wizard/tsconfig.json create mode 100644 cli/src/__test__/e2e_wizard.test.ts create mode 100644 cli/src/connect/__test__/project.test.ts create mode 100644 cli/src/connect/wizard/__test__/helpers.test.ts create mode 100644 cli/src/connect/wizard/__test__/run_wizard.test.ts create mode 100644 cli/src/react/__test__/create.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c88d7d4..d4090ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +# Code Connect v1.0.2 (10th July 2024) + +## Fixed + +### General +- Improvements to CLI Assistant + +### React +- Prevent rendering empty strings as prop values (Fixes: https://github.com/figma/code-connect/issues/67) +- Fix output when there are multiple return statements +- Fix wildcard importPaths mappings with nested folders +- Fix boolean mappings for lowercase boolean-like strings (Fixes: https://github.com/figma/code-connect/issues/70) +- Fix boolean-like keys in enums (Fixes: https://github.com/figma/code-connect/issues/74) + +### SwiftUI +- Fix spaces in Xcode file path + # Code Connect v1.0.1 (20th June 2024) ## Fixed diff --git a/README.md b/README.md index 5f4a620..695f9b0 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Code Connect is easy to set up, easy to maintain, type-safe, and extensible. Out ![image](https://static.figma.com/uploads/d98e747613e01685d6a0f9dd3e2dcd022ff289c0.png) > [!NOTE] -> Code Connect is available on Organization and Enterprise plans and requires a full Design or Dev Mode seat to use. Code Connect is currently in beta, so you can expect this feature to change. You may also experience bugs or performance issues during this time. +> Code Connect is available on Organization and Enterprise plans and requires a full Design or Dev Mode seat to use. ## CLI installation diff --git a/cli/README.md b/cli/README.md index 9a2aa50..7e7a4b9 100644 --- a/cli/README.md +++ b/cli/README.md @@ -276,7 +276,7 @@ figma.string('Title') ### Booleans -Booleans work similar to strings. However Code Connect also provides helpers for mapping booleans in Figma to more complex types in code. For example you may want to map a Figma boolean to the existence of a specific sublayer in code. +Booleans work similar to strings. However Code Connect also provides helpers for mapping booleans in Figma to more complex types in code. For example you may want to map a Figma boolean to the existence of a specific sublayer in code. In addition to mapping boolean props, `figma.boolean` can be used to map boolean Variants in Figma. A boolean Variant is a Variant with only two options that are either "Yes"/"No", "True"/"False" or "On"/Off". For `figma.boolean` these values are normalized to `true` and `false`. ```tsx // simple mapping of boolean from figma to code @@ -287,15 +287,16 @@ figma.boolean('Has Icon', { true: , false: , }) + ``` In some cases, you only want to render a certain prop if it matches some value in Figma. You can do this either by passing a partial mapping object, or setting the value to `undefined`. ```tsx -// Don't render the prop if 'Has Icon' in figma is `false` -figma.boolean('Has Icon', { - true: , - false: undefined, +// Don't render the prop if 'Has label' in figma is `false` +figma.boolean("Has label", { + true: figma.string("Label"), + false: undefined }) ``` @@ -330,6 +331,20 @@ figma.enum('Type', { }) ``` +Note that in contrast to `figma.boolean`, values are _not_ normalized for `figma.enum`. You always need to pass the exact literal values to the mapping object. + +```tsx +// These two are equivalent for a variant with the options "Yes" and "No" +disabled: figma.enum("Boolean Variant", { + Yes: // ... + No: // ... +}) +disabled: figma.boolean("Boolean Variant", { + true: // ... + false: // ... +}) +``` + ### Instances Instances is a Figma term for nested component references. For example, in the case of a `Button` containing an `Icon` as a nested component, we would call the `Icon` an instance. In Figma instances can be properties, (that is, inputs to the component), just like we have render props in code. Similarly to how we can map booleans, enums, and strings from Figma to code, we can also map these to instance props. diff --git a/cli/package.json b/cli/package.json index 15c4c50..c4a2700 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@figma/code-connect", - "version": "1.0.1", + "version": "1.0.2", "description": "A tool for connecting your design system components in code with your design system in Figma", "keywords": [], "author": "Figma", @@ -25,6 +25,7 @@ "test": "cross-env NODE_OPTIONS=--experimental-vm-modules npx jest --coverage", "test:fast": "npm run test -- --testPathIgnorePatterns=template_rendering.test.ts --testPathIgnorePatterns=e2e_connect_command_swift.test.ts", "test:ci": "npm run test:non-mac -- --runInBand", + "test:wizard": "npm run test -- --runInBand --testPathPattern=e2e_wizard.test.ts", "test:swift": "npm run test -- --runInBand --testPathPattern=e2e_connect_command_swift.test.ts", "test:non-mac": "npm run test -- --testPathIgnorePatterns=e2e_connect_command_swift.test.ts", "bundle": "npm run build && npm pack && mkdir -p bundle && mv figma-code-connect*.tgz bundle", @@ -40,7 +41,7 @@ "@types/cross-spawn": "^6.0.6", "@types/jest": "^29.5.5", "@types/lodash": "^4.17.0", - "@types/node": "^18.17.1", + "@types/node": "^20.14.0", "@types/prettier": "2.7.3", "@types/prompts": "^2.4.9", "@types/react": "18.0.26", @@ -73,6 +74,7 @@ "glob": "^10.3.10", "lodash": "^4.17.21", "minimatch": "^9.0.3", + "ora": "^5.4.1", "prettier": "^2.8.8", "prompts": "^2.4.2", "strip-ansi": "^6.0.0", diff --git a/cli/src/__test__/e2e_connect_command/dummy_api_response_for_wizard.json b/cli/src/__test__/e2e_connect_command/dummy_api_response_for_wizard.json new file mode 100644 index 0000000..b025bfd --- /dev/null +++ b/cli/src/__test__/e2e_connect_command/dummy_api_response_for_wizard.json @@ -0,0 +1 @@ +{"document":{"id":"0:0","name":"Document","type":"DOCUMENT","scrollBehavior":"SCROLLS","children":[{"id":"0:1","name":"Page 1","type":"CANVAS","scrollBehavior":"SCROLLS","children":[{"id":"2:19","name":"Loading Spinner","type":"COMPONENT","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","children":[{"id":"2:10","name":"Loading Spinner","type":"RECTANGLE","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","absoluteBoundingBox":{"x":-1094,"y":-630,"width":230,"height":63},"absoluteRenderBounds":{"x":-1094,"y":-630,"width":230,"height":63},"constraints":{"vertical":"SCALE","horizontal":"SCALE"},"fills":[{"blendMode":"NORMAL","type":"SOLID","color":{"r":0.8509804010391235,"g":0.8509804010391235,"b":0.8509804010391235,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","exportSettings":[],"effects":[]}],"absoluteBoundingBox":{"x":-1094,"y":-630,"width":230,"height":63},"absoluteRenderBounds":{"x":-1094,"y":-630,"width":230,"height":63},"constraints":{"vertical":"TOP","horizontal":"LEFT"},"clipsContent":false,"background":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"fills":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","backgroundColor":{"r":0,"g":0,"b":0,"a":0},"exportSettings":[],"effects":[]},{"id":"2:15","name":"Loading Button","type":"COMPONENT","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","children":[{"id":"2:9","name":"Loading Button","type":"RECTANGLE","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","absoluteBoundingBox":{"x":-1128,"y":-732,"width":230,"height":63},"absoluteRenderBounds":{"x":-1128,"y":-732,"width":230,"height":63},"constraints":{"vertical":"SCALE","horizontal":"SCALE"},"fills":[{"blendMode":"NORMAL","type":"SOLID","color":{"r":0.8509804010391235,"g":0.8509804010391235,"b":0.8509804010391235,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","exportSettings":[],"effects":[]}],"absoluteBoundingBox":{"x":-1128,"y":-732,"width":230,"height":63},"absoluteRenderBounds":{"x":-1128,"y":-732,"width":230,"height":63},"constraints":{"vertical":"TOP","horizontal":"LEFT"},"clipsContent":false,"background":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"fills":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","backgroundColor":{"r":0,"g":0,"b":0,"a":0},"exportSettings":[],"effects":[]},{"id":"2:17","name":"Card.Foo","type":"COMPONENT","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","children":[{"id":"2:7","name":"Card","type":"RECTANGLE","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","absoluteBoundingBox":{"x":-1195,"y":-925,"width":230,"height":63},"absoluteRenderBounds":{"x":-1195,"y":-925,"width":230,"height":63},"constraints":{"vertical":"SCALE","horizontal":"SCALE"},"fills":[{"blendMode":"NORMAL","type":"SOLID","color":{"r":0.8509804010391235,"g":0.8509804010391235,"b":0.8509804010391235,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","exportSettings":[],"effects":[]}],"absoluteBoundingBox":{"x":-1195,"y":-925,"width":230,"height":63},"absoluteRenderBounds":{"x":-1195,"y":-925,"width":230,"height":63},"constraints":{"vertical":"TOP","horizontal":"LEFT"},"clipsContent":false,"background":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"fills":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","backgroundColor":{"r":0,"g":0,"b":0,"a":0},"exportSettings":[],"effects":[]},{"id":"140:2","name":"Header","type":"COMPONENT_SET","scrollBehavior":"SCROLLS","componentPropertyDefinitions":{"Property 1":{"type":"VARIANT","defaultValue":"Default","variantOptions":["Default","Variant2"]}},"blendMode":"PASS_THROUGH","children":[{"id":"2:18","name":"Property 1=Default","type":"COMPONENT","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","children":[{"id":"2:3","name":"Header","type":"RECTANGLE","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","absoluteBoundingBox":{"x":-1182,"y":-1130,"width":230,"height":63},"absoluteRenderBounds":{"x":-1182,"y":-1130,"width":230,"height":63},"constraints":{"vertical":"SCALE","horizontal":"SCALE"},"fills":[{"blendMode":"NORMAL","type":"SOLID","color":{"r":0.8509804010391235,"g":0.8509804010391235,"b":0.8509804010391235,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","exportSettings":[],"effects":[]}],"absoluteBoundingBox":{"x":-1182,"y":-1130,"width":230,"height":63},"absoluteRenderBounds":{"x":-1182,"y":-1130,"width":230,"height":63},"constraints":{"vertical":"TOP","horizontal":"LEFT"},"clipsContent":false,"background":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"fills":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","backgroundColor":{"r":0,"g":0,"b":0,"a":0},"exportSettings":[],"effects":[]},{"id":"140:3","name":"Property 1=Variant2","type":"COMPONENT","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","children":[{"id":"140:4","name":"Header","type":"RECTANGLE","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","absoluteBoundingBox":{"x":-1182,"y":-1047,"width":230,"height":63},"absoluteRenderBounds":{"x":-1182,"y":-1047,"width":230,"height":63},"constraints":{"vertical":"SCALE","horizontal":"SCALE"},"fills":[{"blendMode":"NORMAL","type":"SOLID","color":{"r":0.8509804010391235,"g":0.8509804010391235,"b":0.8509804010391235,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","exportSettings":[],"effects":[]}],"absoluteBoundingBox":{"x":-1182,"y":-1047,"width":230,"height":63},"absoluteRenderBounds":{"x":-1182,"y":-1047,"width":230,"height":63},"constraints":{"vertical":"TOP","horizontal":"LEFT"},"clipsContent":false,"background":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"fills":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","backgroundColor":{"r":0,"g":0,"b":0,"a":0},"exportSettings":[],"effects":[]}],"absoluteBoundingBox":{"x":-1202,"y":-1150,"width":270,"height":186},"absoluteRenderBounds":{"x":-1202,"y":-1150,"width":270,"height":186},"constraints":{"vertical":"TOP","horizontal":"LEFT"},"clipsContent":true,"background":[],"fills":[],"strokes":[{"blendMode":"NORMAL","type":"SOLID","color":{"r":0.5921568870544434,"g":0.27843138575553894,"b":1,"a":1}}],"cornerRadius":5,"cornerSmoothing":0,"strokeWeight":1,"strokeAlign":"INSIDE","backgroundColor":{"r":0,"g":0,"b":0,"a":0},"strokeDashes":[10,5],"exportSettings":[],"effects":[]},{"id":"140:9","name":"Modal (FINAL)","type":"COMPONENT_SET","scrollBehavior":"SCROLLS","componentPropertyDefinitions":{"Property 1":{"type":"VARIANT","defaultValue":"Default","variantOptions":["Default","Variant2"]}},"blendMode":"PASS_THROUGH","children":[{"id":"2:16","name":"Property 1=Default","type":"COMPONENT","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","children":[{"id":"2:8","name":"Modal (FINAL)","type":"RECTANGLE","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","absoluteBoundingBox":{"x":-838,"y":-916,"width":230,"height":63},"absoluteRenderBounds":{"x":-838,"y":-916,"width":230,"height":63},"constraints":{"vertical":"SCALE","horizontal":"SCALE"},"fills":[{"blendMode":"NORMAL","type":"SOLID","color":{"r":0.8509804010391235,"g":0.8509804010391235,"b":0.8509804010391235,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","exportSettings":[],"effects":[]}],"absoluteBoundingBox":{"x":-838,"y":-916,"width":230,"height":63},"absoluteRenderBounds":{"x":-838,"y":-916,"width":230,"height":63},"constraints":{"vertical":"TOP","horizontal":"LEFT"},"clipsContent":false,"background":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"fills":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","backgroundColor":{"r":0,"g":0,"b":0,"a":0},"exportSettings":[],"effects":[]},{"id":"140:10","name":"Property 1=Variant2","type":"COMPONENT","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","children":[{"id":"140:11","name":"Modal (FINAL)","type":"RECTANGLE","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","absoluteBoundingBox":{"x":-838,"y":-833,"width":230,"height":63},"absoluteRenderBounds":{"x":-838,"y":-833,"width":230,"height":63},"constraints":{"vertical":"SCALE","horizontal":"SCALE"},"fills":[{"blendMode":"NORMAL","type":"SOLID","color":{"r":0.8509804010391235,"g":0.8509804010391235,"b":0.8509804010391235,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","exportSettings":[],"effects":[]}],"absoluteBoundingBox":{"x":-838,"y":-833,"width":230,"height":63},"absoluteRenderBounds":{"x":-838,"y":-833,"width":230,"height":63},"constraints":{"vertical":"TOP","horizontal":"LEFT"},"clipsContent":false,"background":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"fills":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","backgroundColor":{"r":0,"g":0,"b":0,"a":0},"exportSettings":[],"effects":[]}],"absoluteBoundingBox":{"x":-858,"y":-936,"width":270,"height":186},"absoluteRenderBounds":{"x":-858,"y":-936,"width":270,"height":186},"constraints":{"vertical":"TOP","horizontal":"LEFT"},"clipsContent":true,"background":[],"fills":[],"strokes":[{"blendMode":"NORMAL","type":"SOLID","color":{"r":0.5921568870544434,"g":0.27843138575553894,"b":1,"a":1}}],"cornerRadius":5,"cornerSmoothing":0,"strokeWeight":1,"strokeAlign":"INSIDE","backgroundColor":{"r":0,"g":0,"b":0,"a":0},"strokeDashes":[10,5],"exportSettings":[],"effects":[]},{"id":"140:14","name":"Primary Button","type":"COMPONENT_SET","scrollBehavior":"SCROLLS","componentPropertyDefinitions":{"Property 1":{"type":"VARIANT","defaultValue":"Default","variantOptions":["Default","Variant2"]}},"blendMode":"PASS_THROUGH","children":[{"id":"2:13","name":"Property 1=Default","type":"COMPONENT","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","children":[{"id":"2:12","name":"Primary Button","type":"RECTANGLE","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","absoluteBoundingBox":{"x":-1401,"y":-495,"width":230,"height":63},"absoluteRenderBounds":{"x":-1401,"y":-495,"width":230,"height":63},"constraints":{"vertical":"SCALE","horizontal":"SCALE"},"fills":[{"blendMode":"NORMAL","type":"SOLID","color":{"r":0.8509804010391235,"g":0.8509804010391235,"b":0.8509804010391235,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","exportSettings":[],"effects":[]}],"absoluteBoundingBox":{"x":-1401,"y":-495,"width":230,"height":63},"absoluteRenderBounds":{"x":-1401,"y":-495,"width":230,"height":63},"constraints":{"vertical":"TOP","horizontal":"LEFT"},"clipsContent":false,"background":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"fills":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","backgroundColor":{"r":0,"g":0,"b":0,"a":0},"exportSettings":[],"effects":[]},{"id":"140:15","name":"Property 1=Variant2","type":"COMPONENT","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","children":[{"id":"140:16","name":"Primary Button","type":"RECTANGLE","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","absoluteBoundingBox":{"x":-1401,"y":-412,"width":230,"height":63},"absoluteRenderBounds":{"x":-1401,"y":-412,"width":230,"height":63},"constraints":{"vertical":"SCALE","horizontal":"SCALE"},"fills":[{"blendMode":"NORMAL","type":"SOLID","color":{"r":0.8509804010391235,"g":0.8509804010391235,"b":0.8509804010391235,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","exportSettings":[],"effects":[]}],"absoluteBoundingBox":{"x":-1401,"y":-412,"width":230,"height":63},"absoluteRenderBounds":{"x":-1401,"y":-412,"width":230,"height":63},"constraints":{"vertical":"TOP","horizontal":"LEFT"},"clipsContent":false,"background":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"fills":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","backgroundColor":{"r":0,"g":0,"b":0,"a":0},"exportSettings":[],"effects":[]}],"absoluteBoundingBox":{"x":-1421,"y":-515,"width":270,"height":186},"absoluteRenderBounds":{"x":-1421,"y":-515,"width":270,"height":186},"constraints":{"vertical":"TOP","horizontal":"LEFT"},"clipsContent":true,"background":[],"fills":[],"strokes":[{"blendMode":"NORMAL","type":"SOLID","color":{"r":0.5921568870544434,"g":0.27843138575553894,"b":1,"a":1}}],"cornerRadius":5,"cornerSmoothing":0,"strokeWeight":1,"strokeAlign":"INSIDE","backgroundColor":{"r":0,"g":0,"b":0,"a":0},"strokeDashes":[10,5],"exportSettings":[],"effects":[]},{"id":"142:4","name":"Secondary Button","type":"COMPONENT_SET","scrollBehavior":"SCROLLS","componentPropertyDefinitions":{"Property 1":{"type":"VARIANT","defaultValue":"Default","variantOptions":["Default","Variant2"]}},"blendMode":"PASS_THROUGH","children":[{"id":"2:14","name":"Property 1=Default","type":"COMPONENT","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","children":[{"id":"2:11","name":"Secondary Button","type":"RECTANGLE","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","absoluteBoundingBox":{"x":-1013,"y":-419,"width":230,"height":63},"absoluteRenderBounds":{"x":-1013,"y":-419,"width":230,"height":63},"constraints":{"vertical":"SCALE","horizontal":"SCALE"},"fills":[{"blendMode":"NORMAL","type":"SOLID","color":{"r":0.8509804010391235,"g":0.8509804010391235,"b":0.8509804010391235,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","exportSettings":[],"effects":[]}],"absoluteBoundingBox":{"x":-1013,"y":-419,"width":230,"height":63},"absoluteRenderBounds":{"x":-1013,"y":-419,"width":230,"height":63},"constraints":{"vertical":"TOP","horizontal":"LEFT"},"clipsContent":false,"background":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"fills":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","backgroundColor":{"r":0,"g":0,"b":0,"a":0},"exportSettings":[],"effects":[]},{"id":"142:5","name":"Property 1=Variant2","type":"COMPONENT","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","children":[{"id":"142:6","name":"Secondary Button","type":"RECTANGLE","scrollBehavior":"SCROLLS","blendMode":"PASS_THROUGH","absoluteBoundingBox":{"x":-1013,"y":-336,"width":230,"height":63},"absoluteRenderBounds":{"x":-1013,"y":-336,"width":230,"height":63},"constraints":{"vertical":"SCALE","horizontal":"SCALE"},"fills":[{"blendMode":"NORMAL","type":"SOLID","color":{"r":0.8509804010391235,"g":0.8509804010391235,"b":0.8509804010391235,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","exportSettings":[],"effects":[]}],"absoluteBoundingBox":{"x":-1013,"y":-336,"width":230,"height":63},"absoluteRenderBounds":{"x":-1013,"y":-336,"width":230,"height":63},"constraints":{"vertical":"TOP","horizontal":"LEFT"},"clipsContent":false,"background":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"fills":[{"blendMode":"NORMAL","visible":false,"type":"SOLID","color":{"r":1,"g":1,"b":1,"a":1}}],"strokes":[],"strokeWeight":1,"strokeAlign":"INSIDE","backgroundColor":{"r":0,"g":0,"b":0,"a":0},"exportSettings":[],"effects":[]}],"absoluteBoundingBox":{"x":-1033,"y":-439,"width":270,"height":186},"absoluteRenderBounds":{"x":-1033,"y":-439,"width":270,"height":186},"constraints":{"vertical":"TOP","horizontal":"LEFT"},"clipsContent":true,"background":[],"fills":[],"strokes":[{"blendMode":"NORMAL","type":"SOLID","color":{"r":0.5921568870544434,"g":0.27843138575553894,"b":1,"a":1}}],"cornerRadius":5,"cornerSmoothing":0,"strokeWeight":1,"strokeAlign":"INSIDE","backgroundColor":{"r":0,"g":0,"b":0,"a":0},"strokeDashes":[10,5],"exportSettings":[],"effects":[]}],"backgroundColor":{"r":0.9607843160629272,"g":0.9607843160629272,"b":0.9607843160629272,"a":1},"prototypeStartNodeID":null,"flowStartingPoints":[],"prototypeDevice":{"type":"NONE","rotation":"NONE"},"exportSettings":[]}]},"components":{"2:19":{"key":"e480bb7752a4d67f979ca20418bd4b65003c86a4","name":"Loading Spinner","description":"","remote":false,"documentationLinks":[]},"2:15":{"key":"358cff8aa5c881847b9fa024b371b4f9d0a51ae7","name":"Loading Button","description":"","remote":false,"documentationLinks":[]},"2:17":{"key":"6b7a5e9893855c42b6cc56c748eb20a972b3ce25","name":"Card.Foo","description":"","remote":false,"documentationLinks":[]},"2:18":{"key":"04169647acca49661583640459cc9d7ea2913181","name":"Property 1=Default","description":"","remote":false,"componentSetId":"140:2","documentationLinks":[]},"140:3":{"key":"87bc18ff5435437d476601f26d9dc51f0052df5f","name":"Property 1=Variant2","description":"","remote":false,"componentSetId":"140:2","documentationLinks":[]},"2:16":{"key":"63f5c3912421362744c42978b8dccafd8a2494de","name":"Property 1=Default","description":"","remote":false,"componentSetId":"140:9","documentationLinks":[]},"140:10":{"key":"875523e37ae344baa9726d4d828fbe5d8d2eda06","name":"Property 1=Variant2","description":"","remote":false,"componentSetId":"140:9","documentationLinks":[]},"2:13":{"key":"884752885f668decb2ffb421c69ef694ccafea5b","name":"Property 1=Default","description":"","remote":false,"componentSetId":"140:14","documentationLinks":[]},"140:15":{"key":"45483c9ed180c27f149d88ee8f8e973884fd5796","name":"Property 1=Variant2","description":"","remote":false,"componentSetId":"140:14","documentationLinks":[]},"2:14":{"key":"b54f97b9ca78166a4e620d8f1d961cb30a30eb0a","name":"Property 1=Default","description":"","remote":false,"componentSetId":"142:4","documentationLinks":[]},"142:5":{"key":"df7c4c276c8e7b1292bbc9f2c61a7e13785e50c8","name":"Property 1=Variant2","description":"","remote":false,"componentSetId":"142:4","documentationLinks":[]}},"componentSets":{"140:2":{"key":"96a15bc3c2f43c9e32615bc90a24f118c0f7f434","name":"Header","description":"","documentationLinks":[]},"140:9":{"key":"5e9f82a9c900c6b061114c376a0cdce7a1056f2f","name":"Modal (FINAL)","description":"","documentationLinks":[]},"140:14":{"key":"65ac4254407998ef9b51514145267dbf60e33308","name":"Primary Button","description":"","documentationLinks":[]},"142:4":{"key":"359ac56ae00b9d020ecae774b9b536b7fca8d260","name":"Secondary Button","description":"","documentationLinks":[]}},"schemaVersion":0,"styles":{},"name":"Wizard demo","lastModified":"2024-06-14T12:43:25Z","thumbnailUrl":"https://s3-staging.staging.figma.com/thumbnails/af7eb522-aeb4-426d-9169-1c792bee2f2d?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA56VQ2PUNNZM7AUJN%2F20240620%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20240620T000000Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=a300bc294b456381410629de8a09ff2afcee0011e7e501a2427df0a3a0f54917","version":"165813606","role":"owner","editorType":"figma","linkAccess":"org_view"} diff --git a/cli/src/__test__/e2e_connect_command/react_wizard/components/PrimaryButton.tsx b/cli/src/__test__/e2e_connect_command/react_wizard/components/PrimaryButton.tsx new file mode 100644 index 0000000..6e3f42a --- /dev/null +++ b/cli/src/__test__/e2e_connect_command/react_wizard/components/PrimaryButton.tsx @@ -0,0 +1,16 @@ +import React from 'react' + +interface ButtonProps { + disabled: boolean + children: any +} + +/** + * @description This is a button + * @param children text to render + * @param disabled disable the button + * @returns JSX element + */ +export const PrimaryButton = ({ children, disabled = false }: ButtonProps) => { + return +} diff --git a/cli/src/__test__/e2e_connect_command/react_wizard/package.json b/cli/src/__test__/e2e_connect_command/react_wizard/package.json new file mode 100644 index 0000000..de5e85d --- /dev/null +++ b/cli/src/__test__/e2e_connect_command/react_wizard/package.json @@ -0,0 +1,15 @@ +{ + "name": "tests", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "react": "^18.3.1" + } +} diff --git a/cli/src/__test__/e2e_connect_command/react_wizard/tsconfig.json b/cli/src/__test__/e2e_connect_command/react_wizard/tsconfig.json new file mode 100644 index 0000000..75ab248 --- /dev/null +++ b/cli/src/__test__/e2e_connect_command/react_wizard/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "outDir": "dist", + "module": "commonjs", + "target": "es6", + "esModuleInterop": true, + "skipLibCheck": true, + }, + "include": ["**/*.tsx"], +} diff --git a/cli/src/__test__/e2e_wizard.test.ts b/cli/src/__test__/e2e_wizard.test.ts new file mode 100644 index 0000000..a28b5a3 --- /dev/null +++ b/cli/src/__test__/e2e_wizard.test.ts @@ -0,0 +1,70 @@ +import { promisify } from 'util' +import { exec } from 'child_process' +import { LONG_TEST_TIMEOUT_MS, tidyStdOutput } from './utils' +import fs from 'fs' +import path from 'path' + +describe('e2e test for the wizard', () => { + let result: { + stdout: string + stderr: string + } + + const testPath = path.join(__dirname, 'e2e_connect_command/react_wizard') + + beforeAll(async () => { + const mockDocPath = path.join( + __dirname, + 'e2e_connect_command/dummy_api_response_for_wizard.json', + ) + + const wizardAnswers = [ + 'figd_123', // Access token + './e2e_connect_command/react_wizard/components', // Top-level components directory + 'https://www.figma.com/design/abc123/my-design-system', // Design system URL + 'yes', // Confirm create a new config file + '', // Don't select any links to edit + '', // co-locate CC files + ] + + const escapedStringifiedJson = JSON.stringify(wizardAnswers).replace(/"/g, '\\"') + + result = await promisify(exec)( + `npx cross-env CODE_CONNECT_MOCK_DOC_RESPONSE=${mockDocPath} WIZARD_ANSWERS_TO_PREFILL="${escapedStringifiedJson}" npx tsx ../cli connect --dir ${testPath}`, + { + cwd: __dirname, + }, + ) + }, LONG_TEST_TIMEOUT_MS) + + afterAll(() => { + fs.rmSync(path.join(testPath, 'figma.config.json'), { force: true }) + fs.rmSync(path.join(testPath, 'components/PrimaryButton.figma.tsx'), { force: true }) + }) + + it('starts the wizard', () => { + expect(tidyStdOutput(result.stderr)).toContain('Welcome to Code Connect') + }) + + it('creates config file from given answers', () => { + const configPath = path.join(testPath, 'figma.config.json') + expect(fs.readFileSync(configPath, 'utf8')).toBe(`\ +{ + "codeConnect": { + "include": ["components/**/*.{tsx,jsx}"] + } +} +`) + }) + + it('reaches linking step', () => { + expect(tidyStdOutput(result.stderr)).toContain('Connecting your components') + }) + + it('creates Code Connect file at correct location', () => { + const codeConnectPath = path.join(testPath, 'components/PrimaryButton.figma.tsx') + expect(tidyStdOutput(result.stderr)).toContain(`Created ${codeConnectPath}`) + const exists = fs.existsSync(codeConnectPath) + expect(exists).toBe(true) + }) +}) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 09a477e..6f9b2ef 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -7,6 +7,7 @@ import { updateCli } from './common/updates' require('dotenv').config() async function run() { + const program = new commander.Command().version(require('./../package.json').version) program.enablePositionalOptions() diff --git a/cli/src/connect/__test__/project.test.ts b/cli/src/connect/__test__/project.test.ts new file mode 100644 index 0000000..96ba4a7 --- /dev/null +++ b/cli/src/connect/__test__/project.test.ts @@ -0,0 +1,76 @@ +import { CodeConnectReactConfig, resolveImportPath } from '../project' + +describe('Project helper functions', () => { + function getConfig(importPaths: {}): CodeConnectReactConfig { + return { + parser: 'react', + ...importPaths, + } + } + + describe('importPath mappings', () => { + it('Matches a simple import path', () => { + const mapped = resolveImportPath( + '/Users/test/app/src/button.tsx', + getConfig({ importPaths: { 'src/button.tsx': '@ui/button' } }), + ) + expect(mapped).toEqual('@ui/button') + }) + + it('Matches a wildcard import path', () => { + const mapped = resolveImportPath( + '/Users/test/app/src/button.tsx', + getConfig({ importPaths: { 'src/*': '@ui' } }), + ) + expect(mapped).toEqual('@ui') + }) + + it('Matches a wildcard import path with a wildcard output path', () => { + const mapped = resolveImportPath( + '/Users/test/app/src/button.tsx', + getConfig({ importPaths: { 'src/*': '@ui/*' } }), + ) + expect(mapped).toEqual('@ui/button') + }) + + it('Matches a wildcard import path with a nested directory', () => { + const mapped = resolveImportPath( + '/Users/test/app/src/components/button.tsx', + getConfig({ importPaths: { 'src/*': '@ui' } }), + ) + expect(mapped).toEqual('@ui') + }) + + it('Matches a wildcard import path and output path with a nested directory', () => { + const mapped = resolveImportPath( + '/Users/test/app/src/components/button.tsx', + getConfig({ importPaths: { 'src/*': '@ui/*' } }), + ) + expect(mapped).toEqual('@ui/button') + }) + + it('Passing only a wildcard matches any import', () => { + const mapped = resolveImportPath( + '/Users/test/app/src/components/button.tsx', + getConfig({ importPaths: { '*': '@ui' } }), + ) + expect(mapped).toEqual('@ui') + }) + + it('Returns null for non-matching paths', () => { + const mapped = resolveImportPath( + '/Users/test/app/src/button.tsx', + getConfig({ importPaths: { 'src/components/*': '@ui' } }), + ) + expect(mapped).toBeNull() + }) + + it('Should pick the first match if there are multiple mappings', () => { + const mapped = resolveImportPath( + '/Users/test/app/src/icons/icon.tsx', + getConfig({ importPaths: { 'icons/*': '@ui/icons', 'src/*': '@ui' } }), + ) + expect(mapped).toEqual('@ui/icons') + }) + }) +}) diff --git a/cli/src/connect/project.ts b/cli/src/connect/project.ts index c70f9e9..308cac2 100644 --- a/cli/src/connect/project.ts +++ b/cli/src/connect/project.ts @@ -521,17 +521,20 @@ export async function getProjectInfoFromConfig( ? DEFAULT_INCLUDE_GLOBS_BY_PARSER[config.parser] : undefined + // always ignore any `node_modules` folders in react projects const defaultExcludeGlobs = config.parser ? { - react: [`${absPath}/node_modules/**`], - swift: [''], - compose: [''], - __unit_test__: [''], - }[config.parser] - : '' + react: ['node_modules/**'], + swift: [], + compose: [], + __unit_test__: [], + }[config.parser] ?? [] + : [] const includeGlobs = config.include || defaultIncludeGlobs - const excludeGlobs = config.exclude || defaultExcludeGlobs || [] + const excludeGlobs = config.exclude + ? [...config.exclude, ...defaultExcludeGlobs] + : defaultExcludeGlobs if (!includeGlobs) { exitWithError('No include globs specified in config file') @@ -544,6 +547,12 @@ export async function getProjectInfoFromConfig( absolute: true, }) + if (files.length > 10000) { + logger.warn( + `Matching number of files was excessively large (${files.length}) - consider using more specific include/exclude globs in your config file.`, + ) + } + return { absPath, remoteUrl, @@ -579,6 +588,7 @@ export function getReactProjectInfo( paths: projectInfo.config.paths ?? {}, allowJs: true, } + const tsProgram = ts.createProgram(projectInfo.files, compilerOptions) return { @@ -588,9 +598,31 @@ export function getReactProjectInfo( } export function resolveImportPath(filePath: string, config: CodeConnectReactConfig): string | null { + // Takes the reversed path and pattern parts and check if they match function isMatch(patternParts: string[], pathParts: string[]) { + if (patternParts[0] === '*') { + // if the path is just a wildcard and nothing else, match any import + if (patternParts.length === 1) { + return true + } + + // if the _next_ part in the pattern does not exist in the path, it's not + // a match. + const index = pathParts.indexOf(patternParts[1]) + if (index === -1) { + return false + } + + // Skip to the matching part in the path and match the rest of + // the pattern. E.g if the pattern is `*/ui/src` (reversed) and the path is + // `button.tsx/components/ui/src`, we skip to `ui` and match the rest of the + // pattern. + patternParts = patternParts.slice(1) + pathParts = pathParts.slice(index) + } + for (let i = 0; i < patternParts.length; i++) { - if (patternParts[i] !== '*' && patternParts[i] !== pathParts[i]) { + if (patternParts[i] !== pathParts[i]) { return false } } diff --git a/cli/src/connect/wizard/__test__/helpers.test.ts b/cli/src/connect/wizard/__test__/helpers.test.ts new file mode 100644 index 0000000..03c28ae --- /dev/null +++ b/cli/src/connect/wizard/__test__/helpers.test.ts @@ -0,0 +1,26 @@ +import { DEFAULT_INCLUDE_GLOBS_BY_PARSER } from '../../project' +import { getIncludesGlob } from '../helpers' + +describe('getIncludesGlob', () => { + it('returns default includes glob if no component directory', () => { + const result = getIncludesGlob({ + dir: './', + componentDirectory: null, + config: { + parser: 'react', + }, + }) + expect(result).toBe(DEFAULT_INCLUDE_GLOBS_BY_PARSER.react) + }) + + it('prepends path to component directory to default globs', () => { + const result = getIncludesGlob({ + dir: './', + componentDirectory: './src/connect/wizard/__test__', + config: { + parser: 'react', + }, + }) + expect(result).toEqual(['src/connect/wizard/__test__/**/*.{tsx,jsx}']) + }) +}) diff --git a/cli/src/connect/wizard/__test__/run_wizard.test.ts b/cli/src/connect/wizard/__test__/run_wizard.test.ts new file mode 100644 index 0000000..444ea55 --- /dev/null +++ b/cli/src/connect/wizard/__test__/run_wizard.test.ts @@ -0,0 +1,195 @@ +import path from 'path' +import * as connect from '../../../commands/connect' +import { FigmaRestApi } from '../../figma_rest_api' +import * as project from '../../project' +import { + autoLinkComponents, + convertRemoteFileUrlToRelativePath, + getComponentChoicesForPrompt, + getUnconnectedComponentsAndConnectedComponentMappings, +} from '../run_wizard' + +const _stripAnsi = require('strip-ansi') + +const MOCK_COMPONENTS: FigmaRestApi.Component[] = [ + { + type: 'COMPONENT', + name: 'a reeeeeeeally long component name', + id: '1:11', + children: [], + componentPropertyDefinitions: {}, + }, + { + type: 'COMPONENT', + name: 'another component', + id: '1:12', + children: [], + componentPropertyDefinitions: {}, + }, + { + type: 'COMPONENT', + name: 'different component', + id: '1:13', + children: [], + componentPropertyDefinitions: {}, + }, +] + +describe('getComponentChoicesForPrompt', () => { + it('returns a sorted list of linked + unlinked formatted choices', () => { + const result = getComponentChoicesForPrompt( + MOCK_COMPONENTS, + { + '1:12': '/some/component/path.tsx', + }, + [], + '/', + ) + + expect(result.map((r) => _stripAnsi(r.title))).toEqual([ + `Figma component: another component ↔️ ${path.join('some', 'component', 'path.tsx')}`, + `Figma component: a reeeeeeeally long component name ↔️ -`, + `Figma component: different component ↔️ -`, + ]) + }) + + it('returns results relative to dir', () => { + const result = getComponentChoicesForPrompt( + MOCK_COMPONENTS, + { + '1:12': '/some/component/path.tsx', + }, + [], + '/some', + ) + + expect(result.map((r) => _stripAnsi(r.title))).toEqual([ + `Figma component: another component ↔️ ${path.join('component', 'path.tsx')}`, + `Figma component: a reeeeeeeally long component name ↔️ -`, + `Figma component: different component ↔️ -`, + ]) + }) + + it('displays already connected components underneath unconnected components', () => { + const result = getComponentChoicesForPrompt( + [MOCK_COMPONENTS[0]], + {}, + [ + { + componentName: 'some connected component', + path: '/foo/connectedComponent1.tsx', + }, + { + componentName: 'another connected component', + path: '/foo/connectedComponent2.tsx', + }, + ], + '/', + ) + expect(result.map((r) => _stripAnsi(r.title))).toEqual([ + `Figma component: a reeeeeeeally long component name ↔️ -`, + `Figma component: some connected component ↔️ /foo/connectedComponent1.tsx`, + `Figma component: another connected component ↔️ /foo/connectedComponent2.tsx`, + ]) + }) +}) + +describe('autoLinkComponents', () => { + it('populates linkedNodeIdsToPaths using fuzzy matching', () => { + const linkedNodeIdsToPaths = {} + autoLinkComponents({ + unconnectedComponents: MOCK_COMPONENTS, + linkedNodeIdsToPaths, + componentPaths: ['/foo/bar/AnotherComponent.tsx', '/foo/bar/DifferentComponent.tsx'], + }) + expect(linkedNodeIdsToPaths).toEqual({ + '1:12': '/foo/bar/AnotherComponent.tsx', + '1:13': '/foo/bar/DifferentComponent.tsx', + }) + }) + it('does not populate linkedNodeIdsToPaths with bad matches', () => { + const linkedNodeIdsToPaths = {} + autoLinkComponents({ + unconnectedComponents: MOCK_COMPONENTS, + linkedNodeIdsToPaths, + componentPaths: ['/foo/bar/MyButton.tsx', '/foo/bar/AlternativeComponent.tsx'], + }) + expect(linkedNodeIdsToPaths).toEqual({}) + }) +}) + +describe('getUnconnectedComponentsAndConnectedComponentMappings', () => { + it('correctly derives connected / unconnected components', async () => { + jest.spyOn(project, 'getGitRepoAbsolutePath').mockReturnValue('/user/me/my-repo') + jest.spyOn(connect, 'getCodeConnectObjects').mockReturnValue( + new Promise((resolve) => + resolve([ + { + figmaNode: 'https://figma.com/design/someFileId/wow?node-id=1:11', + label: 'React', + language: 'typescript', + component: 'Modal', + source: 'https://github.com/some-user/my-design-system/blob/main/components/Modal.tsx', + sourceLocation: { line: 2 }, + template: '', + templateData: { + props: { + property: { + kind: 'enum' as any, + args: { + figmaPropName: 'Property 1', + valueMapping: { Default: 'default', Variant2: 'variant2' }, + }, + }, + }, + imports: ['import { Modal } from "./Modal"'], + nestable: true, + }, + metadata: { cliVersion: '1.0.1' }, + }, + ]), + ), + ) + const result = await getUnconnectedComponentsAndConnectedComponentMappings( + { + dir: '/user/me/my-repo/components', + } as any, + 'https://figma.com/design/someFileId/abc', + MOCK_COMPONENTS, + {} as any, + ) + expect(result).toEqual({ + unconnectedComponents: [ + { + type: 'COMPONENT', + name: 'another component', + id: '1:12', + children: [], + componentPropertyDefinitions: {}, + }, + { + type: 'COMPONENT', + name: 'different component', + id: '1:13', + children: [], + componentPropertyDefinitions: {}, + }, + ], + connectedComponentsMappings: [ + { componentName: 'a reeeeeeeally long component name', path: 'Modal.tsx' }, + ], + }) + }) +}) + +describe('convertRemoteFileUrlToRelativePath', () => { + it('converts to relative path', () => { + const result = convertRemoteFileUrlToRelativePath({ + remoteFileUrl: + 'https://github.com/slees-figma/sims-design-system/blob/main/components/ds/Modal.tsx', + gitRootPath: '/user/me/my-repo', + dir: '/user/me/my-repo/components', + }) + expect(result).toBe(path.join('ds', 'Modal.tsx')) + }) +}) diff --git a/cli/src/connect/wizard/helpers.ts b/cli/src/connect/wizard/helpers.ts index c596df7..8f2ce7d 100644 --- a/cli/src/connect/wizard/helpers.ts +++ b/cli/src/connect/wizard/helpers.ts @@ -7,24 +7,47 @@ import { } from '../project' import { logger, success } from '../../common/logging' import path from 'path' +import prompts from 'prompts' -export async function createCodeConnectConfig({ + +/** + * + * Gets the default include globs for config.parser with componentDirectory prepended + * @param args + * @param args.dir project root path + * @param args.componentDirectory optional path to where includes should be limited to + * @param args.config CodeConnectConfig + * @returns array of include globs + */ +export function getIncludesGlob({ dir, - dirToSearchForFiles, + componentDirectory, config, }: { dir: string - dirToSearchForFiles: string + componentDirectory: string | null config: CodeConnectConfig }) { - // use unix separators for config file globs - const pathToComponentsDir = path.relative(dir, dirToSearchForFiles).replaceAll(path.sep, '/') - - const includesGlob = pathToComponentsDir - ? `${pathToComponentsDir}/${DEFAULT_INCLUDE_GLOBS_BY_PARSER[config.parser]}` - : DEFAULT_INCLUDE_GLOBS_BY_PARSER[config.parser] + if (componentDirectory) { + // use unix separators for config file globs + const pathToComponentsDir = path.relative(dir, componentDirectory).replaceAll(path.sep, '/') + return DEFAULT_INCLUDE_GLOBS_BY_PARSER[config.parser].map( + (defaultIncludeGlob) => `${pathToComponentsDir}/${defaultIncludeGlob}`, + ) + } + return DEFAULT_INCLUDE_GLOBS_BY_PARSER[config.parser] +} - const filePath = getDefaultConfigPath(dir) +export async function createCodeConnectConfig({ + dir, + componentDirectory, + config, +}: { + dir: string + componentDirectory: string | null + config: CodeConnectConfig +}) { + const includesGlob = getIncludesGlob({ dir, componentDirectory, config }) const configJson = ` { "codeConnect": { @@ -32,9 +55,11 @@ export async function createCodeConnectConfig({ } } ` - let formatted = await prettier.format(configJson, { + const formatted = await prettier.format(configJson, { parser: 'json', }) + const filePath = getDefaultConfigPath(dir) + fs.writeFileSync(filePath, formatted) logger.info(success(`Created ${filePath}`)) diff --git a/cli/src/connect/wizard/run_wizard.ts b/cli/src/connect/wizard/run_wizard.ts index 895199c..7dcbfe5 100644 --- a/cli/src/connect/wizard/run_wizard.ts +++ b/cli/src/connect/wizard/run_wizard.ts @@ -23,15 +23,28 @@ import { CodeConnectJSON } from '../../common/figma_connect' import boxen from 'boxen' import { Searcher } from 'fast-fuzzy' import { isFigmaConnectFile } from '../../react/parser' -import { createCodeConnectConfig } from './helpers' -import z from 'zod/lib' +import { createCodeConnectConfig, getIncludesGlob } from './helpers' +import stripAnsi from 'strip-ansi' import { handleMessages } from '../parser_executables' +import ora from 'ora' type ConnectedComponentMappings = { componentName: string; path: string }[] const NONE = '(None)' const DELIMITERS_REGEX = /[\s-_]/g +function clearQuestion(prompt: prompts.PromptObject, answer: string) { + const displayedAnswer = + (Array.isArray(prompt.choices) && prompt.choices.find((c) => c.value === answer)?.title) || + answer + const lengthOfDisplayedQuestion = + stripAnsi(prompt.message as string).length + stripAnsi(displayedAnswer).length + 5 // 2 chars before, 3 chars between Q + A + const rowsToRemove = Math.ceil(lengthOfDisplayedQuestion / process.stdout.columns) + + process.stdout.moveCursor(0, -rowsToRemove) + process.stdout.clearScreenDown() +} + async function fetchTopLevelComponentsFromFile({ accessToken, figmaUrl, @@ -45,12 +58,24 @@ async function fetchTopLevelComponentsFromFile({ const apiUrl = getApiUrl(figmaUrl ?? '') + `/code_connect/${fileKey}/cli_data` try { - logger.info('Fetching component information from Figma...') - const response = await axios.get(apiUrl, { - headers: { - 'X-Figma-Token': accessToken, - 'Content-Type': 'application/json', - }, + const spinner = ora({ + text: 'Fetching component information from Figma...', + color: 'green', + }).start() + const response = await ( + process.env.CODE_CONNECT_MOCK_DOC_RESPONSE + ? Promise.resolve({ + status: 200, + data: JSON.parse(fs.readFileSync(process.env.CODE_CONNECT_MOCK_DOC_RESPONSE, 'utf-8')), + }) + : axios.get(apiUrl, { + headers: { + 'X-Figma-Token': accessToken, + 'Content-Type': 'application/json', + }, + }) + ).finally(() => { + spinner.stop() }) if (response.status === 200) { @@ -66,7 +91,9 @@ async function fetchTopLevelComponentsFromFile({ if (isAxiosError(err)) { if (err.response) { logger.error( - `Failed to fetch components from Figma (${err.code}): ${err.response?.status} ${err.response?.data?.err ?? err.response?.data?.message}`, + `Failed to fetch components from Figma (${err.code}): ${err.response?.status} ${ + err.response?.data?.err ?? err.response?.data?.message + }`, ) } else { logger.error(`Failed to fetch components from Figma: ${err.message}`) @@ -143,16 +170,17 @@ async function askQuestionWithExitConfirmation( } } -function formatComponentTitle(componentName: string, path: string, pad: number) { +function formatComponentTitle(componentName: string, path: string | null, pad: number) { const nameLabel = `${chalk.dim('Figma component:')} ${componentName.padEnd(pad, ' ')}` const linkedLabel = `↔️ ${path ?? '-'}` return `${nameLabel} ${linkedLabel}` } -function getComponentChoicesForPrompt( +export function getComponentChoicesForPrompt( components: FigmaRestApi.Component[], linkedNodeIdsToPaths: Record, connectedComponentsMappings: ConnectedComponentMappings, + dir: string, ): prompts.Choice[] { const longestNameLength = [...components, ...connectedComponentsMappings].reduce( (longest, component) => @@ -169,11 +197,16 @@ function getComponentChoicesForPrompt( const linkedComponents = components.filter((c) => !!linkedNodeIdsToPaths[c.id]).sort(nameCompare) const unlinkedComponents = components.filter((c) => !linkedNodeIdsToPaths[c.id]).sort(nameCompare) - const formatComponentChoice = (c: FigmaRestApi.Component) => ({ - title: formatComponentTitle(c.name, linkedNodeIdsToPaths[c.id], longestNameLength), - value: c.id, - description: `${chalk.green('Edit link')}`, - }) + const formatComponentChoice = (c: FigmaRestApi.Component) => { + const componentPath = linkedNodeIdsToPaths[c.id] + ? path.relative(dir, linkedNodeIdsToPaths[c.id]) + : null + return { + title: formatComponentTitle(c.name, componentPath, longestNameLength), + value: c.id, + description: `${chalk.green('Edit link')}`, + } + } return [ ...linkedComponents.map(formatComponentChoice), @@ -189,16 +222,16 @@ function getComponentChoicesForPrompt( ] } -function getUnconnectedComponentChoices(componentPaths: string[]) { +function getUnconnectedComponentChoices(componentPaths: string[], dir: string) { return [ { title: NONE, value: NONE, }, - ...componentPaths.map((path) => { + ...componentPaths.map((absPath) => { return { - title: path, - value: path, + title: path.relative(dir, absPath), + value: absPath, } }), ] @@ -217,39 +250,56 @@ async function runManualLinking({ linkedNodeIdsToPaths, componentPaths, connectedComponentsMappings, + cmd, }: ManualLinkingArgs) { + const dir = getDir(cmd) while (true) { // Don't show exit confirmation as we're relying on esc behavior - const { nodeId } = await prompts({ - type: 'select', - name: 'nodeId', - message: `Select a link to edit (Press ${chalk.green('esc')} when you're ready to continue on)`, - choices: getComponentChoicesForPrompt( - unconnectedComponents, - linkedNodeIdsToPaths, - connectedComponentsMappings, - ), - warn: 'This component already has a local Code Connect file.', - hint: ' ', - }) + const { nodeId } = await prompts( + { + type: 'select', + name: 'nodeId', + message: `Select a link to edit (Press ${chalk.green( + 'esc', + )} when you're ready to continue on)`, + choices: getComponentChoicesForPrompt( + unconnectedComponents, + linkedNodeIdsToPaths, + connectedComponentsMappings, + dir, + ), + warn: 'This component already has a local Code Connect file.', + hint: ' ', + }, + { + onSubmit: clearQuestion, + }, + ) if (!nodeId) { return } - const pathChoices = getUnconnectedComponentChoices(componentPaths) - const { pathToComponent } = await prompts({ - type: 'autocomplete', - name: 'pathToComponent', - message: 'Choose a path to your code component (type to filter results)', - choices: pathChoices, - // default suggest uses .startsWith(input) which isn't very useful for full paths - suggest: (input, choices) => - Promise.resolve(choices.filter((i) => i.value.toUpperCase().includes(input.toUpperCase()))), - // preselect if editing an existing choice - initial: - nodeId in linkedNodeIdsToPaths - ? pathChoices.findIndex(({ value }) => value === linkedNodeIdsToPaths[nodeId]) - : 0, - }) + const pathChoices = getUnconnectedComponentChoices(componentPaths, dir) + const { pathToComponent } = await prompts( + { + type: 'autocomplete', + name: 'pathToComponent', + message: 'Choose a path to your code component (type to filter results)', + choices: pathChoices, + // default suggest uses .startsWith(input) which isn't very useful for full paths + suggest: (input, choices) => + Promise.resolve( + choices.filter((i) => i.value.toUpperCase().includes(input.toUpperCase())), + ), + // preselect if editing an existing choice + initial: + nodeId in linkedNodeIdsToPaths + ? pathChoices.findIndex(({ value }) => value === linkedNodeIdsToPaths[nodeId]) + : 0, + }, + { + onSubmit: clearQuestion, + }, + ) if (pathToComponent) { if (pathToComponent === NONE) { delete linkedNodeIdsToPaths[nodeId] @@ -271,7 +321,9 @@ async function runManualLinkingWithConfirmation(manualLinkingArgs: ManualLinking const { outputDirectory } = await askQuestionWithExitConfirmation({ type: 'text', name: 'outputDirectory', - message: `What directory should Code Connect files be created in? (Press ${chalk.green('enter')} to co-locate your files alongside your component files)`, + message: `What directory should Code Connect files be created in? (Press ${chalk.green( + 'enter', + )} to co-locate your files alongside your component files)`, }) hasAskedOutDirQuestion = true outDir = outputDirectory @@ -301,7 +353,9 @@ async function runManualLinkingWithConfirmation(manualLinkingArgs: ManualLinking const { confirmation } = await askQuestionWithExitConfirmation({ type: 'select', name: 'confirmation', - message: `You're ready to create ${chalk.green(linkedNodes.length)} Code Connect file${linkedNodes.length == 1 ? '' : 's'}. Continue?`, + message: `You're ready to create ${chalk.green(linkedNodes.length)} Code Connect file${ + linkedNodes.length == 1 ? '' : 's' + }. Continue?`, choices: [ { title: 'Create files', @@ -325,7 +379,7 @@ async function runManualLinkingWithConfirmation(manualLinkingArgs: ManualLinking * * Matching is done by fast-fuzzy */ -function autoLinkComponents({ +export function autoLinkComponents({ unconnectedComponents, linkedNodeIdsToPaths, componentPaths, @@ -414,14 +468,14 @@ async function createCodeConnectFiles({ } } -function convertRemoteFileUrlToRelativePath({ +export function convertRemoteFileUrlToRelativePath({ remoteFileUrl, gitRootPath, - componentDirectory, + dir, }: { remoteFileUrl: string gitRootPath: string - componentDirectory: string + dir: string }) { if (!gitRootPath) { return null @@ -433,14 +487,13 @@ function convertRemoteFileUrlToRelativePath({ } const absPath = path.join(gitRootPath, pathWithinRepo) - return path.relative(componentDirectory, absPath) + return path.relative(dir, absPath) } -async function getUnconnectedComponentsAndConnectedComponentMappings( +export async function getUnconnectedComponentsAndConnectedComponentMappings( cmd: BaseCommand, figmaFileUrl: string, componentsFromFile: FigmaRestApi.Component[], - componentDirectory: string, projectInfo: ReactProjectInfo, ) { const dir = getDir(cmd) @@ -464,7 +517,7 @@ async function getUnconnectedComponentsAndConnectedComponentMappings( const unconnectedComponents: FigmaRestApi.Component[] = [] const connectedComponentsMappings: ConnectedComponentMappings = [] - const gitRootPath = getGitRepoAbsolutePath(componentDirectory) + const gitRootPath = getGitRepoAbsolutePath(dir) componentsFromFile.map((c) => { if (c.id in connectedNodeIdsInFileToCodeConnectObjectMap) { @@ -472,7 +525,7 @@ async function getUnconnectedComponentsAndConnectedComponentMappings( const relativePath = convertRemoteFileUrlToRelativePath({ remoteFileUrl: cc.source!, gitRootPath, - componentDirectory, + dir, }) connectedComponentsMappings.push({ componentName: c.name, @@ -498,15 +551,17 @@ async function askForTopLevelDirectoryOrDetermineFromConfig({ hasConfigFile: boolean config: CodeConnectConfig }) { - let dirToSearchForFiles = dir + let componentDirectory: string | null = null while (true) { if (!hasConfigFile) { - const { componentDirectory } = await askQuestionOrExit({ + const { componentDirectory: componentDirectoryAnswer } = await askQuestionOrExit({ type: 'text', - message: `Which top-level directory contains the code to be connected to your Figma design system? (Press ${chalk.green('enter')} to use current directory)`, + message: `Which top-level directory contains the code to be connected to your Figma design system? (Press ${chalk.green( + 'enter', + )} to use current directory)`, name: 'componentDirectory', - format: (val) => val || process.cwd(), + format: (val) => val || process.cwd(), // should this be || dir? validate: (value) => { if (!value) { return true @@ -517,16 +572,36 @@ async function askForTopLevelDirectoryOrDetermineFromConfig({ return true }, }) - dirToSearchForFiles = componentDirectory + componentDirectory = componentDirectoryAnswer } + const configToUse: CodeConnectConfig = componentDirectory + ? { + ...config, + include: getIncludesGlob({ + dir, + componentDirectory, + config, + }), + } + : config + + const spinner = ora({ + text: 'Parsing local files...', + color: 'green', + spinner: { + // Don't show spinner as ts.createProgram blocks thread + frames: [''], + }, + }).start() const projectInfo = getReactProjectInfo( - (await getProjectInfoFromConfig(dirToSearchForFiles, config)) as ReactProjectInfo, + (await getProjectInfoFromConfig(dir, configToUse)) as ReactProjectInfo, ) - const componentPaths = projectInfo.files - .filter((f: string) => !isFigmaConnectFile(projectInfo.tsProgram, f)) - .map((filePath) => path.relative(dirToSearchForFiles, filePath)) + const componentPaths = projectInfo.files.filter( + (f: string) => !isFigmaConnectFile(projectInfo.tsProgram, f), + ) + spinner.stop() if (!componentPaths.length) { if (hasConfigFile) { @@ -542,7 +617,7 @@ async function askForTopLevelDirectoryOrDetermineFromConfig({ } else { return { projectInfo, - dirToSearchForFiles, + componentDirectory, componentPaths, } } @@ -557,8 +632,12 @@ export async function runWizard(cmd: BaseCommand) { `When you're done, you'll be able to see your component code while inspecting in\n` + `Figma's Dev Mode.\n\n` + `Learn more at ${chalk.cyan('https://www.figma.com/developers/code-connect')}.\n\n` + - `Please raise bugs or feedback at ${chalk.cyan('https://github.com/figma/code-connect/issues')}.\n\n` + - `${chalk.red.bold('Note: ')}This process will create and modify Code Connect files. Make sure you've\n` + + `Please raise bugs or feedback at ${chalk.cyan( + 'https://github.com/figma/code-connect/issues', + )}.\n\n` + + `${chalk.red.bold( + 'Note: ', + )}This process will create and modify Code Connect files. Make sure you've\n` + `committed necessary changes in your codebase first.`, { padding: 1, @@ -593,7 +672,7 @@ export async function runWizard(cmd: BaseCommand) { logger.info('') - const { dirToSearchForFiles, projectInfo, componentPaths } = + const { componentDirectory, projectInfo, componentPaths } = await askForTopLevelDirectoryOrDetermineFromConfig({ dir, hasConfigFile, @@ -604,6 +683,7 @@ export async function runWizard(cmd: BaseCommand) { type: 'text', message: 'What is the URL of the Figma file containing your design system library?', name: 'figmaFileUrl', + validate: (value: string) => !!parseFileKey(value) || 'Please enter a valid Figma file URL.', }) const componentsFromFile = await fetchTopLevelComponentsFromFile({ @@ -633,7 +713,7 @@ export async function runWizard(cmd: BaseCommand) { ], }) if (createConfigFile === 'yes') { - await createCodeConnectConfig({ dir, dirToSearchForFiles, config }) + await createCodeConnectConfig({ dir, componentDirectory, config }) } } @@ -644,7 +724,6 @@ export async function runWizard(cmd: BaseCommand) { cmd, figmaFileUrl, componentsFromFile, - dirToSearchForFiles, projectInfo, ) @@ -658,9 +737,19 @@ export async function runWizard(cmd: BaseCommand) { boxen( `${chalk.bold(`Connecting your components`)}\n\n` + `${chalk.green( - `${chalk.bold(Object.keys(linkedNodeIdsToPaths).length)} ${Object.keys(linkedNodeIdsToPaths).length === 1 ? 'component was automatically matched based on its name' : 'components were automatically matched based on their names'}`, + `${chalk.bold(Object.keys(linkedNodeIdsToPaths).length)} ${ + Object.keys(linkedNodeIdsToPaths).length === 1 + ? 'component was automatically matched based on its name' + : 'components were automatically matched based on their names' + }`, )}\n` + - `${chalk.yellow(`${chalk.bold(unconnectedComponents.length)} ${unconnectedComponents.length === 1 ? 'component has not been matched' : 'components have not been matched'}`)}\n\n` + + `${chalk.yellow( + `${chalk.bold(unconnectedComponents.length)} ${ + unconnectedComponents.length === 1 + ? 'component has not been matched' + : 'components have not been matched' + }`, + )}\n\n` + `Match up Figma components with their code definitions. When you're finished, you\n` + `can specify the directory you want to create Code Connect files in.`, { diff --git a/cli/src/index.ts b/cli/src/index.ts index c724366..9052039 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,8 +1,29 @@ +// IMPORTANT: be careful to ensure you don't accidentally add code which has a +// dependency on Node.js-only modules here, as it will break co=located +// components. We don't have a test for this yet. Any such code should be +// conditionally required - see `client` for an example. Reach out in +// #feat-code-connect if you're unsure. + import { FigmaConnectAPI } from './common/api' import * as figma from './common/external' import * as StorybookTypes from './storybook/external' import { FigmaConnectClient } from './client/figma_client' -import * as client from './client/external' + +// `client/external`'s dependency chain ends up including code which is not safe +// in a browser context, e.g. `child_process`. This would mean that if you +// co-locate your Code Connect with React components, your app will be broken in +// a browser, because of this import chain. +// +// To get around this, we only import `client/external` if you are in a Node +// context – it is only used for the icon script, so none of the code which runs +// in the browser should touch it. +// +// Note that you may still see messages from the JS bundler about Node modules, +// but these won't be included at runtime. +let client: FigmaConnectClient = {} as any +if (typeof process !== 'undefined' && process.versions && process.versions.node) { + client = require('./client/external') +} const _client: FigmaConnectClient = client const _figma: FigmaConnectAPI = figma diff --git a/cli/src/parser_scripts/get_swift_parser_dir.ts b/cli/src/parser_scripts/get_swift_parser_dir.ts index a1fc09c..ee5b8fc 100644 --- a/cli/src/parser_scripts/get_swift_parser_dir.ts +++ b/cli/src/parser_scripts/get_swift_parser_dir.ts @@ -14,7 +14,7 @@ export async function getSwiftParserDir(cwd: string, xcodeprojPath?: string) { let figmaPackageDir: string | undefined // Check for the supported project types. - const xcodeProjFile = xcodeprojPath || getFileIfExists(cwd, '*.xcodeproj') + const xcodeProjFile = (xcodeprojPath || getFileIfExists(cwd, '*.xcodeproj')).replace(/\s/g, '\\ ') const packageSwiftFile = getFileIfExists(cwd, 'Package.swift') if (!(xcodeProjFile || packageSwiftFile)) { diff --git a/cli/src/react/__test__/create.test.ts b/cli/src/react/__test__/create.test.ts new file mode 100644 index 0000000..86603f5 --- /dev/null +++ b/cli/src/react/__test__/create.test.ts @@ -0,0 +1,94 @@ +import { createReactCodeConnect } from '../create' +const fs = require('fs') +import prettier from 'prettier' + +jest.mock('fs') + +describe('createReactCodeConnect', () => { + it('Should generate a boolean variant if the variant options are all boolean', async () => { + fs.existsSync.mockReturnValue(false) + + await createReactCodeConnect({ + destinationDir: 'test', + destinationFile: 'test.figma.tsx', + config: { parser: 'react' }, + mode: 'CREATE', + component: { + id: '1:1', + figmaNodeUrl: 'fake-url', + name: 'Test', + normalizedName: 'Test', + type: 'COMPONENT_SET', + componentPropertyDefinitions: { + BooleanVariant: { + type: 'VARIANT', + defaultValue: 'true', + variantOptions: ['true', 'false'], + }, + BooleanVariant2: { + type: 'VARIANT', + defaultValue: 'True', + variantOptions: ['True', 'False'], + }, + BooleanVariant3: { type: 'VARIANT', defaultValue: 'yes', variantOptions: ['yes', 'no'] }, + BooleanVariant4: { type: 'VARIANT', defaultValue: 'Yes', variantOptions: ['Yes', 'No'] }, + BooleanVariant5: { type: 'VARIANT', defaultValue: 'on', variantOptions: ['on', 'off'] }, + BooleanVariant6: { type: 'VARIANT', defaultValue: 'On', variantOptions: ['On', 'Off'] }, + Variant: { + type: 'VARIANT', + defaultValue: 'Yes', + variantOptions: ['Yes', 'No', 'Intermediate'], + }, + Variant2: { + type: 'VARIANT', + defaultValue: 'Yes', + variantOptions: ['True', 'SomethingElse'], + }, + }, + }, + }) + + const expected = await prettier.format( + `\ +import React from "react" +import { Test } from "./Test" +import figma from "@figma/code-connect" + +/** + * -- This file was auto-generated by \`figma connect create\` -- + * \`props\` includes a mapping from Figma properties and variants to + * suggested values. You should update this to match the props of your + * code component, and update the \`example\` function to return the + * code example you'd like to see in Figma +*/ + +figma.connect(Test, "fake-url", { + props: { + booleanVariant: figma.boolean("BooleanVariant"), + booleanVariant2: figma.boolean("BooleanVariant2"), + booleanVariant3: figma.boolean("BooleanVariant3"), + booleanVariant4: figma.boolean("BooleanVariant4"), + booleanVariant5: figma.boolean("BooleanVariant5"), + booleanVariant6: figma.boolean("BooleanVariant6"), + variant: figma.enum("Variant", { + Yes: "yes", + No: "no", + Intermediate: "intermediate", + }), + variant2: figma.enum("Variant2", { + True: "true", + SomethingElse: "somethingelse", + }), + }, + example: (props) => , +})`, + { + parser: 'typescript', + semi: false, + trailingComma: 'all', + }, + ) + + expect(fs.writeFileSync).toHaveBeenCalledWith('test.figma.tsx', expected) + }) +}) diff --git a/cli/src/react/create.ts b/cli/src/react/create.ts index fae24be..87e9afb 100644 --- a/cli/src/react/create.ts +++ b/cli/src/react/create.ts @@ -22,7 +22,7 @@ function normalizePropName(name: string) { } function generateCodePropName(name: string) { - return camelCase(name.replace(/[^a-zA-Z]/g, '')) + return camelCase(name.replace(/[^a-zA-Z0-9]/g, '')) } function normalizePropValue(name: string) { @@ -49,7 +49,9 @@ function generateProps(component: CreateRequestPayload['component']) { props.push(`"${codePropName}": figma.string('${figmaPropName}')`) } if (propDef.type === 'VARIANT') { - if (propDef.variantOptions?.find((value) => isBooleanKind(value))) { + const isBooleanVariant = + propDef.variantOptions?.length === 2 && propDef.variantOptions.every(isBooleanKind) + if (isBooleanVariant) { props.push(`"${codePropName}": figma.boolean('${figmaPropName}')`) } else { props.push( diff --git a/cli/src/react/parser.ts b/cli/src/react/parser.ts index a9e0028..e2edd86 100644 --- a/cli/src/react/parser.ts +++ b/cli/src/react/parser.ts @@ -466,7 +466,6 @@ export function parseRenderFunction( ) { const { sourceFile } = parserContext - let jsx = findJSXElement(exp) let exampleCode: string if (exp.parameters.length > 1) { @@ -533,8 +532,8 @@ export function parseRenderFunction( // can be accessed using the compiler API, which is much easier than using a // regex, then in the next step we can use a simple regex to convert that into // the template string. - if (propsParameter && jsx) { - jsx = ts.transform(jsx, [ + if (propsParameter) { + exp = ts.transform(exp, [ (context) => (rootNode) => { function visit(node: ts.Node): ts.Node { // `props.` notation @@ -648,24 +647,29 @@ export function parseRenderFunction( return ts.visitEachChild(node, visit, context) } - return ts.visitNode(rootNode, visit) as ts.JsxElement + return ts.visitNode(rootNode, visit) as + | ts.ArrowFunction + | ts.FunctionExpression + | ts.FunctionDeclaration }, - ]).transformed[0] as typeof jsx + ]).transformed[0] as typeof exp } const printer = ts.createPrinter() const block = findBlock(exp) let nestable = false + let jsx = findJSXElement(exp) if (jsx && (!block || (block && block.statements.length <= 1))) { // The function body is a single JSX element exampleCode = printer.printNode(ts.EmitHint.Unspecified, jsx, sourceFile) nestable = true } else if (block) { - // The function body has more stuff in it, so we wrap it in a function - // expression that returns the JSX element. Why not just print the exact function passed - // to `render`? Because the parameters to that function are not actually referenced in the - // rendered code snippet in Figma - they're mapped to values on the Figma instance. + // The function body has more stuff in it, so we wrap the body in a function + // expression. Why not just print the exact function passed to `render`? + // Because the parameters to that function are not actually referenced in + // the rendered code snippet in Figma - they're mapped to values on the + // Figma instance. const functionName = 'Example' const functionExpression = ts.factory.createFunctionExpression( undefined, @@ -674,13 +678,7 @@ export function parseRenderFunction( [], undefined, undefined, - ts.factory.createBlock( - [ - ...block.statements.filter((s) => !ts.isReturnStatement(s)), - ts.factory.createReturnStatement(jsx), - ], - true, - ), + block, ) const printer = ts.createPrinter() exampleCode = printer.printNode(ts.EmitHint.Unspecified, functionExpression, sourceFile) diff --git a/cli/src/react/parser_template_helpers.ts b/cli/src/react/parser_template_helpers.ts index b277f78..bd50f03 100644 --- a/cli/src/react/parser_template_helpers.ts +++ b/cli/src/react/parser_template_helpers.ts @@ -81,7 +81,12 @@ function _fcc_renderPropValue(prop: FCCValue | { type: 'CODE' | 'INSTANCE' }[]) // Replace any newlines or quotes in the string with escaped versions if (typeof prop === 'string') { - return `"${prop.replaceAll('\n', '\\n').replaceAll('"', '\\"')}"` + const str = `"${prop.replaceAll('\n', '\\n').replaceAll('"', '\\"')}"` + if (str === '') { + return 'undefined' + } else { + return str + } } if (typeof prop === 'boolean' || typeof prop === 'number') { @@ -136,7 +141,11 @@ function _fcc_renderReactProp(name: string, prop: FCCValue | { type: 'CODE' | 'I // Replace any newlines or quotes in the string with escaped versions if (typeof prop === 'string') { - return ` ${name}="${prop.replaceAll('\n', '\\n').replaceAll('"', '\\"')}"` + const str = prop.replaceAll('\n', '\\n').replaceAll('"', '\\"') + if (str === '') { + return '' + } + return ` ${name}="${str}"` } if (typeof prop === 'number') { diff --git a/compose/README.md b/compose/README.md index d47a0e1..08c99c8 100644 --- a/compose/README.md +++ b/compose/README.md @@ -22,7 +22,7 @@ plugins { ## Add the SDK dependency to your module -In order to start authoring Code Connect files, add the following dependencies to the `build.gradle.kts` file in the module in the module that will contain the files. Note that you may need to add `gradlePluginPortal()` to `repositories in your `dependencyResolutionManagement` block. +In order to start authoring Code Connect files, add the following dependencies to the `build.gradle.kts` file in the module that will contain the files. Note that you may need to add `gradlePluginPortal()` to `repositories in your `dependencyResolutionManagement` block. ``` dependencies { diff --git a/compose/plugin/src/main/kotlin/com/figma/code/connect/FigmaCodeConnectPlugin.kt b/compose/plugin/src/main/kotlin/com/figma/code/connect/FigmaCodeConnectPlugin.kt index 95d83de..27ace0e 100644 --- a/compose/plugin/src/main/kotlin/com/figma/code/connect/FigmaCodeConnectPlugin.kt +++ b/compose/plugin/src/main/kotlin/com/figma/code/connect/FigmaCodeConnectPlugin.kt @@ -112,6 +112,9 @@ class FigmaCodeConnectPlugin : Plugin { throw IllegalArgumentException("filePath property is required") } } + task.notCompatibleWithConfigurationCache( + "This task is not compatible with configuration caching because it reads from a file path property.", + ) } /** @@ -140,6 +143,9 @@ class FigmaCodeConnectPlugin : Plugin { println(json.encodeToString(output)) } } + task.notCompatibleWithConfigurationCache( + "This task is not compatible with configuration caching because it reads from a file path property.", + ) } } }