diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 10ea07a68..d0abc28d3 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -3409,6 +3409,9 @@ "snapsToggle": { "message": "A snap will only run if it is enabled" }, + "snapsUIError": { + "message": "The UI specified by the snap is invalid." + }, "someNetworksMayPoseSecurity": { "message": "Some networks may pose security and/or privacy risks. Understand the risks before adding & using a network." }, diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 042f03002..e9e421f86 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -2435,7 +2435,7 @@ "TextEncoder": true }, "packages": { - "@metamask/snaps-utils>superstruct": true, + "@metamask/snaps-ui>superstruct": true, "browserify>buffer": true, "nock>debug": true } diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 6d7ff9b63..57ba6ae23 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -1275,9 +1275,9 @@ "@metamask/permission-controller": true, "@metamask/rpc-methods>@metamask/key-tree": true, "@metamask/rpc-methods>nanoid": true, + "@metamask/snaps-ui>superstruct": true, "@metamask/snaps-utils": true, "@metamask/snaps-utils>@noble/hashes": true, - "@metamask/snaps-utils>superstruct": true, "eth-block-tracker>@metamask/utils": true, "eth-rpc-errors": true } @@ -1766,11 +1766,11 @@ "URL": true }, "packages": { + "@metamask/snaps-ui>superstruct": true, "@metamask/snaps-utils>@noble/hashes": true, "@metamask/snaps-utils>@scure/base": true, "@metamask/snaps-utils>cron-parser": true, "@metamask/snaps-utils>rfdc": true, - "@metamask/snaps-utils>superstruct": true, "@metamask/snaps-utils>validate-npm-package-name": true, "eth-block-tracker>@metamask/utils": true, "semver": true @@ -2804,7 +2804,7 @@ "TextEncoder": true }, "packages": { - "@metamask/snaps-utils>superstruct": true, + "@metamask/snaps-ui>superstruct": true, "browserify>buffer": true, "nock>debug": true } diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 042f03002..e9e421f86 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -2435,7 +2435,7 @@ "TextEncoder": true }, "packages": { - "@metamask/snaps-utils>superstruct": true, + "@metamask/snaps-ui>superstruct": true, "browserify>buffer": true, "nock>debug": true } diff --git a/package.json b/package.json index 24e3f5066..09854b447 100644 --- a/package.json +++ b/package.json @@ -226,6 +226,7 @@ "@metamask/slip44": "^2.1.0", "@metamask/smart-transactions-controller": "^3.0.0", "@metamask/snaps-controllers": "^0.26.1", + "@metamask/snaps-ui": "^0.26.1", "@metamask/snaps-utils": "^0.26.1", "@metamask/subject-metadata-controller": "^1.0.0", "@ngraveio/bc-ur": "^1.1.6", diff --git a/ui/components/app/app-components.scss b/ui/components/app/app-components.scss index 47289aa8a..d4d22e37c 100644 --- a/ui/components/app/app-components.scss +++ b/ui/components/app/app-components.scss @@ -38,6 +38,7 @@ @import 'flask/snap-content-footer/index'; @import 'flask/snap-install-warning/index'; @import 'flask/snap-remove-warning/index'; +@import 'flask/snap-ui-renderer/index'; @import 'flask/snap-delineator/index'; @import 'flask/snap-settings-card/index'; @import 'flask/update-snap-permission-list/index'; diff --git a/ui/components/app/flask/snap-ui-renderer/index.js b/ui/components/app/flask/snap-ui-renderer/index.js new file mode 100644 index 000000000..572ebe69a --- /dev/null +++ b/ui/components/app/flask/snap-ui-renderer/index.js @@ -0,0 +1 @@ +export { SnapUIRenderer } from './snap-ui-renderer'; diff --git a/ui/components/app/flask/snap-ui-renderer/index.scss b/ui/components/app/flask/snap-ui-renderer/index.scss new file mode 100644 index 000000000..883777fde --- /dev/null +++ b/ui/components/app/flask/snap-ui-renderer/index.scss @@ -0,0 +1,17 @@ +.snap-ui-renderer { + &__error { + margin-top: 0 !important; + } + + &__spinner { + width: 30px; + } + + &__divider { + width: 100%; + } + + &__panel { + height: 100%; + } +} diff --git a/ui/components/app/flask/snap-ui-renderer/snap-ui-renderer.js b/ui/components/app/flask/snap-ui-renderer/snap-ui-renderer.js new file mode 100644 index 000000000..b92530f47 --- /dev/null +++ b/ui/components/app/flask/snap-ui-renderer/snap-ui-renderer.js @@ -0,0 +1,104 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import nanoid from 'nanoid'; +import { isComponent } from '@metamask/snaps-ui'; +import MetaMaskTemplateRenderer from '../../metamask-template-renderer/metamask-template-renderer'; +import { + TYPOGRAPHY, + FONT_WEIGHT, + DISPLAY, + FLEX_DIRECTION, +} from '../../../../helpers/constants/design-system'; +import { SnapDelineator } from '../snap-delineator'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import ActionableMessage from '../../../ui/actionable-message/actionable-message'; +import { getSnap } from '../../../../selectors'; + +export const UI_MAPPING = { + panel: (props) => ({ + element: 'Box', + // eslint-disable-next-line no-use-before-define + children: props.children.map(mapToTemplate), + props: { + display: DISPLAY.FLEX, + flexDirection: FLEX_DIRECTION.COLUMN, + className: 'snap-ui-renderer__panel', + }, + }), + heading: (props) => ({ + element: 'Typography', + children: props.value, + props: { + variant: TYPOGRAPHY.H3, + fontWeight: FONT_WEIGHT.BOLD, + }, + }), + text: (props) => ({ + element: 'Typography', + children: props.value, + props: { + variant: TYPOGRAPHY.H6, + }, + }), + spinner: () => ({ + element: 'Spinner', + props: { + className: 'snap-ui-renderer__spinner', + }, + }), + divider: () => ({ + element: 'hr', + props: { + className: 'snap-ui-renderer__divider', + }, + }), + copyable: (props) => ({ + element: 'Copyable', + props: { + text: props.value, + }, + }), +}; + +const mapToTemplate = (data) => { + const { type } = data; + const mapped = UI_MAPPING[type](data); + // TODO: We may want to have deterministic keys at some point + return { ...mapped, key: nanoid() }; +}; + +// Component that maps Snaps UI JSON format to MetaMask Template Renderer format +export const SnapUIRenderer = ({ snapId, data }) => { + const t = useI18nContext(); + const snap = useSelector((state) => getSnap(state, snapId)); + + const snapName = snap.manifest.proposedName; + + if (!isComponent(data)) { + return ( + + + + ); + } + + const sections = mapToTemplate(data); + + return ( + + + + ); +}; + +SnapUIRenderer.propTypes = { + snapId: PropTypes.string, + data: PropTypes.object, +}; diff --git a/ui/components/app/flask/snap-ui-renderer/snap-ui-renderer.stories.js b/ui/components/app/flask/snap-ui-renderer/snap-ui-renderer.stories.js new file mode 100644 index 000000000..941d473c6 --- /dev/null +++ b/ui/components/app/flask/snap-ui-renderer/snap-ui-renderer.stories.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { object } from '@storybook/addon-knobs'; +import { panel, text, heading, divider, copyable } from '@metamask/snaps-ui'; +import configureStore from '../../../../store/store'; +import testData from '../../../../../.storybook/test-data'; +import { SnapUIRenderer } from '.'; + +const store = configureStore(testData); + +export default { + title: 'Components/App/SnapUIRenderer', + id: __filename, + decorators: [(story) => {story()}], +}; + +const DATA = panel([ + heading('Foo bar'), + text('Description'), + divider(), + text('More text'), + copyable('Text you can copy'), +]); + +export const DefaultStory = () => ( + +); + +export const ErrorStory = () => ( + +); diff --git a/ui/components/app/flask/snap-ui-renderer/snap-ui-renderer.test.js b/ui/components/app/flask/snap-ui-renderer/snap-ui-renderer.test.js new file mode 100644 index 000000000..3a1e83150 --- /dev/null +++ b/ui/components/app/flask/snap-ui-renderer/snap-ui-renderer.test.js @@ -0,0 +1,9 @@ +import { NodeType } from '@metamask/snaps-ui'; +import { UI_MAPPING } from './snap-ui-renderer'; + +describe('Snap UI mapping', () => { + it('supports all exposed components', () => { + const nodes = Object.values(NodeType); + expect(Object.keys(UI_MAPPING).sort()).toStrictEqual(nodes.sort()); + }); +}); diff --git a/ui/components/app/metamask-template-renderer/safe-component-list.js b/ui/components/app/metamask-template-renderer/safe-component-list.js index 243eac2da..f7e190160 100644 --- a/ui/components/app/metamask-template-renderer/safe-component-list.js +++ b/ui/components/app/metamask-template-renderer/safe-component-list.js @@ -15,6 +15,7 @@ import Tooltip from '../../ui/tooltip/tooltip'; ///: BEGIN:ONLY_INCLUDE_IN(flask) import { SnapDelineator } from '../flask/snap-delineator'; import { Copyable } from '../flask/copyable'; +import Spinner from '../../ui/spinner'; ///: END:ONLY_INCLUDE_IN export const safeComponentList = { @@ -41,5 +42,7 @@ export const safeComponentList = { ///: BEGIN:ONLY_INCLUDE_IN(flask) SnapDelineator, Copyable, + Spinner, + hr: 'hr', ///: END:ONLY_INCLUDE_IN }; diff --git a/yarn.lock b/yarn.lock index b7858ae4d..bcb8a11ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4371,6 +4371,16 @@ __metadata: languageName: node linkType: hard +"@metamask/snaps-ui@npm:^0.26.1": + version: 0.26.1 + resolution: "@metamask/snaps-ui@npm:0.26.1" + dependencies: + "@metamask/utils": ^3.3.1 + superstruct: ^0.16.7 + checksum: 73dc68f02670ae075abf54740c4b3265741699b10cf2f9dc16a56393651d6595361feaa3f80cec4900884bd351e9177b0f1eb27e4511cdc7dc6b05be2e107210 + languageName: node + linkType: hard + "@metamask/snaps-utils@npm:^0.26.1": version: 0.26.1 resolution: "@metamask/snaps-utils@npm:0.26.1" @@ -23354,6 +23364,7 @@ __metadata: "@metamask/slip44": ^2.1.0 "@metamask/smart-transactions-controller": ^3.0.0 "@metamask/snaps-controllers": ^0.26.1 + "@metamask/snaps-ui": ^0.26.1 "@metamask/snaps-utils": ^0.26.1 "@metamask/subject-metadata-controller": ^1.0.0 "@metamask/test-dapp": ^5.2.1