From ab717049a804bac812d2253e267d0ccd754c989b Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Fri, 24 May 2024 13:12:18 +0200 Subject: [PATCH] feat: add dropdown component (#2420) Adds support for a `Dropdown` component, that can be used on its own or as part of a `Field`. ```jsx Option 1 Option 2 Option 3 ``` Similarly to `Input`, it optionally uses `value` to determine the initial value/preselected dropdown item. This can also be used to control the input when re-rendering with `snap_updateInterface`. The dropdown will also trigger `InputChangeEvent` similarly to an `Input`. This PR mostly adds validation and types for this new component as well as some handling for the form state. This will need a follow-up PR in the extension to complete integration. This PR also adds support for `selectInDropdown` to `snaps-jest` and uses this new component in the Interactive UI example Snap. Progresses https://github.com/MetaMask/MetaMask-planning/issues/1575 --------- 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/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 +- .../src/components/InteractiveForm.tsx | 9 + .../interactive-ui/src/index.test.tsx | 14 +- .../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-controllers/coverage.json | 2 +- .../src/interface/utils.test.tsx | 47 +++- .../snaps-controllers/src/interface/utils.ts | 10 +- packages/snaps-jest/src/helpers.test.tsx | 3 + .../{request.test.ts => request.test.tsx} | 56 +++++ packages/snaps-jest/src/internals/request.ts | 11 + .../internals/simulation/interface.test.tsx | 214 +++++++++++++++++- .../src/internals/simulation/interface.ts | 91 +++++++- packages/snaps-jest/src/types.ts | 8 + .../src/jsx/components/form/Dropdown.ts | 45 ++++ .../src/jsx/components/form/Field.test.tsx | 46 ++++ .../src/jsx/components/form/Field.ts | 3 +- .../src/jsx/components/form/Option.ts | 40 ++++ .../src/jsx/components/form/index.ts | 8 +- .../snaps-sdk/src/jsx/validation.test.tsx | 40 ++++ packages/snaps-sdk/src/jsx/validation.ts | 28 ++- 46 files changed, 689 insertions(+), 42 deletions(-) rename packages/snaps-jest/src/internals/{request.test.ts => request.test.tsx} (85%) create mode 100644 packages/snaps-sdk/src/jsx/components/form/Dropdown.ts create mode 100644 packages/snaps-sdk/src/jsx/components/form/Option.ts diff --git a/packages/examples/packages/bip32/snap.manifest.json b/packages/examples/packages/bip32/snap.manifest.json index 2a6b19fe5b..02bd6bc20a 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": "9wUdqSqbVJe+0aQ6y/LxGbJK0h4bVGVG8Y6gIAP5zyU=", + "shasum": "GVN0FD0c4xnr+osY4rjRvF/KSGBgVC2fccbLwzGoFnk=", "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 fc263b7d46..e0331f508e 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": "9Ysf5NAslS75tHeB5y4T8SkAEChEx6m2Bf4bJdU6z84=", + "shasum": "j9j34JRDV5P98XgonRGfiqRSntlcaM4P53Z/S1g/XxA=", "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 7f2c88349d..95871122d5 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": "d8uw9NVVT/AWUsXjh+nwUOLaAtifQXT+sCJ0gTqPVjc=", + "shasum": "AVhrthHcnFfQFZgbhwiIzjbqzRW7HWRiPKHkv+Fu+fA=", "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 cdba9832df..4b593ee0e4 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": "xWwHeCztUjPa8YIFoCtTKf77qJczDgWw3v89S6ZJXwI=", + "shasum": "2FquW6bL4OaB6c97+QdgLoVQCBZv22QanqtU+LzmndE=", "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 b418a1292c..d994247a46 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": "tgk38f1RctSTY3valbyPvAWMltiFhlInsHywZH/Bq+A=", + "shasum": "935lBT4lhnqdiUEnatcORNsAEjSfMRmMz0HT2KJK6yg=", "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 cd16941744..1e24f7846e 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": "dF33PfyAEyIG+0x/1ggNeCScROxGheANb3U4k63pz0I=", + "shasum": "Ory0F194oSN15zWAVA5Dyvzpg2LFoWbi0ylfI2qLVz4=", "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 273fd67d89..b658eee473 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": "7eaW1wlK1vuLEQoOyGfl4s6j7HvYwfN8lvhTim/ofUk=", + "shasum": "4a7IfQp8AuCTx4BLTRow8IfZA0BArTuEb9CT8FLa3zE=", "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 ebfc5c462c..05cd07850a 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": "DYAfBoGmsxtJAY/473htgzk0a262nFgmdkhFcbfVQlA=", + "shasum": "25goBURWf+OrQTAgbwpuwN+TH/odY9fTHLDT/64oWvM=", "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 9e272cbadf..8f0cef0389 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": "6aVCE+EKxHD9mcS5asPqN4ejs6mU3+rxd6qre4Hx4v8=", + "shasum": "vj40D2zP2bG8E7+ADfp8htEhYGgP1Z+8ThcvjyEoyOk=", "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 392113413a..d1fbc7f2c8 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": "P+vBqlJolHAqbfdM9F7nGXjF/YR1bs+lthGTX4lwNX8=", + "shasum": "GHUM2ZesXFlhKOPU4mZ4Fty2qRln9aUuqHbVihttUKo=", "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 4b40a2eafa..df9d4387fd 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": "HOT/KmUzzXrhnAI5PNYLapCB8kKoi9cE2ztzc8saaM4=", + "shasum": "ExzexStVLaw/chL4NySLzoH0UAHpw8oSmf7PjP/P1fY=", "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 3f82d35e78..535489d2df 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": "rWABOhiFONdtEPQ6iFVw7XBVDswURwCNb2efK8oaBmE=", + "shasum": "FOe+60S6JcvSTyawpkPqd5ZsPmpqEWY1xG524iMMNKk=", "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 9138e73575..075933027b 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": "7dHD4QcAJrRiowUw2LKyJDvNxz/2WN9jDPajCuHLtcg=", + "shasum": "ehTebDIqhb6jIhvvYnh8x6VLUmu9TmZLwGpWh5CmbMA=", "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 dc7d4022ad..aae83bbae1 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": "2WrIpZaJtZdS9P0tcQn5pB5IvK61XLCArZVJtTRBfJo=", + "shasum": "NaByhc+fxmIG7PPdMjC6gH86rkKftx0/b3cwXc3XliA=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/interactive-ui/src/components/InteractiveForm.tsx b/packages/examples/packages/interactive-ui/src/components/InteractiveForm.tsx index 91158f938b..4a8780e011 100644 --- a/packages/examples/packages/interactive-ui/src/components/InteractiveForm.tsx +++ b/packages/examples/packages/interactive-ui/src/components/InteractiveForm.tsx @@ -6,6 +6,8 @@ import { Heading, Form, Input, + Dropdown, + Option, } from '@metamask/snaps-sdk/jsx'; export const InteractiveForm: SnapComponent = () => { @@ -16,6 +18,13 @@ export const InteractiveForm: SnapComponent = () => { + + + + + + + diff --git a/packages/examples/packages/interactive-ui/src/index.test.tsx b/packages/examples/packages/interactive-ui/src/index.test.tsx index 779e70d88f..afe94f80cd 100644 --- a/packages/examples/packages/interactive-ui/src/index.test.tsx +++ b/packages/examples/packages/interactive-ui/src/index.test.tsx @@ -43,12 +43,16 @@ describe('onRpcRequest', () => { await formScreen.typeInField('example-input', 'foobar'); + await formScreen.selectInDropdown('example-dropdown', 'option3'); + await formScreen.clickElement('submit'); const resultScreen = await response.getInterface(); expect(resultScreen).toRender( - , + , ); await resultScreen.ok(); @@ -72,7 +76,7 @@ describe('onRpcRequest', () => { const resultScreen = await response.getInterface(); expect(resultScreen).toRender( - , + , ); await resultScreen.ok(); @@ -93,12 +97,16 @@ describe('onHomePage', () => { await formScreen.typeInField('example-input', 'foobar'); + await formScreen.selectInDropdown('example-dropdown', 'option3'); + await formScreen.clickElement('submit'); const resultScreen = response.getInterface(); expect(resultScreen).toRender( - , + , ); }); }); 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 1524b1ce44..329969c409 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": "GgSToaZ1ZQ2EAUsER9Ipz7JjXiP34tAh8yHlSeZnrIw=", + "shasum": "dMnmXtz6qjVPnvfPCjLI3jcU/TiR3gN6qhp/y0La5uI=", "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 fd0ac77c74..22a36779b0 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": "qRjdWlOFuznynW12exjTado0bOvmHuyD9tJ6EYllTfI=", + "shasum": "IR1h1od8fD/rVtnohzFC8zz4XP3S+K/KOYcCR/V4hOU=", "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 e3e0795675..29b741462c 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": "ecGcLmxHbfbcggo+xXwKiKqn0jwvZpJBy9jzwbuhx14=", + "shasum": "Zh19stzSbkk4e0rl3mnZ69x6c1Ij+PEfJ3VGLcY1wEI=", "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 277f76f48f..2b8316fd74 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": "HoevSNqYDSD1M1rbAEDLdad/KnqdeJCR/qVQpXoRrfI=", + "shasum": "yqVsBhPZiHirCgF8KkdbvA5uMKdP4JsYipRTGFQ4kf8=", "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 409dc919ca..1571ab29b2 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": "hoY7FWJwVVeWRRz7u0VKyQTnMoLGLSa5aRfyDXPj9AM=", + "shasum": "ADZ5fA/qc6b9sLivha59OaZ8Q+/nvm2Br8QI8uTiMFQ=", "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 da9bf5d660..8e4cbd49ad 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": "y+DckJzrDa6DAdy9g1K7sWK98XLi3b6byj6/SW+mvp0=", + "shasum": "K9QSwxnBwkz0Zf2tA8HWhNiuWJp6DGaD8UhgvtVtENE=", "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 7f44e705c1..c2a229d320 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": "QRgU12P+k4MbET92wLU6RpueK9KYZb3HGItOH8pdXqo=", + "shasum": "g/Wi1iKD5OHqHBMHGAZJxx9BXJK3DVpt7401DP5vxiA=", "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 5fc9965b1b..b3679eef98 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": "idx+4WZjLaansFM6HrxdUpXkhcr3EDknwrRF5xcDPL0=", + "shasum": "3rJJn2lKBzyAgCn9J3m6XflIryIAT4qMqyhmGIL/tnE=", "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 7a8461bd6a..718189dd2b 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": "OozOdoPDn3miL7PzQHBhVDjbA8QULaNurUjXe8pEO5E=", + "shasum": "ta+iT3ThiGeJcfTy1DaDq5tOHJ6B7kweRWujb8hQzsc=", "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 7c54e56ca6..7a130a83c4 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": "3NBcGdsi/8Tw7mYaRS+hhHcLMDbfxZmz6AUJHSPEJ1k=", + "shasum": "0cIQp4wDFLHahG8XiwmCcEcQc2ZiY0kBuOV0MBmc9yo=", "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 baf469b636..cafdd10d55 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": "DqHMyRiHljEYgKJZXJaYhFNCeXZAlc2sCjyI8+7Vgkg=", + "shasum": "kSmphocy6yvQmcbeEAdVxF1WEEJw/wQbRyWEgViyYQo=", "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 33fe04acc3..5cc2615256 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": "oo7KkCxPI9OXMaS+Ik9qdj4AulwiwTrSNCHtMe0WPP4=", + "shasum": "1IdcaOOx96qPQYfTZTRM7brevrP9jpCuxqxaGs0H+Bc=", "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 43346be206..085602af06 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": "p6jcfAtN/r6hg314wA7Z9ihSAVP7+zm4KuZbB/Cu6+k=", + "shasum": "1PbMUDBzJvOO4kM1VIMsnJCXbQT5A3vt9Hh3gboTsyo=", "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 21b47ef5b8..0451f3b5f5 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": "zgVFc93FtIDmzDPyP80bN1gTPgIE+uhPrABgpoP83Gs=", + "shasum": "Eli9yqWMDpM34s1qfBJ5vjBZfkerVJqJyCvgp1Xsmoo=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index 8b18decb67..cbf36ffd58 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,5 +1,5 @@ { - "branches": 91.54, + "branches": 91.57, "functions": 96.75, "lines": 97.88, "statements": 97.55 diff --git a/packages/snaps-controllers/src/interface/utils.test.tsx b/packages/snaps-controllers/src/interface/utils.test.tsx index 95fb2a8b70..f0ee259f5e 100644 --- a/packages/snaps-controllers/src/interface/utils.test.tsx +++ b/packages/snaps-controllers/src/interface/utils.test.tsx @@ -1,4 +1,13 @@ -import { Box, Button, Field, Form, Input, Text } from '@metamask/snaps-sdk/jsx'; +import { + Box, + Button, + Dropdown, + Option, + Field, + Form, + Input, + Text, +} from '@metamask/snaps-sdk/jsx'; import { assertNameIsUnique, constructState } from './utils'; @@ -223,6 +232,42 @@ describe('constructState', () => { }); }); + it('supports root level dropdowns', () => { + const element = ( + + + + + + + ); + + const result = constructState({}, element); + expect(result).toStrictEqual({ + foo: 'option2', + }); + }); + + it('supports dropdowns in forms', () => { + const element = ( + +
+ + + + + + +
+
+ ); + + const result = constructState({}, element); + expect(result).toStrictEqual({ + form: { foo: 'option2' }, + }); + }); + it('deletes unused root level values', () => { const element = ( diff --git a/packages/snaps-controllers/src/interface/utils.ts b/packages/snaps-controllers/src/interface/utils.ts index 1c315f3964..3b5e54f0a0 100644 --- a/packages/snaps-controllers/src/interface/utils.ts +++ b/packages/snaps-controllers/src/interface/utils.ts @@ -7,6 +7,7 @@ import type { } from '@metamask/snaps-sdk'; import type { ButtonElement, + DropdownElement, FieldElement, InputElement, JSXElement, @@ -54,7 +55,10 @@ export function assertNameIsUnique(state: InterfaceState, name: string) { * @param element - The input element. * @returns The input state. */ -function constructInputState(oldState: InterfaceState, element: InputElement) { +function constructInputState( + oldState: InterfaceState, + element: InputElement | DropdownElement, +) { return element.props.value ?? oldState[element.props.name] ?? null; } @@ -68,7 +72,7 @@ function constructInputState(oldState: InterfaceState, element: InputElement) { */ function constructFormInputState( oldState: InterfaceState, - component: InputElement, + component: InputElement | DropdownElement, form: string, ) { const oldFormState = oldState[form] as FormState; @@ -159,7 +163,7 @@ export function constructState( return newState; } - if (component.type === 'Input') { + if (component.type === 'Input' || component.type === 'Dropdown') { assertNameIsUnique(newState, component.props.name); newState[component.props.name] = constructInputState(oldState, component); } diff --git a/packages/snaps-jest/src/helpers.test.tsx b/packages/snaps-jest/src/helpers.test.tsx index d074e26d92..faada8db17 100644 --- a/packages/snaps-jest/src/helpers.test.tsx +++ b/packages/snaps-jest/src/helpers.test.tsx @@ -403,6 +403,7 @@ describe('installSnap', () => { content: Hello, world!, clickElement: expect.any(Function), typeInField: expect.any(Function), + selectInDropdown: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -461,6 +462,7 @@ describe('installSnap', () => { content: Hello, world!, clickElement: expect.any(Function), typeInField: expect.any(Function), + selectInDropdown: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -518,6 +520,7 @@ describe('installSnap', () => { content: Hello, world!, clickElement: expect.any(Function), typeInField: expect.any(Function), + selectInDropdown: expect.any(Function), ok: expect.any(Function), }); diff --git a/packages/snaps-jest/src/internals/request.test.ts b/packages/snaps-jest/src/internals/request.test.tsx similarity index 85% rename from packages/snaps-jest/src/internals/request.test.ts rename to packages/snaps-jest/src/internals/request.test.tsx index 9f9cb127a1..49abb716ed 100644 --- a/packages/snaps-jest/src/internals/request.test.ts +++ b/packages/snaps-jest/src/internals/request.test.tsx @@ -1,6 +1,7 @@ import { SnapInterfaceController } from '@metamask/snaps-controllers'; import type { SnapId } from '@metamask/snaps-sdk'; import { UserInputEventType, button, input, text } from '@metamask/snaps-sdk'; +import { Dropdown, Option } from '@metamask/snaps-sdk/jsx'; import { getJsxElementFromComponent, HandlerType } from '@metamask/snaps-utils'; import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; @@ -194,6 +195,7 @@ describe('getInterfaceApi', () => { content: getJsxElementFromComponent(content), clickElement: expect.any(Function), typeInField: expect.any(Function), + selectInDropdown: expect.any(Function), }); }); @@ -223,6 +225,7 @@ describe('getInterfaceApi', () => { content: getJsxElementFromComponent(content), clickElement: expect.any(Function), typeInField: expect.any(Function), + selectInDropdown: expect.any(Function), }); }); @@ -328,4 +331,57 @@ describe('getInterfaceApi', () => { }, ); }); + + it('sends the request to the snap when using `selectInDropdown`', async () => { + const controllerMessenger = getRootControllerMessenger(); + + jest.spyOn(controllerMessenger, 'call'); + + // eslint-disable-next-line no-new + new SnapInterfaceController({ + messenger: + getRestrictedSnapInterfaceControllerMessenger(controllerMessenger), + }); + + const content = ( + + + + + ); + + const getInterface = await getInterfaceApi( + { content }, + MOCK_SNAP_ID, + controllerMessenger, + ); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const snapInterface = getInterface!(); + + await snapInterface.selectInDropdown('foo', 'option2'); + + expect(controllerMessenger.call).toHaveBeenNthCalledWith( + 6, + 'ExecutionService:handleRpcRequest', + MOCK_SNAP_ID, + { + origin: '', + handler: HandlerType.OnUserInput, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: UserInputEventType.InputChangeEvent, + name: 'foo', + value: 'option2', + }, + id: expect.any(String), + context: null, + }, + }, + }, + ); + }); }); diff --git a/packages/snaps-jest/src/internals/request.ts b/packages/snaps-jest/src/internals/request.ts index c34222fbf8..ee52b932e2 100644 --- a/packages/snaps-jest/src/internals/request.ts +++ b/packages/snaps-jest/src/internals/request.ts @@ -16,6 +16,7 @@ import { getInterface, getNotifications, typeInField, + selectInDropdown, } from './simulation'; import type { RunSagaFunction, Store } from './simulation'; import type { RootControllerMessenger } from './simulation/controllers'; @@ -190,6 +191,16 @@ export async function getInterfaceApi( value, ); }, + selectInDropdown: async (name, value) => { + await selectInDropdown( + controllerMessenger, + interfaceId, + content, + snapId, + name, + value, + ); + }, }; }; } diff --git a/packages/snaps-jest/src/internals/simulation/interface.test.tsx b/packages/snaps-jest/src/internals/simulation/interface.test.tsx index 3bf7fcbb92..bc47197e96 100644 --- a/packages/snaps-jest/src/internals/simulation/interface.test.tsx +++ b/packages/snaps-jest/src/internals/simulation/interface.test.tsx @@ -9,7 +9,14 @@ import { panel, text, } from '@metamask/snaps-sdk'; -import { Button, Text } from '@metamask/snaps-sdk/jsx'; +import { + Button, + Text, + Dropdown, + Option, + Box, + Input, +} from '@metamask/snaps-sdk/jsx'; import { getJsxElementFromComponent, HandlerType, @@ -31,6 +38,7 @@ import { getInterface, getInterfaceResponse, mergeValue, + selectInDropdown, typeInField, } from './interface'; import type { RunSagaFunction } from './store'; @@ -51,7 +59,11 @@ async function getResolve(runSaga: RunSagaFunction) { } describe('getInterfaceResponse', () => { - const interfaceActions = { clickElement: jest.fn(), typeInField: jest.fn() }; + const interfaceActions = { + clickElement: jest.fn(), + typeInField: jest.fn(), + selectInDropdown: jest.fn(), + }; it('returns an `ok` function that resolves the user interface with `null` for alert dialogs', async () => { const { runSaga } = createStore(getMockOptions()); @@ -67,6 +79,7 @@ describe('getInterfaceResponse', () => { content: foo, clickElement: expect.any(Function), typeInField: expect.any(Function), + selectInDropdown: expect.any(Function), ok: expect.any(Function), }); @@ -89,6 +102,7 @@ describe('getInterfaceResponse', () => { content: foo, clickElement: expect.any(Function), typeInField: expect.any(Function), + selectInDropdown: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -113,6 +127,7 @@ describe('getInterfaceResponse', () => { content: foo, clickElement: expect.any(Function), typeInField: expect.any(Function), + selectInDropdown: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -136,6 +151,7 @@ describe('getInterfaceResponse', () => { content: foo, clickElement: expect.any(Function), typeInField: expect.any(Function), + selectInDropdown: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -159,6 +175,7 @@ describe('getInterfaceResponse', () => { content: foo, clickElement: expect.any(Function), typeInField: expect.any(Function), + selectInDropdown: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -183,6 +200,7 @@ describe('getInterfaceResponse', () => { content: foo, clickElement: expect.any(Function), typeInField: expect.any(Function), + selectInDropdown: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), }); @@ -547,6 +565,148 @@ describe('typeInField', () => { }); }); +describe('selectInDropdown', () => { + const rootControllerMessenger = getRootControllerMessenger(); + const controllerMessenger = getRestrictedSnapInterfaceControllerMessenger( + rootControllerMessenger, + ); + + const interfaceController = new SnapInterfaceController({ + messenger: controllerMessenger, + }); + + const handleRpcRequestMock = jest.fn(); + + rootControllerMessenger.registerActionHandler( + 'ExecutionService:handleRpcRequest', + handleRpcRequestMock, + ); + + it('updates the interface state and sends an InputChangeEvent', async () => { + jest.spyOn(rootControllerMessenger, 'call'); + + const content = ( + + + + + ); + + const interfaceId = await interfaceController.createInterface( + MOCK_SNAP_ID, + content, + ); + + await selectInDropdown( + rootControllerMessenger, + interfaceId, + content, + MOCK_SNAP_ID, + 'foo', + 'option2', + ); + + expect(rootControllerMessenger.call).toHaveBeenCalledWith( + 'SnapInterfaceController:updateInterfaceState', + interfaceId, + { foo: 'option2' }, + ); + + expect(handleRpcRequestMock).toHaveBeenCalledWith(MOCK_SNAP_ID, { + origin: '', + handler: HandlerType.OnUserInput, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: UserInputEventType.InputChangeEvent, + name: 'foo', + value: 'option2', + }, + id: interfaceId, + context: null, + }, + }, + }); + }); + + it('throws if selected option does not exist', async () => { + const content = ( + + + + + ); + + const interfaceId = await interfaceController.createInterface( + MOCK_SNAP_ID, + content, + ); + + await expect( + selectInDropdown( + rootControllerMessenger, + interfaceId, + content, + MOCK_SNAP_ID, + 'foo', + 'option3', + ), + ).rejects.toThrow( + 'The dropdown with the name "foo" does not contain "option3"', + ); + }); + + it('throws if there is no dropdowns in the interface', async () => { + const content = ( + + Foo + + ); + + const interfaceId = await interfaceController.createInterface( + MOCK_SNAP_ID, + content, + ); + + await expect( + selectInDropdown( + rootControllerMessenger, + interfaceId, + content, + MOCK_SNAP_ID, + 'bar', + 'baz', + ), + ).rejects.toThrow( + 'Could not find an element in the interface with the name "bar".', + ); + }); + + it('throws if the element is not a dropdown', async () => { + const content = ; + + const interfaceId = await interfaceController.createInterface( + MOCK_SNAP_ID, + content, + ); + + await expect( + selectInDropdown( + rootControllerMessenger, + interfaceId, + content, + MOCK_SNAP_ID, + 'foo', + 'baz', + ), + ).rejects.toThrow( + 'Expected an element of type "Dropdown", but found "Input".', + ); + }); +}); + describe('getInterface', () => { const rootControllerMessenger = getRootControllerMessenger(); const controllerMessenger = getRestrictedSnapInterfaceControllerMessenger( @@ -577,6 +737,7 @@ describe('getInterface', () => { content: getJsxElementFromComponent(content), clickElement: expect.any(Function), typeInField: expect.any(Function), + selectInDropdown: expect.any(Function), ok: expect.any(Function), }); }); @@ -603,6 +764,7 @@ describe('getInterface', () => { content: getJsxElementFromComponent(content), clickElement: expect.any(Function), typeInField: expect.any(Function), + selectInDropdown: expect.any(Function), ok: expect.any(Function), }); }); @@ -691,4 +853,52 @@ describe('getInterface', () => { }, ); }); + + it('sends a request to the snap when `selectInDropdown` is called', async () => { + jest.spyOn(rootControllerMessenger, 'call'); + const { store, runSaga } = createStore(getMockOptions()); + + const content = ( + + + + + ); + const id = await interfaceController.createInterface(MOCK_SNAP_ID, content); + const type = DialogType.Alert; + const ui = { type, id }; + + store.dispatch(setInterface(ui)); + + const result = await runSaga( + getInterface, + runSaga, + MOCK_SNAP_ID, + rootControllerMessenger, + ).toPromise(); + + await result.selectInDropdown('foo', 'option2'); + + expect(rootControllerMessenger.call).toHaveBeenCalledWith( + 'ExecutionService:handleRpcRequest', + MOCK_SNAP_ID, + { + origin: '', + handler: HandlerType.OnUserInput, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: UserInputEventType.InputChangeEvent, + name: 'foo', + value: 'option2', + }, + id, + context: null, + }, + }, + }, + ); + }); }); diff --git a/packages/snaps-jest/src/internals/simulation/interface.ts b/packages/snaps-jest/src/internals/simulation/interface.ts index 1eb75211e9..f392a14fe7 100644 --- a/packages/snaps-jest/src/internals/simulation/interface.ts +++ b/packages/snaps-jest/src/internals/simulation/interface.ts @@ -7,7 +7,12 @@ import type { } from '@metamask/snaps-sdk'; import { DialogType, UserInputEventType, assert } from '@metamask/snaps-sdk'; import type { FormElement, JSXElement } from '@metamask/snaps-sdk/jsx'; -import { HandlerType, unwrapError, walkJsx } from '@metamask/snaps-utils'; +import { + HandlerType, + getJsxChildren, + unwrapError, + walkJsx, +} from '@metamask/snaps-utils'; import { hasProperty } from '@metamask/utils'; import type { PayloadAction } from '@reduxjs/toolkit'; import { type SagaIterator } from 'redux-saga'; @@ -415,6 +420,80 @@ export async function typeInField( }); } +/** + * Type a value in an interface element. + * + * @param controllerMessenger - The controller messenger used to call actions. + * @param id - The interface ID. + * @param content - The interface Components. + * @param snapId - The Snap ID. + * @param name - The element name. + * @param value - The value to type in the element. + */ +export async function selectInDropdown( + controllerMessenger: RootControllerMessenger, + id: string, + content: JSXElement, + snapId: SnapId, + name: string, + value: string, +) { + const result = getElement(content, name); + + assert( + result !== undefined, + `Could not find an element in the interface with the name "${name}".`, + ); + + assert( + result.element.type === 'Dropdown', + `Expected an element of type "Dropdown", but found "${result.element.type}".`, + ); + + const options = getJsxChildren(result.element) as JSXElement[]; + const selectedOption = options.find( + (option) => + hasProperty(option.props, 'value') && option.props.value === value, + ); + + assert( + selectedOption !== undefined, + `The dropdown with the name "${name}" does not contain "${value}".`, + ); + + const { state, context } = controllerMessenger.call( + 'SnapInterfaceController:getInterface', + snapId, + id, + ); + + const newState = mergeValue(state, name, value, result.form); + + controllerMessenger.call( + 'SnapInterfaceController:updateInterfaceState', + id, + newState, + ); + + await controllerMessenger.call('ExecutionService:handleRpcRequest', snapId, { + origin: '', + handler: HandlerType.OnUserInput, + request: { + jsonrpc: '2.0', + method: ' ', + params: { + event: { + type: UserInputEventType.InputChangeEvent, + name: result.element.props.name, + value, + }, + id, + context, + }, + }, + }); +} + /** * Get a user interface object from a Snap. * @@ -442,6 +521,16 @@ export function* getInterface( typeInField: async (name: string, value: string) => { await typeInField(controllerMessenger, id, content, snapId, name, value); }, + selectInDropdown: async (name: string, value: string) => { + await selectInDropdown( + controllerMessenger, + id, + content, + snapId, + name, + value, + ); + }, }; return getInterfaceResponse(runSaga, type, content, interfaceActions); diff --git a/packages/snaps-jest/src/types.ts b/packages/snaps-jest/src/types.ts index e3b8fbda83..bdf3652f31 100644 --- a/packages/snaps-jest/src/types.ts +++ b/packages/snaps-jest/src/types.ts @@ -96,6 +96,14 @@ export type SnapInterfaceActions = { * @param value - The value to type. */ typeInField(name: string, value: string): Promise; + + /** + * Select an option with a value in a dropdown. + * + * @param name - The element name to type in. + * @param value - The value to type. + */ + selectInDropdown(name: string, value: string): Promise; }; /** diff --git a/packages/snaps-sdk/src/jsx/components/form/Dropdown.ts b/packages/snaps-sdk/src/jsx/components/form/Dropdown.ts new file mode 100644 index 0000000000..fa133c526e --- /dev/null +++ b/packages/snaps-sdk/src/jsx/components/form/Dropdown.ts @@ -0,0 +1,45 @@ +import type { MaybeArray } from '../../component'; +import { createSnapComponent } from '../../component'; +import type { OptionElement } from './Option'; + +/** + * 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. + */ +type DropdownProps = { + name: string; + value?: string; + children: MaybeArray; +}; + +const TYPE = 'Dropdown'; + +/** + * A dropdown component, which is used to create a dropdown. This component + * can only be used as a child of the {@link Field} component. + * + * @param props - The props of the component. + * @param props.name - The name of the dropdown field. This is used to identify the + * state in the form data. + * @param props.value - The selected value of the dropdown. + * @param props.children - The children of the dropdown. + * @returns A dropdown element. + * @example + * + * + * + * + * + */ +export const Dropdown = createSnapComponent(TYPE); + +/** + * A dropdown element. + * + * @see Dropdown + */ +export type DropdownElement = ReturnType; diff --git a/packages/snaps-sdk/src/jsx/components/form/Field.test.tsx b/packages/snaps-sdk/src/jsx/components/form/Field.test.tsx index ee4183f98e..0ff73f6aaf 100644 --- a/packages/snaps-sdk/src/jsx/components/form/Field.test.tsx +++ b/packages/snaps-sdk/src/jsx/components/form/Field.test.tsx @@ -1,6 +1,8 @@ import { Button } from './Button'; +import { Dropdown } from './Dropdown'; import { Field } from './Field'; import { Input } from './Input'; +import { Option } from './Option'; describe('Field', () => { it('renders a field element', () => { @@ -86,4 +88,48 @@ describe('Field', () => { }, }); }); + + it('renders a dropdown element', () => { + const result = ( + + + + + + + ); + + expect(result).toStrictEqual({ + type: 'Field', + key: null, + props: { + label: 'Label', + children: { + type: 'Dropdown', + key: null, + props: { + name: 'foo', + children: [ + { + type: 'Option', + key: null, + props: { + children: 'Option 1', + value: 'option1', + }, + }, + { + type: 'Option', + key: null, + props: { + children: 'Option 2', + value: 'option2', + }, + }, + ], + }, + }, + }, + }); + }); }); diff --git a/packages/snaps-sdk/src/jsx/components/form/Field.ts b/packages/snaps-sdk/src/jsx/components/form/Field.ts index e7d9fba7be..d149bc67c8 100644 --- a/packages/snaps-sdk/src/jsx/components/form/Field.ts +++ b/packages/snaps-sdk/src/jsx/components/form/Field.ts @@ -1,5 +1,6 @@ import { createSnapComponent } from '../../component'; import type { ButtonElement } from './Button'; +import type { DropdownElement } from './Dropdown'; import type { InputElement } from './Input'; /** @@ -12,7 +13,7 @@ import type { InputElement } from './Input'; export type FieldProps = { label?: string | undefined; error?: string | undefined; - children: [InputElement, ButtonElement] | InputElement; + children: [InputElement, ButtonElement] | InputElement | DropdownElement; }; const TYPE = 'Field'; diff --git a/packages/snaps-sdk/src/jsx/components/form/Option.ts b/packages/snaps-sdk/src/jsx/components/form/Option.ts new file mode 100644 index 0000000000..91b6484ad2 --- /dev/null +++ b/packages/snaps-sdk/src/jsx/components/form/Option.ts @@ -0,0 +1,40 @@ +import { createSnapComponent } from '../../component'; + +/** + * The props of the {@link Option} component. + * + * @property value - The value of the dropdown option. This is used to populate the + * state in the form data. + * @property children - The text to display. + */ +type OptionProps = { + value: string; + children: string; +}; + +const TYPE = 'Option'; + +/** + * A dropdown option component, which is used to create a dropdown option. This component + * can only be used as a child of the {@link Dropdown} component. + * + * @param props - The props of the component. + * @param props.value - The value of the dropdown option. This is used to populate the + * state in the form data. + * @param props.children - The text to display. + * @returns A dropdown option element. + * @example + * + * + * + * + * + */ +export const Option = createSnapComponent(TYPE); + +/** + * A dropdown option element. + * + * @see Option + */ +export type OptionElement = ReturnType; diff --git a/packages/snaps-sdk/src/jsx/components/form/index.ts b/packages/snaps-sdk/src/jsx/components/form/index.ts index 8947b303e8..bfc310a0cd 100644 --- a/packages/snaps-sdk/src/jsx/components/form/index.ts +++ b/packages/snaps-sdk/src/jsx/components/form/index.ts @@ -1,9 +1,13 @@ import type { ButtonElement } from './Button'; +import type { DropdownElement } from './Dropdown'; import type { FieldElement } from './Field'; import type { FormElement } from './Form'; import type { InputElement } from './Input'; +import type { OptionElement } from './Option'; export * from './Button'; +export * from './Dropdown'; +export * from './Option'; export * from './Field'; export * from './Form'; export * from './Input'; @@ -12,4 +16,6 @@ export type StandardFormElement = | ButtonElement | FormElement | FieldElement - | InputElement; + | InputElement + | DropdownElement + | OptionElement; diff --git a/packages/snaps-sdk/src/jsx/validation.test.tsx b/packages/snaps-sdk/src/jsx/validation.test.tsx index 36208deff9..c1ec1b897f 100644 --- a/packages/snaps-sdk/src/jsx/validation.test.tsx +++ b/packages/snaps-sdk/src/jsx/validation.test.tsx @@ -7,6 +7,8 @@ import { Button, Copyable, Divider, + Dropdown, + Option, Field, Form, Heading, @@ -26,6 +28,7 @@ import { ButtonStruct, CopyableStruct, DividerStruct, + DropdownStruct, ElementStruct, FieldStruct, FormStruct, @@ -205,6 +208,12 @@ describe('FieldStruct', () => { , + + + + + + , ])('validates a field element', (value) => { expect(is(value, FieldStruct)).toBe(true); }); @@ -696,6 +705,37 @@ describe('SpinnerStruct', () => { }); }); +describe('Dropdown', () => { + it.each([ + + + + , + ])('validates a dropdown element', (value) => { + expect(is(value, DropdownStruct)).toBe(true); + }); + + it.each([ + 'foo', + 42, + null, + undefined, + {}, + [], + // @ts-expect-error - Invalid props. + foo, + foo, + + foo + , + + alt + , + ])('does not validate "%p"', (value) => { + expect(is(value, DropdownStruct)).toBe(false); + }); +}); + describe('isJSXElement', () => { it.each([ foo, diff --git a/packages/snaps-sdk/src/jsx/validation.ts b/packages/snaps-sdk/src/jsx/validation.ts index 1e3792f8e6..3c97c70f20 100644 --- a/packages/snaps-sdk/src/jsx/validation.ts +++ b/packages/snaps-sdk/src/jsx/validation.ts @@ -38,6 +38,8 @@ import type { ButtonElement, CopyableElement, DividerElement, + DropdownElement, + OptionElement, FieldElement, FormElement, HeadingElement, @@ -126,13 +128,34 @@ export const InputStruct: Describe = element('Input', { placeholder: optional(string()), }); +/** + * A struct for the {@link OptionElement} type. + */ +export const OptionStruct: Describe = element('Option', { + value: string(), + children: string(), +}); + +/** + * A struct for the {@link DropdownElement} type. + */ +export const DropdownStruct: Describe = element('Dropdown', { + name: string(), + value: optional(string()), + children: maybeArray(OptionStruct), +}); + /** * A struct for the {@link FieldElement} type. */ export const FieldStruct: Describe = element('Field', { label: optional(string()), error: optional(string()), - children: nullUnion([tuple([InputStruct, ButtonStruct]), InputStruct]), + children: nullUnion([ + tuple([InputStruct, ButtonStruct]), + InputStruct, + DropdownStruct, + ]), }); /** @@ -291,6 +314,7 @@ export const BoxChildStruct = nullUnion([ RowStruct, SpinnerStruct, TextStruct, + DropdownStruct, ]); /** @@ -319,6 +343,8 @@ export const JSXElementStruct: Describe = nullUnion([ RowStruct, SpinnerStruct, TextStruct, + DropdownStruct, + OptionStruct, ]); /**