From c8084ffbc9476592e3f7203632e42ba36bed92f6 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Sun, 20 Oct 2024 00:44:43 +0200 Subject: [PATCH 001/346] Hide approve button if report has violations --- src/libs/actions/IOU.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index fb8cd014ec7b..047d081fbacb 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -6988,8 +6988,9 @@ function canApproveIOU(iouReport: OnyxTypes.OnyxInputOrEntry, const reportNameValuePairs = ReportUtils.getReportNameValuePairs(iouReport?.reportID); const isArchivedReport = ReportUtils.isArchivedRoom(iouReport, reportNameValuePairs); const unheldTotalIsZero = iouReport && iouReport.unheldTotal === 0; + const hasViolations = ReportUtils.hasViolations(iouReport?.reportID ?? '-1', allTransactionViolations); - return isCurrentUserManager && !isOpenExpenseReport && !isApproved && !iouSettled && !isArchivedReport && !unheldTotalIsZero; + return isCurrentUserManager && !isOpenExpenseReport && !isApproved && !iouSettled && !isArchivedReport && !unheldTotalIsZero && !hasViolations; } function canIOUBePaid( From 6aa92816654f3873c65f33915daa12832f2df021 Mon Sep 17 00:00:00 2001 From: Hans Date: Sun, 20 Oct 2024 16:46:47 +0700 Subject: [PATCH 002/346] Add validatecode modal when issuing Physical card --- src/libs/actions/Wallet.ts | 3 +- .../Wallet/Card/BaseGetPhysicalCard.tsx | 59 +++++++++++++++---- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/src/libs/actions/Wallet.ts b/src/libs/actions/Wallet.ts index b1f97421eea0..1a345be14100 100644 --- a/src/libs/actions/Wallet.ts +++ b/src/libs/actions/Wallet.ts @@ -257,7 +257,7 @@ function answerQuestionsForWallet(answers: WalletQuestionAnswer[], idNumber: str }); } -function requestPhysicalExpensifyCard(cardID: number, authToken: string, privatePersonalDetails: PrivatePersonalDetails) { +function requestPhysicalExpensifyCard(cardID: number, authToken: string, privatePersonalDetails: PrivatePersonalDetails, validateCode: string) { const {legalFirstName = '', legalLastName = '', phoneNumber = ''} = privatePersonalDetails; const {city = '', country = '', state = '', street = '', zip = ''} = PersonalDetailsUtils.getCurrentAddress(privatePersonalDetails) ?? {}; @@ -271,6 +271,7 @@ function requestPhysicalExpensifyCard(cardID: number, authToken: string, private addressState: state, addressStreet: street, addressZip: zip, + validateCode, }; const optimisticData: OnyxUpdate[] = [ diff --git a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx index ae003c4afbe2..aed73018d582 100644 --- a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx +++ b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx @@ -1,13 +1,16 @@ -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import type {ReactNode} from 'react'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx, withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import FormProvider from '@components/Form/FormProvider'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; +import ValidateCodeActionModal from '@components/ValidateCodeActionModal'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as FormActions from '@libs/actions/FormActions'; +import * as User from '@libs/actions/User'; import * as Wallet from '@libs/actions/Wallet'; import * as CardUtils from '@libs/CardUtils'; import * as GetPhysicalCardUtils from '@libs/GetPhysicalCardUtils'; @@ -108,7 +111,9 @@ function BaseGetPhysicalCard({ }: BaseGetPhysicalCardProps) { const styles = useThemeStyles(); const isRouteSet = useRef(false); - + const {translate} = useLocalize(); + const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const [isActionCodeModalVisible, setActionCodeModalVisible] = useState(false); const domainCards = CardUtils.getDomainCards(cardList)[domain] || []; const cardToBeIssued = domainCards.find((card) => !card?.nameValuePairs?.isVirtual && card?.state === CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED); const cardID = cardToBeIssued?.cardID.toString() ?? '-1'; @@ -145,18 +150,37 @@ function BaseGetPhysicalCard({ }, [cardList, currentRoute, domain, domainCards.length, draftValues, loginList, cardToBeIssued, privatePersonalDetails]); const onSubmit = useCallback(() => { - const updatedPrivatePersonalDetails = GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(draftValues, privatePersonalDetails); - // If the current step of the get physical card flow is the confirmation page - if (isConfirmation) { - Wallet.requestPhysicalExpensifyCard(cardToBeIssued?.cardID ?? -1, session?.authToken ?? '', updatedPrivatePersonalDetails); - // Form draft data needs to be erased when the flow is complete, - // so that no stale data is left on Onyx - FormActions.clearDraftValues(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM); - Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(cardID.toString())); + setActionCodeModalVisible(true); + }, []); + + const handleIssuePhysicalCard = useCallback( + (validateCode: string) => { + const updatedPrivatePersonalDetails = GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(draftValues, privatePersonalDetails); + // If the current step of the get physical card flow is the confirmation page + if (isConfirmation) { + Wallet.requestPhysicalExpensifyCard(cardToBeIssued?.cardID ?? -1, session?.authToken ?? '', updatedPrivatePersonalDetails, validateCode); + // Form draft data needs to be erased when the flow is complete, + // so that no stale data is left on Onyx + FormActions.clearDraftValues(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM); + Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(cardID.toString())); + return; + } + GetPhysicalCardUtils.goToNextPhysicalCardRoute(domain, updatedPrivatePersonalDetails); + }, + [cardID, cardToBeIssued?.cardID, domain, draftValues, isConfirmation, session?.authToken, privatePersonalDetails], + ); + + const sendValidateCode = useCallback(() => { + const primaryLogin = account?.primaryLogin ?? ''; + const loginData = loginList?.[primaryLogin]; + + if (loginData?.validateCodeSent) { return; } - GetPhysicalCardUtils.goToNextPhysicalCardRoute(domain, updatedPrivatePersonalDetails); - }, [cardID, cardToBeIssued?.cardID, domain, draftValues, isConfirmation, session?.authToken, privatePersonalDetails]); + + User.requestValidateCodeAction(); + }, [account]); + return ( {headline} {renderContent({onSubmit, submitButtonText, children, onValidate})} + {}} + handleSubmitForm={handleIssuePhysicalCard} + title={translate('cardPage.validateCardTitle')} + onClose={() => setActionCodeModalVisible(false)} + description={translate('cardPage.enterMagicCode', {contactMethod: account?.primaryLogin ?? ''})} + /> ); } From b1e52cc0a62feac8924e809fb4055e604ad7b396 Mon Sep 17 00:00:00 2001 From: Hans Date: Sun, 20 Oct 2024 17:19:18 +0700 Subject: [PATCH 003/346] adjust logic --- .../RequestPhysicalExpensifyCardParams.ts | 1 + .../Wallet/Card/BaseGetPhysicalCard.tsx | 25 ++++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/libs/API/parameters/RequestPhysicalExpensifyCardParams.ts b/src/libs/API/parameters/RequestPhysicalExpensifyCardParams.ts index 91995b6e37aa..94e45a29b728 100644 --- a/src/libs/API/parameters/RequestPhysicalExpensifyCardParams.ts +++ b/src/libs/API/parameters/RequestPhysicalExpensifyCardParams.ts @@ -8,6 +8,7 @@ type RequestPhysicalExpensifyCardParams = { addressState: string; addressStreet: string; addressZip: string; + validateCode: string; }; export default RequestPhysicalExpensifyCardParams; diff --git a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx index aed73018d582..ac579a0942c4 100644 --- a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx +++ b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx @@ -150,22 +150,23 @@ function BaseGetPhysicalCard({ }, [cardList, currentRoute, domain, domainCards.length, draftValues, loginList, cardToBeIssued, privatePersonalDetails]); const onSubmit = useCallback(() => { - setActionCodeModalVisible(true); - }, []); + const updatedPrivatePersonalDetails = GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(draftValues, privatePersonalDetails); + if (isConfirmation) { + setActionCodeModalVisible(true); + return; + } + GetPhysicalCardUtils.goToNextPhysicalCardRoute(domain, updatedPrivatePersonalDetails); + }, [isConfirmation, domain]); const handleIssuePhysicalCard = useCallback( (validateCode: string) => { const updatedPrivatePersonalDetails = GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(draftValues, privatePersonalDetails); // If the current step of the get physical card flow is the confirmation page - if (isConfirmation) { - Wallet.requestPhysicalExpensifyCard(cardToBeIssued?.cardID ?? -1, session?.authToken ?? '', updatedPrivatePersonalDetails, validateCode); - // Form draft data needs to be erased when the flow is complete, - // so that no stale data is left on Onyx - FormActions.clearDraftValues(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM); - Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(cardID.toString())); - return; - } - GetPhysicalCardUtils.goToNextPhysicalCardRoute(domain, updatedPrivatePersonalDetails); + Wallet.requestPhysicalExpensifyCard(cardToBeIssued?.cardID ?? -1, session?.authToken ?? '', updatedPrivatePersonalDetails, validateCode); + // Form draft data needs to be erased when the flow is complete, + // so that no stale data is left on Onyx + FormActions.clearDraftValues(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM); + Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(cardID.toString())); }, [cardID, cardToBeIssued?.cardID, domain, draftValues, isConfirmation, session?.authToken, privatePersonalDetails], ); @@ -179,7 +180,7 @@ function BaseGetPhysicalCard({ } User.requestValidateCodeAction(); - }, [account]); + }, [account, loginList]); return ( Date: Sun, 20 Oct 2024 17:29:38 +0700 Subject: [PATCH 004/346] address linting --- ios/.ruby-version | 1 + src/libs/Permissions.ts | 2 +- src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 ios/.ruby-version diff --git a/ios/.ruby-version b/ios/.ruby-version new file mode 100644 index 000000000000..fa7adc7ac72a --- /dev/null +++ b/ios/.ruby-version @@ -0,0 +1 @@ +3.3.5 diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 24de2e612208..1d6fd98d8ccc 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -4,7 +4,7 @@ import type {IOUType} from '@src/CONST'; import type Beta from '@src/types/onyx/Beta'; function canUseAllBetas(betas: OnyxEntry): boolean { - return !!betas?.includes(CONST.BETAS.ALL); + return true//!!betas?.includes(CONST.BETAS.ALL); } function canUseDefaultRooms(betas: OnyxEntry): boolean { diff --git a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx index ac579a0942c4..9638fc6aba60 100644 --- a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx +++ b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx @@ -156,7 +156,7 @@ function BaseGetPhysicalCard({ return; } GetPhysicalCardUtils.goToNextPhysicalCardRoute(domain, updatedPrivatePersonalDetails); - }, [isConfirmation, domain]); + }, [isConfirmation, domain, draftValues, privatePersonalDetails]); const handleIssuePhysicalCard = useCallback( (validateCode: string) => { @@ -168,7 +168,7 @@ function BaseGetPhysicalCard({ FormActions.clearDraftValues(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM); Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(cardID.toString())); }, - [cardID, cardToBeIssued?.cardID, domain, draftValues, isConfirmation, session?.authToken, privatePersonalDetails], + [cardID, cardToBeIssued?.cardID, draftValues, session?.authToken, privatePersonalDetails], ); const sendValidateCode = useCallback(() => { From 91bc9b1e06176f0135291fd45dd8442c3a50c9cc Mon Sep 17 00:00:00 2001 From: Hans Date: Sun, 20 Oct 2024 17:30:33 +0700 Subject: [PATCH 005/346] revert .ruby-version --- ios/.ruby-version | 1 - src/libs/Permissions.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 ios/.ruby-version diff --git a/ios/.ruby-version b/ios/.ruby-version deleted file mode 100644 index fa7adc7ac72a..000000000000 --- a/ios/.ruby-version +++ /dev/null @@ -1 +0,0 @@ -3.3.5 diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 1d6fd98d8ccc..24de2e612208 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -4,7 +4,7 @@ import type {IOUType} from '@src/CONST'; import type Beta from '@src/types/onyx/Beta'; function canUseAllBetas(betas: OnyxEntry): boolean { - return true//!!betas?.includes(CONST.BETAS.ALL); + return !!betas?.includes(CONST.BETAS.ALL); } function canUseDefaultRooms(betas: OnyxEntry): boolean { From 98904943d30d963af7c62aacb8deb25c7e6eb349 Mon Sep 17 00:00:00 2001 From: Hans Date: Wed, 23 Oct 2024 14:07:42 +0700 Subject: [PATCH 006/346] Onyx migration --- .../Wallet/Card/BaseGetPhysicalCard.tsx | 50 +++---------------- 1 file changed, 8 insertions(+), 42 deletions(-) diff --git a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx index 9638fc6aba60..f318416e5642 100644 --- a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx +++ b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useEffect, useRef, useState} from 'react'; import type {ReactNode} from 'react'; -import {useOnyx, withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import FormProvider from '@components/Form/FormProvider'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -19,7 +19,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {GetPhysicalCardForm} from '@src/types/form'; -import type {CardList, LoginList, PrivatePersonalDetails, Session} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; @@ -31,24 +30,7 @@ type RenderContentProps = ChildrenProps & { onValidate: OnValidate; }; -type BaseGetPhysicalCardOnyxProps = { - /** List of available assigned cards */ - cardList: OnyxEntry; - - /** User's private personal details */ - privatePersonalDetails: OnyxEntry; - - /** Draft values used by the get physical card form */ - draftValues: OnyxEntry; - - /** Session info for the currently logged in user. */ - session: OnyxEntry; - - /** List of available login methods */ - loginList: OnyxEntry; -}; - -type BaseGetPhysicalCardProps = BaseGetPhysicalCardOnyxProps & { +type BaseGetPhysicalCardProps = { /** Text displayed below page title */ headline: string; @@ -94,17 +76,12 @@ function DefaultRenderContent({onSubmit, submitButtonText, children, onValidate} } function BaseGetPhysicalCard({ - cardList, children, currentRoute, domain, - draftValues, - privatePersonalDetails, headline, isConfirmation = false, - loginList, renderContent = DefaultRenderContent, - session, submitButtonText, title, onValidate = () => ({}), @@ -112,6 +89,11 @@ function BaseGetPhysicalCard({ const styles = useThemeStyles(); const isRouteSet = useRef(false); const {translate} = useLocalize(); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); + const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); + const [session] = useOnyx(ONYXKEYS.SESSION); + const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); + const [draftValues] = useOnyx(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM_DRAFT); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [isActionCodeModalVisible, setActionCodeModalVisible] = useState(false); const domainCards = CardUtils.getDomainCards(cardList)[domain] || []; @@ -209,22 +191,6 @@ function BaseGetPhysicalCard({ BaseGetPhysicalCard.displayName = 'BaseGetPhysicalCard'; -export default withOnyx({ - cardList: { - key: ONYXKEYS.CARD_LIST, - }, - loginList: { - key: ONYXKEYS.LOGIN_LIST, - }, - session: { - key: ONYXKEYS.SESSION, - }, - privatePersonalDetails: { - key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, - }, - draftValues: { - key: ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM_DRAFT, - }, -})(BaseGetPhysicalCard); +export default BaseGetPhysicalCard; export type {RenderContentProps}; From f63804fda2685824aee85d1a1f9eba15d0bed65e Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Fri, 25 Oct 2024 19:24:52 +0800 Subject: [PATCH 007/346] fix hidden shows briefly when mentioning unknown user --- .../HTMLRenderers/MentionUserRenderer.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx index 36586b09e514..96bdf8e9e1e8 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx @@ -4,9 +4,9 @@ import isEmpty from 'lodash/isEmpty'; import React from 'react'; import {StyleSheet} from 'react-native'; import type {TextStyle} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; import {TNodeChildrenRenderer} from 'react-native-render-html'; -import {usePersonalDetails} from '@components/OnyxProvider'; import {ShowContextMenuContext, showContextMenuForReport} from '@components/ShowContextMenuContext'; import Text from '@components/Text'; import UserDetailsTooltip from '@components/UserDetailsTooltip'; @@ -20,6 +20,7 @@ import Navigation from '@libs/Navigation/Navigation'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import asMutable from '@src/types/utils/asMutable'; @@ -31,7 +32,7 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const htmlAttribAccountID = tnode.attributes.accountid; - const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const htmlAttributeAccountID = tnode.attributes.accountid; let accountID: number; @@ -56,7 +57,7 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona return displayText.split('@').at(0); }; - if (!isEmpty(htmlAttribAccountID)) { + if (!isEmpty(htmlAttribAccountID) && personalDetails?.[htmlAttribAccountID]) { const user = personalDetails[htmlAttribAccountID]; accountID = parseInt(htmlAttribAccountID, 10); mentionDisplayText = LocalePhoneNumber.formatPhoneNumber(user?.login ?? '') || PersonalDetailsUtils.getDisplayNameOrDefault(user); From 55187f468b175a13fea360057d204ce20f4324ed Mon Sep 17 00:00:00 2001 From: Hans Date: Wed, 30 Oct 2024 15:21:46 +0700 Subject: [PATCH 008/346] handle generic error --- src/libs/actions/Wallet.ts | 35 ++++++++++++++++++- .../Wallet/Card/BaseGetPhysicalCard.tsx | 17 ++++++--- src/types/onyx/Card.ts | 3 ++ 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/libs/actions/Wallet.ts b/src/libs/actions/Wallet.ts index 1a345be14100..c395c4191cb2 100644 --- a/src/libs/actions/Wallet.ts +++ b/src/libs/actions/Wallet.ts @@ -10,6 +10,7 @@ import type { VerifyIdentityParams, } from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import * as ErrorUtils from '@libs/ErrorUtils'; import type {PrivatePersonalDetails} from '@libs/GetPhysicalCardUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import type CONST from '@src/CONST'; @@ -281,6 +282,9 @@ function requestPhysicalExpensifyCard(cardID: number, authToken: string, private value: { [cardID]: { state: 4, // NOT_ACTIVATED + isLoading: true, + errors: null, + isSuccessfull: null, }, }, }, @@ -291,7 +295,36 @@ function requestPhysicalExpensifyCard(cardID: number, authToken: string, private }, ]; - API.write(WRITE_COMMANDS.REQUEST_PHYSICAL_EXPENSIFY_CARD, requestParams, {optimisticData}); + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CARD_LIST, + value: { + [cardID]: { + state: 4, // NOT_ACTIVATED + isLoading: false, + errors: null, + isSuccessfull: true, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CARD_LIST, + value: { + [cardID]: { + state: 4, // NOT_ACTIVATED + isLoading: false, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + API.write(WRITE_COMMANDS.REQUEST_PHYSICAL_EXPENSIFY_CARD, requestParams, {optimisticData, failureData, successData}); } function resetWalletAdditionalDetailsDraft() { diff --git a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx index f318416e5642..4549e33795cc 100644 --- a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx +++ b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx @@ -99,6 +99,7 @@ function BaseGetPhysicalCard({ const domainCards = CardUtils.getDomainCards(cardList)[domain] || []; const cardToBeIssued = domainCards.find((card) => !card?.nameValuePairs?.isVirtual && card?.state === CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED); const cardID = cardToBeIssued?.cardID.toString() ?? '-1'; + const isSuccessful = cardToBeIssued?.isSuccessfull; useEffect(() => { if (isRouteSet.current || !privatePersonalDetails || !cardList) { @@ -131,6 +132,16 @@ function BaseGetPhysicalCard({ isRouteSet.current = true; }, [cardList, currentRoute, domain, domainCards.length, draftValues, loginList, cardToBeIssued, privatePersonalDetails]); + useEffect(() => { + if (!isSuccessful) { + return; + } + // Form draft data needs to be erased when the flow is complete, + // so that no stale data is left on Onyx + FormActions.clearDraftValues(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM); + Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(cardID.toString())); + }, [isSuccessful, cardID]); + const onSubmit = useCallback(() => { const updatedPrivatePersonalDetails = GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(draftValues, privatePersonalDetails); if (isConfirmation) { @@ -145,12 +156,8 @@ function BaseGetPhysicalCard({ const updatedPrivatePersonalDetails = GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(draftValues, privatePersonalDetails); // If the current step of the get physical card flow is the confirmation page Wallet.requestPhysicalExpensifyCard(cardToBeIssued?.cardID ?? -1, session?.authToken ?? '', updatedPrivatePersonalDetails, validateCode); - // Form draft data needs to be erased when the flow is complete, - // so that no stale data is left on Onyx - FormActions.clearDraftValues(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM); - Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(cardID.toString())); }, - [cardID, cardToBeIssued?.cardID, draftValues, session?.authToken, privatePersonalDetails], + [cardToBeIssued?.cardID, draftValues, session?.authToken, privatePersonalDetails], ); const sendValidateCode = useCallback(() => { diff --git a/src/types/onyx/Card.ts b/src/types/onyx/Card.ts index c1d0e09d9312..dfe0695a0ece 100644 --- a/src/types/onyx/Card.ts +++ b/src/types/onyx/Card.ts @@ -115,6 +115,9 @@ type Card = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Collection of form field errors */ errorFields?: OnyxCommon.ErrorFields; }>; + + /** Card status */ + isSuccessfull?: boolean; }>; /** Model of Expensify card details */ From 528d544a0b7f886c5c96269567b971298ed9e22b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Ch=C3=A1vez?= Date: Wed, 30 Oct 2024 16:04:46 -0600 Subject: [PATCH 009/346] Update transaction violations when the money request is paid --- src/libs/actions/IOU.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 7ce9b9dfb272..27bce6a61a7e 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -6956,6 +6956,26 @@ function getPayMoneyRequestParams( }, }); } + + const optimisticTransactionViolations: OnyxUpdate[] = reportTransactions.map(({transactionID}) => { + const violations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; + return { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: null, + }; + }); + optimisticData.push(...optimisticTransactionViolations); + + const failureTransactionViolations: OnyxUpdate[] = reportTransactions.map(({transactionID}) => { + const violations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; + return { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: violations, + }; + }); + failureData.push(...failureTransactionViolations); } let optimisticHoldReportID; From 234327d88fddb3801addf036ce02c52eb3527192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Ch=C3=A1vez?= Date: Wed, 30 Oct 2024 21:45:23 -0600 Subject: [PATCH 010/346] Remove unused value --- src/libs/actions/IOU.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 27bce6a61a7e..1f8464c47748 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -6958,7 +6958,6 @@ function getPayMoneyRequestParams( } const optimisticTransactionViolations: OnyxUpdate[] = reportTransactions.map(({transactionID}) => { - const violations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; return { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, From 340d3b177a3d378d7c250dc0e105bba6beb5f031 Mon Sep 17 00:00:00 2001 From: Hans Date: Fri, 1 Nov 2024 10:08:45 +0700 Subject: [PATCH 011/346] update the error field --- src/libs/actions/Wallet.ts | 17 +++++++++++++-- .../Wallet/Card/BaseGetPhysicalCard.tsx | 21 +++++++------------ src/types/onyx/Card.ts | 2 +- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/libs/actions/Wallet.ts b/src/libs/actions/Wallet.ts index c395c4191cb2..6849223974c9 100644 --- a/src/libs/actions/Wallet.ts +++ b/src/libs/actions/Wallet.ts @@ -284,7 +284,7 @@ function requestPhysicalExpensifyCard(cardID: number, authToken: string, private state: 4, // NOT_ACTIVATED isLoading: true, errors: null, - isSuccessfull: null, + isSuccessful: null, }, }, }, @@ -304,7 +304,7 @@ function requestPhysicalExpensifyCard(cardID: number, authToken: string, private state: 4, // NOT_ACTIVATED isLoading: false, errors: null, - isSuccessfull: true, + isSuccessful: true, }, }, }, @@ -331,6 +331,18 @@ function resetWalletAdditionalDetailsDraft() { Onyx.set(ONYXKEYS.FORMS.WALLET_ADDITIONAL_DETAILS_DRAFT, null); } +/** + * Clear the error of specific card + * @param cardId The card id of the card that you want to clear the errors. + */ +function clearPhysicalCardError(cardId: string) { + Onyx.merge(ONYXKEYS.CARD_LIST, { + [cardId]: { + errors: null, + }, + }); +} + export { openOnfidoFlow, openInitialSettingsPage, @@ -345,4 +357,5 @@ export { setKYCWallSource, requestPhysicalExpensifyCard, resetWalletAdditionalDetailsDraft, + clearPhysicalCardError, }; diff --git a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx index 4549e33795cc..57eb35b295ec 100644 --- a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx +++ b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx @@ -13,6 +13,7 @@ import * as FormActions from '@libs/actions/FormActions'; import * as User from '@libs/actions/User'; import * as Wallet from '@libs/actions/Wallet'; import * as CardUtils from '@libs/CardUtils'; +import * as ErrorUtils from '@libs/ErrorUtils'; import * as GetPhysicalCardUtils from '@libs/GetPhysicalCardUtils'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; @@ -99,7 +100,8 @@ function BaseGetPhysicalCard({ const domainCards = CardUtils.getDomainCards(cardList)[domain] || []; const cardToBeIssued = domainCards.find((card) => !card?.nameValuePairs?.isVirtual && card?.state === CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED); const cardID = cardToBeIssued?.cardID.toString() ?? '-1'; - const isSuccessful = cardToBeIssued?.isSuccessfull; + const isSuccessful = cardToBeIssued?.isSuccessful; + const errorMessage = ErrorUtils.getLatestErrorMessageField(cardToBeIssued); useEffect(() => { if (isRouteSet.current || !privatePersonalDetails || !cardList) { @@ -139,6 +141,7 @@ function BaseGetPhysicalCard({ // Form draft data needs to be erased when the flow is complete, // so that no stale data is left on Onyx FormActions.clearDraftValues(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM); + Wallet.clearPhysicalCardError(cardID); Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(cardID.toString())); }, [isSuccessful, cardID]); @@ -160,17 +163,6 @@ function BaseGetPhysicalCard({ [cardToBeIssued?.cardID, draftValues, session?.authToken, privatePersonalDetails], ); - const sendValidateCode = useCallback(() => { - const primaryLogin = account?.primaryLogin ?? ''; - const loginData = loginList?.[primaryLogin]; - - if (loginData?.validateCodeSent) { - return; - } - - User.requestValidateCodeAction(); - }, [account, loginList]); - return ( {}} + sendValidateCode={() => User.requestValidateCodeAction()} + clearError={() => Wallet.clearPhysicalCardError(cardID)} + validateError={errorMessage} handleSubmitForm={handleIssuePhysicalCard} title={translate('cardPage.validateCardTitle')} onClose={() => setActionCodeModalVisible(false)} diff --git a/src/types/onyx/Card.ts b/src/types/onyx/Card.ts index dfe0695a0ece..a7debf5df925 100644 --- a/src/types/onyx/Card.ts +++ b/src/types/onyx/Card.ts @@ -117,7 +117,7 @@ type Card = OnyxCommon.OnyxValueWithOfflineFeedback<{ }>; /** Card status */ - isSuccessfull?: boolean; + isSuccessful?: boolean; }>; /** Model of Expensify card details */ From 23c5163536340c45f71f651cef1246d081d64862 Mon Sep 17 00:00:00 2001 From: Hans Date: Fri, 1 Nov 2024 13:50:23 +0700 Subject: [PATCH 012/346] add loading field and other stuffs --- .../ValidateCodeForm/BaseValidateCodeForm.tsx | 6 ++++- .../ValidateCodeActionModal/index.tsx | 2 ++ .../ValidateCodeActionModal/type.ts | 3 +++ src/libs/actions/Wallet.ts | 27 ++++++++++++++++++- .../Wallet/Card/BaseGetPhysicalCard.tsx | 5 +++- 5 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx index cc2a7314f570..2c318996c235 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -65,6 +65,9 @@ type ValidateCodeFormProps = { /** Function is called when validate code modal is mounted and on magic code resend */ sendValidateCode: () => void; + + /** Wheather the form is loading or not */ + isLoading?: boolean; }; function BaseValidateCodeForm({ @@ -78,6 +81,7 @@ function BaseValidateCodeForm({ clearError, sendValidateCode, buttonStyles, + isLoading, }: ValidateCodeFormProps) { const {translate} = useLocalize(); const {isOffline} = useNetwork(); @@ -266,7 +270,7 @@ function BaseValidateCodeForm({ style={[styles.mt4]} success large - isLoading={account?.isLoading} + isLoading={account?.isLoading || isLoading} /> diff --git a/src/components/ValidateCodeActionModal/index.tsx b/src/components/ValidateCodeActionModal/index.tsx index 8c09d8caad62..21298aa06787 100644 --- a/src/components/ValidateCodeActionModal/index.tsx +++ b/src/components/ValidateCodeActionModal/index.tsx @@ -25,6 +25,7 @@ function ValidateCodeActionModal({ footer, sendValidateCode, hasMagicCodeBeenSent, + isLoading, }: ValidateCodeActionModalProps) { const themeStyles = useThemeStyles(); const firstRenderRef = useRef(true); @@ -70,6 +71,7 @@ function ValidateCodeActionModal({ {description} ) => Errors; @@ -97,6 +98,7 @@ function BaseGetPhysicalCard({ const [draftValues] = useOnyx(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM_DRAFT); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [isActionCodeModalVisible, setActionCodeModalVisible] = useState(false); + const [formData] = useOnyx(ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM); const domainCards = CardUtils.getDomainCards(cardList)[domain] || []; const cardToBeIssued = domainCards.find((card) => !card?.nameValuePairs?.isVirtual && card?.state === CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED); const cardID = cardToBeIssued?.cardID.toString() ?? '-1'; @@ -176,10 +178,11 @@ function BaseGetPhysicalCard({ {headline} {renderContent({onSubmit, submitButtonText, children, onValidate})} User.requestValidateCodeAction()} clearError={() => Wallet.clearPhysicalCardError(cardID)} - validateError={errorMessage} + validateError={!isEmptyObject(formData?.errors) ? formData?.errors : errorMessage} handleSubmitForm={handleIssuePhysicalCard} title={translate('cardPage.validateCardTitle')} onClose={() => setActionCodeModalVisible(false)} From 6ce22aa89dc5cd06e86b7954f866a8b5310a359d Mon Sep 17 00:00:00 2001 From: Hans Date: Fri, 1 Nov 2024 14:22:41 +0700 Subject: [PATCH 013/346] remove cardState optimistic --- src/libs/actions/Wallet.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/actions/Wallet.ts b/src/libs/actions/Wallet.ts index cfb4b6082ff8..cf4cc2e0be59 100644 --- a/src/libs/actions/Wallet.ts +++ b/src/libs/actions/Wallet.ts @@ -282,7 +282,6 @@ function requestPhysicalExpensifyCard(cardID: number, authToken: string, private key: ONYXKEYS.CARD_LIST, value: { [cardID]: { - state: 4, // NOT_ACTIVATED isLoading: true, errors: null, isSuccessful: null, From 2224d58b60a34e2314c9d71f0df5623d83df9e08 Mon Sep 17 00:00:00 2001 From: Hans Date: Fri, 1 Nov 2024 14:36:49 +0700 Subject: [PATCH 014/346] prettier --- src/libs/actions/Wallet.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/Wallet.ts b/src/libs/actions/Wallet.ts index cf4cc2e0be59..da88957b1cbd 100644 --- a/src/libs/actions/Wallet.ts +++ b/src/libs/actions/Wallet.ts @@ -14,10 +14,10 @@ import * as ErrorUtils from '@libs/ErrorUtils'; import type {PrivatePersonalDetails} from '@libs/GetPhysicalCardUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import type CONST from '@src/CONST'; -import * as FormActions from './FormActions'; import ONYXKEYS from '@src/ONYXKEYS'; import type {WalletAdditionalQuestionDetails} from '@src/types/onyx'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; +import * as FormActions from './FormActions'; type WalletQuestionAnswer = { question: string; From 60d0fdc000357704fc2896c06409bbc7b6e6063d Mon Sep 17 00:00:00 2001 From: Hans Date: Fri, 1 Nov 2024 14:41:05 +0700 Subject: [PATCH 015/346] fix lint --- .../ValidateCodeForm/BaseValidateCodeForm.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx index 2c318996c235..3e4182399318 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -270,6 +270,7 @@ function BaseValidateCodeForm({ style={[styles.mt4]} success large + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing isLoading={account?.isLoading || isLoading} /> From 3204148992ce2e3927608e2c6359439a17bd313f Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab <59809993+abzokhattab@users.noreply.github.com> Date: Sat, 2 Nov 2024 12:39:52 +0100 Subject: [PATCH 016/346] Disable the pay button if the report has violaitons --- src/libs/actions/IOU.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 047d081fbacb..f0461d03dd26 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -7042,8 +7042,9 @@ function canIOUBePaid( const {reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(iouReport); const isAutoReimbursable = policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES ? false : ReportUtils.canBeAutoReimbursed(iouReport, policy); const shouldBeApproved = canApproveIOU(iouReport, policy); - + const hasViolations = ReportUtils.hasViolations(iouReport?.reportID ?? '-1', allTransactionViolations); const isPayAtEndExpenseReport = ReportUtils.isPayAtEndExpenseReport(iouReport?.reportID, transactions); + return ( isPayer && !isOpenExpenseReport && @@ -7053,8 +7054,10 @@ function canIOUBePaid( !isChatReportArchived && !isAutoReimbursable && !shouldBeApproved && + !hasViolations && !isPayAtEndExpenseReport ); + } function getIOUReportActionToApproveOrPay(chatReport: OnyxEntry, excludedIOUReportID: string): OnyxEntry { From f760f272cdfd83f421fe929553a84c699d23a40a Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab <59809993+abzokhattab@users.noreply.github.com> Date: Sat, 2 Nov 2024 12:40:51 +0100 Subject: [PATCH 017/346] minor edit --- src/libs/actions/IOU.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index f0461d03dd26..575e14e36fd3 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -7057,7 +7057,6 @@ function canIOUBePaid( !hasViolations && !isPayAtEndExpenseReport ); - } function getIOUReportActionToApproveOrPay(chatReport: OnyxEntry, excludedIOUReportID: string): OnyxEntry { From 38a56502c4168ba86d0ae449b8aa1fe03b7d69fa Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab <59809993+abzokhattab@users.noreply.github.com> Date: Sat, 2 Nov 2024 12:45:12 +0100 Subject: [PATCH 018/346] minor edit --- src/libs/actions/IOU.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 575e14e36fd3..3a0ca373d3de 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -7043,6 +7043,7 @@ function canIOUBePaid( const isAutoReimbursable = policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES ? false : ReportUtils.canBeAutoReimbursed(iouReport, policy); const shouldBeApproved = canApproveIOU(iouReport, policy); const hasViolations = ReportUtils.hasViolations(iouReport?.reportID ?? '-1', allTransactionViolations); + const hasViolations = ReportUtils.hasViolations(iouReport?.reportID ?? '-1', allTransactionViolations); const isPayAtEndExpenseReport = ReportUtils.isPayAtEndExpenseReport(iouReport?.reportID, transactions); return ( From 7af0e6a9cebac8ae5c2bc1c53ffd5b4833dc9193 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab <59809993+abzokhattab@users.noreply.github.com> Date: Sat, 2 Nov 2024 12:46:17 +0100 Subject: [PATCH 019/346] minor edit --- src/libs/actions/IOU.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 3a0ca373d3de..575e14e36fd3 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -7043,7 +7043,6 @@ function canIOUBePaid( const isAutoReimbursable = policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES ? false : ReportUtils.canBeAutoReimbursed(iouReport, policy); const shouldBeApproved = canApproveIOU(iouReport, policy); const hasViolations = ReportUtils.hasViolations(iouReport?.reportID ?? '-1', allTransactionViolations); - const hasViolations = ReportUtils.hasViolations(iouReport?.reportID ?? '-1', allTransactionViolations); const isPayAtEndExpenseReport = ReportUtils.isPayAtEndExpenseReport(iouReport?.reportID, transactions); return ( From 6c716bb01bf104c444a4597c4cb140d9b99097bd Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab <59809993+abzokhattab@users.noreply.github.com> Date: Sat, 2 Nov 2024 12:49:51 +0100 Subject: [PATCH 020/346] minor edit --- src/libs/actions/IOU.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 575e14e36fd3..f911f17d0103 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -7044,7 +7044,7 @@ function canIOUBePaid( const shouldBeApproved = canApproveIOU(iouReport, policy); const hasViolations = ReportUtils.hasViolations(iouReport?.reportID ?? '-1', allTransactionViolations); const isPayAtEndExpenseReport = ReportUtils.isPayAtEndExpenseReport(iouReport?.reportID, transactions); - + return ( isPayer && !isOpenExpenseReport && From cf938e08e0cfa47456147b78e957bd16d755336d Mon Sep 17 00:00:00 2001 From: Hans Date: Wed, 6 Nov 2024 15:21:25 +0700 Subject: [PATCH 021/346] update successful condition --- src/libs/actions/Wallet.ts | 4 ---- .../settings/Wallet/Card/BaseGetPhysicalCard.tsx | 12 +++++++----- src/types/onyx/Card.ts | 3 --- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/libs/actions/Wallet.ts b/src/libs/actions/Wallet.ts index da88957b1cbd..d5b3862b62b5 100644 --- a/src/libs/actions/Wallet.ts +++ b/src/libs/actions/Wallet.ts @@ -282,9 +282,7 @@ function requestPhysicalExpensifyCard(cardID: number, authToken: string, private key: ONYXKEYS.CARD_LIST, value: { [cardID]: { - isLoading: true, errors: null, - isSuccessful: null, }, }, }, @@ -310,9 +308,7 @@ function requestPhysicalExpensifyCard(cardID: number, authToken: string, private value: { [cardID]: { state: 4, // NOT_ACTIVATED - isLoading: false, errors: null, - isSuccessful: true, }, }, }, diff --git a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx index 26b70a6e529f..2b92058caedc 100644 --- a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx +++ b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx @@ -102,7 +102,7 @@ function BaseGetPhysicalCard({ const domainCards = CardUtils.getDomainCards(cardList)[domain] || []; const cardToBeIssued = domainCards.find((card) => !card?.nameValuePairs?.isVirtual && card?.state === CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED); const cardID = cardToBeIssued?.cardID.toString() ?? '-1'; - const isSuccessful = cardToBeIssued?.isSuccessful; + const [currentCardId, setCurrentCardId] = useState(cardID); const errorMessage = ErrorUtils.getLatestErrorMessageField(cardToBeIssued); useEffect(() => { @@ -137,15 +137,16 @@ function BaseGetPhysicalCard({ }, [cardList, currentRoute, domain, domainCards.length, draftValues, loginList, cardToBeIssued, privatePersonalDetails]); useEffect(() => { - if (!isSuccessful) { + if (!isConfirmation || !!cardToBeIssued || !currentCardId) { return; } // Form draft data needs to be erased when the flow is complete, // so that no stale data is left on Onyx FormActions.clearDraftValues(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM); - Wallet.clearPhysicalCardError(cardID); - Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(cardID.toString())); - }, [isSuccessful, cardID]); + Wallet.clearPhysicalCardError(currentCardId); + Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(currentCardId.toString())); + setCurrentCardId(undefined); + }, [currentCardId, isConfirmation, cardToBeIssued]); const onSubmit = useCallback(() => { const updatedPrivatePersonalDetails = GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(draftValues, privatePersonalDetails); @@ -158,6 +159,7 @@ function BaseGetPhysicalCard({ const handleIssuePhysicalCard = useCallback( (validateCode: string) => { + setCurrentCardId(cardToBeIssued?.cardID.toString()); const updatedPrivatePersonalDetails = GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(draftValues, privatePersonalDetails); // If the current step of the get physical card flow is the confirmation page Wallet.requestPhysicalExpensifyCard(cardToBeIssued?.cardID ?? -1, session?.authToken ?? '', updatedPrivatePersonalDetails, validateCode); diff --git a/src/types/onyx/Card.ts b/src/types/onyx/Card.ts index a7debf5df925..c1d0e09d9312 100644 --- a/src/types/onyx/Card.ts +++ b/src/types/onyx/Card.ts @@ -115,9 +115,6 @@ type Card = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Collection of form field errors */ errorFields?: OnyxCommon.ErrorFields; }>; - - /** Card status */ - isSuccessful?: boolean; }>; /** Model of Expensify card details */ From 51db8501e5c6811854e659f19e62ad24a593ffe7 Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Thu, 7 Nov 2024 15:10:02 -0800 Subject: [PATCH 022/346] Fix re-authentication test that should be failing --- tests/unit/NetworkTest.ts | 35 ++++++++++++++--------------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/tests/unit/NetworkTest.ts b/tests/unit/NetworkTest.ts index e482cc3261d4..ea638aab4cf7 100644 --- a/tests/unit/NetworkTest.ts +++ b/tests/unit/NetworkTest.ts @@ -67,34 +67,24 @@ describe('NetworkTests', () => { }, }); - // Given a test user login and account ID + // And given they are signed in return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN).then(() => { expect(isOffline).toBe(false); - // Mock fetch() so that it throws a TypeError to simulate a bad network connection - global.fetch = jest.fn().mockRejectedValue(new TypeError(CONST.ERROR.FAILED_TO_FETCH)); - - const actualXhr = HttpUtils.xhr; - + // Set up mocks for the requests const mockedXhr = jest.fn(); mockedXhr .mockImplementationOnce(() => + // Given the first request is made with an expired authToken Promise.resolve({ jsonCode: CONST.JSON_CODE.NOT_AUTHENTICATED, }), ) - // Fail the call to re-authenticate - .mockImplementationOnce(actualXhr) - - // The next call should still be using the old authToken - .mockImplementationOnce(() => - Promise.resolve({ - jsonCode: CONST.JSON_CODE.NOT_AUTHENTICATED, - }), - ) + // And the call to re-authenticate fails to fetch + .mockImplementationOnce(() => Promise.reject(new Error('Failed to fetch'))) - // Succeed the call to set a new authToken + // And there's another request to Authenticate and it succeeds .mockImplementationOnce(() => Promise.resolve({ jsonCode: CONST.JSON_CODE.SUCCESS, @@ -102,7 +92,7 @@ describe('NetworkTests', () => { }), ) - // All remaining requests should succeed + // And all remaining requests should succeed .mockImplementation(() => Promise.resolve({ jsonCode: CONST.JSON_CODE.SUCCESS, @@ -111,8 +101,10 @@ describe('NetworkTests', () => { HttpUtils.xhr = mockedXhr; - // This should first trigger re-authentication and then a Failed to fetch + // When the user opens their public profile page PersonalDetails.openPublicProfilePage(TEST_USER_ACCOUNT_ID); + + // And the network is back online to process the requests return waitForBatchedUpdates() .then(() => Onyx.set(ONYXKEYS.NETWORK, {isOffline: false})) .then(() => { @@ -123,12 +115,13 @@ describe('NetworkTests', () => { return waitForBatchedUpdates(); }) .then(() => { - // Then we will eventually have 1 call to OpenPublicProfilePage and 1 calls to Authenticate - const callsToOpenPublicProfilePage = (HttpUtils.xhr as Mock).mock.calls.filter(([command]) => command === 'OpenPublicProfilePage'); + // Then there will have been 2 calls to Authenticate, one for the failed re-authentication and one retry that succeeds const callsToAuthenticate = (HttpUtils.xhr as Mock).mock.calls.filter(([command]) => command === 'Authenticate'); + expect(callsToAuthenticate.length).toBe(2); + // And two calls to openPublicProfilePage, one with the expired token and one after re-authentication + const callsToOpenPublicProfilePage = (HttpUtils.xhr as Mock).mock.calls.filter(([command]) => command === 'OpenPublicProfilePage'); expect(callsToOpenPublicProfilePage.length).toBe(1); - expect(callsToAuthenticate.length).toBe(1); }); }); }); From 1fd0aae2e5f15d0584ec4a5d23e28c01e9a2997a Mon Sep 17 00:00:00 2001 From: mkzie2 Date: Fri, 8 Nov 2024 13:59:46 +0700 Subject: [PATCH 023/346] refactor requestMoney function --- ios/NewExpensify.xcodeproj/project.pbxproj | 6 +- src/libs/actions/IOU.ts | 78 +++-- .../iou/request/step/IOURequestStepAmount.tsx | 24 +- .../step/IOURequestStepConfirmation.tsx | 53 ++-- .../step/IOURequestStepScan/index.native.tsx | 64 ++-- .../request/step/IOURequestStepScan/index.tsx | 64 ++-- tests/actions/IOUTest.ts | 278 ++++++++++++++---- 7 files changed, 373 insertions(+), 194 deletions(-) diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj index d8eceab72b95..cd38fcaaaf6c 100644 --- a/ios/NewExpensify.xcodeproj/project.pbxproj +++ b/ios/NewExpensify.xcodeproj/project.pbxproj @@ -45,7 +45,7 @@ D27CE6B77196EF3EF450EEAC /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 0D3F9E814828D91464DF9D35 /* PrivacyInfo.xcprivacy */; }; DD79042B2792E76D004484B4 /* RCTBootSplash.mm in Sources */ = {isa = PBXBuildFile; fileRef = DD79042A2792E76D004484B4 /* RCTBootSplash.mm */; }; DDCB2E57F334C143AC462B43 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D20D83B0E39BA6D21761E72 /* ExpoModulesProvider.swift */; }; - E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */ = {isa = PBXBuildFile; }; + E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */ = {isa = PBXBuildFile; }; E9DF872D2525201700607FDC /* AirshipConfig.plist in Resources */ = {isa = PBXBuildFile; fileRef = E9DF872C2525201700607FDC /* AirshipConfig.plist */; }; ED222ED90E074A5481A854FA /* ExpensifyNeue-BoldItalic.otf in Resources */ = {isa = PBXBuildFile; fileRef = 8B28D84EF339436DBD42A203 /* ExpensifyNeue-BoldItalic.otf */; }; F0C450EA2705020500FD2970 /* colors.json in Resources */ = {isa = PBXBuildFile; fileRef = F0C450E92705020500FD2970 /* colors.json */; }; @@ -178,8 +178,8 @@ buildActionMask = 2147483647; files = ( 383643682B6D4AE2005BB9AE /* DeviceCheck.framework in Frameworks */, - E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */, - E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */, + E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */, + E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */, 8744C5400E24E379441C04A4 /* libPods-NewExpensify.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 1db2555f3393..87f35902a36a 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -164,6 +164,37 @@ type GPSPoint = { long: number; }; +type RequestMoneyInformation = { + report: OnyxEntry; + payeeEmail: string | undefined; + payeeAccountID: number; + participant: Participant; + policy?: OnyxEntry; + policyTagList?: OnyxEntry; + policyCategories?: OnyxEntry; + gpsPoints?: GPSPoint; + action?: IOUAction; + reimbursible?: boolean; +}; + +type RequestMoneyTransactionData = { + attendees: Attendee[] | undefined; + amount: number; + currency: string; + comment?: string; + receipt?: Receipt; + category?: string; + tag?: string; + taxCode?: string; + taxAmount?: number; + billable?: boolean; + merchant: string; + created: string; + actionableWhisperReportActionID?: string; + linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction; + linkedTrackedExpenseReportID?: string; +}; + let allPersonalDetails: OnyxTypes.PersonalDetailsList = {}; Onyx.connect({ key: ONYXKEYS.PERSONAL_DETAILS_LIST, @@ -3558,33 +3589,26 @@ function shareTrackedExpense( /** * Submit expense to another user */ -function requestMoney( - report: OnyxEntry, - amount: number, - attendees: Attendee[] | undefined, - currency: string, - created: string, - merchant: string, - payeeEmail: string | undefined, - payeeAccountID: number, - participant: Participant, - comment: string, - receipt: Receipt | undefined, - category?: string, - tag?: string, - taxCode = '', - taxAmount = 0, - billable?: boolean, - policy?: OnyxEntry, - policyTagList?: OnyxEntry, - policyCategories?: OnyxEntry, - gpsPoints?: GPSPoint, - action?: IOUAction, - actionableWhisperReportActionID?: string, - linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction, - linkedTrackedExpenseReportID?: string, - reimbursible?: boolean, -) { +function requestMoney(requestMoneyInformation: RequestMoneyInformation, requestMoneyTransactionData: RequestMoneyTransactionData) { + const {report, payeeEmail, payeeAccountID, participant, policy, policyTagList, policyCategories, gpsPoints, action, reimbursible} = requestMoneyInformation; + const { + amount, + currency, + merchant, + comment = '', + receipt, + category, + tag, + taxCode = '', + taxAmount = 0, + billable, + created, + attendees, + actionableWhisperReportActionID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID, + } = requestMoneyTransactionData; + // If the report is iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); const currentChatReport = isMoneyRequestReport ? ReportUtils.getReportOrDraftReport(report?.chatReportID) : report; diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 9c6f39ea8c5a..8aced83515f4 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -210,17 +210,19 @@ function IOURequestStepAmount({ if (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.REQUEST) { playSound(SOUNDS.DONE); IOU.requestMoney( - report, - backendAmount, - transaction?.attendees, - currency, - transaction?.created ?? '', - CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, - currentUserPersonalDetails.login, - currentUserPersonalDetails.accountID, - participants.at(0) ?? {}, - '', - {}, + { + report, + participant: participants.at(0) ?? {}, + payeeEmail: currentUserPersonalDetails.login, + payeeAccountID: currentUserPersonalDetails.accountID, + }, + { + amount: backendAmount, + currency, + created: transaction?.created ?? '', + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + attendees: transaction?.attendees, + }, ); return; } diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index aa3a432a0e5a..f268e7a03477 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -237,32 +237,35 @@ function IOURequestStepConfirmation({ if (!participant) { return; } - IOU.requestMoney( - report, - transaction.amount, - transaction.attendees, - transaction.currency, - transaction.created, - transaction.merchant, - currentUserPersonalDetails.login, - currentUserPersonalDetails.accountID, - participant, - trimmedComment, - receiptObj, - transaction.category, - transaction.tag, - transactionTaxCode, - transactionTaxAmount, - transaction.billable, - policy, - policyTags, - policyCategories, - gpsPoints, - action, - transaction.actionableWhisperReportActionID, - transaction.linkedTrackedExpenseReportAction, - transaction.linkedTrackedExpenseReportID, + { + report, + payeeEmail: currentUserPersonalDetails.login, + payeeAccountID: currentUserPersonalDetails.accountID, + participant, + policy, + policyTagList: policyTags, + policyCategories, + gpsPoints, + action, + }, + { + amount: transaction.amount, + attendees: transaction.attendees, + currency: transaction.currency, + created: transaction.created, + merchant: transaction.merchant, + comment: trimmedComment, + receipt: receiptObj, + category: transaction.category, + tag: transaction.tag, + taxCode: transactionTaxCode, + taxAmount: transactionTaxAmount, + billable: transaction.billable, + actionableWhisperReportActionID: transaction.actionableWhisperReportActionID, + linkedTrackedExpenseReportAction: transaction.linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID: transaction.linkedTrackedExpenseReportID, + }, ); }, [report, transaction, transactionTaxCode, transactionTaxAmount, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, policy, policyTags, policyCategories, action], diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index f7e575b898fd..ff649dc303e1 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -255,17 +255,20 @@ function IOURequestStepScan({ ); } else { IOU.requestMoney( - report, - 0, - transaction?.attendees, - transaction?.currency ?? 'USD', - transaction?.created ?? '', - '', - currentUserPersonalDetails.login, - currentUserPersonalDetails.accountID, - participant, - '', - receipt, + { + report, + payeeEmail: currentUserPersonalDetails.login, + payeeAccountID: currentUserPersonalDetails.accountID, + participant, + }, + { + amount: 0, + attendees: transaction?.attendees, + currency: transaction?.currency ?? 'USD', + created: transaction?.created ?? '', + merchant: '', + receipt, + }, ); } }, @@ -351,28 +354,25 @@ function IOURequestStepScan({ ); } else { IOU.requestMoney( - report, - 0, - transaction?.attendees, - transaction?.currency ?? 'USD', - transaction?.created ?? '', - '', - currentUserPersonalDetails.login, - currentUserPersonalDetails.accountID, - participant, - '', - receipt, - '', - '', - '', - 0, - false, - policy, - {}, - {}, { - lat: successData.coords.latitude, - long: successData.coords.longitude, + report, + payeeEmail: currentUserPersonalDetails.login, + payeeAccountID: currentUserPersonalDetails.accountID, + participant, + policy, + gpsPoints: { + lat: successData.coords.latitude, + long: successData.coords.longitude, + }, + }, + { + amount: 0, + attendees: transaction?.attendees, + currency: transaction?.currency ?? 'USD', + created: transaction?.created ?? '', + merchant: '', + receipt, + billable: false, }, ); } diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index ecf84c877496..b2984c19af05 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -284,17 +284,20 @@ function IOURequestStepScan({ ); } else { IOU.requestMoney( - report, - 0, - transaction?.attendees, - transaction?.currency ?? 'USD', - transaction?.created ?? '', - '', - currentUserPersonalDetails.login, - currentUserPersonalDetails.accountID, - participant, - '', - receipt, + { + report, + payeeEmail: currentUserPersonalDetails.login, + payeeAccountID: currentUserPersonalDetails.accountID, + participant, + }, + { + amount: 0, + attendees: transaction?.attendees, + currency: transaction?.currency ?? 'USD', + created: transaction?.created ?? '', + merchant: '', + receipt, + }, ); } }, @@ -381,28 +384,25 @@ function IOURequestStepScan({ ); } else { IOU.requestMoney( - report, - 0, - transaction?.attendees, - transaction?.currency ?? 'USD', - transaction?.created ?? '', - '', - currentUserPersonalDetails.login, - currentUserPersonalDetails.accountID, - participant, - '', - receipt, - '', - '', - '', - 0, - false, - policy, - {}, - {}, { - lat: successData.coords.latitude, - long: successData.coords.longitude, + report, + payeeEmail: currentUserPersonalDetails.login, + payeeAccountID: currentUserPersonalDetails.accountID, + participant, + policy, + gpsPoints: { + lat: successData.coords.latitude, + long: successData.coords.longitude, + }, + }, + { + amount: 0, + attendees: transaction?.attendees, + currency: transaction?.currency ?? 'USD', + created: transaction?.created ?? '', + merchant: '', + receipt, + billable: false, }, ); } diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index c2005d221273..7c0748d5404e 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -80,7 +80,22 @@ describe('actions/IOU', () => { let transactionThread: OnyxEntry; let transactionThreadCreatedAction: OnyxEntry; mockFetch?.pause?.(); - IOU.requestMoney({reportID: ''}, amount, [], CONST.CURRENCY.USD, '', merchant, RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment, {}); + IOU.requestMoney( + { + report: {reportID: ''}, + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, + ); return waitForBatchedUpdates() .then( () => @@ -279,7 +294,22 @@ describe('actions/IOU', () => { }), ) .then(() => { - IOU.requestMoney(chatReport, amount, [], CONST.CURRENCY.USD, '', '', RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment, {}); + IOU.requestMoney( + { + report: chatReport, + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment, + }, + ); return waitForBatchedUpdates(); }) .then( @@ -483,7 +513,22 @@ describe('actions/IOU', () => { .then(() => Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${existingTransaction.transactionID}`, existingTransaction)) .then(() => { if (chatReport) { - IOU.requestMoney(chatReport, amount, [], CONST.CURRENCY.USD, '', '', RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment, {}); + IOU.requestMoney( + { + report: chatReport, + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment, + }, + ); } return waitForBatchedUpdates(); }) @@ -623,7 +668,22 @@ describe('actions/IOU', () => { let transactionThreadReport: OnyxEntry; let transactionThreadAction: OnyxEntry; mockFetch?.pause?.(); - IOU.requestMoney({reportID: ''}, amount, [], CONST.CURRENCY.USD, '', '', RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment, {}); + IOU.requestMoney( + { + report: {reportID: ''}, + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment, + }, + ); return ( waitForBatchedUpdates() .then( @@ -1424,7 +1484,22 @@ describe('actions/IOU', () => { let createIOUAction: OnyxEntry>; let payIOUAction: OnyxEntry; let transaction: OnyxEntry; - IOU.requestMoney({reportID: ''}, amount, [], CONST.CURRENCY.USD, '', '', RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment, {}); + IOU.requestMoney( + { + report: {reportID: ''}, + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment, + }, + ); return waitForBatchedUpdates() .then( () => @@ -1624,7 +1699,22 @@ describe('actions/IOU', () => { let transaction: OnyxEntry; mockFetch?.pause?.(); - IOU.requestMoney({reportID: ''}, amount, [], CONST.CURRENCY.USD, '', merchant, RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment, {}); + IOU.requestMoney( + { + report: {reportID: ''}, + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, + ); return waitForBatchedUpdates() .then(() => { Onyx.set(ONYXKEYS.SESSION, {email: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}); @@ -1778,7 +1868,22 @@ describe('actions/IOU', () => { let iouAction: OnyxEntry>; let transaction: OnyxEntry; - IOU.requestMoney({reportID: ''}, amount, [], CONST.CURRENCY.USD, '', merchant, RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment, {}); + IOU.requestMoney( + { + report: {reportID: ''}, + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, + ); return waitForBatchedUpdates() .then(() => { Onyx.set(ONYXKEYS.SESSION, {email: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}); @@ -1961,17 +2066,20 @@ describe('actions/IOU', () => { .then(() => { if (chatReport) { IOU.requestMoney( - chatReport, - amount, - [], - CONST.CURRENCY.USD, - '', - merchant, - RORY_EMAIL, - RORY_ACCOUNT_ID, - {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - comment, - {}, + { + report: {reportID: ''}, + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, ); } return waitForBatchedUpdates(); @@ -2086,17 +2194,20 @@ describe('actions/IOU', () => { .then(() => { if (chatReport) { IOU.requestMoney( - chatReport, - amount, - [], - CONST.CURRENCY.USD, - '', - merchant, - RORY_EMAIL, - RORY_ACCOUNT_ID, - {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - comment, - {}, + { + report: {reportID: ''}, + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, ); } return waitForBatchedUpdates(); @@ -2183,7 +2294,22 @@ describe('actions/IOU', () => { await TestHelper.setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID); // When a submit IOU expense is made - IOU.requestMoney({reportID: ''}, amount, [], CONST.CURRENCY.USD, '', '', TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID, {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}, comment, {}); + IOU.requestMoney( + { + report: {reportID: ''}, + payeeEmail: TEST_USER_LOGIN, + payeeAccountID: TEST_USER_ACCOUNT_ID, + participant: {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}, + }, + { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment, + }, + ); await waitForBatchedUpdates(); // When fetching all reports from Onyx @@ -2914,7 +3040,22 @@ describe('actions/IOU', () => { const amount2 = 20000; const comment2 = 'Send me money please 2'; if (chatReport) { - IOU.requestMoney(chatReport, amount2, [], CONST.CURRENCY.USD, '', '', TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID, {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}, comment2, {}); + IOU.requestMoney( + { + report: chatReport, + payeeEmail: TEST_USER_LOGIN, + payeeAccountID: TEST_USER_ACCOUNT_ID, + participant: {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}, + }, + { + amount: amount2, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment: comment2, + }, + ); } await waitForBatchedUpdates(); @@ -3118,17 +3259,20 @@ describe('actions/IOU', () => { .then(() => { if (chatReport) { IOU.requestMoney( - chatReport, - amount, - [], - CONST.CURRENCY.USD, - '', - merchant, - RORY_EMAIL, - RORY_ACCOUNT_ID, - {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, - comment, - {}, + { + report: chatReport, + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, + }, + { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, ); } return waitForBatchedUpdates(); @@ -3222,17 +3366,20 @@ describe('actions/IOU', () => { .then(() => { if (chatReport) { IOU.requestMoney( - chatReport, - amount, - [], - CONST.CURRENCY.USD, - '', - merchant, - RORY_EMAIL, - RORY_ACCOUNT_ID, - {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, - comment, - {}, + { + report: chatReport, + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, + }, + { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, ); } return waitForBatchedUpdates(); @@ -3327,17 +3474,20 @@ describe('actions/IOU', () => { .then(() => { if (chatReport) { IOU.requestMoney( - chatReport, - amount, - [], - CONST.CURRENCY.USD, - '', - merchant, - RORY_EMAIL, - RORY_ACCOUNT_ID, - {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, - comment, - {}, + { + report: chatReport, + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, + }, + { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, ); } return waitForBatchedUpdates(); From 91caa85e86a9baaecdd933e96533f0356e448757 Mon Sep 17 00:00:00 2001 From: mkzie2 Date: Fri, 8 Nov 2024 14:08:26 +0700 Subject: [PATCH 024/346] revert change --- ios/NewExpensify.xcodeproj/project.pbxproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj index cd38fcaaaf6c..d8eceab72b95 100644 --- a/ios/NewExpensify.xcodeproj/project.pbxproj +++ b/ios/NewExpensify.xcodeproj/project.pbxproj @@ -45,7 +45,7 @@ D27CE6B77196EF3EF450EEAC /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 0D3F9E814828D91464DF9D35 /* PrivacyInfo.xcprivacy */; }; DD79042B2792E76D004484B4 /* RCTBootSplash.mm in Sources */ = {isa = PBXBuildFile; fileRef = DD79042A2792E76D004484B4 /* RCTBootSplash.mm */; }; DDCB2E57F334C143AC462B43 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D20D83B0E39BA6D21761E72 /* ExpoModulesProvider.swift */; }; - E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */ = {isa = PBXBuildFile; }; + E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */ = {isa = PBXBuildFile; }; E9DF872D2525201700607FDC /* AirshipConfig.plist in Resources */ = {isa = PBXBuildFile; fileRef = E9DF872C2525201700607FDC /* AirshipConfig.plist */; }; ED222ED90E074A5481A854FA /* ExpensifyNeue-BoldItalic.otf in Resources */ = {isa = PBXBuildFile; fileRef = 8B28D84EF339436DBD42A203 /* ExpensifyNeue-BoldItalic.otf */; }; F0C450EA2705020500FD2970 /* colors.json in Resources */ = {isa = PBXBuildFile; fileRef = F0C450E92705020500FD2970 /* colors.json */; }; @@ -178,8 +178,8 @@ buildActionMask = 2147483647; files = ( 383643682B6D4AE2005BB9AE /* DeviceCheck.framework in Frameworks */, - E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */, - E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */, + E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */, + E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */, 8744C5400E24E379441C04A4 /* libPods-NewExpensify.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; From 6fc82cf9fa7ecd0dd79d146d6437b2a173909588 Mon Sep 17 00:00:00 2001 From: mkzie2 Date: Fri, 8 Nov 2024 14:20:33 +0700 Subject: [PATCH 025/346] fix test --- tests/actions/IOUTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 7c0748d5404e..dc32f4e99b58 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -2067,7 +2067,7 @@ describe('actions/IOU', () => { if (chatReport) { IOU.requestMoney( { - report: {reportID: ''}, + report: chatReport, payeeEmail: RORY_EMAIL, payeeAccountID: RORY_ACCOUNT_ID, participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, @@ -2195,7 +2195,7 @@ describe('actions/IOU', () => { if (chatReport) { IOU.requestMoney( { - report: {reportID: ''}, + report: chatReport, payeeEmail: RORY_EMAIL, payeeAccountID: RORY_ACCOUNT_ID, participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, From 1aebb87e1d7b37c32b502ffae8936de4543e16ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 8 Nov 2024 12:22:41 +0100 Subject: [PATCH 026/346] clean: moved everything over to CategoryOptionListUtils --- src/components/CategoryPicker.tsx | 11 +- src/libs/CategoryOptionListUtils.ts | 281 +++++ src/libs/OptionsListUtils.ts | 328 +----- tests/unit/CategoryOptionListUtils.ts | 1242 ++++++++++++++++++++ tests/unit/OptionsListUtilsTest.ts | 1522 +++---------------------- 5 files changed, 1676 insertions(+), 1708 deletions(-) create mode 100644 src/libs/CategoryOptionListUtils.ts create mode 100644 tests/unit/CategoryOptionListUtils.ts diff --git a/src/components/CategoryPicker.tsx b/src/components/CategoryPicker.tsx index 33d97c6909f5..97aaf59ac2cf 100644 --- a/src/components/CategoryPicker.tsx +++ b/src/components/CategoryPicker.tsx @@ -3,6 +3,8 @@ import {useOnyx} from 'react-native-onyx'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import * as CategoryOptionsListUtils from '@libs/CategoryOptionListUtils'; +import type {Category} from '@libs/CategoryOptionListUtils'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -27,7 +29,7 @@ function CategoryPicker({selectedCategory, policyID, onSubmit}: CategoryPickerPr const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const offlineMessage = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; - const selectedOptions = useMemo(() => { + const selectedOptions = useMemo((): Category[] => { if (!selectedCategory) { return []; } @@ -35,8 +37,9 @@ function CategoryPicker({selectedCategory, policyID, onSubmit}: CategoryPickerPr return [ { name: selectedCategory, - accountID: undefined, isSelected: true, + // TODO: i added this enabled property, is true the correct default? before it was just "as" casted... + enabled: true, }, ]; }, [selectedCategory]); @@ -44,11 +47,9 @@ function CategoryPicker({selectedCategory, policyID, onSubmit}: CategoryPickerPr const [sections, headerMessage, shouldShowTextInput] = useMemo(() => { const categories = policyCategories ?? policyCategoriesDraft ?? {}; const validPolicyRecentlyUsedCategories = policyRecentlyUsedCategories?.filter?.((p) => !isEmptyObject(p)); - const {categoryOptions} = OptionsListUtils.getFilteredOptions({ + const categoryOptions = CategoryOptionsListUtils.getCategoryListSections({ searchValue: debouncedSearchValue, selectedOptions, - includeP2P: false, - includeCategories: true, categories, recentlyUsedCategories: validPolicyRecentlyUsedCategories, }); diff --git a/src/libs/CategoryOptionListUtils.ts b/src/libs/CategoryOptionListUtils.ts new file mode 100644 index 000000000000..5ea71bca4136 --- /dev/null +++ b/src/libs/CategoryOptionListUtils.ts @@ -0,0 +1,281 @@ +import lodashGet from 'lodash/get'; +import lodashSet from 'lodash/set'; +import CONST from '@src/CONST'; +import type {PolicyCategories} from '@src/types/onyx'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import times from '@src/utils/times'; +import * as Localize from './Localize'; +import type {CategorySectionBase, OptionTree} from './OptionsListUtils'; + +type CategoryTreeSection = CategorySectionBase & { + data: OptionTree[]; + indexOffset?: number; +}; + +type Category = { + name: string; + enabled: boolean; + isSelected?: boolean; + pendingAction?: OnyxCommon.PendingAction; +}; + +type Hierarchy = Record; + +/** + * Builds the options for the category tree hierarchy via indents + * + * @param options - an initial object array + * @param options[].enabled - a flag to enable/disable option in a list + * @param options[].name - a name of an option + * @param [isOneLine] - a flag to determine if text should be one line + */ +function getCategoryOptionTree(options: Record | Category[], isOneLine = false, selectedOptions: Category[] = []): OptionTree[] { + const optionCollection = new Map(); + Object.values(options).forEach((option) => { + if (isOneLine) { + if (optionCollection.has(option.name)) { + return; + } + + optionCollection.set(option.name, { + text: option.name, + keyForList: option.name, + searchText: option.name, + tooltipText: option.name, + isDisabled: !option.enabled || option.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + isSelected: !!option.isSelected, + pendingAction: option.pendingAction, + }); + + return; + } + + option.name.split(CONST.PARENT_CHILD_SEPARATOR).forEach((optionName, index, array) => { + const indents = times(index, () => CONST.INDENTS).join(''); + const isChild = array.length - 1 === index; + const searchText = array.slice(0, index + 1).join(CONST.PARENT_CHILD_SEPARATOR); + const selectedParentOption = !isChild && Object.values(selectedOptions).find((op) => op.name === searchText); + const isParentOptionDisabled = !selectedParentOption || !selectedParentOption.enabled || selectedParentOption.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + + if (optionCollection.has(searchText)) { + return; + } + + optionCollection.set(searchText, { + text: `${indents}${optionName}`, + keyForList: searchText, + searchText, + tooltipText: optionName, + isDisabled: isChild ? !option.enabled || option.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE : isParentOptionDisabled, + isSelected: isChild ? !!option.isSelected : !!selectedParentOption, + pendingAction: option.pendingAction, + }); + }); + }); + + return Array.from(optionCollection.values()); +} + +/** + * Builds the section list for categories + */ +function getCategoryListSections({ + categories, + searchValue, + selectedOptions = [], + recentlyUsedCategories = [], + maxRecentReportsToShow = CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, +}: { + categories: PolicyCategories; + selectedOptions?: Category[]; + searchValue?: string; + recentlyUsedCategories?: string[]; + maxRecentReportsToShow?: number; +}): CategoryTreeSection[] { + const sortedCategories = sortCategories(categories); + const enabledCategories = Object.values(sortedCategories).filter((category) => category.enabled); + const enabledCategoriesNames = enabledCategories.map((category) => category.name); + const selectedOptionsWithDisabledState: Category[] = []; + const categorySections: CategoryTreeSection[] = []; + const numberOfEnabledCategories = enabledCategories.length; + + selectedOptions.forEach((option) => { + if (enabledCategoriesNames.includes(option.name)) { + const categoryObj = enabledCategories.find((category) => category.name === option.name); + selectedOptionsWithDisabledState.push({...(categoryObj ?? option), isSelected: true, enabled: true}); + return; + } + selectedOptionsWithDisabledState.push({...option, isSelected: true, enabled: false}); + }); + + if (numberOfEnabledCategories === 0 && selectedOptions.length > 0) { + const data = getCategoryOptionTree(selectedOptionsWithDisabledState, true); + categorySections.push({ + // "Selected" section + title: '', + shouldShow: false, + data, + indexOffset: data.length, + }); + + return categorySections; + } + + if (searchValue) { + const categoriesForSearch = [...selectedOptionsWithDisabledState, ...enabledCategories]; + const searchCategories: Category[] = []; + + categoriesForSearch.forEach((category) => { + if (!category.name.toLowerCase().includes(searchValue.toLowerCase())) { + return; + } + searchCategories.push({ + ...category, + isSelected: selectedOptions.some((selectedOption) => selectedOption.name === category.name), + }); + }); + + const data = getCategoryOptionTree(searchCategories, true); + categorySections.push({ + // "Search" section + title: '', + shouldShow: true, + data, + indexOffset: data.length, + }); + + return categorySections; + } + + if (selectedOptions.length > 0) { + const data = getCategoryOptionTree(selectedOptionsWithDisabledState, true); + categorySections.push({ + // "Selected" section + title: '', + shouldShow: false, + data, + indexOffset: data.length, + }); + } + + const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name); + const filteredCategories = enabledCategories.filter((category) => !selectedOptionNames.includes(category.name)); + + if (numberOfEnabledCategories < CONST.CATEGORY_LIST_THRESHOLD) { + const data = getCategoryOptionTree(filteredCategories, false, selectedOptionsWithDisabledState); + categorySections.push({ + // "All" section when items amount less than the threshold + title: '', + shouldShow: false, + data, + indexOffset: data.length, + }); + + return categorySections; + } + + const filteredRecentlyUsedCategories = recentlyUsedCategories + .filter( + (categoryName) => + !selectedOptionNames.includes(categoryName) && categories[categoryName]?.enabled && categories[categoryName]?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + ) + .map((categoryName) => ({ + name: categoryName, + enabled: categories[categoryName].enabled ?? false, + })); + + if (filteredRecentlyUsedCategories.length > 0) { + const cutRecentlyUsedCategories = filteredRecentlyUsedCategories.slice(0, maxRecentReportsToShow); + + const data = getCategoryOptionTree(cutRecentlyUsedCategories, true); + categorySections.push({ + // "Recent" section + title: Localize.translateLocal('common.recent'), + shouldShow: true, + data, + indexOffset: data.length, + }); + } + + const data = getCategoryOptionTree(filteredCategories, false, selectedOptionsWithDisabledState); + categorySections.push({ + // "All" section when items amount more than the threshold + title: Localize.translateLocal('common.all'), + shouldShow: true, + data, + indexOffset: data.length, + }); + + return categorySections; +} + +/** + * Sorts categories using a simple object. + * It builds an hierarchy (based on an object), where each category has a name and other keys as subcategories. + * Via the hierarchy we avoid duplicating and sort categories one by one. Subcategories are being sorted alphabetically. + */ +function sortCategories(categories: Record): Category[] { + // Sorts categories alphabetically by name. + const sortedCategories = Object.values(categories).sort((a, b) => a.name.localeCompare(b.name)); + + // An object that respects nesting of categories. Also, can contain only uniq categories. + const hierarchy: Hierarchy = {}; + /** + * Iterates over all categories to set each category in a proper place in hierarchy + * It gets a path based on a category name e.g. "Parent: Child: Subcategory" -> "Parent.Child.Subcategory". + * { + * Parent: { + * name: "Parent", + * Child: { + * name: "Child" + * Subcategory: { + * name: "Subcategory" + * } + * } + * } + * } + */ + sortedCategories.forEach((category) => { + const path = category.name.split(CONST.PARENT_CHILD_SEPARATOR); + const existedValue = lodashGet(hierarchy, path, {}) as Hierarchy; + lodashSet(hierarchy, path, { + ...existedValue, + name: category.name, + pendingAction: category.pendingAction, + }); + }); + + /** + * A recursive function to convert hierarchy into an array of category objects. + * The category object contains base 2 properties: "name" and "enabled". + * It iterates each key one by one. When a category has subcategories, goes deeper into them. Also, sorts subcategories alphabetically. + */ + const flatHierarchy = (initialHierarchy: Hierarchy) => + Object.values(initialHierarchy).reduce((acc: Category[], category) => { + const {name, pendingAction, ...subcategories} = category; + if (name) { + const categoryObject: Category = { + name, + pendingAction, + enabled: categories[name]?.enabled ?? false, + }; + + acc.push(categoryObject); + } + + if (!isEmptyObject(subcategories)) { + const nestedCategories = flatHierarchy(subcategories); + + acc.push(...nestedCategories.sort((a, b) => a.name.localeCompare(b.name))); + } + + return acc; + }, []); + + return flatHierarchy(hierarchy); +} + +export {getCategoryListSections, getCategoryOptionTree, sortCategories}; + +export type {Category, CategorySectionBase, CategoryTreeSection, Hierarchy}; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 1296a64e571d..2b921cac0631 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -1,9 +1,6 @@ /* eslint-disable no-continue */ import {Str} from 'expensify-common'; -// eslint-disable-next-line you-dont-need-lodash-underscore/get -import lodashGet from 'lodash/get'; import lodashOrderBy from 'lodash/orderBy'; -import lodashSet from 'lodash/set'; import lodashSortBy from 'lodash/sortBy'; import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -39,7 +36,6 @@ import type {Attendee, Participant} from '@src/types/onyx/IOU'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import times from '@src/utils/times'; import Timing from './actions/Timing'; import filterArrayByMatch from './filterArrayByMatch'; import localeCompare from './LocaleCompare'; @@ -93,15 +89,6 @@ type PayeePersonalDetails = { keyForList: string; }; -type CategorySectionBase = { - title: string | undefined; - shouldShow: boolean; -}; - -type CategorySection = CategorySectionBase & { - data: Option[]; -}; - type TaxRatesOption = { text?: string; code?: string; @@ -119,26 +106,12 @@ type TaxSection = { data: TaxRatesOption[]; }; -type CategoryTreeSection = CategorySectionBase & { - data: OptionTree[]; - indexOffset?: number; -}; - -type Category = { - name: string; - enabled: boolean; - isSelected?: boolean; - pendingAction?: OnyxCommon.PendingAction; -}; - type Tax = { modifiedName: string; isSelected?: boolean; isDisabled?: boolean; }; -type Hierarchy = Record; - type GetOptionsConfig = { reportActions?: ReportActions; betas?: OnyxEntry; @@ -159,9 +132,6 @@ type GetOptionsConfig = { includeMoneyRequests?: boolean; excludeUnknownUsers?: boolean; includeP2P?: boolean; - includeCategories?: boolean; - categories?: PolicyCategories; - recentlyUsedCategories?: string[]; includeTags?: boolean; tags?: PolicyTags | Array; recentlyUsedTags?: string[]; @@ -206,6 +176,15 @@ type MemberForList = { reportID: string; }; +type CategorySectionBase = { + title: string | undefined; + shouldShow: boolean; +}; + +type CategorySection = CategorySectionBase & { + data: Option[]; +}; + type SectionForSearchTerm = { section: CategorySection; }; @@ -214,7 +193,6 @@ type Options = { personalDetails: ReportUtils.OptionData[]; userToInvite: ReportUtils.OptionData | null; currentUserOption: ReportUtils.OptionData | null | undefined; - categoryOptions: CategoryTreeSection[]; tagOptions: CategorySection[]; taxRatesOptions: CategorySection[]; policyReportFieldOptions?: CategorySection[] | null; @@ -916,72 +894,6 @@ function hasEnabledOptions(options: PolicyCategories | PolicyTag[]): boolean { return Object.values(options).some((option: PolicyTag | PolicyCategory) => option.enabled && option.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); } -/** - * Sorts categories using a simple object. - * It builds an hierarchy (based on an object), where each category has a name and other keys as subcategories. - * Via the hierarchy we avoid duplicating and sort categories one by one. Subcategories are being sorted alphabetically. - */ -function sortCategories(categories: Record): Category[] { - // Sorts categories alphabetically by name. - const sortedCategories = Object.values(categories).sort((a, b) => a.name.localeCompare(b.name)); - - // An object that respects nesting of categories. Also, can contain only uniq categories. - const hierarchy: Hierarchy = {}; - /** - * Iterates over all categories to set each category in a proper place in hierarchy - * It gets a path based on a category name e.g. "Parent: Child: Subcategory" -> "Parent.Child.Subcategory". - * { - * Parent: { - * name: "Parent", - * Child: { - * name: "Child" - * Subcategory: { - * name: "Subcategory" - * } - * } - * } - * } - */ - sortedCategories.forEach((category) => { - const path = category.name.split(CONST.PARENT_CHILD_SEPARATOR); - const existedValue = lodashGet(hierarchy, path, {}) as Hierarchy; - lodashSet(hierarchy, path, { - ...existedValue, - name: category.name, - pendingAction: category.pendingAction, - }); - }); - - /** - * A recursive function to convert hierarchy into an array of category objects. - * The category object contains base 2 properties: "name" and "enabled". - * It iterates each key one by one. When a category has subcategories, goes deeper into them. Also, sorts subcategories alphabetically. - */ - const flatHierarchy = (initialHierarchy: Hierarchy) => - Object.values(initialHierarchy).reduce((acc: Category[], category) => { - const {name, pendingAction, ...subcategories} = category; - if (name) { - const categoryObject: Category = { - name, - pendingAction, - enabled: categories[name]?.enabled ?? false, - }; - - acc.push(categoryObject); - } - - if (!isEmptyObject(subcategories)) { - const nestedCategories = flatHierarchy(subcategories); - - acc.push(...nestedCategories.sort((a, b) => a.name.localeCompare(b.name))); - } - - return acc; - }, []); - - return flatHierarchy(hierarchy); -} - /** * Sorts tags alphabetically by name. */ @@ -992,188 +904,6 @@ function sortTags(tags: Record | Array | Category[], isOneLine = false, selectedOptions: Category[] = []): OptionTree[] { - const optionCollection = new Map(); - Object.values(options).forEach((option) => { - if (isOneLine) { - if (optionCollection.has(option.name)) { - return; - } - - optionCollection.set(option.name, { - text: option.name, - keyForList: option.name, - searchText: option.name, - tooltipText: option.name, - isDisabled: !option.enabled || option.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - isSelected: !!option.isSelected, - pendingAction: option.pendingAction, - }); - - return; - } - - option.name.split(CONST.PARENT_CHILD_SEPARATOR).forEach((optionName, index, array) => { - const indents = times(index, () => CONST.INDENTS).join(''); - const isChild = array.length - 1 === index; - const searchText = array.slice(0, index + 1).join(CONST.PARENT_CHILD_SEPARATOR); - const selectedParentOption = !isChild && Object.values(selectedOptions).find((op) => op.name === searchText); - const isParentOptionDisabled = !selectedParentOption || !selectedParentOption.enabled || selectedParentOption.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - - if (optionCollection.has(searchText)) { - return; - } - - optionCollection.set(searchText, { - text: `${indents}${optionName}`, - keyForList: searchText, - searchText, - tooltipText: optionName, - isDisabled: isChild ? !option.enabled || option.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE : isParentOptionDisabled, - isSelected: isChild ? !!option.isSelected : !!selectedParentOption, - pendingAction: option.pendingAction, - }); - }); - }); - - return Array.from(optionCollection.values()); -} - -/** - * Builds the section list for categories - */ -function getCategoryListSections( - categories: PolicyCategories, - recentlyUsedCategories: string[], - selectedOptions: Category[], - searchInputValue: string, - maxRecentReportsToShow: number, -): CategoryTreeSection[] { - const sortedCategories = sortCategories(categories); - const enabledCategories = Object.values(sortedCategories).filter((category) => category.enabled); - const enabledCategoriesNames = enabledCategories.map((category) => category.name); - const selectedOptionsWithDisabledState: Category[] = []; - const categorySections: CategoryTreeSection[] = []; - const numberOfEnabledCategories = enabledCategories.length; - - selectedOptions.forEach((option) => { - if (enabledCategoriesNames.includes(option.name)) { - const categoryObj = enabledCategories.find((category) => category.name === option.name); - selectedOptionsWithDisabledState.push({...(categoryObj ?? option), isSelected: true, enabled: true}); - return; - } - selectedOptionsWithDisabledState.push({...option, isSelected: true, enabled: false}); - }); - - if (numberOfEnabledCategories === 0 && selectedOptions.length > 0) { - const data = getCategoryOptionTree(selectedOptionsWithDisabledState, true); - categorySections.push({ - // "Selected" section - title: '', - shouldShow: false, - data, - indexOffset: data.length, - }); - - return categorySections; - } - - if (searchInputValue) { - const categoriesForSearch = [...selectedOptionsWithDisabledState, ...enabledCategories]; - const searchCategories: Category[] = []; - - categoriesForSearch.forEach((category) => { - if (!category.name.toLowerCase().includes(searchInputValue.toLowerCase())) { - return; - } - searchCategories.push({ - ...category, - isSelected: selectedOptions.some((selectedOption) => selectedOption.name === category.name), - }); - }); - - const data = getCategoryOptionTree(searchCategories, true); - categorySections.push({ - // "Search" section - title: '', - shouldShow: true, - data, - indexOffset: data.length, - }); - - return categorySections; - } - - if (selectedOptions.length > 0) { - const data = getCategoryOptionTree(selectedOptionsWithDisabledState, true); - categorySections.push({ - // "Selected" section - title: '', - shouldShow: false, - data, - indexOffset: data.length, - }); - } - - const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name); - const filteredCategories = enabledCategories.filter((category) => !selectedOptionNames.includes(category.name)); - - if (numberOfEnabledCategories < CONST.CATEGORY_LIST_THRESHOLD) { - const data = getCategoryOptionTree(filteredCategories, false, selectedOptionsWithDisabledState); - categorySections.push({ - // "All" section when items amount less than the threshold - title: '', - shouldShow: false, - data, - indexOffset: data.length, - }); - - return categorySections; - } - - const filteredRecentlyUsedCategories = recentlyUsedCategories - .filter( - (categoryName) => - !selectedOptionNames.includes(categoryName) && categories[categoryName]?.enabled && categories[categoryName]?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - ) - .map((categoryName) => ({ - name: categoryName, - enabled: categories[categoryName].enabled ?? false, - })); - - if (filteredRecentlyUsedCategories.length > 0) { - const cutRecentlyUsedCategories = filteredRecentlyUsedCategories.slice(0, maxRecentReportsToShow); - - const data = getCategoryOptionTree(cutRecentlyUsedCategories, true); - categorySections.push({ - // "Recent" section - title: Localize.translateLocal('common.recent'), - shouldShow: true, - data, - indexOffset: data.length, - }); - } - - const data = getCategoryOptionTree(filteredCategories, false, selectedOptionsWithDisabledState); - categorySections.push({ - // "All" section when items amount more than the threshold - title: Localize.translateLocal('common.all'), - shouldShow: true, - data, - indexOffset: data.length, - }); - - return categorySections; -} - /** * Transforms the provided tags into option objects. * @@ -1711,9 +1441,6 @@ function getOptions( includeMoneyRequests = false, excludeUnknownUsers = false, includeP2P = true, - includeCategories = false, - categories = {}, - recentlyUsedCategories = [], includeTags = false, tags = {}, recentlyUsedTags = [], @@ -1734,20 +1461,6 @@ function getOptions( shouldBoldTitleByDefault = true, }: GetOptionsConfig, ): Options { - if (includeCategories) { - const categoryOptions = getCategoryListSections(categories, recentlyUsedCategories, selectedOptions as Category[], searchInputValue, maxRecentReportsToShow); - - return { - recentReports: [], - personalDetails: [], - userToInvite: null, - currentUserOption: null, - categoryOptions, - tagOptions: [], - taxRatesOptions: [], - }; - } - if (includeTags) { const tagOptions = getTagListSections(Object.values(tags), recentlyUsedTags, selectedOptions as SelectedTagOption[], searchInputValue, maxRecentReportsToShow); @@ -1756,7 +1469,6 @@ function getOptions( personalDetails: [], userToInvite: null, currentUserOption: null, - categoryOptions: [], tagOptions, taxRatesOptions: [], }; @@ -1770,7 +1482,6 @@ function getOptions( personalDetails: [], userToInvite: null, currentUserOption: null, - categoryOptions: [], tagOptions: [], taxRatesOptions, }; @@ -1783,7 +1494,6 @@ function getOptions( personalDetails: [], userToInvite: null, currentUserOption: null, - categoryOptions: [], tagOptions: [], taxRatesOptions: [], policyReportFieldOptions: transformedPolicyReportFieldOptions, @@ -2045,7 +1755,6 @@ function getOptions( recentReports: recentReportOptions, userToInvite: canInviteUser ? userToInvite : null, currentUserOption, - categoryOptions: [], tagOptions: [], taxRatesOptions: [], }; @@ -2130,9 +1839,6 @@ type FilteredOptionsParams = { excludeLogins?: string[]; includeOwnedWorkspaceChats?: boolean; includeP2P?: boolean; - includeCategories?: boolean; - categories?: PolicyCategories; - recentlyUsedCategories?: string[]; includeTags?: boolean; tags?: PolicyTags | Array; recentlyUsedTags?: string[]; @@ -2171,9 +1877,6 @@ function getFilteredOptions(params: FilteredOptionsParamsWithDefaultSearchValue excludeLogins = [], includeOwnedWorkspaceChats = false, includeP2P = true, - includeCategories = false, - categories = {}, - recentlyUsedCategories = [], includeTags = false, tags = {}, recentlyUsedTags = [], @@ -2201,9 +1904,6 @@ function getFilteredOptions(params: FilteredOptionsParamsWithDefaultSearchValue excludeLogins, includeOwnedWorkspaceChats, includeP2P, - includeCategories, - categories, - recentlyUsedCategories, includeTags, tags, recentlyUsedTags, @@ -2245,9 +1945,6 @@ function getAttendeeOptions( includeOwnedWorkspaceChats, includeRecentReports: false, includeP2P, - includeCategories: false, - categories: {}, - recentlyUsedCategories: [], includeTags: false, tags: {}, recentlyUsedTags: [], @@ -2549,7 +2246,6 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt personalDetails: personalDetails ?? [], userToInvite: null, currentUserOption, - categoryOptions: [], tagOptions: [], taxRatesOptions: [], }; @@ -2585,7 +2281,6 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt recentReports: orderOptions(recentReports, searchValue, {preferChatroomsOverThreads, preferPolicyExpenseChat, preferRecentExpenseReports}), userToInvite, currentUserOption: matchResults.currentUserOption, - categoryOptions: [], tagOptions: [], taxRatesOptions: [], }; @@ -2601,7 +2296,6 @@ function getEmptyOptions(): Options { personalDetails: [], userToInvite: null, currentUserOption: null, - categoryOptions: [], tagOptions: [], taxRatesOptions: [], }; @@ -2635,10 +2329,8 @@ export { getLastMessageTextForReport, getEnabledCategoriesCount, hasEnabledOptions, - sortCategories, sortAlphabetically, sortTags, - getCategoryOptionTree, hasEnabledTags, formatMemberForList, formatSectionsFromSearchTerm, @@ -2660,4 +2352,4 @@ export { hasReportErrors, }; -export type {MemberForList, CategorySection, CategoryTreeSection, Options, OptionList, SearchOption, PayeePersonalDetails, Category, Tax, TaxRatesOption, Option, OptionTree}; +export type {CategorySection, CategorySectionBase, MemberForList, Options, OptionList, SearchOption, PayeePersonalDetails, Tax, TaxRatesOption, Option, OptionTree}; diff --git a/tests/unit/CategoryOptionListUtils.ts b/tests/unit/CategoryOptionListUtils.ts new file mode 100644 index 000000000000..21f5e6533e77 --- /dev/null +++ b/tests/unit/CategoryOptionListUtils.ts @@ -0,0 +1,1242 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import * as CategoryOptionsListUtils from '@libs/CategoryOptionListUtils'; +import type {PolicyCategories} from '@src/types/onyx'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; + +describe('CategoryOptionListUtils', () => { + it('getFilteredOptions() for categories', () => { + const search = 'Food'; + const emptySearch = ''; + const wrongSearch = 'bla bla'; + const recentlyUsedCategories = ['Taxi', 'Restaurant']; + const selectedOptions: CategoryOptionsListUtils.Category[] = [ + { + name: 'Medical', + enabled: true, + }, + ]; + const smallCategoriesList: PolicyCategories = { + Taxi: { + enabled: false, + name: 'Taxi', + unencodedName: 'Taxi', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + pendingAction: undefined, + }, + Restaurant: { + enabled: true, + name: 'Restaurant', + unencodedName: 'Restaurant', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + pendingAction: 'delete', + }, + Food: { + enabled: true, + name: 'Food', + unencodedName: 'Food', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + pendingAction: undefined, + }, + 'Food: Meat': { + enabled: true, + name: 'Food: Meat', + unencodedName: 'Food: Meat', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + pendingAction: undefined, + }, + }; + const smallResultList: CategoryOptionsListUtils.CategoryTreeSection[] = [ + { + title: '', + shouldShow: false, + data: [ + { + text: 'Food', + keyForList: 'Food', + searchText: 'Food', + tooltipText: 'Food', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Meat', + keyForList: 'Food: Meat', + searchText: 'Food: Meat', + tooltipText: 'Meat', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Restaurant', + keyForList: 'Restaurant', + searchText: 'Restaurant', + tooltipText: 'Restaurant', + isDisabled: true, + isSelected: false, + pendingAction: 'delete', + }, + ], + indexOffset: 3, + }, + ]; + const smallSearchResultList: CategoryOptionsListUtils.CategoryTreeSection[] = [ + { + title: '', + shouldShow: true, + indexOffset: 2, + data: [ + { + text: 'Food', + keyForList: 'Food', + searchText: 'Food', + tooltipText: 'Food', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Food: Meat', + keyForList: 'Food: Meat', + searchText: 'Food: Meat', + tooltipText: 'Food: Meat', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + ], + }, + ]; + const smallWrongSearchResultList: CategoryOptionsListUtils.CategoryTreeSection[] = [ + { + title: '', + shouldShow: true, + indexOffset: 0, + data: [], + }, + ]; + const largeCategoriesList: PolicyCategories = { + Taxi: { + enabled: false, + name: 'Taxi', + unencodedName: 'Taxi', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + Restaurant: { + enabled: true, + name: 'Restaurant', + unencodedName: 'Restaurant', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + Food: { + enabled: true, + name: 'Food', + unencodedName: 'Food', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Food: Meat': { + enabled: true, + name: 'Food: Meat', + unencodedName: 'Food: Meat', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Food: Milk': { + enabled: true, + name: 'Food: Milk', + unencodedName: 'Food: Milk', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Food: Vegetables': { + enabled: false, + name: 'Food: Vegetables', + unencodedName: 'Food: Vegetables', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Cars: Audi': { + enabled: true, + name: 'Cars: Audi', + unencodedName: 'Cars: Audi', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Cars: BMW': { + enabled: false, + name: 'Cars: BMW', + unencodedName: 'Cars: BMW', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Cars: Mercedes-Benz': { + enabled: true, + name: 'Cars: Mercedes-Benz', + unencodedName: 'Cars: Mercedes-Benz', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + Medical: { + enabled: false, + name: 'Medical', + unencodedName: 'Medical', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Travel: Meals': { + enabled: true, + name: 'Travel: Meals', + unencodedName: 'Travel: Meals', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Travel: Meals: Breakfast': { + enabled: true, + name: 'Travel: Meals: Breakfast', + unencodedName: 'Travel: Meals: Breakfast', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Travel: Meals: Dinner': { + enabled: false, + name: 'Travel: Meals: Dinner', + unencodedName: 'Travel: Meals: Dinner', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + 'Travel: Meals: Lunch': { + enabled: true, + name: 'Travel: Meals: Lunch', + unencodedName: 'Travel: Meals: Lunch', + areCommentsRequired: false, + 'GL Code': '', + externalID: '', + origin: '', + }, + }; + const largeResultList: CategoryOptionsListUtils.CategoryTreeSection[] = [ + { + title: '', + shouldShow: false, + indexOffset: 1, + data: [ + { + text: 'Medical', + keyForList: 'Medical', + searchText: 'Medical', + tooltipText: 'Medical', + isDisabled: true, + isSelected: true, + pendingAction: undefined, + }, + ], + }, + { + title: 'Recent', + shouldShow: true, + indexOffset: 1, + data: [ + { + text: 'Restaurant', + keyForList: 'Restaurant', + searchText: 'Restaurant', + tooltipText: 'Restaurant', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + ], + }, + { + title: 'All', + shouldShow: true, + indexOffset: 11, + data: [ + { + text: 'Cars', + keyForList: 'Cars', + searchText: 'Cars', + tooltipText: 'Cars', + isDisabled: true, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Audi', + keyForList: 'Cars: Audi', + searchText: 'Cars: Audi', + tooltipText: 'Audi', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Mercedes-Benz', + keyForList: 'Cars: Mercedes-Benz', + searchText: 'Cars: Mercedes-Benz', + tooltipText: 'Mercedes-Benz', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Food', + keyForList: 'Food', + searchText: 'Food', + tooltipText: 'Food', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Meat', + keyForList: 'Food: Meat', + searchText: 'Food: Meat', + tooltipText: 'Meat', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Milk', + keyForList: 'Food: Milk', + searchText: 'Food: Milk', + tooltipText: 'Milk', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Restaurant', + keyForList: 'Restaurant', + searchText: 'Restaurant', + tooltipText: 'Restaurant', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Travel', + keyForList: 'Travel', + searchText: 'Travel', + tooltipText: 'Travel', + isDisabled: true, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Meals', + keyForList: 'Travel: Meals', + searchText: 'Travel: Meals', + tooltipText: 'Meals', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Breakfast', + keyForList: 'Travel: Meals: Breakfast', + searchText: 'Travel: Meals: Breakfast', + tooltipText: 'Breakfast', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Lunch', + keyForList: 'Travel: Meals: Lunch', + searchText: 'Travel: Meals: Lunch', + tooltipText: 'Lunch', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + ], + }, + ]; + const largeSearchResultList: CategoryOptionsListUtils.CategoryTreeSection[] = [ + { + title: '', + shouldShow: true, + indexOffset: 3, + data: [ + { + text: 'Food', + keyForList: 'Food', + searchText: 'Food', + tooltipText: 'Food', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Food: Meat', + keyForList: 'Food: Meat', + searchText: 'Food: Meat', + tooltipText: 'Food: Meat', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Food: Milk', + keyForList: 'Food: Milk', + searchText: 'Food: Milk', + tooltipText: 'Food: Milk', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + ], + }, + ]; + const largeWrongSearchResultList: CategoryOptionsListUtils.CategoryTreeSection[] = [ + { + title: '', + shouldShow: true, + indexOffset: 0, + data: [], + }, + ]; + const emptyCategoriesList = {}; + const emptySelectedResultList: CategoryOptionsListUtils.CategoryTreeSection[] = [ + { + title: '', + shouldShow: false, + indexOffset: 1, + data: [ + { + text: 'Medical', + keyForList: 'Medical', + searchText: 'Medical', + tooltipText: 'Medical', + isDisabled: true, + isSelected: true, + pendingAction: undefined, + }, + ], + }, + ]; + + const smallResult = CategoryOptionsListUtils.getCategoryListSections({ + searchValue: emptySearch, + categories: smallCategoriesList, + }); + expect(smallResult).toStrictEqual(smallResultList); + + const smallSearchResult = CategoryOptionsListUtils.getCategoryListSections({searchValue: search, categories: smallCategoriesList}); + expect(smallSearchResult).toStrictEqual(smallSearchResultList); + + const smallWrongSearchResult = CategoryOptionsListUtils.getCategoryListSections({searchValue: wrongSearch, categories: smallCategoriesList}); + expect(smallWrongSearchResult).toStrictEqual(smallWrongSearchResultList); + + const largeResult = CategoryOptionsListUtils.getCategoryListSections({ + searchValue: emptySearch, + selectedOptions, + categories: largeCategoriesList, + recentlyUsedCategories, + }); + expect(largeResult).toStrictEqual(largeResultList); + + const largeSearchResult = CategoryOptionsListUtils.getCategoryListSections({ + searchValue: search, + selectedOptions, + categories: largeCategoriesList, + recentlyUsedCategories, + }); + expect(largeSearchResult).toStrictEqual(largeSearchResultList); + + const largeWrongSearchResult = CategoryOptionsListUtils.getCategoryListSections({ + searchValue: wrongSearch, + selectedOptions, + categories: largeCategoriesList, + recentlyUsedCategories, + }); + expect(largeWrongSearchResult).toStrictEqual(largeWrongSearchResultList); + + const emptyResult = CategoryOptionsListUtils.getCategoryListSections({searchValue: search, selectedOptions, categories: emptyCategoriesList}); + expect(emptyResult).toStrictEqual(emptySelectedResultList); + }); + + it('getCategoryOptionTree()', () => { + const categories = { + Meals: { + enabled: true, + name: 'Meals', + }, + Restaurant: { + enabled: true, + name: 'Restaurant', + }, + Food: { + enabled: true, + name: 'Food', + }, + 'Food: Meat': { + enabled: true, + name: 'Food: Meat', + }, + 'Food: Milk': { + enabled: true, + name: 'Food: Milk', + }, + 'Cars: Audi': { + enabled: true, + name: 'Cars: Audi', + }, + 'Cars: Mercedes-Benz': { + enabled: true, + name: 'Cars: Mercedes-Benz', + }, + 'Travel: Meals': { + enabled: true, + name: 'Travel: Meals', + }, + 'Travel: Meals: Breakfast': { + enabled: true, + name: 'Travel: Meals: Breakfast', + }, + 'Travel: Meals: Lunch': { + enabled: true, + name: 'Travel: Meals: Lunch', + }, + Plain: { + enabled: true, + name: 'Plain', + }, + Audi: { + enabled: true, + name: 'Audi', + }, + Health: { + enabled: true, + name: 'Health', + }, + 'A: B: C': { + enabled: true, + name: 'A: B: C', + }, + 'A: B: C: D: E': { + enabled: true, + name: 'A: B: C: D: E', + }, + }; + const result = [ + { + text: 'Meals', + keyForList: 'Meals', + searchText: 'Meals', + tooltipText: 'Meals', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Restaurant', + keyForList: 'Restaurant', + searchText: 'Restaurant', + tooltipText: 'Restaurant', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Food', + keyForList: 'Food', + searchText: 'Food', + tooltipText: 'Food', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Meat', + keyForList: 'Food: Meat', + searchText: 'Food: Meat', + tooltipText: 'Meat', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Milk', + keyForList: 'Food: Milk', + searchText: 'Food: Milk', + tooltipText: 'Milk', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Cars', + keyForList: 'Cars', + searchText: 'Cars', + tooltipText: 'Cars', + isDisabled: true, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Audi', + keyForList: 'Cars: Audi', + searchText: 'Cars: Audi', + tooltipText: 'Audi', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Mercedes-Benz', + keyForList: 'Cars: Mercedes-Benz', + searchText: 'Cars: Mercedes-Benz', + tooltipText: 'Mercedes-Benz', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Travel', + keyForList: 'Travel', + searchText: 'Travel', + tooltipText: 'Travel', + isDisabled: true, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Meals', + keyForList: 'Travel: Meals', + searchText: 'Travel: Meals', + tooltipText: 'Meals', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Breakfast', + keyForList: 'Travel: Meals: Breakfast', + searchText: 'Travel: Meals: Breakfast', + tooltipText: 'Breakfast', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' Lunch', + keyForList: 'Travel: Meals: Lunch', + searchText: 'Travel: Meals: Lunch', + tooltipText: 'Lunch', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Plain', + keyForList: 'Plain', + searchText: 'Plain', + tooltipText: 'Plain', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Audi', + keyForList: 'Audi', + searchText: 'Audi', + tooltipText: 'Audi', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Health', + keyForList: 'Health', + searchText: 'Health', + tooltipText: 'Health', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'A', + keyForList: 'A', + searchText: 'A', + tooltipText: 'A', + isDisabled: true, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' B', + keyForList: 'A: B', + searchText: 'A: B', + tooltipText: 'B', + isDisabled: true, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' C', + keyForList: 'A: B: C', + searchText: 'A: B: C', + tooltipText: 'C', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' D', + keyForList: 'A: B: C: D', + searchText: 'A: B: C: D', + tooltipText: 'D', + isDisabled: true, + isSelected: false, + pendingAction: undefined, + }, + { + text: ' E', + keyForList: 'A: B: C: D: E', + searchText: 'A: B: C: D: E', + tooltipText: 'E', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + ]; + const resultOneLine = [ + { + text: 'Meals', + keyForList: 'Meals', + searchText: 'Meals', + tooltipText: 'Meals', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Restaurant', + keyForList: 'Restaurant', + searchText: 'Restaurant', + tooltipText: 'Restaurant', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Food', + keyForList: 'Food', + searchText: 'Food', + tooltipText: 'Food', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Food: Meat', + keyForList: 'Food: Meat', + searchText: 'Food: Meat', + tooltipText: 'Food: Meat', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Food: Milk', + keyForList: 'Food: Milk', + searchText: 'Food: Milk', + tooltipText: 'Food: Milk', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Cars: Audi', + keyForList: 'Cars: Audi', + searchText: 'Cars: Audi', + tooltipText: 'Cars: Audi', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Cars: Mercedes-Benz', + keyForList: 'Cars: Mercedes-Benz', + searchText: 'Cars: Mercedes-Benz', + tooltipText: 'Cars: Mercedes-Benz', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Travel: Meals', + keyForList: 'Travel: Meals', + searchText: 'Travel: Meals', + tooltipText: 'Travel: Meals', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Travel: Meals: Breakfast', + keyForList: 'Travel: Meals: Breakfast', + searchText: 'Travel: Meals: Breakfast', + tooltipText: 'Travel: Meals: Breakfast', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Travel: Meals: Lunch', + keyForList: 'Travel: Meals: Lunch', + searchText: 'Travel: Meals: Lunch', + tooltipText: 'Travel: Meals: Lunch', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Plain', + keyForList: 'Plain', + searchText: 'Plain', + tooltipText: 'Plain', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Audi', + keyForList: 'Audi', + searchText: 'Audi', + tooltipText: 'Audi', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'Health', + keyForList: 'Health', + searchText: 'Health', + tooltipText: 'Health', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'A: B: C', + keyForList: 'A: B: C', + searchText: 'A: B: C', + tooltipText: 'A: B: C', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + { + text: 'A: B: C: D: E', + keyForList: 'A: B: C: D: E', + searchText: 'A: B: C: D: E', + tooltipText: 'A: B: C: D: E', + isDisabled: false, + isSelected: false, + pendingAction: undefined, + }, + ]; + + expect(CategoryOptionsListUtils.getCategoryOptionTree(categories)).toStrictEqual(result); + expect(CategoryOptionsListUtils.getCategoryOptionTree(categories, true)).toStrictEqual(resultOneLine); + }); + + it('sortCategories', () => { + const categoriesIncorrectOrdering = { + Taxi: { + name: 'Taxi', + enabled: false, + }, + 'Test1: Subtest2': { + name: 'Test1: Subtest2', + enabled: true, + }, + 'Test: Test1: Subtest4': { + name: 'Test: Test1: Subtest4', + enabled: true, + }, + Taxes: { + name: 'Taxes', + enabled: true, + }, + Test: { + name: 'Test', + enabled: true, + pendingAction: 'delete' as PendingAction, + }, + Test1: { + name: 'Test1', + enabled: true, + }, + 'Travel: Nested-Travel': { + name: 'Travel: Nested-Travel', + enabled: true, + }, + 'Test1: Subtest1': { + name: 'Test1: Subtest1', + enabled: true, + }, + 'Test: Test1': { + name: 'Test: Test1', + enabled: true, + }, + 'Test: Test1: Subtest1': { + name: 'Test: Test1: Subtest1', + enabled: true, + }, + 'Test: Test1: Subtest3': { + name: 'Test: Test1: Subtest3', + enabled: false, + }, + 'Test: Test1: Subtest2': { + name: 'Test: Test1: Subtest2', + enabled: true, + }, + 'Test: Test2': { + name: 'Test: Test2', + enabled: true, + }, + Travel: { + name: 'Travel', + enabled: true, + }, + Utilities: { + name: 'Utilities', + enabled: true, + }, + 'Test: Test3: Subtest1': { + name: 'Test: Test3: Subtest1', + enabled: true, + }, + 'Test1: Subtest3': { + name: 'Test1: Subtest3', + enabled: true, + }, + }; + const result = [ + { + name: 'Taxes', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Taxi', + enabled: false, + pendingAction: undefined, + }, + { + name: 'Test', + enabled: true, + pendingAction: 'delete', + }, + { + name: 'Test: Test1', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Test: Test1: Subtest1', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Test: Test1: Subtest2', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Test: Test1: Subtest3', + enabled: false, + pendingAction: undefined, + }, + { + name: 'Test: Test1: Subtest4', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Test: Test2', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Test: Test3: Subtest1', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Test1', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Test1: Subtest1', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Test1: Subtest2', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Test1: Subtest3', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Travel', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Travel: Nested-Travel', + enabled: true, + pendingAction: undefined, + }, + { + name: 'Utilities', + enabled: true, + pendingAction: undefined, + }, + ]; + const categoriesIncorrectOrdering2 = { + 'Cars: BMW': { + enabled: false, + name: 'Cars: BMW', + }, + Medical: { + enabled: false, + name: 'Medical', + }, + 'Travel: Meals: Lunch': { + enabled: true, + name: 'Travel: Meals: Lunch', + }, + 'Cars: Mercedes-Benz': { + enabled: true, + name: 'Cars: Mercedes-Benz', + }, + Food: { + enabled: true, + name: 'Food', + }, + 'Food: Meat': { + enabled: true, + name: 'Food: Meat', + }, + 'Travel: Meals: Dinner': { + enabled: false, + name: 'Travel: Meals: Dinner', + }, + 'Food: Vegetables': { + enabled: false, + name: 'Food: Vegetables', + }, + Restaurant: { + enabled: true, + name: 'Restaurant', + }, + Taxi: { + enabled: false, + name: 'Taxi', + }, + 'Food: Milk': { + enabled: true, + name: 'Food: Milk', + }, + 'Travel: Meals': { + enabled: true, + name: 'Travel: Meals', + }, + 'Travel: Meals: Breakfast': { + enabled: true, + name: 'Travel: Meals: Breakfast', + }, + 'Cars: Audi': { + enabled: true, + name: 'Cars: Audi', + }, + }; + const result2 = [ + { + enabled: true, + name: 'Cars: Audi', + pendingAction: undefined, + }, + { + enabled: false, + name: 'Cars: BMW', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Cars: Mercedes-Benz', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Food', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Food: Meat', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Food: Milk', + pendingAction: undefined, + }, + { + enabled: false, + name: 'Food: Vegetables', + pendingAction: undefined, + }, + { + enabled: false, + name: 'Medical', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Restaurant', + pendingAction: undefined, + }, + { + enabled: false, + name: 'Taxi', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Travel: Meals', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Travel: Meals: Breakfast', + pendingAction: undefined, + }, + { + enabled: false, + name: 'Travel: Meals: Dinner', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Travel: Meals: Lunch', + pendingAction: undefined, + }, + ]; + const categoriesIncorrectOrdering3 = { + 'Movies: Mr. Nobody': { + enabled: true, + name: 'Movies: Mr. Nobody', + }, + Movies: { + enabled: true, + name: 'Movies', + }, + 'House, M.D.': { + enabled: true, + name: 'House, M.D.', + }, + 'Dr. House': { + enabled: true, + name: 'Dr. House', + }, + 'Many.dots.on.the.way.': { + enabled: true, + name: 'Many.dots.on.the.way.', + }, + 'More.Many.dots.on.the.way.': { + enabled: false, + name: 'More.Many.dots.on.the.way.', + }, + }; + const result3 = [ + { + enabled: true, + name: 'Dr. House', + pendingAction: undefined, + }, + { + enabled: true, + name: 'House, M.D.', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Many.dots.on.the.way.', + pendingAction: undefined, + }, + { + enabled: false, + name: 'More.Many.dots.on.the.way.', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Movies', + pendingAction: undefined, + }, + { + enabled: true, + name: 'Movies: Mr. Nobody', + pendingAction: undefined, + }, + ]; + + expect(CategoryOptionsListUtils.sortCategories(categoriesIncorrectOrdering)).toStrictEqual(result); + expect(CategoryOptionsListUtils.sortCategories(categoriesIncorrectOrdering2)).toStrictEqual(result2); + expect(CategoryOptionsListUtils.sortCategories(categoriesIncorrectOrdering3)).toStrictEqual(result3); + }); +}); diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts index 5a0cd6638a07..ca830cb48074 100644 --- a/tests/unit/OptionsListUtilsTest.ts +++ b/tests/unit/OptionsListUtilsTest.ts @@ -7,8 +7,7 @@ import CONST from '@src/CONST'; import * as OptionsListUtils from '@src/libs/OptionsListUtils'; import * as ReportUtils from '@src/libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, Policy, PolicyCategories, Report, TaxRatesWithDefault, Transaction} from '@src/types/onyx'; -import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {PersonalDetails, Policy, Report, TaxRatesWithDefault, Transaction} from '@src/types/onyx'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; type PersonalDetailsList = Record; @@ -639,115 +638,86 @@ describe('OptionsListUtils', () => { expect(results.personalDetails.at(3)?.text).toBe('Invisible Woman'); }); - it('getFilteredOptions() for categories', () => { - const search = 'Food'; + it('getFilteredOptions() for tags', () => { + const search = 'ing'; const emptySearch = ''; const wrongSearch = 'bla bla'; - const recentlyUsedCategories = ['Taxi', 'Restaurant']; - const selectedOptions: Array> = [ + const recentlyUsedTags = ['Engineering', 'HR']; + + const selectedOptions = [ { name: 'Medical', - enabled: true, }, ]; - const smallCategoriesList: PolicyCategories = { - Taxi: { + const smallTagsList: Record = { + Engineering: { enabled: false, - name: 'Taxi', - unencodedName: 'Taxi', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - pendingAction: undefined, + name: 'Engineering', + accountID: undefined, }, - Restaurant: { + Medical: { enabled: true, - name: 'Restaurant', - unencodedName: 'Restaurant', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - pendingAction: 'delete', + name: 'Medical', + accountID: undefined, }, - Food: { + Accounting: { enabled: true, - name: 'Food', - unencodedName: 'Food', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - pendingAction: undefined, + name: 'Accounting', + accountID: undefined, }, - 'Food: Meat': { + HR: { enabled: true, - name: 'Food: Meat', - unencodedName: 'Food: Meat', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - pendingAction: undefined, + name: 'HR', + accountID: undefined, + pendingAction: 'delete', }, }; - const smallResultList: OptionsListUtils.CategoryTreeSection[] = [ + const smallResultList: OptionsListUtils.CategorySection[] = [ { title: '', shouldShow: false, + // data sorted alphabetically by name data: [ { - text: 'Food', - keyForList: 'Food', - searchText: 'Food', - tooltipText: 'Food', + text: 'Accounting', + keyForList: 'Accounting', + searchText: 'Accounting', + tooltipText: 'Accounting', isDisabled: false, isSelected: false, pendingAction: undefined, }, { - text: ' Meat', - keyForList: 'Food: Meat', - searchText: 'Food: Meat', - tooltipText: 'Meat', - isDisabled: false, + text: 'HR', + keyForList: 'HR', + searchText: 'HR', + tooltipText: 'HR', + isDisabled: true, isSelected: false, - pendingAction: undefined, + pendingAction: 'delete', }, { - text: 'Restaurant', - keyForList: 'Restaurant', - searchText: 'Restaurant', - tooltipText: 'Restaurant', - isDisabled: true, + text: 'Medical', + keyForList: 'Medical', + searchText: 'Medical', + tooltipText: 'Medical', + isDisabled: false, isSelected: false, - pendingAction: 'delete', + pendingAction: undefined, }, ], - indexOffset: 3, }, ]; - const smallSearchResultList: OptionsListUtils.CategoryTreeSection[] = [ + const smallSearchResultList: OptionsListUtils.CategorySection[] = [ { title: '', shouldShow: true, - indexOffset: 2, data: [ { - text: 'Food', - keyForList: 'Food', - searchText: 'Food', - tooltipText: 'Food', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Food: Meat', - keyForList: 'Food: Meat', - searchText: 'Food: Meat', - tooltipText: 'Food: Meat', + text: 'Accounting', + keyForList: 'Accounting', + searchText: 'Accounting', + tooltipText: 'Accounting', isDisabled: false, isSelected: false, pendingAction: undefined, @@ -755,154 +725,82 @@ describe('OptionsListUtils', () => { ], }, ]; - const smallWrongSearchResultList: OptionsListUtils.CategoryTreeSection[] = [ + const smallWrongSearchResultList: OptionsListUtils.CategorySection[] = [ { title: '', shouldShow: true, - indexOffset: 0, data: [], }, ]; - const largeCategoriesList: PolicyCategories = { - Taxi: { + const largeTagsList: Record = { + Engineering: { enabled: false, - name: 'Taxi', - unencodedName: 'Taxi', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', + name: 'Engineering', + accountID: undefined, }, - Restaurant: { + Medical: { enabled: true, - name: 'Restaurant', - unencodedName: 'Restaurant', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', + name: 'Medical', + accountID: undefined, }, - Food: { + Accounting: { enabled: true, - name: 'Food', - unencodedName: 'Food', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', + name: 'Accounting', + accountID: undefined, }, - 'Food: Meat': { + HR: { enabled: true, - name: 'Food: Meat', - unencodedName: 'Food: Meat', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', + name: 'HR', + accountID: undefined, }, - 'Food: Milk': { + Food: { enabled: true, - name: 'Food: Milk', - unencodedName: 'Food: Milk', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', + name: 'Food', + accountID: undefined, }, - 'Food: Vegetables': { + Traveling: { enabled: false, - name: 'Food: Vegetables', - unencodedName: 'Food: Vegetables', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', + name: 'Traveling', + accountID: undefined, }, - 'Cars: Audi': { + Cleaning: { enabled: true, - name: 'Cars: Audi', - unencodedName: 'Cars: Audi', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - }, - 'Cars: BMW': { - enabled: false, - name: 'Cars: BMW', - unencodedName: 'Cars: BMW', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', + name: 'Cleaning', + accountID: undefined, }, - 'Cars: Mercedes-Benz': { + Software: { enabled: true, - name: 'Cars: Mercedes-Benz', - unencodedName: 'Cars: Mercedes-Benz', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', + name: 'Software', + accountID: undefined, }, - Medical: { + OfficeSupplies: { enabled: false, - name: 'Medical', - unencodedName: 'Medical', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - }, - 'Travel: Meals': { - enabled: true, - name: 'Travel: Meals', - unencodedName: 'Travel: Meals', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', + name: 'Office Supplies', + accountID: undefined, }, - 'Travel: Meals: Breakfast': { + Taxes: { enabled: true, - name: 'Travel: Meals: Breakfast', - unencodedName: 'Travel: Meals: Breakfast', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', - }, - 'Travel: Meals: Dinner': { - enabled: false, - name: 'Travel: Meals: Dinner', - unencodedName: 'Travel: Meals: Dinner', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', + name: 'Taxes', + accountID: undefined, + pendingAction: 'delete', }, - 'Travel: Meals: Lunch': { + Benefits: { enabled: true, - name: 'Travel: Meals: Lunch', - unencodedName: 'Travel: Meals: Lunch', - areCommentsRequired: false, - 'GL Code': '', - externalID: '', - origin: '', + name: 'Benefits', + accountID: undefined, }, }; - const largeResultList: OptionsListUtils.CategoryTreeSection[] = [ + const largeResultList: OptionsListUtils.CategorySection[] = [ { title: '', - shouldShow: false, - indexOffset: 1, + shouldShow: true, data: [ { text: 'Medical', keyForList: 'Medical', searchText: 'Medical', tooltipText: 'Medical', - isDisabled: true, + isDisabled: false, isSelected: true, pendingAction: undefined, }, @@ -911,13 +809,12 @@ describe('OptionsListUtils', () => { { title: 'Recent', shouldShow: true, - indexOffset: 1, data: [ { - text: 'Restaurant', - keyForList: 'Restaurant', - searchText: 'Restaurant', - tooltipText: 'Restaurant', + text: 'HR', + keyForList: 'HR', + searchText: 'HR', + tooltipText: 'HR', isDisabled: false, isSelected: false, pendingAction: undefined, @@ -927,31 +824,31 @@ describe('OptionsListUtils', () => { { title: 'All', shouldShow: true, - indexOffset: 11, + // data sorted alphabetically by name data: [ { - text: 'Cars', - keyForList: 'Cars', - searchText: 'Cars', - tooltipText: 'Cars', - isDisabled: true, + text: 'Accounting', + keyForList: 'Accounting', + searchText: 'Accounting', + tooltipText: 'Accounting', + isDisabled: false, isSelected: false, pendingAction: undefined, }, { - text: ' Audi', - keyForList: 'Cars: Audi', - searchText: 'Cars: Audi', - tooltipText: 'Audi', + text: 'Benefits', + keyForList: 'Benefits', + searchText: 'Benefits', + tooltipText: 'Benefits', isDisabled: false, isSelected: false, pendingAction: undefined, }, { - text: ' Mercedes-Benz', - keyForList: 'Cars: Mercedes-Benz', - searchText: 'Cars: Mercedes-Benz', - tooltipText: 'Mercedes-Benz', + text: 'Cleaning', + keyForList: 'Cleaning', + searchText: 'Cleaning', + tooltipText: 'Cleaning', isDisabled: false, isSelected: false, pendingAction: undefined, @@ -966,100 +863,54 @@ describe('OptionsListUtils', () => { pendingAction: undefined, }, { - text: ' Meat', - keyForList: 'Food: Meat', - searchText: 'Food: Meat', - tooltipText: 'Meat', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Milk', - keyForList: 'Food: Milk', - searchText: 'Food: Milk', - tooltipText: 'Milk', + text: 'HR', + keyForList: 'HR', + searchText: 'HR', + tooltipText: 'HR', isDisabled: false, isSelected: false, pendingAction: undefined, }, { - text: 'Restaurant', - keyForList: 'Restaurant', - searchText: 'Restaurant', - tooltipText: 'Restaurant', + text: 'Software', + keyForList: 'Software', + searchText: 'Software', + tooltipText: 'Software', isDisabled: false, isSelected: false, pendingAction: undefined, }, { - text: 'Travel', - keyForList: 'Travel', - searchText: 'Travel', - tooltipText: 'Travel', + text: 'Taxes', + keyForList: 'Taxes', + searchText: 'Taxes', + tooltipText: 'Taxes', isDisabled: true, isSelected: false, - pendingAction: undefined, - }, - { - text: ' Meals', - keyForList: 'Travel: Meals', - searchText: 'Travel: Meals', - tooltipText: 'Meals', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Breakfast', - keyForList: 'Travel: Meals: Breakfast', - searchText: 'Travel: Meals: Breakfast', - tooltipText: 'Breakfast', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Lunch', - keyForList: 'Travel: Meals: Lunch', - searchText: 'Travel: Meals: Lunch', - tooltipText: 'Lunch', - isDisabled: false, - isSelected: false, - pendingAction: undefined, + pendingAction: 'delete', }, ], }, ]; - const largeSearchResultList: OptionsListUtils.CategoryTreeSection[] = [ + const largeSearchResultList: OptionsListUtils.CategorySection[] = [ { title: '', shouldShow: true, - indexOffset: 3, data: [ { - text: 'Food', - keyForList: 'Food', - searchText: 'Food', - tooltipText: 'Food', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Food: Meat', - keyForList: 'Food: Meat', - searchText: 'Food: Meat', - tooltipText: 'Food: Meat', + text: 'Accounting', + keyForList: 'Accounting', + searchText: 'Accounting', + tooltipText: 'Accounting', isDisabled: false, isSelected: false, pendingAction: undefined, }, { - text: 'Food: Milk', - keyForList: 'Food: Milk', - searchText: 'Food: Milk', - tooltipText: 'Food: Milk', + text: 'Cleaning', + keyForList: 'Cleaning', + searchText: 'Cleaning', + tooltipText: 'Cleaning', isDisabled: false, isSelected: false, pendingAction: undefined, @@ -1067,1137 +918,38 @@ describe('OptionsListUtils', () => { ], }, ]; - const largeWrongSearchResultList: OptionsListUtils.CategoryTreeSection[] = [ + const largeWrongSearchResultList: OptionsListUtils.CategorySection[] = [ { title: '', shouldShow: true, - indexOffset: 0, data: [], }, ]; - const emptyCategoriesList = {}; - const emptySelectedResultList: OptionsListUtils.CategoryTreeSection[] = [ - { - title: '', - shouldShow: false, - indexOffset: 1, - data: [ - { - text: 'Medical', - keyForList: 'Medical', - searchText: 'Medical', - tooltipText: 'Medical', - isDisabled: true, - isSelected: true, - pendingAction: undefined, - }, - ], - }, - ]; - const smallResult = OptionsListUtils.getFilteredOptions({ - reports: OPTIONS.reports, - personalDetails: OPTIONS.personalDetails, - searchValue: emptySearch, - includeP2P: false, - includeCategories: true, - categories: smallCategoriesList, - }); - expect(smallResult.categoryOptions).toStrictEqual(smallResultList); - - const smallSearchResult = OptionsListUtils.getFilteredOptions({searchValue: search, includeP2P: false, includeCategories: true, categories: smallCategoriesList}); - expect(smallSearchResult.categoryOptions).toStrictEqual(smallSearchResultList); + const smallResult = OptionsListUtils.getFilteredOptions({searchValue: emptySearch, includeP2P: false, includeTags: true, tags: smallTagsList}); + expect(smallResult.tagOptions).toStrictEqual(smallResultList); - const smallWrongSearchResult = OptionsListUtils.getFilteredOptions({searchValue: wrongSearch, includeP2P: false, includeCategories: true, categories: smallCategoriesList}); - expect(smallWrongSearchResult.categoryOptions).toStrictEqual(smallWrongSearchResultList); + const smallSearchResult = OptionsListUtils.getFilteredOptions({searchValue: search, includeP2P: false, includeTags: true, tags: smallTagsList}); + expect(smallSearchResult.tagOptions).toStrictEqual(smallSearchResultList); - const largeResult = OptionsListUtils.getFilteredOptions({ - searchValue: emptySearch, - selectedOptions, - includeP2P: false, - includeCategories: true, - categories: largeCategoriesList, - recentlyUsedCategories, - }); - expect(largeResult.categoryOptions).toStrictEqual(largeResultList); + const smallWrongSearchResult = OptionsListUtils.getFilteredOptions({searchValue: wrongSearch, includeP2P: false, includeTags: true, tags: smallTagsList}); + expect(smallWrongSearchResult.tagOptions).toStrictEqual(smallWrongSearchResultList); - const largeSearchResult = OptionsListUtils.getFilteredOptions({ - searchValue: search, - selectedOptions, + const largeResult = OptionsListUtils.getFilteredOptions({searchValue: emptySearch, selectedOptions, includeP2P: false, includeTags: true, tags: largeTagsList, recentlyUsedTags}); + expect(largeResult.tagOptions).toStrictEqual(largeResultList); - includeP2P: false, - includeCategories: true, - categories: largeCategoriesList, - recentlyUsedCategories, - }); - expect(largeSearchResult.categoryOptions).toStrictEqual(largeSearchResultList); + const largeSearchResult = OptionsListUtils.getFilteredOptions({searchValue: search, selectedOptions, includeP2P: false, includeTags: true, tags: largeTagsList, recentlyUsedTags}); + expect(largeSearchResult.tagOptions).toStrictEqual(largeSearchResultList); const largeWrongSearchResult = OptionsListUtils.getFilteredOptions({ searchValue: wrongSearch, selectedOptions, includeP2P: false, - includeCategories: true, - categories: largeCategoriesList, - recentlyUsedCategories, + includeTags: true, + tags: largeTagsList, + recentlyUsedTags, }); - expect(largeWrongSearchResult.categoryOptions).toStrictEqual(largeWrongSearchResultList); - - const emptyResult = OptionsListUtils.getFilteredOptions({searchValue: search, selectedOptions, includeP2P: false, includeCategories: true, categories: emptyCategoriesList}); - expect(emptyResult.categoryOptions).toStrictEqual(emptySelectedResultList); - }); - - it('getFilteredOptions() for tags', () => { - const search = 'ing'; - const emptySearch = ''; - const wrongSearch = 'bla bla'; - const recentlyUsedTags = ['Engineering', 'HR']; - - const selectedOptions = [ - { - name: 'Medical', - }, - ]; - const smallTagsList: Record = { - Engineering: { - enabled: false, - name: 'Engineering', - accountID: undefined, - }, - Medical: { - enabled: true, - name: 'Medical', - accountID: undefined, - }, - Accounting: { - enabled: true, - name: 'Accounting', - accountID: undefined, - }, - HR: { - enabled: true, - name: 'HR', - accountID: undefined, - pendingAction: 'delete', - }, - }; - const smallResultList: OptionsListUtils.CategorySection[] = [ - { - title: '', - shouldShow: false, - // data sorted alphabetically by name - data: [ - { - text: 'Accounting', - keyForList: 'Accounting', - searchText: 'Accounting', - tooltipText: 'Accounting', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'HR', - keyForList: 'HR', - searchText: 'HR', - tooltipText: 'HR', - isDisabled: true, - isSelected: false, - pendingAction: 'delete', - }, - { - text: 'Medical', - keyForList: 'Medical', - searchText: 'Medical', - tooltipText: 'Medical', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - ], - }, - ]; - const smallSearchResultList: OptionsListUtils.CategorySection[] = [ - { - title: '', - shouldShow: true, - data: [ - { - text: 'Accounting', - keyForList: 'Accounting', - searchText: 'Accounting', - tooltipText: 'Accounting', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - ], - }, - ]; - const smallWrongSearchResultList: OptionsListUtils.CategoryTreeSection[] = [ - { - title: '', - shouldShow: true, - data: [], - }, - ]; - const largeTagsList: Record = { - Engineering: { - enabled: false, - name: 'Engineering', - accountID: undefined, - }, - Medical: { - enabled: true, - name: 'Medical', - accountID: undefined, - }, - Accounting: { - enabled: true, - name: 'Accounting', - accountID: undefined, - }, - HR: { - enabled: true, - name: 'HR', - accountID: undefined, - }, - Food: { - enabled: true, - name: 'Food', - accountID: undefined, - }, - Traveling: { - enabled: false, - name: 'Traveling', - accountID: undefined, - }, - Cleaning: { - enabled: true, - name: 'Cleaning', - accountID: undefined, - }, - Software: { - enabled: true, - name: 'Software', - accountID: undefined, - }, - OfficeSupplies: { - enabled: false, - name: 'Office Supplies', - accountID: undefined, - }, - Taxes: { - enabled: true, - name: 'Taxes', - accountID: undefined, - pendingAction: 'delete', - }, - Benefits: { - enabled: true, - name: 'Benefits', - accountID: undefined, - }, - }; - const largeResultList: OptionsListUtils.CategorySection[] = [ - { - title: '', - shouldShow: true, - data: [ - { - text: 'Medical', - keyForList: 'Medical', - searchText: 'Medical', - tooltipText: 'Medical', - isDisabled: false, - isSelected: true, - pendingAction: undefined, - }, - ], - }, - { - title: 'Recent', - shouldShow: true, - data: [ - { - text: 'HR', - keyForList: 'HR', - searchText: 'HR', - tooltipText: 'HR', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - ], - }, - { - title: 'All', - shouldShow: true, - // data sorted alphabetically by name - data: [ - { - text: 'Accounting', - keyForList: 'Accounting', - searchText: 'Accounting', - tooltipText: 'Accounting', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Benefits', - keyForList: 'Benefits', - searchText: 'Benefits', - tooltipText: 'Benefits', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Cleaning', - keyForList: 'Cleaning', - searchText: 'Cleaning', - tooltipText: 'Cleaning', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Food', - keyForList: 'Food', - searchText: 'Food', - tooltipText: 'Food', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'HR', - keyForList: 'HR', - searchText: 'HR', - tooltipText: 'HR', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Software', - keyForList: 'Software', - searchText: 'Software', - tooltipText: 'Software', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Taxes', - keyForList: 'Taxes', - searchText: 'Taxes', - tooltipText: 'Taxes', - isDisabled: true, - isSelected: false, - pendingAction: 'delete', - }, - ], - }, - ]; - const largeSearchResultList: OptionsListUtils.CategorySection[] = [ - { - title: '', - shouldShow: true, - data: [ - { - text: 'Accounting', - keyForList: 'Accounting', - searchText: 'Accounting', - tooltipText: 'Accounting', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Cleaning', - keyForList: 'Cleaning', - searchText: 'Cleaning', - tooltipText: 'Cleaning', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - ], - }, - ]; - const largeWrongSearchResultList: OptionsListUtils.CategoryTreeSection[] = [ - { - title: '', - shouldShow: true, - data: [], - }, - ]; - - const smallResult = OptionsListUtils.getFilteredOptions({searchValue: emptySearch, includeP2P: false, includeTags: true, tags: smallTagsList}); - expect(smallResult.tagOptions).toStrictEqual(smallResultList); - - const smallSearchResult = OptionsListUtils.getFilteredOptions({searchValue: search, includeP2P: false, includeTags: true, tags: smallTagsList}); - expect(smallSearchResult.tagOptions).toStrictEqual(smallSearchResultList); - - const smallWrongSearchResult = OptionsListUtils.getFilteredOptions({searchValue: wrongSearch, includeP2P: false, includeTags: true, tags: smallTagsList}); - expect(smallWrongSearchResult.tagOptions).toStrictEqual(smallWrongSearchResultList); - - const largeResult = OptionsListUtils.getFilteredOptions({searchValue: emptySearch, selectedOptions, includeP2P: false, includeTags: true, tags: largeTagsList, recentlyUsedTags}); - expect(largeResult.tagOptions).toStrictEqual(largeResultList); - - const largeSearchResult = OptionsListUtils.getFilteredOptions({searchValue: search, selectedOptions, includeP2P: false, includeTags: true, tags: largeTagsList, recentlyUsedTags}); - expect(largeSearchResult.tagOptions).toStrictEqual(largeSearchResultList); - - const largeWrongSearchResult = OptionsListUtils.getFilteredOptions({ - searchValue: wrongSearch, - selectedOptions, - includeP2P: false, - includeTags: true, - tags: largeTagsList, - recentlyUsedTags, - }); - expect(largeWrongSearchResult.tagOptions).toStrictEqual(largeWrongSearchResultList); - }); - - it('getCategoryOptionTree()', () => { - const categories = { - Meals: { - enabled: true, - name: 'Meals', - }, - Restaurant: { - enabled: true, - name: 'Restaurant', - }, - Food: { - enabled: true, - name: 'Food', - }, - 'Food: Meat': { - enabled: true, - name: 'Food: Meat', - }, - 'Food: Milk': { - enabled: true, - name: 'Food: Milk', - }, - 'Cars: Audi': { - enabled: true, - name: 'Cars: Audi', - }, - 'Cars: Mercedes-Benz': { - enabled: true, - name: 'Cars: Mercedes-Benz', - }, - 'Travel: Meals': { - enabled: true, - name: 'Travel: Meals', - }, - 'Travel: Meals: Breakfast': { - enabled: true, - name: 'Travel: Meals: Breakfast', - }, - 'Travel: Meals: Lunch': { - enabled: true, - name: 'Travel: Meals: Lunch', - }, - Plain: { - enabled: true, - name: 'Plain', - }, - Audi: { - enabled: true, - name: 'Audi', - }, - Health: { - enabled: true, - name: 'Health', - }, - 'A: B: C': { - enabled: true, - name: 'A: B: C', - }, - 'A: B: C: D: E': { - enabled: true, - name: 'A: B: C: D: E', - }, - }; - const result = [ - { - text: 'Meals', - keyForList: 'Meals', - searchText: 'Meals', - tooltipText: 'Meals', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Restaurant', - keyForList: 'Restaurant', - searchText: 'Restaurant', - tooltipText: 'Restaurant', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Food', - keyForList: 'Food', - searchText: 'Food', - tooltipText: 'Food', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Meat', - keyForList: 'Food: Meat', - searchText: 'Food: Meat', - tooltipText: 'Meat', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Milk', - keyForList: 'Food: Milk', - searchText: 'Food: Milk', - tooltipText: 'Milk', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Cars', - keyForList: 'Cars', - searchText: 'Cars', - tooltipText: 'Cars', - isDisabled: true, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Audi', - keyForList: 'Cars: Audi', - searchText: 'Cars: Audi', - tooltipText: 'Audi', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Mercedes-Benz', - keyForList: 'Cars: Mercedes-Benz', - searchText: 'Cars: Mercedes-Benz', - tooltipText: 'Mercedes-Benz', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Travel', - keyForList: 'Travel', - searchText: 'Travel', - tooltipText: 'Travel', - isDisabled: true, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Meals', - keyForList: 'Travel: Meals', - searchText: 'Travel: Meals', - tooltipText: 'Meals', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Breakfast', - keyForList: 'Travel: Meals: Breakfast', - searchText: 'Travel: Meals: Breakfast', - tooltipText: 'Breakfast', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' Lunch', - keyForList: 'Travel: Meals: Lunch', - searchText: 'Travel: Meals: Lunch', - tooltipText: 'Lunch', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Plain', - keyForList: 'Plain', - searchText: 'Plain', - tooltipText: 'Plain', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Audi', - keyForList: 'Audi', - searchText: 'Audi', - tooltipText: 'Audi', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Health', - keyForList: 'Health', - searchText: 'Health', - tooltipText: 'Health', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'A', - keyForList: 'A', - searchText: 'A', - tooltipText: 'A', - isDisabled: true, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' B', - keyForList: 'A: B', - searchText: 'A: B', - tooltipText: 'B', - isDisabled: true, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' C', - keyForList: 'A: B: C', - searchText: 'A: B: C', - tooltipText: 'C', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' D', - keyForList: 'A: B: C: D', - searchText: 'A: B: C: D', - tooltipText: 'D', - isDisabled: true, - isSelected: false, - pendingAction: undefined, - }, - { - text: ' E', - keyForList: 'A: B: C: D: E', - searchText: 'A: B: C: D: E', - tooltipText: 'E', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - ]; - const resultOneLine = [ - { - text: 'Meals', - keyForList: 'Meals', - searchText: 'Meals', - tooltipText: 'Meals', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Restaurant', - keyForList: 'Restaurant', - searchText: 'Restaurant', - tooltipText: 'Restaurant', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Food', - keyForList: 'Food', - searchText: 'Food', - tooltipText: 'Food', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Food: Meat', - keyForList: 'Food: Meat', - searchText: 'Food: Meat', - tooltipText: 'Food: Meat', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Food: Milk', - keyForList: 'Food: Milk', - searchText: 'Food: Milk', - tooltipText: 'Food: Milk', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Cars: Audi', - keyForList: 'Cars: Audi', - searchText: 'Cars: Audi', - tooltipText: 'Cars: Audi', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Cars: Mercedes-Benz', - keyForList: 'Cars: Mercedes-Benz', - searchText: 'Cars: Mercedes-Benz', - tooltipText: 'Cars: Mercedes-Benz', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Travel: Meals', - keyForList: 'Travel: Meals', - searchText: 'Travel: Meals', - tooltipText: 'Travel: Meals', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Travel: Meals: Breakfast', - keyForList: 'Travel: Meals: Breakfast', - searchText: 'Travel: Meals: Breakfast', - tooltipText: 'Travel: Meals: Breakfast', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Travel: Meals: Lunch', - keyForList: 'Travel: Meals: Lunch', - searchText: 'Travel: Meals: Lunch', - tooltipText: 'Travel: Meals: Lunch', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Plain', - keyForList: 'Plain', - searchText: 'Plain', - tooltipText: 'Plain', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Audi', - keyForList: 'Audi', - searchText: 'Audi', - tooltipText: 'Audi', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'Health', - keyForList: 'Health', - searchText: 'Health', - tooltipText: 'Health', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'A: B: C', - keyForList: 'A: B: C', - searchText: 'A: B: C', - tooltipText: 'A: B: C', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - { - text: 'A: B: C: D: E', - keyForList: 'A: B: C: D: E', - searchText: 'A: B: C: D: E', - tooltipText: 'A: B: C: D: E', - isDisabled: false, - isSelected: false, - pendingAction: undefined, - }, - ]; - - expect(OptionsListUtils.getCategoryOptionTree(categories)).toStrictEqual(result); - expect(OptionsListUtils.getCategoryOptionTree(categories, true)).toStrictEqual(resultOneLine); - }); - - it('sortCategories', () => { - const categoriesIncorrectOrdering = { - Taxi: { - name: 'Taxi', - enabled: false, - }, - 'Test1: Subtest2': { - name: 'Test1: Subtest2', - enabled: true, - }, - 'Test: Test1: Subtest4': { - name: 'Test: Test1: Subtest4', - enabled: true, - }, - Taxes: { - name: 'Taxes', - enabled: true, - }, - Test: { - name: 'Test', - enabled: true, - pendingAction: 'delete' as PendingAction, - }, - Test1: { - name: 'Test1', - enabled: true, - }, - 'Travel: Nested-Travel': { - name: 'Travel: Nested-Travel', - enabled: true, - }, - 'Test1: Subtest1': { - name: 'Test1: Subtest1', - enabled: true, - }, - 'Test: Test1': { - name: 'Test: Test1', - enabled: true, - }, - 'Test: Test1: Subtest1': { - name: 'Test: Test1: Subtest1', - enabled: true, - }, - 'Test: Test1: Subtest3': { - name: 'Test: Test1: Subtest3', - enabled: false, - }, - 'Test: Test1: Subtest2': { - name: 'Test: Test1: Subtest2', - enabled: true, - }, - 'Test: Test2': { - name: 'Test: Test2', - enabled: true, - }, - Travel: { - name: 'Travel', - enabled: true, - }, - Utilities: { - name: 'Utilities', - enabled: true, - }, - 'Test: Test3: Subtest1': { - name: 'Test: Test3: Subtest1', - enabled: true, - }, - 'Test1: Subtest3': { - name: 'Test1: Subtest3', - enabled: true, - }, - }; - const result = [ - { - name: 'Taxes', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Taxi', - enabled: false, - pendingAction: undefined, - }, - { - name: 'Test', - enabled: true, - pendingAction: 'delete', - }, - { - name: 'Test: Test1', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Test: Test1: Subtest1', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Test: Test1: Subtest2', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Test: Test1: Subtest3', - enabled: false, - pendingAction: undefined, - }, - { - name: 'Test: Test1: Subtest4', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Test: Test2', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Test: Test3: Subtest1', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Test1', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Test1: Subtest1', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Test1: Subtest2', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Test1: Subtest3', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Travel', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Travel: Nested-Travel', - enabled: true, - pendingAction: undefined, - }, - { - name: 'Utilities', - enabled: true, - pendingAction: undefined, - }, - ]; - const categoriesIncorrectOrdering2 = { - 'Cars: BMW': { - enabled: false, - name: 'Cars: BMW', - }, - Medical: { - enabled: false, - name: 'Medical', - }, - 'Travel: Meals: Lunch': { - enabled: true, - name: 'Travel: Meals: Lunch', - }, - 'Cars: Mercedes-Benz': { - enabled: true, - name: 'Cars: Mercedes-Benz', - }, - Food: { - enabled: true, - name: 'Food', - }, - 'Food: Meat': { - enabled: true, - name: 'Food: Meat', - }, - 'Travel: Meals: Dinner': { - enabled: false, - name: 'Travel: Meals: Dinner', - }, - 'Food: Vegetables': { - enabled: false, - name: 'Food: Vegetables', - }, - Restaurant: { - enabled: true, - name: 'Restaurant', - }, - Taxi: { - enabled: false, - name: 'Taxi', - }, - 'Food: Milk': { - enabled: true, - name: 'Food: Milk', - }, - 'Travel: Meals': { - enabled: true, - name: 'Travel: Meals', - }, - 'Travel: Meals: Breakfast': { - enabled: true, - name: 'Travel: Meals: Breakfast', - }, - 'Cars: Audi': { - enabled: true, - name: 'Cars: Audi', - }, - }; - const result2 = [ - { - enabled: true, - name: 'Cars: Audi', - pendingAction: undefined, - }, - { - enabled: false, - name: 'Cars: BMW', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Cars: Mercedes-Benz', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Food', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Food: Meat', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Food: Milk', - pendingAction: undefined, - }, - { - enabled: false, - name: 'Food: Vegetables', - pendingAction: undefined, - }, - { - enabled: false, - name: 'Medical', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Restaurant', - pendingAction: undefined, - }, - { - enabled: false, - name: 'Taxi', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Travel: Meals', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Travel: Meals: Breakfast', - pendingAction: undefined, - }, - { - enabled: false, - name: 'Travel: Meals: Dinner', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Travel: Meals: Lunch', - pendingAction: undefined, - }, - ]; - const categoriesIncorrectOrdering3 = { - 'Movies: Mr. Nobody': { - enabled: true, - name: 'Movies: Mr. Nobody', - }, - Movies: { - enabled: true, - name: 'Movies', - }, - 'House, M.D.': { - enabled: true, - name: 'House, M.D.', - }, - 'Dr. House': { - enabled: true, - name: 'Dr. House', - }, - 'Many.dots.on.the.way.': { - enabled: true, - name: 'Many.dots.on.the.way.', - }, - 'More.Many.dots.on.the.way.': { - enabled: false, - name: 'More.Many.dots.on.the.way.', - }, - }; - const result3 = [ - { - enabled: true, - name: 'Dr. House', - pendingAction: undefined, - }, - { - enabled: true, - name: 'House, M.D.', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Many.dots.on.the.way.', - pendingAction: undefined, - }, - { - enabled: false, - name: 'More.Many.dots.on.the.way.', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Movies', - pendingAction: undefined, - }, - { - enabled: true, - name: 'Movies: Mr. Nobody', - pendingAction: undefined, - }, - ]; - - expect(OptionsListUtils.sortCategories(categoriesIncorrectOrdering)).toStrictEqual(result); - expect(OptionsListUtils.sortCategories(categoriesIncorrectOrdering2)).toStrictEqual(result2); - expect(OptionsListUtils.sortCategories(categoriesIncorrectOrdering3)).toStrictEqual(result3); + expect(largeWrongSearchResult.tagOptions).toStrictEqual(largeWrongSearchResultList); }); it('sortTags', () => { From 8f626c451f339d28cd8d50a0a9a7bd749dee015d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 8 Nov 2024 12:31:47 +0100 Subject: [PATCH 027/346] accept lodash/get for now --- src/libs/CategoryOptionListUtils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/CategoryOptionListUtils.ts b/src/libs/CategoryOptionListUtils.ts index 5ea71bca4136..d370da441110 100644 --- a/src/libs/CategoryOptionListUtils.ts +++ b/src/libs/CategoryOptionListUtils.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line you-dont-need-lodash-underscore/get import lodashGet from 'lodash/get'; import lodashSet from 'lodash/set'; import CONST from '@src/CONST'; From f275b476ef8c4aec8c31345cdb53fc9d5371acb6 Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Fri, 8 Nov 2024 08:25:59 -0800 Subject: [PATCH 028/346] WIP retry authentication with throttle --- src/ONYXKEYS.ts | 3 + src/libs/Authentication.ts | 79 +++++++++++-------------- src/libs/Middleware/Reauthentication.ts | 33 ++++++++++- src/libs/Network/SequentialQueue.ts | 12 ++-- src/libs/Network/index.ts | 14 +++++ src/libs/RequestThrottle.ts | 65 ++++++++++---------- 6 files changed, 124 insertions(+), 82 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 49dd42fa8281..4773cdab8c6b 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -36,6 +36,9 @@ const ONYXKEYS = { PERSISTED_REQUESTS: 'networkRequestQueue', PERSISTED_ONGOING_REQUESTS: 'networkOngoingRequestQueue', + /** The re-authentication request to be retried as needed */ + REAUTHENTICATION_REQUEST: 'reauthenticationRequest', + /** Stores current date */ CURRENT_DATE: 'currentDate', diff --git a/src/libs/Authentication.ts b/src/libs/Authentication.ts index 34630af81733..1ab7083b2d8e 100644 --- a/src/libs/Authentication.ts +++ b/src/libs/Authentication.ts @@ -62,55 +62,48 @@ function reauthenticate(command = ''): Promise { partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD, partnerUserID: credentials?.autoGeneratedLogin, partnerUserSecret: credentials?.autoGeneratedPassword, - }) - .then((response) => { - if (response.jsonCode === CONST.JSON_CODE.UNABLE_TO_RETRY) { - // If authentication fails, then the network can be unpaused - NetworkStore.setIsAuthenticating(false); + }).then((response) => { + if (response.jsonCode === CONST.JSON_CODE.UNABLE_TO_RETRY) { + // If authentication fails, then the network can be unpaused + NetworkStore.setIsAuthenticating(false); - // When a fetch() fails due to a network issue and an error is thrown we won't log the user out. Most likely they - // have a spotty connection and will need to try to reauthenticate when they come back online. We will error so it - // can be handled by callers of reauthenticate(). - throw new Error('Unable to retry Authenticate request'); - } + // When a fetch() fails due to a network issue and an error is thrown we won't log the user out. Most likely they + // have a spotty connection and will need to try to reauthenticate when they come back online. We will error so it + // can be handled by callers of reauthenticate(). + throw new Error('Unable to retry Authenticate request'); + } - // If authentication fails and we are online then log the user out - if (response.jsonCode !== 200) { - const errorMessage = ErrorUtils.getAuthenticateErrorMessage(response); - NetworkStore.setIsAuthenticating(false); - Log.hmmm('Redirecting to Sign In because we failed to reauthenticate', { - command, - error: errorMessage, - }); - redirectToSignIn(errorMessage); - return; - } + // If authentication fails and we are online then log the user out + if (response.jsonCode !== 200) { + const errorMessage = ErrorUtils.getAuthenticateErrorMessage(response); + NetworkStore.setIsAuthenticating(false); + Log.hmmm('Redirecting to Sign In because we failed to reauthenticate', { + command, + error: errorMessage, + }); + redirectToSignIn(errorMessage); + return; + } - // If we reauthenticated due to an expired delegate token, restore the delegate's original account. - // This is because the credentials used to reauthenticate were for the delegate's original account, and not for the account they were connected as. - if (Delegate.isConnectedAsDelegate()) { - Log.info('Reauthenticated while connected as a delegate. Restoring original account.'); - Delegate.restoreDelegateSession(response); - return; - } + // If we reauthenticated due to an expired delegate token, restore the delegate's original account. + // This is because the credentials used to reauthenticate were for the delegate's original account, and not for the account they were connected as. + if (Delegate.isConnectedAsDelegate()) { + Log.info('Reauthenticated while connected as a delegate. Restoring original account.'); + Delegate.restoreDelegateSession(response); + return; + } - // Update authToken in Onyx and in our local variables so that API requests will use the new authToken - updateSessionAuthTokens(response.authToken, response.encryptedAuthToken); + // Update authToken in Onyx and in our local variables so that API requests will use the new authToken + updateSessionAuthTokens(response.authToken, response.encryptedAuthToken); - // Note: It is important to manually set the authToken that is in the store here since any requests that are hooked into - // reauthenticate .then() will immediate post and use the local authToken. Onyx updates subscribers lately so it is not - // enough to do the updateSessionAuthTokens() call above. - NetworkStore.setAuthToken(response.authToken ?? null); + // Note: It is important to manually set the authToken that is in the store here since any requests that are hooked into + // reauthenticate .then() will immediate post and use the local authToken. Onyx updates subscribers lately so it is not + // enough to do the updateSessionAuthTokens() call above. + NetworkStore.setAuthToken(response.authToken ?? null); - // The authentication process is finished so the network can be unpaused to continue processing requests - NetworkStore.setIsAuthenticating(false); - }) - .catch((error) => { - // In case the authenticate call throws error, we need to sign user out as most likely they are missing credentials - NetworkStore.setIsAuthenticating(false); - Log.hmmm('Redirecting to Sign In because we failed to reauthenticate', {error}); - redirectToSignIn('passwordForm.error.fallback'); - }); + // The authentication process is finished so the network can be unpaused to continue processing requests + NetworkStore.setIsAuthenticating(false); + }); } export {reauthenticate, Authenticate}; diff --git a/src/libs/Middleware/Reauthentication.ts b/src/libs/Middleware/Reauthentication.ts index 09a01e821cb2..a67214e04420 100644 --- a/src/libs/Middleware/Reauthentication.ts +++ b/src/libs/Middleware/Reauthentication.ts @@ -1,33 +1,59 @@ +import redirectToSignIn from '@libs/actions/SignInRedirect'; import * as Authentication from '@libs/Authentication'; import Log from '@libs/Log'; import * as MainQueue from '@libs/Network/MainQueue'; import * as NetworkStore from '@libs/Network/NetworkStore'; +import type {RequestError} from '@libs/Network/SequentialQueue'; import NetworkConnection from '@libs/NetworkConnection'; import * as Request from '@libs/Request'; +import RequestThrottle from '@libs/RequestThrottle'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type Middleware from './types'; // We store a reference to the active authentication request so that we are only ever making one request to authenticate at a time. let isAuthenticating: Promise | null = null; +const reauthThrottle = new RequestThrottle(); + function reauthenticate(commandName?: string): Promise { if (isAuthenticating) { return isAuthenticating; } - isAuthenticating = Authentication.reauthenticate(commandName) + const reauthRequest = { + commandName, + }; + Onyx.set(ONYXKEYS.REAUTHENTICATION_REQUEST, reauthRequest); + + isAuthenticating = retryReauthenticate(commandName) .then((response) => { - isAuthenticating = null; return response; }) .catch((error) => { - isAuthenticating = null; throw error; + }) + .finally(() => { + Onyx.set(CONST.ONYXKEYS.REAUTHENTICATION_REQUEST, null); + isAuthenticating = null; }); return isAuthenticating; } +function retryReauthenticate(commandName?: string): Promise { + return Authentication.reauthenticate(commandName).catch((error: RequestError) => { + return reauthThrottle + .sleep(error, 'Authenticate') + .then(() => retryReauthenticate(commandName)) + .catch(() => { + NetworkStore.setIsAuthenticating(false); + Log.hmmm('Redirecting to Sign In because we failed to reauthenticate after multiple attempts', {error}); + redirectToSignIn('passwordForm.error.fallback'); + }); + }); +} + const Reauthentication: Middleware = (response, request, isFromSequentialQueue) => response .then((data) => { @@ -118,3 +144,4 @@ const Reauthentication: Middleware = (response, request, isFromSequentialQueue) }); export default Reauthentication; +export {reauthenticate}; diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index 643ed64ae7f6..ec07d315a608 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -2,7 +2,7 @@ import Onyx from 'react-native-onyx'; import * as ActiveClientManager from '@libs/ActiveClientManager'; import Log from '@libs/Log'; import * as Request from '@libs/Request'; -import * as RequestThrottle from '@libs/RequestThrottle'; +import RequestThrottle from '@libs/RequestThrottle'; import * as PersistedRequests from '@userActions/PersistedRequests'; import * as QueuedOnyxUpdates from '@userActions/QueuedOnyxUpdates'; import CONST from '@src/CONST'; @@ -28,6 +28,7 @@ resolveIsReadyPromise?.(); let isSequentialQueueRunning = false; let currentRequestPromise: Promise | null = null; let isQueuePaused = false; +const requestThrottle = new RequestThrottle(); /** * Puts the queue into a paused state so that no requests will be processed @@ -99,7 +100,7 @@ function process(): Promise { Log.info('[SequentialQueue] Removing persisted request because it was processed successfully.', false, {request: requestToProcess}); PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess); - RequestThrottle.clear(); + requestThrottle.clear(); return process(); }) .catch((error: RequestError) => { @@ -108,17 +109,18 @@ function process(): Promise { if (error.name === CONST.ERROR.REQUEST_CANCELLED || error.message === CONST.ERROR.DUPLICATE_RECORD) { Log.info("[SequentialQueue] Removing persisted request because it failed and doesn't need to be retried.", false, {error, request: requestToProcess}); PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess); - RequestThrottle.clear(); + requestThrottle.clear(); return process(); } PersistedRequests.rollbackOngoingRequest(); - return RequestThrottle.sleep(error, requestToProcess.command) + return requestThrottle + .sleep(error, requestToProcess.command) .then(process) .catch(() => { Onyx.update(requestToProcess.failureData ?? []); Log.info('[SequentialQueue] Removing persisted request because it failed too many times.', false, {error, request: requestToProcess}); PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess); - RequestThrottle.clear(); + requestThrottle.clear(); return process(); }); }); diff --git a/src/libs/Network/index.ts b/src/libs/Network/index.ts index 2adb4a2da4c2..2a600d5d51de 100644 --- a/src/libs/Network/index.ts +++ b/src/libs/Network/index.ts @@ -1,5 +1,8 @@ +import Onyx from 'react-native-onyx'; import * as ActiveClientManager from '@libs/ActiveClientManager'; +import {reauthenticate} from '@libs/Middleware/Reauthentication'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {Request} from '@src/types/onyx'; import type Response from '@src/types/onyx/Response'; import pkg from '../../../package.json'; @@ -12,6 +15,17 @@ ActiveClientManager.isReady().then(() => { // Start main queue and process once every n ms delay setInterval(MainQueue.process, CONST.NETWORK.PROCESS_REQUEST_DELAY_MS); + + // If a reauthentication request is set make sure it is processed + Onyx.connect({ + key: ONYXKEYS.REAUTHENTICATION_REQUEST, + callback: (request) => { + if (!request) { + return; + } + reauthenticate(request.commandName); + }, + }); }); /** diff --git a/src/libs/RequestThrottle.ts b/src/libs/RequestThrottle.ts index 3bbc82ff5b45..8a6673c22a92 100644 --- a/src/libs/RequestThrottle.ts +++ b/src/libs/RequestThrottle.ts @@ -3,41 +3,44 @@ import Log from './Log'; import type {RequestError} from './Network/SequentialQueue'; import {generateRandomInt} from './NumberUtils'; -let requestWaitTime = 0; -let requestRetryCount = 0; +class RequestThrottle { + private requestWaitTime = 0; -function clear() { - requestWaitTime = 0; - requestRetryCount = 0; - Log.info(`[RequestThrottle] in clear()`); -} + private requestRetryCount = 0; -function getRequestWaitTime() { - if (requestWaitTime) { - requestWaitTime = Math.min(requestWaitTime * 2, CONST.NETWORK.MAX_RETRY_WAIT_TIME_MS); - } else { - requestWaitTime = generateRandomInt(CONST.NETWORK.MIN_RETRY_WAIT_TIME_MS, CONST.NETWORK.MAX_RANDOM_RETRY_WAIT_TIME_MS); + clear() { + this.requestWaitTime = 0; + this.requestRetryCount = 0; + Log.info(`[RequestThrottle] in clear()`); } - return requestWaitTime; -} -function getLastRequestWaitTime() { - return requestWaitTime; -} - -function sleep(error: RequestError, command: string): Promise { - requestRetryCount++; - return new Promise((resolve, reject) => { - if (requestRetryCount <= CONST.NETWORK.MAX_REQUEST_RETRIES) { - const currentRequestWaitTime = getRequestWaitTime(); - Log.info( - `[RequestThrottle] Retrying request after error: '${error.name}', '${error.message}', '${error.status}'. Command: ${command}. Retry count: ${requestRetryCount}. Wait time: ${currentRequestWaitTime}`, - ); - setTimeout(resolve, currentRequestWaitTime); - return; + getRequestWaitTime() { + if (this.requestWaitTime) { + this.requestWaitTime = Math.min(this.requestWaitTime * 2, CONST.NETWORK.MAX_RETRY_WAIT_TIME_MS); + } else { + this.requestWaitTime = generateRandomInt(CONST.NETWORK.MIN_RETRY_WAIT_TIME_MS, CONST.NETWORK.MAX_RANDOM_RETRY_WAIT_TIME_MS); } - reject(); - }); + return this.requestWaitTime; + } + + getLastRequestWaitTime() { + return this.requestWaitTime; + } + + sleep(error: RequestError, command: string): Promise { + this.requestRetryCount++; + return new Promise((resolve, reject) => { + if (this.requestRetryCount <= CONST.NETWORK.MAX_REQUEST_RETRIES) { + const currentRequestWaitTime = this.getRequestWaitTime(); + Log.info( + `[RequestThrottle] Retrying request after error: '${error.name}', '${error.message}', '${error.status}'. Command: ${command}. Retry count: ${this.requestRetryCount}. Wait time: ${currentRequestWaitTime}`, + ); + setTimeout(resolve, currentRequestWaitTime); + } else { + reject(); + } + }); + } } -export {clear, getRequestWaitTime, sleep, getLastRequestWaitTime}; +export default RequestThrottle; From 7ffdbbeed84bdd52c6e3413ea6f3e97a163caa5b Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Fri, 8 Nov 2024 08:33:39 -0800 Subject: [PATCH 029/346] Fix types --- src/ONYXKEYS.ts | 1 + src/libs/Authentication.ts | 6 +----- src/libs/Middleware/Reauthentication.ts | 5 ++++- tests/unit/APITest.ts | 8 +++++--- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 4773cdab8c6b..08feab508556 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -891,6 +891,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_SIDEBAR_LOADED]: boolean; [ONYXKEYS.PERSISTED_REQUESTS]: OnyxTypes.Request[]; [ONYXKEYS.PERSISTED_ONGOING_REQUESTS]: OnyxTypes.Request; + [ONYXKEYS.REAUTHENTICATION_REQUEST]: OnyxTypes.Request; [ONYXKEYS.CURRENT_DATE]: string; [ONYXKEYS.CREDENTIALS]: OnyxTypes.Credentials; [ONYXKEYS.STASHED_CREDENTIALS]: OnyxTypes.Credentials; diff --git a/src/libs/Authentication.ts b/src/libs/Authentication.ts index 1ab7083b2d8e..5e7b00472471 100644 --- a/src/libs/Authentication.ts +++ b/src/libs/Authentication.ts @@ -64,12 +64,8 @@ function reauthenticate(command = ''): Promise { partnerUserSecret: credentials?.autoGeneratedPassword, }).then((response) => { if (response.jsonCode === CONST.JSON_CODE.UNABLE_TO_RETRY) { - // If authentication fails, then the network can be unpaused - NetworkStore.setIsAuthenticating(false); - // When a fetch() fails due to a network issue and an error is thrown we won't log the user out. Most likely they - // have a spotty connection and will need to try to reauthenticate when they come back online. We will error so it - // can be handled by callers of reauthenticate(). + // have a spotty connection and will need to retry reauthenticate when they come back online. Error so it can be handled by the retry mechanism. throw new Error('Unable to retry Authenticate request'); } diff --git a/src/libs/Middleware/Reauthentication.ts b/src/libs/Middleware/Reauthentication.ts index a67214e04420..859dfa01697a 100644 --- a/src/libs/Middleware/Reauthentication.ts +++ b/src/libs/Middleware/Reauthentication.ts @@ -1,3 +1,4 @@ +import Onyx from 'react-native-onyx'; import redirectToSignIn from '@libs/actions/SignInRedirect'; import * as Authentication from '@libs/Authentication'; import Log from '@libs/Log'; @@ -24,6 +25,7 @@ function reauthenticate(commandName?: string): Promise { const reauthRequest = { commandName, }; + // eslint-disable-next-line rulesdir/prefer-actions-set-data Onyx.set(ONYXKEYS.REAUTHENTICATION_REQUEST, reauthRequest); isAuthenticating = retryReauthenticate(commandName) @@ -34,7 +36,8 @@ function reauthenticate(commandName?: string): Promise { throw error; }) .finally(() => { - Onyx.set(CONST.ONYXKEYS.REAUTHENTICATION_REQUEST, null); + // eslint-disable-next-line rulesdir/prefer-actions-set-data + Onyx.set(ONYXKEYS.REAUTHENTICATION_REQUEST, null); isAuthenticating = null; }); diff --git a/tests/unit/APITest.ts b/tests/unit/APITest.ts index 14c4cadcb26d..bc4b650fb6e5 100644 --- a/tests/unit/APITest.ts +++ b/tests/unit/APITest.ts @@ -9,7 +9,7 @@ import * as MainQueue from '@src/libs/Network/MainQueue'; import * as NetworkStore from '@src/libs/Network/NetworkStore'; import * as SequentialQueue from '@src/libs/Network/SequentialQueue'; import * as Request from '@src/libs/Request'; -import * as RequestThrottle from '@src/libs/RequestThrottle'; +import RequestThrottle from '@src/libs/RequestThrottle'; import ONYXKEYS from '@src/ONYXKEYS'; import type ReactNativeOnyxMock from '../../__mocks__/react-native-onyx'; import * as TestHelper from '../utils/TestHelper'; @@ -39,6 +39,7 @@ type XhrCalls = Array<{ }>; const originalXHR = HttpUtils.xhr; +const requestThrottle = new RequestThrottle(); beforeEach(() => { global.fetch = TestHelper.getGlobalFetchMock(); @@ -47,6 +48,7 @@ beforeEach(() => { MainQueue.clear(); HttpUtils.cancelPendingRequests(); PersistedRequests.clear(); + requestThrottle.clear(); NetworkStore.checkRequiredData(); // Wait for any Log command to finish and Onyx to fully clear @@ -242,7 +244,7 @@ describe('APITests', () => { // We let the SequentialQueue process again after its wait time return new Promise((resolve) => { - setTimeout(resolve, RequestThrottle.getLastRequestWaitTime()); + setTimeout(resolve, requestThrottle.getLastRequestWaitTime()); }); }) .then(() => { @@ -255,7 +257,7 @@ describe('APITests', () => { // We let the SequentialQueue process again after its wait time return new Promise((resolve) => { - setTimeout(resolve, RequestThrottle.getLastRequestWaitTime()); + setTimeout(resolve, requestThrottle.getLastRequestWaitTime()); }).then(waitForBatchedUpdates); }) .then(() => { From 2a031a04d73bbb8b9e0b421358a7a76a5fbf2720 Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Fri, 8 Nov 2024 22:04:11 +0530 Subject: [PATCH 030/346] Feature: Per Diem Rates Settings Page --- src/ROUTES.ts | 4 + src/SCREENS.ts | 1 + .../CategorySelectorModal.tsx | 0 .../CategorySelector/index.tsx | 0 src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- src/libs/API/types.ts | 2 + .../ModalStackNavigators/index.tsx | 1 + .../FULL_SCREEN_TO_RHP_MAPPING.ts | 1 + src/libs/Navigation/linkingConfig/config.ts | 3 + src/libs/Navigation/types.ts | 3 + src/libs/actions/Policy/Category.ts | 55 +++++++++ src/libs/actions/Policy/PerDiem.ts | 13 ++- .../PolicyDistanceRatesSettingsPage.tsx | 4 +- .../perDiem/WorkspacePerDiemPage.tsx | 5 +- .../perDiem/WorkspacePerDiemSettingsPage.tsx | 104 ++++++++++++++++++ 16 files changed, 192 insertions(+), 8 deletions(-) rename src/{pages/workspace/distanceRates => components}/CategorySelector/CategorySelectorModal.tsx (100%) rename src/{pages/workspace/distanceRates => components}/CategorySelector/index.tsx (100%) create mode 100644 src/pages/workspace/perDiem/WorkspacePerDiemSettingsPage.tsx diff --git a/src/ROUTES.ts b/src/ROUTES.ts index cd94035e0fff..da1f489f6164 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1277,6 +1277,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/per-diem', getRoute: (policyID: string) => `settings/workspaces/${policyID}/per-diem` as const, }, + WORKSPACE_PER_DIEM_SETTINGS: { + route: 'settings/workspaces/:policyID/per-diem/settings', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/per-diem/settings` as const, + }, RULES_CUSTOM_NAME: { route: 'settings/workspaces/:policyID/rules/name', getRoute: (policyID: string) => `settings/workspaces/${policyID}/rules/name` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 9b8fe54111cf..061903e0a876 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -543,6 +543,7 @@ const SCREENS = { RULES_MAX_EXPENSE_AGE: 'Rules_Max_Expense_Age', RULES_BILLABLE_DEFAULT: 'Rules_Billable_Default', PER_DIEM: 'Per_Diem', + PER_DIEM_SETTINGS: 'Per_Diem_Settings', }, EDIT_REQUEST: { diff --git a/src/pages/workspace/distanceRates/CategorySelector/CategorySelectorModal.tsx b/src/components/CategorySelector/CategorySelectorModal.tsx similarity index 100% rename from src/pages/workspace/distanceRates/CategorySelector/CategorySelectorModal.tsx rename to src/components/CategorySelector/CategorySelectorModal.tsx diff --git a/src/pages/workspace/distanceRates/CategorySelector/index.tsx b/src/components/CategorySelector/index.tsx similarity index 100% rename from src/pages/workspace/distanceRates/CategorySelector/index.tsx rename to src/components/CategorySelector/index.tsx diff --git a/src/languages/en.ts b/src/languages/en.ts index 221b718a7699..35a81fa89cc8 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2430,6 +2430,7 @@ const translations = { return 'Member'; } }, + defaultCategory: 'Default category', }, perDiem: { subtitle: 'Set per diem rates to control daily employee spend. ', @@ -3973,7 +3974,6 @@ const translations = { unit: 'Unit', taxFeatureNotEnabledMessage: 'Taxes must be enabled on the workspace to use this feature. Head over to ', changePromptMessage: ' to make that change.', - defaultCategory: 'Default category', deleteDistanceRate: 'Delete distance rate', areYouSureDelete: () => ({ one: 'Are you sure you want to delete this rate?', diff --git a/src/languages/es.ts b/src/languages/es.ts index 1db2cd23011e..343634b2815d 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2453,6 +2453,7 @@ const translations = { return 'Miembro'; } }, + defaultCategory: 'Categoría predeterminada', }, perDiem: { subtitle: 'Establece las tasas per diem para controlar los gastos diarios de los empleados. ', @@ -4017,7 +4018,6 @@ const translations = { unit: 'Unidad', taxFeatureNotEnabledMessage: 'Los impuestos deben estar activados en el área de trabajo para poder utilizar esta función. Dirígete a ', changePromptMessage: ' para hacer ese cambio.', - defaultCategory: 'Categoría predeterminada', deleteDistanceRate: 'Eliminar tasa de distancia', areYouSureDelete: () => ({ one: '¿Estás seguro de que quieres eliminar esta tasa?', diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index b8b4bb749701..a8847545fbb3 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -288,6 +288,7 @@ const WRITE_COMMANDS = { ADD_BILLING_CARD_AND_REQUEST_WORKSPACE_OWNER_CHANGE: 'AddBillingCardAndRequestPolicyOwnerChange', SET_POLICY_DISTANCE_RATES_UNIT: 'SetPolicyDistanceRatesUnit', SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY: 'SetPolicyDistanceRatesDefaultCategory', + SET_POLICY_PER_DIEM_RATES_DEFAULT_CATEGORY: 'SetPolicyPerDiemRatesDefaultCategory', ENABLE_DISTANCE_REQUEST_TAX: 'EnableDistanceRequestTax', UPDATE_POLICY_DISTANCE_RATE_VALUE: 'UpdatePolicyDistanceRateValue', UPDATE_POLICY_DISTANCE_TAX_RATE_VALUE: 'UpdateDistanceTaxRate', @@ -692,6 +693,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_POLICY_TAX_CODE]: Parameters.UpdatePolicyTaxCodeParams; [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_UNIT]: Parameters.SetPolicyDistanceRatesUnitParams; [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; + [WRITE_COMMANDS.SET_POLICY_PER_DIEM_RATES_DEFAULT_CATEGORY]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; [WRITE_COMMANDS.ENABLE_DISTANCE_REQUEST_TAX]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; [WRITE_COMMANDS.REPORT_EXPORT]: Parameters.ReportExportParams; [WRITE_COMMANDS.MARK_AS_EXPORTED]: Parameters.MarkAsExportedParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 8a64424c8f7d..fb8178cc0bd3 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -566,6 +566,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/RulesMaxExpenseAmountPage').default, [SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AGE]: () => require('../../../../pages/workspace/rules/RulesMaxExpenseAgePage').default, [SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT]: () => require('../../../../pages/workspace/rules/RulesBillableDefaultPage').default, + [SCREENS.WORKSPACE.PER_DIEM_SETTINGS]: () => require('../../../../pages/workspace/perDiem/WorkspacePerDiemSettingsPage').default, }); const EnablePaymentsStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts index d282bab770c6..c8309ba70ea9 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -243,6 +243,7 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.RULES_MAX_EXPENSE_AGE, SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT, ], + [SCREENS.WORKSPACE.PER_DIEM]: [SCREENS.WORKSPACE.PER_DIEM_SETTINGS], }; export default FULL_SCREEN_TO_RHP_MAPPING; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 7a5b31489764..e789c2cce4f3 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -935,6 +935,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT]: { path: ROUTES.RULES_BILLABLE_DEFAULT.route, }, + [SCREENS.WORKSPACE.PER_DIEM_SETTINGS]: { + path: ROUTES.WORKSPACE_PER_DIEM_SETTINGS.route, + }, }, }, [SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index ba859efff944..4ec8787c696a 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -896,6 +896,9 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.RULES_BILLABLE_DEFAULT]: { policyID: string; }; + [SCREENS.WORKSPACE.PER_DIEM_SETTINGS]: { + policyID: string; + }; } & ReimbursementAccountNavigatorParamList; type NewChatNavigatorParamList = { diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index dced49976c5a..63aee4bb1e46 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -1069,6 +1069,60 @@ function setPolicyDistanceRatesDefaultCategory(policyID: string, currentCustomUn API.write(WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY, params, {optimisticData, successData, failureData}); } +function setPolicyPerDiemRatesDefaultCategory(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [newCustomUnit.customUnitID]: { + ...newCustomUnit, + pendingFields: {defaultCategory: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [newCustomUnit.customUnitID]: { + pendingFields: {defaultCategory: null}, + }, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [currentCustomUnit.customUnitID]: { + ...currentCustomUnit, + errorFields: {defaultCategory: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + pendingFields: {defaultCategory: null}, + }, + }, + }, + }, + ]; + + const params: SetPolicyDistanceRatesDefaultCategoryParams = { + policyID, + customUnit: JSON.stringify(removePendingFieldsFromCustomUnit(newCustomUnit)), + }; + + API.write(WRITE_COMMANDS.SET_POLICY_PER_DIEM_RATES_DEFAULT_CATEGORY, params, {optimisticData, successData, failureData}); +} + function downloadCategoriesCSV(policyID: string, onDownloadFailed: () => void) { const finalParameters = enhanceParameters(WRITE_COMMANDS.EXPORT_CATEGORIES_CSV, { policyID, @@ -1365,6 +1419,7 @@ export { clearCategoryErrors, enablePolicyCategories, setPolicyDistanceRatesDefaultCategory, + setPolicyPerDiemRatesDefaultCategory, deleteWorkspaceCategories, buildOptimisticPolicyCategories, setPolicyCategoryReceiptsRequired, diff --git a/src/libs/actions/Policy/PerDiem.ts b/src/libs/actions/Policy/PerDiem.ts index 2ce31fd4c921..a480b1a35a9e 100644 --- a/src/libs/actions/Policy/PerDiem.ts +++ b/src/libs/actions/Policy/PerDiem.ts @@ -9,6 +9,7 @@ import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report} from '@src/types/onyx'; +import type {ErrorFields} from '@src/types/onyx/OnyxCommon'; import type {OnyxData} from '@src/types/onyx/Request'; const allPolicies: OnyxCollection = {}; @@ -119,4 +120,14 @@ function openPolicyPerDiemPage(policyID?: string) { API.read(READ_COMMANDS.OPEN_POLICY_PER_DIEM_RATES_PAGE, params); } -export {enablePerDiem, openPolicyPerDiemPage}; +function clearPolicyPerDiemRatesErrorFields(policyID: string, customUnitID: string, updatedErrorFields: ErrorFields) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + customUnits: { + [customUnitID]: { + errorFields: updatedErrorFields, + }, + }, + }); +} + +export {enablePerDiem, openPolicyPerDiemPage, clearPolicyPerDiemRatesErrorFields}; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx index eed24a4ea13f..bf8b28d2580a 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx @@ -23,12 +23,12 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import * as Category from '@userActions/Policy/Category'; import * as DistanceRate from '@userActions/Policy/DistanceRate'; import * as Policy from '@userActions/Policy/Policy'; +import CategorySelector from '@src/components/CategorySelector'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {CustomUnit} from '@src/types/onyx/Policy'; -import CategorySelector from './CategorySelector'; import UnitSelector from './UnitSelector'; type PolicyDistanceRatesSettingsPageProps = StackScreenProps; @@ -125,7 +125,7 @@ function PolicyDistanceRatesSettingsPage({route}: PolicyDistanceRatesSettingsPag > { - // TODO: Uncomment this when the import feature is ready - // Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_RATES_SETTINGS.getRoute(policyID)); + Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_SETTINGS.getRoute(policyID)); }; // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/src/pages/workspace/perDiem/WorkspacePerDiemSettingsPage.tsx b/src/pages/workspace/perDiem/WorkspacePerDiemSettingsPage.tsx new file mode 100644 index 000000000000..89a83f805e6b --- /dev/null +++ b/src/pages/workspace/perDiem/WorkspacePerDiemSettingsPage.tsx @@ -0,0 +1,104 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useState} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import type {ListItem} from '@components/SelectionList/types'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {clearPolicyPerDiemRatesErrorFields} from '@libs/actions/Policy/PerDiem'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import {getPerDiemCustomUnit} from '@libs/PolicyUtils'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as Category from '@userActions/Policy/Category'; +import CategorySelector from '@src/components/CategorySelector'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type {CustomUnit} from '@src/types/onyx/Policy'; + +type WorkspacePerDiemSettingsPageProps = StackScreenProps; + +function WorkspacePerDiemSettingsPage({route}: WorkspacePerDiemSettingsPageProps) { + const policyID = route.params.policyID; + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + + const styles = useThemeStyles(); + const [isCategoryPickerVisible, setIsCategoryPickerVisible] = useState(false); + const {translate} = useLocalize(); + const customUnit = getPerDiemCustomUnit(policy); + const customUnitID = customUnit?.customUnitID ?? ''; + + const defaultCategory = customUnit?.defaultCategory; + const errorFields = customUnit?.errorFields; + + const FullPageBlockingView = !customUnit ? FullPageOfflineBlockingView : View; + + const setNewCategory = (category: ListItem) => { + if (!category.searchText || !customUnit) { + return; + } + + Category.setPolicyPerDiemRatesDefaultCategory(policyID, customUnit, { + ...customUnit, + defaultCategory: defaultCategory === category.searchText ? '' : category.searchText, + }); + }; + + const clearErrorFields = (fieldName: keyof CustomUnit) => { + clearPolicyPerDiemRatesErrorFields(policyID, customUnitID, {...errorFields, [fieldName]: null}); + }; + + return ( + + + + + + {!!policy?.areCategoriesEnabled && OptionsListUtils.hasEnabledOptions(policyCategories ?? {}) && ( + clearErrorFields('defaultCategory')} + > + setIsCategoryPickerVisible(true)} + hidePickerModal={() => setIsCategoryPickerVisible(false)} + /> + + )} + + + + + ); +} + +WorkspacePerDiemSettingsPage.displayName = 'WorkspacePerDiemSettingsPage'; + +export default WorkspacePerDiemSettingsPage; From 92302b405790d17c3a09dde61eaba5d864cd311a Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Fri, 8 Nov 2024 22:12:35 +0530 Subject: [PATCH 031/346] Fix lint --- .../workspace/categories/WorkspaceCategoriesSettingsPage.tsx | 2 +- .../workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx | 2 +- src/pages/workspace/perDiem/WorkspacePerDiemSettingsPage.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx index aea6f596badd..12502787d9df 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx @@ -1,6 +1,7 @@ import React, {useMemo, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import CategorySelectorModal from '@components/CategorySelector/CategorySelectorModal'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; @@ -12,7 +13,6 @@ import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import CategorySelectorModal from '@pages/workspace/distanceRates/CategorySelector/CategorySelectorModal'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx index bf8b28d2580a..2d25f066023c 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx @@ -3,6 +3,7 @@ import React, {useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import CategorySelector from '@components/CategorySelector'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -23,7 +24,6 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import * as Category from '@userActions/Policy/Category'; import * as DistanceRate from '@userActions/Policy/DistanceRate'; import * as Policy from '@userActions/Policy/Policy'; -import CategorySelector from '@src/components/CategorySelector'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; diff --git a/src/pages/workspace/perDiem/WorkspacePerDiemSettingsPage.tsx b/src/pages/workspace/perDiem/WorkspacePerDiemSettingsPage.tsx index 89a83f805e6b..c620c811184c 100644 --- a/src/pages/workspace/perDiem/WorkspacePerDiemSettingsPage.tsx +++ b/src/pages/workspace/perDiem/WorkspacePerDiemSettingsPage.tsx @@ -3,6 +3,7 @@ import React, {useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import CategorySelector from '@components/CategorySelector'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -17,7 +18,6 @@ import {getPerDiemCustomUnit} from '@libs/PolicyUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import * as Category from '@userActions/Policy/Category'; -import CategorySelector from '@src/components/CategorySelector'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; From f5da09521939008c1974b8943dea06dcc75e74ed Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Fri, 8 Nov 2024 10:45:28 -0800 Subject: [PATCH 032/346] Try advancing timers past throttle --- tests/unit/NetworkTest.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/NetworkTest.ts b/tests/unit/NetworkTest.ts index ea638aab4cf7..bb2738416440 100644 --- a/tests/unit/NetworkTest.ts +++ b/tests/unit/NetworkTest.ts @@ -111,7 +111,8 @@ describe('NetworkTests', () => { expect(isOffline).toBe(false); // Advance the network request queue by 1 second so that it can realize it's back online - jest.advanceTimersByTime(CONST.NETWORK.PROCESS_REQUEST_DELAY_MS); + // And advance past the retry delay + jest.advanceTimersByTime(CONST.NETWORK.PROCESS_REQUEST_DELAY_MS + CONST.NETWORK.MAX_RANDOM_RETRY_WAIT_TIME_MS); return waitForBatchedUpdates(); }) .then(() => { From 225ad109708674a3d5722be799b5f9020fccce57 Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Fri, 8 Nov 2024 13:47:43 -0800 Subject: [PATCH 033/346] Comment out shit causing a circular dependency --- src/libs/Network/index.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/libs/Network/index.ts b/src/libs/Network/index.ts index 2a600d5d51de..668a7038e706 100644 --- a/src/libs/Network/index.ts +++ b/src/libs/Network/index.ts @@ -1,6 +1,6 @@ import Onyx from 'react-native-onyx'; import * as ActiveClientManager from '@libs/ActiveClientManager'; -import {reauthenticate} from '@libs/Middleware/Reauthentication'; +// import {reauthenticate} from '@libs/Middleware/Reauthentication'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Request} from '@src/types/onyx'; @@ -17,15 +17,15 @@ ActiveClientManager.isReady().then(() => { setInterval(MainQueue.process, CONST.NETWORK.PROCESS_REQUEST_DELAY_MS); // If a reauthentication request is set make sure it is processed - Onyx.connect({ - key: ONYXKEYS.REAUTHENTICATION_REQUEST, - callback: (request) => { - if (!request) { - return; - } - reauthenticate(request.commandName); - }, - }); + // Onyx.connect({ + // key: ONYXKEYS.REAUTHENTICATION_REQUEST, + // callback: (request) => { + // if (!request) { + // return; + // } + // // reauthenticate(request.commandName); + // }, + // }); }); /** From 117a8e8c9db9cbbf7e17fcb02c2e057efaf6a90a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Sat, 9 Nov 2024 09:50:03 +0100 Subject: [PATCH 034/346] fix test naming --- tests/unit/CategoryOptionListUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/CategoryOptionListUtils.ts b/tests/unit/CategoryOptionListUtils.ts index 21f5e6533e77..2537094511ce 100644 --- a/tests/unit/CategoryOptionListUtils.ts +++ b/tests/unit/CategoryOptionListUtils.ts @@ -4,7 +4,7 @@ import type {PolicyCategories} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; describe('CategoryOptionListUtils', () => { - it('getFilteredOptions() for categories', () => { + it('getCategoryListSections()', () => { const search = 'Food'; const emptySearch = ''; const wrongSearch = 'bla bla'; From 33c65fc54563e395ac0f67a6ef3b994626f80cfb Mon Sep 17 00:00:00 2001 From: Hans Date: Mon, 11 Nov 2024 18:21:58 +0700 Subject: [PATCH 035/346] addressing comment --- .../settings/Wallet/Card/BaseGetPhysicalCard.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx index 2b92058caedc..ee1875b14276 100644 --- a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx +++ b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx @@ -102,7 +102,7 @@ function BaseGetPhysicalCard({ const domainCards = CardUtils.getDomainCards(cardList)[domain] || []; const cardToBeIssued = domainCards.find((card) => !card?.nameValuePairs?.isVirtual && card?.state === CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED); const cardID = cardToBeIssued?.cardID.toString() ?? '-1'; - const [currentCardId, setCurrentCardId] = useState(cardID); + const [currentCardID, setCurrentCardID] = useState(cardID); const errorMessage = ErrorUtils.getLatestErrorMessageField(cardToBeIssued); useEffect(() => { @@ -137,16 +137,18 @@ function BaseGetPhysicalCard({ }, [cardList, currentRoute, domain, domainCards.length, draftValues, loginList, cardToBeIssued, privatePersonalDetails]); useEffect(() => { - if (!isConfirmation || !!cardToBeIssued || !currentCardId) { + // If that's not the confirmation route, or if there's a value for cardToBeIssued, + // It means the current card is not issued and we still need to stay on this screen. + if (!isConfirmation || !!cardToBeIssued || !currentCardID) { return; } // Form draft data needs to be erased when the flow is complete, // so that no stale data is left on Onyx FormActions.clearDraftValues(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM); - Wallet.clearPhysicalCardError(currentCardId); - Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(currentCardId.toString())); - setCurrentCardId(undefined); - }, [currentCardId, isConfirmation, cardToBeIssued]); + Wallet.clearPhysicalCardError(currentCardID); + Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(currentCardID.toString())); + setCurrentCardID(undefined); + }, [currentCardID, isConfirmation, cardToBeIssued]); const onSubmit = useCallback(() => { const updatedPrivatePersonalDetails = GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(draftValues, privatePersonalDetails); @@ -159,7 +161,7 @@ function BaseGetPhysicalCard({ const handleIssuePhysicalCard = useCallback( (validateCode: string) => { - setCurrentCardId(cardToBeIssued?.cardID.toString()); + setCurrentCardID(cardToBeIssued?.cardID.toString()); const updatedPrivatePersonalDetails = GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(draftValues, privatePersonalDetails); // If the current step of the get physical card flow is the confirmation page Wallet.requestPhysicalExpensifyCard(cardToBeIssued?.cardID ?? -1, session?.authToken ?? '', updatedPrivatePersonalDetails, validateCode); From 14652ea57b6477b87e02ffae72fd0e90c084a399 Mon Sep 17 00:00:00 2001 From: Hans Date: Tue, 12 Nov 2024 09:45:56 +0700 Subject: [PATCH 036/346] add code request status --- src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx index ee1875b14276..fa1593cf41fb 100644 --- a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx +++ b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx @@ -95,6 +95,7 @@ function BaseGetPhysicalCard({ const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); const [session] = useOnyx(ONYXKEYS.SESSION); const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); + const [validateCodeAction] = useOnyx(ONYXKEYS.VALIDATE_ACTION_CODE); const [draftValues] = useOnyx(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM_DRAFT); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [isActionCodeModalVisible, setActionCodeModalVisible] = useState(false); @@ -183,6 +184,7 @@ function BaseGetPhysicalCard({ {renderContent({onSubmit, submitButtonText, children, onValidate})} User.requestValidateCodeAction()} clearError={() => Wallet.clearPhysicalCardError(cardID)} From 982abf03c4f30b43ba29bfde446ce85f26f9a8de Mon Sep 17 00:00:00 2001 From: Hans Date: Tue, 12 Nov 2024 10:11:50 +0700 Subject: [PATCH 037/346] Update src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx Co-authored-by: Dominic <165644294+dominictb@users.noreply.github.com> --- src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx index fa1593cf41fb..16b0afcb0c6d 100644 --- a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx +++ b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx @@ -164,7 +164,6 @@ function BaseGetPhysicalCard({ (validateCode: string) => { setCurrentCardID(cardToBeIssued?.cardID.toString()); const updatedPrivatePersonalDetails = GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(draftValues, privatePersonalDetails); - // If the current step of the get physical card flow is the confirmation page Wallet.requestPhysicalExpensifyCard(cardToBeIssued?.cardID ?? -1, session?.authToken ?? '', updatedPrivatePersonalDetails, validateCode); }, [cardToBeIssued?.cardID, draftValues, session?.authToken, privatePersonalDetails], From 55c4a0c7ab8e7b92e3d8c70ecb99b6fdf87ac115 Mon Sep 17 00:00:00 2001 From: Hans Date: Tue, 12 Nov 2024 10:12:01 +0700 Subject: [PATCH 038/346] Update src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx Co-authored-by: Dominic <165644294+dominictb@users.noreply.github.com> --- src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx index 16b0afcb0c6d..bce0a8a928b6 100644 --- a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx +++ b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx @@ -138,8 +138,8 @@ function BaseGetPhysicalCard({ }, [cardList, currentRoute, domain, domainCards.length, draftValues, loginList, cardToBeIssued, privatePersonalDetails]); useEffect(() => { - // If that's not the confirmation route, or if there's a value for cardToBeIssued, - // It means the current card is not issued and we still need to stay on this screen. + // Current step of the get physical card flow should be the confirmation page; and + // Card has NOT_ACTIVATED state when successfully being issued so cardToBeIssued should be undefined if (!isConfirmation || !!cardToBeIssued || !currentCardID) { return; } From 66742e61b03f872c17483dfe469414fd6bea70cc Mon Sep 17 00:00:00 2001 From: Wildan M Date: Tue, 12 Nov 2024 12:44:34 +0700 Subject: [PATCH 039/346] Fix gesture in modal for android --- .../ValidateCodeActionModal/ValidateCodeForm/index.android.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/index.android.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/index.android.tsx index 704405f93a2c..2d36965e7412 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeForm/index.android.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/index.android.tsx @@ -1,6 +1,7 @@ import React, {forwardRef} from 'react'; import BaseValidateCodeForm from './BaseValidateCodeForm'; import type {ValidateCodeFormHandle, ValidateCodeFormProps} from './BaseValidateCodeForm'; +import { gestureHandlerRootHOC } from 'react-native-gesture-handler'; const ValidateCodeForm = forwardRef((props, ref) => ( )); -export default ValidateCodeForm; +export default gestureHandlerRootHOC(ValidateCodeForm); From 1776b6860cd5b64ae9c8acefa620524b79cadf31 Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Tue, 12 Nov 2024 11:24:39 +0530 Subject: [PATCH 040/346] Use newer command to change default category --- .../EnableDistanceRequestTaxParams.ts | 6 ++ .../SetCustomUnitDefaultCategoryParams.ts | 7 ++ ...olicyDistanceRatesDefaultCategoryParams.ts | 6 -- src/libs/API/parameters/index.ts | 3 +- src/libs/API/types.ts | 8 +- src/libs/actions/Policy/Category.ts | 81 +++---------------- .../PolicyDistanceRatesSettingsPage.tsx | 5 +- .../perDiem/WorkspacePerDiemSettingsPage.tsx | 5 +- 8 files changed, 33 insertions(+), 88 deletions(-) create mode 100644 src/libs/API/parameters/EnableDistanceRequestTaxParams.ts create mode 100644 src/libs/API/parameters/SetCustomUnitDefaultCategoryParams.ts delete mode 100644 src/libs/API/parameters/SetPolicyDistanceRatesDefaultCategoryParams.ts diff --git a/src/libs/API/parameters/EnableDistanceRequestTaxParams.ts b/src/libs/API/parameters/EnableDistanceRequestTaxParams.ts new file mode 100644 index 000000000000..0d42d9ba84b4 --- /dev/null +++ b/src/libs/API/parameters/EnableDistanceRequestTaxParams.ts @@ -0,0 +1,6 @@ +type EnableDistanceRequestTaxParams = { + policyID: string; + customUnit: string; +}; + +export default EnableDistanceRequestTaxParams; diff --git a/src/libs/API/parameters/SetCustomUnitDefaultCategoryParams.ts b/src/libs/API/parameters/SetCustomUnitDefaultCategoryParams.ts new file mode 100644 index 000000000000..53eac3110af7 --- /dev/null +++ b/src/libs/API/parameters/SetCustomUnitDefaultCategoryParams.ts @@ -0,0 +1,7 @@ +type SetCustomUnitDefaultCategoryParams = { + policyID: string; + customUnitID: string; + category: string; +}; + +export default SetCustomUnitDefaultCategoryParams; diff --git a/src/libs/API/parameters/SetPolicyDistanceRatesDefaultCategoryParams.ts b/src/libs/API/parameters/SetPolicyDistanceRatesDefaultCategoryParams.ts deleted file mode 100644 index d2d11993a172..000000000000 --- a/src/libs/API/parameters/SetPolicyDistanceRatesDefaultCategoryParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -type SetPolicyDistanceRatesDefaultCategoryParams = { - policyID: string; - customUnit: string; -}; - -export default SetPolicyDistanceRatesDefaultCategoryParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index fb5558fb0350..55714480762f 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -206,7 +206,8 @@ export type {default as EnablePolicyTaxesParams} from './EnablePolicyTaxesParams export type {default as OpenPolicyMoreFeaturesPageParams} from './OpenPolicyMoreFeaturesPageParams'; export type {default as CreatePolicyDistanceRateParams} from './CreatePolicyDistanceRateParams'; export type {default as SetPolicyDistanceRatesUnitParams} from './SetPolicyDistanceRatesUnitParams'; -export type {default as SetPolicyDistanceRatesDefaultCategoryParams} from './SetPolicyDistanceRatesDefaultCategoryParams'; +export type {default as EnableDistanceRequestTaxParams} from './EnableDistanceRequestTaxParams'; +export type {default as SetCustomUnitDefaultCategoryParams} from './SetCustomUnitDefaultCategoryParams'; export type {default as UpdatePolicyDistanceRateValueParams} from './UpdatePolicyDistanceRateValueParams'; export type {default as SetPolicyDistanceRatesEnabledParams} from './SetPolicyDistanceRatesEnabledParams'; export type {default as DeletePolicyDistanceRatesParams} from './DeletePolicyDistanceRatesParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index a8847545fbb3..d730819d876c 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -287,8 +287,7 @@ const WRITE_COMMANDS = { REQUEST_WORKSPACE_OWNER_CHANGE: 'RequestWorkspaceOwnerChange', ADD_BILLING_CARD_AND_REQUEST_WORKSPACE_OWNER_CHANGE: 'AddBillingCardAndRequestPolicyOwnerChange', SET_POLICY_DISTANCE_RATES_UNIT: 'SetPolicyDistanceRatesUnit', - SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY: 'SetPolicyDistanceRatesDefaultCategory', - SET_POLICY_PER_DIEM_RATES_DEFAULT_CATEGORY: 'SetPolicyPerDiemRatesDefaultCategory', + SET_CUSTOM_UNIT_DEFAULT_CATEGORY: 'SetCustomUnitDefaultCategory', ENABLE_DISTANCE_REQUEST_TAX: 'EnableDistanceRequestTax', UPDATE_POLICY_DISTANCE_RATE_VALUE: 'UpdatePolicyDistanceRateValue', UPDATE_POLICY_DISTANCE_TAX_RATE_VALUE: 'UpdateDistanceTaxRate', @@ -692,9 +691,8 @@ type WriteCommandParameters = { [WRITE_COMMANDS.RENAME_POLICY_TAX]: Parameters.RenamePolicyTaxParams; [WRITE_COMMANDS.UPDATE_POLICY_TAX_CODE]: Parameters.UpdatePolicyTaxCodeParams; [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_UNIT]: Parameters.SetPolicyDistanceRatesUnitParams; - [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; - [WRITE_COMMANDS.SET_POLICY_PER_DIEM_RATES_DEFAULT_CATEGORY]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; - [WRITE_COMMANDS.ENABLE_DISTANCE_REQUEST_TAX]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; + [WRITE_COMMANDS.SET_CUSTOM_UNIT_DEFAULT_CATEGORY]: Parameters.SetCustomUnitDefaultCategoryParams; + [WRITE_COMMANDS.ENABLE_DISTANCE_REQUEST_TAX]: Parameters.EnableDistanceRequestTaxParams; [WRITE_COMMANDS.REPORT_EXPORT]: Parameters.ReportExportParams; [WRITE_COMMANDS.MARK_AS_EXPORTED]: Parameters.MarkAsExportedParams; [WRITE_COMMANDS.REQUEST_EXPENSIFY_CARD_LIMIT_INCREASE]: Parameters.RequestExpensifyCardLimitIncreaseParams; diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 63aee4bb1e46..cb9a39612b09 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -13,7 +13,6 @@ import type { SetPolicyCategoryMaxAmountParams, SetPolicyCategoryReceiptsRequiredParams, SetPolicyCategoryTaxParams, - SetPolicyDistanceRatesDefaultCategoryParams, SetWorkspaceCategoryDescriptionHintParams, UpdatePolicyCategoryGLCodeParams, } from '@libs/API/parameters'; @@ -28,13 +27,13 @@ import {translateLocal} from '@libs/Localize'; import Log from '@libs/Log'; import enhanceParameters from '@libs/Network/enhanceParameters'; import * as OptionsListUtils from '@libs/OptionsListUtils'; -import {navigateWhenEnableFeature, removePendingFieldsFromCustomUnit} from '@libs/PolicyUtils'; +import {navigateWhenEnableFeature} from '@libs/PolicyUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyCategory, RecentlyUsedCategories, Report} from '@src/types/onyx'; -import type {ApprovalRule, CustomUnit, ExpenseRule} from '@src/types/onyx/Policy'; +import type {ApprovalRule, ExpenseRule} from '@src/types/onyx/Policy'; import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -1015,15 +1014,15 @@ function enablePolicyCategories(policyID: string, enabled: boolean) { } } -function setPolicyDistanceRatesDefaultCategory(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit) { +function setPolicyCustomUnitDefaultCategory(policyID: string, customUnitID: string, oldCatrgory: string | undefined, category: string) { const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { customUnits: { - [newCustomUnit.customUnitID]: { - ...newCustomUnit, + [customUnitID]: { + defaultCategory: category, pendingFields: {defaultCategory: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, }, }, @@ -1037,7 +1036,7 @@ function setPolicyDistanceRatesDefaultCategory(policyID: string, currentCustomUn key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { customUnits: { - [newCustomUnit.customUnitID]: { + [customUnitID]: { pendingFields: {defaultCategory: null}, }, }, @@ -1051,8 +1050,8 @@ function setPolicyDistanceRatesDefaultCategory(policyID: string, currentCustomUn key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { customUnits: { - [currentCustomUnit.customUnitID]: { - ...currentCustomUnit, + [customUnitID]: { + defaultCategory: oldCatrgory, errorFields: {defaultCategory: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, pendingFields: {defaultCategory: null}, }, @@ -1061,66 +1060,13 @@ function setPolicyDistanceRatesDefaultCategory(policyID: string, currentCustomUn }, ]; - const params: SetPolicyDistanceRatesDefaultCategoryParams = { + const params = { policyID, - customUnit: JSON.stringify(removePendingFieldsFromCustomUnit(newCustomUnit)), + customUnitID, + category, }; - API.write(WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY, params, {optimisticData, successData, failureData}); -} - -function setPolicyPerDiemRatesDefaultCategory(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit) { - const optimisticData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [newCustomUnit.customUnitID]: { - ...newCustomUnit, - pendingFields: {defaultCategory: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, - }, - }, - }, - }, - ]; - - const successData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [newCustomUnit.customUnitID]: { - pendingFields: {defaultCategory: null}, - }, - }, - }, - }, - ]; - - const failureData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [currentCustomUnit.customUnitID]: { - ...currentCustomUnit, - errorFields: {defaultCategory: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, - pendingFields: {defaultCategory: null}, - }, - }, - }, - }, - ]; - - const params: SetPolicyDistanceRatesDefaultCategoryParams = { - policyID, - customUnit: JSON.stringify(removePendingFieldsFromCustomUnit(newCustomUnit)), - }; - - API.write(WRITE_COMMANDS.SET_POLICY_PER_DIEM_RATES_DEFAULT_CATEGORY, params, {optimisticData, successData, failureData}); + API.write(WRITE_COMMANDS.SET_CUSTOM_UNIT_DEFAULT_CATEGORY, params, {optimisticData, successData, failureData}); } function downloadCategoriesCSV(policyID: string, onDownloadFailed: () => void) { @@ -1418,8 +1364,7 @@ export { setPolicyCategoryGLCode, clearCategoryErrors, enablePolicyCategories, - setPolicyDistanceRatesDefaultCategory, - setPolicyPerDiemRatesDefaultCategory, + setPolicyCustomUnitDefaultCategory, deleteWorkspaceCategories, buildOptimisticPolicyCategories, setPolicyCategoryReceiptsRequired, diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx index 2d25f066023c..d971b42b45eb 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx @@ -65,10 +65,7 @@ function PolicyDistanceRatesSettingsPage({route}: PolicyDistanceRatesSettingsPag return; } - Category.setPolicyDistanceRatesDefaultCategory(policyID, customUnit, { - ...customUnit, - defaultCategory: defaultCategory === category.searchText ? '' : category.searchText, - }); + Category.setPolicyCustomUnitDefaultCategory(policyID, customUnitID, customUnit.defaultCategory, defaultCategory === category.searchText ? '' : category.searchText); }; const clearErrorFields = (fieldName: keyof CustomUnit) => { diff --git a/src/pages/workspace/perDiem/WorkspacePerDiemSettingsPage.tsx b/src/pages/workspace/perDiem/WorkspacePerDiemSettingsPage.tsx index c620c811184c..4ac57ed296b5 100644 --- a/src/pages/workspace/perDiem/WorkspacePerDiemSettingsPage.tsx +++ b/src/pages/workspace/perDiem/WorkspacePerDiemSettingsPage.tsx @@ -46,10 +46,7 @@ function WorkspacePerDiemSettingsPage({route}: WorkspacePerDiemSettingsPageProps return; } - Category.setPolicyPerDiemRatesDefaultCategory(policyID, customUnit, { - ...customUnit, - defaultCategory: defaultCategory === category.searchText ? '' : category.searchText, - }); + Category.setPolicyCustomUnitDefaultCategory(policyID, customUnitID, customUnit.defaultCategory, defaultCategory === category.searchText ? '' : category.searchText); }; const clearErrorFields = (fieldName: keyof CustomUnit) => { From c9bcc679361382b9ba6c30fdd26a14828aa09ad7 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Tue, 12 Nov 2024 14:28:07 +0800 Subject: [PATCH 041/346] fix go back/forward gesture doesn't work --- web/index.html | 1 - 1 file changed, 1 deletion(-) diff --git a/web/index.html b/web/index.html index c15f79b428a7..aad82434c342 100644 --- a/web/index.html +++ b/web/index.html @@ -44,7 +44,6 @@ } body { overflow: hidden; - overscroll-behavior: none; touch-action: none; } [data-drag-area='true'] { From 6f13505285eec8c15338139d2d4bd66e15669e0a Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Tue, 12 Nov 2024 14:47:37 +0100 Subject: [PATCH 042/346] feat: edge-to-edge mode on Android --- android/app/src/main/res/values/styles.xml | 2 + ...ontroller+1.14.1+001+disable-android.patch | 62 -------- src/App.tsx | 2 +- .../KeyboardAvoidingView/index.android.tsx | 132 ++++++++++++++++++ src/components/KeyboardAvoidingView/index.tsx | 12 +- src/components/KeyboardProvider/index.tsx | 15 ++ 6 files changed, 153 insertions(+), 72 deletions(-) delete mode 100644 patches/react-native-keyboard-controller+1.14.1+001+disable-android.patch create mode 100644 src/components/KeyboardAvoidingView/index.android.tsx create mode 100644 src/components/KeyboardProvider/index.tsx diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 75126afbd407..610524fb130f 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -7,6 +7,7 @@ diff --git a/patches/react-native-keyboard-controller+1.14.1+001+disable-android.patch b/patches/react-native-keyboard-controller+1.14.1+001+disable-android.patch deleted file mode 100644 index 6bb62155a98c..000000000000 --- a/patches/react-native-keyboard-controller+1.14.1+001+disable-android.patch +++ /dev/null @@ -1,62 +0,0 @@ -diff --git a/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt b/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt -index 7ef8b36..f4d44ff 100644 ---- a/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt -+++ b/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt -@@ -74,7 +74,7 @@ class EdgeToEdgeReactViewGroup( - } - - override fun onConfigurationChanged(newConfig: Configuration?) { -- this.reApplyWindowInsets() -+ // this.reApplyWindowInsets() - } - // endregion - -@@ -124,12 +124,12 @@ class EdgeToEdgeReactViewGroup( - } - - private fun goToEdgeToEdge(edgeToEdge: Boolean) { -- reactContext.currentActivity?.let { -- WindowCompat.setDecorFitsSystemWindows( -- it.window, -- !edgeToEdge, -- ) -- } -+ // reactContext.currentActivity?.let { -+ // WindowCompat.setDecorFitsSystemWindows( -+ // it.window, -+ // !edgeToEdge, -+ // ) -+ // } - } - - private fun setupKeyboardCallbacks() { -@@ -182,16 +182,16 @@ class EdgeToEdgeReactViewGroup( - // region State managers - private fun enable() { - this.goToEdgeToEdge(true) -- this.setupWindowInsets() -+ // this.setupWindowInsets() - this.setupKeyboardCallbacks() -- modalAttachedWatcher.enable() -+ // modalAttachedWatcher.enable() - } - - private fun disable() { - this.goToEdgeToEdge(false) -- this.setupWindowInsets() -+ // this.setupWindowInsets() - this.removeKeyboardCallbacks() -- modalAttachedWatcher.disable() -+ // modalAttachedWatcher.disable() - } - // endregion - -@@ -219,7 +219,7 @@ class EdgeToEdgeReactViewGroup( - fun forceStatusBarTranslucent(isStatusBarTranslucent: Boolean) { - if (active && this.isStatusBarTranslucent != isStatusBarTranslucent) { - this.isStatusBarTranslucent = isStatusBarTranslucent -- this.reApplyWindowInsets() -+ // this.reApplyWindowInsets() - } - } - // endregion diff --git a/src/App.tsx b/src/App.tsx index 643e2146e501..52904e0a06c4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,6 @@ import {PortalProvider} from '@gorhom/portal'; import React from 'react'; import {LogBox} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; -import {KeyboardProvider} from 'react-native-keyboard-controller'; import {PickerStateProvider} from 'react-native-picker-select'; import {SafeAreaProvider} from 'react-native-safe-area-context'; import '../wdyr'; @@ -15,6 +14,7 @@ import CustomStatusBarAndBackgroundContextProvider from './components/CustomStat import ErrorBoundary from './components/ErrorBoundary'; import HTMLEngineProvider from './components/HTMLEngineProvider'; import InitialURLContextProvider from './components/InitialURLContextProvider'; +import KeyboardProvider from './components/KeyboardProvider'; import {LocaleContextProvider} from './components/LocaleContextProvider'; import OnyxProvider from './components/OnyxProvider'; import PopoverContextProvider from './components/PopoverProvider'; diff --git a/src/components/KeyboardAvoidingView/index.android.tsx b/src/components/KeyboardAvoidingView/index.android.tsx new file mode 100644 index 000000000000..6330fecba9fe --- /dev/null +++ b/src/components/KeyboardAvoidingView/index.android.tsx @@ -0,0 +1,132 @@ +import React, {forwardRef, useCallback, useMemo, useState} from 'react'; +import type {LayoutRectangle, View, ViewProps} from 'react-native'; +import {useKeyboardContext, useKeyboardHandler} from 'react-native-keyboard-controller'; +import Reanimated, {interpolate, runOnUI, useAnimatedStyle, useDerivedValue, useSharedValue} from 'react-native-reanimated'; +import {useSafeAreaFrame} from 'react-native-safe-area-context'; +import type {KeyboardAvoidingViewProps} from './types'; + +const useKeyboardAnimation = () => { + const {reanimated} = useKeyboardContext(); + + // calculate it only once on mount, to avoid `SharedValue` reads during a render + const [initialHeight] = useState(() => -reanimated.height.value); + const [initialProgress] = useState(() => reanimated.progress.value); + + const heightWhenOpened = useSharedValue(initialHeight); + const height = useSharedValue(initialHeight); + const progress = useSharedValue(initialProgress); + const isClosed = useSharedValue(initialProgress === 0); + + useKeyboardHandler( + { + onStart: (e) => { + 'worklet'; + + progress.value = e.progress; + height.value = e.height; + + if (e.height > 0) { + // eslint-disable-next-line react-compiler/react-compiler + isClosed.value = false; + heightWhenOpened.value = e.height; + } + }, + onEnd: (e) => { + 'worklet'; + + isClosed.value = e.height === 0; + + height.value = e.height; + progress.value = e.progress; + }, + }, + [], + ); + + return {height, progress, heightWhenOpened, isClosed}; +}; + +const defaultLayout: LayoutRectangle = { + x: 0, + y: 0, + width: 0, + height: 0, +}; + +/** + * View that moves out of the way when the keyboard appears by automatically + * adjusting its height, position, or bottom padding. + * + * This `KeyboardAvoidingView` acts as a backward compatible layer for the previous Android behavior (prior to edge-to-edge mode). + * We can use `KeyboardAvoidingView` directly from the `react-native-keyboard-controller` package, but in this case animations are stuttering and it's better to handle as a separate task. + */ +const KeyboardAvoidingView = forwardRef>( + ({behavior, children, contentContainerStyle, enabled = true, keyboardVerticalOffset = 0, style, onLayout: onLayoutProps, ...props}, ref) => { + const initialFrame = useSharedValue(null); + const frame = useDerivedValue(() => initialFrame.value ?? defaultLayout); + + const keyboard = useKeyboardAnimation(); + const {height: screenHeight} = useSafeAreaFrame(); + + const relativeKeyboardHeight = useCallback(() => { + 'worklet'; + + const keyboardY = screenHeight - keyboard.heightWhenOpened.value - keyboardVerticalOffset; + + return Math.max(frame.value.y + frame.value.height - keyboardY, 0); + }, [screenHeight, keyboardVerticalOffset]); + + const onLayoutWorklet = useCallback((layout: LayoutRectangle) => { + 'worklet'; + + if (keyboard.isClosed.value || initialFrame.value === null) { + // eslint-disable-next-line react-compiler/react-compiler + initialFrame.value = layout; + } + }, []); + const onLayout = useCallback>( + (e) => { + runOnUI(onLayoutWorklet)(e.nativeEvent.layout); + onLayoutProps?.(e); + }, + [onLayoutProps], + ); + + const animatedStyle = useAnimatedStyle(() => { + const bottom = interpolate(keyboard.progress.value, [0, 1], [0, relativeKeyboardHeight()]); + const bottomHeight = enabled ? bottom : 0; + + switch (behavior) { + case 'height': + if (!keyboard.isClosed.value) { + return { + height: frame.value.height - bottomHeight, + flex: 0, + }; + } + + return {}; + + case 'padding': + return {paddingBottom: bottomHeight}; + + default: + return {}; + } + }, [behavior, enabled, relativeKeyboardHeight]); + const combinedStyles = useMemo(() => [style, animatedStyle], [style, animatedStyle]); + + return ( + + {children} + + ); + }, +); + +export default KeyboardAvoidingView; diff --git a/src/components/KeyboardAvoidingView/index.tsx b/src/components/KeyboardAvoidingView/index.tsx index c0882ae1e9cc..4a24d6cab618 100644 --- a/src/components/KeyboardAvoidingView/index.tsx +++ b/src/components/KeyboardAvoidingView/index.tsx @@ -1,16 +1,10 @@ -/* - * The KeyboardAvoidingView is only used on ios - */ import React from 'react'; -import {View} from 'react-native'; +import {KeyboardAvoidingView as KeyboardAvoidingViewComponent} from 'react-native-keyboard-controller'; import type {KeyboardAvoidingViewProps} from './types'; function KeyboardAvoidingView(props: KeyboardAvoidingViewProps) { - const {behavior, contentContainerStyle, enabled, keyboardVerticalOffset, ...rest} = props; - return ( - // eslint-disable-next-line react/jsx-props-no-spreading - - ); + // eslint-disable-next-line react/jsx-props-no-spreading + return ; } KeyboardAvoidingView.displayName = 'KeyboardAvoidingView'; diff --git a/src/components/KeyboardProvider/index.tsx b/src/components/KeyboardProvider/index.tsx new file mode 100644 index 000000000000..bf2b003abea3 --- /dev/null +++ b/src/components/KeyboardProvider/index.tsx @@ -0,0 +1,15 @@ +import type {PropsWithChildren} from 'react'; +import {KeyboardProvider} from 'react-native-keyboard-controller'; + +function KeyboardProviderWrapper({children}: PropsWithChildren>) { + return ( + + {children} + + ); +} + +export default KeyboardProviderWrapper; From 70cfe6f67f65915a93a428d4b8144ee72d5fc4f4 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Tue, 12 Nov 2024 16:40:56 +0100 Subject: [PATCH 043/346] fix: proper kav implementation for remaining platforms --- src/components/KeyboardAvoidingView/index.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/KeyboardAvoidingView/index.tsx b/src/components/KeyboardAvoidingView/index.tsx index 4a24d6cab618..170f1bed9978 100644 --- a/src/components/KeyboardAvoidingView/index.tsx +++ b/src/components/KeyboardAvoidingView/index.tsx @@ -1,12 +1,18 @@ +/* + * The KeyboardAvoidingView stub implementation for web and other platforms where the keyboard handling is handled automatically. + */ import React from 'react'; -import {KeyboardAvoidingView as KeyboardAvoidingViewComponent} from 'react-native-keyboard-controller'; +import {View} from 'react-native'; import type {KeyboardAvoidingViewProps} from './types'; function KeyboardAvoidingView(props: KeyboardAvoidingViewProps) { - // eslint-disable-next-line react/jsx-props-no-spreading - return ; + const {behavior, contentContainerStyle, enabled, keyboardVerticalOffset, ...rest} = props; + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ); } KeyboardAvoidingView.displayName = 'KeyboardAvoidingView'; -export default KeyboardAvoidingView; +export default KeyboardAvoidingView; \ No newline at end of file From c6cb0b3d7f66aef63d1fea6fef5e9c8462357d67 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Tue, 12 Nov 2024 16:43:28 +0100 Subject: [PATCH 044/346] fix: eslint --- src/components/KeyboardAvoidingView/index.android.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/KeyboardAvoidingView/index.android.tsx b/src/components/KeyboardAvoidingView/index.android.tsx index 6330fecba9fe..e8eb79d18bbd 100644 --- a/src/components/KeyboardAvoidingView/index.android.tsx +++ b/src/components/KeyboardAvoidingView/index.android.tsx @@ -74,6 +74,7 @@ const KeyboardAvoidingView = forwardRef { @@ -83,13 +84,14 @@ const KeyboardAvoidingView = forwardRef>( (e) => { runOnUI(onLayoutWorklet)(e.nativeEvent.layout); onLayoutProps?.(e); }, - [onLayoutProps], + [onLayoutProps, onLayoutWorklet], ); const animatedStyle = useAnimatedStyle(() => { @@ -121,6 +123,7 @@ const KeyboardAvoidingView = forwardRef {children} From 4cb3e3ea5fb73755f9530ddc1c4c626d6a4d07e9 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Tue, 12 Nov 2024 17:09:02 +0100 Subject: [PATCH 045/346] fix: prettier --- src/components/KeyboardAvoidingView/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/KeyboardAvoidingView/index.tsx b/src/components/KeyboardAvoidingView/index.tsx index 170f1bed9978..56a3e644fbfc 100644 --- a/src/components/KeyboardAvoidingView/index.tsx +++ b/src/components/KeyboardAvoidingView/index.tsx @@ -15,4 +15,4 @@ function KeyboardAvoidingView(props: KeyboardAvoidingViewProps) { KeyboardAvoidingView.displayName = 'KeyboardAvoidingView'; -export default KeyboardAvoidingView; \ No newline at end of file +export default KeyboardAvoidingView; From 6c69ab83991f4f4b610bf14670402ca358e61e59 Mon Sep 17 00:00:00 2001 From: Vit Horacek Date: Wed, 13 Nov 2024 01:08:47 +0100 Subject: [PATCH 046/346] Update the reviewer and author checklist to include unit tests and update the proposal template --- .github/PULL_REQUEST_TEMPLATE.md | 3 ++- contributingGuides/PROPOSAL_TEMPLATE.md | 3 +++ contributingGuides/REVIEWER_CHECKLIST.md | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 459a780ca8b4..917ca7fff142 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -94,7 +94,7 @@ This is a checklist for PR authors. Please make sure to complete all tasks and c - [ ] I followed the guidelines as stated in the [Review Guidelines](https://github.com/Expensify/App/blob/main/contributingGuides/PR_REVIEW_GUIDELINES.md) - [ ] I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like `Avatar`, I verified the components using `Avatar` are working as expected) - [ ] I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests) -- [ ] I verified any variables that can be defined as constants (ie. in CONST.js or at the top of the file that uses the constant) are defined as such +- [ ] I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such - [ ] I verified that if a function's arguments changed that all usages have also been updated correctly - [ ] If any new file was added I verified that: - [ ] The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory @@ -109,6 +109,7 @@ This is a checklist for PR authors. Please make sure to complete all tasks and c - [ ] I verified that all the inputs inside a form are aligned with each other. - [ ] I added `Design` label and/or tagged `@Expensify/design` so the design team can review the changes. - [ ] If a new page is added, I verified it's using the `ScrollView` component to make it scrollable when more elements are added to the page. +- [ ] I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow. - [ ] If the `main` branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the `Test` steps. ### Screenshots/Videos diff --git a/contributingGuides/PROPOSAL_TEMPLATE.md b/contributingGuides/PROPOSAL_TEMPLATE.md index 8c9fa7968fe2..437355448269 100644 --- a/contributingGuides/PROPOSAL_TEMPLATE.md +++ b/contributingGuides/PROPOSAL_TEMPLATE.md @@ -7,6 +7,9 @@ ### What changes do you think we should make in order to solve the problem? +### What specific scenarios should we cover in unit tests to prevent reintroducing this issue in the future? + + ### What alternative solutions did you explore? (Optional) **Reminder:** Please use plain English, be brief and avoid jargon. Feel free to use images, charts or pseudo-code if necessary. Do not post large multi-line diffs or write walls of text. Do not create PRs unless you have been hired for this job. diff --git a/contributingGuides/REVIEWER_CHECKLIST.md b/contributingGuides/REVIEWER_CHECKLIST.md index 5fc14328f3b4..baee49e27156 100644 --- a/contributingGuides/REVIEWER_CHECKLIST.md +++ b/contributingGuides/REVIEWER_CHECKLIST.md @@ -30,7 +30,7 @@ - [ ] I verified that this PR follows the guidelines as stated in the [Review Guidelines](https://github.com/Expensify/App/blob/main/contributingGuides/PR_REVIEW_GUIDELINES.md) - [ ] I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like `Avatar`, I verified the components using `Avatar` have been tested & I retested again) - [ ] I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests) -- [ ] I verified any variables that can be defined as constants (ie. in CONST.js or at the top of the file that uses the constant) are defined as such +- [ ] I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such - [ ] If a new component is created I verified that: - [ ] A similar component doesn't exist in the codebase - [ ] All props are defined accurately and each prop has a `/** comment above it */` @@ -54,6 +54,7 @@ - [ ] I verified that all the inputs inside a form are aligned with each other. - [ ] I added `Design` label and/or tagged `@Expensify/design` so the design team can review the changes. - [ ] If a new page is added, I verified it's using the `ScrollView` component to make it scrollable when more elements are added to the page. +- [ ] For any bug fix or new feature in this PR, I verified that sufficient unit tests are included to prevent regressions in this flow. - [ ] If the `main` branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the `Test` steps. - [ ] I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR. From f1a930d0687272601b2cb0e7c74cde6923c096a4 Mon Sep 17 00:00:00 2001 From: Vit Horacek Date: Wed, 13 Nov 2024 01:13:26 +0100 Subject: [PATCH 047/346] Update the template --- contributingGuides/PROPOSAL_TEMPLATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contributingGuides/PROPOSAL_TEMPLATE.md b/contributingGuides/PROPOSAL_TEMPLATE.md index 437355448269..aee4aa5d22e5 100644 --- a/contributingGuides/PROPOSAL_TEMPLATE.md +++ b/contributingGuides/PROPOSAL_TEMPLATE.md @@ -8,7 +8,7 @@ ### What specific scenarios should we cover in unit tests to prevent reintroducing this issue in the future? - + ### What alternative solutions did you explore? (Optional) From c40fae897fb4246181efa7c31e23beff6d4d4018 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Tue, 12 Nov 2024 17:55:51 -0700 Subject: [PATCH 048/346] Revert "[CP Staging] Revert "Show search action buttons"" --- src/CONST.ts | 3 + src/components/Search/SearchPageHeader.tsx | 2 +- src/components/Search/types.ts | 8 ++ .../SelectionList/Search/ActionCell.tsx | 43 +++++--- .../Search/ExpenseItemHeaderNarrow.tsx | 3 + .../SelectionList/Search/ReportListItem.tsx | 7 +- .../Search/TransactionListItem.tsx | 6 +- .../Search/TransactionListItemRow.tsx | 4 + .../SelectionList/SearchTableHeaderColumn.tsx | 0 .../PayMoneyRequestOnSearchParams.ts | 5 +- src/libs/PolicyUtils.ts | 11 +- src/libs/ReportUtils.ts | 30 ++--- src/libs/SearchUIUtils.ts | 65 ++++++++++- src/libs/TransactionUtils/index.ts | 3 +- src/libs/actions/IOU.ts | 27 +++-- src/libs/actions/Search.ts | 78 +++++++++++-- src/types/onyx/Report.ts | 2 +- src/types/onyx/SearchResults.ts | 104 +++++++++++++++++- 18 files changed, 334 insertions(+), 67 deletions(-) delete mode 100644 src/components/SelectionList/SearchTableHeaderColumn.tsx diff --git a/src/CONST.ts b/src/CONST.ts index 4e873163cc95..056151742c25 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5848,6 +5848,9 @@ const CONST = { ACTION_TYPES: { VIEW: 'view', REVIEW: 'review', + SUBMIT: 'submit', + APPROVE: 'approve', + PAY: 'pay', DONE: 'done', PAID: 'paid', }, diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index a330be3d5ff6..d73937aeadd9 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -182,7 +182,7 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) { return; } - const reportIDList = (selectedReports?.filter((report) => !!report) as string[]) ?? []; + const reportIDList = selectedReports?.filter((report) => !!report) ?? []; SearchActions.exportSearchItemsToCSV( {query: status, jsonQuery: JSON.stringify(queryJSON), reportIDList, transactionIDList: selectedTransactionsKeys, policyIDs: [activeWorkspaceID ?? '']}, () => { diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 74bf7b16d020..130ad7ae6f6e 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -24,6 +24,13 @@ type SelectedTransactionInfo = { /** Model of selected results */ type SelectedTransactions = Record; +/** Model of payment data used by Search bulk actions */ +type PaymentData = { + reportID: string; + amount: number; + paymentType: ValueOf; +}; + type SortOrder = ValueOf; type SearchColumnType = ValueOf; type ExpenseSearchStatus = ValueOf; @@ -117,5 +124,6 @@ export type { TripSearchStatus, ChatSearchStatus, SearchAutocompleteResult, + PaymentData, SearchAutocompleteQueryRange, }; diff --git a/src/components/SelectionList/Search/ActionCell.tsx b/src/components/SelectionList/Search/ActionCell.tsx index faafa6159dc1..0a360a96e7c7 100644 --- a/src/components/SelectionList/Search/ActionCell.tsx +++ b/src/components/SelectionList/Search/ActionCell.tsx @@ -4,6 +4,7 @@ import Badge from '@components/Badge'; import Button from '@components/Button'; import * as Expensicons from '@components/Icon/Expensicons'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -15,6 +16,9 @@ import type {SearchTransactionAction} from '@src/types/onyx/SearchResults'; const actionTranslationsMap: Record = { view: 'common.view', review: 'common.review', + submit: 'common.submit', + approve: 'iou.approve', + pay: 'iou.pay', done: 'common.done', paid: 'iou.settledExpensify', }; @@ -26,13 +30,23 @@ type ActionCellProps = { goToItem: () => void; isChildListItem?: boolean; parentAction?: string; + isLoading?: boolean; }; -function ActionCell({action = CONST.SEARCH.ACTION_TYPES.VIEW, isLargeScreenWidth = true, isSelected = false, goToItem, isChildListItem = false, parentAction = ''}: ActionCellProps) { +function ActionCell({ + action = CONST.SEARCH.ACTION_TYPES.VIEW, + isLargeScreenWidth = true, + isSelected = false, + goToItem, + isChildListItem = false, + parentAction = '', + isLoading = false, +}: ActionCellProps) { const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const {isOffline} = useNetwork(); const text = translate(actionTranslationsMap[action]); @@ -61,9 +75,8 @@ function ActionCell({action = CONST.SEARCH.ACTION_TYPES.VIEW, isLargeScreenWidth ); } - const buttonInnerStyles = isSelected ? styles.buttonDefaultHovered : {}; - if (action === CONST.SEARCH.ACTION_TYPES.VIEW || shouldUseViewAction) { + const buttonInnerStyles = isSelected ? styles.buttonDefaultHovered : {}; return isLargeScreenWidth ? (