From ecc3f945f0472b53e5b109dc2a5922e35115a7f6 Mon Sep 17 00:00:00 2001 From: Likhith Kolayari <98398322+likhith-deriv@users.noreply.github.com> Date: Mon, 22 Jan 2024 12:37:18 +0400 Subject: [PATCH] [account-v2]/lkhith/[FEQ-1112]/Incorporated IDV Form for V2 account package (#12910) * feat: added base components * feat: incorporated IDV form * feat: display the form values * feat: display the form values * fix: missing type * fix: removed testcases for dummy component * fix: disabled stylelint for dummy CSS * feat: incorporated deriv-com/ui * feat: added validation * fix: resolve sonar clod issue * fix: resolved type issue * fix: updated package version * fix: idv types * fix: idv types * fix: disable CSS lint rules * fix: disable CSS lint rules * feat: added testcases * feat: incorporated review comments * Merge branch 'master' into likhith/FEQ-1112/idv-form-for-account-v2 --- jest.config.js | 2 +- package-lock.json | 11 ++ packages/account-v2/.eslintrc.js | 18 +- packages/account-v2/package.json | 1 + packages/account-v2/src/App.tsx | 13 ++ .../base/WalletDropdown/WalletDropdown.scss | 146 ++++++++++++++ .../base/WalletDropdown/WalletDropdown.tsx | 151 ++++++++++++++ .../components/base/WalletDropdown/index.ts | 1 + .../base/WalletText/WalletText.scss | 186 ++++++++++++++++++ .../components/base/WalletText/WalletText.tsx | 42 ++++ .../src/components/base/WalletText/index.ts | 1 + .../base/WalletTextField/HelperMessage.tsx | 45 +++++ .../base/WalletTextField/WalletTextField.scss | 145 ++++++++++++++ .../base/WalletTextField/WalletTextField.tsx | 113 +++++++++++ .../components/base/WalletTextField/index.ts | 1 + .../account-v2/src/components/base/types.ts | 1 + .../account-v2/src/components/base/utils.ts | 17 ++ .../src/components/form-progress/index.tsx | 14 +- .../components/responsive-wrapper/index.tsx | 20 ++ .../account-v2/src/mocks/idv-form.mock.ts | 88 +++++++++ .../modules/IDVForm/__tests__/utils.spec.ts | 62 ++++++ .../account-v2/src/modules/IDVForm/index.tsx | 129 ++++++++++++ .../account-v2/src/modules/IDVForm/utils.ts | 98 +++++++++ .../account-v2/src/utils/default-options.ts | 8 + 24 files changed, 1304 insertions(+), 9 deletions(-) create mode 100644 packages/account-v2/src/components/base/WalletDropdown/WalletDropdown.scss create mode 100644 packages/account-v2/src/components/base/WalletDropdown/WalletDropdown.tsx create mode 100644 packages/account-v2/src/components/base/WalletDropdown/index.ts create mode 100644 packages/account-v2/src/components/base/WalletText/WalletText.scss create mode 100644 packages/account-v2/src/components/base/WalletText/WalletText.tsx create mode 100644 packages/account-v2/src/components/base/WalletText/index.ts create mode 100644 packages/account-v2/src/components/base/WalletTextField/HelperMessage.tsx create mode 100644 packages/account-v2/src/components/base/WalletTextField/WalletTextField.scss create mode 100644 packages/account-v2/src/components/base/WalletTextField/WalletTextField.tsx create mode 100644 packages/account-v2/src/components/base/WalletTextField/index.ts create mode 100644 packages/account-v2/src/components/base/types.ts create mode 100644 packages/account-v2/src/components/base/utils.ts create mode 100644 packages/account-v2/src/components/responsive-wrapper/index.tsx create mode 100644 packages/account-v2/src/mocks/idv-form.mock.ts create mode 100644 packages/account-v2/src/modules/IDVForm/__tests__/utils.spec.ts create mode 100644 packages/account-v2/src/modules/IDVForm/index.tsx create mode 100644 packages/account-v2/src/modules/IDVForm/utils.ts create mode 100644 packages/account-v2/src/utils/default-options.ts diff --git a/jest.config.js b/jest.config.js index a96778de779b..e1d6b9381fd1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,7 +10,7 @@ module.exports = { coverageReporters: ['lcov'], coverageDirectory: './coverage/', clearMocks: true, - projects: ['/packages/*/jest.config.js'], + projects: ['/packages/*/jest.config.js', '/packages/*/jest.config.ts'], transform: { '^.+\\.jsx?$': 'babel-jest', '^.+/es/^.+$': 'babel-jest', diff --git a/package-lock.json b/package-lock.json index 365a6bdf17c0..01a9ab6b4dd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@contentpass/zxcvbn": "^4.4.3", "@datadog/browser-logs": "^4.36.0", "@datadog/browser-rum": "^4.37.0", + "@deriv-com/ui": "0.0.1-beta.4", "@deriv/analytics": "^1.4.8", "@deriv/api-types": "^1.0.118", "@deriv/deriv-api": "^1.0.13", @@ -2908,6 +2909,11 @@ "@datadog/browser-core": "4.50.1" } }, + "node_modules/@deriv-com/ui": { + "version": "0.0.1-beta.4", + "resolved": "https://registry.npmjs.org/@deriv-com/ui/-/ui-0.0.1-beta.4.tgz", + "integrity": "sha512-A2I/bE/jyilj02/hTcdjAvmXclBxVMntfgH5MGRwEbEszvB2pAO3DklGJkKTjjktE+O82Jx9Y2o9cN93nbDIlA==" + }, "node_modules/@deriv/analytics": { "version": "1.4.8", "resolved": "https://registry.npmjs.org/@deriv/analytics/-/analytics-1.4.8.tgz", @@ -52002,6 +52008,11 @@ "@datadog/browser-core": "4.50.1" } }, + "@deriv-com/ui": { + "version": "0.0.1-beta.4", + "resolved": "https://registry.npmjs.org/@deriv-com/ui/-/ui-0.0.1-beta.4.tgz", + "integrity": "sha512-A2I/bE/jyilj02/hTcdjAvmXclBxVMntfgH5MGRwEbEszvB2pAO3DklGJkKTjjktE+O82Jx9Y2o9cN93nbDIlA==" + }, "@deriv/analytics": { "version": "1.4.8", "resolved": "https://registry.npmjs.org/@deriv/analytics/-/analytics-1.4.8.tgz", diff --git a/packages/account-v2/.eslintrc.js b/packages/account-v2/.eslintrc.js index cb5fe72635d2..836cbb167b0e 100644 --- a/packages/account-v2/.eslintrc.js +++ b/packages/account-v2/.eslintrc.js @@ -38,7 +38,23 @@ module.exports = { '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-unused-vars': 'error', '@typescript-eslint/sort-type-constituents': 'error', - camelcase: 'error', + '@typescript-eslint/naming-convention': [ + 'error', + { + selector: 'variable', + modifiers: ['destructured'], + format: ['camelCase', 'snake_case'], + }, + { + selector: 'variable', + format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + leadingUnderscore: 'allow', + }, + { + selector: 'function', + format: ['camelCase', 'PascalCase'], + }, + ], 'import/first': 'error', 'import/newline-after-import': 'error', 'import/no-duplicates': 'error', diff --git a/packages/account-v2/package.json b/packages/account-v2/package.json index 0c4d99d65f70..638091cd341f 100644 --- a/packages/account-v2/package.json +++ b/packages/account-v2/package.json @@ -11,6 +11,7 @@ "start": "rimraf dist && npm run test && npm run serve" }, "dependencies": { + "@deriv-com/ui": "0.0.1-beta.4", "@deriv/api": "^1.0.0", "@deriv/library": "^1.0.0", "@deriv/quill-design": "^1.3.2", diff --git a/packages/account-v2/src/App.tsx b/packages/account-v2/src/App.tsx index fd61ae79d70a..6302981129e1 100644 --- a/packages/account-v2/src/App.tsx +++ b/packages/account-v2/src/App.tsx @@ -1,10 +1,16 @@ +// TODO - Remove this once the IDV form is moved out +/* eslint-disable @typescript-eslint/no-empty-function */ import React from 'react'; +import { Formik } from 'formik'; import { APIProvider } from '@deriv/api'; import { BreakpointProvider } from '@deriv/quill-design'; import { FormProgress } from './components/form-progress'; import SignupWizard from './components/SignupWizard'; import { SignupWizardProvider, useSignupWizardContext } from './context/SignupWizardContext'; import { stepProgress } from './mocks/form-progress.mock'; +import { DOCUMENT_LIST, INITIAL_VALUES, SELECTED_COUNTRY } from './mocks/idv-form.mock'; +import { IDVForm } from './modules/IDVForm'; +import { getIDVFormValidationSchema } from './modules/IDVForm/utils'; import RouteLinks from './router/components/route-links/route-links'; import './index.scss'; @@ -15,6 +21,9 @@ const TriggerSignupWizardModal: React.FC = () => { }; const App: React.FC = () => { + // TODO - Remove this once the IDV form is moved out + const getValidationSchema = getIDVFormValidationSchema(DOCUMENT_LIST); + return ( @@ -25,6 +34,10 @@ const App: React.FC = () => { {/* [TODO]:Mock - Remove hardcoded initial value once isActive comes from Modal */} + {/* [TODO]:Mock - Remove Mock values */} + {}} validationSchema={getValidationSchema}> + + diff --git a/packages/account-v2/src/components/base/WalletDropdown/WalletDropdown.scss b/packages/account-v2/src/components/base/WalletDropdown/WalletDropdown.scss new file mode 100644 index 000000000000..88d5c44b9df9 --- /dev/null +++ b/packages/account-v2/src/components/base/WalletDropdown/WalletDropdown.scss @@ -0,0 +1,146 @@ +/* stylelint-disable color-no-hex */ + +.wallets-dropdown { + width: 100%; + position: relative; + cursor: pointer; + + &--disabled { + pointer-events: none; + + & label { + color: var(--system-light-5-active-background, #999); + } + } + + &__button { + all: unset; + right: 1.6rem; + transform: rotate(0); + transform-origin: 50% 45%; + transition: transform 0.2s cubic-bezier(0.25, 0.1, 0.25, 1); + + &--active { + transform: rotate(180deg); + } + } + + &__content { + width: 100%; + background: var(--system-light-8-primary-background, #fff); + display: flex; + align-items: center; + + .wallets-textfield__field { + cursor: pointer; + } + } + + &__field { + position: absolute; + inset: 0; + min-width: 0; /* this is required to reset input's default width */ + padding-left: 2rem; + display: flex; + flex-grow: 1; + font-family: inherit; + outline: 0; + font-size: 1.4rem; + background-color: transparent; + color: var(--system-light-2-general-text, #333); + transition: border-color 0.2s; + cursor: unset; + user-select: none; + &::selection { + background-color: transparent; + } + + &::placeholder { + color: transparent; + } + } + + &__field:placeholder-shown ~ &__label { + font-size: 1.4rem; + cursor: text; + top: 30%; + padding: 0; + } + + &__field:placeholder-shown ~ &__label--with-icon { + left: 4.4rem; + } + + label, + &__field:focus ~ &__label { + position: absolute; + top: -0.5rem; + display: block; + transition: 0.2s; + font-size: 1rem; + color: var(--system-light-3-less-prominent-text, #999); + background: var(--system-light-8-primary-background, #fff); + padding-inline: 0.4rem; + left: 1.6rem; + } + + &__field:focus ~ &__label { + color: var(--brand-blue, #85acb0); + } + + &__items { + position: absolute; + top: 4.8rem; + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + z-index: 2; + border-radius: 0.4rem; + background: var(--system-light-8-primary-background, #fff); + box-shadow: 0 3.2rem 6.4rem 0 rgba(14, 14, 14, 0.14); + overflow-y: auto; + + & > :first-child { + border-radius: 0.4rem 0.4rem 0 0; + } + + & > :last-child { + border-radius: 0 0 0.4rem 0.4rem; + } + + &--sm { + max-height: 22rem; + } + + &--md { + max-height: 42rem; + } + + &--lg { + max-height: 66rem; + } + } + + &__icon { + position: absolute; + left: 1.6rem; + width: 1.6rem; + height: 1.6rem; + } + + &__item { + padding: 10px 16px; + width: 100%; + z-index: 2; + + &:hover:not(&--active) { + cursor: pointer; + background: var(--system-light-6-hover-background, #e6e9e9); + } + + &--active { + background: var(--system-light-5-active-background, #d6dadb); + } + } +} diff --git a/packages/account-v2/src/components/base/WalletDropdown/WalletDropdown.tsx b/packages/account-v2/src/components/base/WalletDropdown/WalletDropdown.tsx new file mode 100644 index 000000000000..6f8b8439d62e --- /dev/null +++ b/packages/account-v2/src/components/base/WalletDropdown/WalletDropdown.tsx @@ -0,0 +1,151 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { useCombobox } from 'downshift'; +import { StandaloneChevronDownRegularIcon } from '@deriv/quill-icons'; +import { TGenericSizes } from '../types'; +import { reactNodeToString } from '../utils'; +import { WalletText } from '../WalletText'; +import { WalletTextField } from '../WalletTextField'; +import { WalletTextFieldProps } from '../WalletTextField/WalletTextField'; +import './WalletDropdown.scss'; + +type TProps = { + disabled?: boolean; + errorMessage?: WalletTextFieldProps['errorMessage']; + icon?: React.ReactNode; + isRequired?: boolean; + label?: WalletTextFieldProps['label']; + list: { + text?: React.ReactNode; + value?: string; + }[]; + listHeight?: Extract; + name: WalletTextFieldProps['name']; + onChange?: (inputValue: string) => void; + onSelect: (value: string) => void; + value?: WalletTextFieldProps['value']; + variant?: 'comboBox' | 'prompt'; +}; + +const WalletDropdown: React.FC = ({ + disabled, + errorMessage, + icon = false, + isRequired = false, + label, + list, + listHeight = 'md', + name, + onChange, + onSelect, + value, + variant = 'prompt', +}) => { + const [items, setItems] = useState(list); + const [hasSelected, setHasSelected] = useState(false); + const [shouldFilterList, setShouldFilterList] = useState(false); + const clearFilter = useCallback(() => { + setShouldFilterList(false); + setItems(list); + }, [list]); + const { closeMenu, getInputProps, getItemProps, getMenuProps, getToggleButtonProps, isOpen, openMenu } = + useCombobox({ + defaultSelectedItem: items.find(item => item.value === value) ?? null, + items, + itemToString(item) { + return item ? reactNodeToString(item.text) : ''; + }, + onInputValueChange({ inputValue }) { + onChange?.(inputValue ?? ''); + if (shouldFilterList) { + setItems( + list.filter(item => + reactNodeToString(item.text) + .toLowerCase() + .includes(inputValue?.toLowerCase() ?? '') + ) + ); + } + }, + onIsOpenChange({ isOpen }) { + if (!isOpen) { + clearFilter(); + } + }, + onSelectedItemChange({ selectedItem }) { + onSelect(selectedItem?.value ?? ''); + closeMenu(); + }, + }); + + const handleInputClick = useCallback(() => { + variant === 'comboBox' && setShouldFilterList(true); + + if (isOpen) { + closeMenu(); + } else { + openMenu(); + } + }, [closeMenu, isOpen, openMenu, variant]); + + useEffect(() => { + setItems(list); + }, [list]); + + return ( +
+
+ setHasSelected(true)} + onKeyUp={() => setShouldFilterList(true)} + placeholder={reactNodeToString(label)} + readOnly={variant !== 'comboBox'} + renderLeftIcon={icon ? () => icon : undefined} + renderRightIcon={() => ( + + )} + type='text' + value={value} + {...getInputProps()} + /> +
+
    + {isOpen && + items.map((item, index) => ( +
  • clearFilter()} + {...getItemProps({ index, item })} + > + + {item.text} + +
  • + ))} +
+
+ ); +}; + +export default WalletDropdown; diff --git a/packages/account-v2/src/components/base/WalletDropdown/index.ts b/packages/account-v2/src/components/base/WalletDropdown/index.ts new file mode 100644 index 000000000000..722c0d14170e --- /dev/null +++ b/packages/account-v2/src/components/base/WalletDropdown/index.ts @@ -0,0 +1 @@ +export { default as WalletDropdown } from './WalletDropdown'; diff --git a/packages/account-v2/src/components/base/WalletText/WalletText.scss b/packages/account-v2/src/components/base/WalletText/WalletText.scss new file mode 100644 index 000000000000..e98b2ea82e3c --- /dev/null +++ b/packages/account-v2/src/components/base/WalletText/WalletText.scss @@ -0,0 +1,186 @@ +/* stylelint-disable color-no-hex */ + +$color-map: ( + general: #333333, + primary: #999999, + success: #4bb4b3, + warning: #ffad3a, + error: #ec3f3f, + white: #ffffff, + black: #000000, + less-prominent: #999999, + red: #ff444f, + blue: #377cfc, + green: #17eabd, + orange: #ff9c13, + system-dark-2-general-text: #c2c2c2, +); + +$desktop-font-size-map: ( + '2xs': ( + size: 1rem, + line_height: 1.4rem, + ), + 'xs': ( + size: 1.2rem, + line_height: 1.8rem, + ), + 'sm': ( + size: 1.4rem, + line_height: 2rem, + ), + 'md': ( + size: 1.6rem, + line_height: 2.4rem, + ), + 'lg': ( + size: 2rem, + line_height: 3rem, + ), + 'xl': ( + size: 2.4rem, + line_height: 3.6rem, + ), + '2xl': ( + size: 3.2rem, + line_height: 4rem, + ), + '3xl': ( + size: 4.8rem, + line_height: 6rem, + ), + '4xl': ( + size: 6.4rem, + line_height: 8rem, + ), + '5xl': ( + size: 8rem, + line_height: 10rem, + ), +); + +$mobile-font-size-map: ( + '2xs': ( + size: 0.8rem, + line_height: 1.2rem, + ), + 'xs': ( + size: 1rem, + line_height: 1.4rem, + ), + 'sm': ( + size: 1.2rem, + line_height: 1.8rem, + ), + 'md': ( + size: 1.4rem, + line_height: 2rem, + ), + 'lg': ( + size: 1.6rem, + line_height: 2.4rem, + ), + 'xl': ( + size: 1.8rem, + line_height: 2.6rem, + ), + '2xl': ( + size: 2.4rem, + line_height: 3rem, + ), + '3xl': ( + size: 2.8rem, + line_height: 3.4rem, + ), + '4xl': ( + size: 3.2rem, + line_height: 4rem, + ), + '5xl': ( + size: 4rem, + line_height: 5rem, + ), +); + +$line-height-map: ( + '3xs': 1.2rem, + '2xs': 1.4rem, + 'xs': 1.6rem, + 'sm': 1.8rem, + 'md': 2rem, + 'lg': 2.2rem, + 'xl': 2.4rem, + '2xl': 2.6rem, + '3xl': 2.8rem, + '4xl': 3rem, + '5xl': 3.2rem, + '6xl': 3.4rem, + '7xl': 3.6rem, +); + +$font-weight-map: ( + light: 300, + normal: 400, + bold: 700, +); + +$font-align-map: ( + left: left, + center: center, + right: right, +); + +$font-style-map: ( + italic: italic, + normal: normal, +); + +.wallets-text { + @each $color, $value in $color-map { + &__color--#{$color} { + color: $value; + } + } + + @each $size, $values in $desktop-font-size-map { + &__size--#{$size} { + @include desktop { + font-size: map-get($values, size); + line-height: map-get($values, line_height); + } + } + } + + @each $size, $values in $mobile-font-size-map { + &__size--#{$size} { + @include mobile { + font-size: map-get($values, size); + line-height: map-get($values, line_height); + } + } + } + + @each $lineHeight, $value in $line-height-map { + &__line-height--#{$lineHeight} { + line-height: $value; + } + } + + @each $weight, $value in $font-weight-map { + &__weight--#{$weight} { + font-weight: $value; + } + } + + @each $align, $value in $font-align-map { + &__align--#{$align} { + text-align: $value; + } + } + + @each $fontStyle, $value in $font-style-map { + &__font-style--#{$fontStyle} { + font-style: $value; + } + } +} diff --git a/packages/account-v2/src/components/base/WalletText/WalletText.tsx b/packages/account-v2/src/components/base/WalletText/WalletText.tsx new file mode 100644 index 000000000000..5767b84c0bdb --- /dev/null +++ b/packages/account-v2/src/components/base/WalletText/WalletText.tsx @@ -0,0 +1,42 @@ +import React, { CSSProperties, ElementType, ReactNode } from 'react'; +import classNames from 'classnames'; +import { TGenericSizes } from '../types'; +import './WalletText.scss'; + +export interface WalletTextProps { + align?: CSSProperties['textAlign']; + as?: ElementType; + children: ReactNode; + color?: CSSProperties['color'] | 'error' | 'general' | 'less-prominent' | 'primary' | 'success' | 'warning'; + fontStyle?: CSSProperties['fontStyle']; + lineHeight?: TGenericSizes; + size?: Exclude; + weight?: CSSProperties['fontWeight']; +} + +const WalletText: React.FC = ({ + align = 'left', + as = 'span', + children, + color = 'general', + fontStyle = 'normal', + lineHeight, + size = 'md', + weight = 'normal', +}) => { + const textClassNames = classNames( + 'wallet-text', + `wallets-text__size--${size}`, + `wallets-text__weight--${weight}`, + `wallets-text__align--${align}`, + `wallets-text__color--${color}`, + `wallets-text__line-height--${lineHeight}`, + `wallets-text__font-style--${fontStyle}` + ); + + const Tag = as; + + return {children}; +}; + +export default WalletText; diff --git a/packages/account-v2/src/components/base/WalletText/index.ts b/packages/account-v2/src/components/base/WalletText/index.ts new file mode 100644 index 000000000000..4c5f28ea4fc0 --- /dev/null +++ b/packages/account-v2/src/components/base/WalletText/index.ts @@ -0,0 +1 @@ +export { default as WalletText } from './WalletText'; diff --git a/packages/account-v2/src/components/base/WalletTextField/HelperMessage.tsx b/packages/account-v2/src/components/base/WalletTextField/HelperMessage.tsx new file mode 100644 index 000000000000..1d14bdd611c0 --- /dev/null +++ b/packages/account-v2/src/components/base/WalletTextField/HelperMessage.tsx @@ -0,0 +1,45 @@ +import React, { InputHTMLAttributes, memo } from 'react'; +import WalletText, { WalletTextProps } from '../WalletText/WalletText'; + +export type HelperMessageProps = { + inputValue?: InputHTMLAttributes['value']; + isError?: boolean; + maxLength?: InputHTMLAttributes['maxLength']; + message?: string; + messageVariant?: 'error' | 'general' | 'warning'; +}; + +const HelperMessage: React.FC = memo( + ({ inputValue, isError, maxLength, message, messageVariant = 'general' }) => { + const HelperMessageColors: Record = { + error: 'error', + general: 'less-prominent', + warning: 'warning', + }; + + return ( + + {message && ( +
+ + {message} + +
+ )} + {maxLength && ( +
+ + {inputValue?.toString().length || 0} / {maxLength} + +
+ )} +
+ ); + } +); + +HelperMessage.displayName = 'HelperMessage'; +export default HelperMessage; diff --git a/packages/account-v2/src/components/base/WalletTextField/WalletTextField.scss b/packages/account-v2/src/components/base/WalletTextField/WalletTextField.scss new file mode 100644 index 000000000000..fc005cf16067 --- /dev/null +++ b/packages/account-v2/src/components/base/WalletTextField/WalletTextField.scss @@ -0,0 +1,145 @@ +/* stylelint-disable color-no-hex */ +.wallets-textfield { + min-width: 12rem; + width: 100%; + position: relative; + display: flex; + flex-direction: column; + gap: 0.2rem; + + &--error { + .wallets-textfield__box, + .wallets-textfield__box:hover { + border: 1px solid var(--status-light-danger, #ec3f3f); + } + + .wallets-textfield__box:has(.wallets-textfield__field:focus) { + border: 1px solid var(--brand-blue, #ec3f3f); + } + + .wallets-textfield__box:has(.wallets-textfield__field:valid) { + border: 1px solid var(--brand-blue, #ec3f3f); + } + + .wallets-textfield__label { + color: #ec3f3f; + } + + .wallets-textfield__field:focus ~ .wallets-textfield__label { + color: #ec3f3f; + } + } + + &--disabled { + pointer-events: none; + + & .wallets-textfield__box, + .wallets-textfield__box:hover { + border: 1px solid var(--system-light-5-active-background, #eaeced); + + & input { + color: var(--system-light-5-active-background, #999); + } + } + & .wallets-textfield__box:has(.wallets-textfield__field:focus) { + border: 1px solid var(--system-light-5-active-background, #eaeced); + } + & .wallets-textfield__field { + background: inherit; + } + } + + &__box { + height: 4rem; + width: 100%; + border-radius: 0.4rem; + padding: 1rem 1.6rem; + border: 1px solid var(--system-light-5-active-background, #d6dadb); + display: inline-flex; + align-items: center; + transition: border-color 0.2s; + + &:hover { + border-color: var(--system-light-3-less-prominent-text, #999); + } + } + + &__box:has(&__field:focus) { + border: 1px solid var(--brand-blue, #85acb0); + } + + &__box:has(&__field:invalid) { + border: 1px solid var(--status-light-danger, #ec3f3f); + } + + &__field { + min-width: 0; + font-family: inherit; + outline: 0; + font-size: 1.4rem; + color: var(--system-light-2-general-text, #333); + transition: border-color 0.2s; + flex: 1; + } + + &__field::placeholder { + color: transparent; + } + + &__field:placeholder-shown ~ &__label { + font-size: 1.4rem; + cursor: text; + top: 2rem; + padding: 0; + } + + label, + &__field:focus ~ &__label { + position: absolute; + top: 0%; + transform: translateY(-50%); + display: block; + transition: 0.2s; + font-size: 1rem; + color: var(--system-light-3-less-prominent-text, #999); + background: var(--system-light-8-primary-background, #fff); + padding-inline: 0.4rem; + left: 1.6rem; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + } + + &__field:focus ~ &__label { + color: var(--brand-blue, #85acb0); + } + + &__field:invalid ~ &__label { + color: var(--status-light-danger, #ec3f3f); + } + + &__icon { + &-left { + margin-right: 0.8rem; + } + + &-right { + margin-left: 1.6rem; + } + } + + &__message-container { + height: 2rem; + padding: 0rem 0rem 0rem 1.6rem; + width: 100%; + + &--maxchar { + float: right; + } + + &--msg { + float: left; + text-align: left; + } + } +} diff --git a/packages/account-v2/src/components/base/WalletTextField/WalletTextField.tsx b/packages/account-v2/src/components/base/WalletTextField/WalletTextField.tsx new file mode 100644 index 000000000000..082ea996f146 --- /dev/null +++ b/packages/account-v2/src/components/base/WalletTextField/WalletTextField.tsx @@ -0,0 +1,113 @@ +import React, { ChangeEvent, ComponentProps, forwardRef, Ref, useState } from 'react'; +import classNames from 'classnames'; +import { FormikErrors } from 'formik'; +import HelperMessage, { HelperMessageProps } from './HelperMessage'; +import './WalletTextField.scss'; + +export interface WalletTextFieldProps extends ComponentProps<'input'>, HelperMessageProps { + defaultValue?: string; + disabled?: boolean; + errorMessage?: FormikErrors | FormikErrors[] | string[] | string; + isInvalid?: boolean; + label?: string; + renderLeftIcon?: () => React.ReactNode; + renderRightIcon?: () => React.ReactNode; + shouldShowWarningMessage?: boolean; + showMessage?: boolean; +} + +const WalletTextField = forwardRef( + ( + { + defaultValue = '', + disabled, + errorMessage, + isInvalid, + label, + maxLength, + message, + messageVariant = 'general', + name = 'walletTextField', + onChange, + renderLeftIcon, + renderRightIcon, + shouldShowWarningMessage = false, + showMessage = false, + ...rest + }: WalletTextFieldProps, + ref: Ref + ) => { + const [value, setValue] = useState(defaultValue); + + const handleChange = (e: ChangeEvent) => { + const newValue = e.target.value; + setValue(newValue); + onChange?.(e); + }; + + return ( +
+
+ {typeof renderLeftIcon === 'function' && ( +
+ {renderLeftIcon()} +
+ )} + + {label && ( + + )} + {typeof renderRightIcon === 'function' && ( +
+ {renderRightIcon()} +
+ )} +
+
+ {!disabled && ( + <> + {showMessage && !isInvalid && ( + + )} + {errorMessage && (isInvalid || (!isInvalid && shouldShowWarningMessage)) && ( + + )} + + )} +
+
+ ); + } +); + +WalletTextField.displayName = 'WalletTextField'; +export default WalletTextField; diff --git a/packages/account-v2/src/components/base/WalletTextField/index.ts b/packages/account-v2/src/components/base/WalletTextField/index.ts new file mode 100644 index 000000000000..822c726f6cf1 --- /dev/null +++ b/packages/account-v2/src/components/base/WalletTextField/index.ts @@ -0,0 +1 @@ +export { default as WalletTextField } from './WalletTextField'; diff --git a/packages/account-v2/src/components/base/types.ts b/packages/account-v2/src/components/base/types.ts new file mode 100644 index 000000000000..15ed01d98604 --- /dev/null +++ b/packages/account-v2/src/components/base/types.ts @@ -0,0 +1 @@ +export type TGenericSizes = '2xl' | '2xs' | '3xl' | '3xs' | '4xl' | '5xl' | '6xl' | 'lg' | 'md' | 'sm' | 'xl' | 'xs'; diff --git a/packages/account-v2/src/components/base/utils.ts b/packages/account-v2/src/components/base/utils.ts new file mode 100644 index 000000000000..3a5474f56596 --- /dev/null +++ b/packages/account-v2/src/components/base/utils.ts @@ -0,0 +1,17 @@ +import React, { isValidElement } from 'react'; + +export const reactNodeToString = function (reactNode: React.ReactNode): string { + let string = ''; + if (typeof reactNode === 'string') { + string = reactNode; + } else if (typeof reactNode === 'number') { + string = reactNode.toString(); + } else if (reactNode instanceof Array) { + reactNode.forEach(function (child) { + string += reactNodeToString(child); + }); + } else if (isValidElement(reactNode)) { + string += reactNodeToString(reactNode.props.children); + } + return string; +}; diff --git a/packages/account-v2/src/components/form-progress/index.tsx b/packages/account-v2/src/components/form-progress/index.tsx index a83813027a17..f481792561d5 100644 --- a/packages/account-v2/src/components/form-progress/index.tsx +++ b/packages/account-v2/src/components/form-progress/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { Fragment, useState } from 'react'; import { useBreakpoint } from '@deriv/quill-design'; import StepConnector from './step-connector'; import Stepper, { TStep } from './stepper'; @@ -26,9 +26,9 @@ export const FormProgress = ({ steps = [] }: TFormProgressProps) => { }; return ( - + {isMobile ? ( - + {' '} {/* [TODO]:Mock - remove Fragment once isActive comes from Modal*/}
@@ -45,9 +45,9 @@ export const FormProgress = ({ steps = [] }: TFormProgressProps) => { Update {/* [TODO]:Mock - remove Fragment once isActive comes from Modal*/} - + ) : ( - + {steps.map((step, index) => ( { stepCount={index} /> ))} - + )} - + ); }; diff --git a/packages/account-v2/src/components/responsive-wrapper/index.tsx b/packages/account-v2/src/components/responsive-wrapper/index.tsx new file mode 100644 index 000000000000..cd0dd11bc7e2 --- /dev/null +++ b/packages/account-v2/src/components/responsive-wrapper/index.tsx @@ -0,0 +1,20 @@ +import React, { Fragment } from 'react'; +import { useBreakpoint } from '@deriv/quill-design'; + +type TResponsiveWrapperProps = { + children: { + desktop: React.ReactNode; + mobile: React.ReactNode; + }; +}; + +export const ResponsiveWrapper = ({ children }: TResponsiveWrapperProps) => { + const { isDesktop, isMobile } = useBreakpoint(); + + return ( + + {isMobile && children.mobile} + {isDesktop && children.desktop} + + ); +}; diff --git a/packages/account-v2/src/mocks/idv-form.mock.ts b/packages/account-v2/src/mocks/idv-form.mock.ts new file mode 100644 index 000000000000..55aae8497563 --- /dev/null +++ b/packages/account-v2/src/mocks/idv-form.mock.ts @@ -0,0 +1,88 @@ +import { useResidenceList } from '@deriv/api'; + +export const DOCUMENT_LIST = [ + { + id: 'aadhaar', + text: 'Aadhaar Card', + additional: { + display_name: 'PAN Card', + format: '^[a-zA-Z]{5}\\d{4}[a-zA-Z]{1}$', + example_format: 'ABCDE1234F', + }, + value: '^[0-9]{12}$', + example_format: '123456789012', + }, + { + id: 'drivers_license', + text: 'Drivers License', + value: '^[a-zA-Z0-9]{10,17}$', + example_format: 'AB1234567890123', + }, + { + id: 'epic', + text: 'Voter ID', + value: '^[a-zA-Z]{3}[0-9]{7}$', + example_format: 'ABC1234567', + }, + { + id: 'pan', + text: 'PAN Card', + value: '^[a-zA-Z]{5}\\d{4}[a-zA-Z]{1}$', + example_format: 'ABCDE1234F', + }, + { + id: 'passport', + text: 'Passport', + additional: { + display_name: 'File Number', + format: '^.{15}$', + example_format: 'AB1234567890123', + }, + value: '^.{8}$', + example_format: 'A1234567', + }, +]; + +export const SELECTED_COUNTRY: Exclude< + NonNullable['data'][0]['identity']>['services']>['idv'], + undefined +> = { + documents_supported: { + aadhaar: { + additional: { + display_name: 'PAN Card', + format: '^[a-zA-Z]{5}\\d{4}[a-zA-Z]{1}$', + }, + display_name: 'Aadhaar Card', + format: '^[0-9]{12}$', + }, + drivers_license: { + display_name: 'Drivers License', + format: '^[a-zA-Z0-9]{10,17}$', + }, + epic: { + display_name: 'Voter ID', + format: '^[a-zA-Z]{3}[0-9]{7}$', + }, + pan: { + display_name: 'PAN Card', + format: '^[a-zA-Z]{5}\\d{4}[a-zA-Z]{1}$', + }, + passport: { + additional: { + display_name: 'File Number', + format: '^.{15}$', + }, + display_name: 'Passport', + format: '^.{8}$', + }, + }, + has_visual_sample: 0, + is_country_supported: 1, +}; + +export const INITIAL_VALUES = { + document_type: '', + document_number: '', + document_additional: '', +}; diff --git a/packages/account-v2/src/modules/IDVForm/__tests__/utils.spec.ts b/packages/account-v2/src/modules/IDVForm/__tests__/utils.spec.ts new file mode 100644 index 000000000000..4c4d51e29c57 --- /dev/null +++ b/packages/account-v2/src/modules/IDVForm/__tests__/utils.spec.ts @@ -0,0 +1,62 @@ +import { getIDVFormValidationSchema, getSelectedDocumentConfigData } from '../utils'; + +const DOCUMENT_LIST = [ + { + additional: { + display_name: 'PAN Card', + example_format: 'ABCDE1234F', + format: '^[a-zA-Z]{5}\\d{4}[a-zA-Z]{1}$', + }, + example_format: '123456789012', + id: 'aadhaar', + text: 'Aadhaar Card', + value: '^[0-9]{12}$', + }, + { + example_format: 'AB1234567890123', + id: 'drivers_license', + text: 'Drivers License', + value: '^[a-zA-Z0-9]{10,17}$', + }, + { + example_format: 'ABC1234567', + id: 'epic', + text: 'Voter ID', + value: '^[a-zA-Z]{3}[0-9]{7}$', + }, +]; + +describe('getSelectedDocumentConfigData', () => { + it('should return undefined if list is empty', () => { + expect(getSelectedDocumentConfigData('passport', DOCUMENT_LIST)).toBeUndefined(); + }); + + it('should return document congfig if document type is matched', () => { + expect(getSelectedDocumentConfigData('epic', DOCUMENT_LIST)).toEqual(DOCUMENT_LIST[2]); + }); +}); + +describe('getIDVFormValidationSchema', () => { + it('should return return true when data matches schema', async () => { + const schema = getIDVFormValidationSchema(DOCUMENT_LIST); + + const result = await schema.isValid({ + document_additional: 'hompl7358z', + document_number: '123456789011', + document_type: 'aadhaar', + }); + + expect(result).toBeTruthy(); + }); + + it('should return false when data fails to match schema', async () => { + const schema = getIDVFormValidationSchema(DOCUMENT_LIST); + + const result = await schema.isValid({ + document_number: 'Abc123456', + document_type: 'epic', + }); + + expect(result).toBeFalsy(); + }); +}); diff --git a/packages/account-v2/src/modules/IDVForm/index.tsx b/packages/account-v2/src/modules/IDVForm/index.tsx new file mode 100644 index 000000000000..c108a7a3c253 --- /dev/null +++ b/packages/account-v2/src/modules/IDVForm/index.tsx @@ -0,0 +1,129 @@ +import React, { Fragment, useEffect, useMemo, useState } from 'react'; +import { Field, FieldProps, FormikProps, useFormikContext } from 'formik'; +import { useResidenceList } from '@deriv/api'; +import { useBreakpoint } from '@deriv/quill-design'; +import { WalletDropdown } from '../../components/base/WalletDropdown'; +import { WalletTextField } from '../../components/base/WalletTextField'; +import { DOCUMENT_LIST } from '../../mocks/idv-form.mock'; +import { getIDVNotApplicableOption } from '../../utils/default-options'; +import { getSelectedDocumentConfigData, TDocument } from './utils'; + +type TIDVFormProps = { + allowIDVSkip?: boolean; + selectedCountry: Exclude< + NonNullable['data'][0]['identity']>['services']>['idv'], + undefined + >; +}; + +type TIDVFormValues = { + document_additional?: string; + document_number: string; + document_type: string; +}; + +type TDropDownList = { + text: string; + value: string; +}; + +export const IDVForm = ({ allowIDVSkip, selectedCountry }: TIDVFormProps) => { + const { setFieldValue, values }: FormikProps = useFormikContext(); + const [documentList, setDocumentList] = useState([]); + + const [selectedDocument, setSelectedDocument] = useState(); + + const { isMobile } = useBreakpoint(); + + const { documents_supported } = selectedCountry; + + const IDV_NOT_APPLICABLE_OPTION = useMemo(() => getIDVNotApplicableOption(allowIDVSkip), [allowIDVSkip]); + + const defaultDocument = { + example_format: '', + id: '', + text: '', + value: '', + }; + + const bindDocumentData = (item: string) => { + setFieldValue('document_type', item, true); + setSelectedDocument(getSelectedDocumentConfigData(item, DOCUMENT_LIST)); + if (item === IDV_NOT_APPLICABLE_OPTION.value) { + setFieldValue('document_number', '', true); + setFieldValue('document_additional', '', true); + } + }; + + const handleSelect = (item: string) => { + if (item === 'No results found') { + setFieldValue('document_type', defaultDocument, true); + } else { + bindDocumentData(item); + } + }; + + useEffect(() => { + if (documents_supported && Object.keys(documents_supported)?.length) { + const docList = Object.keys(documents_supported).map((key: string) => { + return { + text: documents_supported[key].display_name, + value: key, + }; + }); + setDocumentList(docList as TDropDownList[]); + } + }, [documents_supported]); + + return ( + +
+ + {({ field, meta }: FieldProps) => ( + + )} + + {values?.document_type !== IDV_NOT_APPLICABLE_OPTION.value && ( + + {({ field, meta }: FieldProps) => ( + + )} + + )} + {selectedDocument?.additional?.display_name && ( + + {({ field, meta }: FieldProps) => ( + + )} + + )} +
+ {/* [TODO]:Mock - Remove Display for form values */} +
+

Document Type: {values?.document_type}

+

Document Number: {values.document_number}

+

Additional Document number: {values.document_additional ?? '--'}

+
+
+ ); +}; diff --git a/packages/account-v2/src/modules/IDVForm/utils.ts b/packages/account-v2/src/modules/IDVForm/utils.ts new file mode 100644 index 000000000000..8857c3315482 --- /dev/null +++ b/packages/account-v2/src/modules/IDVForm/utils.ts @@ -0,0 +1,98 @@ +import * as Yup from 'yup'; +import { AnyObject } from 'yup/lib/object'; + +export const getExampleFormat = (example_format?: string) => (example_format ? `Example: ${example_format}` : ''); + +export type TDocument = { + additional?: { + display_name?: string; + example_format?: string; + format?: string; + }; + example_format?: string; + id: string; + text: string; + value: string; +}; + +const validateDocumentNumber = ( + documentConfig: TDocument | undefined, + documentNumber: string, + context: Yup.TestContext +) => { + const isSameAsExample = documentNumber === documentConfig?.example_format; + const exampleFormat = getExampleFormat(documentConfig?.example_format); + + if (!documentNumber && documentConfig?.text) { + let documentName = ''; + switch (documentConfig.id) { + case 'drivers_license': + documentName = 'Driver License Reference number'; + break; + case 'ssnit': + documentName = 'SSNIT number'; + break; + case 'national_id_no_photo': + documentName = 'NIN'; + break; + default: + documentName = 'document number'; + break; + } + return context.createError({ message: `Please enter your ${documentName}. ${exampleFormat}` }); + } else if (isSameAsExample) { + return context.createError({ message: 'Please enter a valid ID number' }); + } else if (documentConfig && !new RegExp(documentConfig.value).test(documentNumber)) { + return context.createError({ message: `Please enter the correct format. ${exampleFormat}` }); + } + return true; +}; + +const validateAdditionalDocumentNumber = ( + documentConfig: TDocument | undefined, + additionalDocNumber: string | undefined, + context: Yup.TestContext +) => { + if (!additionalDocNumber) { + return context.createError({ + message: `Please enter your ${ + documentConfig?.additional?.display_name?.toLowerCase() ?? 'document number' + }.`, + }); + } else if ( + documentConfig?.additional?.format && + !new RegExp(documentConfig?.additional?.format).test(additionalDocNumber) + ) { + return context.createError({ + message: 'Please enter the correct format', + }); + } + return true; +}; + +export const getIDVFormValidationSchema = (list: TDocument[]) => { + return Yup.object().shape({ + document_additional: Yup.string().test({ + name: 'test-additional-document-number', + test: (value, context) => { + const documentConfig = getSelectedDocumentConfigData(context.parent.document_type, list); + return validateAdditionalDocumentNumber(documentConfig, value, context); + }, + }), + document_number: Yup.string().test({ + name: 'test-document-number', + test: (value, context) => { + const documentConfig = getSelectedDocumentConfigData(context.parent.document_type, list); + return validateDocumentNumber(documentConfig, value as string, context); + }, + }), + document_type: Yup.string().required('Please select a document type.'), + }); +}; + +export const getSelectedDocumentConfigData: (prop: string, list: TDocument[]) => TDocument | undefined = ( + item: string, + list: TDocument[] = [] +) => { + return list?.find(doc => doc.id === item); +}; diff --git a/packages/account-v2/src/utils/default-options.ts b/packages/account-v2/src/utils/default-options.ts new file mode 100644 index 000000000000..4d492cdad748 --- /dev/null +++ b/packages/account-v2/src/utils/default-options.ts @@ -0,0 +1,8 @@ +/** + * Returns an object that allows user to skip IDV + */ + +export const getIDVNotApplicableOption = (isIDVSkip?: boolean) => ({ + text: isIDVSkip ? 'I want to do this later' : "I don't have any of these", + value: 'none', +});