diff --git a/packages/account/src/Containers/Account/__tests__/account.spec.tsx b/packages/account/src/Containers/Account/__tests__/account.spec.tsx new file mode 100644 index 000000000000..984e4e37c1ca --- /dev/null +++ b/packages/account/src/Containers/Account/__tests__/account.spec.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { MemoryRouter, BrowserRouter } from 'react-router-dom'; +import { render, screen } from '@testing-library/react'; +import { StoreProvider, mockStore } from '@deriv/stores'; +import { routes } from '@deriv/shared'; +import { TRoute } from 'Types'; +import Account from '../account'; + +jest.mock('../../Account/page-overlay-wrapper', () => jest.fn(() =>
MockPageOverlayWrapper
)); + +jest.mock('@deriv/components', () => ({ + ...jest.requireActual('@deriv/components'), + Loading: () =>
MockLoading
, +})); + +describe('Account', () => { + const store = mockStore({ + ui: { + is_account_settings_visible: true, + }, + }); + + const route_list: Array = [ + { + getTitle: () => 'Profile', + icon: 'mockIcon', + subroutes: [ + { + path: routes.personal_details, + component: () =>
MockPersonalDetails
, + getTitle: () => 'Personal details', + default: true, + }, + { + path: routes.trading_assessment, + component: () =>
MockTradeAssessment
, + getTitle: () => 'Trade assessment', + }, + ], + }, + ]; + + const mock_props: React.ComponentProps = { + routes: route_list, + }; + + const mock_route = routes.personal_details; + + const renderComponent = ({ store_config = store, route = mock_route, props = mock_props }) => + render( + + + + + + + + ); + + it('should render account page', () => { + renderComponent({}); + expect(screen.getByText('MockPageOverlayWrapper')).toBeInTheDocument(); + }); + + it('should render loader while the client is still logging in', () => { + const new_store_config = mockStore({ + client: { + is_logging_in: true, + }, + }); + + renderComponent({ store_config: new_store_config }); + expect(screen.getByText('MockLoading')).toBeInTheDocument(); + }); +}); diff --git a/packages/account/src/Containers/Account/__tests__/tradinghub-logout.spec.tsx b/packages/account/src/Containers/Account/__tests__/tradinghub-logout.spec.tsx new file mode 100644 index 000000000000..985f7a20ca8b --- /dev/null +++ b/packages/account/src/Containers/Account/__tests__/tradinghub-logout.spec.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import TradingHubLogout from '../tradinghub-logout'; + +describe('TradingHubLogout', () => { + const mock_props: React.ComponentProps = { + handleOnLogout: jest.fn(), + }; + + it('should render logout tab', () => { + render(); + expect(screen.getByText('Log out')).toBeInTheDocument(); + }); + + it('should invoke handleOnLogout when logout tab is clicked', () => { + render(); + const el_tab = screen.getByTestId('dt_logout_tab'); + userEvent.click(el_tab); + expect(mock_props.handleOnLogout).toBeCalledTimes(1); + }); +}); diff --git a/packages/account/src/Containers/Account/account.tsx b/packages/account/src/Containers/Account/account.tsx new file mode 100644 index 000000000000..3883eaf8bc11 --- /dev/null +++ b/packages/account/src/Containers/Account/account.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { RouteComponentProps, withRouter } from 'react-router-dom'; +import { FadeWrapper, Loading } from '@deriv/components'; +import { matchRoute, routes as shared_routes } from '@deriv/shared'; +import { observer, useStore } from '@deriv/stores'; +import PageOverlayWrapper from './page-overlay-wrapper'; +import { TRoute } from '../../Types'; +import 'Styles/account.scss'; + +type TAccountProps = RouteComponentProps & { + routes: Array; +}; + +/** + * Component that renders the account section + * @name Account + * @param history - history object passed from react-router-dom + * @param location - location object passed from react-router-dom + * @param routes - routes object passed from react-router-dom + * @returns React component + */ +const Account = observer(({ history, location, routes }: TAccountProps) => { + const { client, ui } = useStore(); + const { + is_virtual, + is_logged_in, + is_logging_in, + is_pending_proof_of_ownership, + landing_company_shortcode, + should_allow_authentication, + } = client; + const { toggleAccountSettings, is_account_settings_visible } = ui; + const subroutes = routes.map(i => i.subroutes); + let selected_content = subroutes.find(r => matchRoute(r, location.pathname)); + + React.useEffect(() => { + toggleAccountSettings(true); + }, [toggleAccountSettings]); + + routes.forEach(menu_item => { + if (menu_item?.subroutes?.length) { + menu_item.subroutes.forEach(route => { + if (route.path === shared_routes.financial_assessment) { + route.is_disabled = is_virtual || landing_company_shortcode === 'maltainvest'; + } + + if (route.path === shared_routes.trading_assessment) { + route.is_disabled = is_virtual || landing_company_shortcode !== 'maltainvest'; + } + + if (route.path === shared_routes.proof_of_identity || route.path === shared_routes.proof_of_address) { + route.is_disabled = !should_allow_authentication; + } + + if (route.path === shared_routes.proof_of_ownership) { + route.is_disabled = is_virtual || !is_pending_proof_of_ownership; + } + }); + } + }); + + if (!selected_content) { + // fallback + selected_content = subroutes[0]; + history.push(shared_routes.personal_details); + } + + if (!is_logged_in && is_logging_in) { + return ; + } + + return ( + +
+ +
+
+ ); +}); + +Account.displayName = 'Account'; + +export default withRouter(Account); diff --git a/packages/account/src/Containers/Account/page-overlay-wrapper.tsx b/packages/account/src/Containers/Account/page-overlay-wrapper.tsx new file mode 100644 index 000000000000..658edbe162bc --- /dev/null +++ b/packages/account/src/Containers/Account/page-overlay-wrapper.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { PageOverlay, VerticalTab } from '@deriv/components'; +import { getSelectedRoute, getStaticUrl, routes as shared_routes } from '@deriv/shared'; +import { observer, useStore } from '@deriv/stores'; +import { Localize } from '@deriv/translations'; +import TradingHubLogout from './tradinghub-logout'; +import { TRoute } from '../../Types'; + +type RouteItems = React.ComponentProps['list']; + +type PageOverlayWrapperProps = { + routes: Array; + subroutes: RouteItems; +}; + +/** + * @name PageOverlayWrapper + * @param routes - routes object pased by react-router-dom + * @param subroutes - list of subroutes + */ +const PageOverlayWrapper = observer(({ routes, subroutes }: PageOverlayWrapperProps) => { + const history = useHistory(); + const { client, common, ui } = useStore(); + const { is_mobile } = ui; + const { logout } = client; + const { is_from_derivgo, routeBackInApp } = common; + + const list_groups = routes.map(route_group => ({ + icon: route_group.icon, + label: route_group?.getTitle(), + subitems: route_group?.subroutes?.length ? route_group.subroutes.map(sub => subroutes.indexOf(sub)) : [], + })); + + const onClickClose = React.useCallback(() => routeBackInApp(history), [routeBackInApp, history]); + + const selected_route = getSelectedRoute({ routes: subroutes as Array, pathname: location.pathname }); + + const onClickLogout = () => { + history.push(shared_routes.index); + logout().then(() => (window.location.href = getStaticUrl('/'))); + }; + + if (is_mobile && selected_route) { + const RouteComponent = selected_route.component as React.ElementType<{ component_icon: string | undefined }>; + return ( + + + + ); + } + return ( + } + onClickClose={onClickClose} + is_from_app={is_from_derivgo} + > + } + /> + + ); +}); + +PageOverlayWrapper.displayName = 'PageOverlayWrapper'; + +export default PageOverlayWrapper; diff --git a/packages/account/src/Containers/Account/tradinghub-logout.tsx b/packages/account/src/Containers/Account/tradinghub-logout.tsx new file mode 100644 index 000000000000..208142984b18 --- /dev/null +++ b/packages/account/src/Containers/Account/tradinghub-logout.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Text, Icon } from '@deriv/components'; +import { Localize } from '@deriv/translations'; + +/** + * Content to be displayed in the side bar + * @name TradingHubLogout + * @param handleOnLogout - function to handle action when user click on logout + * @returns React Component + */ +const TradingHubLogout = ({ handleOnLogout }: { handleOnLogout: () => void }) => ( +
+
+ + + + +
+
+); + +export default TradingHubLogout; diff --git a/packages/account/src/Containers/account.jsx b/packages/account/src/Containers/account.jsx deleted file mode 100644 index a580baa1d4f9..000000000000 --- a/packages/account/src/Containers/account.jsx +++ /dev/null @@ -1,195 +0,0 @@ -import 'Styles/account.scss'; -import { - PlatformContext, - getSelectedRoute, - getStaticUrl, - isMobile, - matchRoute, - routes as shared_routes, -} from '@deriv/shared'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { withRouter } from 'react-router-dom'; -import { VerticalTab, FadeWrapper, PageOverlay, Loading, Text, Icon } from '@deriv/components'; -import { observer, useStore } from '@deriv/stores'; -import { flatten } from '../Helpers/flatten'; -import { localize } from '@deriv/translations'; - -const onClickLogout = (logout, history) => { - history.push(shared_routes.index); - logout().then(() => (window.location.href = getStaticUrl('/'))); -}; - -const AccountLogout = ({ logout, history }) => { - return ( -
onClickLogout(logout, history)}> -
- - {localize('Log out')} - -
-
- ); -}; - -const TradingHubLogout = ({ logout, history }) => { - return ( -
onClickLogout(logout, history)}> -
- - - {localize('Log out')} - -
-
- ); -}; - -const PageOverlayWrapper = ({ - is_from_derivgo, - is_appstore, - list_groups, - logout, - onClickClose, - selected_route, - subroutes, - history, -}) => { - const routeToPrevious = () => history.push(shared_routes.traders_hub); - - if (isMobile() && selected_route) { - return ( - - - - ); - } else if (is_appstore) { - return ( - } - /> - ); - } - - return ( - - } - /> - - ); -}; - -const Account = observer(({ history, location, routes }) => { - const { client, common, ui } = useStore(); - const { - is_virtual, - is_logged_in, - is_logging_in, - is_risky_client, - is_pending_proof_of_ownership, - landing_company_shortcode, - should_allow_authentication, - logout, - } = client; - const { is_from_derivgo, routeBackInApp, platform } = common; - const { toggleAccountSettings, is_account_settings_visible } = ui; - const { is_appstore } = React.useContext(PlatformContext); - const subroutes = flatten(routes.map(i => i.subroutes)); - let list_groups = [...routes]; - list_groups = list_groups.map(route_group => ({ - icon: route_group.icon, - label: route_group.getTitle(), - subitems: route_group.subroutes.map(sub => subroutes.indexOf(sub)), - })); - let selected_content = subroutes.find(r => matchRoute(r, location.pathname)); - const onClickClose = React.useCallback(() => routeBackInApp(history), [routeBackInApp, history]); - - React.useEffect(() => { - toggleAccountSettings(true); - }, [toggleAccountSettings]); - - routes.forEach(menu_item => { - menu_item.subroutes.forEach(route => { - if (route.path === shared_routes.financial_assessment) { - route.is_disabled = is_virtual || (landing_company_shortcode === 'maltainvest' && !is_risky_client); - } - - if (route.path === shared_routes.trading_assessment) { - route.is_disabled = is_virtual || landing_company_shortcode !== 'maltainvest'; - } - - if (route.path === shared_routes.proof_of_identity || route.path === shared_routes.proof_of_address) { - route.is_disabled = !should_allow_authentication; - } - - if (route.path === shared_routes.proof_of_ownership) { - route.is_disabled = is_virtual || !is_pending_proof_of_ownership; - } - }); - }); - - if (!selected_content) { - // fallback - selected_content = subroutes[0]; - history.push(shared_routes.personal_details); - } - - if (!is_logged_in && is_logging_in) { - return ; - } - - const selected_route = getSelectedRoute({ routes: subroutes, pathname: location.pathname }); - - return ( - -
- -
-
- ); -}); - -Account.propTypes = { - active_account_landing_company: PropTypes.string, - history: PropTypes.object, - location: PropTypes.object, - routes: PropTypes.arrayOf(PropTypes.object), -}; - -export default withRouter(Account); diff --git a/packages/account/src/Helpers/flatten.js b/packages/account/src/Helpers/flatten.js deleted file mode 100644 index d9f0a696b24f..000000000000 --- a/packages/account/src/Helpers/flatten.js +++ /dev/null @@ -1 +0,0 @@ -export const flatten = arr => [].concat(...arr); diff --git a/packages/account/src/Sections/index.js b/packages/account/src/Sections/index.js index 0326508f030b..e0f1bdd1814a 100644 --- a/packages/account/src/Sections/index.js +++ b/packages/account/src/Sections/index.js @@ -9,7 +9,7 @@ import ProofOfOwnership from 'Sections/Verification/ProofOfOwnership'; import TwoFactorAuthentication from 'Sections/Security/TwoFactorAuthentication'; import ApiToken from 'Sections/Security/ApiToken'; import SelfExclusion from 'Sections/Security/SelfExclusion'; -import Account from 'Containers/account.jsx'; +import Account from 'Containers/Account/account'; import ClosingAccount from 'Sections/Security/ClosingAccount'; import ConnectedApps from 'Sections/Security/ConnectedApps'; import LoginHistory from 'Sections/Security/LoginHistory'; diff --git a/packages/account/src/Types/common-prop.type.ts b/packages/account/src/Types/common-prop.type.ts index 5298105baa8b..2717c3450461 100644 --- a/packages/account/src/Types/common-prop.type.ts +++ b/packages/account/src/Types/common-prop.type.ts @@ -65,8 +65,9 @@ export type TRoute = { icon?: string; default?: boolean; to?: string; - component?: ((cashier_routes?: TRoute[]) => JSX.Element) | typeof Redirect; + component?: ((routes?: TRoute[]) => JSX.Element) | typeof Redirect; getTitle?: () => string; + is_disabled?: boolean; subroutes?: TRoute[]; }; diff --git a/packages/components/src/components/vertical-tab/vertical-tab.tsx b/packages/components/src/components/vertical-tab/vertical-tab.tsx index 14722e85f493..ba870ca7e1f9 100644 --- a/packages/components/src/components/vertical-tab/vertical-tab.tsx +++ b/packages/components/src/components/vertical-tab/vertical-tab.tsx @@ -39,7 +39,7 @@ type TVerticalTab = { list_groups?: TItem[]; onClickClose?: () => void; setVerticalTabIndex?: (index: number) => void; - tab_headers_note: React.ReactNode | React.ReactNode[]; + tab_headers_note?: React.ReactNode | React.ReactNode[]; title?: string; vertical_tab_index?: number; }; diff --git a/packages/shared/src/utils/route/route.ts b/packages/shared/src/utils/route/route.ts index 905cb381e859..de927db7d182 100644 --- a/packages/shared/src/utils/route/route.ts +++ b/packages/shared/src/utils/route/route.ts @@ -1,8 +1,8 @@ // Checks if pathname matches route. (Works even with query string /?) -import React from 'react'; + // TODO: Add test cases for this -type TRoute = { - component?: JSX.Element | null; +export type TRoute = { + component?: React.ElementType | null; default?: boolean; exact?: boolean; getTitle?: () => string; @@ -19,7 +19,8 @@ type TGetSelectedRoute = { pathname: string; }; -export const matchRoute = (route: TRoute, pathname: string) => new RegExp(`^${route.path}(/.*)?$`).test(pathname); +// @ts-expect-error as this is a utility function with dynamic types +export const matchRoute = (route: T, pathname: string) => new RegExp(`^${route?.path}(/.*)?$`).test(pathname); export const getSelectedRoute = ({ routes, pathname }: TGetSelectedRoute) => { const matching_route = routes.find(route => matchRoute(route, pathname)); diff --git a/packages/stores/src/mockStore.ts b/packages/stores/src/mockStore.ts index da779c6c7307..f2ba74649e5d 100644 --- a/packages/stores/src/mockStore.ts +++ b/packages/stores/src/mockStore.ts @@ -279,6 +279,7 @@ const mock = (): TStores & { is_mock: boolean } => { current: null, }, current_focus: null, + is_account_settings_visible: false, is_loading: false, is_cashier_visible: false, is_app_disabled: false, @@ -296,6 +297,7 @@ const mock = (): TStores & { is_mock: boolean } => { enableApp: jest.fn(), setCurrentFocus: jest.fn(), toggleAccountsDialog: jest.fn(), + toggleAccountSettings: jest.fn(), toggleCashier: jest.fn(), togglePositionsDrawer: jest.fn(), setDarkMode: jest.fn(), diff --git a/packages/stores/types.ts b/packages/stores/types.ts index 20687872a115..b4d33c464ad7 100644 --- a/packages/stores/types.ts +++ b/packages/stores/types.ts @@ -437,6 +437,7 @@ type TUiStore = { disableApp: () => void; enableApp: () => void; has_real_account_signup_ended: boolean; + is_account_settings_visible: boolean; is_loading: boolean; is_cashier_visible: boolean; is_closing_create_real_account_modal: boolean; @@ -464,6 +465,7 @@ type TUiStore = { setSubSectionIndex: (index: number) => void; shouldNavigateAfterChooseCrypto: (value: Omit | TRoutes) => void; toggleAccountsDialog: () => void; + toggleAccountSettings: (props?: boolean) => void; toggleCashier: () => void; toggleLanguageSettingsModal: () => void; toggleLinkExpiredModal: (state_change: boolean) => void;