diff --git a/src/features/dashboard/components/AppForm/__tests__/app-form.test.tsx b/src/features/dashboard/components/AppForm/__tests__/app-form.test.tsx index 20fe8017..36c2d58c 100644 --- a/src/features/dashboard/components/AppForm/__tests__/app-form.test.tsx +++ b/src/features/dashboard/components/AppForm/__tests__/app-form.test.tsx @@ -1,15 +1,17 @@ -import { Button } from '@deriv/ui'; 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 from 'react'; import AppForm from '..'; +import { ApplicationObject } from '@deriv/api-types'; +import useAppManager from '@site/src/hooks/useAppManager'; 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> @@ -20,19 +22,19 @@ mockUseApiToken.mockImplementation(() => ({ updateCurrentToken: jest.fn(), })); -const renderButtons = () => { - return ( -
- -
- ); -}; +const mockUseAppManager = useAppManager as jest.MockedFunction< + () => Partial> +>; +mockUseAppManager.mockImplementation(() => ({ + apps: [], + getApps: jest.fn(), +})); describe('App Form', () => { const mockOnSubmit = jest.fn(); beforeEach(() => { - render(); + render(); }); afterEach(() => { @@ -40,12 +42,69 @@ describe('App Form', () => { 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: '', + }, + { + 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: '', + }, + ]; + 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 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'], + scopes: ['read', 'trade', 'admin'], token: 'first_token', valid_for_ip: '', }, @@ -71,8 +130,11 @@ describe('App Form', () => { }); it('Should show error message for empty app name', async () => { - const submitButton = screen.getByText('Update Application'); - + const submitButton = screen.getByText('Register Application'); + const tokenNameInput = screen.getByRole('textbox', { + name: 'App name (required)', + }); + await userEvent.clear(tokenNameInput); await userEvent.click(submitButton); const appNameErrorText = await screen.findByText('Enter your app name.'); @@ -81,7 +143,7 @@ describe('App Form', () => { }); it('Should show error for long app name', async () => { - const submitButton = screen.getByText('Update Application'); + const submitButton = screen.getByText('Register Application'); const tokenNameInput = screen.getByRole('textbox', { name: 'App name (required)', @@ -99,8 +161,26 @@ describe('App Form', () => { 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 userEvent.type(tokenNameInput, 'invalid-token...'); + + await userEvent.click(submitButton); + + const appNameErrorText = await screen.findByText( + 'Only alphanumeric characters with spaces and underscores are allowed. (Example: my_application)', + ); + + expect(appNameErrorText).toBeInTheDocument(); + }); + it('Should show error message for long app markup percentage', async () => { - const submitButton = screen.getByText('Update Application'); + const submitButton = screen.getByText('Register Application'); const appMarkupPercentageInput = screen.getByRole('spinbutton', { name: 'Markup percentage (optional)', @@ -117,6 +197,42 @@ describe('App Form', () => { expect(appMarkupPercentageError).toBeInTheDocument(); }); + it('Should show error for invalid Auth url', async () => { + const submitButton = screen.getByText('Register Application'); + + const authURLInput = screen.getByRole('textbox', { + name: 'Authorization URL (optional)', + }); + + 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 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 = [ { @@ -147,7 +263,7 @@ describe('App Form', () => { updateCurrentToken: jest.fn(), })); - const submitButton = screen.getByText('Update Application'); + const submitButton = screen.getByText('Register Application'); const appMarkupPercentageInput = screen.getByRole('spinbutton', { name: 'Markup percentage (optional)', @@ -165,7 +281,7 @@ describe('App Form', () => { }); it('Should call onSubmit on submitting the form', async () => { - const submitButton = screen.getByText('Update Application'); + const submitButton = screen.getByText('Register Application'); const selectTokenOption = screen.getByTestId('select-token'); @@ -193,4 +309,27 @@ describe('App Form', () => { expect(mockOnSubmit).toHaveBeenCalledTimes(1); }); + + it('Should display restrictions when app name is in focus and disappear if error occurs', async () => { + const submitButton = screen.getByText('Register Application'); + + const tokenNameInput = screen.getByRole('textbox', { + name: 'App name (required)', + }); + + await userEvent.type(tokenNameInput, 'Lorem ipsum dolor sit amet'); + + const restrictionsList = screen.queryByRole('list'); + expect(restrictionsList).toBeInTheDocument(); + + await userEvent.clear(tokenNameInput); + + 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', + ); + + await userEvent.click(submitButton); + expect(restrictionsList).not.toBeInTheDocument(); + }); }); diff --git a/src/features/dashboard/components/AppForm/app-form.module.scss b/src/features/dashboard/components/AppForm/app-form.module.scss index 3575d17c..4dedeb37 100644 --- a/src/features/dashboard/components/AppForm/app-form.module.scss +++ b/src/features/dashboard/components/AppForm/app-form.module.scss @@ -59,7 +59,7 @@ fieldset .customTextInput:last-child { } .helperMargin { - margin: rem(1) 0; + margin: rem(1) 0 0; } .verificationMargin { margin: rem(2) 0; @@ -122,6 +122,7 @@ fieldset .customTextInput:last-child { } .formsubHeading { padding: rem(1.6); + display: inline-block; } .wrapperHeading { padding-left: rem(1.6); @@ -174,3 +175,27 @@ fieldset .customTextInput:last-child { margin-left: rem(4); margin-top: rem(6); } + +.buttons { + display: flex; + gap: rem(1); +} + +.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/AppForm/index.tsx b/src/features/dashboard/components/AppForm/index.tsx index a903ee01..058d9cbd 100644 --- a/src/features/dashboard/components/AppForm/index.tsx +++ b/src/features/dashboard/components/AppForm/index.tsx @@ -1,5 +1,5 @@ -import React, { ReactNode, useMemo } from 'react'; -import { Text } from '@deriv/ui'; +import React, { Dispatch, ReactNode, SetStateAction, useEffect, useMemo, useState } from 'react'; +import { Button, Text } from '@deriv/ui'; import { useForm } from 'react-hook-form'; import { isNotDemoCurrency } from '@site/src/utils'; import { yupResolver } from '@hookform/resolvers/yup'; @@ -14,40 +14,76 @@ import AccountDropdown from '@site/src/components/CustomSelectDropdown/account-d import CustomCheckbox from '@site/src/components/CustomCheckbox'; import styles from './app-form.module.scss'; import clsx from 'clsx'; +import useAppManager from '@site/src/hooks/useAppManager'; +import useWS from '@site/src/hooks/useWs'; +import RestrictionsAppname from '../RestrictionsAppname'; type TAppFormProps = { initialValues?: Partial; isUpdating?: boolean; - renderButtons: () => ReactNode; submit: (data: IRegisterAppForm) => void; is_update_mode?: boolean; + form_is_cleared?: boolean; + setFormIsCleared?: Dispatch>; + cancelButton?: () => ReactNode; }; const AppForm = ({ initialValues, submit, - renderButtons, is_update_mode = false, + form_is_cleared, + setFormIsCleared, + cancelButton, }: TAppFormProps) => { const { register, handleSubmit, + reset, formState: { errors }, } = useForm({ - mode: 'onBlur', + 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 (form_is_cleared) { + setInputValue(''); + setFormIsCleared(false); + reset(); + } + getApps(); + }, [form_is_cleared, 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?.includes('admin'); + 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); @@ -69,20 +105,33 @@ const AppForm = ({ ); + const renderButtons = () => { + return ( +
+ + {is_update_mode && cancelButton()} +
+ ); + }; return (
- - App information - +

App information

{!is_update_mode && ( Select your api token ( it should have admin scope ) @@ -94,7 +143,7 @@ const AppForm = ({
@@ -121,18 +170,31 @@ const AppForm = ({
)} -
- - +
+
{ + 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 && + )}
- {errors && errors?.name && ( - - {errors.name?.message} - - )}

Markup

@@ -174,9 +236,9 @@ const AppForm = ({ > Enter 0 if you don‘t want to earn a markup. Max markup: 3% - {errors && errors?.app_markup_percentage && ( + {errors && errors.app_markup_percentage && ( - {errors.app_markup_percentage?.message} + {errors.app_markup_percentage.message} )}
@@ -226,8 +288,8 @@ const AppForm = ({ />
- {errors && errors?.verification_uri && ( - {errors.verification_uri?.message} + {errors && errors.verification_uri && ( + {errors.verification_uri.message} )}
diff --git a/src/features/dashboard/components/Dialogs/UpdateAppDialog/index.tsx b/src/features/dashboard/components/Dialogs/UpdateAppDialog/index.tsx index 9dc9d994..2113bfea 100644 --- a/src/features/dashboard/components/Dialogs/UpdateAppDialog/index.tsx +++ b/src/features/dashboard/components/Dialogs/UpdateAppDialog/index.tsx @@ -16,7 +16,7 @@ interface IUpdateAppDialog { } const UpdateAppDialog = ({ app, onClose }: IUpdateAppDialog) => { - const { send: updateApp, is_loading, data, error, clear } = useWS('app_update'); + const { send: updateApp, data, error, clear } = useWS('app_update'); const { currentLoginAccount } = useAuthContext(); const { getApps } = useAppManager(); @@ -73,15 +73,8 @@ const UpdateAppDialog = ({ app, onClose }: IUpdateAppDialog) => { [updateApp], ); - const renderButtons = () => { - return ( -
- - -
- ); + const cancelButton = () => { + return ; }; return ( @@ -98,7 +91,7 @@ const UpdateAppDialog = ({ app, onClose }: IUpdateAppDialog) => {
{ + it('Should render the list', () => { + render(); + + const AppRestrictionList = screen.getByRole('list'); + expect(AppRestrictionList).toBeInTheDocument(); + }); + + it('Should display correct content inside list items', () => { + render(); + + const listItem1 = screen.getByText( + 'Only alphanumeric characters with spaces and underscores are allowed.', + ); + const listItem2 = screen.getByText('The name can contain up to 48 characters.'); + const listItem3 = screen.getByText('Duplicate token names aren’t allowed.'); + const listItem4 = screen.getByText( + 'The name cannot contain “Binary”, “Deriv”, or similar words.', + ); + + expect(listItem1).toBeInTheDocument(); + expect(listItem2).toBeInTheDocument(); + expect(listItem3).toBeInTheDocument(); + expect(listItem4).toBeInTheDocument(); + }); +}); diff --git a/src/features/dashboard/components/RestrictionsAppname/index.tsx b/src/features/dashboard/components/RestrictionsAppname/index.tsx new file mode 100644 index 00000000..1bc9b14f --- /dev/null +++ b/src/features/dashboard/components/RestrictionsAppname/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import styles from './RestrictionsAppname.module.scss'; + +const RestrictionsAppname = () => { + return ( +
    +
  1. Only alphanumeric characters with spaces and underscores are allowed.
  2. +
  3. The name can contain up to 48 characters.
  4. +
  5. Duplicate token names aren’t allowed.
  6. +
  7. The name cannot contain “Binary”, “Deriv”, or similar words.
  8. +
+ ); +}; + +export default RestrictionsAppname; diff --git a/src/features/dashboard/register-app/index.tsx b/src/features/dashboard/register-app/index.tsx index 99952d37..c1205a48 100644 --- a/src/features/dashboard/register-app/index.tsx +++ b/src/features/dashboard/register-app/index.tsx @@ -1,17 +1,16 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import useWS from '@site/src/hooks/useWs'; import AppForm from '../components/AppForm'; -import { Button } from '@deriv/ui'; import { scopesObjectToArray } from '@site/src/utils'; import { RegisterAppDialogError } from '../components/Dialogs/RegisterAppDialogError'; import { RegisterAppDialogSuccess } from '../components/Dialogs/RegisterAppDialogSuccess'; import { IRegisterAppForm } from '../types'; import useAuthContext from '@site/src/hooks/useAuthContext'; -import styles from '../components/AppForm/app-form.module.scss'; const AppRegistration = () => { - const { is_loading, send: registerApp, error, clear, data } = useWS('app_register'); + const { send: registerApp, error, clear, data } = useWS('app_register'); const { currentLoginAccount } = useAuthContext(); + const [form_is_cleared, setFormIsCleared] = useState(false); const onSubmit = useCallback( (data: IRegisterAppForm) => { @@ -38,30 +37,18 @@ const AppRegistration = () => { ...can_have_markup, scopes: selectedScopes, }); + setFormIsCleared(true); }, [registerApp], ); - const renderButtons = () => { - return ( - <> - - - ); - }; - return ( <> - + {error && } {data && } diff --git a/src/features/dashboard/types.ts b/src/features/dashboard/types.ts index f4bd28df..f9cf8a73 100644 --- a/src/features/dashboard/types.ts +++ b/src/features/dashboard/types.ts @@ -7,7 +7,19 @@ const base_schema = { name: yup .string() .required('Enter your app name.') - .max(48, 'Your app name cannot exceed 48 characters.'), + .max(48, 'Your app name cannot exceed 48 characters.') + .matches(/^(?=.*[a-zA-Z0-9])[a-zA-Z0-9_ ]*$/, { + 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: 'The name cannot contain “Binary”, “Deriv”, or similar words.', + excludeEmptyString: true, + }, + ), read: yup.boolean(), trade: yup.boolean(), payments: yup.boolean(), diff --git a/src/styles/index.scss b/src/styles/index.scss index 89b6d12a..09ce97d6 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -29,7 +29,7 @@ --ifm-menu-link-sublist-icon-2: url(/img/arrow_sidebar.svg); --ifm-menu-link-padding-vertical: 9px; --doc-sidebar-width: 283px !important; - --search-result: #FF9BA3; + --search-result: #ff9ba3; --schema-string: #21d169; --schema-array: #ff8fc8; --schema-number: #acb2ff; @@ -87,6 +87,19 @@ h6 { .error-message { color: var(--colors-coral500) !important; + padding-left: 12px; + font-size: var(--fontSizes-2xs) !important; + display: inline-block; +} + +.error-border { + border-color: var(--colors-coral500) !important; + &:focus-within { + border-color: var(--colors-coral500) !important; + } + &:focus { + outline: var(--colors-coral500) !important; + } } .error-border { @@ -214,61 +227,61 @@ div[class*='sidebarViewport'] { z-index: 1; } } - &+ div[class*='searchBox'] { - position: fixed; - min-width: rem(32); - max-width: rem(79.2); - max-height: rem(48); - top: 5%; - width: 100%; - height: 100%; - border-radius: rem(1.6); - left: 50%; - transform: translateX(-50%); - padding: 0; - background-color: var(--ifm-color-white); - - @media (max-width: 768px) { - min-width: unset; - max-width: unset; - max-height: unset; - border-radius: unset; - top: 0; - } + & + div[class*='searchBox'] { + position: fixed; + min-width: rem(32); + max-width: rem(79.2); + max-height: rem(48); + top: 5%; + width: 100%; + height: 100%; + border-radius: rem(1.6); + left: 50%; + transform: translateX(-50%); + padding: 0; + background-color: var(--ifm-color-white); + + @media (max-width: 768px) { + min-width: unset; + max-width: unset; + max-height: unset; + border-radius: unset; + top: 0; + } - span[class*='searchBar'] { + span[class*='searchBar'] { + width: 100%; + span[class*='dropdownMenu'] { width: 100%; - span[class*='dropdownMenu'] { - width: 100%; - max-width: unset; - background-color: unset; + max-width: unset; + background-color: unset; + box-shadow: none; + padding: 0; + div[class*='hitFooter'] a { + color: var(--colors-coral500); + } + div[class*='suggestion'] { box-shadow: none; - padding: 0; - div[class*='hitFooter'] a { - color: var(--colors-coral500); + border-radius: 0; + height: rem(4); + span[class*='hitAction'] { + display: none; } - div[class*='suggestion'] { - box-shadow: none; - border-radius: 0; - height: rem(4); - span[class*='hitAction'] { - display: none; - } - span[class*='hitWrapper'] { - span[class*='hitTitle'], - span[class*='hitPath'] { - text-align: left; - mark { - color: var(--colors-coral500); - } + span[class*='hitWrapper'] { + span[class*='hitTitle'], + span[class*='hitPath'] { + text-align: left; + mark { + color: var(--colors-coral500); } - } - &[class*='cursor'] { - background-color: var(--search-result); } } + &[class*='cursor'] { + background-color: var(--search-result); + } } } + } .navbar__search { margin: 0; margin-top: rem(2);