diff --git a/packages/bot-web-ui/src/app/app.scss b/packages/bot-web-ui/src/app/app.scss index 74c48607c549..9bc3e6996dbe 100644 --- a/packages/bot-web-ui/src/app/app.scss +++ b/packages/bot-web-ui/src/app/app.scss @@ -15,4 +15,5 @@ --zindex-drawer: 5; --zindex-modal: 6; --zindex-draggable-modal: 7; + --zindex-snackbar: 8; } diff --git a/packages/bot-web-ui/src/components/bot-snackbar/__tests__/bot-snackbar.spec.tsx b/packages/bot-web-ui/src/components/bot-snackbar/__tests__/bot-snackbar.spec.tsx new file mode 100644 index 000000000000..03763a79cdcf --- /dev/null +++ b/packages/bot-web-ui/src/components/bot-snackbar/__tests__/bot-snackbar.spec.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { mockStore, StoreProvider } from '@deriv/stores'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { act, fireEvent, render, screen } from '@testing-library/react'; +// eslint-disable-next-line import/no-extraneous-dependencies +import userEvent from '@testing-library/user-event'; +import RootStore from '../../../stores/root-store'; +import { DBotStoreProvider, mockDBotStore } from '../../../stores/useDBotStore'; +import BotSnackbar from '../bot-snackbar'; + +jest.mock('@deriv/shared', () => ({ + ...jest.requireActual('@deriv/shared'), + isMobile: jest.fn(() => false), +})); + +jest.mock('@deriv/bot-skeleton/src/scratch/blockly', () => jest.fn()); +jest.mock('@deriv/bot-skeleton/src/scratch/dbot', () => ({ + saveRecentWorkspace: jest.fn(), + unHighlightAllBlocks: jest.fn(), +})); +jest.mock('@deriv/bot-skeleton/src/scratch/hooks/block_svg', () => jest.fn()); +jest.mock('@deriv/deriv-charts', () => ({ + setSmartChartsPublicPath: jest.fn(), +})); + +const mock_ws = { + authorized: { + subscribeProposalOpenContract: jest.fn(), + send: jest.fn(), + }, + storage: { + send: jest.fn(), + }, + contractUpdate: jest.fn(), + subscribeTicksHistory: jest.fn(), + forgetStream: jest.fn(), + activeSymbols: jest.fn(), + send: jest.fn(), +}; + +jest.useFakeTimers(); + +describe('BotSnackbar', () => { + let wrapper: ({ children }: { children: JSX.Element }) => JSX.Element, mock_DBot_store: RootStore | undefined; + const mockHandleClose = jest.fn(); + beforeAll(() => { + const mock_store = mockStore({}); + mock_DBot_store = mockDBotStore(mock_store, mock_ws); + + wrapper = ({ children }: { children: JSX.Element }) => ( + + + {children} + + + ); + }); + it('should render BotSnackbar with correct message', () => { + const test_message = 'message test'; + render(, { + wrapper, + }); + expect(screen.getByText(test_message)).toBeInTheDocument(); + }); + + it('should not render BotSnackbar if snack bar is not opened', () => { + const test_message = 'message test'; + render(, { + wrapper, + }); + expect(screen.queryByText(test_message)).not.toBeInTheDocument(); + }); + + it('should render close button if snackbar is open', () => { + render(, { + wrapper, + }); + const cls_btn = screen.getByTestId('bot-snackbar-notification-close'); + expect(cls_btn).toBeInTheDocument(); + }); + + it('should hanlde close function on click close button', async () => { + render(, { + wrapper, + }); + const cls_btn = screen.getByTestId('bot-snackbar-notification-close'); + await userEvent.click(cls_btn); + expect(mockHandleClose).toBeCalled(); + }); + + it('should close snackbar after timeout is passed and mouse is not over the snackbar', async () => { + render(, { + wrapper, + }); + const element = screen.getByTestId('bot-snackbar-notification-container'); + act(() => { + fireEvent.mouseLeave(element); + jest.advanceTimersByTime(4100); + }); + const cls_btn = screen.queryByTestId('bot-snackbar-notification-close'); + expect(cls_btn).not.toBeInTheDocument(); + }); +}); diff --git a/packages/bot-web-ui/src/components/bot-snackbar/bot-snackbar.scss b/packages/bot-web-ui/src/components/bot-snackbar/bot-snackbar.scss new file mode 100644 index 000000000000..73e1a2cde9da --- /dev/null +++ b/packages/bot-web-ui/src/components/bot-snackbar/bot-snackbar.scss @@ -0,0 +1,36 @@ +.bot-snackbar { + position: fixed; + z-index: var(--zindex-snackbar); + right: 38rem; + top: 12rem; + + .dc-toast { + width: 100%; + &__message { + background: var(--text-prominent); + color: var(--general-main-1); + padding: 1rem 1.6rem; + } + &__message-content { + display: flex; + + @include mobile { + align-items: center; + } + } + } + + @include mobile { + top: unset; + left: 0; + right: 0; + bottom: 10.5rem; + } + + .notification-close { + cursor: pointer; + filter: invert(1); + margin-left: 1rem; + margin-top: 0.1rem; + } +} diff --git a/packages/bot-web-ui/src/components/bot-snackbar/bot-snackbar.tsx b/packages/bot-web-ui/src/components/bot-snackbar/bot-snackbar.tsx new file mode 100644 index 000000000000..df518174f7e6 --- /dev/null +++ b/packages/bot-web-ui/src/components/bot-snackbar/bot-snackbar.tsx @@ -0,0 +1,57 @@ +import React, { useState } from 'react'; +import { Icon, Toast } from '@deriv/components'; +import { Localize } from '@deriv/translations'; + +type TBotSnackbar = { + className?: string; + is_open: boolean; + onClick?: React.MouseEventHandler; + handleClose: () => void; + type?: 'error' | 'info' | 'notification'; + timeout?: number; + msg_localize_components?: JSX.Element[]; + message: string; +}; + +const BotSnackbar = ({ + message, + msg_localize_components = [], + timeout = 6000, + is_open, + onClick, + handleClose, + type, + className, +}: TBotSnackbar) => { + const [notification_timer, setNotificationTimer] = useState(timeout); + + React.useEffect(() => { + if (is_open) { + setNotificationTimer(timeout); + } + }, [is_open, timeout]); + + return ( +
{ + setNotificationTimer(0); + }} + onMouseLeave={() => { + setNotificationTimer(timeout); + }} + className={className ?? 'bot-snackbar'} + data-testid='bot-snackbar-notification-container' + > + +
{message && }
+ +
+
+ ); +}; +export default BotSnackbar; diff --git a/packages/bot-web-ui/src/components/bot-snackbar/index.tsx b/packages/bot-web-ui/src/components/bot-snackbar/index.tsx new file mode 100644 index 000000000000..28deeddded1e --- /dev/null +++ b/packages/bot-web-ui/src/components/bot-snackbar/index.tsx @@ -0,0 +1,4 @@ +import BotSnackbar from './bot-snackbar'; +import './bot-snackbar.scss'; + +export default BotSnackbar; diff --git a/packages/bot-web-ui/src/components/dashboard/bot-builder/bot-builder.tsx b/packages/bot-web-ui/src/components/dashboard/bot-builder/bot-builder.tsx index 3caffd8258e4..71ba996f978a 100644 --- a/packages/bot-web-ui/src/components/dashboard/bot-builder/bot-builder.tsx +++ b/packages/bot-web-ui/src/components/dashboard/bot-builder/bot-builder.tsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { useRef, useState } from 'react'; import classNames from 'classnames'; import { observer } from '@deriv/stores'; import { useDBotStore } from '../../../stores/useDBotStore'; +import BotSnackbar from '../../bot-snackbar'; import LoadModal from '../../load-modal'; import SaveModal from '../dashboard-component/load-bot-preview/save-modal'; import BotBuilderTourHandler from '../dbot-tours/bot-builder-tour'; @@ -12,8 +13,11 @@ import QuickStrategy from '../quick-strategy'; import WorkspaceWrapper from './workspace-wrapper'; const BotBuilder = observer(() => { - const { dashboard, app } = useDBotStore(); + const { dashboard, app, run_panel } = useDBotStore(); const { active_tab, active_tour, is_preview_on_popup } = dashboard; + const { is_running } = run_panel; + const is_blockly_listener_registered = useRef(false); + const [show_snackbar, setShowSnackbar] = useState(false); const { onMount, onUnmount } = app; const el_ref = React.useRef(null); @@ -23,8 +27,43 @@ const BotBuilder = observer(() => { return () => onUnmount(); }, []); + const handleBlockChangeOnBotRun = (e: Event) => { + if (e.type !== 'ui') { + setShowSnackbar(true); + removeBlockChangeListener(); + } + }; + + const removeBlockChangeListener = () => { + is_blockly_listener_registered.current = false; + window.Blockly?.derivWorkspace?.removeChangeListener(handleBlockChangeOnBotRun); + }; + + React.useEffect(() => { + const workspace = window.Blockly?.derivWorkspace; + if (workspace && is_running && !is_blockly_listener_registered.current) { + is_blockly_listener_registered.current = true; + workspace.addChangeListener(handleBlockChangeOnBotRun); + } else { + setShowSnackbar(false); + removeBlockChangeListener(); + } + + return () => { + if (workspace && is_blockly_listener_registered.current) { + removeBlockChangeListener(); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [is_running]); + return ( <> + setShowSnackbar(false)} + />