diff --git a/app/components/Snaps/SnapUIButton/SnapUIButton.test.tsx b/app/components/Snaps/SnapUIButton/SnapUIButton.test.tsx new file mode 100644 index 000000000000..e9c23f24873f --- /dev/null +++ b/app/components/Snaps/SnapUIButton/SnapUIButton.test.tsx @@ -0,0 +1,202 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { SnapUIButton } from './SnapUIButton'; +import { useSnapInterfaceContext } from '../SnapInterfaceContext'; +import { ButtonType, UserInputEventType } from '@metamask/snaps-sdk'; +import Text, { + TextColor, +} from '../../../component-library/components/Texts/Text'; +import { View } from 'react-native'; +import AnimatedLottieView from 'lottie-react-native'; + +jest.mock('../SnapInterfaceContext', () => ({ + useSnapInterfaceContext: jest.fn(), +})); + +jest.mock('../../../util/theme', () => ({ + useTheme: jest.fn().mockReturnValue({ + colors: { + background: { default: '#FFFFFF' }, + border: { muted: '#CCCCCC', default: '#DDDDDD' }, + text: { default: '#000000' }, + primary: { default: '#0376C9' }, + error: { default: '#D73A49' }, + }, + }), +})); + +describe('SnapUIButton', () => { + const mockHandleEvent = jest.fn(); + const mockOnPress = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useSnapInterfaceContext as jest.Mock).mockReturnValue({ + handleEvent: mockHandleEvent, + }); + }); + + const getColorForTextVariant = (color: TextColor): string => { + switch (color) { + case TextColor.Info: + return '#0376C9'; + case TextColor.Error: + return '#D73A49'; + case TextColor.Muted: + return '#000000'; + default: + return '#000000'; + } + }; + + const createStyledText = ( + text: string, + color: TextColor = TextColor.Info, + ) => ( + + {text} + + ); + + it('renders correctly with text children', () => { + const { getByText } = render( + {createStyledText('Test Button')}, + ); + + expect(getByText('Test Button')).toBeTruthy(); + }); + + it('renders correctly when disabled', () => { + const { getByText, getByTestId } = render( + + {createStyledText('Disabled Button', TextColor.Muted)} + , + ); + + expect(getByText('Disabled Button')).toBeTruthy(); + expect(getByTestId('disabled-button').props.disabled).toBe(true); + }); + + it('renders with loading state', () => { + const { UNSAFE_getByType } = render( + {createStyledText('Loading Button')}, + ); + + expect(UNSAFE_getByType(AnimatedLottieView)).toBeTruthy(); + }); + + it('calls onPress and handles ButtonClickEvent when pressed', () => { + const { getByText } = render( + + {createStyledText('Click Me')} + , + ); + + fireEvent.press(getByText('Click Me')); + + expect(mockOnPress).toHaveBeenCalledTimes(1); + expect(mockHandleEvent).toHaveBeenCalledWith({ + event: UserInputEventType.ButtonClickEvent, + name: 'test-button', + }); + }); + + it('handles FormSubmitEvent when button type is Submit', () => { + const { getByText } = render( + + {createStyledText('Submit Form')} + , + ); + + fireEvent.press(getByText('Submit Form')); + + expect(mockHandleEvent).toHaveBeenCalledWith({ + event: UserInputEventType.ButtonClickEvent, + name: 'test-button', + }); + expect(mockHandleEvent).toHaveBeenCalledWith({ + event: UserInputEventType.FormSubmitEvent, + name: 'test-form', + }); + }); + + it('renders icon component correctly', () => { + const MockIcon = () => ; + MockIcon.displayName = 'Icon'; + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('icon-button')).toBeTruthy(); + expect(getByTestId('mock-icon')).toBeTruthy(); + }); + + it('renders destructive variant button correctly', () => { + const { getByTestId } = render( + + {createStyledText('Destructive Button', TextColor.Error)} + , + ); + + expect(getByTestId('destructive-button')).toBeTruthy(); + }); + + it('renders with custom style', () => { + const customStyle = { backgroundColor: '#FF0000', borderRadius: 16 }; + const { getByTestId } = render( + + {createStyledText('Styled Button')} + , + ); + + const button = getByTestId('styled-button'); + expect(button.props.style.backgroundColor).toBe('#FF0000'); + expect(button.props.style.borderRadius).toBe(16); + }); + + it('handles non-string, non-icon children properly', () => { + const NonStringComponent = () => ; + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('custom-children-button')).toBeTruthy(); + expect(getByTestId('non-string-component')).toBeTruthy(); + }); + + it('sets proper accessibility properties', () => { + const { getByTestId } = render( + + {createStyledText('Accessible Button')} + , + ); + + const button = getByTestId('accessible-button'); + expect(button.props.accessible).toBe(true); + expect(button.props.accessibilityRole).toBe('button'); + expect(button.props.accessibilityLabel).toBe('button-name'); + }); + + it('provides fallback to name for accessibilityLabel when children is not a string', () => { + const NonStringComponent = () => ; + + const { getByTestId } = render( + + + , + ); + + const button = getByTestId('accessible-button'); + expect(button.props.accessibilityLabel).toBe('button-name'); + }); +}); diff --git a/app/components/Snaps/SnapUIButton/SnapUIButton.tsx b/app/components/Snaps/SnapUIButton/SnapUIButton.tsx index e4729cbe6232..afd8508a1eb0 100644 --- a/app/components/Snaps/SnapUIButton/SnapUIButton.tsx +++ b/app/components/Snaps/SnapUIButton/SnapUIButton.tsx @@ -1,45 +1,46 @@ import React, { FunctionComponent } from 'react'; import { ButtonType, UserInputEventType } from '@metamask/snaps-sdk'; -import ButtonLink from '../../../component-library/components/Buttons/Button/variants/ButtonLink'; -import { ButtonLinkProps } from '../../../component-library/components/Buttons/Button/variants/ButtonLink/ButtonLink.types'; +import { + TouchableOpacity, + StyleSheet, + ViewStyle, + StyleProp, + TouchableOpacityProps, +} from 'react-native'; import { useSnapInterfaceContext } from '../SnapInterfaceContext'; -import Text, { - TextColor, - TextVariant, -} from '../../../component-library/components/Texts/Text'; import AnimatedLottieView from 'lottie-react-native'; +import { useTheme } from '../../../util/theme'; -export interface SnapUIButtonProps { +export interface SnapUIButtonProps extends TouchableOpacityProps { name?: string; loading?: boolean; type?: ButtonType; form?: string; - variant: keyof typeof COLORS; - textVariant?: TextVariant; + style?: StyleProp; + disabled?: boolean; + onPress?: () => void; + children?: React.ReactNode; + testID?: string; } -const COLORS = { - primary: TextColor.Info, - destructive: TextColor.Error, - disabled: TextColor.Muted, -}; - -export const SnapUIButton: FunctionComponent< - SnapUIButtonProps & ButtonLinkProps -> = ({ +export const SnapUIButton: FunctionComponent = ({ name, children, form, type = ButtonType.Button, - variant = 'primary', disabled = false, loading = false, - textVariant, + style, + onPress, + testID, ...props }) => { const { handleEvent } = useSnapInterfaceContext(); + const { colors } = useTheme(); const handlePress = () => { + onPress?.(); + handleEvent({ event: UserInputEventType.ButtonClickEvent, name, @@ -54,34 +55,54 @@ export const SnapUIButton: FunctionComponent< } }; - const overriddenVariant = disabled ? 'disabled' : variant; + const styles = StyleSheet.create({ + button: { + backgroundColor: colors.background.default, + borderRadius: 8, + paddingVertical: 8, + flexDirection: 'row', + alignItems: 'center', + ...(style as ViewStyle), + }, + loadingAnimation: { + width: 24, + height: 24, + }, + }); - const color = COLORS[overriddenVariant as keyof typeof COLORS]; + if (loading) { + return ( + + + + ); + } return ( - - ) : ( - - {children} - - ) - } - /> + accessible + accessibilityRole="button" + accessibilityLabel={typeof children === 'string' ? children : name} + testID={testID} + {...props} + > + {children} + ); }; diff --git a/app/components/Snaps/SnapUIRenderer/__snapshots__/SnapUIRenderer.test.tsx.snap b/app/components/Snaps/SnapUIRenderer/__snapshots__/SnapUIRenderer.test.tsx.snap index b273123faef1..436f863441ad 100644 --- a/app/components/Snaps/SnapUIRenderer/__snapshots__/SnapUIRenderer.test.tsx.snap +++ b/app/components/Snaps/SnapUIRenderer/__snapshots__/SnapUIRenderer.test.tsx.snap @@ -1182,7 +1182,12 @@ exports[`SnapUIRenderer renders complex nested components 1`] = ` } > Foo @@ -1411,7 +1416,12 @@ exports[`SnapUIRenderer renders footers 1`] = ` } > Foo @@ -1629,48 +1639,36 @@ exports[`SnapUIRenderer supports fields with multiple components 1`] = ` } testID="textfield-endacccessory" > - - - Submit - + Submit - + @@ -2671,47 +2669,35 @@ exports[`SnapUIRenderer supports forms with fields 1`] = ` - - - Submit - + Submit - + @@ -3058,7 +3044,12 @@ exports[`SnapUIRenderer supports the onCancel prop 1`] = ` } > Foo diff --git a/app/components/Snaps/SnapUIRenderer/components/button.test.ts b/app/components/Snaps/SnapUIRenderer/components/button.test.ts new file mode 100644 index 000000000000..12c0ab8b0e62 --- /dev/null +++ b/app/components/Snaps/SnapUIRenderer/components/button.test.ts @@ -0,0 +1,383 @@ +import { ButtonType } from '@metamask/snaps-sdk'; +import { button } from './button'; +import { mockTheme } from '../../../../util/theme'; +import { + TextColor, + TextVariant, +} from '../../../../component-library/components/Texts/Text'; +import { UIComponent } from './types'; + +interface TestButtonElement { + type: string; + props: { + variant: string; + disabled: boolean; + loading: boolean; + children?: (string | MockUIComponent)[]; + [key: string]: unknown; + }; + key: string; + children: (string | MockUIComponent)[]; +} + +interface MockUIComponent { + element: string; + props?: Record; + children?: (string | MockUIComponent)[]; +} + +jest.mock('@metamask/snaps-utils', () => ({ + getJsxChildren: jest.fn((elem) => elem.children || []), +})); + +jest.mock('../utils', () => ({ + mapTextToTemplate: jest.fn((children) => + children.map((child: string | MockUIComponent) => { + if (typeof child === 'string') { + return child; + } + return { + element: child.element || 'Text', + props: child.props || {}, + children: child.children || [], + }; + }), + ), +})); + +describe('button component factory', () => { + const mockT = (key: string) => key; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const createButtonElement = ( + props: Record = {}, + children: (string | MockUIComponent)[] = ['Button Text'], + ): TestButtonElement => ({ + type: 'Button', + props: { + variant: 'primary', + disabled: false, + loading: false, + children, + ...props, + }, + key: 'test-key', + children, + }); + + it('creates a basic button with text children', () => { + const buttonElement = createButtonElement(); + + const result = button({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + element: buttonElement as any, + theme: mockTheme, + form: undefined, + map: {}, + t: mockT, + }); + + expect(result.element).toBe('SnapUIButton'); + expect(result.props?.variant).toBe('primary'); + expect(result.props?.disabled).toBe(false); + expect(result.props?.loading).toBe(false); + + const children = result.children as UIComponent[]; + expect(children).toHaveLength(1); + + const textChild = children[0] as UIComponent; + expect(textChild.element).toBe('Text'); + expect(textChild.props?.color).toBe(TextColor.Info); + + const styleObj = textChild.props?.style as Record; + expect(styleObj?.color).toBe(mockTheme.colors.primary.default); + }); + + it('sets button props correctly based on input', () => { + const buttonElement = createButtonElement({ + variant: 'destructive', + disabled: true, + loading: true, + name: 'test-button', + type: ButtonType.Submit, + form: 'test-form', + }); + + const result = button({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + element: buttonElement as any, + theme: mockTheme, + form: 'parent-form', + map: {}, + t: mockT, + }); + + expect(result.props?.variant).toBe('destructive'); + expect(result.props?.disabled).toBe(true); + expect(result.props?.loading).toBe(true); + expect(result.props?.name).toBe('test-button'); + expect(result.props?.type).toBe(ButtonType.Submit); + expect(result.props?.form).toBe('test-form'); + }); + + it('uses parent form when button form is not provided', () => { + const buttonElement = createButtonElement(); + + const result = button({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + element: buttonElement as any, + theme: mockTheme, + form: 'parent-form', + map: {}, + t: mockT, + }); + + expect(result.props?.form).toBe('parent-form'); + }); + + it('handles size property and sets text variant correctly', () => { + const smallButtonElement = createButtonElement({ size: 'sm' }); + const mediumButtonElement = createButtonElement({ size: 'md' }); + + const smallResult = button({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + element: smallButtonElement as any, + theme: mockTheme, + form: undefined, + map: {}, + t: mockT, + }); + + const mediumResult = button({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + element: mediumButtonElement as any, + theme: mockTheme, + form: undefined, + map: {}, + t: mockT, + }); + + expect(smallResult.props?.textVariant).toBe(TextVariant.BodySMMedium); + expect(mediumResult.props?.textVariant).toBe(TextVariant.BodyMDMedium); + }); + + it('applies disabled styling correctly', () => { + const buttonElement = createButtonElement({ disabled: true }); + + const result = button({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + element: buttonElement as any, + theme: mockTheme, + form: undefined, + map: {}, + t: mockT, + }); + + const textChild = (result.children as UIComponent[])[0] as UIComponent; + expect(textChild.props?.color).toBe(TextColor.Muted); + + const styleObj = textChild.props?.style as Record; + expect(styleObj?.color).toBe(mockTheme.colors.text.muted); + }); + + it('applies destructive variant styling correctly', () => { + const buttonElement = createButtonElement({ variant: 'destructive' }); + + const result = button({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + element: buttonElement as any, + theme: mockTheme, + form: undefined, + map: {}, + t: mockT, + }); + + const textChild = (result.children as UIComponent[])[0] as UIComponent; + expect(textChild.props?.color).toBe(TextColor.Error); + + const styleObj = textChild.props?.style as Record; + expect(styleObj?.color).toBe(mockTheme.colors.error.default); + }); + + it('styles complex children recursively', () => { + const nestedTextComponent: MockUIComponent = { + element: 'Text', + props: {}, + children: ['Nested Text'], + }; + + const complexTextComponent: MockUIComponent = { + element: 'Text', + props: {}, + children: ['Parent Text', nestedTextComponent], + }; + + const buttonElement = createButtonElement({}, [complexTextComponent]); + + const result = button({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + element: buttonElement as any, + theme: mockTheme, + form: undefined, + map: {}, + t: mockT, + }); + + const parent = (result.children as UIComponent[])[0] as UIComponent; + expect(parent.element).toBe('Text'); + expect(parent.props?.color).toBe(TextColor.Info); + + const styleObj = parent.props?.style as Record; + expect(styleObj?.color).toBe(mockTheme.colors.primary.default); + }); + + it('styles components with sections correctly', () => { + const sectionContainer: MockUIComponent = { + element: 'SectionContainer', + props: { + sections: [ + { + element: 'RNText', + props: {}, + }, + { + element: 'SnapUIIcon', + props: { + name: 'test-icon', + size: 24, + }, + }, + ], + }, + }; + + const buttonElement = createButtonElement({}, [sectionContainer]); + + const result = button({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + element: buttonElement as any, + theme: mockTheme, + form: undefined, + map: {}, + t: mockT, + }); + + const container = (result.children as UIComponent[])[0] as UIComponent; + expect(container.element).toBe('SectionContainer'); + + const sections = container.props?.sections as { + element: string; + props: Record; + }[]; + expect(sections).toHaveLength(2); + + const textSection = sections[0]; + expect(textSection.element).toBe('RNText'); + const textSectionStyle = textSection.props.style as Record; + expect(textSectionStyle.color).toBe(mockTheme.colors.primary.default); + + const iconSection = sections[1]; + expect(iconSection.element).toBe('SnapUIIcon'); + expect(iconSection.props.color).toBe(mockTheme.colors.primary.default); + }); + + it('handles icon components correctly', () => { + const iconComponent: MockUIComponent = { + element: 'SnapUIIcon', + props: { + name: 'test-icon', + size: 24, + }, + }; + + const buttonElement = createButtonElement({}, [iconComponent]); + + const result = button({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + element: buttonElement as any, + theme: mockTheme, + form: undefined, + map: {}, + t: mockT, + }); + + const icon = (result.children as UIComponent[])[0] as UIComponent; + expect(icon.element).toBe('SnapUIIcon'); + expect(icon.props?.color).toBe(mockTheme.colors.primary.default); + }); + + it('handles mixed text and icon children', () => { + const iconComponent: MockUIComponent = { + element: 'SnapUIIcon', + props: { + name: 'test-icon', + size: 24, + }, + }; + + const buttonElement = createButtonElement({}, [ + 'Text Content', + iconComponent, + ]); + + const result = button({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + element: buttonElement as any, + theme: mockTheme, + form: undefined, + map: {}, + t: mockT, + }); + + const children = result.children as UIComponent[]; + expect(children).toHaveLength(2); + + const textChild = children[0] as UIComponent; + expect(textChild.element).toBe('Text'); + expect(textChild.props?.color).toBe(TextColor.Info); + + const iconChild = children[1] as UIComponent; + expect(iconChild.element).toBe('SnapUIIcon'); + expect(iconChild.props?.color).toBe(mockTheme.colors.primary.default); + }); + + it('applies the same color to both text and icons', () => { + const textComponent: MockUIComponent = { + element: 'Text', + props: {}, + children: ['Text'], + }; + + const iconComponent: MockUIComponent = { + element: 'SnapUIIcon', + props: { + name: 'test-icon', + size: 24, + }, + }; + + const buttonElement = createButtonElement({}, [ + textComponent, + iconComponent, + ]); + + const result = button({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + element: buttonElement as any, + theme: mockTheme, + form: undefined, + map: {}, + t: mockT, + }); + + const children = result.children as UIComponent[]; + expect(children).toHaveLength(2); + + const textChild = children[0] as UIComponent; + const iconChild = children[1] as UIComponent; + + const textStyle = textChild.props?.style as Record; + expect(textStyle?.color).toBe(mockTheme.colors.primary.default); + expect(iconChild.props?.color).toBe(mockTheme.colors.primary.default); + }); +}); diff --git a/app/components/Snaps/SnapUIRenderer/components/button.ts b/app/components/Snaps/SnapUIRenderer/components/button.ts index ce8093e4f226..7f46d61d2adc 100644 --- a/app/components/Snaps/SnapUIRenderer/components/button.ts +++ b/app/components/Snaps/SnapUIRenderer/components/button.ts @@ -6,8 +6,13 @@ import { import { getJsxChildren } from '@metamask/snaps-utils'; import { NonEmptyArray } from '@metamask/utils'; import { mapTextToTemplate } from '../utils'; -import { UIComponentFactory } from './types'; -import { TextVariant } from '../../../../component-library/components/Texts/Text'; +import { UIComponent, UIComponentFactory } from './types'; +import { + TextColor, + TextVariant, +} from '../../../../component-library/components/Texts/Text'; +import { Theme } from '@metamask/design-tokens'; +import { StyleProp, TextStyle } from 'react-native'; interface ButtonElementProps extends ButtonElement { props: ButtonProps & { @@ -15,27 +20,201 @@ interface ButtonElementProps extends ButtonElement { size?: 'sm' | 'md'; }; } +interface CommonProps { + style?: StyleProp; + color?: TextColor | string; + variant?: TextVariant; + [key: string]: unknown; +} + +interface TextComponentProps extends CommonProps { + color?: TextColor; + variant: TextVariant; +} + +interface IconComponentProps extends CommonProps { + color?: string; + name: string; + size: string | number; +} + +// Section component types +interface SectionComponent { + element: string; + props: CommonProps; +} + +interface TextSectionComponent extends SectionComponent { + element: 'RNText' | 'Text'; + props: TextComponentProps; +} + +interface IconSectionComponent extends SectionComponent { + element: 'SnapUIIcon'; + props: IconComponentProps; +} + +type ProcessedSectionComponent = + | TextSectionComponent + | IconSectionComponent + | SectionComponent; + +interface ComponentWithSections extends UIComponent { + props: { + sections: ProcessedSectionComponent[]; + [key: string]: unknown; + }; +} + +const COLORS = { + primary: TextColor.Info, + destructive: TextColor.Error, + disabled: TextColor.Muted, +}; + +/** + * Applies styling to button content elements based on the button variant + */ +function processButtonContent( + content: NonEmptyArray, + variant: string, + disabled: boolean, + textVariant: TextVariant, + theme: Theme, +): NonEmptyArray { + const overriddenVariant = disabled ? 'disabled' : variant; + const enumColor = COLORS[overriddenVariant as keyof typeof COLORS]; + + const themeColor = + enumColor === TextColor.Info + ? theme.colors.primary.default + : enumColor === TextColor.Error + ? theme.colors.error.default + : theme.colors.text.muted; + + /** + * Apply styles to a component based on its type + */ + function styleComponent(component: UIComponent): UIComponent { + const result: UIComponent = { + ...component, + }; + + if (component.element === 'Text' || component.element === 'RNText') { + result.props = { + ...component.props, + color: enumColor, + style: { + ...((component.props?.style as Record) || {}), + color: themeColor, + }, + }; + } else if (component.element === 'SnapUIIcon') { + result.props = { + ...component.props, + color: themeColor, + }; + } + + if (component.children && Array.isArray(component.children)) { + result.children = component.children + .map((child) => { + if (typeof child === 'string') return child; + if (!child || typeof child !== 'object') return null; + return styleComponent(child as UIComponent); + }) + .filter(Boolean) as NonEmptyArray; + } + + return result; + } + + return content.map((child) => { + if (typeof child === 'string') { + return { + element: 'Text', + props: { + color: enumColor, + variant: textVariant, + style: { color: themeColor }, + }, + children: [child], + }; + } + + if (child.props && 'sections' in child.props) { + const componentWithSections = child as ComponentWithSections; + const modifiedSections = componentWithSections.props.sections.map( + (section) => { + const result = { ...section }; + + if (section.element === 'RNText') { + result.props = { + ...section.props, + style: { + ...((section.props.style as Record) || {}), + color: themeColor, + }, + }; + } else if (section.element === 'SnapUIIcon') { + result.props = { + ...section.props, + color: themeColor, + }; + } + + return result; + }, + ); + + return { + ...componentWithSections, + props: { + ...componentWithSections.props, + sections: modifiedSections, + }, + }; + } + + return styleComponent(child); + }) as NonEmptyArray; +} export const button: UIComponentFactory = ({ element: e, + theme, ...params -}) => ({ - element: 'SnapUIButton', - props: { - type: e.props.type, - // This differs from the extension implementation because we don't have proper form support on RN - form: e.props.form ?? params.form, - variant: e.props.variant, - name: e.props.name, - disabled: e.props.disabled, - loading: e.props.loading ?? false, - textVariant: - e.props.size === 'sm' - ? TextVariant.BodySMMedium - : TextVariant.BodyMDMedium, - }, - children: mapTextToTemplate( +}) => { + const textVariant = + e.props.size === 'sm' ? TextVariant.BodySMMedium : TextVariant.BodyMDMedium; + + const mappedChildren = mapTextToTemplate( getJsxChildren(e) as NonEmptyArray, - params, - ), -}); + { + ...params, + theme, + }, + ); + + const processedChildren = processButtonContent( + mappedChildren, + e.props.variant || 'primary', + !!e.props.disabled, + textVariant, + theme, + ); + + return { + element: 'SnapUIButton', + props: { + type: e.props.type, + form: e.props.form ?? params.form, + variant: e.props.variant || 'primary', + name: e.props.name, + disabled: e.props.disabled, + loading: e.props.loading ?? false, + textVariant, + }, + children: processedChildren, + }; +}; diff --git a/app/components/Snaps/SnapUIRenderer/components/footer.test.ts b/app/components/Snaps/SnapUIRenderer/components/footer.test.ts index 74b3710bddff..c43df2a7f8e9 100644 --- a/app/components/Snaps/SnapUIRenderer/components/footer.test.ts +++ b/app/components/Snaps/SnapUIRenderer/components/footer.test.ts @@ -2,6 +2,7 @@ import { ButtonElement, FooterElement } from '@metamask/snaps-sdk/jsx'; import { footer, DEFAULT_FOOTER } from './footer'; import { mockTheme } from '../../../../util/theme'; import { ButtonVariants } from '../../../../component-library/components/Buttons/Button'; +import { UIComponent } from './types'; describe('footer', () => { const mockT = (value: string) => `translated_${value}`; @@ -46,34 +47,34 @@ describe('footer', () => { theme: mockTheme, }); - expect(result).toEqual({ - ...DEFAULT_FOOTER, - children: [ - { - element: 'SnapUIFooterButton', - key: 'snap-footer-button-0', - props: { - disabled: undefined, - form: undefined, - isSnapAction: true, - loading: false, - name: undefined, - onCancel: undefined, - textVariant: 'sBodyMDMedium', - type: undefined, - variant: 'Primary', - }, - children: [ - { - key: '57fd48ba929aa415dc4c3996c826a75f8686418c77765eb14fad2658efa73d87_1', - element: 'RNText', - children: 'Button', - props: { color: 'inherit' }, - }, - ], - }, - ], + expect(result.element).toBe(DEFAULT_FOOTER.element); + + const children = result.children as UIComponent[]; + expect(children).toBeTruthy(); + expect(Array.isArray(children)).toBe(true); + + const buttonComponent = children[0]; + expect(buttonComponent.element).toBe('SnapUIFooterButton'); + expect(buttonComponent.props).toMatchObject({ + disabled: undefined, + form: undefined, + isSnapAction: true, + loading: false, + name: undefined, + onCancel: undefined, + textVariant: 'sBodyMDMedium', + type: undefined, + variant: 'Primary', }); + + const buttonChildren = buttonComponent.children as UIComponent[]; + expect(buttonChildren).toBeTruthy(); + expect(Array.isArray(buttonChildren)).toBe(true); + + const textComponent = buttonChildren[0]; + expect(textComponent.element).toBe('RNText'); + expect(textComponent.children).toBe('Button'); + expect(textComponent.props?.color).toBe('Info'); }); it('add cancel button when onCancel is provided and only one child', () => { @@ -89,18 +90,42 @@ describe('footer', () => { theme: mockTheme, }); - expect(Array.isArray(result.children)).toBe(true); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((result.children as any[])[0]).toEqual({ - element: 'SnapUIFooterButton', - key: 'default-button', - props: { - isSnapAction: false, - onCancel: mockOnCancel, - variant: 'Secondary', - }, - children: 'translated_template_confirmation.cancel', + const children = result.children as UIComponent[]; + expect(children).toBeTruthy(); + expect(Array.isArray(children)).toBe(true); + + const cancelButton = children[0]; + expect(cancelButton.element).toBe('SnapUIFooterButton'); + expect(cancelButton.props).toMatchObject({ + isSnapAction: false, + onCancel: mockOnCancel, + variant: 'Secondary', }); + + if (typeof cancelButton.children === 'string') { + expect(cancelButton.children).toBe( + 'translated_template_confirmation.cancel', + ); + } else { + const buttonChildren = cancelButton.children as (string | UIComponent)[]; + + if (Array.isArray(buttonChildren) && buttonChildren.length > 0) { + const content = buttonChildren[0]; + + if (typeof content === 'string') { + expect(content).toBe('translated_template_confirmation.cancel'); + } else if ( + content && + typeof content === 'object' && + 'children' in content + ) { + const textContent = content.children; + if (typeof textContent === 'string') { + expect(textContent).toBe('translated_template_confirmation.cancel'); + } + } + } + } }); it('handle multiple buttons with correct variants', () => { @@ -116,19 +141,52 @@ describe('footer', () => { theme: mockTheme, }); - expect(Array.isArray(result.children)).toBe(true); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((result.children as any[])[0].props.variant).toBe( - ButtonVariants.Secondary, - ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((result.children as any[])[1].props.variant).toBe( - ButtonVariants.Primary, - ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((result.children as any[])[0].props.isSnapAction).toBe(true); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((result.children as any[])[1].props.isSnapAction).toBe(true); + const children = result.children as UIComponent[]; + expect(children).toBeTruthy(); + expect(Array.isArray(children)).toBe(true); + expect(children.length).toBeGreaterThanOrEqual(2); + + const rejectButton = children[0]; + expect(rejectButton.props?.variant).toBe(ButtonVariants.Secondary); + expect(rejectButton.props?.isSnapAction).toBe(true); + + const confirmButton = children[1]; + expect(confirmButton.props?.variant).toBe(ButtonVariants.Primary); + expect(confirmButton.props?.isSnapAction).toBe(true); + + const rejectButtonChildren = rejectButton.children as ( + | string + | UIComponent + )[]; + if ( + Array.isArray(rejectButtonChildren) && + rejectButtonChildren.length > 0 + ) { + const textContent = rejectButtonChildren[0]; + if (typeof textContent === 'object' && textContent.element === 'RNText') { + const text = textContent.children; + if (typeof text === 'string') { + expect(text).toBe('Reject'); + } + } + } + + const confirmButtonChildren = confirmButton.children as ( + | string + | UIComponent + )[]; + if ( + Array.isArray(confirmButtonChildren) && + confirmButtonChildren.length > 0 + ) { + const textContent = confirmButtonChildren[0]; + if (typeof textContent === 'object' && textContent.element === 'RNText') { + const text = textContent.children; + if (typeof text === 'string') { + expect(text).toBe('Confirm'); + } + } + } }); it('use index as key when button name is not provided', () => { @@ -143,7 +201,11 @@ describe('footer', () => { theme: mockTheme, }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((result.children as any[])[0].key).toBe('snap-footer-button-0'); + const children = result.children as UIComponent[]; + expect(children).toBeTruthy(); + expect(Array.isArray(children)).toBe(true); + + const button = children[0]; + expect(button.key).toBe('snap-footer-button-0'); }); });