From 85d7f8a81d39f83b8cfe627a1820466307bf7d10 Mon Sep 17 00:00:00 2001 From: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com> Date: Wed, 22 May 2024 09:27:33 +0100 Subject: [PATCH] feat: trigger alert modal on confirm button (#24049) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR aims to add a button to review alerts when there are unacknowledged alerts in a confirmation Once the button is clicked the `MultipleAlerModal` is displayed enabling users to review and take action on each alert individually. If all alerts are reviewed a `ConfirmAlertModal` is displayed warning the user and with the possibility to again review all the alerts before confirming it. Create the logic to support the friction modal when all the alerts are reviewed and the confirm button is clicked. **Background** As with the inline alerts alongside the confirmation fields, the confirmation confirm button should also display all the confirmation alerts sequentially in the alert modal. In addition the confirm button should be suitably styled to indicate alerts are present. Based on [figma](https://www.figma.com/file/yUczlH2HbHaHsYijiONKx0/Warnings%2C-Alerts-and-Flags?type=design&node-id=1208-25406&mode=design). [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/24049?quickstart=1) ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/2292 ## **Manual testing steps** Those components are currently not in use and are not available to users. It is part of the new alert system, and we will have a dedicated PR to integrate this component into the new confirmation screens. - Regression for new confirmation screens. ## **Screenshots/Recordings** ### **Before** ### **After** ![image](https://github.com/MetaMask/metamask-extension/assets/45455812/c16c0ded-b450-49f7-b63b-ec238c69ea9c) [frictionModal.webm](https://github.com/MetaMask/metamask-extension/assets/45455812/49badc8d-a90e-4449-81a1-4a744f5da499) ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- Co-authored-by: Matthew Walsh Co-authored-by: George Marshall --- app/_locales/en/messages.json | 15 ++ test/data/mock-state.json | 4 + .../alert-modal/alert-modal.stories.tsx | 27 ++- .../alerts/alert-modal/alert-modal.tsx | 192 ++++++++++++------ .../confirm-alert-modal.stories.tsx | 115 +++++++++++ .../confirm-alert-modal.test.tsx | 94 +++++++++ .../confirm-alert-modal.tsx | 186 +++++++++++++++++ .../alerts/confirm-alert-modal/index.tsx | 1 + .../alerts/inline-alert/inline-alert.tsx | 14 +- .../multiple-alert-modal.stories.tsx | 121 ++++++----- .../multiple-alert-modal.tsx | 4 +- .../app/confirmations/alerts/utils.test.ts | 22 ++ .../app/confirmations/alerts/utils.ts | 22 ++ .../components/confirm/footer/footer.test.tsx | 49 +++++ .../components/confirm/footer/footer.tsx | 87 +++++++- 15 files changed, 801 insertions(+), 152 deletions(-) create mode 100644 ui/components/app/confirmations/alerts/confirm-alert-modal/confirm-alert-modal.stories.tsx create mode 100644 ui/components/app/confirmations/alerts/confirm-alert-modal/confirm-alert-modal.test.tsx create mode 100644 ui/components/app/confirmations/alerts/confirm-alert-modal/confirm-alert-modal.tsx create mode 100644 ui/components/app/confirmations/alerts/confirm-alert-modal/index.tsx create mode 100644 ui/components/app/confirmations/alerts/utils.test.ts create mode 100644 ui/components/app/confirmations/alerts/utils.ts diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index bac2c66e7234..14ba0f25b085 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -352,6 +352,9 @@ "alertModalDetails": { "message": "Alert Details" }, + "alertModalReviewAllAlerts": { + "message": "Review all alerts" + }, "alertSettingsUnconnectedAccount": { "message": "Browsing a website with an unconnected account selected" }, @@ -818,6 +821,15 @@ "confirm": { "message": "Confirm" }, + "confirmAlertModalAcknowledge": { + "message": "I have acknowledged the alerts and still want to proceed" + }, + "confirmAlertModalDetails": { + "message": "If you sign in, a third party known for scams might take all your assets. Please review the alerts before you proceed." + }, + "confirmAlertModalTitle": { + "message": "Your assets may be at risk" + }, "confirmConnectCustodianRedirect": { "message": "We will redirect you to $1 upon clicking continue." }, @@ -4160,6 +4172,9 @@ "revealTheSeedPhrase": { "message": "Reveal seed phrase" }, + "reviewAlerts": { + "message": "Review alerts" + }, "revokeAllTokensTitle": { "message": "Revoke permission to access and transfer all of your $1?", "description": "$1 is the symbol of the token for which the user is revoking approval" diff --git a/test/data/mock-state.json b/test/data/mock-state.json index d4d6555504f8..5bad1276c21f 100644 --- a/test/data/mock-state.json +++ b/test/data/mock-state.json @@ -30,6 +30,10 @@ "customTokenAmount": "10" }, "confirm": {}, + "confirmAlerts": { + "alerts": [], + "confirmed": [] + }, "confirmTransaction": { "txData": { "txParams": { diff --git a/ui/components/app/confirmations/alerts/alert-modal/alert-modal.stories.tsx b/ui/components/app/confirmations/alerts/alert-modal/alert-modal.stories.tsx index 180c1e7e2b5e..df79b51cbca0 100644 --- a/ui/components/app/confirmations/alerts/alert-modal/alert-modal.stories.tsx +++ b/ui/components/app/confirmations/alerts/alert-modal/alert-modal.stories.tsx @@ -1,10 +1,12 @@ import React from 'react'; import { AlertModal } from './alert-modal'; import { Severity } from '../../../../../helpers/constants/design-system'; -import { Meta } from '@storybook/react'; +import { Meta, StoryFn } from '@storybook/react'; import configureStore from '../../../../../store/store'; import { Provider } from 'react-redux'; import { Alert } from '../../../../../ducks/confirm-alerts/confirm-alerts'; +import { useArgs } from '@storybook/client-api'; +import { Box, Button } from '../../../../component-library'; const alertsMock: Alert[] = [ { key: 'from', severity: Severity.Danger, message: 'Description of what may happen if this alert was ignored', reason: 'Reason for the alert 1', alertDetails: ['We found the contract Petname 0xEqT3b9773b1763efa556f55ccbeb20441962d82x to be malicious', @@ -47,8 +49,27 @@ export default { decorators: [(story) => {story()}], } as Meta; -export const DefaultStory = (args) => { - return ; +export const DefaultStory: StoryFn = (args) => { + const [{ isOpen }, updateArgs] = useArgs(); + const handleOnClick = () => { + updateArgs({ isOpen: true }); + }; + const handleOnClose = () => { + updateArgs({ isOpen: false }); + }; + return ( + + {isOpen && ( + + )} + + + ); }; DefaultStory.storyName = 'Critical Alert'; diff --git a/ui/components/app/confirmations/alerts/alert-modal/alert-modal.tsx b/ui/components/app/confirmations/alerts/alert-modal/alert-modal.tsx index 086eb7e3411b..bb03914c9642 100644 --- a/ui/components/app/confirmations/alerts/alert-modal/alert-modal.tsx +++ b/ui/components/app/confirmations/alerts/alert-modal/alert-modal.tsx @@ -22,6 +22,7 @@ import { BlockSize, BorderRadius, Display, + FlexDirection, IconColor, Severity, TextAlign, @@ -33,22 +34,46 @@ import useAlerts from '../../../../../hooks/useAlerts'; import { Alert } from '../../../../../ducks/confirm-alerts/confirm-alerts'; export type AlertModalProps = { - /** The unique key representing the specific alert field. */ + /** + * The unique key representing the specific alert field. + */ alertKey: string; + /** + * The custom button component for acknowledging the alert. + */ + customAcknowledgeButton?: React.ReactNode; + /** + * The custom checkbox component for acknowledging the alert. + */ + customAcknowledgeCheckbox?: React.ReactNode; + /** + * The custom details component for the alert. + */ + customAlertDetails?: React.ReactNode; + /** + * The custom title for the alert. + */ + customAlertTitle?: string; /** * The start (left) content area of ModalHeader. - * It override `startAccessory` of ModalHeaderDefault and by default no content is present. + * It overrides `startAccessory` of ModalHeaderDefault and by default no content is present. */ headerStartAccessory?: React.ReactNode; - /** The owner ID of the relevant alert from the `confirmAlerts` reducer. */ + /** + * The owner ID of the relevant alert from the `confirmAlerts` reducer. + */ ownerId: string; - /** The function invoked when the user acknowledges the alert. */ + /** + * The function invoked when the user acknowledges the alert. + */ onAcknowledgeClick: () => void; - /** The function to be executed when the modal needs to be closed. */ + /** + * The function to be executed when the modal needs to be closed. + */ onClose: () => void; }; -function getSeverityStyle(severity: Severity) { +export function getSeverityStyle(severity?: Severity) { switch (severity) { case Severity.Warning: return { @@ -68,7 +93,13 @@ function getSeverityStyle(severity: Severity) { } } -function AlertHeader({ selectedAlert }: { selectedAlert: Alert }) { +function AlertHeader({ + selectedAlert, + customAlertTitle, +}: { + selectedAlert: Alert; + customAlertTitle?: string; +}) { const t = useI18nContext(); const severityStyle = getSeverityStyle(selectedAlert.severity); return ( @@ -95,58 +126,74 @@ function AlertHeader({ selectedAlert }: { selectedAlert: Alert }) { marginTop={3} marginBottom={4} > - {selectedAlert.reason ?? t('alert')} + {customAlertTitle ?? selectedAlert.reason ?? t('alert')} ); } -function AlertDetails({ selectedAlert }: { selectedAlert: Alert }) { +function AlertDetails({ + selectedAlert, + customAlertDetails, +}: { + selectedAlert: Alert; + customAlertDetails?: React.ReactNode; +}) { const t = useI18nContext(); const severityStyle = getSeverityStyle(selectedAlert.severity); return ( - - {selectedAlert.message} - {selectedAlert.alertDetails?.length ? ( - - {t('alertModalDetails')} - - ) : null} - - - {selectedAlert.alertDetails?.map((detail, index) => ( - - {detail} + <> + + {customAlertDetails ?? ( + + {selectedAlert.message} + {selectedAlert.alertDetails?.length ? ( + + {t('alertModalDetails')} + + ) : null} + + {selectedAlert.alertDetails?.map((detail, index) => ( + + {detail} + + ))} + - ))} + )} - + ); } -function AcknowledgeCheckbox({ +export function AcknowledgeCheckboxBase({ selectedAlert, - setAlertConfirmed, + onCheckboxClick, isConfirmed, + label, }: { selectedAlert: Alert; - setAlertConfirmed: (alertKey: string, isConfirmed: boolean) => void; + onCheckboxClick: () => void; isConfirmed: boolean; + label?: string; }) { const t = useI18nContext(); const severityStyle = getSeverityStyle(selectedAlert.severity); - const handleCheckboxClick = () => { - return setAlertConfirmed(selectedAlert.key, !isConfirmed); - }; return ( @@ -179,18 +226,16 @@ function AcknowledgeButton({ const t = useI18nContext(); return ( - <> - - + ); } @@ -200,6 +245,10 @@ export function AlertModal({ alertKey, onClose, headerStartAccessory, + customAlertTitle, + customAlertDetails, + customAcknowledgeCheckbox, + customAcknowledgeButton, }: AlertModalProps) { const { alerts, isAlertConfirmed, setAlertConfirmed } = useAlerts(ownerId); @@ -214,6 +263,10 @@ export function AlertModal({ } const isConfirmed = isAlertConfirmed(selectedAlert.key); + const handleCheckboxClick = useCallback(() => { + return setAlertConfirmed(selectedAlert.key, !isConfirmed); + }, [isConfirmed, selectedAlert.key]); + return ( @@ -225,20 +278,37 @@ export function AlertModal({ borderWidth={1} display={headerStartAccessory ? Display.InlineFlex : Display.Block} /> - + - - + {customAcknowledgeCheckbox ?? ( + + )} - + + {customAcknowledgeButton ?? ( + + )} + diff --git a/ui/components/app/confirmations/alerts/confirm-alert-modal/confirm-alert-modal.stories.tsx b/ui/components/app/confirmations/alerts/confirm-alert-modal/confirm-alert-modal.stories.tsx new file mode 100644 index 000000000000..9e7723660a96 --- /dev/null +++ b/ui/components/app/confirmations/alerts/confirm-alert-modal/confirm-alert-modal.stories.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { ConfirmAlertModal } from './confirm-alert-modal'; +import { Severity } from '../../../../../helpers/constants/design-system'; +import { Meta, StoryFn } from '@storybook/react'; +import configureStore from '../../../../../store/store'; +import { Provider } from 'react-redux'; +import { Alert } from '../../../../../ducks/confirm-alerts/confirm-alerts'; +import { Box, Button } from '../../../../component-library'; +import { useArgs } from '@storybook/client-api'; + +const ALERTS_MOCK: Alert[] = [ + { + key: 'from', + severity: Severity.Danger, + message: 'Description of what may happen if this alert was ignored', + reason: 'Reason for the alert 1', + alertDetails: [ + 'We found the contract Petname 0xEqT3b9773b1763efa556f55ccbeb20441962d82x to be malicious', + 'Operator is an externally owned account (EOA) ', + 'Operator is untrusted according to previous activity', + ], + }, + { + key: 'data', + severity: Severity.Warning, + message: 'Alert 2', + alertDetails: ['detail 1 warning', 'detail 2 warning'], + }, + { + key: 'contract', + severity: Severity.Info, + message: 'Alert Info', + alertDetails: ['detail 1 info', 'detail info'], + }, +]; +const OWNER_ID_MOCK = '123'; +const storeMock = configureStore({ + confirmAlerts: { + alerts: { [OWNER_ID_MOCK]: ALERTS_MOCK }, + confirmed: { + [OWNER_ID_MOCK]: { from: false, data: false, contract: false }, + }, + }, +}); + +export default { + title: 'Confirmations/Components/Alerts/ConfirmAlertModal', + component: ConfirmAlertModal, + argTypes: { + alertKey: { + control: 'text', + description: 'The unique key identifying the specific alert.', + }, + onAcknowledgeClick: { + action: 'acknowledged', + description: + 'Callback function invoked when the acknowledge action is triggered.', + }, + onClose: { + action: 'closed', + description: + 'Callback function invoked when the modal is requested to close.', + }, + ownerId: { + control: 'text', + description: + 'The owner ID of the relevant alert from the `confirmAlerts` reducer.', + }, + onAlertLinkClick: { + action: 'alertLinkClicked', + description: 'Callback function called when the alert link is clicked.', + }, + onCancel: { + action: 'cancelled', + description: + 'Callback function called when the cancel button is clicked.', + }, + onSubmit: { + action: 'submitted', + description: + 'Callback function called when the submit button is clicked.', + }, + }, + args: { + onAcknowledgeClick: () => {}, + onClose: () => {}, + ownerId: OWNER_ID_MOCK, + }, + decorators: [(story) => {story()}], +} as Meta; + +export const TemplateStory: StoryFn = (args) => { + const [{ isOpen }, updateArgs] = useArgs(); + const handleOnClick = () => { + updateArgs({ isOpen: true }); + }; + const handleOnClose = () => { + updateArgs({ isOpen: false }); + }; + return ( + + {isOpen && ( + + )} + + + ); +}; +TemplateStory.storyName = 'Confirm Critical Alert Modal'; diff --git a/ui/components/app/confirmations/alerts/confirm-alert-modal/confirm-alert-modal.test.tsx b/ui/components/app/confirmations/alerts/confirm-alert-modal/confirm-alert-modal.test.tsx new file mode 100644 index 000000000000..2bed8323b453 --- /dev/null +++ b/ui/components/app/confirmations/alerts/confirm-alert-modal/confirm-alert-modal.test.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { fireEvent } from '@testing-library/react'; +import { Severity } from '../../../../../helpers/constants/design-system'; +import { renderWithProvider } from '../../../../../../test/lib/render-helpers'; +import { + ConfirmAlertModalProps, + ConfirmAlertModal, +} from './confirm-alert-modal'; + +describe('ConfirmAlertModal', () => { + const OWNER_ID_MOCK = '123'; + const FROM_ALERT_KEY_MOCK = 'from'; + const onCloseMock = jest.fn(); + const onCancelMock = jest.fn(); + const onSubmitMock = jest.fn(); + const alertsMock = [ + { + key: FROM_ALERT_KEY_MOCK, + severity: Severity.Warning, + message: 'Alert 1', + reason: 'Reason 1', + alertDetails: ['Detail 1', 'Detail 2'], + }, + { key: 'data', severity: Severity.Danger, message: 'Alert 2' }, + ]; + + const STATE_MOCK = { + confirmAlerts: { + alerts: { [OWNER_ID_MOCK]: alertsMock }, + confirmed: { + [OWNER_ID_MOCK]: { [FROM_ALERT_KEY_MOCK]: false, data: false }, + }, + }, + }; + const mockStore = configureMockStore([])(STATE_MOCK); + + const defaultProps: ConfirmAlertModalProps = { + ownerId: OWNER_ID_MOCK, + alertKey: FROM_ALERT_KEY_MOCK, + onClose: onCloseMock, + onCancel: onCancelMock, + onSubmit: onSubmitMock, + }; + + it('renders the confirm alert modal', () => { + const { getByText } = renderWithProvider( + , + mockStore, + ); + + expect(getByText('Your assets may be at risk')).toBeInTheDocument(); + }); + + it('disables submit button when confirm modal is not acknowledged', () => { + const { getByTestId } = renderWithProvider( + , + mockStore, + ); + + expect(getByTestId('confirm-alert-modal-submit-button')).toBeDisabled(); + }); + + it('calls onCancel when the button is clicked', () => { + const { getByTestId } = renderWithProvider( + , + mockStore, + ); + + fireEvent.click(getByTestId('confirm-alert-modal-cancel-button')); + expect(onCancelMock).toHaveBeenCalledTimes(1); + }); + + it('calls onSubmit when the button is clicked', () => { + const { getByTestId } = renderWithProvider( + , + mockStore, + ); + + fireEvent.click(getByTestId('alert-modal-acknowledge-checkbox')); + fireEvent.click(getByTestId('confirm-alert-modal-submit-button')); + expect(onSubmitMock).toHaveBeenCalledTimes(1); + }); + + it('calls open multiple alert modal when review alerts link is clicked', () => { + const { getByTestId } = renderWithProvider( + , + mockStore, + ); + + fireEvent.click(getByTestId('confirm-alert-modal-review-all-alerts')); + expect(getByTestId('alert-modal-button')).toBeInTheDocument(); + }); +}); diff --git a/ui/components/app/confirmations/alerts/confirm-alert-modal/confirm-alert-modal.tsx b/ui/components/app/confirmations/alerts/confirm-alert-modal/confirm-alert-modal.tsx new file mode 100644 index 000000000000..51126238c429 --- /dev/null +++ b/ui/components/app/confirmations/alerts/confirm-alert-modal/confirm-alert-modal.tsx @@ -0,0 +1,186 @@ +import React, { useCallback, useState } from 'react'; +import { + Box, + Button, + ButtonLink, + ButtonLinkSize, + ButtonSize, + ButtonVariant, + Icon, + IconName, + IconSize, + Text, +} from '../../../../component-library'; +import { + AlignItems, + TextAlign, + TextVariant, +} from '../../../../../helpers/constants/design-system'; +import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import useAlerts from '../../../../../hooks/useAlerts'; +import { AlertModal } from '../alert-modal'; +import { AcknowledgeCheckboxBase } from '../alert-modal/alert-modal'; +import { MultipleAlertModal } from '../multiple-alert-modal'; + +export type ConfirmAlertModalProps = { + /** The unique key representing the specific alert field. */ + alertKey: string; + /** Callback function that is called when the cancel button is clicked. */ + onCancel: () => void; + /** The function to be executed when the modal needs to be closed. */ + onClose: () => void; + /** Callback function that is called when the submit button is clicked. */ + onSubmit: () => void; + /** The owner ID of the relevant alert from the `confirmAlerts` reducer. */ + ownerId: string; +}; + +function ConfirmButtons({ + onCancel, + onSubmit, + isConfirmed, +}: { + onCancel: () => void; + onSubmit: () => void; + isConfirmed: boolean; +}) { + const t = useI18nContext(); + return ( + <> + + + + ); +} + +function ConfirmDetails({ + onAlertLinkClick, +}: { + onAlertLinkClick?: () => void; +}) { + const t = useI18nContext(); + return ( + <> + + + {t('confirmAlertModalDetails')} + + + + {t('alertModalReviewAllAlerts')} + + + + ); +} + +export function ConfirmAlertModal({ + alertKey, + onCancel, + onClose, + onSubmit, + ownerId, +}: ConfirmAlertModalProps) { + const t = useI18nContext(); + const { alerts, isAlertConfirmed } = useAlerts(ownerId); + + const unconfirmedAlerts = alerts.filter( + (alert) => alert.field && !isAlertConfirmed(alert.key), + ); + const selectedAlert = alerts.find((alert) => alert.key === alertKey); + const hasUnconfirmedAlerts = unconfirmedAlerts.length > 0; + + const [confirmCheckbox, setConfirmCheckbox] = useState(false); + // if there are multiple alerts, show the multiple alert modal + const [multipleAlertModalVisible, setMultipleAlertModalVisible] = + useState(hasUnconfirmedAlerts); + + const handleCloseMultipleAlertModal = useCallback(() => { + setMultipleAlertModalVisible(false); + }, []); + + const handleOpenMultipleAlertModal = useCallback(() => { + setMultipleAlertModalVisible(true); + }, []); + + const handleConfirmCheckbox = useCallback(() => { + setConfirmCheckbox(!confirmCheckbox); + }, [confirmCheckbox, selectedAlert]); + + if (!selectedAlert) { + return null; + } + + if (multipleAlertModalVisible) { + return ( + + ); + } + + return ( + + } + customAcknowledgeCheckbox={ + + } + customAcknowledgeButton={ + + } + /> + ); +} diff --git a/ui/components/app/confirmations/alerts/confirm-alert-modal/index.tsx b/ui/components/app/confirmations/alerts/confirm-alert-modal/index.tsx new file mode 100644 index 000000000000..688d056d4c67 --- /dev/null +++ b/ui/components/app/confirmations/alerts/confirm-alert-modal/index.tsx @@ -0,0 +1 @@ +export { ConfirmAlertModal } from './confirm-alert-modal'; diff --git a/ui/components/app/confirmations/alerts/inline-alert/inline-alert.tsx b/ui/components/app/confirmations/alerts/inline-alert/inline-alert.tsx index 28b41a0f7060..9da5372a8509 100644 --- a/ui/components/app/confirmations/alerts/inline-alert/inline-alert.tsx +++ b/ui/components/app/confirmations/alerts/inline-alert/inline-alert.tsx @@ -9,7 +9,6 @@ import { } from '../../../../component-library'; import { AlignItems, - BackgroundColor, BorderRadius, Display, Severity, @@ -17,6 +16,7 @@ import { TextVariant, } from '../../../../../helpers/constants/design-system'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import { getSeverityBackground } from '../utils'; export type InlineAlertProps = { /** The onClick handler for the inline alerts */ @@ -27,18 +27,6 @@ export type InlineAlertProps = { style?: React.CSSProperties; }; -function getSeverityBackground(severity: Severity): BackgroundColor { - switch (severity) { - case Severity.Danger: - return BackgroundColor.errorMuted; - case Severity.Warning: - return BackgroundColor.warningMuted; - // Defaults to Severity.Info - default: - return BackgroundColor.primaryMuted; - } -} - export default function InlineAlert({ onClick, severity = Severity.Info, diff --git a/ui/components/app/confirmations/alerts/multiple-alert-modal/multiple-alert-modal.stories.tsx b/ui/components/app/confirmations/alerts/multiple-alert-modal/multiple-alert-modal.stories.tsx index c8dd56b98d91..b3108b7d6832 100644 --- a/ui/components/app/confirmations/alerts/multiple-alert-modal/multiple-alert-modal.stories.tsx +++ b/ui/components/app/confirmations/alerts/multiple-alert-modal/multiple-alert-modal.stories.tsx @@ -1,23 +1,47 @@ import React from 'react'; import { MultipleAlertModal } from './multiple-alert-modal'; import { Severity } from '../../../../../helpers/constants/design-system'; -import { Meta } from '@storybook/react'; +import { Meta, StoryFn } from '@storybook/react'; import configureStore from '../../../../../store/store'; import { Provider } from 'react-redux'; import { Alert } from '../../../../../ducks/confirm-alerts/confirm-alerts'; +import { useArgs } from '@storybook/client-api'; +import { Box, Button } from '../../../../component-library'; const ALERTS_MOCK: Alert[] = [ - { key: 'from', severity: Severity.Danger, message: 'Description of what may happen if this alert was ignored', reason: 'Reason for the alert 1', alertDetails: ['We found the contract Petname 0xEqT3b9773b1763efa556f55ccbeb20441962d82x to be malicious', - 'Operator is an externally owned account (EOA) ', - 'Operator is untrusted according to previous activity',]}, - { key: 'data', severity: Severity.Warning, message: 'Alert 2', alertDetails:['detail 1 warning', 'detail 2 warning'] }, - { key: 'contract', severity: Severity.Info, message: 'Alert Info', alertDetails:['detail 1 info', 'detail info'] }, + { + key: 'from', + severity: Severity.Danger, + message: 'Description of what may happen if this alert was ignored', + reason: 'Reason for the alert 1', + alertDetails: [ + 'We found the contract Petname 0xEqT3b9773b1763efa556f55ccbeb20441962d82x to be malicious', + 'Operator is an externally owned account (EOA) ', + 'Operator is untrusted according to previous activity', + ], + }, + { + key: 'data', + severity: Severity.Warning, + message: 'Alert 2', + alertDetails: ['detail 1 warning', 'detail 2 warning'], + }, + { + key: 'contract', + severity: Severity.Info, + message: 'Alert Info', + alertDetails: ['detail 1 info', 'detail info'], + }, ]; const OWNER_ID_MOCK = '123'; -const storeMock = configureStore({ confirmAlerts: { - alerts: {[OWNER_ID_MOCK]: ALERTS_MOCK}, - confirmed: {[OWNER_ID_MOCK]: {'from': false, 'data': false, 'contract': false}}, - } }); +const storeMock = configureStore({ + confirmAlerts: { + alerts: { [OWNER_ID_MOCK]: ALERTS_MOCK }, + confirmed: { + [OWNER_ID_MOCK]: { from: false, data: false, contract: false }, + }, + }, +}); export default { title: 'Confirmations/Components/Alerts/MultipleAlertModal', @@ -33,7 +57,8 @@ export default { }, onClose: { action: 'onClick', - description: 'The function to be executed when the modal needs to be closed.', + description: + 'The function to be executed when the modal needs to be closed.', }, ownerId: { control: 'text', @@ -41,62 +66,34 @@ export default { }, }, args: { - onAcknowledgeClick: () => {}, - onClose: () => {}, ownerId: OWNER_ID_MOCK, }, decorators: [(story) => {story()}], } as Meta; -export const TemplateStory = (args) => { - return ; -}; -TemplateStory.storyName = 'Multiple Critical Alert Modal'; - -/** - * Single Critical Alert Modal. - */ -export const SingleCriticalAlertModal = TemplateStory.bind({}); -SingleCriticalAlertModal.storyName = 'Single Critical Alert Modal'; -SingleCriticalAlertModal.args = { - alertKey: 'from', -}; -SingleCriticalAlertModal.decorators = [ - (story) => { - const singleAlertStore = configureStore({ - confirmAlerts: { - alerts: { [OWNER_ID_MOCK]: [ALERTS_MOCK[0]] }, - confirmed: { [OWNER_ID_MOCK]: { 'from': false } }, - } - }); - return {story()} - } -]; - -/** - * Multiple Warning Alert Modal. - */ -export const MultipleWarningAlertModal = TemplateStory.bind({}); - -MultipleWarningAlertModal.storyName = 'Multiple Warning Alert Modal'; -MultipleWarningAlertModal.args = { - alertKey: 'data', -}; - -/** - * Multiple Info Alert Modal. - */ -export const MultipleInfoAlertModal = TemplateStory.bind({}); -MultipleInfoAlertModal.storyName = 'Multiple Info Alert Modal'; -MultipleInfoAlertModal.args = { - alertKey: 'contract', -}; - /** * Multiple Critical Alert Modal. */ -export const MultipleCriticalAlertModal = TemplateStory.bind({}); -MultipleCriticalAlertModal.storyName = 'Multiple Critical Alert Modal'; -MultipleCriticalAlertModal.args = { - alertKey: 'from', -}; \ No newline at end of file +export const TemplateStory: StoryFn = (args) => { + const [{ isOpen }, updateArgs] = useArgs(); + const handleOnClick = () => { + updateArgs({ isOpen: true }); + }; + const handleOnClose = () => { + updateArgs({ isOpen: false }); + }; + return ( + + {isOpen && ( + + )} + + + ); +}; +TemplateStory.storyName = 'Multiple Critical Alert Modal'; diff --git a/ui/components/app/confirmations/alerts/multiple-alert-modal/multiple-alert-modal.tsx b/ui/components/app/confirmations/alerts/multiple-alert-modal/multiple-alert-modal.tsx index 86321779e947..dca3b9fff8cb 100644 --- a/ui/components/app/confirmations/alerts/multiple-alert-modal/multiple-alert-modal.tsx +++ b/ui/components/app/confirmations/alerts/multiple-alert-modal/multiple-alert-modal.tsx @@ -168,8 +168,8 @@ export function MultipleAlertModal({ return; } - handleBackButtonClick(); - }, [onFinalAcknowledgeClick, handleBackButtonClick, selectedIndex, alerts]); + handleNextButtonClick(); + }, [onFinalAcknowledgeClick, handleNextButtonClick, selectedIndex, alerts]); return ( { + it('returns the correct background color for Danger severity', () => { + const result = getSeverityBackground(Severity.Danger); + expect(result).toBe(BackgroundColor.errorMuted); + }); + + it('returns the correct background color for Warning severity', () => { + const result = getSeverityBackground(Severity.Warning); + expect(result).toBe(BackgroundColor.warningMuted); + }); + + it('returns the default background color for other severity levels', () => { + const result = getSeverityBackground(Severity.Info); + expect(result).toBe(BackgroundColor.primaryMuted); + }); +}); diff --git a/ui/components/app/confirmations/alerts/utils.ts b/ui/components/app/confirmations/alerts/utils.ts new file mode 100644 index 000000000000..582c43d2d6c2 --- /dev/null +++ b/ui/components/app/confirmations/alerts/utils.ts @@ -0,0 +1,22 @@ +import { + BackgroundColor, + Severity, +} from '../../../../helpers/constants/design-system'; + +/** + * Returns the background color based on the severity level. + * + * @param severity - The severity level. + * @returns The background color corresponding to the severity level. + */ +export function getSeverityBackground(severity: Severity): BackgroundColor { + switch (severity) { + case Severity.Danger: + return BackgroundColor.errorMuted; + case Severity.Warning: + return BackgroundColor.warningMuted; + // Defaults to Severity.Info + default: + return BackgroundColor.primaryMuted; + } +} diff --git a/ui/pages/confirmations/components/confirm/footer/footer.test.tsx b/ui/pages/confirmations/components/confirm/footer/footer.test.tsx index 7e3d629595de..5deb6021ca69 100644 --- a/ui/pages/confirmations/components/confirm/footer/footer.test.tsx +++ b/ui/pages/confirmations/components/confirm/footer/footer.test.tsx @@ -11,6 +11,7 @@ import { fireEvent, renderWithProvider } from '../../../../../../test/jest'; import * as MMIConfirmations from '../../../../../hooks/useMMIConfirmations'; import * as Actions from '../../../../../store/actions'; import configureStore from '../../../../../store/store'; +import { Severity } from '../../../../../helpers/constants/design-system'; import Footer from './footer'; jest.mock('react-redux', () => ({ @@ -159,4 +160,52 @@ describe('ConfirmFooter', () => { fireEvent.click(submitButton); expect(mockFn).toHaveBeenCalledTimes(1); }); + + describe('ConfirmButton', () => { + const OWNER_ID_MOCK = '123'; + const KEY_ALERT_KEY_MOCK = 'Key'; + const ALERT_MESSAGE_MOCK = 'Alert 1'; + const alertsMock = [ + { + key: KEY_ALERT_KEY_MOCK, + field: KEY_ALERT_KEY_MOCK, + severity: Severity.Warning, + message: ALERT_MESSAGE_MOCK, + reason: 'Reason 1', + alertDetails: ['Detail 1', 'Detail 2'], + }, + ]; + const stateWithAlertsMock = { + ...mockState, + confirmAlerts: { + alerts: { [OWNER_ID_MOCK]: alertsMock }, + confirmed: { + [OWNER_ID_MOCK]: { [KEY_ALERT_KEY_MOCK]: false }, + }, + }, + confirm: { + currentConfirmation: { + id: OWNER_ID_MOCK, + msgParams: { + from: '0xc42edfcc21ed14dda456aa0756c153f7985d8813', + }, + }, + }, + }; + it('renders the review alerts button when there are unconfirmed alerts', () => { + const { getByText } = render(stateWithAlertsMock); + expect(getByText('Review alerts')).toBeInTheDocument(); + }); + + it('renders the confirm button when there are no unconfirmed alerts', () => { + const { getByText } = render(); + expect(getByText('Confirm')).toBeInTheDocument(); + }); + + it('sets the alert modal visible when the review alerts button is clicked', () => { + const { getByTestId } = render(stateWithAlertsMock); + fireEvent.click(getByTestId('confirm-footer-confirm-button')); + expect(getByTestId('alert-modal-button')).toBeDefined(); + }); + }); }); diff --git a/ui/pages/confirmations/components/confirm/footer/footer.tsx b/ui/pages/confirmations/components/confirm/footer/footer.tsx index fcf1ae4ae591..60011c7f648c 100644 --- a/ui/pages/confirmations/components/confirm/footer/footer.tsx +++ b/ui/pages/confirmations/components/confirm/footer/footer.tsx @@ -1,10 +1,11 @@ import { ethErrors, serializeError } from 'eth-rpc-errors'; -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Button, ButtonSize, ButtonVariant, + IconName, } from '../../../../../components/component-library'; import { Footer as PageFooter } from '../../../../../components/multichain/pages/page'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; @@ -19,13 +20,80 @@ import { } from '../../../../../store/actions'; import { confirmSelector } from '../../../selectors'; import { getConfirmationSender } from '../utils'; +import useAlerts from '../../../../../hooks/useAlerts'; +import { ConfirmAlertModal } from '../../../../../components/app/confirmations/alerts/confirm-alert-modal'; import useIsDangerButton from './useIsDangerButton'; +function getIconName(hasUnconfirmedAlerts: boolean): IconName { + if (hasUnconfirmedAlerts) { + return IconName.SecuritySearch; + } + return IconName.Danger; +} + +function ConfirmButton({ + alertOwnerId = '', + disabled, + onSubmit, + onCancel, +}: { + alertOwnerId?: string; + disabled: boolean; + onSubmit: () => void; + onCancel: () => void; +}) { + const isDangerButton = useIsDangerButton(); + const t = useI18nContext(); + + const [confirmModalVisible, setConfirmModalVisible] = + useState(false); + const { alerts, isAlertConfirmed } = useAlerts(alertOwnerId); + const unconfirmedAlerts = alerts.filter( + (alert) => alert.field && !isAlertConfirmed(alert.key), + ); + const hasAlerts = alerts.length > 0; + const hasUnconfirmedAlerts = unconfirmedAlerts.length > 0; + + const handleCloseConfirmModal = useCallback(() => { + setConfirmModalVisible(false); + }, []); + + const handleOpenConfirmModal = useCallback(() => { + setConfirmModalVisible(true); + }, [hasUnconfirmedAlerts]); + + return ( + <> + {confirmModalVisible && ( + + )} + + + ); +} + const Footer = () => { const dispatch = useDispatch(); const t = useI18nContext(); const confirm = useSelector(confirmSelector); - const isDangerButton = useIsDangerButton(); const { currentConfirmation, isScrollToBottomNeeded } = confirm; const { from } = getConfirmationSender(currentConfirmation); @@ -58,6 +126,7 @@ const Footer = () => { if (!currentConfirmation) { return; } + dispatch(resolvePendingApproval(currentConfirmation.id, undefined)); ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) @@ -75,12 +144,9 @@ const Footer = () => { > {t('cancel')} - + onCancel={onCancel} + /> ); };