diff --git a/packages/snaps-ui/src/builder.test.ts b/packages/snaps-ui/src/builder.test.ts index c0aaf39118..9ce5faf28f 100644 --- a/packages/snaps-ui/src/builder.test.ts +++ b/packages/snaps-ui/src/builder.test.ts @@ -11,38 +11,38 @@ import { NodeType } from './nodes'; describe('copyable', () => { it('creates a copyable component', () => { - expect(copyable({ text: 'Hello, world!' })).toStrictEqual({ + expect(copyable({ value: 'Hello, world!' })).toStrictEqual({ type: NodeType.Copyable, - text: 'Hello, world!', + value: 'Hello, world!', }); - expect(copyable({ text: 'foo bar' })).toStrictEqual({ + expect(copyable({ value: 'foo bar' })).toStrictEqual({ type: NodeType.Copyable, - text: 'foo bar', + value: 'foo bar', }); }); it('creates a copyable component using the shorthand form', () => { expect(copyable('Hello, world!')).toStrictEqual({ type: NodeType.Copyable, - text: 'Hello, world!', + value: 'Hello, world!', }); expect(copyable('foo bar')).toStrictEqual({ type: NodeType.Copyable, - text: 'foo bar', + value: 'foo bar', }); }); it('validates the args', () => { // @ts-expect-error - Invalid args. - expect(() => copyable({ text: 'foo', bar: 'baz' })).toThrow( + expect(() => copyable({ value: 'foo', bar: 'baz' })).toThrow( 'Invalid copyable component: At path: bar -- Expected a value of type `never`, but received: `"baz"`.', ); // @ts-expect-error - Invalid args. expect(() => copyable({})).toThrow( - 'Invalid copyable component: At path: text -- Expected a string, but received: undefined.', + 'Invalid copyable component: At path: value -- Expected a string, but received: undefined.', ); }); }); @@ -64,38 +64,38 @@ describe('divider', () => { describe('heading', () => { it('creates a heading component', () => { - expect(heading({ text: 'Hello, world!' })).toStrictEqual({ + expect(heading({ value: 'Hello, world!' })).toStrictEqual({ type: NodeType.Heading, - text: 'Hello, world!', + value: 'Hello, world!', }); - expect(heading({ text: 'foo bar' })).toStrictEqual({ + expect(heading({ value: 'foo bar' })).toStrictEqual({ type: NodeType.Heading, - text: 'foo bar', + value: 'foo bar', }); }); it('creates a heading component using the shorthand form', () => { expect(heading('Hello, world!')).toStrictEqual({ type: NodeType.Heading, - text: 'Hello, world!', + value: 'Hello, world!', }); expect(heading('foo bar')).toStrictEqual({ type: NodeType.Heading, - text: 'foo bar', + value: 'foo bar', }); }); it('validates the args', () => { // @ts-expect-error - Invalid args. - expect(() => heading({ text: 'foo', bar: 'baz' })).toThrow( + expect(() => heading({ value: 'foo', bar: 'baz' })).toThrow( 'Invalid heading component: At path: bar -- Expected a value of type `never`, but received: `"baz"`.', ); // @ts-expect-error - Invalid args. expect(() => heading({})).toThrow( - 'Invalid heading component: At path: text -- Expected a string, but received: undefined.', + 'Invalid heading component: At path: value -- Expected a string, but received: undefined.', ); }); }); @@ -103,20 +103,20 @@ describe('heading', () => { describe('panel', () => { it('creates a panel component', () => { expect( - panel({ children: [heading({ text: 'Hello, world!' })] }), + panel({ children: [heading({ value: 'Hello, world!' })] }), ).toStrictEqual({ type: NodeType.Panel, children: [ { type: NodeType.Heading, - text: 'Hello, world!', + value: 'Hello, world!', }, ], }); expect( panel({ - children: [panel({ children: [heading({ text: 'Hello, world!' })] })], + children: [panel({ children: [heading({ value: 'Hello, world!' })] })], }), ).toStrictEqual({ type: NodeType.Panel, @@ -126,7 +126,7 @@ describe('panel', () => { children: [ { type: NodeType.Heading, - text: 'Hello, world!', + value: 'Hello, world!', }, ], }, @@ -140,7 +140,7 @@ describe('panel', () => { children: [ { type: NodeType.Heading, - text: 'Hello, world!', + value: 'Hello, world!', }, ], }); @@ -153,7 +153,7 @@ describe('panel', () => { children: [ { type: NodeType.Heading, - text: 'Hello, world!', + value: 'Hello, world!', }, ], }, @@ -206,38 +206,38 @@ describe('spinner', () => { describe('text', () => { it('creates a text component', () => { - expect(text({ text: 'Hello, world!' })).toStrictEqual({ + expect(text({ value: 'Hello, world!' })).toStrictEqual({ type: NodeType.Text, - text: 'Hello, world!', + value: 'Hello, world!', }); - expect(text({ text: 'foo bar' })).toStrictEqual({ + expect(text({ value: 'foo bar' })).toStrictEqual({ type: NodeType.Text, - text: 'foo bar', + value: 'foo bar', }); }); it('creates a text component using the shorthand form', () => { expect(text('Hello, world!')).toStrictEqual({ type: NodeType.Text, - text: 'Hello, world!', + value: 'Hello, world!', }); expect(text('foo bar')).toStrictEqual({ type: NodeType.Text, - text: 'foo bar', + value: 'foo bar', }); }); it('validates the args', () => { // @ts-expect-error - Invalid args. - expect(() => text({ text: 'foo', bar: 'baz' })).toThrow( + expect(() => text({ value: 'foo', bar: 'baz' })).toThrow( 'Invalid text component: At path: bar -- Expected a value of type `never`, but received: `"baz"`.', ); // @ts-expect-error - Invalid args. expect(() => text({})).toThrow( - 'Invalid text component: At path: text -- Expected a string, but received: undefined.', + 'Invalid text component: At path: value -- Expected a string, but received: undefined.', ); }); }); diff --git a/packages/snaps-ui/src/builder.ts b/packages/snaps-ui/src/builder.ts index 93dab4fc3e..40e2c68a38 100644 --- a/packages/snaps-ui/src/builder.ts +++ b/packages/snaps-ui/src/builder.ts @@ -101,7 +101,7 @@ function createBuilder< * @returns A {@link Copyable} component. */ export const copyable = createBuilder(NodeType.Copyable, CopyableStruct, [ - 'text', + 'value', ]); /** @@ -128,7 +128,9 @@ export const divider = createBuilder(NodeType.Divider, DividerStruct); * const node = heading('Hello, world!'); * ``` */ -export const heading = createBuilder(NodeType.Heading, HeadingStruct, ['text']); +export const heading = createBuilder(NodeType.Heading, HeadingStruct, [ + 'value', +]); /** * Create a {@link Panel} node. @@ -190,4 +192,4 @@ export const spinner = createBuilder(NodeType.Spinner, SpinnerStruct); * const node = text('Hello, world!'); * ``` */ -export const text = createBuilder(NodeType.Text, TextStruct, ['text']); +export const text = createBuilder(NodeType.Text, TextStruct, ['value']); diff --git a/packages/snaps-ui/src/nodes.ts b/packages/snaps-ui/src/nodes.ts index c47bbf131d..ddc700fd75 100644 --- a/packages/snaps-ui/src/nodes.ts +++ b/packages/snaps-ui/src/nodes.ts @@ -8,15 +8,51 @@ import { string, Struct, union, + unknown, } from 'superstruct'; -export const NodeStruct = object({ +const NodeStruct = object({ type: string(), }); -export type Node = { - type: string; -}; +/** + * The base node type. + * + * @property type - The node type. + */ +export type Node = Infer; + +const ParentStruct = assign( + NodeStruct, + object({ + // This node references itself indirectly, so we need to use `lazy()`. + // eslint-disable-next-line @typescript-eslint/no-use-before-define + children: array(lazy(() => ComponentStruct)), + }), +); + +/** + * A node with children. + * + * @property type - The node type. + * @property children - The children of this node. + */ +export type Parent = Infer; + +const LiteralStruct = assign( + NodeStruct, + object({ + value: unknown(), + }), +); + +/** + * A node with a value. + * + * @property type - The node type. + * @property value - The value of this node. + */ +export type Literal = Infer; export enum NodeType { Copyable = 'copyable', @@ -28,73 +64,87 @@ export enum NodeType { Text = 'text', } -export const CopyableStruct = object({ - type: literal(NodeType.Copyable), - text: string(), -}); +export const CopyableStruct = assign( + LiteralStruct, + object({ + type: literal(NodeType.Copyable), + value: string(), + }), +); /** * Text that can be copied to the clipboard. + * + * @property type - The type of the node, must be the string 'copyable'. + * @property value - The text to be copied. */ export type Copyable = Infer; -export const DividerStruct = object({ - type: literal(NodeType.Divider), -}); +export const DividerStruct = assign( + NodeStruct, + object({ + type: literal(NodeType.Divider), + }), +); /** * A divider node, that renders a line between other nodes. */ export type Divider = Infer; -export const HeadingStruct = object({ - type: literal(NodeType.Heading), - text: string(), -}); +export const HeadingStruct = assign( + LiteralStruct, + object({ + type: literal(NodeType.Heading), + value: string(), + }), +); /** * A heading node, that renders the text as a heading. The level of the heading * is determined by the depth of the heading in the document. * * @property type - The type of the node, must be the string 'text'. - * @property text - The text content of the node, either as plain text, or as a + * @property value - The text content of the node, either as plain text, or as a * markdown string. */ export type Heading = Infer; export const PanelStruct: Struct = assign( - NodeStruct, + ParentStruct, object({ type: literal(NodeType.Panel), - - // This node references itself indirectly, so we need to use `lazy()`. - // eslint-disable-next-line @typescript-eslint/no-use-before-define - children: lazy(() => array(ComponentStruct)), }), -) as unknown as Struct; +); /** * A panel node, which renders its children. * * @property type - The type of the node, must be the string 'text'. - * @property text - The text content of the node, either as plain text, or as a + * @property value - The text content of the node, either as plain text, or as a * markdown string. */ // This node references itself indirectly, so it cannot be inferred. export type Panel = { type: NodeType.Panel; children: Component[] }; -export const SpacerStruct = object({ - type: literal(NodeType.Spacer), -}); +export const SpacerStruct = assign( + NodeStruct, + object({ + type: literal(NodeType.Spacer), + }), +); /** * A spacer node, that renders a blank space between other nodes. */ export type Spacer = Infer; -export const SpinnerStruct = object({ - type: literal(NodeType.Spinner), -}); +export const SpinnerStruct = assign( + NodeStruct, + object({ + type: literal(NodeType.Spinner), + }), +); /** * A spinner node, that renders a spinner, either as a full-screen overlay, or @@ -103,10 +153,10 @@ export const SpinnerStruct = object({ export type Spinner = Infer; export const TextStruct = assign( - NodeStruct, + LiteralStruct, object({ type: literal(NodeType.Text), - text: string(), + value: string(), }), ); @@ -114,7 +164,7 @@ export const TextStruct = assign( * A text node, that renders the text as one or more paragraphs. * * @property type - The type of the node, must be the string 'text'. - * @property text - The text content of the node, either as plain text, or as a + * @property value - The text content of the node, either as plain text, or as a * markdown string. */ export type Text = Infer; diff --git a/packages/snaps-ui/src/validation.test.ts b/packages/snaps-ui/src/validation.test.ts index 0c96154a9b..1ee0f60a13 100644 --- a/packages/snaps-ui/src/validation.test.ts +++ b/packages/snaps-ui/src/validation.test.ts @@ -21,7 +21,7 @@ describe('isComponent', () => { it('returns true for a heading component', () => { const heading: Heading = { type: NodeType.Heading, - text: 'Hello, world!', + value: 'Hello, world!', }; expect(isComponent(heading)).toBe(true); @@ -33,7 +33,7 @@ describe('isComponent', () => { children: [ { type: NodeType.Heading, - text: 'Hello, world!', + value: 'Hello, world!', }, ], }; @@ -50,7 +50,7 @@ describe('isComponent', () => { children: [ { type: NodeType.Heading, - text: 'Hello, world!', + value: 'Hello, world!', }, ], }, @@ -79,7 +79,7 @@ describe('isComponent', () => { it('returns true for a text component', () => { const text: Text = { type: NodeType.Text, - text: 'Hello, world!', + value: 'Hello, world!', }; expect(isComponent(text)).toBe(true); @@ -116,7 +116,7 @@ describe('assertIsComponent', () => { it('does not throw for a heading component', () => { const heading: Heading = { type: NodeType.Heading, - text: 'Hello, world!', + value: 'Hello, world!', }; expect(() => assertIsComponent(heading)).not.toThrow(); @@ -128,7 +128,7 @@ describe('assertIsComponent', () => { children: [ { type: NodeType.Heading, - text: 'Hello, world!', + value: 'Hello, world!', }, ], }; @@ -145,7 +145,7 @@ describe('assertIsComponent', () => { children: [ { type: NodeType.Heading, - text: 'Hello, world!', + value: 'Hello, world!', }, ], }, @@ -174,7 +174,7 @@ describe('assertIsComponent', () => { it('does not throw for a text component', () => { const text: Text = { type: NodeType.Text, - text: 'Hello, world!', + value: 'Hello, world!', }; expect(() => assertIsComponent(text)).not.toThrow();