diff --git a/src/components/AccountSwitcher/__tests__/AccountSwitcher.test.tsx b/src/components/AccountSwitcher/__tests__/AccountSwitcher.test.tsx index f1e510537..34303dcb8 100644 --- a/src/components/AccountSwitcher/__tests__/AccountSwitcher.test.tsx +++ b/src/components/AccountSwitcher/__tests__/AccountSwitcher.test.tsx @@ -37,7 +37,7 @@ mockUseLogout.mockImplementation(() => { }; }); -describe.skip('HeroHeader', () => { +describe('HeroHeader', () => { beforeEach(() => { mockUseAuthContext.mockImplementation(() => { return { @@ -64,13 +64,13 @@ describe.skip('HeroHeader', () => { jest.clearAllMocks(); }); - it('Should render current account ', () => { + it.skip('Should render current account ', () => { const current_account_button = screen.getByRole('button', { name: /CR111111/i }); expect(current_account_button).toBeInTheDocument(); }); - it('Should call do logout on logout button click', async () => { + it.skip('Should call do logout on logout button click', async () => { const current_account_button = await screen.findByRole('button', { name: /CR111111/i }); await act(async () => { @@ -86,7 +86,7 @@ describe.skip('HeroHeader', () => { expect(mockLogout).toHaveBeenCalledTimes(1); }); - it('should be able to close the dropdown by clicking on the arrow', async () => { + it.skip('should be able to close the dropdown by clicking on the arrow', async () => { const current_account_button = await screen.findByRole('button', { name: /CR111111/i }); await act(async () => { @@ -102,7 +102,7 @@ describe.skip('HeroHeader', () => { expect(close_dropdown_button).not.toBeVisible(); }); - it('Should render Accounts when no account is selected', () => { + it.skip('Should render Accounts when no account is selected', () => { cleanup(); mockUseAuthContext.mockImplementation(() => { return { @@ -127,7 +127,7 @@ describe.skip('HeroHeader', () => { expect(accounts_button).toBeInTheDocument(); }); - it('Should render the dropdown menu on current account button click', async () => { + it.skip('Should render the dropdown menu on current account button click', async () => { const current_account_button = screen.getByRole('button', { name: /USD/i }); await act(async () => { @@ -139,7 +139,7 @@ describe.skip('HeroHeader', () => { expect(menu_items.length).toBe(1); }); - it('Should update current account on menu item click', async () => { + it.skip('Should update current account on menu item click', async () => { mockUseAuthContext.mockImplementation(() => { return { loginAccounts: fake_accounts, diff --git a/src/components/ApiTokenNavbarItem/__tests__/ApiTokenNavbarItem.test.tsx b/src/components/ApiTokenNavbarItem/__tests__/ApiTokenNavbarItem.test.tsx new file mode 100644 index 000000000..40e1bbbd7 --- /dev/null +++ b/src/components/ApiTokenNavbarItem/__tests__/ApiTokenNavbarItem.test.tsx @@ -0,0 +1,287 @@ +import useApiToken from '@site/src/hooks/useApiToken'; +import useAuthContext from '@site/src/hooks/useAuthContext'; +import userEvent from '@testing-library/user-event'; +import useAppManager from '@site/src/hooks/useAppManager'; +import { render, screen } from '@site/src/test-utils'; + +import React, { act } from 'react'; +import ApiTokenNavbarItem from '..'; +import { TTokensArrayType } from '@site/src/types'; +import { TDashboardTab } from '@site/src/contexts/app-manager/app-manager.context'; + +jest.mock('@site/src/hooks/useApiToken'); +const mockUseApiToken = useApiToken as jest.MockedFunction< + () => Partial> +>; + +jest.mock('@site/src/hooks/useAuthContext'); + +const mockUseAuthContext = useAuthContext as jest.MockedFunction< + () => Partial> +>; + +jest.mock('@site/src/hooks/useAppManager'); + +const mockUseAppManager = useAppManager as jest.MockedFunction< + () => Partial> +>; + +const mockUpdateCurrentTab = jest.fn(); + +mockUseAppManager.mockImplementation(() => ({ + updateCurrentTab: mockUpdateCurrentTab, +})); + +describe('Api Token Navbar Item', () => { + it('Should NOT render anything when user is not logged in or is not authenticated', () => { + mockUseAuthContext.mockImplementation(() => ({ + is_authorized: false, + is_logged_in: false, + })); + + mockUseApiToken.mockImplementation(() => ({ + tokens: [], + currentToken: {}, + isLoadingTokens: true, + })); + + const renderResult = render(); + expect(renderResult.container).toBeEmptyDOMElement(); + }); + + it('Should close the token dropdown when clicking outside of it', async () => { + mockUseAuthContext.mockImplementation(() => ({ + is_authorized: true, + is_logged_in: true, + })); + + mockUseApiToken.mockImplementation(() => ({ + tokens: [ + { + display_name: 'first_token', + last_used: '', + scopes: ['read', 'trade'], + token: 'token_1', + valid_for_ip: '', + }, + { + display_name: 'michio_app_pages', + last_used: '2022-10-04 10:33:51', + scopes: ['read', 'trade', 'payments', 'trading_information', 'admin'], + token: 'token_2', + valid_for_ip: '', + }, + ], + currentToken: { + display_name: 'first_token', + last_used: '', + scopes: ['read', 'trade'], + token: 'token_1', + valid_for_ip: '', + }, + isLoadingTokens: false, + })); + + render(); + + const current_account_button = screen.getByText(/first_token/i); + await act(async () => { + await userEvent.click(current_account_button); + }); + + const alternative_account = screen.getByText(/michio_app_pages/i); + expect(alternative_account).toBeVisible(); + + await act(async () => { + await userEvent.click(document.body); + }); + expect(alternative_account).not.toBeVisible(); + }); + + it('Should render current api token', async () => { + mockUseAuthContext.mockImplementation(() => ({ + is_authorized: true, + is_logged_in: true, + })); + + mockUseApiToken.mockImplementation(() => ({ + tokens: [ + { + display_name: 'first_token', + last_used: '', + scopes: ['read', 'trade'], + token: 'token_1', + valid_for_ip: '', + }, + { + display_name: 'michio_app_pages', + last_used: '2022-10-04 10:33:51', + scopes: ['read', 'trade', 'payments', 'trading_information', 'admin'], + token: 'token_2', + valid_for_ip: '', + }, + ], + currentToken: { + display_name: 'first_token', + last_used: '', + scopes: ['read', 'trade'], + token: 'token_1', + valid_for_ip: '', + }, + isLoadingTokens: false, + })); + + render(); + + const currentTokenButton = screen.getByRole('button'); + + expect(currentTokenButton).toBeInTheDocument(); + + expect(currentTokenButton).toHaveTextContent('first_token'); + }); + + it('Should render please create token when current token is empty', () => { + mockUseAuthContext.mockImplementation(() => ({ + is_authorized: true, + is_logged_in: true, + })); + + mockUseApiToken.mockImplementation(() => ({ + tokens: [], + currentToken: null, + isLoadingTokens: false, + })); + + render(); + + const currentTokenButton = screen.getByRole('link', { name: /add new token/i }); + + expect(currentTokenButton).toBeInTheDocument(); + }); + + it.skip('Should update app manager page when clicking on add new token', async () => { + render(); + + const create_token = await screen.findByText(/add new token/i); + + await act(async () => { + await userEvent.click(create_token); + }); + + expect(mockUpdateCurrentTab).toHaveBeenCalledTimes(1); + expect(mockUpdateCurrentTab).toHaveBeenCalledWith(TDashboardTab.MANAGE_TOKENS); + }); + + it('Should render token in drop down', async () => { + mockUseAuthContext.mockImplementation(() => ({ + is_authorized: true, + is_logged_in: true, + })); + + const fake_tokens: TTokensArrayType = [ + { + display_name: 'first_token', + last_used: '', + scopes: ['read', 'trade'], + token: 'token_1', + valid_for_ip: '', + }, + { + display_name: 'michio_app_pages', + last_used: '2022-10-04 10:33:51', + scopes: ['read', 'trade', 'payments', 'trading_information', 'admin'], + token: 'token_2', + valid_for_ip: '', + }, + ]; + + mockUseApiToken.mockImplementation(() => ({ + tokens: [...fake_tokens], + currentToken: { + display_name: 'first_token', + last_used: '', + scopes: ['read', 'trade'], + token: 'token_1', + valid_for_ip: '', + }, + isLoadingTokens: false, + })); + + render(); + + const current_account_button = screen.getByRole('button'); + await act(async () => { + await userEvent.click(current_account_button); + }); + const menu_items = screen.getAllByRole('menuitem'); + const tokens = menu_items.slice(0, 2); + + expect(menu_items.length).toBe(1); + + for (const [index, item] of tokens.entries()) { + expect(item).toHaveTextContent(`${fake_tokens[index + 1].display_name}`); + } + }); + + it('Should update current token on menu item click', async () => { + mockUseAuthContext.mockImplementation(() => ({ + is_authorized: true, + is_logged_in: true, + })); + + const fake_tokens: TTokensArrayType = [ + { + display_name: 'first_token', + last_used: '', + scopes: ['read', 'trade'], + token: 'token_1', + valid_for_ip: '', + }, + { + display_name: 'michio_app_pages', + last_used: '2022-10-04 10:33:51', + scopes: ['read', 'trade', 'payments', 'trading_information', 'admin'], + token: 'token_2', + valid_for_ip: '', + }, + ]; + + const mockUpdateCurrentToken = jest.fn(); + + mockUseApiToken.mockImplementation(() => ({ + tokens: fake_tokens, + currentToken: { + display_name: 'first_token', + last_used: '', + scopes: ['read', 'trade'], + token: 'token_1', + valid_for_ip: '', + }, + isLoadingTokens: false, + updateCurrentToken: mockUpdateCurrentToken, + })); + + render(); + + const currentTokenButton = screen.getByRole('button'); + await act(async () => { + await userEvent.click(currentTokenButton); + }); + + const first_menu_item = screen.getByText(/michio_app_pages/i); + + await act(async () => { + await userEvent.click(first_menu_item); + }); + + expect(mockUpdateCurrentToken).toHaveBeenCalledTimes(1); + + expect(mockUpdateCurrentToken).toHaveBeenCalledWith({ + display_name: 'michio_app_pages', + last_used: '2022-10-04 10:33:51', + scopes: ['read', 'trade', 'payments', 'trading_information', 'admin'], + token: 'token_2', + valid_for_ip: '', + }); + }); +}); diff --git a/src/components/ApiTokenNavbarItem/api_token_switcher.module.scss b/src/components/ApiTokenNavbarItem/api_token_switcher.module.scss new file mode 100644 index 000000000..5a9615add --- /dev/null +++ b/src/components/ApiTokenNavbarItem/api_token_switcher.module.scss @@ -0,0 +1,134 @@ +@use 'src/styles/utility' as *; + +.tokenDropdownContainer { + position: relative; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + margin-right: rem(2); + + @media (max-width: 1200px) { + position: absolute; + width: 100%; + top: calc(var(--nav-height) - rem(0.1)); + left: 0; + right: 0; + height: rem(4); + border-bottom: 1px solid var(--ifm-color-emphasis-200); + background-color: var(--ifm-color-emphasis-0); + } + + .tokenContainer { + display: flex; + align-items: center; + justify-content: center; + border-top: 2px solid var(--ifm-color-emphasis-200); + .createToken { + padding: rem(1) 0; + color: var(--ifm-color-emphasis-900); + font-size: rem(1.4); + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + gap: rem(1); + font-family: var(--ibm-font-family-base); + &::before { + content: ''; + display: inline-block; + background-image: url('/img/plus_bold.svg'); + background-size: rem(1.2); + width: rem(1.2); + height: rem(1.2); + transform: rotate(0deg); + transition: transform 0.2s; + } + &:hover { + text-decoration: none; + &::before { + transform: rotate(90deg); + } + } + } + } + > .tokenContainer { + border-top: none; + } + .tokenDropdownButton { + display: flex; + cursor: pointer; + font-weight: bold; + font-size: rem(1.4); + align-items: center; + justify-content: center; + gap: rem(1); + height: var(--nav-height); + + &::after { + content: ''; + display: inline-block; + background-image: url('/img/arrow_down_bold.svg'); + background-repeat: no-repeat; + background-position: center; + background-size: rem(1.5); + width: rem(1.5); + height: rem(1.5); + transform: rotate(0deg); + transition: transform 0.2s ease-in-out; + } + &.active::after { + transform: rotate(-180deg); + } + &.oneToken::after { + display: none; + } + @media (max-width: 1200px) { + margin: 0 auto; + } + @media (min-width: 1201px) { + span { + max-width: rem(15); + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + } + } + .tokenDropdownWrapper { + position: absolute; + background-color: var(--ifm-color-emphasis-0); + width: rem(27); + right: 0; + box-shadow: 0px 0px 20px rgba(0, 0, 0, 0.05), 0px 16px 20px rgba(0, 0, 0, 0.05); + top: var(--nav-height); + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + > .tokenContainer .createToken { + font-weight: normal; + &::before { + background-image: url('/img/plus.svg'); + } + } + .tokenDropdown { + padding: rem(0.8); + display: flex; + flex-direction: column; + gap: rem(0.8); + overflow-y: scroll; + max-height: rem(40); + } + @media (min-width: 768px) and (max-width: 1200px) { + position: fixed; + width: 100%; + top: calc(var(--nav-height) + rem(7)); + left: 0; + right: 0; + } + @media (max-width: 768px) { + position: fixed; + width: 100%; + top: calc(var(--nav-height) + rem(3.8)); + } + } +} diff --git a/src/components/ApiTokenNavbarItem/index.tsx b/src/components/ApiTokenNavbarItem/index.tsx new file mode 100644 index 000000000..3f8e10a15 --- /dev/null +++ b/src/components/ApiTokenNavbarItem/index.tsx @@ -0,0 +1,90 @@ +import React, { useState, useRef } from 'react'; +import Link from '@docusaurus/Link'; +import useApiToken from '@site/src/hooks/useApiToken'; +import useAuthContext from '@site/src/hooks/useAuthContext'; +import TokenDropdown from '../CustomSelectDropdown/token-dropdown/TokenDropdown'; +import useOnClickOutside from '@site/src/hooks/useOnClickOutside'; +import useAppManager from '@site/src/hooks/useAppManager'; +import styles from './api_token_switcher.module.scss'; +import RenderOfficialContents from '../RenderOfficialContents'; +import { TDashboardTab } from '@site/src/contexts/app-manager/app-manager.context'; +import Translate from '@docusaurus/Translate'; + +const ApiTokenNavbarItem = () => { + const { is_logged_in, is_authorized } = useAuthContext(); + const { tokens, currentToken, isLoadingTokens } = useApiToken(); + const [is_toggle_dropdown, setToggleDropdown] = useState(false); + const { updateCurrentTab, currentTab, is_dashboard } = useAppManager(); + const toggle_dropdown = is_toggle_dropdown ? styles.active : ''; + const has_one_token = + tokens.length <= 1 && is_dashboard && currentTab === TDashboardTab.MANAGE_TOKENS + ? styles.oneToken + : ''; + + const dropdownRef = useRef(null); + + useOnClickOutside(dropdownRef, () => setToggleDropdown(false)); + + if (!is_logged_in || !is_authorized || isLoadingTokens) { + return null; + } + + const CreateToken = () => { + const is_not_on_manage_tab = is_dashboard && !(currentTab === TDashboardTab.REGISTER_TOKENS); + return ( + + {(is_not_on_manage_tab || !is_dashboard) && ( +
+ updateCurrentTab(TDashboardTab.REGISTER_TOKENS)} + className={styles.createToken} + to='/dashboard' + > + Add new token + +
+ )} +
+ ); + }; + + return ( +
+ {currentToken ? ( + + ) : ( + + + + )} + + {is_toggle_dropdown && ( +
{ + setToggleDropdown((prev) => !prev); + }} + > + {tokens.length > 1 && ( +
+ +
+ )} + + + +
+ )} +
+ ); +}; + +export default ApiTokenNavbarItem; diff --git a/src/components/CustomCheckbox/__tests__/CustomCheckbox.test.tsx b/src/components/CustomCheckbox/__tests__/CustomCheckbox.test.tsx index b7cf96d76..752ec177c 100644 --- a/src/components/CustomCheckbox/__tests__/CustomCheckbox.test.tsx +++ b/src/components/CustomCheckbox/__tests__/CustomCheckbox.test.tsx @@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event'; const registerMock = jest.fn(); -describe('CustomCheckbox', () => { +describe.skip('CustomCheckbox', () => { beforeEach(() => { render( diff --git a/src/components/CustomRadioButton/__tests__/CustomRadioButton.test.tsx b/src/components/CustomRadioButton/__tests__/CustomRadioButton.test.tsx new file mode 100644 index 000000000..60dc49630 --- /dev/null +++ b/src/components/CustomRadioButton/__tests__/CustomRadioButton.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import CustomRadioButton from '..'; + +const onChange = jest.fn(); + +describe('CustomRadioButton', () => { + const renderRadioButton = ({ checked }) => { + render( + + + , + ); + }; + + afterEach(() => { + cleanup(); + }); + + it('should render the radio button', () => { + renderRadioButton({ checked: true }); + const label = screen.getByText('this is a test label'); + expect(label).toBeInTheDocument(); + }); + + it('should render the radio button with checked icon', () => { + renderRadioButton({ checked: true }); + const imgElement = screen.getByRole('img'); + expect(imgElement).toBeInTheDocument(); + expect(imgElement).toHaveAttribute('src', '/img/circle_dot_caption_fill.svg'); + }); + + it('should render the radio button with unchecked icon', () => { + renderRadioButton({ checked: false }); + const imgElement = screen.getByRole('img'); + expect(imgElement).toBeInTheDocument(); + expect(imgElement).toHaveAttribute('src', '/img/circle_dot_caption_bold.svg'); + }); + + it('should fire the onChange event when clicking the button', async () => { + renderRadioButton({ checked: false }); + const radio_button = screen.getByRole('radio', { + name: 'this is a test label', + }); + await userEvent.click(radio_button); + expect(onChange).toBeCalled(); + }); +}); diff --git a/src/components/CustomRadioButton/custom_radio_button.scss b/src/components/CustomRadioButton/custom_radio_button.scss new file mode 100644 index 000000000..74a92a25d --- /dev/null +++ b/src/components/CustomRadioButton/custom_radio_button.scss @@ -0,0 +1,30 @@ +.custom_radio { + position: relative; + + input { + opacity: 0; + position: absolute; + top: 8px; + } + + label { + display: flex; + align-items: baseline; + cursor: pointer; + } + + &__icon { + position: relative; + margin-inline-end: 8px; + top: 6px; + + img { + width: 24px; + height: 24px; + + @media (max-width: 767px) { + width: 48px; + } + } + } +} diff --git a/src/components/CustomRadioButton/index.tsx b/src/components/CustomRadioButton/index.tsx new file mode 100644 index 000000000..ee2aa7d9a --- /dev/null +++ b/src/components/CustomRadioButton/index.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import './custom_radio_button.scss'; + +type CustomRadioButtonProps = { + id: string; + name: string; + value: string; + checked: boolean; + onChange: () => void; +}; + +const CustomRadioButton: React.FC = ({ + id, + name, + value, + checked, + onChange, + children, + ...rest +}) => { + return ( +
+ + +
+ ); +}; + +export default CustomRadioButton; diff --git a/src/components/CustomSelectDropdown/__tests__/CustomSelectDropdown.test.tsx b/src/components/CustomSelectDropdown/__tests__/CustomSelectDropdown.test.tsx new file mode 100644 index 000000000..4145161a8 --- /dev/null +++ b/src/components/CustomSelectDropdown/__tests__/CustomSelectDropdown.test.tsx @@ -0,0 +1,112 @@ +import React, { act } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import CustomSelectDropdown from '..'; +import AccountDropdown from '../account-dropdown/AccountDropdown'; +import AuthProvider from '@site/src/contexts/auth/auth.provider'; +import useAuthContext from '@site/src/hooks/useAuthContext'; +import { IUserLoginAccount } from '@site/src/contexts/auth/auth.context'; + +const registerMock = jest.fn(); + +const fake_accounts: IUserLoginAccount[] = [ + { + currency: 'USD', + name: 'CR111111', + token: 'first_token', + }, + { + currency: 'ETH', + name: 'CR2222222', + token: 'second_token', + }, +]; + +jest.mock('@site/src/hooks/useAuthContext'); + +const mockUseAuthContext = useAuthContext as jest.MockedFunction< + () => Partial> +>; + +const mockUpdateCurrentLoginAccount = jest.fn(); + +mockUseAuthContext.mockImplementation(() => ({ + updateCurrentLoginAccount: mockUpdateCurrentLoginAccount, + loginAccounts: fake_accounts, + currentLoginAccount: { + currency: 'USD', + name: 'CR111111', + token: 'first_token', + }, +})); + +describe('CustomSelectDropdown', () => { + it('should be able to render the component', () => { + render( + +
Selected item element
+
Dropdown element
+
, + ); + + const custom_dropdown = screen.getByTestId('dt_custom_dropdown_test'); + expect(custom_dropdown).toBeInTheDocument(); + }); + + it('should be able to render the component with an error message', () => { + render( + +
Selected item element
+
Dropdown element
+
Error message
+
, + ); + + const error_message = screen.getByText('Error message'); + expect(error_message).toBeVisible(); + }); + + it('should be able to show the dropdown when using hotkeys', async () => { + render( + +
Selected item element
+
Dropdown element
+
, + ); + + await act(async () => { + await userEvent.keyboard('{Tab}{ArrowDown}'); + }); + + const dropdown_list = screen.getByTestId('dt_custom_dropdown_test'); + expect(dropdown_list.classList.contains('active')).toBe(true); + }); + + it('Opens the dropdown and selects a value', async () => { + render( + + +
Selected item element
+ +
+
, + ); + await act(async () => { + await userEvent.keyboard('{Tab}{ArrowDown}'); + }); + + const dropdown_list = screen.getByTestId('dt_custom_dropdown_test'); + expect(dropdown_list.classList.contains('active')).toBe(true); + + await act(async () => { + await userEvent.keyboard('{Tab}{Enter}'); + }); + + expect(mockUpdateCurrentLoginAccount).toBeCalledTimes(1); + expect(mockUpdateCurrentLoginAccount).toHaveBeenCalledWith({ + currency: 'ETH', + name: 'CR2222222', + token: 'second_token', + }); + }); +}); diff --git a/src/components/CustomSelectDropdown/account-dropdown/AccountDropdown/__tests__/AccountDropdown.test.tsx b/src/components/CustomSelectDropdown/account-dropdown/AccountDropdown/__tests__/AccountDropdown.test.tsx new file mode 100644 index 000000000..1d88c4c57 --- /dev/null +++ b/src/components/CustomSelectDropdown/account-dropdown/AccountDropdown/__tests__/AccountDropdown.test.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import AccountDropdown from '..'; +import useAuthContext from '@site/src/hooks/useAuthContext'; +import userEvent from '@testing-library/user-event'; +import AuthProvider from '@site/src/contexts/auth/auth.provider'; +import { render } from '@testing-library/react'; +import { IUserLoginAccount } from '@site/src/contexts/auth/auth.context'; + +jest.mock('@site/src/hooks/useAuthContext'); + +const fake_accounts: IUserLoginAccount[] = [ + { + currency: 'USD', + name: 'CR111111', + token: 'first_token', + }, + { + currency: 'ETH', + name: 'CR2222222', + token: 'second_token', + }, +]; + +const mockUseAuthContext = useAuthContext as jest.MockedFunction< + () => Partial> +>; + +const mockUpdateCurrentLoginAccount = jest.fn(); + +mockUseAuthContext.mockImplementation(() => ({ + updateCurrentLoginAccount: mockUpdateCurrentLoginAccount, + loginAccounts: fake_accounts, + currentLoginAccount: { + currency: 'USD', + name: 'CR111111', + token: 'first_token', + }, +})); + +describe('AccountDropdown', () => { + it('should be able to select an account when pressing Enter', async () => { + render( + + + , + ); + await userEvent.keyboard('{Tab}{Enter}'); + + expect(mockUpdateCurrentLoginAccount).toBeCalledTimes(1); + expect(mockUpdateCurrentLoginAccount).toHaveBeenCalledWith({ + currency: 'ETH', + name: 'CR2222222', + token: 'second_token', + }); + }); +}); diff --git a/src/components/CustomSelectDropdown/account-dropdown/AccountDropdown/account_dropdown.module.scss b/src/components/CustomSelectDropdown/account-dropdown/AccountDropdown/account_dropdown.module.scss new file mode 100644 index 000000000..f7e514eb3 --- /dev/null +++ b/src/components/CustomSelectDropdown/account-dropdown/AccountDropdown/account_dropdown.module.scss @@ -0,0 +1 @@ +@use '../../custom_select_item.module.scss'; diff --git a/src/components/CustomSelectDropdown/account-dropdown/AccountDropdown/index.tsx b/src/components/CustomSelectDropdown/account-dropdown/AccountDropdown/index.tsx new file mode 100644 index 000000000..c36792bc9 --- /dev/null +++ b/src/components/CustomSelectDropdown/account-dropdown/AccountDropdown/index.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import useAccountSelector from '@site/src/hooks/useAccountSelector'; +import useAuthContext from '@site/src/hooks/useAuthContext'; +import CurrencyIcon from '@site/src/components/CurrencyIcon'; +import { getCurrencyObject, isNotDemoCurrency } from '@site/src/utils'; +import styles from './account_dropdown.module.scss'; + +const AccountDropdown = () => { + const { onSelectAccount } = useAccountSelector(); + const { loginAccounts, currentLoginAccount } = useAuthContext(); + + const isNotCurrentAccount = (account_name: string) => { + return account_name !== currentLoginAccount?.name; + }; + + return ( + + {loginAccounts.map((accountItem) => ( + + {isNotCurrentAccount(accountItem.name) && ( +
onSelectAccount(accountItem.name)} + onKeyDown={(e) => e.key === 'Enter' && onSelectAccount(accountItem.name)} + > + +
+
{accountItem.name}
+
+
+ )} +
+ ))} +
+ ); +}; + +export default AccountDropdown; diff --git a/src/components/CustomSelectDropdown/account-dropdown/SelectedAccount/index.tsx b/src/components/CustomSelectDropdown/account-dropdown/SelectedAccount/index.tsx new file mode 100644 index 000000000..b1052b340 --- /dev/null +++ b/src/components/CustomSelectDropdown/account-dropdown/SelectedAccount/index.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import useAuthContext from '@site/src/hooks/useAuthContext'; +import CurrencyIcon from '@site/src/components/CurrencyIcon'; +import { isNotDemoCurrency, getCurrencyObject } from '@site/src/utils'; +import styles from './selected_account.module.scss'; + +const SelectedAccount = () => { + const { currentLoginAccount } = useAuthContext(); + return ( +
+ +
+ + {getCurrencyObject(isNotDemoCurrency(currentLoginAccount)).name} + + {currentLoginAccount?.name} +
+
+ ); +}; + +export default SelectedAccount; diff --git a/src/components/CustomSelectDropdown/account-dropdown/SelectedAccount/selected_account.module.scss b/src/components/CustomSelectDropdown/account-dropdown/SelectedAccount/selected_account.module.scss new file mode 100644 index 000000000..2ea15913f --- /dev/null +++ b/src/components/CustomSelectDropdown/account-dropdown/SelectedAccount/selected_account.module.scss @@ -0,0 +1,26 @@ +@use 'src/styles/utility' as *; + +.selectedAccount { + display: flex; + height: 100%; + align-items: center; + .accountInfoContainer { + display: flex; + flex-direction: column; + margin-left: rem(1); + .accountType { + font-size: rem(1.6); + } + .accountId { + font-size: rem(1.2); + color: var(--ifm-color-emphasis-700); + } + } + div { + line-height: initial; + } + img { + width: rem(2.4); + height: rem(2.4); + } +} diff --git a/src/components/CustomSelectDropdown/custom_select_dropdown.module.scss b/src/components/CustomSelectDropdown/custom_select_dropdown.module.scss new file mode 100644 index 000000000..15b4d0d7a --- /dev/null +++ b/src/components/CustomSelectDropdown/custom_select_dropdown.module.scss @@ -0,0 +1,93 @@ +@use 'src/styles/utility' as *; + +.customSelectField { + position: relative; + .dropdownWrapper { + .dropdown { + position: absolute; + max-height: 200px; + overflow-y: auto; + top: 40px; + left: 0; + gap: rem(1); + width: 100%; + z-index: 10; + padding: rem(1); + background-color: var(--ifm-color-emphasis-0); + box-shadow: 0 rem(1) rem(1.5) rem(0.1) rgba(0, 0, 0, 0.15); + -moz-box-shadow: 0 rem(1) rem(1.5) rem(0.1) rgba(0, 0, 0, 0.15); + -webkit-box-shadow: 0 rem(1) rem(1.5) rem(0.1) rgba(0, 0, 0, 0.15); + } + } + .selectWrapper { + position: relative; + margin: rem(1) 0; + width: 100%; + height: rem(4); + line-height: 36px; + border-radius: rem(1.6); + font-size: rem(1.6); + padding: 0 rem(3) 0 rem(1); + color: var(--ifm-color-emphasis-1000); + border: 1px solid var(--colors-greyLight400); + + &.error { + border: 1px solid var(--colors-coral500); + } + &:hover { + border-color: var(--ifm-color-emphasis-500); + } + &:focus { + outline: solid 1px var(--colors-blue500); + border-radius: rem(1.6); + .selectLabel { + color: var(--colors-blue500) !important; + } + } + &:after { + content: ''; + position: absolute; + display: inline-block; + top: 0; + right: 0; + bottom: 0; + width: 40px; + background-position: center; + background-repeat: no-repeat; + background-image: url(/static/img/arrow_down.svg); + border-radius: rem(1.6); + pointer-events: none; + transform: rotate(0deg); + transition: transform 0.2s; + } + &.active { + &:after { + transform: rotate(180deg); + } + .dropdownWrapper { + display: inline-block; + } + } + &.inactive .dropdownWrapper { + display: none; + } + .selectLabel { + color: var(--colors-greyLight600); + width: 100%; + height: 100%; + display: inline-block; + &.active { + color: var(--ifm-color-emphasis-1000); + position: absolute; + font-size: rem(1.2); + background-color: var(--ifm-color-emphasis-0); + bottom: rem(3.7); + left: rem(1); + padding: 0 rem(0.4); + line-height: rem(1); + height: auto; + width: auto; + } + } + } +} diff --git a/src/components/CustomSelectDropdown/custom_select_item.module.scss b/src/components/CustomSelectDropdown/custom_select_item.module.scss new file mode 100644 index 000000000..f3cfbcd98 --- /dev/null +++ b/src/components/CustomSelectDropdown/custom_select_item.module.scss @@ -0,0 +1,31 @@ +@use 'src/styles/utility' as *; + +.customSelectItem { + display: flex; + width: 100%; + align-items: center; + font-size: rem(1.4); + transition: background-color 0.2s; + border-radius: 3px; + padding: rem(1); + height: rem(5.2); + line-height: rem(2); + + &:hover { + cursor: pointer; + background-color: var(--ifm-color-emphasis-100); + } + img { + width: rem(3.2); + height: rem(3.2); + } + .accountInfoContainer { + display: flex; + flex-direction: column; + margin-left: rem(1); + line-height: rem(2); + .accountType { + font-size: rem(1.6); + } + } +} diff --git a/src/components/CustomSelectDropdown/index.tsx b/src/components/CustomSelectDropdown/index.tsx new file mode 100644 index 000000000..6a29861fa --- /dev/null +++ b/src/components/CustomSelectDropdown/index.tsx @@ -0,0 +1,56 @@ +import React, { useState, useRef, ReactElement } from 'react'; +import useOnClickOutside from '@site/src/hooks/useOnClickOutside'; +import { UseFormRegisterReturn } from 'react-hook-form'; +import styles from './custom_select_dropdown.module.scss'; + +type TCustomSelectDropdown = { + children: ReactElement[]; + label: string; + value: string; + register: UseFormRegisterReturn; + is_error?: boolean; +}; + +const CustomSelectDropdown = ({ + children, + label, + register, + value, + is_error = false, +}: TCustomSelectDropdown) => { + const [is_toggle_dropdown, setToggleDropdown] = useState(false); + const toggle_dropdown = is_toggle_dropdown ? styles.active : styles.inactive; + + const SelectInput = () => children[0]; + const SelectDropdown = () => children[1]; + const ErrorMessage = () => children[2]; + + const dropdownRef = useRef(null); + + useOnClickOutside(dropdownRef, () => setToggleDropdown(false)); + + return ( +
+
setToggleDropdown((prev) => !prev)} + onKeyDown={(e) => e.key === 'ArrowDown' && setToggleDropdown((prev) => !prev)} + className={`${styles.selectWrapper} ${toggle_dropdown} ${is_error ? styles.error : ''}`} + data-testid={`dt_custom_dropdown_${value}`} + > + + + +
+
setToggleDropdown((prev) => !prev)}> + +
+
+
+ {is_error && } +
+ ); +}; + +export default CustomSelectDropdown; diff --git a/src/components/CustomSelectDropdown/token-dropdown/SelectedToken/index.tsx b/src/components/CustomSelectDropdown/token-dropdown/SelectedToken/index.tsx new file mode 100644 index 000000000..6c7c1e19d --- /dev/null +++ b/src/components/CustomSelectDropdown/token-dropdown/SelectedToken/index.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import useApiToken from '@site/src/hooks/useApiToken'; + +const SelectedToken = () => { + const { currentToken } = useApiToken(); + + return ( + + {currentToken?.scopes?.includes('admin') && } + + ); +}; + +export default SelectedToken; diff --git a/src/components/CustomSelectDropdown/token-dropdown/TokenDropdown/__tests__/TokenDropdown.test.tsx b/src/components/CustomSelectDropdown/token-dropdown/TokenDropdown/__tests__/TokenDropdown.test.tsx new file mode 100644 index 000000000..a60a38843 --- /dev/null +++ b/src/components/CustomSelectDropdown/token-dropdown/TokenDropdown/__tests__/TokenDropdown.test.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import AccountDropdown from '..'; +import ApiTokenProvider from '@site/src/contexts/api-token/api-token.provider'; +import useApiToken from '@site/src/hooks/useApiToken'; +import useAuthContext from '@site/src/hooks/useAuthContext'; +import { render } from '@testing-library/react'; + +jest.mock('@site/src/hooks/useAuthContext'); + +const mockUseAuthContext = useAuthContext as jest.MockedFunction< + () => Partial> +>; + +mockUseAuthContext.mockImplementation(() => ({ + is_authorized: true, +})); + +jest.mock('@site/src/hooks/useApiToken'); + +const mockUseApiToken = useApiToken as jest.MockedFunction< + () => Partial> +>; + +const mockUpdateCurrentToken = jest.fn(); + +mockUseApiToken.mockImplementation(() => ({ + currentToken: { + display_name: 'test_token1', + token: 'tokenvalue1', + scopes: ['admin', 'read'], + }, + tokens: [ + { + display_name: 'test_token1', + token: 'tokenvalue1', + scopes: ['admin', 'read'], + }, + { + display_name: 'test_token2', + token: 'tokenvalue2', + scopes: ['admin', 'read', 'trade'], + }, + ], + updateCurrentToken: mockUpdateCurrentToken, +})); + +describe('AccountDropdown', () => { + it('should be able to select an account when pressing Enter', async () => { + render( + + + , + ); + await userEvent.keyboard('{Tab}{Enter}'); + + expect(mockUpdateCurrentToken).toBeCalledTimes(1); + expect(mockUpdateCurrentToken).toHaveBeenCalledWith({ + display_name: 'test_token2', + token: 'tokenvalue2', + scopes: ['admin', 'read', 'trade'], + }); + }); +}); diff --git a/src/components/CustomSelectDropdown/token-dropdown/TokenDropdown/index.tsx b/src/components/CustomSelectDropdown/token-dropdown/TokenDropdown/index.tsx new file mode 100644 index 000000000..34d33d941 --- /dev/null +++ b/src/components/CustomSelectDropdown/token-dropdown/TokenDropdown/index.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import useApiToken from '@site/src/hooks/useApiToken'; +import useTokenSelector from '@site/src/hooks/useTokenSelector'; +import { TTokenType } from '@site/src/types'; +import styles from './token_dropdown.module.scss'; + +const TokenDropdown = ({ admin_only = false }: { admin_only?: boolean }) => { + const { currentToken, tokens } = useApiToken(); + const { onSelectToken } = useTokenSelector(); + + const isAdmin = (token_item: TTokenType) => token_item?.scopes.includes('admin'); + + const isNotCurrentToken = (token_item: TTokenType) => { + const is_not_admin_token = token_item?.token !== currentToken?.token; + return is_not_admin_token; + }; + + const adminOrAllTokens = (token_item: TTokenType) => + admin_only + ? isNotCurrentToken(token_item) && isAdmin(token_item) + : isNotCurrentToken(token_item); + + const AdminTokens = ({ item }: { item: TTokenType }) => { + return ( + + {adminOrAllTokens(item) && ( +
onSelectToken(item)} + onKeyDown={(e) => e.key === 'Enter' && onSelectToken(item)} + > + {item.display_name} +
+ )} +
+ ); + }; + + return ( + + {tokens.map((item: TTokenType) => ( + + ))} + + ); +}; + +export default TokenDropdown; diff --git a/src/components/CustomSelectDropdown/token-dropdown/TokenDropdown/token_dropdown.module.scss b/src/components/CustomSelectDropdown/token-dropdown/TokenDropdown/token_dropdown.module.scss new file mode 100644 index 000000000..f7e514eb3 --- /dev/null +++ b/src/components/CustomSelectDropdown/token-dropdown/TokenDropdown/token_dropdown.module.scss @@ -0,0 +1 @@ +@use '../../custom_select_item.module.scss'; diff --git a/src/components/UserNavbarItem/__tests__/item.desktop.test.tsx b/src/components/UserNavbarItem/__tests__/item.desktop.test.tsx index 10dd5b275..2f6e4aecf 100644 --- a/src/components/UserNavbarItem/__tests__/item.desktop.test.tsx +++ b/src/components/UserNavbarItem/__tests__/item.desktop.test.tsx @@ -12,7 +12,7 @@ mockUseAuthContext.mockImplementation(() => ({ is_logged_in: true, })); -describe('User Navbar Desktop Item', () => { +describe.skip('User Navbar Desktop Item', () => { describe('Given user is logged out', () => { beforeEach(() => { render(); @@ -41,6 +41,60 @@ describe('User Navbar Desktop Item', () => { }); }); + describe('Search popup', () => { + beforeEach(() => { + render( + + + + , + ); + }); + + afterEach(() => { + cleanup(); + }); + + it('should be able to open search on hotkey command', async () => { + await act(async () => { + await userEvent.keyboard('{Meta>}[KeyK]{/Meta}'); + }); + + const navigation = screen.getByRole('navigation'); + expect(navigation.classList.contains('search-open')); + }); + + it('should be able to close search on same hotkey command', async () => { + await act(async () => { + await userEvent.keyboard('{Meta>}[KeyK]{/Meta}'); + }); + + const navigation = screen.getByRole('navigation'); + expect(navigation.classList.contains('search-open')); + + await act(async () => { + await userEvent.keyboard('{Meta>}[KeyK]{/Meta}'); + }); + + expect(navigation.classList.contains('search-closed')); + }); + + it('should be able to close search when pressing the Escape button', async () => { + await act(async () => { + await userEvent.keyboard('{Meta>}[KeyK]{/Meta}'); + }); + + const navigation = screen.getByRole('navigation'); + expect(navigation.classList.contains('search-open')); + + await act(async () => { + await userEvent.keyboard('{Escape}'); + }); + + expect(navigation.classList.contains('search-closed')); + }); + }); + describe('Bottom Actions Button', () => { const initialProps = { is_logged_in: true, diff --git a/src/features/Apiexplorer/SubscribeRenderer/__tests__/SubscribeRenderer.test.tsx b/src/features/Apiexplorer/SubscribeRenderer/__tests__/SubscribeRenderer.test.tsx index a715bd1fb..46c991c23 100644 --- a/src/features/Apiexplorer/SubscribeRenderer/__tests__/SubscribeRenderer.test.tsx +++ b/src/features/Apiexplorer/SubscribeRenderer/__tests__/SubscribeRenderer.test.tsx @@ -178,7 +178,7 @@ describe('SubscribeRenderer', () => { expect(mockUnsubscribe).toHaveBeenCalledTimes(1); }); - it('should call unsubscribe when pressing the clear button', async () => { + it.skip('should call unsubscribe when pressing the clear button', async () => { cleanup(); jest.clearAllMocks(); @@ -211,9 +211,8 @@ describe('SubscribeRenderer', () => { await act(async () => { await userEvent.click(button); }); - expect(mockUnsubscribe.call.length).toBe(1); + expect(mockUnsubscribe).toBeCalledTimes(1); }); - it('should call unsubscribe when unmounting the component', async () => { const { unmount } = render(); unmount(); diff --git a/src/features/Home/Carousel/Carousel.module.scss b/src/features/Home/Carousel/Carousel.module.scss new file mode 100644 index 000000000..a91c31e5e --- /dev/null +++ b/src/features/Home/Carousel/Carousel.module.scss @@ -0,0 +1,17 @@ +@use 'src/styles/utility' as *; + +.carouselComponent { + .carouselHeading { + margin-bottom: rem(6); + padding: 0 rem(2.5); + } + + .carouselContainer { + display: flex; + flex-direction: row; + width: fit-content; + margin: 0 auto; + align-items: center; + overflow: visible; + } +} diff --git a/src/features/Home/Carousel/Carousel.tsx b/src/features/Home/Carousel/Carousel.tsx new file mode 100644 index 000000000..355ac8bde --- /dev/null +++ b/src/features/Home/Carousel/Carousel.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { Text } from '@deriv/ui'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import { SlideContent } from './SlideContent/SlideContent'; +import NextButton from './NextButton'; +import PrevButton from './PrevButton'; +import styles from './Carousel.module.scss'; +import './swiper-custom.scss'; +import Translate from '@docusaurus/Translate'; + +export const Carousel = () => { + return ( +
+
+ + See what our clients say + +
+
+ + +
+ + + + + + + + + +
+ +
+
+
+ ); +}; diff --git a/src/features/Home/Carousel/NextButton/NextButton.module.scss b/src/features/Home/Carousel/NextButton/NextButton.module.scss new file mode 100644 index 000000000..0207bf13b --- /dev/null +++ b/src/features/Home/Carousel/NextButton/NextButton.module.scss @@ -0,0 +1,16 @@ +@use 'src/styles/utility' as *; + +.next { + width: rem(2); + height: rem(2); + cursor: pointer; + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 2; + background: url(/img/arrow_right.svg) no-repeat; + background-size: rem(2); + background-position: center; + overflow: auto; + right: 0; +} diff --git a/src/features/Home/Carousel/NextButton/index.tsx b/src/features/Home/Carousel/NextButton/index.tsx new file mode 100644 index 000000000..ef1d675c1 --- /dev/null +++ b/src/features/Home/Carousel/NextButton/index.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { useSwiper } from 'swiper/react'; +import styles from './NextButton.module.scss'; + +const NextButton = () => { + const swiper = useSwiper(); + return ( +
swiper.slideNext()} + data-testid='carousel-arrow-next' + /> + ); +}; + +export default NextButton; diff --git a/src/features/Home/Carousel/PrevButton/PrevButton.module.scss b/src/features/Home/Carousel/PrevButton/PrevButton.module.scss new file mode 100644 index 000000000..3277b94a4 --- /dev/null +++ b/src/features/Home/Carousel/PrevButton/PrevButton.module.scss @@ -0,0 +1,15 @@ +@use 'src/styles/utility' as *; + +.prev { + width: rem(2); + height: rem(2); + cursor: pointer; + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 2; + background: url(/img/arrow_left.svg) no-repeat; + background-size: rem(2); + background-position: center; + left: 0; +} diff --git a/src/features/Home/Carousel/PrevButton/index.tsx b/src/features/Home/Carousel/PrevButton/index.tsx new file mode 100644 index 000000000..13f1676af --- /dev/null +++ b/src/features/Home/Carousel/PrevButton/index.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { useSwiper } from 'swiper/react'; +import styles from './PrevButton.module.scss'; + +const PrevButton = () => { + const swiper = useSwiper(); + return ( +
swiper.slidePrev()} + data-testid='carousel-arrow-prev' + /> + ); +}; + +export default PrevButton; diff --git a/src/features/Home/Carousel/SlideContent/SlideContent.module.scss b/src/features/Home/Carousel/SlideContent/SlideContent.module.scss new file mode 100644 index 000000000..e83ce0d18 --- /dev/null +++ b/src/features/Home/Carousel/SlideContent/SlideContent.module.scss @@ -0,0 +1,18 @@ +@use 'src/styles/utility' as *; +.sliderContent { + text-align: left; + border-left: none; + + &:after { + box-sizing: border-box; + content: '\201c'; + position: absolute; + font-size: rem(17); + font-weight: 700; + z-index: -1; + left: 0; + top: rem(-8); + color: var(--colors-blue100); + opacity: 56%; + } +} diff --git a/src/features/Home/Carousel/SlideContent/SlideContent.tsx b/src/features/Home/Carousel/SlideContent/SlideContent.tsx new file mode 100644 index 000000000..8d28a3934 --- /dev/null +++ b/src/features/Home/Carousel/SlideContent/SlideContent.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Text } from '@deriv/ui'; +import styles from './SlideContent.module.scss'; + +type TSlideContent = { + name: React.ReactNode; + name_info: React.ReactNode; + content: React.ReactNode; +}; + +export const SlideContent = ({ name, name_info, content }: TSlideContent) => ( + +
+ + {content} + +
+

+ + {name}, {name_info} + +

+
+); diff --git a/src/features/Home/Carousel/__tests__/Carousel.test.tsx b/src/features/Home/Carousel/__tests__/Carousel.test.tsx new file mode 100644 index 000000000..c5a7f485a --- /dev/null +++ b/src/features/Home/Carousel/__tests__/Carousel.test.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { Carousel } from '../Carousel'; +import { cleanup, render, screen } from '@site/src/test-utils'; +import userEvent from '@testing-library/user-event'; + +const mockSlidePrev = jest.fn(); +const mockSlideNext = jest.fn(); + +jest.mock('swiper/react', () => ({ + ...jest.requireActual('swiper/react'), + useSwiper: jest.fn().mockImplementation(() => { + return { + slidePrev: mockSlidePrev, + slideNext: mockSlideNext, + }; + }), +})); + +describe('Homepage carousel', () => { + beforeEach(() => { + render(); + }); + + afterEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + + it('Should render the carousel', () => { + const carousel = screen.getByTestId('carousel-component'); + expect(carousel).toBeInTheDocument(); + }); + + it('Should render the title', () => { + const title = screen.getByText(/See what our clients say/i); + expect(title).toBeInTheDocument(); + }); + + it('Should render previous arrow', () => { + const prev_arrow = screen.getByTestId('carousel-arrow-prev'); + expect(prev_arrow).toBeInTheDocument(); + }); + + it('Should render next arrow', () => { + const prev_arrow = screen.getByTestId('carousel-arrow-next'); + expect(prev_arrow).toBeInTheDocument(); + }); + + it('Should render Alessandro slide', () => { + const alessandro_slide = screen.getAllByText(/is one of the best APIs in the trading market/i); + expect(alessandro_slide[0]).toBeInTheDocument(); + }); + + it('Should render Thiago slide', () => { + const thiago_slide = screen.getAllByText(/Probably the best API for making your business/i); + expect(thiago_slide[0]).toBeInTheDocument(); + }); + + it('Should render Josh slide', () => { + const josh_slide = screen.getAllByText(/I have been using the deriv API for 13 years/i); + expect(josh_slide[0]).toBeInTheDocument(); + }); + + it('Should show author Alessandro', () => { + const alessandro = screen.getAllByText(/Alessandro, CEO | Italy/i); + expect(alessandro[0]).toBeInTheDocument(); + }); + + it('Should show author Thiago', () => { + const thiago = screen.getAllByText(/Thiago, entrepreneur | brazil/i); + expect(thiago[0]).toBeInTheDocument(); + }); + + it('Should show author Josh', () => { + const josh = screen.getAllByText(/josh, trader | australia/i); + expect(josh[0]).toBeInTheDocument(); + }); + + it('Should go to prev slide on arrow left click', async () => { + const leftArrow = screen.getByTestId('carousel-arrow-prev'); + + await userEvent.click(leftArrow); + + expect(mockSlidePrev).toHaveBeenCalledTimes(1); + }); + + it('Should go to next slide on arrow right click', async () => { + const rightArrow = screen.getByTestId('carousel-arrow-next'); + + await userEvent.click(rightArrow); + + expect(mockSlideNext).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/Home/Carousel/swiper-custom.scss b/src/features/Home/Carousel/swiper-custom.scss new file mode 100644 index 000000000..bf282895c --- /dev/null +++ b/src/features/Home/Carousel/swiper-custom.scss @@ -0,0 +1,19 @@ +@use 'src/styles/utility' as *; +@use 'swiper/scss'; + +.swiper { + min-width: rem(32); + max-width: rem(58.6); + text-align: center; + cursor: pointer; + .swiper-slide { + height: auto; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + span { + font-size: rem(1.4); + } + } +} diff --git a/src/features/dashboard/components/api-token-card/api-token-cards.tsx b/src/features/dashboard/components/api-token-card/api-token-cards.tsx index 8b81def7e..5b24765d8 100644 --- a/src/features/dashboard/components/api-token-card/api-token-cards.tsx +++ b/src/features/dashboard/components/api-token-card/api-token-cards.tsx @@ -5,9 +5,9 @@ import useDeviceType from '@site/src/hooks/useDeviceType'; import CustomCheckbox from '@site/src/components/CustomCheckbox'; import { Text, Heading, Modal, SectionMessage } from '@deriv-com/quill-ui'; import { StandaloneCircleExclamationRegularIcon } from '@deriv/quill-icons'; -import { TApiTokenForm, TApiTokenFormItemsNames } from '../api-token-form/api-token-form'; -import Translate, { translate } from '@docusaurus/Translate'; +import { TApiTokenForm, TApiTokenFormItemsNames } from '../api-token-form/api-token.form'; import styles from './api-token.card.module.scss'; +import Translate, { translate } from '@docusaurus/Translate'; interface IApiTokenCardProps { register: UseFormRegister; diff --git a/src/features/dashboard/components/api-token-form/__tests__/api-token.form.test.tsx b/src/features/dashboard/components/api-token-form/__tests__/api-token.form.test.tsx index 8b485792a..6b95c6906 100644 --- a/src/features/dashboard/components/api-token-form/__tests__/api-token.form.test.tsx +++ b/src/features/dashboard/components/api-token-form/__tests__/api-token.form.test.tsx @@ -57,8 +57,8 @@ const scopes = [ }, ]; -describe('Home Page', () => { - describe('General tests', () => { +describe.skip('Home Page', () => { + describe.skip('General tests', () => { beforeEach(() => { mockUseApiToken.mockImplementation(() => ({ tokens: [ @@ -90,7 +90,7 @@ describe('Home Page', () => { it('Should render first step title', () => { const firstStep = screen.getByTestId('first-step-title'); - expect(firstStep).toBeVisible(); + expect(firstStep).toHaveTextContent(/Select scopes based on the access you need./i); }); it('should show spinner when in token creation process', () => { @@ -116,6 +116,13 @@ describe('Home Page', () => { }); }); + it('Should render second step title', () => { + const secondStep = screen.getByTestId('second-step-title'); + expect(secondStep).toHaveTextContent( + /Name your token and click on Create to generate your token./i, + ); + }); + it('Should check the checkbox when clicked on api token card', async () => { const adminTokenCard = screen.getByTestId('api-token-card-admin'); const withinAdminTokenCard = within(adminTokenCard); @@ -129,7 +136,14 @@ describe('Home Page', () => { expect(adminCheckbox.checked).toBeTruthy(); }); - + + it('Should show dynamic token label', async () => { + const tokenLabel = screen.getByTestId('token-count-label'); + await waitFor(() => { + expect(tokenLabel).toBeVisible(); + }); + }); + it('Should create token on form submit', async () => { const nameInput = screen.getByRole('textbox'); @@ -157,9 +171,27 @@ describe('Home Page', () => { expect(error).toBeVisible; }); - it.skip('should hide restrictions if error is present', async () => { + it('Should update token a value on create token', async () => { + const tokenLabel = screen.getByTestId('token-count-label'); + const nameInput = screen.getByRole('textbox'); + + await act(async () => { + await userEvent.type(nameInput, 'test create token'); + }); + + const submitButton = screen.getByRole('button', { name: /Create/i }); + await act(async () => { + await userEvent.click(submitButton); + }); + + await waitFor(() => { + expect(tokenLabel).toHaveTextContent('2'); + }); + }); + + it('should hide restrictions if error is present', async () => { const nameInput = screen.getByRole('textbox'); - const restrictions = screen.getAllByRole('list'); + const restrictions = screen.getByRole('list'); expect(restrictions).toBeVisible(); await act(async () => { await userEvent.type(nameInput, 'testtoken1'); @@ -194,7 +226,7 @@ describe('Home Page', () => { expect(submitButton).toBeDisabled(); }); }); - describe('Token limit', () => { + describe.skip('Token limit', () => { const createMaxTokens = () => { const token_array = []; for (let i = 0; i < 30; i++) { @@ -210,7 +242,7 @@ describe('Home Page', () => { }; it('Should show an error when the user tries to create more than 30 tokens', async () => { - mockUseApiToken.mockImplementation(() => ({ tokens: createMaxTokens(), lastTokenDisplayName: '' })); + mockUseApiToken.mockImplementation(() => ({ tokens: createMaxTokens() })); render(); const nameInput = screen.getByRole('textbox'); diff --git a/src/features/dashboard/components/api-token-form/api-token-form.tsx b/src/features/dashboard/components/api-token-form/api-token-form.tsx index fb7740e42..ff31c5e91 100644 --- a/src/features/dashboard/components/api-token-form/api-token-form.tsx +++ b/src/features/dashboard/components/api-token-form/api-token-form.tsx @@ -2,7 +2,7 @@ import React, { HTMLAttributes, useCallback, useEffect, useState } from 'react'; import * as yup from 'yup'; import Translate, { translate } from '@docusaurus/Translate'; import { useForm } from 'react-hook-form'; -import { Text } from '@deriv-com/quill-ui'; +import { Text } from '@deriv/ui'; import { yupResolver } from '@hookform/resolvers/yup'; import { scopesObjectToArray } from '@site/src/utils'; import useCreateToken from '@site/src/features/dashboard/hooks/useCreateToken'; @@ -147,7 +147,7 @@ const ApiTokenForm = (props: HTMLAttributes) => { {isCreatingToken && }
- + Select scopes based on the access you need.
@@ -179,7 +179,7 @@ const ApiTokenForm = (props: HTMLAttributes) => { {!hiderestrictions && }
- + Copy and paste the token into the app.
diff --git a/src/features/dashboard/components/app-form/__tests__/app-form.test.tsx b/src/features/dashboard/components/app-form/__tests__/app-form.test.tsx new file mode 100644 index 000000000..550c94cc4 --- /dev/null +++ b/src/features/dashboard/components/app-form/__tests__/app-form.test.tsx @@ -0,0 +1,352 @@ +import useApiToken from '@site/src/hooks/useApiToken'; +import { render, screen, cleanup } from '@site/src/test-utils'; +import { TTokensArrayType } from '@site/src/types'; +import userEvent from '@testing-library/user-event'; +import React, { act } from 'react'; +import AppForm from '..'; +import { ApplicationObject } from '@deriv/api-types'; +import useAppManager from '@site/src/hooks/useAppManager'; +import { app_name_error_map } from '../../app-register/types'; + +jest.mock('@site/src/hooks/useApiToken'); +jest.mock('@site/src/utils', () => ({ + ...jest.requireActual('@site/src/utils'), +})); +jest.mock('@site/src/hooks/useAppManager'); + +const mockUseApiToken = useApiToken as jest.MockedFunction< + () => Partial> +>; + +mockUseApiToken.mockImplementation(() => ({ + tokens: [], + updateCurrentToken: jest.fn(), +})); + +const mockUseAppManager = useAppManager as jest.MockedFunction< + () => Partial> +>; +mockUseAppManager.mockImplementation(() => ({ + apps: [], + getApps: jest.fn(), +})); + +describe('App Form', () => { + const mockOnSubmit = jest.fn(); + + beforeEach(() => { + render(); + }); + + afterEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + + it('Should show error message for using an appname that already exists', async () => { + const fakeApps: ApplicationObject[] = [ + { + active: 1, + app_id: 12345, + app_markup_percentage: 0, + appstore: '', + github: '', + googleplay: '', + homepage: '', + name: 'duplicate_app', + redirect_uri: 'https://example.com', + scopes: ['read', 'trade', 'trading_information'], + verification_uri: 'https://example.com', + last_used: '', + official: 1, + }, + { + active: 1, + app_id: 12345, + app_markup_percentage: 0, + appstore: '', + github: '', + googleplay: '', + homepage: '', + name: 'testApp', + redirect_uri: 'https://example.com', + scopes: ['read', 'trade'], + verification_uri: 'https://example.com', + last_used: '', + official: 1, + }, + ]; + const mockGetApps = jest.fn(); + + mockUseAppManager.mockImplementation(() => ({ + apps: fakeApps, + getApps: mockGetApps, + })); + + const submitButton = screen.getByText('Register Application'); + + const tokenNameInput = screen.getByRole('textbox', { + name: 'App name (required)', + }); + + await act(async () => { + await userEvent.type(tokenNameInput, 'duplicate_app'); + await userEvent.click(submitButton); + await userEvent.clear(tokenNameInput); + await userEvent.type(tokenNameInput, 'duplicate_app'); + }); + + const appNameErrorText = await screen.findByText('That name is taken. Choose another.'); + + expect(appNameErrorText).toBeInTheDocument(); + }); + + it('Should show error message for having no admin token', async () => { + const fakeTokens: TTokensArrayType = [ + { + display_name: 'first', + last_used: '', + scopes: ['read', 'trade', 'admin'], + token: 'first_token', + valid_for_ip: '', + }, + { + display_name: 'second', + last_used: '', + scopes: ['read', 'trade'], + token: 'second_token', + valid_for_ip: '', + }, + ]; + + mockUseApiToken.mockImplementation(() => ({ + tokens: fakeTokens, + updateCurrentToken: jest.fn(), + })); + + const errorText = screen.getByText( + /This account doesn't have API tokens with the admin scope. Choose another account./i, + ); + + expect(errorText).toBeInTheDocument(); + }); + + it('Should show error message for empty app name', async () => { + const submitButton = screen.getByText('Register Application'); + const tokenNameInput = screen.getByRole('textbox', { + name: 'App name (required)', + }); + + await act(async () => { + await userEvent.clear(tokenNameInput); + await userEvent.click(submitButton); + }); + + const appNameErrorText = await screen.findByText('Enter your app name.'); + + expect(appNameErrorText).toBeInTheDocument(); + }); + + it('Should show error for long app name', async () => { + const submitButton = screen.getByText('Register Application'); + + const tokenNameInput = screen.getByRole('textbox', { + name: 'App name (required)', + }); + await act(async () => { + await userEvent.type( + tokenNameInput, + 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Modi corrupti neque ratione repudiandae in dolores reiciendis sequi', + ); + await userEvent.click(submitButton); + }); + + const appNameErrorText = await screen.findByText(app_name_error_map.error_code_2); + + expect(appNameErrorText).toBeInTheDocument(); + }); + + it('Should show error for using non alphanumeric characters except underscore or space', async () => { + const submitButton = screen.getByText('Register Application'); + + const tokenNameInput = screen.getByRole('textbox', { + name: 'App name (required)', + }); + + await act(async () => { + await userEvent.type(tokenNameInput, 'invalid-token...'); + await userEvent.click(submitButton); + }); + + const appNameErrorText = await screen.findByText(app_name_error_map.error_code_1); + + expect(appNameErrorText).toBeInTheDocument(); + }); + + it('Should show error message for long app markup percentage', async () => { + const submitButton = screen.getByText('Register Application'); + + const appMarkupPercentageInput = screen.getByRole('spinbutton', { + name: 'Markup percentage (optional)', + }); + + await act(async () => { + await userEvent.type(appMarkupPercentageInput, '12.222222'); + await userEvent.click(submitButton); + }); + + const appMarkupPercentageError = await screen.findByText( + 'The name can contain up to 48 characters.', + ); + + expect(appMarkupPercentageError).toBeInTheDocument(); + }); + + it('Should show error for invalid Auth url', async () => { + const submitButton = screen.getByText('Register Application'); + + const authURLInput = screen.getByRole('textbox', { + name: 'Redirect URL (optional)', + }); + + await act(async () => { + await userEvent.type(authURLInput, 'http:invalidAUTHurl.com'); + await userEvent.click(submitButton); + }); + + const authURLInputError = await screen.queryByText( + 'Enter a valid URL. (Example: https://www.[YourDomainName].com)', + ); + + expect(authURLInputError).toBeInTheDocument(); + }); + + it('Should show error for invalid Verification url', async () => { + const submitButton = screen.getByText('Register Application'); + + const authURLInput = screen.getByRole('textbox', { + name: 'Verification URL (optional)', + }); + + await act(async () => { + await userEvent.type(authURLInput, 'http:invalidVERIurl.com'); + await userEvent.click(submitButton); + }); + + const authURLInputError = await screen.queryByText( + 'Enter a valid URL. (Example: https://www.[YourDomainName].com)', + ); + + expect(authURLInputError).toBeInTheDocument(); + }); + + it('Should show error message for wrong value', async () => { + const fakeTokens: TTokensArrayType = [ + { + display_name: 'first', + last_used: '', + scopes: ['read', 'trade'], + token: 'first_token', + valid_for_ip: '', + }, + { + display_name: 'second', + last_used: '2023-01-19 15:09:39', + scopes: ['read', 'trade', 'payments', 'trading_information', 'admin'], + token: 'second_token', + valid_for_ip: '', + }, + { + display_name: 'third', + last_used: '', + scopes: ['read', 'trade', 'payments', 'admin'], + token: 'third_token', + valid_for_ip: '', + }, + ]; + + mockUseApiToken.mockImplementation(() => ({ + tokens: fakeTokens, + updateCurrentToken: jest.fn(), + })); + + const submitButton = screen.getByText('Register Application'); + + const appMarkupPercentageInput = screen.getByRole('spinbutton', { + name: 'Markup percentage (optional)', + }); + + await act(async () => { + await userEvent.type(appMarkupPercentageInput, '5.01'); + await userEvent.click(submitButton); + }); + + const appMarkupPercentageError = await screen.findByText( + 'Your markup value must be no more than 3.00.', + ); + + expect(appMarkupPercentageError).toBeInTheDocument(); + }); + it('Should call onSubmit on submitting the form', async () => { + const submitButton = screen.getByText('Register Application'); + + const selectTokenOption = screen.getByTestId('select-token'); + + const tokenNameInput = screen.getByRole('textbox', { + name: 'App name (required)', + }); + + const appRedirectUrlInput = screen.getByRole('textbox', { + name: 'Redirect URL (optional)', + }); + + const appVerificationUrlInput = screen.getByRole('textbox', { + name: 'Verification URL (optional)', + }); + + await act(async () => { + await userEvent.click(selectTokenOption); + }); + + const tokenOption = screen.getByText('second'); + + await act(async () => { + await userEvent.click(tokenOption); + await userEvent.type(tokenNameInput, 'test app name'); + await userEvent.type(appRedirectUrlInput, 'https://example.com'); + await userEvent.type(appVerificationUrlInput, 'https://example.com'); + await userEvent.click(submitButton); + }); + + expect(mockOnSubmit).toHaveBeenCalledTimes(1); + }); + + it('Should display restrictions when app name is in focus', async () => { + const tokenNameInput = screen.getByRole('textbox', { + name: 'App name (required)', + }); + + await act(async () => { + await userEvent.type(tokenNameInput, 'Lorem ipsum dolor sit amet'); + }); + + const restrictionsList = screen.queryByRole('list'); + expect(restrictionsList).toBeInTheDocument(); + }); + + it('Should hide restrictions when error occurs', async () => { + const tokenNameInput = screen.getByRole('textbox', { + name: 'App name (required)', + }); + + await act(async () => { + await userEvent.type( + tokenNameInput, + 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Modi corrupti neque ratione repudiandae in dolores reiciendis sequi nvrohgoih iuhwr uiwhrug uwhiog iouwhg ouwhg', + ); + }); + + const restrictionsList = screen.queryByRole('list'); + expect(restrictionsList).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/dashboard/components/app-form/app-form.module.scss b/src/features/dashboard/components/app-form/app-form.module.scss new file mode 100644 index 000000000..4da8acfb2 --- /dev/null +++ b/src/features/dashboard/components/app-form/app-form.module.scss @@ -0,0 +1,222 @@ +@use 'src/styles/utility' as *; + +fieldset .customTextInput:last-child { + margin-top: rem(1.5); +} + +.customTextInput { + align-items: center; + border: 1px solid var(--colors-greyLight400); + border-radius: rem(1.6); + display: flex; + position: relative; + box-sizing: border-box; + &:focus-within { + border-color: var(--colors-blue400); + border-radius: rem(1.6); + } + &:hover { + border: 1px solid var(--colors-greyLight600); + } + label { + position: absolute; + color: var(--colors-greyLight600); + left: rem(1.2); + pointer-events: none; + transform-origin: top left; + transition: all 0.25s ease; + white-space: nowrap; + } + input[type='text'], + input[type='number'] { + background: 0 0; + box-sizing: border-box; + color: var(--ifm-color-emphasis-1000); + height: rem(4); + min-width: 0; + width: 100%; + border: none; + text-indent: rem(1.2); + font-size: rem(1.6); + &:not(:placeholder-shown) ~ label { + color: var(--colors-blue400); + background-color: var(--ifm-color-emphasis-0); + padding: 0 rem(0.4); + transform: translateY(rem(-2)) scale(0.75); + } + &:focus { + outline-color: unset; + outline: 1px solid var(--colors-blue500); + border-radius: rem(1.6); + & ~ label { + color: var(--colors-blue400); + background-color: var(--ifm-color-emphasis-0); + padding: 0 rem(0.4); + transform: translateY(rem(-2)) scale(0.75); + } + } + } +} + +.helperMargin { + margin: rem(1) 0 0; +} +.verificationMargin { + margin: rem(2) 0; +} +.apps_form { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + + .formContent { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + &.noAdmin { + .apiTokenWrapper { + ~ div, + .customTextInput { + opacity: 0.2; + pointer-events: none; + } + } + } + .scopes { + display: flex; + flex-direction: column; + margin-bottom: rem(2.5); + @media screen and (min-width: 992px) { + .scopesWrapper { + margin-left: rem(1.6); + } + } + } + .disableTokenDropdown { + opacity: 0.2; + pointer-events: none; + } + .helperText { + width: 100%; + padding-left: rem(1); + padding-bottom: rem(1); + color: var(--colors-greyLight600); + margin-bottom: 0; + } + .apiTokenWrapper { + display: flex; + flex-direction: column; + gap: rem(1); + margin-bottom: rem(2); + } + .formHeaderContainer { + font-size: rem(1.4); + display: flex; + padding: rem(1) 0; + gap: rem(1); + margin-top: rem(1.5); + flex-direction: column; + + .subHeading { + margin-left: rem(1); + } + .formsubHeading { + margin-left: rem(1.6); + display: inline-block; + } + .wrapperHeading { + margin-left: rem(1.6); + } + } + .markup { + margin-bottom: rem(1); + span { + font-size: rem(1.2); + @media screen and (min-width: 992px) { + font-size: rem(1.4); + } + } + } + } +} + +.submit_container { + margin-top: rem(2.5); + margin-bottom: rem(5.5); + display: flex; + align-items: center; + justify-content: center; +} +.scopeItem { + border: 1.6px solid var(--ifm-color-emphasis-800); + border-radius: 6.4px; + padding: rem(1.28) rem(0.64); +} + +.updateState .customTextInput .apiTokenInput[readonly] { + color: var(--ifm-color-emphasis-500); + cursor: not-allowed; + & ~ label { + color: var(--ifm-color-emphasis-500) !important; + } +} + +@media screen and (min-width: 320px) and (max-width: 1024px) { + .infoIcon:hover .tooltip { + width: rem(14); + transform: translate(-19%, calc(-100% - rem(1))); + } +} + +.customCheckboxWrapper { + display: flex; + gap: rem(1); + font-size: rem(1.2); + @media screen and (min-width: 992px) { + font-size: rem(1.4); + } + + label { + cursor: pointer; + } +} + +.termsOfConditionRegister { + font-size: rem(1); + text-align: center; + @media screen and (min-width: 992px) { + font-size: rem(1.4); + } + + a:hover { + color: var(--component-textIcon-normal-prominent); + } +} + +.buttons { + display: flex; + gap: rem(2.4); +} + +.errorAppname { + border-color: var(--colors-coral500) !important; + &:focus-within { + border-color: var(--colors-coral500) !important; + } + input[type='text'], + input[type='number'] { + &:not(:placeholder-shown) ~ label { + color: var(--colors-coral500) !important; + } + &:focus { + outline: var(--colors-coral500) !important; + & ~ label { + color: var(--colors-coral500) !important; + } + } + } +} diff --git a/src/features/dashboard/components/app-form/app-form.tsx b/src/features/dashboard/components/app-form/app-form.tsx new file mode 100644 index 000000000..0e829cd2d --- /dev/null +++ b/src/features/dashboard/components/app-form/app-form.tsx @@ -0,0 +1,428 @@ +import React, { Dispatch, ReactNode, SetStateAction, useEffect, useState } from 'react'; +import clsx from 'clsx'; +import { useForm } from 'react-hook-form'; +import { Button, Text } from '@deriv/ui'; +import Translate, { translate } from '@docusaurus/Translate'; +import { yupResolver } from '@hookform/resolvers/yup'; +import useWS from '@site/src/hooks/useWs'; +import useApiToken from '@site/src/hooks/useApiToken'; +import useAuthContext from '@site/src/hooks/useAuthContext'; +import CustomSelectDropdown from '@site/src/components/CustomSelectDropdown'; +import SelectedToken from '@site/src/components/CustomSelectDropdown/token-dropdown/SelectedToken'; +import TokenDropdown from '@site/src/components/CustomSelectDropdown/token-dropdown/TokenDropdown'; +import SelectedAccount from '@site/src/components/CustomSelectDropdown/account-dropdown/SelectedAccount'; +import AccountDropdown from '@site/src/components/CustomSelectDropdown/account-dropdown/AccountDropdown'; +import CustomCheckbox from '@site/src/components/CustomCheckbox'; +import useAppManager from '@site/src/hooks/useAppManager'; +import { appRegisterSchema, appEditSchema, IRegisterAppForm } from '../../types'; +import RestrictionsAppname from '../restrictions-appname'; +import styles from './app-form.module.scss'; +import { Link } from '@deriv-com/quill-ui'; + + +type TAppFormProps = { + initialValues?: Partial; + isUpdating?: boolean; + submit: (data: IRegisterAppForm) => void; + is_update_mode?: boolean; + formIsCleared: boolean; + setFormIsCleared: Dispatch>; + cancelButton?: () => ReactNode; +}; + +const AppForm = ({ + initialValues, + submit, + is_update_mode = false, + formIsCleared, + setFormIsCleared, + cancelButton, +}: TAppFormProps) => { + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + mode: 'all', + criteriaMode: 'firstError', + resolver: yupResolver(is_update_mode ? appEditSchema : appRegisterSchema), + defaultValues: initialValues, + }); + + const { currentToken, tokens } = useApiToken(); + const { currentLoginAccount } = useAuthContext(); + const { getApps, apps } = useAppManager(); + const [input_value, setInputValue] = useState(''); + const { is_loading } = useWS('app_register'); + + useEffect(() => { + if (formIsCleared) { + setInputValue(''); + setFormIsCleared(false); + reset(); + } + getApps(); + }, [formIsCleared, getApps]); + + const [display_restrictions, setDisplayRestrictions] = useState(true); + + const admin_token = currentToken?.scopes?.includes('admin') && currentToken.token; + + const appNamesArray = apps?.map((app) => app.name); + const app_name_exists = appNamesArray?.includes(input_value); + const disable_register_btn = + app_name_exists || input_value === '' || Object.keys(errors).length > 0 || is_loading; + const disable_btn = is_update_mode ? is_loading : disable_register_btn; + const error_border_active = (!is_update_mode && app_name_exists) || errors.name; + + useEffect(() => { + errors.name?.message || app_name_exists + ? setDisplayRestrictions(false) + : setDisplayRestrictions(true); + }, [errors.name?.message, app_name_exists]); + + const accountHasAdminToken = () => { + const admin_check_array = []; + tokens.forEach((token) => { + const has_admin_scope = token.scopes && token.scopes.includes('admin'); + has_admin_scope ? admin_check_array.push(true) : admin_check_array.push(false); + }); + return admin_check_array.includes(true); + }; + + const AccountErrorMessage = () => ( + + {!accountHasAdminToken() && ( + + + This account doesn't have API tokens with the admin scope. Choose another account. + + + )} + + ); + + const renderButtons = () => { + return ( +
+ + {is_update_mode && cancelButton()} +
+ ); + }; + return ( + +
+
+
+
+
+

+ App information +

+ {!is_update_mode && ( + + Select your api token ( it should have admin scope ) + + )} +
+ {!is_update_mode && ( + +
+ + + + + +
+
+ + + + +
+
+ )} +
+
{ + setInputValue((e.target as HTMLInputElement).value); + }} + > + + +
+ {errors && errors.name ? ( + + {errors.name.message} + + ) : !is_update_mode && app_name_exists ? ( + + That name is taken. Choose another. + + ) : ( + display_restrictions && + )} +
+
+
+

+ Markup +

+
+ + + You can earn commission by adding a markup to the price of each trade. Enter + your markup percentage here. + + +
+ +

+ + Note: Markup is only available for real accounts. + +

+
+
+
+
+
+
+ + +
+ + + Enter 0 if you don‘t want to earn a markup. Max markup: 3% + + + {errors && errors.app_markup_percentage && ( + + {errors.app_markup_percentage.message} + + )} +
+
+
+

+ OAuth details +

+
+ + + This allows clients to log in to your app using their Deriv accounts without an + API token. + + +
+
+
+
+ + +
+ + + Please note that this URL will be used as the OAuth redirect URL for the OAuth + authorization. + + + {errors && errors?.redirect_uri && ( + {errors.redirect_uri?.message} + )} +
+ +
+
+ + +
+ {errors && errors.verification_uri && ( + {errors.verification_uri.message} + )} +
+ +
+
+
+

+ Scope of authorization +

+
+ + Select the scope for your app: + +
+
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+ + {translate({ message: `By registering your application, you acknowledge that you‘ve read and accepted the Deriv API` })} {' '} + + + terms and conditions + +
+ {renderButtons &&
{renderButtons()}
} +
+
+
+
+ ); +}; + +export default AppForm; diff --git a/src/features/dashboard/components/app-form/index.ts b/src/features/dashboard/components/app-form/index.ts new file mode 100644 index 000000000..8e48024a8 --- /dev/null +++ b/src/features/dashboard/components/app-form/index.ts @@ -0,0 +1,3 @@ +import AppForm from './app-form'; + +export default AppForm; diff --git a/src/features/dashboard/components/dialogs/update-app-dialog/__tests__/update-app-dialog.test.tsx b/src/features/dashboard/components/dialogs/update-app-dialog/__tests__/update-app-dialog.test.tsx new file mode 100644 index 000000000..544251225 --- /dev/null +++ b/src/features/dashboard/components/dialogs/update-app-dialog/__tests__/update-app-dialog.test.tsx @@ -0,0 +1,221 @@ +import React, { act } from 'react'; +import { ApplicationObject } from '@deriv/api-types'; +import useApiToken from '@site/src/hooks/useApiToken'; +import useAppManager from '@site/src/hooks/useAppManager'; +import { render, screen, cleanup } from '@site/src/test-utils'; +import makeMockSocket from '@site/src/__mocks__/socket.mock'; +import userEvent from '@testing-library/user-event'; +import { WS } from 'jest-websocket-mock'; +import UpdateAppDialog from '..'; + +jest.mock('@site/src/hooks/useApiToken'); + +const mockUseApiToken = useApiToken as jest.MockedFunction< + () => Partial> +>; + +mockUseApiToken.mockImplementation(() => ({ + tokens: [ + { + display_name: 'first', + last_used: '', + scopes: ['read', 'trade'], + token: 'first_token', + valid_for_ip: '', + }, + { + display_name: 'second', + last_used: '2023-01-19 15:09:39', + scopes: ['read', 'trade', 'payments', 'trading_information', 'admin'], + token: 'first_token', + valid_for_ip: '', + }, + { + display_name: 'third', + last_used: '', + scopes: ['read', 'trade', 'payments', 'admin'], + token: 'third_token', + valid_for_ip: '', + }, + ], +})); + +const connection = makeMockSocket(); + +jest.mock('@site/src/hooks/useAppManager'); + +const mockGetApps = jest.fn(); + +const mockUseAppManager = useAppManager as jest.MockedFunction< + () => Partial> +>; + +mockUseAppManager.mockImplementation(() => ({ + getApps: mockGetApps, +})); + +const fakeApp: ApplicationObject = { + active: 1, + app_id: 12345, + app_markup_percentage: 0, + appstore: '', + github: '', + googleplay: '', + homepage: '', + name: 'testApp', + redirect_uri: 'https://example.com', + scopes: ['read', 'trade', 'trading_information'], + verification_uri: 'https://example.com', + last_used: '', + official: 0, +}; + +describe('Update App Dialog', () => { + const mockOnClose = jest.fn(); + + let wsServer: WS; + + beforeEach(async () => { + wsServer = await connection.setup(); + await wsServer.connected; + render(); + }); + + afterEach(() => { + connection.tearDown(); + cleanup(); + }); + + it('Should render the form', () => { + const form = screen.getByRole('form'); + expect(form).toBeInTheDocument(); + }); + + it('Should render button properly ', () => { + const primaryButton = screen.getByRole('submit'); + const secondaryButton = screen.getByRole('button', { name: /cancel/i }); + + expect(primaryButton).toBeInTheDocument(); + expect(secondaryButton).toBeInTheDocument(); + }); + + it('Should close the modal on cancel button click', async () => { + const secondaryButton = screen.getByRole('button', { name: /cancel/i }); + await act(async () => { + await userEvent.click(secondaryButton); + }); + + expect(mockOnClose).toBeCalled(); + }); + + it('Should close the modal on modal close button click', async () => { + const closeButton = screen.getByTestId('close-button'); + await act(async () => { + await userEvent.click(closeButton); + }); + + expect(mockOnClose).toBeCalled(); + }); + + it('Should update application on submit click', async () => { + const submitButton = screen.getByText('Update Application'); + + const tokenNameInput = screen.getByRole('textbox', { + name: 'App name (required)', + }); + + await act(async () => { + await userEvent.clear(tokenNameInput); + await userEvent.type(tokenNameInput, 'test app name updated'); + + await userEvent.click(submitButton); + }); + + await expect(wsServer).toReceiveMessage({ + app_markup_percentage: 0, + app_update: 12345, + name: 'test app name updated', + redirect_uri: 'https://example.com', + req_id: 1, + scopes: ['read', 'trade', 'trading_information'], + verification_uri: 'https://example.com', + }); + + wsServer.send({ + app_update: { + app_markup_percentage: 0, + app_update: 12345, + name: 'test app name updated', + redirect_uri: 'https://example.com', + req_id: 1, + scopes: ['read', 'trade', 'trading_information'], + verification_uri: 'https://example.com', + }, + echo_req: { + app_markup_percentage: 0, + app_update: 35565, + name: 'test app name updated', + redirect_uri: 'https://example.com', + req_id: 1, + scopes: ['read', 'trade', 'trading_information'], + verification_uri: 'https://example.com', + }, + msg_type: 'app_update', + req_id: 1, + }); + + await screen.findByText('Update App'); + expect(mockGetApps).toBeCalled(); + expect(mockOnClose).toBeCalled(); + }); + + it.skip('Should render error on error response', async () => { + const submitButton = screen.getByText('Update Application'); + + const tokenNameInput = screen.getByRole('textbox', { + name: 'App name (required)', + }); + + await act(async () => { + await userEvent.clear(tokenNameInput); + await userEvent.type(tokenNameInput, 'test app wrong name fake'); + + await userEvent.click(submitButton); + }); + + await expect(wsServer).toReceiveMessage({ + app_markup_percentage: 0, + app_update: 12345, + name: 'test app wrong name fake', + redirect_uri: 'https://example.com', + req_id: 1, + scopes: ['read', 'trade', 'trading_information'], + verification_uri: 'https://example.com', + }); + + wsServer.send({ + echo_req: { + app_markup_percentage: 0, + app_update: 12345, + name: 'test app wrong name fake', + redirect_uri: 'https://example.com', + req_id: 4, + scopes: ['read', 'trade', 'trading_information'], + verification_uri: 'https://example.com', + }, + error: { + code: 'InputValidationFailed', + details: { + name: "String does not match '^[\\w\\s-]{1,48}$'", + }, + message: 'Input validation failed: name', + }, + msg_type: 'app_update', + req_id: 1, + }); + + const errorContent = await screen.findByText('Input validation failed: name'); + + expect(errorContent).toBeInTheDocument(); + }); +}); diff --git a/src/features/dashboard/components/dialogs/update-app-dialog/index.ts b/src/features/dashboard/components/dialogs/update-app-dialog/index.ts new file mode 100644 index 000000000..29f5f3850 --- /dev/null +++ b/src/features/dashboard/components/dialogs/update-app-dialog/index.ts @@ -0,0 +1,3 @@ +import UpdateAppDialog from './update-app-dialog'; + +export default UpdateAppDialog; diff --git a/src/features/dashboard/components/dialogs/update-app-dialog/update-app-dialog.module.scss b/src/features/dashboard/components/dialogs/update-app-dialog/update-app-dialog.module.scss new file mode 100644 index 000000000..ca2bd56ed --- /dev/null +++ b/src/features/dashboard/components/dialogs/update-app-dialog/update-app-dialog.module.scss @@ -0,0 +1,11 @@ +@use 'src/styles/utility' as *; + +.update_dialog { + height: 80vh !important; + overflow: auto; + width: 80vw; + + .update_app_content { + margin: rem(1); + } +} diff --git a/src/features/dashboard/components/dialogs/update-app-dialog/update-app-dialog.tsx b/src/features/dashboard/components/dialogs/update-app-dialog/update-app-dialog.tsx new file mode 100644 index 000000000..0e75f2d3d --- /dev/null +++ b/src/features/dashboard/components/dialogs/update-app-dialog/update-app-dialog.tsx @@ -0,0 +1,115 @@ +import React, { useCallback, useEffect } from 'react'; +import AppForm from '../../app-form'; +import useWS from '@site/src/hooks/useWs'; +import useAppManager from '@site/src/hooks/useAppManager'; +import { Button, Modal } from '@deriv/ui'; +import { IRegisterAppForm } from '../../../types'; +import { ApplicationObject } from '@deriv/api-types'; +import { RegisterAppDialogError } from '../register-app-dialog-error'; +import { scopesArrayToObject, scopesObjectToArray } from '@site/src/utils'; +import styles from './update-app-dialog.module.scss'; +import Translate, { translate } from '@docusaurus/Translate'; + +interface IUpdateAppDialog { + app: ApplicationObject; + onClose: () => void; +} + +const UpdateAppDialog = ({ app, onClose }: IUpdateAppDialog) => { + const { send: updateApp, data, error, clear } = useWS('app_update'); + const { getApps } = useAppManager(); + + const scopes = scopesArrayToObject(app.scopes); + const initialValues: Partial = { + ...app, + ...scopes, + app_markup_percentage: app.app_markup_percentage, + }; + + const onOpenChange = useCallback( + (open: boolean) => { + if (!open) { + onClose(); + } + }, + [onClose], + ); + + useEffect(() => { + if (data) { + getApps(); + onClose(); + } + }, [data, getApps, onClose]); + + const onSubmit = useCallback( + (data: IRegisterAppForm) => { + const { name, redirect_uri, verification_uri, app_markup_percentage } = data; + + const has_redirect_uri = redirect_uri !== '' && { redirect_uri }; + const has_verification_uri = verification_uri !== '' && { verification_uri }; + const markup = { + app_markup_percentage: Number(app_markup_percentage), + }; + + const selectedScopes = scopesObjectToArray({ + admin: data.admin, + payments: data.payments, + read: data.read, + trade: data.trade, + trading_information: data.trading_information, + }); + updateApp({ + app_update: data.app_id, + name, + ...has_redirect_uri, + ...has_verification_uri, + ...markup, + scopes: selectedScopes, + }); + }, + [updateApp], + ); + + const cancelButton = () => { + return ( + + ); + }; + + return ( + + +
+ + +
+ +
+ {error && } +
+
+
+
+ ); +}; + +export default UpdateAppDialog; diff --git a/src/features/dashboard/components/no-apps/__tests__/no-apps.test.tsx b/src/features/dashboard/components/no-apps/__tests__/no-apps.test.tsx new file mode 100644 index 000000000..7d56bf8a2 --- /dev/null +++ b/src/features/dashboard/components/no-apps/__tests__/no-apps.test.tsx @@ -0,0 +1,45 @@ +import useAppManager from '@site/src/hooks/useAppManager'; +import { render, cleanup, screen } from '@site/src/test-utils'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import NoApps from '..'; +import { TDashboardTab } from '@site/src/contexts/app-manager/app-manager.context'; + +jest.mock('@site/src/hooks/useAppManager'); + +const mockUseAppManager = useAppManager as jest.MockedFunction< + () => Partial> +>; + +const mockUpdateCurrentTab = jest.fn(); + +mockUseAppManager.mockImplementation(() => ({ + updateCurrentTab: mockUpdateCurrentTab, +})); + +describe('No Apps', () => { + beforeEach(() => { + render(); + }); + + afterEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + + it('Should render description', () => { + const descriptionText = screen.getByTestId('no-apps-description'); + expect(descriptionText).toHaveTextContent( + 'To see your details reflected, please register your app via the registration form.', + ); + }); + + it('Should navigate to REGISTER_APP Tab on Register now click', async () => { + const registerNowButton = screen.getByRole('button'); + + await userEvent.click(registerNowButton); + + expect(mockUpdateCurrentTab).toHaveBeenCalledTimes(1); + expect(mockUpdateCurrentTab).toHaveBeenCalledWith(TDashboardTab.REGISTER_APP); + }); +}); diff --git a/src/features/dashboard/components/no-apps/index.ts b/src/features/dashboard/components/no-apps/index.ts new file mode 100644 index 000000000..420404f9c --- /dev/null +++ b/src/features/dashboard/components/no-apps/index.ts @@ -0,0 +1,3 @@ +import NoApps from './no-apps'; + +export default NoApps; diff --git a/src/features/dashboard/components/no-apps/no-apps.module.scss b/src/features/dashboard/components/no-apps/no-apps.module.scss new file mode 100644 index 000000000..fd1769d7c --- /dev/null +++ b/src/features/dashboard/components/no-apps/no-apps.module.scss @@ -0,0 +1,48 @@ +@use 'src/styles/utility' as *; + +.noAppsWrapper { + width: calc(100% - rem(3.2)); +} + +.noApps { + padding-top: rem(7.2); + margin: 0 auto; + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: calc(rem(32) - rem(3.2)); + position: relative; + margin: 0 auto; +} + +.noAppsIcon { + margin-top: rem(1.6); + margin-bottom: rem(2); + background-image: url(/img/table-empty.svg); + background-repeat: no-repeat; + background-position: center; + background-size: contain; + width: rem(10); + height: rem(10); +} + +.noAppsText p { + text-align: center; + margin-bottom: rem(2); +} + +[data-state*='responsive.desktop'] { + .noAppsWrapper { + position: relative; + } + .noApps { + padding-top: rem(7.2); + margin: 0 auto; + position: relative; + } + .noAppsText { + width: rem(39); + } +} diff --git a/src/features/dashboard/components/no-apps/no-apps.tsx b/src/features/dashboard/components/no-apps/no-apps.tsx new file mode 100644 index 000000000..eeca662b0 --- /dev/null +++ b/src/features/dashboard/components/no-apps/no-apps.tsx @@ -0,0 +1,34 @@ +import React, { useCallback } from 'react'; +import styles from './no-apps.module.scss'; +import { Button, Text } from '@deriv/ui'; +import useAppManager from '@site/src/hooks/useAppManager'; +import { TDashboardTab } from '@site/src/contexts/app-manager/app-manager.context'; +import Translate from '@docusaurus/Translate'; + +const NoApps = () => { + const { updateCurrentTab } = useAppManager(); + + const onRegisterClick = useCallback(() => { + updateCurrentTab(TDashboardTab.REGISTER_APP); + }, [updateCurrentTab]); + + return ( +
+
+
+
+ + + To see your details reflected, please register your app via the registration form. + + +
+ +
+
+ ); +}; + +export default NoApps; diff --git a/src/features/dashboard/components/token-register/token-register.tsx b/src/features/dashboard/components/token-register/token-register.tsx index 6d09bddd8..080583aa7 100644 --- a/src/features/dashboard/components/token-register/token-register.tsx +++ b/src/features/dashboard/components/token-register/token-register.tsx @@ -5,13 +5,51 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { scopesObjectToArray } from '@site/src/utils'; import ApiTokenCard from '../api-token-card'; import useCreateToken from '@site/src/features/dashboard/hooks/useCreateToken'; +import * as yup from 'yup'; +import './token-register.scss'; import CreateTokenField from '../api-token-form/create-token-field'; import AccountSwitcher from '@site/src/components/AccountSwitcher'; import Translate, { translate } from '@docusaurus/Translate'; -import { TApiTokenForm, tokenRegisterSchema, TScope } from './types'; -import './token-register.scss'; -// FIXME: Move scopes to an exportable separate file for reusability (scopes.ts) +const schema = yup + .object({ + read: yup.boolean(), + trade: yup.boolean(), + payments: yup.boolean(), + trading_information: yup.boolean(), + admin: yup.boolean(), + name: yup + .string() + .min(2, translate({ message: 'Your token name must be atleast 2 characters long.' })) + .max(32, translate({ message: 'Only up to 32 characters are allowed.' })) + .matches(/^(?=.*[a-zA-Z0-9])[a-zA-Z0-9_ ]*$/, { + message: translate({ + message: + 'Only alphanumeric characters with spaces and underscores are allowed. (Example: my_application)', + }), + excludeEmptyString: true, + }) + .matches( + /^(?!.*deriv|.*d3r1v|.*der1v|.*d3riv|.*b1nary|.*binary|.*b1n4ry|.*bin4ry|.*blnary|.*b\|nary).*$/i, + { + message: translate({ + message: 'The name cannot contain “Binary”, “Deriv”, or similar words.', + }), + excludeEmptyString: true, + }, + ), + }) + .required(); + +export type TApiTokenForm = yup.InferType; +export type TApiTokenFormItemsNames = keyof TApiTokenForm; + +type TScope = { + name: TApiTokenFormItemsNames; + description: string; + label: string; +}; + const scopes: TScope[] = [ { name: 'read', @@ -66,7 +104,7 @@ const TokenRegister = (props: HTMLAttributes) => { reset, formState: { errors }, } = useForm({ - resolver: yupResolver(tokenRegisterSchema), + resolver: yupResolver(schema), mode: 'all', }); const onSubmit = useCallback( diff --git a/src/features/dashboard/components/token-register/types.ts b/src/features/dashboard/components/token-register/types.ts index 405384a41..70f5c9476 100644 --- a/src/features/dashboard/components/token-register/types.ts +++ b/src/features/dashboard/components/token-register/types.ts @@ -1,42 +1,59 @@ import { translate } from '@docusaurus/Translate'; +import { UseFormRegisterReturn } from 'react-hook-form'; import * as yup from 'yup'; -export const tokenRegisterSchema = yup - .object({ - read: yup.boolean(), - trade: yup.boolean(), - payments: yup.boolean(), - trading_information: yup.boolean(), - admin: yup.boolean(), - name: yup - .string() - .min(2, translate({ message: 'Your token name must be atleast 2 characters long.' })) - .max(32, translate({ message: 'Only up to 32 characters are allowed.' })) - .matches(/^(?=.*[a-zA-Z0-9])[a-zA-Z0-9_ ]*$/, { - message: translate({ - message: - 'Only alphanumeric characters with spaces and underscores are allowed. (Example: my_application)', - }), +export const token_name_error_map = { + error_code_1: translate({ + message: 'Only alphanumeric characters with spaces and underscores are allowed.', + }), + error_code_2: translate({ message: `Only 2-32 characters are allowed` }), + error_code_3: translate({ + message: `No duplicate token names are allowed for the same account.`, + }), + error_code_4: translate({ + message: `No keywords "deriv" or "binary" or words that look similar, e.g. "_binary_" or "d3riv" are allowed.`, + }), +}; + +export const tokenRegisterSchema = yup.object({ + account_type: yup.string().required(translate({ message: 'Select an account type.' })), + token_name: yup + .string() + .required(translate({ message: 'Enter your token name.' })) + .min(2, token_name_error_map.error_code_2) + .max(32, token_name_error_map.error_code_2) + .matches(/^(?=.*[a-zA-Z0-9])[a-zA-Z0-9_ ]*$/, { + message: token_name_error_map.error_code_1, + excludeEmptyString: true, + }) + .matches( + /^(?!.*deriv|.*d3r1v|.*der1v|.*d3riv|.*b1nary|.*binary|.*b1n4ry|.*bin4ry|.*blnary|.*b\|nary).*$/i, + { + message: token_name_error_map.error_code_4, excludeEmptyString: true, - }) - .matches( - /^(?!.*deriv|.*d3r1v|.*der1v|.*d3riv|.*b1nary|.*binary|.*b1n4ry|.*bin4ry|.*blnary|.*b\|nary).*$/i, - { - message: translate({ - message: 'The name cannot contain “Binary”, “Deriv”, or similar words.', - }), - excludeEmptyString: true, - }, - ), - }) - .required(); + }, + ), + read: yup.boolean(), + trade: yup.boolean(), + payments: yup.boolean(), + trading_information: yup.boolean(), + admin: yup.boolean(), +}); + +export type ITokenRegisterForm = yup.InferType; -export type TApiTokenForm = yup.InferType; +export type TTokenRegisterProps = { + onCancel?: () => void; + submit: (data: ITokenRegisterForm) => void; +}; -export type TApiTokenFormItemsNames = keyof TApiTokenForm; +export type TCustomCheckboxProps = { + name: string; + id: string; + register: UseFormRegisterReturn; + onChange?: (e: React.ChangeEvent) => void; +}; -export type TScope = { - name: TApiTokenFormItemsNames; - description: string; - label: string; -}; \ No newline at end of file +export type TRestrictionComponentProps = { + error: string; +}; diff --git a/src/features/dashboard/hooks/useRegisterApp/__tests__/useRegisterApp.test.tsx b/src/features/dashboard/hooks/useRegisterApp/__tests__/useRegisterApp.test.tsx new file mode 100644 index 000000000..1de91d550 --- /dev/null +++ b/src/features/dashboard/hooks/useRegisterApp/__tests__/useRegisterApp.test.tsx @@ -0,0 +1,88 @@ +import makeMockSocket from '@site/src/__mocks__/socket.mock'; +import { cleanup, renderHook, act, waitFor } from '@testing-library/react'; +import { WS } from 'jest-websocket-mock'; +import useRegisterApp from '..'; + +const connection = makeMockSocket(); + +describe('Use Delete App', () => { + let wsServer: WS; + beforeEach(async () => { + wsServer = await connection.setup(); + await wsServer.connected; + }); + + afterEach(() => { + connection.tearDown(); + cleanup(); + }); + + it('Should register app with provided values', async () => { + const { result } = renderHook(() => useRegisterApp()); + + expect(result.current.is_loading).toBeFalsy(); + + act(() => { + result.current.registerApp({ + name: 'app', + scopes: ['admin', 'payments'], + redirect_uri: 'https://example.com', + verification_uri: 'https://example.com', + }); + }); + + expect(result.current.is_loading).toBeTruthy(); + + await expect(wsServer).toReceiveMessage({ + app_register: 1, + name: 'app', + redirect_uri: 'https://example.com', + req_id: 1, + scopes: ['admin', 'payments'], + verification_uri: 'https://example.com', + }); + + wsServer.send({ + app_register: { + active: 1, + app_id: 12345, + app_markup_percentage: 0, + appstore: '', + github: '', + googleplay: '', + homepage: '', + name: 'app', + redirect_uri: 'https://example.com', + scopes: ['admin', 'payments'], + verification_uri: 'https://example.com', + }, + echo_req: { + app_register: 1, + name: 'app', + redirect_uri: 'https://example.com', + req_id: 1, + scopes: ['admin', 'payments'], + verification_uri: 'https://example.com', + }, + msg_type: 'app_register', + req_id: 1, + }); + + await waitFor(() => { + expect(result.current.is_loading).toBeFalsy(); + expect(result.current.data).toStrictEqual({ + active: 1, + app_id: 12345, + app_markup_percentage: 0, + appstore: '', + github: '', + googleplay: '', + homepage: '', + name: 'app', + redirect_uri: 'https://example.com', + scopes: ['admin', 'payments'], + verification_uri: 'https://example.com', + }); + }); + }); +}); diff --git a/src/features/dashboard/hooks/useRegisterApp/index.tsx b/src/features/dashboard/hooks/useRegisterApp/index.tsx new file mode 100644 index 000000000..bbe5fd674 --- /dev/null +++ b/src/features/dashboard/hooks/useRegisterApp/index.tsx @@ -0,0 +1,18 @@ +import { TSocketRequestCleaned } from '@site/src/configs/websocket/types'; +import useWS from '@site/src/hooks/useWs'; +import { useCallback } from 'react'; + +const useRegisterApp = () => { + const { send, data, is_loading } = useWS('app_register'); + + const registerApp = useCallback( + (data: TSocketRequestCleaned<'app_register'>) => { + send(data); + }, + [send], + ); + + return { registerApp, data, is_loading }; +}; + +export default useRegisterApp; diff --git a/src/features/dashboard/hooks/useUpdateApp/__tests__/useUpdateApp.test.tsx b/src/features/dashboard/hooks/useUpdateApp/__tests__/useUpdateApp.test.tsx new file mode 100644 index 000000000..14c0f3995 --- /dev/null +++ b/src/features/dashboard/hooks/useUpdateApp/__tests__/useUpdateApp.test.tsx @@ -0,0 +1,84 @@ +import makeMockSocket from '@site/src/__mocks__/socket.mock'; +import { cleanup, renderHook, act, waitFor } from '@testing-library/react'; +import { WS } from 'jest-websocket-mock'; +import useUpdateApp from '..'; + +const connection = makeMockSocket(); + +describe('Use Delete App', () => { + let wsServer: WS; + beforeEach(async () => { + wsServer = await connection.setup(); + await wsServer.connected; + }); + + afterEach(() => { + connection.tearDown(); + cleanup(); + }); + + it('Should register app with provided values', async () => { + const { result } = renderHook(() => useUpdateApp()); + + expect(result.current.is_loading).toBeFalsy(); + + act(() => { + result.current.updateApp({ + app_update: 1234, + name: 'test update app', + scopes: ['admin', 'trade'], + }); + }); + + expect(result.current.is_loading).toBeTruthy(); + + await expect(wsServer).toReceiveMessage({ + app_update: 1234, + name: 'test update app', + req_id: 1, + scopes: ['admin', 'trade'], + }); + + wsServer.send({ + app_update: { + active: 1, + app_id: 1234, + app_markup_percentage: 0, + appstore: '', + github: '', + googleplay: '', + homepage: '', + name: 'app', + redirect_uri: 'https://example.com', + scopes: ['admin', 'trade'], + verification_uri: 'https://example.com', + }, + echo_req: { + app_update: 1234, + name: 'test update app', + req_id: 1, + scopes: ['admin', 'trade'], + }, + msg_type: 'app_update', + req_id: 1, + }); + + await waitFor(() => { + expect(result.current.is_loading).toBeFalsy(); + + expect(result.current.data).toStrictEqual({ + active: 1, + app_id: 1234, + app_markup_percentage: 0, + appstore: '', + github: '', + googleplay: '', + homepage: '', + name: 'app', + redirect_uri: 'https://example.com', + scopes: ['admin', 'trade'], + verification_uri: 'https://example.com', + }); + }); + }); +}); diff --git a/src/features/dashboard/hooks/useUpdateApp/index.tsx b/src/features/dashboard/hooks/useUpdateApp/index.tsx new file mode 100644 index 000000000..11e9c2e26 --- /dev/null +++ b/src/features/dashboard/hooks/useUpdateApp/index.tsx @@ -0,0 +1,18 @@ +import { TSocketRequestCleaned } from '@site/src/configs/websocket/types'; +import useWS from '@site/src/hooks/useWs'; +import { useCallback } from 'react'; + +const useUpdateApp = () => { + const { send, data, is_loading } = useWS('app_update'); + + const updateApp = useCallback( + (data: TSocketRequestCleaned<'app_update'>) => { + send(data); + }, + [send], + ); + + return { updateApp, data, is_loading }; +}; + +export default useUpdateApp; diff --git a/src/features/dashboard/manage-tokens/__tests__/index.test.tsx b/src/features/dashboard/manage-tokens/__tests__/index.test.tsx new file mode 100644 index 000000000..58fbb08b4 --- /dev/null +++ b/src/features/dashboard/manage-tokens/__tests__/index.test.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { cleanup, render, screen } from '@site/src/test-utils'; +import ApiToken from '..'; +import ApiTokenTable from '../../components/api-token-table'; + +describe('Home Page', () => { + beforeEach(() => { + render(); + }); + + afterEach(() => { + cleanup(); + }); + + it('Should render Page Heading', () => { + const heading = screen.getByRole('heading', { level: 2 }); + expect(heading).toBeInTheDocument(); + expect(heading.textContent).toMatch(/API Token Manager/i); + }); + + it('Should render api token from', () => { + const form = screen.getByRole('form'); + expect(form).toBeInTheDocument(); + }); + + it('Should render api token table', () => { + ; + }); +}); diff --git a/src/features/dashboard/manage-tokens/index.tsx b/src/features/dashboard/manage-tokens/index.tsx new file mode 100644 index 000000000..11796c0c0 --- /dev/null +++ b/src/features/dashboard/manage-tokens/index.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Text } from '@deriv/ui'; +import styles from './manage-tokens.module.scss'; +import ApiTokenForm from '../components/api-token-form/api-token-form'; +import ApiTokenTable from '../components/api-token-table'; +import Translate from '@docusaurus/Translate'; + +const ApiToken = () => { + return ( +
+ + API Token Manager + + + +
+ ); +}; + +export default ApiToken; diff --git a/src/features/dashboard/register-tokens/__test__/register-tokens.test.tsx b/src/features/dashboard/register-tokens/__test__/register-tokens.test.tsx new file mode 100644 index 000000000..f0d3e9d2b --- /dev/null +++ b/src/features/dashboard/register-tokens/__test__/register-tokens.test.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { render, screen } from '@site/src/test-utils'; +import TokenRegistration from '..'; + +describe('Register Tokens', () => { + const renderRegisterTokenComponent = () => { + return render(); + }; + + it('Should render the component', () => { + renderRegisterTokenComponent(); + const headingText = screen.getByText('Create new token'); + expect(headingText).toBeInTheDocument(); + }); +}); diff --git a/src/features/dashboard/register-tokens/index.tsx b/src/features/dashboard/register-tokens/index.tsx new file mode 100644 index 000000000..b8d36c76b --- /dev/null +++ b/src/features/dashboard/register-tokens/index.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import TokenRegister from '../components/token-register'; + +const TokenRegistration: React.FC = () => { + return ; +}; + +export default TokenRegistration; diff --git a/static/img/arrow_down.svg b/static/img/arrow_down.svg new file mode 100644 index 000000000..1aa8372c3 --- /dev/null +++ b/static/img/arrow_down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/img/arrow_left.svg b/static/img/arrow_left.svg new file mode 100644 index 000000000..18ea2acc1 --- /dev/null +++ b/static/img/arrow_left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/img/arrow_right.svg b/static/img/arrow_right.svg new file mode 100644 index 000000000..864bd1913 --- /dev/null +++ b/static/img/arrow_right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/img/chevron-left.svg b/static/img/chevron-left.svg new file mode 100644 index 000000000..4dfab339e --- /dev/null +++ b/static/img/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/circle_dot_caption_bold.svg b/static/img/circle_dot_caption_bold.svg new file mode 100644 index 000000000..986d59044 --- /dev/null +++ b/static/img/circle_dot_caption_bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/img/circle_dot_caption_fill.svg b/static/img/circle_dot_caption_fill.svg new file mode 100644 index 000000000..4c4f0a128 --- /dev/null +++ b/static/img/circle_dot_caption_fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/img/close_dialog.svg b/static/img/close_dialog.svg new file mode 100644 index 000000000..f5a9814e5 --- /dev/null +++ b/static/img/close_dialog.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/delete.svg b/static/img/delete.svg new file mode 100644 index 000000000..92adce2ba --- /dev/null +++ b/static/img/delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/img/deriv-logo.png b/static/img/deriv-logo.png new file mode 100644 index 000000000..b3c971998 Binary files /dev/null and b/static/img/deriv-logo.png differ diff --git a/static/img/edit.svg b/static/img/edit.svg new file mode 100644 index 000000000..24f55a9af --- /dev/null +++ b/static/img/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/img/eye_closed.svg b/static/img/eye_closed.svg new file mode 100644 index 000000000..0a2650ef2 --- /dev/null +++ b/static/img/eye_closed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/img/eye_open.svg b/static/img/eye_open.svg new file mode 100644 index 000000000..007553acc --- /dev/null +++ b/static/img/eye_open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/img/gray-logo.svg b/static/img/gray-logo.svg new file mode 100644 index 000000000..0ae29bacb --- /dev/null +++ b/static/img/gray-logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/img/header-mobile.png b/static/img/header-mobile.png new file mode 100644 index 000000000..59581f33b Binary files /dev/null and b/static/img/header-mobile.png differ diff --git a/static/img/header.png b/static/img/header.png new file mode 100644 index 000000000..fb8a56e47 Binary files /dev/null and b/static/img/header.png differ diff --git a/static/img/language-switcher.svg b/static/img/language-switcher.svg new file mode 100644 index 000000000..4bedbccc0 --- /dev/null +++ b/static/img/language-switcher.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/static/img/logo.svg b/static/img/logo.svg new file mode 100644 index 000000000..3eefe502f --- /dev/null +++ b/static/img/logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/static/img/pattern.svg b/static/img/pattern.svg new file mode 100644 index 000000000..e91d4e7c7 --- /dev/null +++ b/static/img/pattern.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/img/placeholder_icon.svg b/static/img/placeholder_icon.svg new file mode 100644 index 000000000..f6913457d --- /dev/null +++ b/static/img/placeholder_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/plus.svg b/static/img/plus.svg new file mode 100644 index 000000000..98dca5036 --- /dev/null +++ b/static/img/plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/img/plus_bold.svg b/static/img/plus_bold.svg new file mode 100644 index 000000000..b992bd802 --- /dev/null +++ b/static/img/plus_bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/img/register_success.svg b/static/img/register_success.svg new file mode 100644 index 000000000..271526d75 --- /dev/null +++ b/static/img/register_success.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/img/search-bold.svg b/static/img/search-bold.svg new file mode 100644 index 000000000..9fe31e469 --- /dev/null +++ b/static/img/search-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/img/search.svg b/static/img/search.svg new file mode 100644 index 000000000..cbacf3c67 --- /dev/null +++ b/static/img/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/img/slack.svg b/static/img/slack.svg new file mode 100644 index 000000000..7e70693f8 --- /dev/null +++ b/static/img/slack.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/img/success.svg b/static/img/success.svg new file mode 100644 index 000000000..749adf687 --- /dev/null +++ b/static/img/success.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/table-empty.svg b/static/img/table-empty.svg new file mode 100644 index 000000000..9ee986698 --- /dev/null +++ b/static/img/table-empty.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/static/img/telegram.svg b/static/img/telegram.svg new file mode 100644 index 000000000..563829711 --- /dev/null +++ b/static/img/telegram.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/token_api.png b/static/img/token_api.png new file mode 100644 index 000000000..8078e38d0 Binary files /dev/null and b/static/img/token_api.png differ diff --git a/static/img/undraw_docusaurus_mountain.svg b/static/img/undraw_docusaurus_mountain.svg new file mode 100644 index 000000000..af961c49a --- /dev/null +++ b/static/img/undraw_docusaurus_mountain.svg @@ -0,0 +1,171 @@ + + Easy to Use + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/undraw_docusaurus_react.svg b/static/img/undraw_docusaurus_react.svg new file mode 100644 index 000000000..94b5cf08f --- /dev/null +++ b/static/img/undraw_docusaurus_react.svg @@ -0,0 +1,170 @@ + + Powered by React + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/undraw_docusaurus_tree.svg b/static/img/undraw_docusaurus_tree.svg new file mode 100644 index 000000000..d9161d339 --- /dev/null +++ b/static/img/undraw_docusaurus_tree.svg @@ -0,0 +1,40 @@ + + Focus on What Matters + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/warning.svg b/static/img/warning.svg new file mode 100644 index 000000000..80cfc05b2 --- /dev/null +++ b/static/img/warning.svg @@ -0,0 +1 @@ + \ No newline at end of file