From d738f7c94915d9ec0fa58cfa9c34facf9f5cbe1e Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 25 Jun 2024 12:21:33 +0200 Subject: [PATCH] Support conditional children in most JSX components (#2506) This adds support for conditional children in most JSX components. The exceptions are `Field`, `Row`, and other components which don't have children in the first place. For example, the following is now possible: ```tsx {condition && Hello} ``` To support this, I've added `boolean` as valid prop for the JSX components. The `getJsxChildren` function filters out any falsy or literal `true` values. I've also added a small contributing guide for conventions around JSX components, including the support for conditional children. Closes #2505. --------- Co-authored-by: MetaMask Bot --- .../packages/bip32/snap.manifest.json | 2 +- .../packages/bip44/snap.manifest.json | 2 +- .../browserify-plugin/snap.manifest.json | 2 +- .../packages/browserify/snap.manifest.json | 2 +- .../packages/client-status/snap.manifest.json | 2 +- .../packages/cronjobs/snap.manifest.json | 2 +- .../packages/dialogs/snap.manifest.json | 2 +- .../ethereum-provider/snap.manifest.json | 2 +- .../packages/ethers-js/snap.manifest.json | 2 +- .../packages/file-upload/snap.manifest.json | 2 +- .../packages/get-entropy/snap.manifest.json | 2 +- .../packages/get-file/snap.manifest.json | 2 +- .../packages/home-page/snap.manifest.json | 2 +- .../packages/images/snap.manifest.json | 2 +- .../interactive-ui/snap.manifest.json | 2 +- .../consumer-signer/snap.manifest.json | 2 +- .../packages/core-signer/snap.manifest.json | 2 +- .../packages/json-rpc/snap.manifest.json | 2 +- .../examples/packages/jsx/snap.manifest.json | 2 +- .../lifecycle-hooks/snap.manifest.json | 2 +- .../packages/localization/snap.manifest.json | 2 +- .../packages/manage-state/snap.manifest.json | 2 +- .../network-access/snap.manifest.json | 2 +- .../packages/notifications/snap.manifest.json | 2 +- .../packages/rollup-plugin/snap.manifest.json | 2 +- .../signature-insights/snap.manifest.json | 2 +- .../transaction-insights/snap.manifest.json | 2 +- .../examples/packages/wasm/snap.manifest.json | 2 +- .../webpack-plugin/snap.manifest.json | 2 +- packages/snaps-jest/src/matchers.ts | 8 +- packages/snaps-sdk/CONTRIBUTING.md | 97 +++++++++++++++++ packages/snaps-sdk/jest.config.js | 6 +- packages/snaps-sdk/src/internals/jsx.ts | 2 +- packages/snaps-sdk/src/jsx/component.ts | 15 ++- .../snaps-sdk/src/jsx/components/Box.test.tsx | 18 ++++ packages/snaps-sdk/src/jsx/components/Box.ts | 4 +- .../src/jsx/components/Heading.test.tsx | 17 +++ .../src/jsx/components/Link.test.tsx | 18 ++++ packages/snaps-sdk/src/jsx/components/Link.ts | 6 +- packages/snaps-sdk/src/jsx/components/Text.ts | 6 +- .../src/jsx/components/Tooltip.test.tsx | 102 ++++++++++++++++++ .../snaps-sdk/src/jsx/components/Tooltip.ts | 1 + .../src/jsx/components/form/Dropdown.test.tsx | 69 ++++++++++++ .../src/jsx/components/form/Dropdown.ts | 6 +- .../snaps-sdk/src/jsx/components/form/Form.ts | 4 +- .../src/jsx/components/form/Option.test.tsx | 16 +++ .../src/jsx/components/form/Option.ts | 2 + .../jsx/components/formatting/Bold.test.tsx | 17 +++ .../src/jsx/components/formatting/Bold.ts | 7 +- .../jsx/components/formatting/Italic.test.tsx | 17 +++ .../src/jsx/components/formatting/Italic.ts | 5 +- .../snaps-sdk/src/jsx/validation.test.tsx | 2 +- packages/snaps-sdk/src/jsx/validation.ts | 95 ++++++++-------- packages/snaps-utils/coverage.json | 4 +- packages/snaps-utils/src/ui.test.tsx | 39 +++++++ packages/snaps-utils/src/ui.tsx | 18 +++- 56 files changed, 543 insertions(+), 116 deletions(-) create mode 100644 packages/snaps-sdk/CONTRIBUTING.md create mode 100644 packages/snaps-sdk/src/jsx/components/Tooltip.test.tsx create mode 100644 packages/snaps-sdk/src/jsx/components/form/Dropdown.test.tsx create mode 100644 packages/snaps-sdk/src/jsx/components/form/Option.test.tsx diff --git a/packages/examples/packages/bip32/snap.manifest.json b/packages/examples/packages/bip32/snap.manifest.json index 35a97a1475..01bb717540 100644 --- a/packages/examples/packages/bip32/snap.manifest.json +++ b/packages/examples/packages/bip32/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "7AJkQic0kErNfOuBozGOUaJ5U5Z24yOV28zHL+WEPz0=", + "shasum": "WfupwVnHUswiO0x/o2coJ0oj85v1x0j7wx1c5lYxP+o=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/bip44/snap.manifest.json b/packages/examples/packages/bip44/snap.manifest.json index ed5acb595d..9227bb7049 100644 --- a/packages/examples/packages/bip44/snap.manifest.json +++ b/packages/examples/packages/bip44/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "R/jtwKJg/6gTCuMrphuKHZkTtUlggfwnNgKiOXx11GU=", + "shasum": "+WhZ7cQ8dqA46bL5kzblh/1+GRShg87VtAS5N8N8+bU=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json index d461c74fa0..9375e7acb6 100644 --- a/packages/examples/packages/browserify-plugin/snap.manifest.json +++ b/packages/examples/packages/browserify-plugin/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "u7x/swxx6XHT7G0BDY8mJkwcY/mP/i4YmdYaRQ/QKZY=", + "shasum": "cvVZ8a8g9/b3tXhh9d1zf1GU11WvqcwVnR90ilpGSGw=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/browserify/snap.manifest.json b/packages/examples/packages/browserify/snap.manifest.json index e246e23315..8036034a6d 100644 --- a/packages/examples/packages/browserify/snap.manifest.json +++ b/packages/examples/packages/browserify/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "105XsceecyaQZ+reC6ZNusiOJVUMBoNSYK4PHvREPQw=", + "shasum": "fuKiL65m1QakTuad3sCbK8YzRLNgVb9o/gSg80FK4RE=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/client-status/snap.manifest.json b/packages/examples/packages/client-status/snap.manifest.json index 5aef27eb43..72b5268f16 100644 --- a/packages/examples/packages/client-status/snap.manifest.json +++ b/packages/examples/packages/client-status/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "9Ttl+TbR7gufQzhQx4o/keKeINCjio65rjLWOlP36NM=", + "shasum": "I8VkGGWoxhalUw5IIncvCY/InJ15yst1MTnICJ6jUZ4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/cronjobs/snap.manifest.json b/packages/examples/packages/cronjobs/snap.manifest.json index ff794a8fe1..43edc28bdf 100644 --- a/packages/examples/packages/cronjobs/snap.manifest.json +++ b/packages/examples/packages/cronjobs/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "0NNqpKgq/+zNokLs67+6vrA+kZxvDnDe9PWrX6X8Goc=", + "shasum": "bt0ymBAUIDJC0ksow6/BO2+TCm8l5f4I7myCg/tXJfc=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/dialogs/snap.manifest.json b/packages/examples/packages/dialogs/snap.manifest.json index a2a3144243..b075fc627a 100644 --- a/packages/examples/packages/dialogs/snap.manifest.json +++ b/packages/examples/packages/dialogs/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "tDbsxyvQUz2VoKmIzIXbLr3mtJfNewiHzoEjW7X6c8k=", + "shasum": "P87ZOtKfVhN78E9zEw5fTGWhgKG0assHA1MZQNAZ0Xk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/ethereum-provider/snap.manifest.json b/packages/examples/packages/ethereum-provider/snap.manifest.json index 79ee74c52d..fa8141d3db 100644 --- a/packages/examples/packages/ethereum-provider/snap.manifest.json +++ b/packages/examples/packages/ethereum-provider/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "8/P91GreWpqGOWLGAntmd3Kyu6A3WMelRIWUuxKcsGI=", + "shasum": "sNKcZWHKrpSkWmsHD9nw4SNjuYehxTTXI+PhF0PAyP4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/ethers-js/snap.manifest.json b/packages/examples/packages/ethers-js/snap.manifest.json index afb4738193..1d47aba947 100644 --- a/packages/examples/packages/ethers-js/snap.manifest.json +++ b/packages/examples/packages/ethers-js/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "1bLwZCxQusG/VCAswAJ5ohHF6fBgiQtTLhQG1Mby4HI=", + "shasum": "y9N2+eSpLMWFxoH+gmVT9u7oR95EP8QaYnlFNowAdZE=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/file-upload/snap.manifest.json b/packages/examples/packages/file-upload/snap.manifest.json index ff9b4d4c18..9d3f223841 100644 --- a/packages/examples/packages/file-upload/snap.manifest.json +++ b/packages/examples/packages/file-upload/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "9m0//6LmoFrxb/Mpkjlr7eNRe9HK92iKJgtqtijlkFw=", + "shasum": "axxTRcxOlRzfmwdfTGo0MGMGxIXqxWW3ZMcvgZgU0RU=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/get-entropy/snap.manifest.json b/packages/examples/packages/get-entropy/snap.manifest.json index 764d362693..ed2353e8d1 100644 --- a/packages/examples/packages/get-entropy/snap.manifest.json +++ b/packages/examples/packages/get-entropy/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "sKg86tSHApZz49OdruONj63Yhts7zDlyLF2lpf6sguw=", + "shasum": "+Dq7vWhymZkiKZPixpzPOac9GYNFd3NXZmThe+RP5Rk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/get-file/snap.manifest.json b/packages/examples/packages/get-file/snap.manifest.json index ba9cd98a97..6b5d699162 100644 --- a/packages/examples/packages/get-file/snap.manifest.json +++ b/packages/examples/packages/get-file/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "0vuiA3xbMMPYDMcE7ytDmXM0B4wwc+b90Ncy114JU24=", + "shasum": "YONnSNnY1QXI799OnXcq7pOJ9mB2mf0TKA+hwrXei0M=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/home-page/snap.manifest.json b/packages/examples/packages/home-page/snap.manifest.json index 34e5273135..c73b1a2ac8 100644 --- a/packages/examples/packages/home-page/snap.manifest.json +++ b/packages/examples/packages/home-page/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "I2MgHF7MuMKoGGKMSJTB2TAdRNt3ThuAs7N5QLqsFwc=", + "shasum": "A9oCcxfQ/Ze21a+qSjmFEMjpyJDZTXBEUF/eEtzr00g=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/images/snap.manifest.json b/packages/examples/packages/images/snap.manifest.json index 9de489cf9c..add7df61c4 100644 --- a/packages/examples/packages/images/snap.manifest.json +++ b/packages/examples/packages/images/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "F+ZTNm0I7LeuhiRfFLz34sEn1nQrHEmZ4vbl4V1buiU=", + "shasum": "QpOZlqHNXUWxJEkcks0CEg15OO3HSximP79r+3hronM=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/interactive-ui/snap.manifest.json b/packages/examples/packages/interactive-ui/snap.manifest.json index d0b3a94d16..41f4417d82 100644 --- a/packages/examples/packages/interactive-ui/snap.manifest.json +++ b/packages/examples/packages/interactive-ui/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "LSk+GmGMP8byzHX013+Fnj8hPWxFD1ctKE7FwmXwUZ4=", + "shasum": "hFzGG0OogA6v6B3SOokVHVqTRuuTyLdA9iULpuifFAo=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/invoke-snap/packages/consumer-signer/snap.manifest.json b/packages/examples/packages/invoke-snap/packages/consumer-signer/snap.manifest.json index 951f2c349d..db046d352f 100644 --- a/packages/examples/packages/invoke-snap/packages/consumer-signer/snap.manifest.json +++ b/packages/examples/packages/invoke-snap/packages/consumer-signer/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "gRoujvXbGzclQmIEgWQQvuKxeeTXt4jt8Kv+SggT5I8=", + "shasum": "cRsMxa73YE9a7rrLfAvqWkL7LtqDCt1KQifgF4bA/YA=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/invoke-snap/packages/core-signer/snap.manifest.json b/packages/examples/packages/invoke-snap/packages/core-signer/snap.manifest.json index cf2c2e4664..4ad17151e9 100644 --- a/packages/examples/packages/invoke-snap/packages/core-signer/snap.manifest.json +++ b/packages/examples/packages/invoke-snap/packages/core-signer/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "pgKvkVBqtJ4sPb6shDMNXiDc1C8apmV4+WfN6ciavKg=", + "shasum": "o4UgAxX2mrxEIUbi2og85R/dECjjg+q51X0t1x8KXb4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/json-rpc/snap.manifest.json b/packages/examples/packages/json-rpc/snap.manifest.json index 5e624673b7..47371a682b 100644 --- a/packages/examples/packages/json-rpc/snap.manifest.json +++ b/packages/examples/packages/json-rpc/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "MDjrpUYVTAW1NxWp544Gn4DcF0t6GoDBQIR3F+zeqtI=", + "shasum": "3F5KdhaTzO0rYEAOJ9Y7Q0COWJqXOqcyTkG0FqdZ05s=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/jsx/snap.manifest.json b/packages/examples/packages/jsx/snap.manifest.json index d188a8a74a..724d061e6d 100644 --- a/packages/examples/packages/jsx/snap.manifest.json +++ b/packages/examples/packages/jsx/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "G9fNyPGqUsjlqzMskFKEkdy04ny6eZDBBlXSajmQcN8=", + "shasum": "NUAroyflmKAfjOcXwHF9PWN9Ioa9AbakdYBBduNtlEI=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/lifecycle-hooks/snap.manifest.json b/packages/examples/packages/lifecycle-hooks/snap.manifest.json index ec8df0fe47..053f42c014 100644 --- a/packages/examples/packages/lifecycle-hooks/snap.manifest.json +++ b/packages/examples/packages/lifecycle-hooks/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "1Vo2b7dgicVZYWx6DMTlnkDnK6UBbk4fmilSsLKwSDg=", + "shasum": "Hpmj/t7F9hsI52wGOdI+ak3W8yYLsQAKDrwT5tVhnYg=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/localization/snap.manifest.json b/packages/examples/packages/localization/snap.manifest.json index 10ca7dc40f..6509b6bf1f 100644 --- a/packages/examples/packages/localization/snap.manifest.json +++ b/packages/examples/packages/localization/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "czQq93Uay+jE8HZvwSViZfMdDQTPn0ClMJaH34VN0dc=", + "shasum": "wtOoGYeD+m/9PFLtyVgAEOLeJaZbmScyb5ovC1S4AWA=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/manage-state/snap.manifest.json b/packages/examples/packages/manage-state/snap.manifest.json index 230a3667b3..620898c380 100644 --- a/packages/examples/packages/manage-state/snap.manifest.json +++ b/packages/examples/packages/manage-state/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "Vr0QhrtHYgg7DFczqQc4KEkAkKrOCOAc3ZnNesEVZ+w=", + "shasum": "3CUmmQR8Rm9YPrR2vYdg97SGUzUndQf3OgYrUzlvLI4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/network-access/snap.manifest.json b/packages/examples/packages/network-access/snap.manifest.json index dc4a0e6564..febf56b2b8 100644 --- a/packages/examples/packages/network-access/snap.manifest.json +++ b/packages/examples/packages/network-access/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "6btrG6bcFAWosjSF/ldUuTd3sXNlYxVsRm0gcTJRE2M=", + "shasum": "VDkYGW0UgbTFLVuhnjjbYCJvHlMaFRfklmdUK3wyl4w=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/notifications/snap.manifest.json b/packages/examples/packages/notifications/snap.manifest.json index b26add1a30..6b5d15498a 100644 --- a/packages/examples/packages/notifications/snap.manifest.json +++ b/packages/examples/packages/notifications/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "khCrwAziXNhRJrV8cHp3hfRyudXVBYVB3Cew7YpWDG0=", + "shasum": "6os9tFCmhv3HTapJ6Mzr5eG04r28c+hjgD/cLSDraEY=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/rollup-plugin/snap.manifest.json b/packages/examples/packages/rollup-plugin/snap.manifest.json index 03ac09d8de..db64fce1ce 100644 --- a/packages/examples/packages/rollup-plugin/snap.manifest.json +++ b/packages/examples/packages/rollup-plugin/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "7wOe/70O/A4BoUF5+CabV0Rjivnnv2D/xcQ1AlAZdw0=", + "shasum": "rG0w/CpDp7pUUEYz6z6LB/t2UtOnLPq3tXNC0ZuI5p4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/signature-insights/snap.manifest.json b/packages/examples/packages/signature-insights/snap.manifest.json index d0e13487f4..4be49df722 100644 --- a/packages/examples/packages/signature-insights/snap.manifest.json +++ b/packages/examples/packages/signature-insights/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "5dDSK6C4XDIT1ukZh1nEimci2a+z0hnqUNEqi7qBNAk=", + "shasum": "3sWzEjRApKpX8lrHwPjB4f0kuvwx7o1ZiuPxqTrEl3s=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/transaction-insights/snap.manifest.json b/packages/examples/packages/transaction-insights/snap.manifest.json index 3b2e3be783..e9b59740d1 100644 --- a/packages/examples/packages/transaction-insights/snap.manifest.json +++ b/packages/examples/packages/transaction-insights/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "8kKJRiaM18IxSr89svPvmzGIjRszgJvmfLkJxJLyFA4=", + "shasum": "h6yDf1EBo4mpSb1qqHGQiutSvuExPmtthseHyuXhsvQ=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/wasm/snap.manifest.json b/packages/examples/packages/wasm/snap.manifest.json index eb730f138c..118c2f6141 100644 --- a/packages/examples/packages/wasm/snap.manifest.json +++ b/packages/examples/packages/wasm/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "DGgaRZmS8KSVnUzCq/V7f9TauAelSCFe9sg3myXrWks=", + "shasum": "VWTxqVgjWnanv5fmejkDm5Gz+yiKveFROceWqIg6vi0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/webpack-plugin/snap.manifest.json b/packages/examples/packages/webpack-plugin/snap.manifest.json index 54f4234e1c..d0db864731 100644 --- a/packages/examples/packages/webpack-plugin/snap.manifest.json +++ b/packages/examples/packages/webpack-plugin/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "U4Zhw9ZpNVXJHoYfuYJ4a6j/ynAaFXxZYUYo0DSESVs=", + "shasum": "0PQHPzqWuahbPrh5XviC7NnEnRhA185kaAIdCZcOfqs=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snaps-jest/src/matchers.ts b/packages/snaps-jest/src/matchers.ts index 92d90bae23..e5702ec68a 100644 --- a/packages/snaps-jest/src/matchers.ts +++ b/packages/snaps-jest/src/matchers.ts @@ -11,7 +11,11 @@ import type { ComponentOrElement, Component, } from '@metamask/snaps-sdk'; -import type { JSXElement, SnapNode } from '@metamask/snaps-sdk/jsx'; +import type { + GenericSnapElement, + JSXElement, + SnapNode, +} from '@metamask/snaps-sdk/jsx'; import { isJSXElementUnsafe } from '@metamask/snaps-sdk/jsx'; import { getJsxElementFromComponent } from '@metamask/snaps-utils'; import type { Json } from '@metamask/utils'; @@ -234,7 +238,7 @@ export function serialiseJsx(node: SnapNode, indentation = 0): string { return ''; } - const { type, props } = node; + const { type, props } = node as GenericSnapElement; const trailingNewline = indentation > 0 ? '\n' : ''; if (hasProperty(props, 'children')) { diff --git a/packages/snaps-sdk/CONTRIBUTING.md b/packages/snaps-sdk/CONTRIBUTING.md new file mode 100644 index 0000000000..d16e8cdecb --- /dev/null +++ b/packages/snaps-sdk/CONTRIBUTING.md @@ -0,0 +1,97 @@ +# Contributing + +## Adding new JSX components + +The MetaMask Snaps SDK uses JSX components for building user interfaces in +Snaps. To add a new component, follow these steps: + +1. Create a new file in the `src/jsx/components` directory, optionally in a + subdirectory if the component is part of a group of related components. +2. It is recommended to copy an existing component file and modify it to create + the new component. +3. Create a test for the new component, following the existing test files for + the other components. +4. Add the new component to the `src/jsx/components/index.ts` file, exporting it + from the file. + - This file also contains a `JSXElement` union type that should + be updated to include the new component. +5. Add validation for the component to `src/jsx/validation.ts`, making sure to + use the `Describe` helper type to ensure that the component is correctly + validated. + - Make sure to add tests for the validation in `src/jsx/validation.test.ts` + as well. +6. If the component is stateful, make sure to add it to the + `SnapInterfaceController` too. + - You can use [this PR](https://github.com/MetaMask/snaps/pull/2501) as a + reference. + +### Component props + +When adding a new component, make sure to document the props that the component +accepts. This can be done by adding a JSDoc comment to the component function +declaration, like so: + +```typescript +/** + * The props of the {@link Dropdown} component. + * + * @property name - The name of the dropdown. This is used to identify the + * state in the form data. + * @property value - The selected value of the dropdown. + * @property children - The children of the dropdown. + */ +export type DropdownProps = { + name: string; + value?: string | undefined; + children: SnapsChildren; +}; +``` + +The props type should be exported from the component file and used in the +component function declaration, like so: + +```typescript +// ... +export const Dropdown = createSnapComponent(TYPE); +``` + +To ensure consistency, make sure to follow the existing patterns in the codebase +when documenting component props. + +#### Optional props + +Optional props should be both marked with a `?` in the type definition, and +also include `undefined` in the type definition. This is to ensure that the +component can be used with or without TypeScript's exact optional props feature. + +```typescript +export type ComponentProps = { + value?: string | undefined; +}; +``` + +#### Children + +In most cases, the children prop should be defined as `SnapsChildren` to allow +for both a single child element or an array of child elements. There are some +exceptions to this rule, such as when the component only accepts a single child +element (e.g., `Field`). + +Children should also accept `boolean` and `null` values to allow for conditional +rendering of child elements. This is handled automatically by the +`SnapsChildren` type. + +```typescript +export type ComponentProps = { + children: SnapsChildren; // Nestable; +}; +``` + +If the children are optional, make sure to include `undefined` in the type and +add the `?` to the prop definition. + +```typescript +export type ComponentProps = { + children?: SnapsChildren | undefined; +}; +``` diff --git a/packages/snaps-sdk/jest.config.js b/packages/snaps-sdk/jest.config.js index f188496908..17019b1bfa 100644 --- a/packages/snaps-sdk/jest.config.js +++ b/packages/snaps-sdk/jest.config.js @@ -4,7 +4,11 @@ const { resolve } = require('path'); const baseConfig = require('../../jest.config.base'); module.exports = deepmerge(baseConfig, { - collectCoverageFrom: ['!./src/**/index.ts'], + collectCoverageFrom: [ + '!./src/**/index.ts', + '!./src/types/global.ts', + '!./src/types/images.ts', + ], coverageThreshold: { global: { diff --git a/packages/snaps-sdk/src/internals/jsx.ts b/packages/snaps-sdk/src/internals/jsx.ts index 82d311b9d2..ea9350bbf8 100644 --- a/packages/snaps-sdk/src/internals/jsx.ts +++ b/packages/snaps-sdk/src/internals/jsx.ts @@ -90,7 +90,7 @@ export type Describe = Struct>; */ export function nullUnion( structs: [head: Head, ...tail: Tail], -) { +): Struct | InferStructTuple[number], null> { return union(structs) as unknown as Struct< Infer | InferStructTuple[number], null diff --git a/packages/snaps-sdk/src/jsx/component.ts b/packages/snaps-sdk/src/jsx/component.ts index cb6ad32411..43ca510f4e 100644 --- a/packages/snaps-sdk/src/jsx/component.ts +++ b/packages/snaps-sdk/src/jsx/component.ts @@ -51,25 +51,22 @@ export type SnapElement< export type Nestable = Type | Nestable[]; /** - * A type that can be a single value or an array of values. + * A type that can be a single value or an array of values, a boolean, or null. * * @template Type - The type that can be an array. - * @example - * type MaybeArrayString = MaybeArray; - * const maybeArrayString: MaybeArrayString = 'hello'; - * const maybeArrayStringArray: MaybeArrayString = ['hello', 'world']; */ -export type MaybeArray = Nestable; +export type SnapsChildren = Nestable; /** * A JSX node, which can be an element, a string, null, or an array of nodes. */ -export type SnapNode = MaybeArray; +export type SnapNode = SnapsChildren; /** - * A JSX string element, which can be a string or an array of strings. + * A JSX string element, which can be a string or an array of strings, or + * booleans (for conditional rendering). */ -export type StringElement = MaybeArray; +export type StringElement = SnapsChildren; /** * A JSX component. diff --git a/packages/snaps-sdk/src/jsx/components/Box.test.tsx b/packages/snaps-sdk/src/jsx/components/Box.test.tsx index b21bdadaaa..a03c797dbe 100644 --- a/packages/snaps-sdk/src/jsx/components/Box.test.tsx +++ b/packages/snaps-sdk/src/jsx/components/Box.test.tsx @@ -89,4 +89,22 @@ describe('Box', () => { }, }); }); + + it('renders a box with a conditional', () => { + const result = ( + + {false && Hello} + + ); + + expect(result).toStrictEqual({ + type: 'Box', + key: null, + props: { + direction: 'horizontal', + alignment: 'space-between', + children: false, + }, + }); + }); }); diff --git a/packages/snaps-sdk/src/jsx/components/Box.ts b/packages/snaps-sdk/src/jsx/components/Box.ts index 417616a45d..a58b502aac 100644 --- a/packages/snaps-sdk/src/jsx/components/Box.ts +++ b/packages/snaps-sdk/src/jsx/components/Box.ts @@ -1,4 +1,4 @@ -import type { GenericSnapElement, MaybeArray } from '../component'; +import type { GenericSnapElement, SnapsChildren } from '../component'; import { createSnapComponent } from '../component'; /** @@ -10,7 +10,7 @@ import { createSnapComponent } from '../component'; */ export type BoxProps = { // We can't use `JSXElement` because it causes a circular reference. - children: MaybeArray; + children: SnapsChildren; direction?: 'vertical' | 'horizontal' | undefined; alignment?: | 'start' diff --git a/packages/snaps-sdk/src/jsx/components/Heading.test.tsx b/packages/snaps-sdk/src/jsx/components/Heading.test.tsx index 5e33b8b4a1..cc2add3b04 100644 --- a/packages/snaps-sdk/src/jsx/components/Heading.test.tsx +++ b/packages/snaps-sdk/src/jsx/components/Heading.test.tsx @@ -24,4 +24,21 @@ describe('Heading', () => { }, }); }); + + it('renders a heading with a conditional value', () => { + const result = ( + + Hello + {false && 'world'} + + ); + + expect(result).toStrictEqual({ + type: 'Heading', + key: null, + props: { + children: ['Hello', false], + }, + }); + }); }); diff --git a/packages/snaps-sdk/src/jsx/components/Link.test.tsx b/packages/snaps-sdk/src/jsx/components/Link.test.tsx index 309a41348a..a28fedd27f 100644 --- a/packages/snaps-sdk/src/jsx/components/Link.test.tsx +++ b/packages/snaps-sdk/src/jsx/components/Link.test.tsx @@ -26,4 +26,22 @@ describe('Link', () => { }, }); }); + + it('renders a link with a conditional value', () => { + const result = ( + + Hello + {false && 'world'} + + ); + + expect(result).toStrictEqual({ + type: 'Link', + key: null, + props: { + href: 'https://example.com', + children: ['Hello', false], + }, + }); + }); }); diff --git a/packages/snaps-sdk/src/jsx/components/Link.ts b/packages/snaps-sdk/src/jsx/components/Link.ts index 8d22b7404c..047ba7b0ed 100644 --- a/packages/snaps-sdk/src/jsx/components/Link.ts +++ b/packages/snaps-sdk/src/jsx/components/Link.ts @@ -1,13 +1,11 @@ -import type { MaybeArray } from '../component'; +import type { SnapsChildren } from '../component'; import { createSnapComponent } from '../component'; import type { StandardFormattingElement } from './formatting'; /** * The children of the {@link Link} component. */ -export type LinkChildren = MaybeArray< - string | StandardFormattingElement | null ->; +export type LinkChildren = SnapsChildren; /** * The props of the {@link Link} component. diff --git a/packages/snaps-sdk/src/jsx/components/Text.ts b/packages/snaps-sdk/src/jsx/components/Text.ts index 895dbb2ba7..7d18e1c3b0 100644 --- a/packages/snaps-sdk/src/jsx/components/Text.ts +++ b/packages/snaps-sdk/src/jsx/components/Text.ts @@ -1,4 +1,4 @@ -import type { MaybeArray } from '../component'; +import type { SnapsChildren } from '../component'; import { createSnapComponent } from '../component'; import type { StandardFormattingElement } from './formatting'; import type { LinkElement } from './Link'; @@ -6,8 +6,8 @@ import type { LinkElement } from './Link'; /** * The children of the {@link Text} component. */ -export type TextChildren = MaybeArray< - string | StandardFormattingElement | LinkElement | null +export type TextChildren = SnapsChildren< + string | StandardFormattingElement | LinkElement >; /** diff --git a/packages/snaps-sdk/src/jsx/components/Tooltip.test.tsx b/packages/snaps-sdk/src/jsx/components/Tooltip.test.tsx new file mode 100644 index 0000000000..3a63cb541b --- /dev/null +++ b/packages/snaps-sdk/src/jsx/components/Tooltip.test.tsx @@ -0,0 +1,102 @@ +import { Link } from './Link'; +import { Text } from './Text'; +import { Tooltip } from './Tooltip'; + +describe('Tooltip', () => { + it('renders a tooltip', () => { + const result = ( + Hello}> + World + + ); + + expect(result).toStrictEqual({ + type: 'Tooltip', + props: { + children: { + type: 'Text', + props: { + children: 'World', + }, + key: null, + }, + content: { + type: 'Text', + props: { + children: 'Hello', + }, + key: null, + }, + }, + key: null, + }); + }); + + it('renders a tooltip with a string content', () => { + const result = ( + + World + + ); + + expect(result).toStrictEqual({ + type: 'Tooltip', + props: { + children: { + type: 'Text', + props: { + children: 'World', + }, + key: null, + }, + content: 'Hello', + }, + key: null, + }); + }); + + it('renders a tooltip with a link content', () => { + const result = ( + Link}> + World + + ); + + expect(result).toStrictEqual({ + type: 'Tooltip', + props: { + children: { + type: 'Text', + props: { + children: 'World', + }, + key: null, + }, + content: { + type: 'Link', + props: { + children: 'Link', + href: 'https://example.com', + }, + key: null, + }, + }, + key: null, + }); + }); + + it('renders a tooltip with a conditional value', () => { + const result = ( + {false && World} + ); + + expect(result).toStrictEqual({ + type: 'Tooltip', + props: { + children: false, + content: 'Hello', + }, + key: null, + }); + }); +}); diff --git a/packages/snaps-sdk/src/jsx/components/Tooltip.ts b/packages/snaps-sdk/src/jsx/components/Tooltip.ts index c11795e8cd..e1932e94c8 100644 --- a/packages/snaps-sdk/src/jsx/components/Tooltip.ts +++ b/packages/snaps-sdk/src/jsx/components/Tooltip.ts @@ -9,6 +9,7 @@ export type TooltipChildren = | StandardFormattingElement | LinkElement | ImageElement + | boolean | null; /** diff --git a/packages/snaps-sdk/src/jsx/components/form/Dropdown.test.tsx b/packages/snaps-sdk/src/jsx/components/form/Dropdown.test.tsx new file mode 100644 index 0000000000..a43e4f91f7 --- /dev/null +++ b/packages/snaps-sdk/src/jsx/components/form/Dropdown.test.tsx @@ -0,0 +1,69 @@ +import { Dropdown } from './Dropdown'; +import { Option } from './Option'; + +describe('Dropdown', () => { + it('renders a dropdown with options', () => { + const result = ( + + + + + ); + + expect(result).toStrictEqual({ + type: 'Dropdown', + props: { + name: 'dropdown', + value: 'foo', + children: [ + { + type: 'Option', + props: { + value: 'foo', + children: 'Foo', + }, + key: null, + }, + { + type: 'Option', + props: { + value: 'bar', + children: 'Bar', + }, + key: null, + }, + ], + }, + key: null, + }); + }); + + it('renders a dropdown with a conditional option', () => { + const result = ( + + + {false && } + + ); + + expect(result).toStrictEqual({ + type: 'Dropdown', + props: { + name: 'dropdown', + value: 'foo', + children: [ + { + type: 'Option', + props: { + value: 'foo', + children: 'Foo', + }, + key: null, + }, + false, + ], + }, + key: null, + }); + }); +}); diff --git a/packages/snaps-sdk/src/jsx/components/form/Dropdown.ts b/packages/snaps-sdk/src/jsx/components/form/Dropdown.ts index 090907c993..f0c476fb2b 100644 --- a/packages/snaps-sdk/src/jsx/components/form/Dropdown.ts +++ b/packages/snaps-sdk/src/jsx/components/form/Dropdown.ts @@ -1,4 +1,4 @@ -import type { MaybeArray } from '../../component'; +import type { SnapsChildren } from '../../component'; import { createSnapComponent } from '../../component'; import type { OptionElement } from './Option'; @@ -10,10 +10,10 @@ import type { OptionElement } from './Option'; * @property value - The selected value of the dropdown. * @property children - The children of the dropdown. */ -type DropdownProps = { +export type DropdownProps = { name: string; value?: string | undefined; - children: MaybeArray; + children: SnapsChildren; }; const TYPE = 'Dropdown'; diff --git a/packages/snaps-sdk/src/jsx/components/form/Form.ts b/packages/snaps-sdk/src/jsx/components/form/Form.ts index be1671c22f..879d0c2b84 100644 --- a/packages/snaps-sdk/src/jsx/components/form/Form.ts +++ b/packages/snaps-sdk/src/jsx/components/form/Form.ts @@ -1,4 +1,4 @@ -import type { GenericSnapElement, MaybeArray } from '../../component'; +import type { GenericSnapElement, SnapsChildren } from '../../component'; import { createSnapComponent } from '../../component'; // TODO: Add `onSubmit` prop to the `FormProps` type. @@ -11,7 +11,7 @@ import { createSnapComponent } from '../../component'; * the event handler. */ export type FormProps = { - children: MaybeArray; + children: SnapsChildren; name: string; }; diff --git a/packages/snaps-sdk/src/jsx/components/form/Option.test.tsx b/packages/snaps-sdk/src/jsx/components/form/Option.test.tsx new file mode 100644 index 0000000000..0c6a5eb3b2 --- /dev/null +++ b/packages/snaps-sdk/src/jsx/components/form/Option.test.tsx @@ -0,0 +1,16 @@ +import { Option } from './Option'; + +describe('Option', () => { + it('renders a dropdown option', () => { + const result = ; + + expect(result).toStrictEqual({ + type: 'Option', + props: { + value: 'foo', + children: 'Foo', + }, + key: null, + }); + }); +}); diff --git a/packages/snaps-sdk/src/jsx/components/form/Option.ts b/packages/snaps-sdk/src/jsx/components/form/Option.ts index 91b6484ad2..6466ce068c 100644 --- a/packages/snaps-sdk/src/jsx/components/form/Option.ts +++ b/packages/snaps-sdk/src/jsx/components/form/Option.ts @@ -1,4 +1,6 @@ import { createSnapComponent } from '../../component'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Dropdown } from './Dropdown'; /** * The props of the {@link Option} component. diff --git a/packages/snaps-sdk/src/jsx/components/formatting/Bold.test.tsx b/packages/snaps-sdk/src/jsx/components/formatting/Bold.test.tsx index 225c42029c..dcd8dc95a2 100644 --- a/packages/snaps-sdk/src/jsx/components/formatting/Bold.test.tsx +++ b/packages/snaps-sdk/src/jsx/components/formatting/Bold.test.tsx @@ -24,4 +24,21 @@ describe('Bold', () => { key: null, }); }); + + it('returns a bold element with a conditional value', () => { + const result = ( + + Hello + {false && 'world'} + + ); + + expect(result).toStrictEqual({ + type: 'Bold', + props: { + children: ['Hello', false], + }, + key: null, + }); + }); }); diff --git a/packages/snaps-sdk/src/jsx/components/formatting/Bold.ts b/packages/snaps-sdk/src/jsx/components/formatting/Bold.ts index 93735beeff..c98eb27c34 100644 --- a/packages/snaps-sdk/src/jsx/components/formatting/Bold.ts +++ b/packages/snaps-sdk/src/jsx/components/formatting/Bold.ts @@ -1,16 +1,15 @@ -import type { JsonObject, MaybeArray, SnapElement } from '../../component'; +import type { JsonObject, SnapElement, SnapsChildren } from '../../component'; import { createSnapComponent } from '../../component'; // eslint-disable-next-line @typescript-eslint/no-unused-vars -import type { Text } from '../Text'; +import { Text } from '../Text'; /** * The children of the {@link Bold} component. */ -export type BoldChildren = MaybeArray< +export type BoldChildren = SnapsChildren< | string // We have to specify the type here to avoid a circular reference. | SnapElement - | null >; /** diff --git a/packages/snaps-sdk/src/jsx/components/formatting/Italic.test.tsx b/packages/snaps-sdk/src/jsx/components/formatting/Italic.test.tsx index 68dcd6d6ea..cb0113352e 100644 --- a/packages/snaps-sdk/src/jsx/components/formatting/Italic.test.tsx +++ b/packages/snaps-sdk/src/jsx/components/formatting/Italic.test.tsx @@ -24,4 +24,21 @@ describe('Italic', () => { key: null, }); }); + + it('returns an italic element with a conditional value', () => { + const result = ( + + Hello + {false && 'world'} + + ); + + expect(result).toStrictEqual({ + type: 'Italic', + props: { + children: ['Hello', false], + }, + key: null, + }); + }); }); diff --git a/packages/snaps-sdk/src/jsx/components/formatting/Italic.ts b/packages/snaps-sdk/src/jsx/components/formatting/Italic.ts index ffa820428b..03e2f421a0 100644 --- a/packages/snaps-sdk/src/jsx/components/formatting/Italic.ts +++ b/packages/snaps-sdk/src/jsx/components/formatting/Italic.ts @@ -1,14 +1,13 @@ -import type { JsonObject, MaybeArray, SnapElement } from '../../component'; +import type { JsonObject, SnapElement, SnapsChildren } from '../../component'; import { createSnapComponent } from '../../component'; /** * The children of the {@link Italic} component. */ -export type ItalicChildren = MaybeArray< +export type ItalicChildren = SnapsChildren< | string // We have to specify the type here to avoid a circular reference. | SnapElement - | null >; /** diff --git a/packages/snaps-sdk/src/jsx/validation.test.tsx b/packages/snaps-sdk/src/jsx/validation.test.tsx index 98ccfff126..a2db491599 100644 --- a/packages/snaps-sdk/src/jsx/validation.test.tsx +++ b/packages/snaps-sdk/src/jsx/validation.test.tsx @@ -73,7 +73,7 @@ describe('StringElementStruct', () => { expect(is(['foo', 'bar'], StringElementStruct)).toBe(true); }); - it.each([null, undefined, {}])('does not validate "%p"', (value) => { + it.each([undefined, {}])('does not validate "%p"', (value) => { expect(is(value, StringElementStruct)).toBe(false); }); }); diff --git a/packages/snaps-sdk/src/jsx/validation.ts b/packages/snaps-sdk/src/jsx/validation.ts index 1c6a2ccc7a..64a38ac4ef 100644 --- a/packages/snaps-sdk/src/jsx/validation.ts +++ b/packages/snaps-sdk/src/jsx/validation.ts @@ -4,7 +4,7 @@ import { isPlainObject, JsonStruct, } from '@metamask/utils'; -import type { Struct } from 'superstruct'; +import type { Infer, Struct } from 'superstruct'; import { is, boolean, @@ -18,7 +18,11 @@ import { string, tuple, } from 'superstruct'; -import type { ObjectSchema } from 'superstruct/dist/utils'; +import type { + AnyStruct, + InferStructTuple, + ObjectSchema, +} from 'superstruct/dist/utils'; import type { Describe } from '../internals'; import { literal, nullUnion, svg } from '../internals'; @@ -27,9 +31,9 @@ import type { GenericSnapElement, JsonObject, Key, - MaybeArray, Nestable, SnapElement, + SnapsChildren, StringElement, } from './component'; import type { @@ -67,9 +71,9 @@ export const KeyStruct: Describe = nullUnion([string(), number()]); /** * A struct for the {@link StringElement} type. */ -export const StringElementStruct: Describe = maybeArray( +export const StringElementStruct: Describe = children([ string(), -); +]); /** * A struct for the {@link GenericSnapElement} type. @@ -86,7 +90,9 @@ export const ElementStruct: Describe = object({ * @param struct - The struct for the type to test. * @returns The struct for the nestable type. */ -function nestable(struct: Struct) { +function nestable( + struct: Struct, +): Struct, any> { const nestableStruct: Struct> = nullUnion([ struct, array(lazy(() => nestableStruct)), @@ -96,15 +102,19 @@ function nestable(struct: Struct) { } /** - * A helper function for creating a struct for a {@link MaybeArray} type. + * A helper function for creating a struct which allows children of a specific + * type, as well as `null` and `boolean`. * - * @param struct - The struct for the maybe array type. - * @returns The struct for the maybe array type. - */ -function maybeArray( - struct: Struct, -): Struct, any> { - return nestable(struct); + * @param structs - The structs to allow as children. + * @returns The struct for the children. + */ +function children( + structs: [head: Head, ...tail: Tail], +): Struct< + Nestable | InferStructTuple[number] | boolean | null>, + null +> { + return nestable(nullable(nullUnion([...structs, boolean()]))); } /** @@ -172,7 +182,7 @@ export const OptionStruct: Describe = element('Option', { export const DropdownStruct: Describe = element('Dropdown', { name: string(), value: optional(string()), - children: maybeArray(OptionStruct), + children: children([OptionStruct]), }); /** @@ -206,10 +216,10 @@ export const FieldStruct: Describe = element('Field', { * A struct for the {@link FormElement} type. */ export const FormStruct: Describe = element('Form', { - children: maybeArray( + children: children( // eslint-disable-next-line @typescript-eslint/no-use-before-define - nullable(nullUnion([FieldStruct, lazy(() => BoxChildStruct)])), - ) as unknown as Struct, null>, + [FieldStruct, lazy(() => BoxChildStruct)], + ) as unknown as Struct, null>, name: string(), }); @@ -217,34 +227,26 @@ export const FormStruct: Describe = element('Form', { * A struct for the {@link BoldElement} type. */ export const BoldStruct: Describe = element('Bold', { - children: maybeArray( - nullable( - nullUnion([ - string(), - // eslint-disable-next-line @typescript-eslint/no-use-before-define - lazy(() => ItalicStruct) as unknown as Struct< - SnapElement - >, - ]), - ), - ), + children: children([ + string(), + // eslint-disable-next-line @typescript-eslint/no-use-before-define + lazy(() => ItalicStruct) as unknown as Struct< + SnapElement + >, + ]), }); /** * A struct for the {@link ItalicElement} type. */ export const ItalicStruct: Describe = element('Italic', { - children: maybeArray( - nullable( - nullUnion([ - string(), - // eslint-disable-next-line @typescript-eslint/no-use-before-define - lazy(() => BoldStruct) as unknown as Struct< - SnapElement - >, - ]), - ), - ), + children: children([ + string(), + // eslint-disable-next-line @typescript-eslint/no-use-before-define + lazy(() => BoldStruct) as unknown as Struct< + SnapElement + >, + ]), }); export const FormattingStruct: Describe = nullUnion([ @@ -263,10 +265,10 @@ export const AddressStruct: Describe = element('Address', { * A struct for the {@link BoxElement} type. */ export const BoxStruct: Describe = element('Box', { - children: maybeArray( + children: children( // eslint-disable-next-line @typescript-eslint/no-use-before-define - nullable(lazy(() => BoxChildStruct)), - ) as unknown as Struct, null>, + [lazy(() => BoxChildStruct)], + ) as unknown as Struct, null>, direction: optional(nullUnion([literal('horizontal'), literal('vertical')])), alignment: optional( nullUnion([ @@ -320,16 +322,14 @@ export const ImageStruct: Describe = element('Image', { */ export const LinkStruct: Describe = element('Link', { href: string(), - children: maybeArray(nullable(nullUnion([FormattingStruct, string()]))), + children: children([FormattingStruct, string()]), }); /** * A struct for the {@link TextElement} type. */ export const TextStruct: Describe = element('Text', { - children: maybeArray( - nullable(nullUnion([string(), BoldStruct, ItalicStruct, LinkStruct])), - ), + children: children([string(), BoldStruct, ItalicStruct, LinkStruct]), alignment: optional( nullUnion([literal('start'), literal('center'), literal('end')]), ), @@ -345,6 +345,7 @@ export const TooltipChildStruct = nullUnion([ ItalicStruct, LinkStruct, ImageStruct, + boolean(), ]); /** diff --git a/packages/snaps-utils/coverage.json b/packages/snaps-utils/coverage.json index a5ffcef354..4990dcaaee 100644 --- a/packages/snaps-utils/coverage.json +++ b/packages/snaps-utils/coverage.json @@ -1,6 +1,6 @@ { - "branches": 96.75, + "branches": 96.77, "functions": 98.76, - "lines": 98.84, + "lines": 98.85, "statements": 94.92 } diff --git a/packages/snaps-utils/src/ui.test.tsx b/packages/snaps-utils/src/ui.test.tsx index 8bd5c20f3d..ea60f37c88 100644 --- a/packages/snaps-utils/src/ui.test.tsx +++ b/packages/snaps-utils/src/ui.test.tsx @@ -785,6 +785,45 @@ describe('getJsxChildren', () => { ]); }); + it('removes falsy children from the array', () => { + const element = ( + + Hello + {false && Foo} + World + + ); + + expect(getJsxChildren(element)).toStrictEqual([ + Hello, + World, + ]); + }); + + it('removes falsy children from a text element', () => { + const element = ( + + Hello + {false && 'Foo'} + World + + ); + + expect(getJsxChildren(element)).toStrictEqual(['Hello', 'World']); + }); + + it('removes `true` children from the array', () => { + const element = ( + + Hello + {true} + World + + ); + + expect(getJsxChildren(element)).toStrictEqual(['Hello', 'World']); + }); + it('flattens the children array', () => { const element = ( diff --git a/packages/snaps-utils/src/ui.tsx b/packages/snaps-utils/src/ui.tsx index 6ba648315a..1a7a6d313a 100644 --- a/packages/snaps-utils/src/ui.tsx +++ b/packages/snaps-utils/src/ui.tsx @@ -5,7 +5,7 @@ import type { ItalicChildren, JSXElement, LinkElement, - MaybeArray, + Nestable, RowChildren, StandardFormattingElement, TextChildren, @@ -433,11 +433,23 @@ export function getTotalTextLength(component: Component): number { export function hasChildren( element: Element, ): element is Element & { - props: { children: MaybeArray }; + props: { children: Nestable }; } { return hasProperty(element.props, 'children'); } +/** + * Filter a JSX child to remove `null`, `undefined`, plain booleans, and empty + * strings. + * + * @param child - The JSX child to filter. + * @returns `true` if the child is not `null`, `undefined`, a plain boolean, or + * an empty string, `false` otherwise. + */ +function filterJsxChild(child: JSXElement | string | boolean | null): boolean { + return Boolean(child) && child !== true; +} + /** * Get the children of a JSX element as an array. If the element has only one * child, the child is returned as an array. @@ -450,7 +462,7 @@ export function getJsxChildren(element: JSXElement): (JSXElement | string)[] { if (Array.isArray(element.props.children)) { // @ts-expect-error - Each member of the union type has signatures, but // none of those signatures are compatible with each other. - return element.props.children.filter(Boolean).flat(Infinity); + return element.props.children.filter(filterJsxChild).flat(Infinity); } if (element.props.children) {