From bea39ff03605a6371b61346045b425d9df1cabbf Mon Sep 17 00:00:00 2001 From: David Drazic Date: Mon, 16 Sep 2024 15:02:15 +0200 Subject: [PATCH] Add example for Selector UI component (#2724) Add example of `` component to Interactive UI Snap example. Also, add `snaps-jest` tests that cover `` component functionality. Fixes: https://github.com/MetaMask/snaps/issues/2701 ![Screenshot 2024-09-13 at 14 58 26](https://github.com/user-attachments/assets/71576f69-5caa-4d2d-b002-78e764bac13e) ![Screenshot 2024-09-13 at 14 58 42](https://github.com/user-attachments/assets/83623456-8094-473b-9ce3-fa725df868c9) ![Screenshot 2024-09-13 at 14 58 54](https://github.com/user-attachments/assets/047798b9-637a-4e2c-895b-26ca19f4529b) --- .../interactive-ui/snap.manifest.json | 2 +- .../src/components/InteractiveForm.tsx | 33 +++- .../interactive-ui/src/components/Result.tsx | 4 +- .../interactive-ui/src/index.test.tsx | 7 + packages/snaps-jest/src/helpers.test.tsx | 3 + .../snaps-jest/src/internals/request.test.tsx | 69 +++++++- .../internals/simulation/interface.test.tsx | 166 ++++++++++++++++++ .../src/internals/simulation/interface.ts | 85 +++++++++ packages/snaps-jest/src/types/types.ts | 8 + 9 files changed, 371 insertions(+), 6 deletions(-) diff --git a/packages/examples/packages/interactive-ui/snap.manifest.json b/packages/examples/packages/interactive-ui/snap.manifest.json index 6581945413..583e934906 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": "uOuMArCEwmOmCl0Bl1WnRRm+DKcq0Y+O+5n8Z1KBMr8=", + "shasum": "WSCjxt5olWIenXrxEpjc90jeiv5odFCZ1PQ67OwzgBk=", "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 b1e85a3700..bea2545f9e 100644 --- a/packages/examples/packages/interactive-ui/src/components/InteractiveForm.tsx +++ b/packages/examples/packages/interactive-ui/src/components/InteractiveForm.tsx @@ -1,5 +1,8 @@ import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; import { + Card, + Selector, + SelectorOption, Radio, RadioGroup, Button, @@ -36,6 +39,11 @@ export type InteractiveFormState = { * The value of the example checkbox. */ 'example-checkbox': boolean; + + /** + * The value of the example Selector. + */ + 'example-selector': string; }; export const InteractiveForm: SnapComponent = () => { @@ -63,9 +71,28 @@ export const InteractiveForm: SnapComponent = () => { - + + + + + + + + + + + + + + + + ); diff --git a/packages/examples/packages/interactive-ui/src/components/Result.tsx b/packages/examples/packages/interactive-ui/src/components/Result.tsx index 9e210aea5a..3d75d4dff4 100644 --- a/packages/examples/packages/interactive-ui/src/components/Result.tsx +++ b/packages/examples/packages/interactive-ui/src/components/Result.tsx @@ -17,7 +17,9 @@ export const Result: SnapComponent = ({ values }) => { ))} - + + + ); }; diff --git a/packages/examples/packages/interactive-ui/src/index.test.tsx b/packages/examples/packages/interactive-ui/src/index.test.tsx index a15951e19b..5304ee8c77 100644 --- a/packages/examples/packages/interactive-ui/src/index.test.tsx +++ b/packages/examples/packages/interactive-ui/src/index.test.tsx @@ -45,6 +45,8 @@ describe('onRpcRequest', () => { await formScreen.selectFromRadioGroup('example-radiogroup', 'option3'); + await formScreen.selectFromSelector('example-selector', 'option2'); + await formScreen.clickElement('example-checkbox'); await formScreen.clickElement('submit'); @@ -59,6 +61,7 @@ describe('onRpcRequest', () => { 'example-dropdown': 'option3', 'example-radiogroup': 'option3', 'example-checkbox': true, + 'example-selector': 'option2', }} />, ); @@ -90,6 +93,7 @@ describe('onRpcRequest', () => { 'example-dropdown': 'option1', 'example-radiogroup': 'option1', 'example-checkbox': false, + 'example-selector': 'option1', }} />, ); @@ -116,6 +120,8 @@ describe('onHomePage', () => { await formScreen.selectFromRadioGroup('example-radiogroup', 'option3'); + await formScreen.selectFromSelector('example-selector', 'option2'); + await formScreen.clickElement('submit'); const resultScreen = response.getInterface(); @@ -127,6 +133,7 @@ describe('onHomePage', () => { 'example-dropdown': 'option3', 'example-radiogroup': 'option3', 'example-checkbox': false, + 'example-selector': 'option2', }} />, ); diff --git a/packages/snaps-jest/src/helpers.test.tsx b/packages/snaps-jest/src/helpers.test.tsx index f24364cb48..1867def9d9 100644 --- a/packages/snaps-jest/src/helpers.test.tsx +++ b/packages/snaps-jest/src/helpers.test.tsx @@ -409,6 +409,7 @@ describe('installSnap', () => { typeInField: expect.any(Function), selectInDropdown: expect.any(Function), selectFromRadioGroup: expect.any(Function), + selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), @@ -470,6 +471,7 @@ describe('installSnap', () => { typeInField: expect.any(Function), selectInDropdown: expect.any(Function), selectFromRadioGroup: expect.any(Function), + selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), @@ -531,6 +533,7 @@ describe('installSnap', () => { typeInField: expect.any(Function), selectInDropdown: expect.any(Function), selectFromRadioGroup: expect.any(Function), + selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), ok: expect.any(Function), }); diff --git a/packages/snaps-jest/src/internals/request.test.tsx b/packages/snaps-jest/src/internals/request.test.tsx index 488dda1b72..2c80da79ca 100644 --- a/packages/snaps-jest/src/internals/request.test.tsx +++ b/packages/snaps-jest/src/internals/request.test.tsx @@ -1,7 +1,15 @@ 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, Radio, RadioGroup } from '@metamask/snaps-sdk/jsx'; +import { + Card, + Dropdown, + Option, + Radio, + RadioGroup, + Selector, + SelectorOption, +} from '@metamask/snaps-sdk/jsx'; import { getJsxElementFromComponent, HandlerType } from '@metamask/snaps-utils'; import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; @@ -273,6 +281,7 @@ describe('getInterfaceApi', () => { typeInField: expect.any(Function), selectInDropdown: expect.any(Function), selectFromRadioGroup: expect.any(Function), + selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), }); }); @@ -305,6 +314,7 @@ describe('getInterfaceApi', () => { typeInField: expect.any(Function), selectInDropdown: expect.any(Function), selectFromRadioGroup: expect.any(Function), + selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), }); }); @@ -517,4 +527,61 @@ describe('getInterfaceApi', () => { }, ); }); + + it('sends the request to the snap when using `selectInSelector`', 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.selectFromSelector('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/simulation/interface.test.tsx b/packages/snaps-jest/src/internals/simulation/interface.test.tsx index d3be3c69b4..a511cab366 100644 --- a/packages/snaps-jest/src/internals/simulation/interface.test.tsx +++ b/packages/snaps-jest/src/internals/simulation/interface.test.tsx @@ -24,6 +24,9 @@ import { Form, Container, Footer, + SelectorOption, + Card, + Selector, } from '@metamask/snaps-sdk/jsx'; import { getJsxElementFromComponent, @@ -59,6 +62,7 @@ import { selectFromRadioGroup, typeInField, uploadFile, + selectFromSelector, } from './interface'; import type { RunSagaFunction } from './store'; import { createStore, resolveInterface, setInterface } from './store'; @@ -83,6 +87,7 @@ describe('getInterfaceResponse', () => { typeInField: jest.fn(), selectInDropdown: jest.fn(), selectFromRadioGroup: jest.fn(), + selectFromSelector: jest.fn(), uploadFile: jest.fn(), }; @@ -103,6 +108,7 @@ describe('getInterfaceResponse', () => { typeInField: expect.any(Function), selectInDropdown: expect.any(Function), selectFromRadioGroup: expect.any(Function), + selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), ok: expect.any(Function), }); @@ -129,6 +135,7 @@ describe('getInterfaceResponse', () => { typeInField: expect.any(Function), selectInDropdown: expect.any(Function), selectFromRadioGroup: expect.any(Function), + selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), @@ -156,6 +163,7 @@ describe('getInterfaceResponse', () => { typeInField: expect.any(Function), selectInDropdown: expect.any(Function), selectFromRadioGroup: expect.any(Function), + selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), @@ -183,6 +191,7 @@ describe('getInterfaceResponse', () => { typeInField: expect.any(Function), selectInDropdown: expect.any(Function), selectFromRadioGroup: expect.any(Function), + selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), @@ -210,6 +219,7 @@ describe('getInterfaceResponse', () => { typeInField: expect.any(Function), selectInDropdown: expect.any(Function), selectFromRadioGroup: expect.any(Function), + selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), @@ -237,6 +247,7 @@ describe('getInterfaceResponse', () => { typeInField: expect.any(Function), selectInDropdown: expect.any(Function), selectFromRadioGroup: expect.any(Function), + selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), ok: expect.any(Function), cancel: expect.any(Function), @@ -283,6 +294,7 @@ describe('getInterfaceResponse', () => { typeInField: expect.any(Function), selectInDropdown: expect.any(Function), selectFromRadioGroup: expect.any(Function), + selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), }); }); @@ -321,6 +333,7 @@ describe('getInterfaceResponse', () => { typeInField: expect.any(Function), selectInDropdown: expect.any(Function), selectFromRadioGroup: expect.any(Function), + selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), cancel: expect.any(Function), }); @@ -354,6 +367,7 @@ describe('getInterfaceResponse', () => { typeInField: expect.any(Function), selectInDropdown: expect.any(Function), selectFromRadioGroup: expect.any(Function), + selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), cancel: expect.any(Function), ok: expect.any(Function), @@ -1143,6 +1157,7 @@ describe('getInterface', () => { typeInField: expect.any(Function), selectInDropdown: expect.any(Function), selectFromRadioGroup: expect.any(Function), + selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), ok: expect.any(Function), }); @@ -1172,6 +1187,7 @@ describe('getInterface', () => { typeInField: expect.any(Function), selectInDropdown: expect.any(Function), selectFromRadioGroup: expect.any(Function), + selectFromSelector: expect.any(Function), uploadFile: expect.any(Function), ok: expect.any(Function), }); @@ -1504,3 +1520,153 @@ describe('selectFromRadioGroup', () => { ); }); }); + +describe('selectFromSelector', () => { + 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 selectFromSelector( + 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 chosen option does not exist', async () => { + const content = ( + + + + + + + + + ); + + const interfaceId = await interfaceController.createInterface( + MOCK_SNAP_ID, + content, + ); + + await expect( + selectFromSelector( + rootControllerMessenger, + interfaceId, + content, + MOCK_SNAP_ID, + 'foo', + 'option3', + ), + ).rejects.toThrow( + 'The Selector with the name "foo" does not contain "option3"', + ); + }); + + it('throws if there is no Selector in the interface', async () => { + const content = ( + + Foo + + ); + + const interfaceId = await interfaceController.createInterface( + MOCK_SNAP_ID, + content, + ); + + await expect( + selectFromSelector( + 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 Selector', async () => { + const content = ; + + const interfaceId = await interfaceController.createInterface( + MOCK_SNAP_ID, + content, + ); + + await expect( + selectFromSelector( + rootControllerMessenger, + interfaceId, + content, + MOCK_SNAP_ID, + 'foo', + 'baz', + ), + ).rejects.toThrow( + 'Expected an element of type "Selector", but found "Input".', + ); + }); +}); diff --git a/packages/snaps-jest/src/internals/simulation/interface.ts b/packages/snaps-jest/src/internals/simulation/interface.ts index f11666a864..cc1023ee35 100644 --- a/packages/snaps-jest/src/internals/simulation/interface.ts +++ b/packages/snaps-jest/src/internals/simulation/interface.ts @@ -664,6 +664,80 @@ export async function selectFromRadioGroup( }); } +/** + * Choose an option with value from Selector 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 selectFromSelector( + 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 === 'Selector', + `Expected an element of type "Selector", 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 Selector 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 formatted file size. * @@ -809,6 +883,17 @@ export function getInterfaceActions( ); }, + selectFromSelector: async (name: string, value: string) => { + await selectFromSelector( + controllerMessenger, + id, + content, + snapId, + name, + value, + ); + }, + uploadFile: async ( name: string, file: string | Uint8Array, diff --git a/packages/snaps-jest/src/types/types.ts b/packages/snaps-jest/src/types/types.ts index a5476de548..0d3eedd3ae 100644 --- a/packages/snaps-jest/src/types/types.ts +++ b/packages/snaps-jest/src/types/types.ts @@ -129,6 +129,14 @@ export type SnapInterfaceActions = { */ selectFromRadioGroup(name: string, value: string): Promise; + /** + * Choose an option with a value from Selector component. + * + * @param name - The element name to type in. + * @param value - The value to type. + */ + selectFromSelector(name: string, value: string): Promise; + /** * Upload a file. *