From 4a5d40bc232ef142310219c96a4bada1860f23eb Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 11 Sep 2024 22:09:10 +0530 Subject: [PATCH 001/312] fix: Remove GBR from the account settings on initial signup. Signed-off-by: krishna2323 --- src/libs/UserUtils.ts | 13 ++++++++++++- .../Profile/Contacts/ContactMethodsPage.tsx | 4 ++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/libs/UserUtils.ts b/src/libs/UserUtils.ts index c5291a2864d7..f2fa9559d0ce 100644 --- a/src/libs/UserUtils.ts +++ b/src/libs/UserUtils.ts @@ -1,9 +1,11 @@ import {Str} from 'expensify-common'; import type {OnyxEntry} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import * as defaultAvatars from '@components/Icon/DefaultAvatars'; import {ConciergeAvatar, NotificationsAvatar} from '@components/Icon/Expensicons'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {LoginList} from '@src/types/onyx'; import type Login from '@src/types/onyx/Login'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -15,6 +17,14 @@ type AvatarSource = IconAsset | string; type LoginListIndicator = ValueOf | undefined; +let currentUserLogin: string | undefined; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (value) => { + currentUserLogin = value?.email; + }, +}); + /** * Searches through given loginList for any contact method / login with an error. * @@ -46,7 +56,8 @@ function hasLoginListError(loginList: OnyxEntry): boolean { * has an unvalidated contact method. */ function hasLoginListInfo(loginList: OnyxEntry): boolean { - return !Object.values(loginList ?? {}).every((field) => field.validatedDate); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return !Object.values(loginList ?? {}).every((field) => field.validatedDate || currentUserLogin === field.partnerUserID); } /** diff --git a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx index 44c7a9b567cf..27d1b0b6520c 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx +++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx @@ -43,9 +43,9 @@ function ContactMethodsPage({loginList, session, route}: ContactMethodsPageProps // Sort the login names by placing the one corresponding to the default contact method as the first item before displaying the contact methods. // The default contact method is determined by checking against the session email (the current login). const sortedLoginNames = loginNames.sort((loginName) => (loginList?.[loginName].partnerUserID === session?.email ? -1 : 1)); - const loginMenuItems = sortedLoginNames.map((loginName) => { const login = loginList?.[loginName]; + const isDefaultContactMethod = session?.email === login?.partnerUserID; const pendingAction = login?.pendingFields?.deletedLogin ?? login?.pendingFields?.addedLogin ?? undefined; if (!login?.partnerUserID && !pendingAction) { return null; @@ -62,7 +62,7 @@ function ContactMethodsPage({loginList, session, route}: ContactMethodsPageProps let indicator; if (Object.values(login?.errorFields ?? {}).some((errorField) => !isEmptyObject(errorField))) { indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; - } else if (!login?.validatedDate) { + } else if (!login?.validatedDate && !isDefaultContactMethod && sortedLoginNames.length === 1) { indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; } From 4d3f3c8486bda1c32b68a2bda7b510acd19b551a Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 11 Sep 2024 22:24:24 +0530 Subject: [PATCH 002/312] minor fix. Signed-off-by: krishna2323 --- src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx index 27d1b0b6520c..00c58f6c1279 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx +++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx @@ -62,7 +62,9 @@ function ContactMethodsPage({loginList, session, route}: ContactMethodsPageProps let indicator; if (Object.values(login?.errorFields ?? {}).some((errorField) => !isEmptyObject(errorField))) { indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; - } else if (!login?.validatedDate && !isDefaultContactMethod && sortedLoginNames.length === 1) { + } else if (!login?.validatedDate && !isDefaultContactMethod) { + indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; + } else if (!login?.validatedDate && isDefaultContactMethod && sortedLoginNames.length > 1) { indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; } From 56d75cad283c39040ecc25ac52f84f418e39ef2e Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 12 Sep 2024 01:33:08 +0530 Subject: [PATCH 003/312] filter out logins without partnerUserID. Signed-off-by: krishna2323 --- src/libs/UserUtils.ts | 7 ++++++- .../Profile/Contacts/ContactMethodsPage.tsx | 13 +++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/libs/UserUtils.ts b/src/libs/UserUtils.ts index f2fa9559d0ce..25ff32eab1ca 100644 --- a/src/libs/UserUtils.ts +++ b/src/libs/UserUtils.ts @@ -56,8 +56,13 @@ function hasLoginListError(loginList: OnyxEntry): boolean { * has an unvalidated contact method. */ function hasLoginListInfo(loginList: OnyxEntry): boolean { + const filteredLoginList = Object.values(loginList ?? {}).filter((login) => { + const pendingAction = login?.pendingFields?.deletedLogin ?? login?.pendingFields?.addedLogin ?? undefined; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return login.partnerUserID || pendingAction; + }); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return !Object.values(loginList ?? {}).every((field) => field.validatedDate || currentUserLogin === field.partnerUserID); + return !Object.values(filteredLoginList ?? {}).every((field) => field.validatedDate || currentUserLogin === field.partnerUserID); } /** diff --git a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx index 00c58f6c1279..55731a28e156 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx +++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx @@ -40,16 +40,21 @@ function ContactMethodsPage({loginList, session, route}: ContactMethodsPageProps const loginNames = Object.keys(loginList ?? {}); const navigateBackTo = route?.params?.backTo; + const filteredLoginNames = loginNames.filter((loginName) => { + const login = loginList?.[loginName]; + const pendingAction = login?.pendingFields?.deletedLogin ?? login?.pendingFields?.addedLogin ?? undefined; + + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return login?.partnerUserID || pendingAction; + }); + // Sort the login names by placing the one corresponding to the default contact method as the first item before displaying the contact methods. // The default contact method is determined by checking against the session email (the current login). - const sortedLoginNames = loginNames.sort((loginName) => (loginList?.[loginName].partnerUserID === session?.email ? -1 : 1)); + const sortedLoginNames = filteredLoginNames.sort((loginName) => (loginList?.[loginName].partnerUserID === session?.email ? -1 : 1)); const loginMenuItems = sortedLoginNames.map((loginName) => { const login = loginList?.[loginName]; const isDefaultContactMethod = session?.email === login?.partnerUserID; const pendingAction = login?.pendingFields?.deletedLogin ?? login?.pendingFields?.addedLogin ?? undefined; - if (!login?.partnerUserID && !pendingAction) { - return null; - } let description = ''; if (session?.email === login?.partnerUserID) { From cc90673e8d6f62a2396da4c95d8c7ba33829c963 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Fri, 13 Sep 2024 17:47:02 +0200 Subject: [PATCH 004/312] Initial work on the configs to allow hot reloading on web --- config/webpack/webpack.common.ts | 4 ++-- config/webpack/webpack.dev.ts | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/config/webpack/webpack.common.ts b/config/webpack/webpack.common.ts index 33fd9131eca0..b6f48f931c85 100644 --- a/config/webpack/webpack.common.ts +++ b/config/webpack/webpack.common.ts @@ -8,7 +8,7 @@ import type {Class} from 'type-fest'; import type {Configuration, WebpackPluginInstance} from 'webpack'; import {DefinePlugin, EnvironmentPlugin, IgnorePlugin, ProvidePlugin} from 'webpack'; import {BundleAnalyzerPlugin} from 'webpack-bundle-analyzer'; -import CustomVersionFilePlugin from './CustomVersionFilePlugin'; +// import CustomVersionFilePlugin from './CustomVersionFilePlugin'; import type Environment from './types'; type Options = { @@ -128,7 +128,7 @@ const getCommonConfiguration = ({file = '.env', platform = 'web'}: Environment): resourceRegExp: /^\.\/locale$/, contextRegExp: /moment$/, }), - ...(platform === 'web' ? [new CustomVersionFilePlugin()] : []), + // ...(platform === 'web' ? [new CustomVersionFilePlugin()] : []), new DefinePlugin({ ...(platform === 'desktop' ? {} : {process: {env: {}}}), // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/config/webpack/webpack.dev.ts b/config/webpack/webpack.dev.ts index 80813adc1e3a..d6607ecf9b59 100644 --- a/config/webpack/webpack.dev.ts +++ b/config/webpack/webpack.dev.ts @@ -1,3 +1,4 @@ +import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'; import path from 'path'; import portfinder from 'portfinder'; import {TimeAnalyticsPlugin} from 'time-analytics-webpack-plugin'; @@ -8,6 +9,7 @@ import {merge} from 'webpack-merge'; import type Environment from './types'; import getCommonConfiguration from './webpack.common'; +process.env.NODE_ENV = 'development'; const BASE_PORT = 8082; /** @@ -63,6 +65,7 @@ const getConfiguration = (environment: Environment): Promise => // eslint-disable-next-line @typescript-eslint/naming-convention 'process.env.PORT': port, }), + new ReactRefreshWebpackPlugin(), ], cache: { type: 'filesystem', @@ -82,6 +85,7 @@ const getConfiguration = (environment: Environment): Promise => }, }); + return config; return TimeAnalyticsPlugin.wrap(config); }); From bc8c598ebebf8faf1f455b65648423887aa047d1 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 15 Sep 2024 17:55:00 +0530 Subject: [PATCH 005/312] minor fix. Signed-off-by: krishna2323 --- src/libs/UserUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/UserUtils.ts b/src/libs/UserUtils.ts index 25ff32eab1ca..fc3bcd1ac3fe 100644 --- a/src/libs/UserUtils.ts +++ b/src/libs/UserUtils.ts @@ -59,10 +59,10 @@ function hasLoginListInfo(loginList: OnyxEntry): boolean { const filteredLoginList = Object.values(loginList ?? {}).filter((login) => { const pendingAction = login?.pendingFields?.deletedLogin ?? login?.pendingFields?.addedLogin ?? undefined; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return login.partnerUserID || pendingAction; + return currentUserLogin !== login.partnerUserID && (login.partnerUserID || pendingAction); }); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return !Object.values(filteredLoginList ?? {}).every((field) => field.validatedDate || currentUserLogin === field.partnerUserID); + return !!filteredLoginList.length && filteredLoginList.every((field) => field.validatedDate || currentUserLogin === field.partnerUserID); } /** From f51a4656d0c6f003c8d6cf0dfe789313c24519ca Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 16 Sep 2024 09:17:58 +0200 Subject: [PATCH 006/312] Install packages --- package-lock.json | 107 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 2 + 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index fc65c419dbf4..0b39e94bcbc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -155,6 +155,7 @@ "@perf-profiler/profiler": "^0.10.10", "@perf-profiler/reporter": "^0.9.0", "@perf-profiler/types": "^0.8.0", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", "@react-native-community/eslint-config": "3.2.0", "@react-native/babel-preset": "0.75.2", "@react-native/metro-config": "0.75.2", @@ -244,6 +245,7 @@ "react-compiler-healthcheck": "^0.0.0-experimental-ab3118d-20240725", "react-is": "^18.3.1", "react-native-clean-project": "^4.0.0-alpha4.0", + "react-refresh": "^0.14.2", "react-test-renderer": "18.3.1", "reassure": "^1.0.0-rc.4", "setimmediate": "^1.0.5", @@ -7252,6 +7254,85 @@ "node": ">=14" } }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.15.tgz", + "integrity": "sha512-LFWllMA55pzB9D34w/wXUCf8+c+IYKuJDgxiZ3qMhl64KRMBHYM1I3VdGaD2BV5FNPV2/S2596bppxHbv2ZydQ==", + "dev": true, + "dependencies": { + "ansi-html": "^0.0.9", + "core-js-pure": "^3.23.3", + "error-stack-parser": "^2.0.6", + "html-entities": "^2.1.0", + "loader-utils": "^2.0.4", + "schema-utils": "^4.2.0", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "@types/webpack": "4.x || 5.x", + "react-refresh": ">=0.10.0 <1.0.0", + "sockjs-client": "^1.4.0", + "type-fest": ">=0.17.0 <5.0.0", + "webpack": ">=4.43.0 <6.0.0", + "webpack-dev-server": "3.x || 4.x || 5.x", + "webpack-hot-middleware": "2.x", + "webpack-plugin-serve": "0.x || 1.x" + }, + "peerDependenciesMeta": { + "@types/webpack": { + "optional": true + }, + "sockjs-client": { + "optional": true + }, + "type-fest": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + }, + "webpack-hot-middleware": { + "optional": true + }, + "webpack-plugin-serve": { + "optional": true + } + } + }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.21", "dev": true, @@ -17149,6 +17230,18 @@ "node": ">=6" } }, + "node_modules/ansi-html": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/ansi-html/-/ansi-html-0.0.9.tgz", + "integrity": "sha512-ozbS3LuenHVxNRh/wdnN16QapUHzauqSomAl1jwwJRRsGwFwtj644lIhxfWu0Fy0acCij2+AEgHvjscq3dlVXg==", + "dev": true, + "engines": [ + "node >= 0.8.0" + ], + "bin": { + "ansi-html": "bin/ansi-html" + } + }, "node_modules/ansi-html-community": { "version": "0.0.8", "dev": true, @@ -21023,6 +21116,17 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-js-pure": { + "version": "3.38.1", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.38.1.tgz", + "integrity": "sha512-BY8Etc1FZqdw1glX0XNOq2FDwfrg/VGqoZOZCdaL+UmdaqDwQwYXkMJT4t6In+zfEfOJDcM9T0KdbBeJg8KKCQ==", + "dev": true, + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.2", "license": "MIT" @@ -38008,7 +38112,8 @@ }, "node_modules/react-refresh": { "version": "0.14.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "engines": { "node": ">=0.10.0" } diff --git a/package.json b/package.json index 183590b7f305..461aa2ad0267 100644 --- a/package.json +++ b/package.json @@ -212,6 +212,7 @@ "@perf-profiler/profiler": "^0.10.10", "@perf-profiler/reporter": "^0.9.0", "@perf-profiler/types": "^0.8.0", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", "@react-native-community/eslint-config": "3.2.0", "@react-native/babel-preset": "0.75.2", "@react-native/metro-config": "0.75.2", @@ -301,6 +302,7 @@ "react-compiler-healthcheck": "^0.0.0-experimental-ab3118d-20240725", "react-is": "^18.3.1", "react-native-clean-project": "^4.0.0-alpha4.0", + "react-refresh": "^0.14.2", "react-test-renderer": "18.3.1", "reassure": "^1.0.0-rc.4", "setimmediate": "^1.0.5", From b2a5dac17597fac3ca69f79d0853f36bfdb7fc56 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Fri, 20 Sep 2024 19:38:28 +0700 Subject: [PATCH 007/312] feat: add translations --- src/languages/en.ts | 2 ++ src/languages/es.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/languages/en.ts b/src/languages/en.ts index bd5ff405529e..cd0c542b2fd3 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2882,6 +2882,8 @@ export default { expensifyCard: { issueAndManageCards: 'Issue and manage your Expensify Cards', getStartedIssuing: 'Get started by issuing your first virtual or physical card.', + verificationInProgress: 'Verification in progress...', + verifyingTheDetails: "We're verifying a few details. Concierge will let you know when Expensify Cards are ready to issue.", disclaimer: 'The Expensify Visa® Commercial Card is issued by The Bancorp Bank, N.A., Member FDIC, pursuant to a license from Visa U.S.A. Inc. and may not be used at all merchants that accept Visa cards. Apple® and the Apple logo® are trademarks of Apple Inc., registered in the U.S. and other countries. App Store is a service mark of Apple Inc. Google Play and the Google Play logo are trademarks of Google LLC.', issueCard: 'Issue card', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2f11de46faed..7a5f613f3df3 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2927,6 +2927,8 @@ export default { expensifyCard: { issueAndManageCards: 'Emitir y gestionar Tarjetas Expensify', getStartedIssuing: 'Empieza emitiendo tu primera tarjeta virtual o física.', + verificationInProgress: 'Verificación en curso...', + verifyingTheDetails: "We're verifying a few details. Concierge will let you know when Expensify Cards are ready to issue.", disclaimer: 'La tarjeta comercial Expensify Visa® es emitida por The Bancorp Bank, N.A., miembro de la FDIC, en virtud de una licencia de Visa U.S.A. Inc. y no puede utilizarse en todos los comercios que aceptan tarjetas Visa. Apple® y el logotipo de Apple® son marcas comerciales de Apple Inc. registradas en EE.UU. y otros países. App Store es una marca de servicio de Apple Inc. Google Play y el logotipo de Google Play son marcas comerciales de Google LLC.', issueCard: 'Emitir tarjeta', From 5e6a468e538e25632efb12dba0ed58319583c998 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Fri, 20 Sep 2024 20:12:43 +0700 Subject: [PATCH 008/312] feat: change modal text and hide the buttons for unverified bank account --- .../workspace/expensifyCard/EmptyCardView.tsx | 14 +++++++++++--- .../WorkspaceExpensifyCardListPage.tsx | 17 ++++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/pages/workspace/expensifyCard/EmptyCardView.tsx b/src/pages/workspace/expensifyCard/EmptyCardView.tsx index d3eb3a15fae8..b2915823ab8d 100644 --- a/src/pages/workspace/expensifyCard/EmptyCardView.tsx +++ b/src/pages/workspace/expensifyCard/EmptyCardView.tsx @@ -13,7 +13,15 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import colors from '@styles/theme/colors'; import CONST from '@src/CONST'; -function EmptyCardView() { +type EmptyCardViewProps = { + /** Title of the empty state */ + title: string; + + /** Subtitle of the empty state */ + subtitle: string; +}; + +function EmptyCardView({title, subtitle}: EmptyCardViewProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {windowHeight} = useWindowDimensions(); @@ -35,8 +43,8 @@ function EmptyCardView() { }, shouldUseNarrowLayout && {maxHeight: 250}, ]} - title={translate('workspace.expensifyCard.issueAndManageCards')} - subtitle={translate('workspace.expensifyCard.getStartedIssuing')} + title={title} + subtitle={subtitle} minModalHeight={500} /> diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index 25de151bbb6d..044fbeddf506 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -16,6 +16,7 @@ import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CardUtils from '@libs/CardUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; import type {FullScreenNavigatorParamList} from '@navigation/types'; import CONST from '@src/CONST'; @@ -43,7 +44,14 @@ function WorkspaceExpensifyCardListPage({route, cardsList}: WorkspaceExpensifyCa const policyID = route.params.policyID; const policy = usePolicy(policyID); + const workspaceAccountID = PolicyUtils.getWorkspaceAccountID(policyID); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}`); + + const paymentBankAccountID = cardSettings?.paymentBankAccountID ?? 0; + const [bankAccountsList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + + const isBankAccountVerified = bankAccountsList?.[paymentBankAccountID]?.accountData?.approvedBy !== 'Automatic'; const policyCurrency = useMemo(() => policy?.outputCurrency ?? CONST.CURRENCY.USD, [policy]); @@ -115,11 +123,14 @@ function WorkspaceExpensifyCardListPage({route, cardsList}: WorkspaceExpensifyCa shouldShowBackButton={shouldUseNarrowLayout} onBackButtonPress={() => Navigation.goBack()} > - {!shouldUseNarrowLayout && getHeaderButtons()} + {!shouldUseNarrowLayout && isBankAccountVerified && getHeaderButtons()} - {shouldUseNarrowLayout && {getHeaderButtons()}} + {shouldUseNarrowLayout && isBankAccountVerified && {getHeaderButtons()}} {isEmptyObject(cardsList) ? ( - + ) : ( Date: Fri, 20 Sep 2024 20:37:58 +0700 Subject: [PATCH 009/312] fix: fix header height for mobile --- src/hooks/useEmptyViewHeaderHeight/index.ios.ts | 5 +++-- src/hooks/useEmptyViewHeaderHeight/index.ts | 6 ++++-- src/pages/workspace/expensifyCard/EmptyCardView.tsx | 7 +++++-- .../expensifyCard/WorkspaceExpensifyCardListPage.tsx | 4 +++- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/hooks/useEmptyViewHeaderHeight/index.ios.ts b/src/hooks/useEmptyViewHeaderHeight/index.ios.ts index d59e105574bf..d74e713f4b07 100644 --- a/src/hooks/useEmptyViewHeaderHeight/index.ios.ts +++ b/src/hooks/useEmptyViewHeaderHeight/index.ios.ts @@ -1,10 +1,11 @@ import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; import {BUTTON_HEIGHT, BUTTON_MARGIN, HEADER_HEIGHT} from './const'; -function useEmptyViewHeaderHeight(isSmallScreenWidth: boolean): number { +function useEmptyViewHeaderHeight(isSmallScreenWidth: boolean, areHeaderButtonsDisplayed: boolean): number { const safeAreaInsets = useSafeAreaInsets(); + const BUTTONS_HEIGHT = areHeaderButtonsDisplayed ? BUTTON_HEIGHT + BUTTON_MARGIN : 0; - return isSmallScreenWidth ? HEADER_HEIGHT + BUTTON_HEIGHT + BUTTON_MARGIN + safeAreaInsets.top : HEADER_HEIGHT; + return isSmallScreenWidth ? HEADER_HEIGHT + BUTTONS_HEIGHT + safeAreaInsets.top : HEADER_HEIGHT; } export default useEmptyViewHeaderHeight; diff --git a/src/hooks/useEmptyViewHeaderHeight/index.ts b/src/hooks/useEmptyViewHeaderHeight/index.ts index d241d95b236f..3f0c34d340fa 100644 --- a/src/hooks/useEmptyViewHeaderHeight/index.ts +++ b/src/hooks/useEmptyViewHeaderHeight/index.ts @@ -1,7 +1,9 @@ import {BUTTON_HEIGHT, BUTTON_MARGIN, HEADER_HEIGHT} from './const'; -function useEmptyViewHeaderHeight(isSmallScreenWidth: boolean): number { - return isSmallScreenWidth ? HEADER_HEIGHT + BUTTON_HEIGHT + BUTTON_MARGIN : HEADER_HEIGHT; +function useEmptyViewHeaderHeight(isSmallScreenWidth: boolean, areHeaderButtonsDisplayed: boolean): number { + const BUTTONS_HEIGHT = areHeaderButtonsDisplayed ? BUTTON_HEIGHT + BUTTON_MARGIN : 0; + + return isSmallScreenWidth ? HEADER_HEIGHT + BUTTONS_HEIGHT : HEADER_HEIGHT; } export default useEmptyViewHeaderHeight; diff --git a/src/pages/workspace/expensifyCard/EmptyCardView.tsx b/src/pages/workspace/expensifyCard/EmptyCardView.tsx index b2915823ab8d..24162e7c2b48 100644 --- a/src/pages/workspace/expensifyCard/EmptyCardView.tsx +++ b/src/pages/workspace/expensifyCard/EmptyCardView.tsx @@ -19,15 +19,18 @@ type EmptyCardViewProps = { /** Subtitle of the empty state */ subtitle: string; + + /** Whether the header buttons be displayed */ + areHeaderButtonsDisplayed: boolean; }; -function EmptyCardView({title, subtitle}: EmptyCardViewProps) { +function EmptyCardView({title, subtitle, areHeaderButtonsDisplayed}: EmptyCardViewProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {windowHeight} = useWindowDimensions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const headerHeight = useEmptyViewHeaderHeight(shouldUseNarrowLayout); + const headerHeight = useEmptyViewHeaderHeight(shouldUseNarrowLayout, areHeaderButtonsDisplayed); return ( diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index 044fbeddf506..40a9d63f10c2 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -51,7 +51,8 @@ function WorkspaceExpensifyCardListPage({route, cardsList}: WorkspaceExpensifyCa const paymentBankAccountID = cardSettings?.paymentBankAccountID ?? 0; const [bankAccountsList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); - const isBankAccountVerified = bankAccountsList?.[paymentBankAccountID]?.accountData?.approvedBy !== 'Automatic'; + // TODO: change approvedBy check for more accurate verification status + const isBankAccountVerified = bankAccountsList?.[paymentBankAccountID]?.accountData?.approvedBy === 'Automatic'; const policyCurrency = useMemo(() => policy?.outputCurrency ?? CONST.CURRENCY.USD, [policy]); @@ -128,6 +129,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList}: WorkspaceExpensifyCa {shouldUseNarrowLayout && isBankAccountVerified && {getHeaderButtons()}} {isEmptyObject(cardsList) ? ( From 08afe474bd342d6ff52eb5080278fb40aa2bf385 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Fri, 20 Sep 2024 15:42:55 +0200 Subject: [PATCH 010/312] Move NODE_ENV to DefinePlugin --- config/webpack/webpack.dev.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/config/webpack/webpack.dev.ts b/config/webpack/webpack.dev.ts index d6607ecf9b59..733da1ea4c5f 100644 --- a/config/webpack/webpack.dev.ts +++ b/config/webpack/webpack.dev.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'; import path from 'path'; import portfinder from 'portfinder'; @@ -9,7 +10,6 @@ import {merge} from 'webpack-merge'; import type Environment from './types'; import getCommonConfiguration from './webpack.common'; -process.env.NODE_ENV = 'development'; const BASE_PORT = 8082; /** @@ -56,14 +56,13 @@ const getConfiguration = (environment: Environment): Promise => }, }, headers: { - // eslint-disable-next-line @typescript-eslint/naming-convention 'Document-Policy': 'js-profiling', }, }, plugins: [ new DefinePlugin({ - // eslint-disable-next-line @typescript-eslint/naming-convention 'process.env.PORT': port, + 'process.env.NODE_ENV': JSON.stringify('development'), }), new ReactRefreshWebpackPlugin(), ], From c0e22484138a160d5532f171e28e1261db0080e3 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Sat, 21 Sep 2024 08:05:56 +0700 Subject: [PATCH 011/312] fix: apply requested changes --- src/pages/workspace/expensifyCard/EmptyCardView.tsx | 2 +- .../workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/expensifyCard/EmptyCardView.tsx b/src/pages/workspace/expensifyCard/EmptyCardView.tsx index 24162e7c2b48..f71f837be50e 100644 --- a/src/pages/workspace/expensifyCard/EmptyCardView.tsx +++ b/src/pages/workspace/expensifyCard/EmptyCardView.tsx @@ -20,7 +20,7 @@ type EmptyCardViewProps = { /** Subtitle of the empty state */ subtitle: string; - /** Whether the header buttons be displayed */ + /** Whether the header buttons are displayed */ areHeaderButtonsDisplayed: boolean; }; diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index 40a9d63f10c2..b3864088324d 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -51,8 +51,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList}: WorkspaceExpensifyCa const paymentBankAccountID = cardSettings?.paymentBankAccountID ?? 0; const [bankAccountsList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); - // TODO: change approvedBy check for more accurate verification status - const isBankAccountVerified = bankAccountsList?.[paymentBankAccountID]?.accountData?.approvedBy === 'Automatic'; + const isBankAccountVerified = bankAccountsList?.[paymentBankAccountID]?.accountData?.state === CONST.BANK_ACCOUNT.STATE.OPEN; const policyCurrency = useMemo(() => policy?.outputCurrency ?? CONST.CURRENCY.USD, [policy]); From e058da375a61a96746c6ddc672e9c26ba1f20b3f Mon Sep 17 00:00:00 2001 From: burczu Date: Wed, 25 Sep 2024 10:26:34 +0200 Subject: [PATCH 012/312] interactive step wrapper extended to match all use cases --- src/components/InteractiveStepWrapper.tsx | 46 ++++++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/src/components/InteractiveStepWrapper.tsx b/src/components/InteractiveStepWrapper.tsx index 6ffe00b9bd5d..0ed2f89f2885 100644 --- a/src/components/InteractiveStepWrapper.tsx +++ b/src/components/InteractiveStepWrapper.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import {View} from 'react-native'; +import React, {forwardRef} from 'react'; +import {StyleProp, View, ViewStyle} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import HeaderWithBackButton from './HeaderWithBackButton'; @@ -24,21 +24,55 @@ type InteractiveStepWrapperProps = { // Array of step names stepNames?: readonly string[]; + + // Should enable max height + shouldEnableMaxHeight?: boolean; + + // Should show offline indicator + shouldShowOfflineIndicator?: boolean; + + // Should enable picker avoiding + shouldEnablePickerAvoiding?: boolean; + + // Call task ID for the guides + guidesCallTaskID?: string; + + // Offline indicator style + offlineIndicatorStyle?: StyleProp; }; -function InteractiveStepWrapper({children, wrapperID, handleBackButtonPress, headerTitle, startStepIndex, stepNames}: InteractiveStepWrapperProps) { +function InteractiveStepWrapper( + { + children, + wrapperID, + handleBackButtonPress, + headerTitle, + startStepIndex, + stepNames, + shouldEnableMaxHeight, + shouldShowOfflineIndicator, + shouldEnablePickerAvoiding = false, + guidesCallTaskID, + offlineIndicatorStyle, + }: InteractiveStepWrapperProps, + ref: React.ForwardedRef, +) { const styles = useThemeStyles(); return ( {stepNames && ( @@ -55,4 +89,4 @@ function InteractiveStepWrapper({children, wrapperID, handleBackButtonPress, hea InteractiveStepWrapper.displayName = 'InteractiveStepWrapper'; -export default InteractiveStepWrapper; +export default forwardRef(InteractiveStepWrapper); From 5ed587c28c547f564240583763efbd392a03431c Mon Sep 17 00:00:00 2001 From: burczu Date: Wed, 25 Sep 2024 14:29:06 +0200 Subject: [PATCH 013/312] refactor: using InteractiveStepWrapper wherever applies --- .../FeesAndTerms/FeesAndTerms.tsx | 28 +++++------------ .../PersonalInfo/PersonalInfo.tsx | 28 +++++------------ .../BankInfo/BankInfo.tsx | 29 +++++------------- .../BeneficialOwnersStep.tsx | 23 +++++--------- .../BusinessInfo/BusinessInfo.tsx | 30 ++++++------------- .../CompleteVerification.tsx | 26 +++++----------- .../PersonalInfo/PersonalInfo.tsx | 27 ++++++----------- .../VerifyIdentity/VerifyIdentity.tsx | 25 ++++++---------- .../expensifyCard/issueNew/AssigneeStep.tsx | 26 +++++----------- .../expensifyCard/issueNew/CardNameStep.tsx | 26 +++++----------- .../expensifyCard/issueNew/CardTypeStep.tsx | 22 +++++--------- .../issueNew/ConfirmationStep.tsx | 25 +++++----------- .../expensifyCard/issueNew/LimitStep.tsx | 26 +++++----------- .../expensifyCard/issueNew/LimitTypeStep.tsx | 26 +++++----------- 14 files changed, 115 insertions(+), 252 deletions(-) diff --git a/src/pages/EnablePayments/FeesAndTerms/FeesAndTerms.tsx b/src/pages/EnablePayments/FeesAndTerms/FeesAndTerms.tsx index 512abc57a990..e8db5d75cfd0 100644 --- a/src/pages/EnablePayments/FeesAndTerms/FeesAndTerms.tsx +++ b/src/pages/EnablePayments/FeesAndTerms/FeesAndTerms.tsx @@ -1,13 +1,9 @@ import React from 'react'; -import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; -import ScreenWrapper from '@components/ScreenWrapper'; +import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import useLocalize from '@hooks/useLocalize'; import useSubStep from '@hooks/useSubStep'; import type {SubStepProps} from '@hooks/useSubStep/types'; -import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@navigation/Navigation'; import * as BankAccounts from '@userActions/BankAccounts'; import * as Wallet from '@userActions/Wallet'; @@ -21,7 +17,6 @@ const termsAndFeesSubsteps: Array> = [FeesStep function FeesAndTerms() { const {translate} = useLocalize(); - const styles = useThemeStyles(); const [walletTerms] = useOnyx(ONYXKEYS.WALLET_TERMS); const submit = () => { @@ -44,28 +39,21 @@ function FeesAndTerms() { }; return ( - - - - - - + ); } diff --git a/src/pages/EnablePayments/PersonalInfo/PersonalInfo.tsx b/src/pages/EnablePayments/PersonalInfo/PersonalInfo.tsx index 55d369b4a2c5..2a91766b5203 100644 --- a/src/pages/EnablePayments/PersonalInfo/PersonalInfo.tsx +++ b/src/pages/EnablePayments/PersonalInfo/PersonalInfo.tsx @@ -1,14 +1,10 @@ import React, {useMemo} from 'react'; -import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; -import ScreenWrapper from '@components/ScreenWrapper'; +import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import useLocalize from '@hooks/useLocalize'; import useSubStep from '@hooks/useSubStep'; import type {SubStepProps} from '@hooks/useSubStep/types'; -import useThemeStyles from '@hooks/useThemeStyles'; import {parsePhoneNumber} from '@libs/PhoneNumber'; import IdologyQuestions from '@pages/EnablePayments/IdologyQuestions'; import getInitialSubstepForPersonalInfo from '@pages/EnablePayments/utils/getInitialSubstepForPersonalInfo'; @@ -41,7 +37,6 @@ const bodyContent: Array> = [FullName, DateOfB function PersonalInfoPage({walletAdditionalDetails, walletAdditionalDetailsDraft}: PersonalInfoPageProps) { const {translate} = useLocalize(); - const styles = useThemeStyles(); const showIdologyQuestions = walletAdditionalDetails?.questions && walletAdditionalDetails?.questions.length > 0; const values = useMemo(() => getSubstepValues(PERSONAL_INFO_STEP_KEYS, walletAdditionalDetailsDraft, walletAdditionalDetails), [walletAdditionalDetails, walletAdditionalDetailsDraft]); @@ -94,20 +89,13 @@ function PersonalInfoPage({walletAdditionalDetails, walletAdditionalDetailsDraft }; return ( - - - - - {showIdologyQuestions ? ( )} - + ); } diff --git a/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx b/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx index 888ad24ba2be..8bb02fdda4d0 100644 --- a/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx +++ b/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx @@ -1,13 +1,9 @@ import React, {useCallback, useEffect, useMemo} from 'react'; -import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; -import ScreenWrapper from '@components/ScreenWrapper'; +import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import useLocalize from '@hooks/useLocalize'; import useSubStep from '@hooks/useSubStep'; import type {SubStepProps} from '@hooks/useSubStep/types'; -import useThemeStyles from '@hooks/useThemeStyles'; import getPlaidOAuthReceivedRedirectURI from '@libs/getPlaidOAuthReceivedRedirectURI'; import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues'; import * as BankAccounts from '@userActions/BankAccounts'; @@ -37,7 +33,6 @@ function BankInfo({onBackButtonPress, policyID}: BankInfoProps) { const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); const [plaidLinkToken] = useOnyx(ONYXKEYS.PLAID_LINK_TOKEN); const {translate} = useLocalize(); - const styles = useThemeStyles(); const [redirectedFromPlaidToManual, setRedirectedFromPlaidToManual] = React.useState(false); const values = useMemo(() => getSubstepValues(BANK_INFO_STEP_KEYS, reimbursementAccountDraft, reimbursementAccount ?? {}), [reimbursementAccount, reimbursementAccountDraft]); @@ -125,28 +120,20 @@ function BankInfo({onBackButtonPress, policyID}: BankInfoProps) { }; return ( - - - - - - + ); } diff --git a/src/pages/ReimbursementAccount/BeneficialOwnersStep.tsx b/src/pages/ReimbursementAccount/BeneficialOwnersStep.tsx index ae0fded74347..0e67a5f84c10 100644 --- a/src/pages/ReimbursementAccount/BeneficialOwnersStep.tsx +++ b/src/pages/ReimbursementAccount/BeneficialOwnersStep.tsx @@ -5,6 +5,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; +import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; import useSubStep from '@hooks/useSubStep'; @@ -216,23 +217,15 @@ function BeneficialOwnersStep({reimbursementAccount, reimbursementAccountDraft, }; return ( - - - - - - {currentUBOSubstep === SUBSTEP.IS_USER_UBO && ( )} - + ); } diff --git a/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx b/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx index 814db3536973..835ac77ee441 100644 --- a/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx +++ b/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx @@ -1,15 +1,11 @@ import lodashPick from 'lodash/pick'; import React, {useCallback, useMemo} from 'react'; -import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; -import ScreenWrapper from '@components/ScreenWrapper'; +import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import useLocalize from '@hooks/useLocalize'; import useSubStep from '@hooks/useSubStep'; import type {SubStepProps} from '@hooks/useSubStep/types'; -import useThemeStyles from '@hooks/useThemeStyles'; import {parsePhoneNumber} from '@libs/PhoneNumber'; import getInitialSubstepForBusinessInfo from '@pages/ReimbursementAccount/utils/getInitialSubstepForBusinessInfo'; import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues'; @@ -58,7 +54,6 @@ const bodyContent: Array> = [ function BusinessInfo({reimbursementAccount, reimbursementAccountDraft, onBackButtonPress}: BusinessInfoProps) { const {translate} = useLocalize(); - const styles = useThemeStyles(); const getBankAccountFields = useCallback( (fieldNames: string[]) => ({ @@ -114,29 +109,22 @@ function BusinessInfo({reimbursementAccount, reimbursementAccountDraft, onBackBu }; return ( - - - - - - + ); } diff --git a/src/pages/ReimbursementAccount/CompleteVerification/CompleteVerification.tsx b/src/pages/ReimbursementAccount/CompleteVerification/CompleteVerification.tsx index 900ca7207f59..a258f69d5eb9 100644 --- a/src/pages/ReimbursementAccount/CompleteVerification/CompleteVerification.tsx +++ b/src/pages/ReimbursementAccount/CompleteVerification/CompleteVerification.tsx @@ -1,11 +1,8 @@ import type {ComponentType} from 'react'; import React, {useCallback, useMemo} from 'react'; -import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; -import ScreenWrapper from '@components/ScreenWrapper'; +import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import useLocalize from '@hooks/useLocalize'; import useSubStep from '@hooks/useSubStep'; import type {SubStepProps} from '@hooks/useSubStep/types'; @@ -70,28 +67,21 @@ function CompleteVerification({reimbursementAccount, reimbursementAccountDraft, }; return ( - - - - - - + ); } diff --git a/src/pages/ReimbursementAccount/PersonalInfo/PersonalInfo.tsx b/src/pages/ReimbursementAccount/PersonalInfo/PersonalInfo.tsx index 1aa7e519416e..f955572da738 100644 --- a/src/pages/ReimbursementAccount/PersonalInfo/PersonalInfo.tsx +++ b/src/pages/ReimbursementAccount/PersonalInfo/PersonalInfo.tsx @@ -1,11 +1,9 @@ import type {RefAttributes} from 'react'; import React, {forwardRef, useCallback, useMemo} from 'react'; -import {View} from 'react-native'; +import type {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; -import ScreenWrapper from '@components/ScreenWrapper'; +import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import useLocalize from '@hooks/useLocalize'; import useSubStep from '@hooks/useSubStep'; import type {SubStepProps} from '@hooks/useSubStep/types'; @@ -79,29 +77,22 @@ function PersonalInfo({reimbursementAccount, reimbursementAccountDraft, onBackBu }; return ( - - - - - - + ); } diff --git a/src/pages/ReimbursementAccount/VerifyIdentity/VerifyIdentity.tsx b/src/pages/ReimbursementAccount/VerifyIdentity/VerifyIdentity.tsx index cabaf543a756..81ff3599628e 100644 --- a/src/pages/ReimbursementAccount/VerifyIdentity/VerifyIdentity.tsx +++ b/src/pages/ReimbursementAccount/VerifyIdentity/VerifyIdentity.tsx @@ -1,13 +1,10 @@ import React, {useCallback} from 'react'; -import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; +import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import Onfido from '@components/Onfido'; import type {OnfidoData} from '@components/Onfido/types'; -import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -61,17 +58,13 @@ function VerifyIdentity({reimbursementAccount, onBackButtonPress, onfidoApplican }; return ( - - - - - + - + ); } diff --git a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx index e89db5cb88db..769532e49351 100644 --- a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx @@ -1,11 +1,8 @@ import React, {useMemo} from 'react'; -import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; -import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; -import ScreenWrapper from '@components/ScreenWrapper'; +import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import SelectionList from '@components/SelectionList'; import type {ListItem} from '@components/SelectionList/types'; import UserListItem from '@components/SelectionList/UserListItem'; @@ -134,22 +131,15 @@ function AssigneeStep({policy}: AssigneeStepProps) { }, [debouncedSearchTerm, sections]); return ( - - - - - {translate('workspace.card.issueNewCard.whoNeedsCard')} - + ); } diff --git a/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx b/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx index b75a1f417c78..26ae497406d6 100644 --- a/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/CardNameStep.tsx @@ -1,12 +1,9 @@ import React, {useCallback} from 'react'; -import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; -import ScreenWrapper from '@components/ScreenWrapper'; +import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; @@ -61,22 +58,15 @@ function CardNameStep() { }, [isEditing]); return ( - - - - - {translate('workspace.card.issueNewCard.giveItName')} - + ); } diff --git a/src/pages/workspace/expensifyCard/issueNew/CardTypeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/CardTypeStep.tsx index fd1fb5a7586f..acb1da3a8ec2 100644 --- a/src/pages/workspace/expensifyCard/issueNew/CardTypeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/CardTypeStep.tsx @@ -5,6 +5,7 @@ import type {ValueOf} from 'type-fest'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Illustrations from '@components/Icon/Illustrations'; import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; +import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import MenuItem from '@components/MenuItem'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; @@ -41,23 +42,16 @@ function CardTypeStep() { }; return ( - - - - - {translate('workspace.card.issueNewCard.chooseCardType')} - + ); } diff --git a/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx b/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx index c5a852450828..e2f18836c73b 100644 --- a/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx @@ -2,10 +2,8 @@ import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; +import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; @@ -62,22 +60,15 @@ function ConfirmationStep({policyID, backTo}: ConfirmationStepProps) { const translationForLimitType = getTranslationKeyForLimitType(data?.limitType); return ( - - - - - - + ); } diff --git a/src/pages/workspace/expensifyCard/issueNew/LimitStep.tsx b/src/pages/workspace/expensifyCard/issueNew/LimitStep.tsx index eb7c2e7d8e0f..70776be526fa 100644 --- a/src/pages/workspace/expensifyCard/issueNew/LimitStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/LimitStep.tsx @@ -1,13 +1,10 @@ import React, {useCallback} from 'react'; -import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import AmountForm from '@components/AmountForm'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; -import ScreenWrapper from '@components/ScreenWrapper'; +import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import Text from '@components/Text'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; @@ -60,22 +57,15 @@ function LimitStep() { ); return ( - - - - - {translate('workspace.card.issueNewCard.setLimit')} - + ); } diff --git a/src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx index de4bca070d51..110f39460c82 100644 --- a/src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx @@ -1,11 +1,8 @@ import React, {useCallback, useMemo, useState} from 'react'; -import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; -import ScreenWrapper from '@components/ScreenWrapper'; +import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/RadioListItem'; import Text from '@components/Text'; @@ -84,22 +81,15 @@ function LimitTypeStep({policy}: LimitTypeStepProps) { }, [areApprovalsConfigured, translate, typeSelected]); return ( - - - - - {translate('workspace.card.issueNewCard.chooseLimitType')} - + ); } From ee229c84fe26e20e1c735e8c211d159e26aeaad1 Mon Sep 17 00:00:00 2001 From: burczu Date: Wed, 2 Oct 2024 09:07:20 +0200 Subject: [PATCH 014/312] missing personal details refactor: using separate form providers --- src/hooks/usePersonalDetailsStepFormSubmit.ts | 27 ++++++++ src/pages/MissingPersonalDetails/index.tsx | 63 +++++++++-------- .../substeps/Address.tsx | 67 ++++++++++++++++++- .../substeps/DateOfBirth.tsx | 42 ++++++++++-- .../substeps/LegalName.tsx | 46 +++++++++++-- .../substeps/PhoneNumber.tsx | 48 +++++++++++-- 6 files changed, 251 insertions(+), 42 deletions(-) create mode 100644 src/hooks/usePersonalDetailsStepFormSubmit.ts diff --git a/src/hooks/usePersonalDetailsStepFormSubmit.ts b/src/hooks/usePersonalDetailsStepFormSubmit.ts new file mode 100644 index 000000000000..32dc1371049b --- /dev/null +++ b/src/hooks/usePersonalDetailsStepFormSubmit.ts @@ -0,0 +1,27 @@ +import type {FormOnyxKeys} from '@components/Form/types'; +import type {OnyxFormKey} from '@src/ONYXKEYS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useStepFormSubmit from './useStepFormSubmit'; +import type {SubStepProps} from './useSubStep/types'; + +type UsePersonalDetailsStepFormSubmitParams = Pick & { + formId?: OnyxFormKey; + fieldIds: Array>; + shouldSaveDraft: boolean; +}; + +/** + * Hook for handling submit method in MissingPersonalDetails substeps. + * When user is in editing mode, we should save values only when user confirms the change + * @param onNext - callback + * @param fieldIds - field IDs for particular step + * @param shouldSaveDraft - if we should save draft values + */ +export default function usePersonalDetailsStepFormSubmit({onNext, fieldIds, shouldSaveDraft}: UsePersonalDetailsStepFormSubmitParams) { + return useStepFormSubmit({ + formId: ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM, + onNext, + fieldIds, + shouldSaveDraft, + }); +} diff --git a/src/pages/MissingPersonalDetails/index.tsx b/src/pages/MissingPersonalDetails/index.tsx index 5220f25be981..2ad9d772b2ca 100644 --- a/src/pages/MissingPersonalDetails/index.tsx +++ b/src/pages/MissingPersonalDetails/index.tsx @@ -68,14 +68,14 @@ function MissingPersonalDetails() { prevScreen(); }; - const handleNextScreen = useCallback(() => { - if (isEditing) { - goToTheLastStep(); - return; - } - ref.current?.moveNext(); - nextScreen(); - }, [goToTheLastStep, isEditing, nextScreen]); + // const handleNextScreen = useCallback(() => { + // if (isEditing) { + // goToTheLastStep(); + // return; + // } + // ref.current?.moveNext(); + // nextScreen(); + // }, [goToTheLastStep, isEditing, nextScreen]); const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { @@ -186,25 +186,34 @@ function MissingPersonalDetails() { stepNames={CONST.MISSING_PERSONAL_DETAILS_INDEXES.INDEX_LIST} /> - - - - - + + + + {/**/} + {/* */} + {/* */} + {/* */} + {/**/} ); } diff --git a/src/pages/MissingPersonalDetails/substeps/Address.tsx b/src/pages/MissingPersonalDetails/substeps/Address.tsx index 384a2648b307..153027db6fa1 100644 --- a/src/pages/MissingPersonalDetails/substeps/Address.tsx +++ b/src/pages/MissingPersonalDetails/substeps/Address.tsx @@ -2,21 +2,28 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import AddressSearch from '@components/AddressSearch'; import CountryPicker from '@components/CountryPicker'; +import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import StatePicker from '@components/StatePicker'; import type {State} from '@components/StateSelector'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; import useLocalize from '@hooks/useLocalize'; +import usePersonalDetailsStepFormSubmit from '@hooks/usePersonalDetailsStepFormSubmit'; import useThemeStyles from '@hooks/useThemeStyles'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import * as ValidationUtils from '@libs/ValidationUtils'; import type {CountryZipRegex, CustomSubStepProps} from '@pages/MissingPersonalDetails/types'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; import type {Address} from '@src/types/onyx/PrivatePersonalDetails'; -function AddressStep({privatePersonalDetails}: CustomSubStepProps) { +const STEP_FIELDS = [INPUT_IDS.ADDRESS_LINE_1, INPUT_IDS.ADDRESS_LINE_2, INPUT_IDS.CITY, INPUT_IDS.STATE, INPUT_IDS.COUNTRY, INPUT_IDS.ZIP_POST_CODE]; + +function AddressStep({privatePersonalDetails, isEditing, onNext}: CustomSubStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const address = useMemo(() => PersonalDetailsUtils.getCurrentAddress(privatePersonalDetails), [privatePersonalDetails]); @@ -39,6 +46,40 @@ function AddressStep({privatePersonalDetails}: CustomSubStepProps) { // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [address?.state, address?.country, address?.city, address?.zip]); + const validate = useCallback( + (values: FormOnyxValues): FormInputErrors => { + const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS); + const addressRequiredFields = [INPUT_IDS.ADDRESS_LINE_1, INPUT_IDS.CITY, INPUT_IDS.COUNTRY, INPUT_IDS.STATE] as const; + addressRequiredFields.forEach((fieldKey) => { + const fieldValue = values[fieldKey] ?? ''; + if (ValidationUtils.isRequiredFulfilled(fieldValue)) { + return; + } + errors[fieldKey] = translate('common.error.fieldRequired'); + }); + + // If no country is selected, default value is an empty string and there's no related regex data so we default to an empty object + const countryRegexDetails = (values.country ? CONST.COUNTRY_ZIP_REGEX_DATA?.[values.country] : {}) as CountryZipRegex; + + // The postal code system might not exist for a country, so no regex either for them. + const countrySpecificZipRegex = countryRegexDetails?.regex; + const countryZipFormat = countryRegexDetails?.samples ?? ''; + if (countrySpecificZipRegex) { + if (!countrySpecificZipRegex.test(values[INPUT_IDS.ZIP_POST_CODE]?.trim().toUpperCase())) { + if (ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.ZIP_POST_CODE]?.trim())) { + errors[INPUT_IDS.ZIP_POST_CODE] = translate('privatePersonalDetails.error.incorrectZipFormat', {zipFormat: countryZipFormat}); + } else { + errors[INPUT_IDS.ZIP_POST_CODE] = translate('common.error.fieldRequired'); + } + } + } else if (!CONST.GENERIC_ZIP_CODE_REGEX.test(values[INPUT_IDS.ZIP_POST_CODE]?.trim()?.toUpperCase() ?? '')) { + errors[INPUT_IDS.ZIP_POST_CODE] = translate('privatePersonalDetails.error.incorrectZipFormat'); + } + return errors; + }, + [translate], + ); + const handleAddressChange = useCallback((value: unknown, key: unknown) => { const addressPart = value as string; const addressPartKey = key as keyof Address; @@ -67,6 +108,12 @@ function AddressStep({privatePersonalDetails}: CustomSubStepProps) { setZipcode(addressPart); }, []); + const handleSubmit = usePersonalDetailsStepFormSubmit({ + fieldIds: STEP_FIELDS, + onNext, + shouldSaveDraft: isEditing, + }); + const isUSAForm = currentCountry === CONST.COUNTRY.US; const zipSampleFormat = (currentCountry && (CONST.COUNTRY_ZIP_REGEX_DATA[currentCountry] as CountryZipRegex)?.samples) ?? ''; @@ -74,7 +121,14 @@ function AddressStep({privatePersonalDetails}: CustomSubStepProps) { const zipFormat = translate('common.zipCodeExampleFormat', {zipSampleFormat}); return ( - <> + {translate('privatePersonalDetails.enterAddress')} {isUSAForm ? ( @@ -123,6 +180,7 @@ function AddressStep({privatePersonalDetails}: CustomSubStepProps) { inputID={INPUT_IDS.STATE} value={state as State} onValueChange={handleAddressChange} + shouldSaveDraft={!isEditing} /> ) : ( @@ -137,6 +195,7 @@ function AddressStep({privatePersonalDetails}: CustomSubStepProps) { spellCheck={false} onValueChange={handleAddressChange} containerStyles={styles.mt3} + shouldSaveDraft={!isEditing} /> )} - + ); } diff --git a/src/pages/MissingPersonalDetails/substeps/DateOfBirth.tsx b/src/pages/MissingPersonalDetails/substeps/DateOfBirth.tsx index 5aba7ed74ef8..e5850240d071 100644 --- a/src/pages/MissingPersonalDetails/substeps/DateOfBirth.tsx +++ b/src/pages/MissingPersonalDetails/substeps/DateOfBirth.tsx @@ -1,23 +1,56 @@ import {subYears} from 'date-fns'; -import React from 'react'; +import React, {useCallback} from 'react'; import DatePicker from '@components/DatePicker'; +import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; +import usePersonalDetailsStepFormSubmit from '@hooks/usePersonalDetailsStepFormSubmit'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as ValidationUtils from '@libs/ValidationUtils'; import type {CustomSubStepProps} from '@pages/MissingPersonalDetails/types'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; -function DateOfBirthStep({privatePersonalDetails}: CustomSubStepProps) { +const STEP_FIELDS = [INPUT_IDS.DATE_OF_BIRTH]; + +function DateOfBirthStep({privatePersonalDetails, isEditing, onNext}: CustomSubStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const minDate = subYears(new Date(), CONST.DATE_BIRTH.MAX_AGE); const maxDate = subYears(new Date(), CONST.DATE_BIRTH.MIN_AGE); + const validate = useCallback( + (values: FormOnyxValues): FormInputErrors => { + const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS); + if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.DATE_OF_BIRTH])) { + errors[INPUT_IDS.DATE_OF_BIRTH] = translate('common.error.fieldRequired'); + } else if (!ValidationUtils.isValidPastDate(values[INPUT_IDS.DATE_OF_BIRTH]) || !ValidationUtils.meetsMaximumAgeRequirement(values[INPUT_IDS.DATE_OF_BIRTH])) { + errors[INPUT_IDS.DATE_OF_BIRTH] = translate('bankAccount.error.dob'); + } + return errors; + }, + [translate], + ); + + const handleSubmit = usePersonalDetailsStepFormSubmit({ + fieldIds: STEP_FIELDS, + onNext, + shouldSaveDraft: isEditing, + }); + return ( - <> + {translate('privatePersonalDetails.enterDateOfBirth')} - + ); } diff --git a/src/pages/MissingPersonalDetails/substeps/LegalName.tsx b/src/pages/MissingPersonalDetails/substeps/LegalName.tsx index 8b88bee4337d..6b41791f3695 100644 --- a/src/pages/MissingPersonalDetails/substeps/LegalName.tsx +++ b/src/pages/MissingPersonalDetails/substeps/LegalName.tsx @@ -1,20 +1,56 @@ -import React from 'react'; +import React, {useCallback} from 'react'; import {View} from 'react-native'; +import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; import useLocalize from '@hooks/useLocalize'; +import usePersonalDetailsStepFormSubmit from '@hooks/usePersonalDetailsStepFormSubmit'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as ValidationUtils from '@libs/ValidationUtils'; import type {CustomSubStepProps} from '@pages/MissingPersonalDetails/types'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; -function LegalNameStep({privatePersonalDetails}: CustomSubStepProps) { +const STEP_FIELDS = [INPUT_IDS.LEGAL_FIRST_NAME, INPUT_IDS.LEGAL_LAST_NAME]; + +function LegalNameStep({privatePersonalDetails, isEditing, onNext}: CustomSubStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + // TODO: apply validation from index file + const validate = useCallback( + (values: FormOnyxValues): FormInputErrors => { + const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS); + if (values.legalFirstName && !ValidationUtils.isValidPersonName(values.legalLastName)) { + errors.legalFirstName = translate('bankAccount.error.firstName'); + } + + if (values.legalLastName && !ValidationUtils.isValidPersonName(values.legalLastName)) { + errors.legalLastName = translate('bankAccount.error.lastName'); + } + return errors; + }, + [translate], + ); + + const handleSubmit = usePersonalDetailsStepFormSubmit({ + fieldIds: STEP_FIELDS, + onNext, + shouldSaveDraft: isEditing, + }); + return ( - <> + {translate('privatePersonalDetails.enterLegalName')} @@ -38,9 +75,10 @@ function LegalNameStep({privatePersonalDetails}: CustomSubStepProps) { role={CONST.ROLE.PRESENTATION} defaultValue={privatePersonalDetails?.legalLastName} spellCheck={false} + shouldSaveDraft={!isEditing} /> - + ); } diff --git a/src/pages/MissingPersonalDetails/substeps/PhoneNumber.tsx b/src/pages/MissingPersonalDetails/substeps/PhoneNumber.tsx index d336e15dd6c2..f38e69a53ca7 100644 --- a/src/pages/MissingPersonalDetails/substeps/PhoneNumber.tsx +++ b/src/pages/MissingPersonalDetails/substeps/PhoneNumber.tsx @@ -1,19 +1,58 @@ -import React from 'react'; +import {Str} from 'expensify-common'; +import React, {useCallback} from 'react'; +import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; import useLocalize from '@hooks/useLocalize'; +import usePersonalDetailsStepFormSubmit from '@hooks/usePersonalDetailsStepFormSubmit'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as LoginUtils from '@libs/LoginUtils'; +import * as PhoneNumberUtils from '@libs/PhoneNumber'; +import * as ValidationUtils from '@libs/ValidationUtils'; import type {CustomSubStepProps} from '@pages/MissingPersonalDetails/types'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; -function PhoneNumberStep({privatePersonalDetails}: CustomSubStepProps) { +const STEP_FIELDS = [INPUT_IDS.PHONE_NUMBER]; + +function PhoneNumberStep({privatePersonalDetails, isEditing, onNext}: CustomSubStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const handleSubmit = usePersonalDetailsStepFormSubmit({ + fieldIds: STEP_FIELDS, + onNext, + shouldSaveDraft: isEditing, + }); + + const validate = useCallback( + (values: FormOnyxValues): FormInputErrors => { + const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS); + if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.PHONE_NUMBER])) { + errors[INPUT_IDS.PHONE_NUMBER] = translate('common.error.fieldRequired'); + } + const phoneNumber = LoginUtils.appendCountryCode(values[INPUT_IDS.PHONE_NUMBER]); + const parsedPhoneNumber = PhoneNumberUtils.parsePhoneNumber(phoneNumber); + if (!parsedPhoneNumber.possible || !Str.isValidE164Phone(phoneNumber.slice(0))) { + errors[INPUT_IDS.PHONE_NUMBER] = translate('bankAccount.error.phoneNumber'); + } + return errors; + }, + [translate], + ); + return ( - <> + {translate('privatePersonalDetails.enterPhoneNumber')} - + ); } From 21a39de2b19e0bf56923dbcf3f175e23e4b76b67 Mon Sep 17 00:00:00 2001 From: burczu Date: Thu, 3 Oct 2024 10:05:51 +0200 Subject: [PATCH 015/312] missing personal details page final refactor --- src/pages/MissingPersonalDetails/index.tsx | 141 +++--------------- src/pages/MissingPersonalDetails/types.ts | 7 +- .../PersonalInfo/PersonalInfo.tsx | 2 - 3 files changed, 26 insertions(+), 124 deletions(-) diff --git a/src/pages/MissingPersonalDetails/index.tsx b/src/pages/MissingPersonalDetails/index.tsx index 2ad9d772b2ca..01e1270baf4c 100644 --- a/src/pages/MissingPersonalDetails/index.tsx +++ b/src/pages/MissingPersonalDetails/index.tsx @@ -1,11 +1,8 @@ /* eslint-disable no-case-declarations */ -import {Str} from 'expensify-common'; import React, {useCallback, useMemo, useRef} from 'react'; import type {ForwardedRef} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; -import FormProvider from '@components/Form/FormProvider'; -import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; import type {InteractiveStepSubHeaderHandle} from '@components/InteractiveStepSubHeader'; @@ -13,20 +10,17 @@ import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; import useSubStep from '@hooks/useSubStep'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as ErrorUtils from '@libs/ErrorUtils'; -import * as LoginUtils from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; -import * as PhoneNumberUtils from '@libs/PhoneNumber'; -import * as ValidationUtils from '@libs/ValidationUtils'; import * as PersonalDetails from '@userActions/PersonalDetails'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsForm} from '@src/types/form/PersonalDetailsForm'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; import Address from './substeps/Address'; import DateOfBirth from './substeps/DateOfBirth'; import LegalName from './substeps/LegalName'; import PhoneNumber from './substeps/PhoneNumber'; -import type {CountryZipRegex, CustomSubStepProps} from './types'; +import type {CustomSubStepProps, SubStepsValues} from './types'; const formSteps = [LegalName, DateOfBirth, Address, PhoneNumber]; @@ -35,13 +29,23 @@ function MissingPersonalDetails() { const {translate} = useLocalize(); const ref: ForwardedRef = useRef(null); const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); + const [personalDetailsForm] = useOnyx(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM); + const [personalDetailsFormDraft] = useOnyx(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM_DRAFT); const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const firstUnissuedCard = useMemo(() => Object.values(cardList ?? {}).find((card) => card.state === CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED), [cardList]); + const values = useMemo(() => { + return Object.entries(INPUT_IDS).reduce((acc, [, value]) => { + // @ts-expect-error complaints about Country not being a string, but it is + acc[value] = (personalDetailsFormDraft?.[value] ?? personalDetailsForm?.[value] ?? '') as PersonalDetailsForm[keyof PersonalDetailsForm]; + return acc; + }, {} as SubStepsValues); + }, [personalDetailsForm, personalDetailsFormDraft]); const handleFinishStep = useCallback(() => { + PersonalDetails.updatePersonalDetailsAndShipExpensifyCard(values, firstUnissuedCard?.cardID ?? 0); Navigation.goBack(); - }, []); + }, [firstUnissuedCard?.cardID, values]); const { componentToRender: SubStep, @@ -51,7 +55,12 @@ function MissingPersonalDetails() { screenIndex, moveTo, goToTheLastStep, - } = useSubStep({bodyContent: formSteps, startFrom: CONST.MISSING_PERSONAL_DETAILS_INDEXES.MAPPING.LEGAL_NAME, onFinished: handleFinishStep}); + } = useSubStep({ + bodyContent: formSteps, + startFrom: CONST.MISSING_PERSONAL_DETAILS_INDEXES.MAPPING.LEGAL_NAME, + onFinished: handleFinishStep, + onNextSubStep: () => ref.current?.moveNext(), + }); const handleBackButtonPress = () => { if (isEditing) { @@ -68,6 +77,7 @@ function MissingPersonalDetails() { prevScreen(); }; + // TODO: consider if this is necessary // const handleNextScreen = useCallback(() => { // if (isEditing) { // goToTheLastStep(); @@ -77,98 +87,6 @@ function MissingPersonalDetails() { // nextScreen(); // }, [goToTheLastStep, isEditing, nextScreen]); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; - switch (screenIndex) { - case CONST.MISSING_PERSONAL_DETAILS_INDEXES.MAPPING.LEGAL_NAME: - if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.LEGAL_FIRST_NAME])) { - errors[INPUT_IDS.LEGAL_FIRST_NAME] = translate('common.error.fieldRequired'); - } else if (!ValidationUtils.isValidLegalName(values[INPUT_IDS.LEGAL_FIRST_NAME])) { - errors[INPUT_IDS.LEGAL_FIRST_NAME] = translate('privatePersonalDetails.error.hasInvalidCharacter'); - } else if (values[INPUT_IDS.LEGAL_FIRST_NAME].length > CONST.LEGAL_NAME.MAX_LENGTH) { - errors[INPUT_IDS.LEGAL_FIRST_NAME] = translate('common.error.characterLimitExceedCounter', { - length: values[INPUT_IDS.LEGAL_FIRST_NAME].length, - limit: CONST.LEGAL_NAME.MAX_LENGTH, - }); - } - if (ValidationUtils.doesContainReservedWord(values[INPUT_IDS.LEGAL_FIRST_NAME], CONST.DISPLAY_NAME.RESERVED_NAMES)) { - ErrorUtils.addErrorMessage(errors, INPUT_IDS.LEGAL_FIRST_NAME, translate('personalDetails.error.containsReservedWord')); - } - if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.LEGAL_LAST_NAME])) { - errors[INPUT_IDS.LEGAL_LAST_NAME] = translate('common.error.fieldRequired'); - } else if (!ValidationUtils.isValidLegalName(values[INPUT_IDS.LEGAL_LAST_NAME])) { - errors[INPUT_IDS.LEGAL_LAST_NAME] = translate('privatePersonalDetails.error.hasInvalidCharacter'); - } else if (values[INPUT_IDS.LEGAL_LAST_NAME].length > CONST.LEGAL_NAME.MAX_LENGTH) { - errors[INPUT_IDS.LEGAL_LAST_NAME] = translate('common.error.characterLimitExceedCounter', { - length: values[INPUT_IDS.LEGAL_LAST_NAME].length, - limit: CONST.LEGAL_NAME.MAX_LENGTH, - }); - } - if (ValidationUtils.doesContainReservedWord(values[INPUT_IDS.LEGAL_LAST_NAME], CONST.DISPLAY_NAME.RESERVED_NAMES)) { - ErrorUtils.addErrorMessage(errors, INPUT_IDS.LEGAL_LAST_NAME, translate('personalDetails.error.containsReservedWord')); - } - return errors; - case CONST.MISSING_PERSONAL_DETAILS_INDEXES.MAPPING.DATE_OF_BIRTH: - if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.DATE_OF_BIRTH])) { - errors[INPUT_IDS.DATE_OF_BIRTH] = translate('common.error.fieldRequired'); - } else if (!ValidationUtils.isValidPastDate(values[INPUT_IDS.DATE_OF_BIRTH]) || !ValidationUtils.meetsMaximumAgeRequirement(values[INPUT_IDS.DATE_OF_BIRTH])) { - errors[INPUT_IDS.DATE_OF_BIRTH] = translate('bankAccount.error.dob'); - } - return errors; - case CONST.MISSING_PERSONAL_DETAILS_INDEXES.MAPPING.ADDRESS: - const addressRequiredFields = [INPUT_IDS.ADDRESS_LINE_1, INPUT_IDS.CITY, INPUT_IDS.COUNTRY, INPUT_IDS.STATE] as const; - addressRequiredFields.forEach((fieldKey) => { - const fieldValue = values[fieldKey] ?? ''; - if (ValidationUtils.isRequiredFulfilled(fieldValue)) { - return; - } - errors[fieldKey] = translate('common.error.fieldRequired'); - }); - - // If no country is selected, default value is an empty string and there's no related regex data so we default to an empty object - const countryRegexDetails = (values.country ? CONST.COUNTRY_ZIP_REGEX_DATA?.[values.country] : {}) as CountryZipRegex; - - // The postal code system might not exist for a country, so no regex either for them. - const countrySpecificZipRegex = countryRegexDetails?.regex; - const countryZipFormat = countryRegexDetails?.samples ?? ''; - if (countrySpecificZipRegex) { - if (!countrySpecificZipRegex.test(values[INPUT_IDS.ZIP_POST_CODE]?.trim().toUpperCase())) { - if (ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.ZIP_POST_CODE]?.trim())) { - errors[INPUT_IDS.ZIP_POST_CODE] = translate('privatePersonalDetails.error.incorrectZipFormat', {zipFormat: countryZipFormat}); - } else { - errors[INPUT_IDS.ZIP_POST_CODE] = translate('common.error.fieldRequired'); - } - } - } else if (!CONST.GENERIC_ZIP_CODE_REGEX.test(values[INPUT_IDS.ZIP_POST_CODE]?.trim()?.toUpperCase() ?? '')) { - errors[INPUT_IDS.ZIP_POST_CODE] = translate('privatePersonalDetails.error.incorrectZipFormat'); - } - return errors; - case CONST.MISSING_PERSONAL_DETAILS_INDEXES.MAPPING.PHONE_NUMBER: - if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.PHONE_NUMBER])) { - errors[INPUT_IDS.PHONE_NUMBER] = translate('common.error.fieldRequired'); - } - const phoneNumber = LoginUtils.appendCountryCode(values[INPUT_IDS.PHONE_NUMBER]); - const parsedPhoneNumber = PhoneNumberUtils.parsePhoneNumber(phoneNumber); - if (!parsedPhoneNumber.possible || !Str.isValidE164Phone(phoneNumber.slice(0))) { - errors[INPUT_IDS.PHONE_NUMBER] = translate('bankAccount.error.phoneNumber'); - } - return errors; - default: - return errors; - } - }, - [screenIndex, translate], - ); - - const updatePersonalDetails = useCallback( - (formValues: FormOnyxValues) => { - PersonalDetails.updatePersonalDetailsAndShipExpensifyCard(formValues, firstUnissuedCard?.cardID ?? 0); - nextScreen(); - }, - [nextScreen, firstUnissuedCard], - ); - return ( - {/**/} - {/* */} - {/* */} - {/* */} - {/**/} ); } diff --git a/src/pages/MissingPersonalDetails/types.ts b/src/pages/MissingPersonalDetails/types.ts index 9a9963cc628a..ccc90f0ae11f 100644 --- a/src/pages/MissingPersonalDetails/types.ts +++ b/src/pages/MissingPersonalDetails/types.ts @@ -1,5 +1,6 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {SubStepProps} from '@hooks/useSubStep/types'; +import type {PersonalDetailsForm} from '@src/types/form'; import type {PrivatePersonalDetails} from '@src/types/onyx'; type CustomSubStepProps = SubStepProps & { @@ -12,4 +13,8 @@ type CountryZipRegex = { samples?: string; }; -export type {CustomSubStepProps, CountryZipRegex}; +type SubStepsValues = { + [TKey in keyof PersonalDetailsForm]: PersonalDetailsForm[TKey]; +}; + +export type {CustomSubStepProps, CountryZipRegex, SubStepsValues}; diff --git a/src/pages/ReimbursementAccount/PersonalInfo/PersonalInfo.tsx b/src/pages/ReimbursementAccount/PersonalInfo/PersonalInfo.tsx index f955572da738..84c3cc5bab5e 100644 --- a/src/pages/ReimbursementAccount/PersonalInfo/PersonalInfo.tsx +++ b/src/pages/ReimbursementAccount/PersonalInfo/PersonalInfo.tsx @@ -7,7 +7,6 @@ import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import useLocalize from '@hooks/useLocalize'; import useSubStep from '@hooks/useSubStep'; import type {SubStepProps} from '@hooks/useSubStep/types'; -import useThemeStyles from '@hooks/useThemeStyles'; import getInitialSubstepForPersonalInfo from '@pages/ReimbursementAccount/utils/getInitialSubstepForPersonalInfo'; import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues'; import * as BankAccounts from '@userActions/BankAccounts'; @@ -40,7 +39,6 @@ const bodyContent: Array> = [FullName, DateOfB function PersonalInfo({reimbursementAccount, reimbursementAccountDraft, onBackButtonPress}: PersonalInfoProps, ref: React.ForwardedRef) { const {translate} = useLocalize(); - const styles = useThemeStyles(); const policyID = reimbursementAccount?.achData?.policyID ?? '-1'; const values = useMemo(() => getSubstepValues(PERSONAL_INFO_STEP_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]); From 0910231da82b97cea870fe3807af5b937c5e007b Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Fri, 4 Oct 2024 08:29:59 +0530 Subject: [PATCH 016/312] Use localize with libs/memoize --- src/libs/Localize/index.ts | 45 ++++++-------------------------------- 1 file changed, 7 insertions(+), 38 deletions(-) diff --git a/src/libs/Localize/index.ts b/src/libs/Localize/index.ts index bd8a34406846..d342b158657f 100644 --- a/src/libs/Localize/index.ts +++ b/src/libs/Localize/index.ts @@ -1,7 +1,7 @@ import * as RNLocalize from 'react-native-localize'; import Onyx from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; import Log from '@libs/Log'; +import memoize from '@libs/memoize'; import type {MessageElementBase, MessageTextElement} from '@libs/MessageElement'; import Config from '@src/CONFIG'; import CONST from '@src/CONST'; @@ -45,28 +45,6 @@ function init() { }, {}); } -/** - * Map to store translated values for each locale. - * This is used to avoid translating the same phrase multiple times. - * - * The data is stored in the following format: - * - * { - * "en": { - * "name": "Name", - * } - * - * Note: We are not storing any translated values for phrases with variables, - * as they have higher chance of being unique, so we'll end up wasting space - * in our cache. - */ -const translationCache = new Map, Map>( - Object.values(CONST.LOCALES).reduce((cache, locale) => { - cache.push([locale, new Map()]); - return cache; - }, [] as Array<[ValueOf, Map]>), -); - /** * Helper function to get the translated string for given * locale and phrase. This function is used to avoid @@ -86,18 +64,6 @@ function getTranslatedPhrase( fallbackLanguage: 'en' | 'es' | null, ...parameters: TranslationParameters ): string | null { - // Get the cache for the above locale - const cacheForLocale = translationCache.get(language); - - // Directly access and assign the translated value from the cache, instead of - // going through map.has() and map.get() to avoid multiple lookups. - const valueFromCache = cacheForLocale?.get(phraseKey); - - // If the phrase is already translated, return the translated value - if (valueFromCache) { - return valueFromCache; - } - const translatedPhrase = translations?.[language]?.[phraseKey]; if (translatedPhrase) { @@ -138,8 +104,6 @@ function getTranslatedPhrase( return translateResult.other(phraseObject.count); } - // We set the translated value in the cache only for the phrases without parameters. - cacheForLocale?.set(phraseKey, translatedPhrase); return translatedPhrase; } @@ -162,6 +126,11 @@ function getTranslatedPhrase( return getTranslatedPhrase(CONST.LOCALES.DEFAULT, phraseKey, null, ...parameters); } +const memoizedGetTranslatedPhrase = memoize(getTranslatedPhrase, { + monitoringName: 'getTranslatedPhrase', + transformKey: ([language, phraseKey, fallbackLanguage, ...parameters]) => `${language}-${phraseKey}-${fallbackLanguage}-${parameters.length > 0 ? JSON.stringify(parameters.at(0)) : ''}`, +}); + /** * Return translated string for given locale and phrase * @@ -174,7 +143,7 @@ function translate(desiredLanguage: 'en' | 'es' // Phrase is not found in full locale, search it in fallback language e.g. es const languageAbbreviation = desiredLanguage.substring(0, 2) as 'en' | 'es'; - const translatedPhrase = getTranslatedPhrase(language, path, languageAbbreviation, ...parameters); + const translatedPhrase = memoizedGetTranslatedPhrase(language, path, languageAbbreviation, ...parameters); if (translatedPhrase !== null && translatedPhrase !== undefined) { return translatedPhrase; } From d0b68c9be2f73c0ea2a628ebda16b203d3088c2f Mon Sep 17 00:00:00 2001 From: burczu Date: Mon, 7 Oct 2024 08:37:44 +0200 Subject: [PATCH 017/312] styling fixed --- .../substeps/Address.tsx | 160 +++++++++--------- .../substeps/DateOfBirth.tsx | 27 +-- .../substeps/LegalName.tsx | 56 +++--- .../substeps/PhoneNumber.tsx | 29 ++-- 4 files changed, 141 insertions(+), 131 deletions(-) diff --git a/src/pages/MissingPersonalDetails/substeps/Address.tsx b/src/pages/MissingPersonalDetails/substeps/Address.tsx index 153027db6fa1..dcf34631e0b3 100644 --- a/src/pages/MissingPersonalDetails/substeps/Address.tsx +++ b/src/pages/MissingPersonalDetails/substeps/Address.tsx @@ -126,105 +126,107 @@ function AddressStep({privatePersonalDetails, isEditing, onNext}: CustomSubStepP submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')} onSubmit={handleSubmit} validate={validate} - style={[styles.mh0, styles.flexGrow1]} + style={[styles.mh5, styles.flexGrow1]} submitButtonStyles={[styles.mb0]} > - {translate('privatePersonalDetails.enterAddress')} + {translate('privatePersonalDetails.enterAddress')} + + { + handleAddressChange(data, key); + }} + defaultValue={street1} + containerStyles={styles.mt3} + renamedInputKeys={{ + street: INPUT_IDS.ADDRESS_LINE_1, + street2: INPUT_IDS.ADDRESS_LINE_2, + city: INPUT_IDS.CITY, + state: INPUT_IDS.STATE, + zipCode: INPUT_IDS.ZIP_POST_CODE, + country: INPUT_IDS.COUNTRY as Country, + }} + maxInputLength={CONST.FORM_CHARACTER_LIMIT} + shouldSaveDraft={!isEditing} + /> + { - handleAddressChange(data, key); - }} - defaultValue={street1} - containerStyles={styles.mt3} - renamedInputKeys={{ - street: INPUT_IDS.ADDRESS_LINE_1, - street2: INPUT_IDS.ADDRESS_LINE_2, - city: INPUT_IDS.CITY, - state: INPUT_IDS.STATE, - zipCode: INPUT_IDS.ZIP_POST_CODE, - country: INPUT_IDS.COUNTRY as Country, - }} - maxInputLength={CONST.FORM_CHARACTER_LIMIT} - shouldSaveDraft={!isEditing} - /> - - - - - - {isUSAForm ? ( - ) : ( + {isUSAForm ? ( + + + + ) : ( + + )} - )} - - + + ); } diff --git a/src/pages/MissingPersonalDetails/substeps/DateOfBirth.tsx b/src/pages/MissingPersonalDetails/substeps/DateOfBirth.tsx index e5850240d071..ec63fdca2a17 100644 --- a/src/pages/MissingPersonalDetails/substeps/DateOfBirth.tsx +++ b/src/pages/MissingPersonalDetails/substeps/DateOfBirth.tsx @@ -1,5 +1,6 @@ import {subYears} from 'date-fns'; import React, {useCallback} from 'react'; +import {View} from 'react-native'; import DatePicker from '@components/DatePicker'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; @@ -48,20 +49,22 @@ function DateOfBirthStep({privatePersonalDetails, isEditing, onNext}: CustomSubS submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')} onSubmit={handleSubmit} validate={validate} - style={[styles.mh0, styles.flexGrow1]} + style={[styles.mh5, styles.flexGrow1]} submitButtonStyles={[styles.mb0]} > - {translate('privatePersonalDetails.enterDateOfBirth')} - + + {translate('privatePersonalDetails.enterDateOfBirth')} + + ); } diff --git a/src/pages/MissingPersonalDetails/substeps/LegalName.tsx b/src/pages/MissingPersonalDetails/substeps/LegalName.tsx index 6b41791f3695..e2926cc7dbb8 100644 --- a/src/pages/MissingPersonalDetails/substeps/LegalName.tsx +++ b/src/pages/MissingPersonalDetails/substeps/LegalName.tsx @@ -48,35 +48,37 @@ function LegalNameStep({privatePersonalDetails, isEditing, onNext}: CustomSubSte submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')} validate={validate} onSubmit={handleSubmit} - style={[styles.mh0, styles.flexGrow1]} + style={[styles.mh5, styles.flexGrow1]} submitButtonStyles={[styles.mb0]} > - {translate('privatePersonalDetails.enterLegalName')} - - - - - + + {translate('privatePersonalDetails.enterLegalName')} + + + + + + ); diff --git a/src/pages/MissingPersonalDetails/substeps/PhoneNumber.tsx b/src/pages/MissingPersonalDetails/substeps/PhoneNumber.tsx index f38e69a53ca7..b76e7a80f80d 100644 --- a/src/pages/MissingPersonalDetails/substeps/PhoneNumber.tsx +++ b/src/pages/MissingPersonalDetails/substeps/PhoneNumber.tsx @@ -1,5 +1,6 @@ import {Str} from 'expensify-common'; import React, {useCallback} from 'react'; +import {View} from 'react-native'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; @@ -50,21 +51,23 @@ function PhoneNumberStep({privatePersonalDetails, isEditing, onNext}: CustomSubS submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')} onSubmit={handleSubmit} validate={validate} - style={[styles.mh0, styles.flexGrow1]} + style={[styles.mh5, styles.flexGrow1]} submitButtonStyles={[styles.mb0]} > - {translate('privatePersonalDetails.enterPhoneNumber')} - + + {translate('privatePersonalDetails.enterPhoneNumber')} + + ); } From 6c9be432f4c6894aa1f60ea7668afe1a2193c52c Mon Sep 17 00:00:00 2001 From: burczu Date: Mon, 7 Oct 2024 13:14:04 +0200 Subject: [PATCH 018/312] missing personal details adjusted to use interactive step wrapper --- src/components/InteractiveStepSubHeader.tsx | 9 +- src/hooks/useSubStep/index.ts | 7 +- src/pages/MissingPersonalDetails/index.tsx | 85 +++++++------------ .../utils/getFormValues.tsx | 20 +++++ .../utils/getInitialStepForPersonalInfo.tsx | 31 +++++++ 5 files changed, 94 insertions(+), 58 deletions(-) create mode 100644 src/pages/MissingPersonalDetails/utils/getFormValues.tsx create mode 100644 src/pages/MissingPersonalDetails/utils/getInitialStepForPersonalInfo.tsx diff --git a/src/components/InteractiveStepSubHeader.tsx b/src/components/InteractiveStepSubHeader.tsx index d8899a317df5..483e8857461b 100644 --- a/src/components/InteractiveStepSubHeader.tsx +++ b/src/components/InteractiveStepSubHeader.tsx @@ -1,5 +1,5 @@ import type {ForwardedRef} from 'react'; -import React, {forwardRef, useImperativeHandle, useState} from 'react'; +import React, {forwardRef, useEffect, useImperativeHandle, useState} from 'react'; import type {ViewStyle} from 'react-native'; import {View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -36,12 +36,17 @@ const MIN_AMOUNT_OF_STEPS = 2; function InteractiveStepSubHeader({stepNames, startStepIndex = 0, onStepSelected}: InteractiveStepSubHeaderProps, ref: ForwardedRef) { const styles = useThemeStyles(); const containerWidthStyle: ViewStyle = stepNames.length < MIN_AMOUNT_FOR_EXPANDING ? styles.mnw60 : styles.mnw100; + const [currentStep, setCurrentStep] = useState(startStepIndex); if (stepNames.length < MIN_AMOUNT_OF_STEPS) { throw new Error(`stepNames list must have at least ${MIN_AMOUNT_OF_STEPS} elements.`); } - const [currentStep, setCurrentStep] = useState(startStepIndex); + useEffect(() => { + // make sure to update current step if startStepIndex changes outside the component + setCurrentStep(startStepIndex); + }, [startStepIndex]); + useImperativeHandle( ref, () => ({ diff --git a/src/hooks/useSubStep/index.ts b/src/hooks/useSubStep/index.ts index c89bb5fd5735..229140f71e83 100644 --- a/src/hooks/useSubStep/index.ts +++ b/src/hooks/useSubStep/index.ts @@ -1,4 +1,4 @@ -import {useCallback, useRef, useState} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; import type {SubStepProps, UseSubStep} from './types'; /** @@ -12,6 +12,11 @@ export default function useSubStep({bodyContent, on const [screenIndex, setScreenIndex] = useState(startFrom); const isEditing = useRef(false); + useEffect(() => { + // make sure the screen index is updated whenever the startFrom prop changes + setScreenIndex(startFrom); + }, [startFrom]); + const prevScreen = useCallback(() => { const prevScreenIndex = screenIndex - 1; diff --git a/src/pages/MissingPersonalDetails/index.tsx b/src/pages/MissingPersonalDetails/index.tsx index 01e1270baf4c..9e5667b33397 100644 --- a/src/pages/MissingPersonalDetails/index.tsx +++ b/src/pages/MissingPersonalDetails/index.tsx @@ -1,49 +1,43 @@ /* eslint-disable no-case-declarations */ -import React, {useCallback, useMemo, useRef} from 'react'; -import type {ForwardedRef} from 'react'; -import {View} from 'react-native'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {useOnyx} from 'react-native-onyx'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; -import type {InteractiveStepSubHeaderHandle} from '@components/InteractiveStepSubHeader'; -import ScreenWrapper from '@components/ScreenWrapper'; +import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import useLocalize from '@hooks/useLocalize'; import useSubStep from '@hooks/useSubStep'; -import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; +import * as FormActions from '@userActions/FormActions'; import * as PersonalDetails from '@userActions/PersonalDetails'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetailsForm} from '@src/types/form/PersonalDetailsForm'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; import Address from './substeps/Address'; import DateOfBirth from './substeps/DateOfBirth'; import LegalName from './substeps/LegalName'; import PhoneNumber from './substeps/PhoneNumber'; -import type {CustomSubStepProps, SubStepsValues} from './types'; +import type {CustomSubStepProps} from './types'; +import getFormValues from './utils/getFormValues'; +import getInitialStepForPersonalInfo from './utils/getInitialStepForPersonalInfo'; const formSteps = [LegalName, DateOfBirth, Address, PhoneNumber]; function MissingPersonalDetails() { - const styles = useThemeStyles(); const {translate} = useLocalize(); - const ref: ForwardedRef = useRef(null); const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); const [personalDetailsForm] = useOnyx(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM); const [personalDetailsFormDraft] = useOnyx(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM_DRAFT); const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); + const [currentStep, setCurrentStep] = useState(getInitialStepForPersonalInfo(personalDetailsFormDraft)); + + useEffect(() => { + setCurrentStep(getInitialStepForPersonalInfo(personalDetailsFormDraft)); + }, [personalDetailsFormDraft]); const firstUnissuedCard = useMemo(() => Object.values(cardList ?? {}).find((card) => card.state === CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED), [cardList]); - const values = useMemo(() => { - return Object.entries(INPUT_IDS).reduce((acc, [, value]) => { - // @ts-expect-error complaints about Country not being a string, but it is - acc[value] = (personalDetailsFormDraft?.[value] ?? personalDetailsForm?.[value] ?? '') as PersonalDetailsForm[keyof PersonalDetailsForm]; - return acc; - }, {} as SubStepsValues); - }, [personalDetailsForm, personalDetailsFormDraft]); + const values = useMemo(() => getFormValues(INPUT_IDS, personalDetailsFormDraft, personalDetailsForm), [personalDetailsForm, personalDetailsFormDraft]); const handleFinishStep = useCallback(() => { PersonalDetails.updatePersonalDetailsAndShipExpensifyCard(values, firstUnissuedCard?.cardID ?? 0); + FormActions.clearDraftValues(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM); Navigation.goBack(); }, [firstUnissuedCard?.cardID, values]); @@ -57,9 +51,9 @@ function MissingPersonalDetails() { goToTheLastStep, } = useSubStep({ bodyContent: formSteps, - startFrom: CONST.MISSING_PERSONAL_DETAILS_INDEXES.MAPPING.LEGAL_NAME, + startFrom: currentStep, onFinished: handleFinishStep, - onNextSubStep: () => ref.current?.moveNext(), + onNextSubStep: () => setCurrentStep(currentStep + 1), }); const handleBackButtonPress = () => { @@ -73,47 +67,28 @@ function MissingPersonalDetails() { Navigation.goBack(); return; } - ref.current?.movePrevious(); + setCurrentStep(currentStep - 1); prevScreen(); }; - // TODO: consider if this is necessary - // const handleNextScreen = useCallback(() => { - // if (isEditing) { - // goToTheLastStep(); - // return; - // } - // ref.current?.moveNext(); - // nextScreen(); - // }, [goToTheLastStep, isEditing, nextScreen]); - return ( - - - - - - - - - + ); } diff --git a/src/pages/MissingPersonalDetails/utils/getFormValues.tsx b/src/pages/MissingPersonalDetails/utils/getFormValues.tsx new file mode 100644 index 000000000000..aa58c476602b --- /dev/null +++ b/src/pages/MissingPersonalDetails/utils/getFormValues.tsx @@ -0,0 +1,20 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import type {PersonalDetailsForm} from '@src/types/form'; + +type FormValues = { + [TKey in TProps]: PersonalDetailsForm[TKey]; +}; + +function getFormValues( + inputKeys: Record, + personalDetailsFormDraft: OnyxEntry, + personalDetailsForm: OnyxEntry, +): FormValues { + return Object.entries(inputKeys).reduce((acc, [, value]) => { + // @ts-expect-error complaints about Country not being a string, but it is + acc[value] = (personalDetailsFormDraft?.[value] ?? personalDetailsForm?.[value] ?? '') as PersonalDetailsForm[keyof PersonalDetailsForm]; + return acc; + }, {} as FormValues); +} + +export default getFormValues; diff --git a/src/pages/MissingPersonalDetails/utils/getInitialStepForPersonalInfo.tsx b/src/pages/MissingPersonalDetails/utils/getInitialStepForPersonalInfo.tsx new file mode 100644 index 000000000000..c1353dfb0002 --- /dev/null +++ b/src/pages/MissingPersonalDetails/utils/getInitialStepForPersonalInfo.tsx @@ -0,0 +1,31 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import type {PersonalDetailsForm} from '@src/types/form/PersonalDetailsForm'; +import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; + +/** + * Returns the initial step for the Personal Info step based on already existing data + */ +function getInitialStepForPersonalInfo(data: OnyxEntry): number { + if (!data?.[INPUT_IDS.LEGAL_FIRST_NAME] || !data?.[INPUT_IDS.LEGAL_LAST_NAME]) { + return 0; + } + + if (!data?.[INPUT_IDS.DATE_OF_BIRTH]) { + return 1; + } + + if ( + !data?.[INPUT_IDS.ADDRESS_LINE_1] || + !data?.[INPUT_IDS.ADDRESS_LINE_2] || + !data?.[INPUT_IDS.CITY] || + !data?.[INPUT_IDS.STATE] || + !data?.[INPUT_IDS.ZIP_POST_CODE] || + !data?.[INPUT_IDS.COUNTRY] + ) { + return 2; + } + + return 3; +} + +export default getInitialStepForPersonalInfo; From 2b3b92b4b777a0f57e74b41cd74edd216babdb28 Mon Sep 17 00:00:00 2001 From: burczu Date: Mon, 7 Oct 2024 15:53:20 +0200 Subject: [PATCH 019/312] handling draft and form correctly --- src/libs/actions/FormActions.ts | 10 +++++++++- src/pages/MissingPersonalDetails/index.tsx | 7 ++++--- .../MissingPersonalDetails/substeps/Address.tsx | 12 ++++++++++-- .../MissingPersonalDetails/substeps/DateOfBirth.tsx | 12 ++++++++++-- .../MissingPersonalDetails/substeps/LegalName.tsx | 12 ++++++++++-- .../MissingPersonalDetails/substeps/PhoneNumber.tsx | 13 +++++++++++-- 6 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/libs/actions/FormActions.ts b/src/libs/actions/FormActions.ts index 5fe1705d8db3..df9f5c0a2a94 100644 --- a/src/libs/actions/FormActions.ts +++ b/src/libs/actions/FormActions.ts @@ -31,4 +31,12 @@ function clearDraftValues(formID: OnyxFormKey) { Onyx.set(`${formID}Draft`, null); } -export {clearDraftValues, clearErrorFields, clearErrors, setDraftValues, setErrorFields, setErrors, setIsLoading}; +function setFormValues(formId: OnyxFormKey, values: NullishDeep>) { + Onyx.merge(formId, values ?? null); +} + +function clearFormValues(formId: OnyxFormKey) { + Onyx.set(formId, null); +} + +export {clearDraftValues, clearErrorFields, clearErrors, setDraftValues, setErrorFields, setErrors, setIsLoading, setFormValues, clearFormValues}; diff --git a/src/pages/MissingPersonalDetails/index.tsx b/src/pages/MissingPersonalDetails/index.tsx index 9e5667b33397..78a6bf408d7b 100644 --- a/src/pages/MissingPersonalDetails/index.tsx +++ b/src/pages/MissingPersonalDetails/index.tsx @@ -26,17 +26,18 @@ function MissingPersonalDetails() { const [personalDetailsForm] = useOnyx(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM); const [personalDetailsFormDraft] = useOnyx(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM_DRAFT); const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); - const [currentStep, setCurrentStep] = useState(getInitialStepForPersonalInfo(personalDetailsFormDraft)); + const [currentStep, setCurrentStep] = useState(getInitialStepForPersonalInfo(personalDetailsForm)); useEffect(() => { - setCurrentStep(getInitialStepForPersonalInfo(personalDetailsFormDraft)); - }, [personalDetailsFormDraft]); + setCurrentStep(getInitialStepForPersonalInfo(personalDetailsForm)); + }, [personalDetailsForm]); const firstUnissuedCard = useMemo(() => Object.values(cardList ?? {}).find((card) => card.state === CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED), [cardList]); const values = useMemo(() => getFormValues(INPUT_IDS, personalDetailsFormDraft, personalDetailsForm), [personalDetailsForm, personalDetailsFormDraft]); const handleFinishStep = useCallback(() => { PersonalDetails.updatePersonalDetailsAndShipExpensifyCard(values, firstUnissuedCard?.cardID ?? 0); + FormActions.clearFormValues(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM); FormActions.clearDraftValues(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM); Navigation.goBack(); }, [firstUnissuedCard?.cardID, values]); diff --git a/src/pages/MissingPersonalDetails/substeps/Address.tsx b/src/pages/MissingPersonalDetails/substeps/Address.tsx index dcf34631e0b3..22bfff7844db 100644 --- a/src/pages/MissingPersonalDetails/substeps/Address.tsx +++ b/src/pages/MissingPersonalDetails/substeps/Address.tsx @@ -15,11 +15,13 @@ import useThemeStyles from '@hooks/useThemeStyles'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ValidationUtils from '@libs/ValidationUtils'; import type {CountryZipRegex, CustomSubStepProps} from '@pages/MissingPersonalDetails/types'; +import * as FormActions from '@userActions/FormActions'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; import type {Address} from '@src/types/onyx/PrivatePersonalDetails'; +import {useOnyx} from "react-native-onyx"; const STEP_FIELDS = [INPUT_IDS.ADDRESS_LINE_1, INPUT_IDS.ADDRESS_LINE_2, INPUT_IDS.CITY, INPUT_IDS.STATE, INPUT_IDS.COUNTRY, INPUT_IDS.ZIP_POST_CODE]; @@ -108,12 +110,18 @@ function AddressStep({privatePersonalDetails, isEditing, onNext}: CustomSubStepP setZipcode(addressPart); }, []); - const handleSubmit = usePersonalDetailsStepFormSubmit({ + const submitPersonalDetails = usePersonalDetailsStepFormSubmit({ fieldIds: STEP_FIELDS, onNext, - shouldSaveDraft: isEditing, + shouldSaveDraft: true, }); + const handleSubmit = (values: FormOnyxValues<'personalDetailsForm'>) => { + // in case the address is taken from existing personal details object, we need to force apply its values to the draft object + FormActions.setFormValues(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM, values); + submitPersonalDetails(values); + }; + const isUSAForm = currentCountry === CONST.COUNTRY.US; const zipSampleFormat = (currentCountry && (CONST.COUNTRY_ZIP_REGEX_DATA[currentCountry] as CountryZipRegex)?.samples) ?? ''; diff --git a/src/pages/MissingPersonalDetails/substeps/DateOfBirth.tsx b/src/pages/MissingPersonalDetails/substeps/DateOfBirth.tsx index ec63fdca2a17..bee0d07aa18b 100644 --- a/src/pages/MissingPersonalDetails/substeps/DateOfBirth.tsx +++ b/src/pages/MissingPersonalDetails/substeps/DateOfBirth.tsx @@ -1,6 +1,7 @@ import {subYears} from 'date-fns'; import React, {useCallback} from 'react'; import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import DatePicker from '@components/DatePicker'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; @@ -11,6 +12,7 @@ import usePersonalDetailsStepFormSubmit from '@hooks/usePersonalDetailsStepFormS import useThemeStyles from '@hooks/useThemeStyles'; import * as ValidationUtils from '@libs/ValidationUtils'; import type {CustomSubStepProps} from '@pages/MissingPersonalDetails/types'; +import * as FormActions from '@userActions/FormActions'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; @@ -37,12 +39,18 @@ function DateOfBirthStep({privatePersonalDetails, isEditing, onNext}: CustomSubS [translate], ); - const handleSubmit = usePersonalDetailsStepFormSubmit({ + const submitPersonalDetails = usePersonalDetailsStepFormSubmit({ fieldIds: STEP_FIELDS, onNext, - shouldSaveDraft: isEditing, + shouldSaveDraft: true, }); + const handleSubmit = (values: FormOnyxValues<'personalDetailsForm'>) => { + // in case the dob is taken from existing personal details object, we need to force apply its values to the draft object + FormActions.setFormValues(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM, values); + submitPersonalDetails(values); + }; + return ( ) => { + // in case the legal name is taken from existing personal details object, we need to force apply its values to the draft object + FormActions.setFormValues(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM, values); + submitPersonalDetails(values); + }; + return ( ) => { + // in case the phone number is taken from existing personal details object, we need to force apply its values to the draft object + FormActions.setFormValues(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM, values); + submitPersonalDetails(values); + }; + const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS); From 1e138f9db848024953192d83898ff09b1a18ed52 Mon Sep 17 00:00:00 2001 From: c3024 Date: Wed, 9 Oct 2024 14:43:03 +0530 Subject: [PATCH 020/312] write platform specific mute settings to backend --- src/CONST.ts | 1 + src/ONYXKEYS.ts | 3 ++ src/libs/API/types.ts | 1 + src/libs/actions/User.ts | 40 +++++++++++++++++-- .../settings/Preferences/PreferencesPage.tsx | 15 ++++++- 5 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index 7131fab28bdb..eb6850281ad8 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -515,6 +515,7 @@ const CONST = { ANDROID: 'android', WEB: 'web', DESKTOP: 'desktop', + MOBILEWEB: 'mobileweb', }, PLATFORM_SPECIFIC_KEYS: { CTRL: { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 2a325bb4a74f..3bac252d396c 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -118,6 +118,9 @@ const ONYXKEYS = { /** This NVP contains data associated with HybridApp */ NVP_TRYNEWDOT: 'nvp_tryNewDot', + /** Contains the platforms for which the user muted the sounds */ + NVP_MUTED_PLATFORMS: 'nvp_mutedPlatforms', + /** Contains the user preference for the LHN priority mode */ NVP_PRIORITY_MODE: 'nvp_priorityMode', diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index fe2d176847b2..17322ef10157 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -75,6 +75,7 @@ const WRITE_COMMANDS = { VALIDATE_SECONDARY_LOGIN: 'ValidateSecondaryLogin', UPDATE_PREFERRED_EMOJI_SKIN_TONE: 'UpdatePreferredEmojiSkinTone', UPDATE_CHAT_PRIORITY_MODE: 'UpdateChatPriorityMode', + TOGGLE_PLATFORM_MUTE: 'TogglePlatformMute', SET_CONTACT_METHOD_AS_DEFAULT: 'SetContactMethodAsDefault', UPDATE_THEME: 'UpdateTheme', UPDATE_STATUS: 'UpdateStatus', diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 9ea29506accc..00176e265cb6 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -52,11 +52,13 @@ import * as Session from './Session'; let currentUserAccountID = -1; let currentEmail = ''; +let authToken = ''; Onyx.connect({ key: ONYXKEYS.SESSION, callback: (value) => { currentUserAccountID = value?.accountID ?? -1; currentEmail = value?.email ?? ''; + authToken = value?.authToken ?? ''; }, }); @@ -982,8 +984,39 @@ function clearUserErrorMessage() { Onyx.merge(ONYXKEYS.USER, {error: ''}); } -function setMuteAllSounds(isMutedAllSounds: boolean) { - Onyx.merge(ONYXKEYS.USER, {isMutedAllSounds}); +// function setMuteAllSounds(isMutedAllSounds: boolean) { +// Onyx.merge(ONYXKEYS.USER, {isMutedAllSounds}); +// } + +function togglePlatformMute(platform, mutedPlatforms) { + const isPlatformMuted = mutedPlatforms?.includes(platform); + const newMutedPlatforms = isPlatformMuted + ? mutedPlatforms.filter((mutedPlatform) => mutedPlatform !== platform) + : [...mutedPlatforms, platform]; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_MUTED_PLATFORMS, + value: newMutedPlatforms, + }, + ]; + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_MUTED_PLATFORMS, + value: mutedPlatforms, + }, + ]; + + const parameters: GetTogglePlatformMuteParams = {authToken, platform}; + + API.write(WRITE_COMMANDS.TOGGLE_PLATFORM_MUTE, parameters, { + optimisticData, + failureData, + }); + + // Onyx.merge(ONYXKEYS.USER, {isMutedAllSounds: !isMutedAllSounds}); } /** @@ -1358,7 +1391,8 @@ export { subscribeToUserEvents, updatePreferredSkinTone, setShouldUseStagingServer, - setMuteAllSounds, + // setMuteAllSounds, + togglePlatformMute, clearUserErrorMessage, joinScreenShare, clearScreenShareRequest, diff --git a/src/pages/settings/Preferences/PreferencesPage.tsx b/src/pages/settings/Preferences/PreferencesPage.tsx index f2c5f0366640..8d0b5c89aec7 100755 --- a/src/pages/settings/Preferences/PreferencesPage.tsx +++ b/src/pages/settings/Preferences/PreferencesPage.tsx @@ -19,12 +19,22 @@ import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import getPlatform from '@libs/getPlatform'; +import * as Browser from '@libs/Browser'; function PreferencesPage() { const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); + + let platform = getPlatform(); + if (Browser.isMobile()) { + platform = CONST.PLATFORM.MOBILEWEB; + } + const [mutedPlatforms] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS); + const isPlatformMuted = mutedPlatforms.includes(platform); const [user] = useOnyx(ONYXKEYS.USER); const [preferredTheme] = useOnyx(ONYXKEYS.PREFERRED_THEME); + const styles = useThemeStyles(); const {translate, preferredLocale} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -79,8 +89,9 @@ function PreferencesPage() { User.togglePlatformMute(platform, mutedPlatforms)} /> From 2595cb8b69cb207c637209550cafc71fffc19776 Mon Sep 17 00:00:00 2001 From: c3024 Date: Wed, 9 Oct 2024 22:03:38 +0530 Subject: [PATCH 021/312] add types --- src/ONYXKEYS.ts | 2 ++ .../API/parameters/TogglePlatformMuteParams.ts | 8 ++++++++ src/libs/API/parameters/index.ts | 1 + src/libs/actions/User.ts | 16 +++++----------- .../settings/Preferences/PreferencesPage.tsx | 9 ++++----- 5 files changed, 20 insertions(+), 16 deletions(-) create mode 100644 src/libs/API/parameters/TogglePlatformMuteParams.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 3bac252d396c..f3755f9acea3 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1,4 +1,5 @@ import type {ValueOf} from 'type-fest'; +import type Platform from '@libs/getPlatform/types'; import type CONST from './CONST'; import type {OnboardingPurposeType} from './CONST'; import type * as FormTypes from './types/form'; @@ -899,6 +900,7 @@ type OnyxValuesMapping = { [ONYXKEYS.USER_METADATA]: OnyxTypes.UserMetadata; [ONYXKEYS.STASHED_SESSION]: OnyxTypes.Session; [ONYXKEYS.BETAS]: OnyxTypes.Beta[]; + [ONYXKEYS.NVP_MUTED_PLATFORMS]: Platform[]; [ONYXKEYS.NVP_PRIORITY_MODE]: ValueOf; [ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE]: OnyxTypes.BlockedFromConcierge; diff --git a/src/libs/API/parameters/TogglePlatformMuteParams.ts b/src/libs/API/parameters/TogglePlatformMuteParams.ts new file mode 100644 index 000000000000..a9d559f4e1d8 --- /dev/null +++ b/src/libs/API/parameters/TogglePlatformMuteParams.ts @@ -0,0 +1,8 @@ +import Platform from '@libs/getPlatform/types'; + +type TogglePlatformMuteParams = { + authToken: string; + platform: Platform; +}; + +export default TogglePlatformMuteParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index ca233021517f..29e1371990cd 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -330,3 +330,4 @@ export type {default as UpdateCompanyCardNameParams} from './UpdateCompanyCardNa export type {default as SetCompanyCardExportAccountParams} from './SetCompanyCardExportAccountParams'; export type {default as SetMissingPersonalDetailsAndShipExpensifyCardParams} from './SetMissingPersonalDetailsAndShipExpensifyCardParams'; export type {default as SetInvoicingTransferBankAccountParams} from './SetInvoicingTransferBankAccountParams'; +export type {default as TogglePlatformMuteParams} from './TogglePlatformMuteParams'; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 00176e265cb6..f9aff6ed2f9f 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -12,6 +12,7 @@ import type { RequestContactMethodValidateCodeParams, SetContactMethodAsDefaultParams, SetNameValuePairParams, + TogglePlatformMuteParams, UpdateChatPriorityModeParams, UpdateNewsletterSubscriptionParams, UpdatePreferredEmojiSkinToneParams, @@ -23,6 +24,7 @@ import type { import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; +import type Platform from '@libs/getPlatform/types'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; @@ -984,15 +986,9 @@ function clearUserErrorMessage() { Onyx.merge(ONYXKEYS.USER, {error: ''}); } -// function setMuteAllSounds(isMutedAllSounds: boolean) { -// Onyx.merge(ONYXKEYS.USER, {isMutedAllSounds}); -// } - -function togglePlatformMute(platform, mutedPlatforms) { +function togglePlatformMute(platform: Platform, mutedPlatforms: Platform[]) { const isPlatformMuted = mutedPlatforms?.includes(platform); - const newMutedPlatforms = isPlatformMuted - ? mutedPlatforms.filter((mutedPlatform) => mutedPlatform !== platform) - : [...mutedPlatforms, platform]; + const newMutedPlatforms = isPlatformMuted ? mutedPlatforms.filter((mutedPlatform) => mutedPlatform !== platform) : [...mutedPlatforms, platform]; const optimisticData: OnyxUpdate[] = [ { @@ -1009,14 +1005,12 @@ function togglePlatformMute(platform, mutedPlatforms) { }, ]; - const parameters: GetTogglePlatformMuteParams = {authToken, platform}; + const parameters: TogglePlatformMuteParams = {authToken, platform}; API.write(WRITE_COMMANDS.TOGGLE_PLATFORM_MUTE, parameters, { optimisticData, failureData, }); - - // Onyx.merge(ONYXKEYS.USER, {isMutedAllSounds: !isMutedAllSounds}); } /** diff --git a/src/pages/settings/Preferences/PreferencesPage.tsx b/src/pages/settings/Preferences/PreferencesPage.tsx index 8d0b5c89aec7..2fa592b7fbee 100755 --- a/src/pages/settings/Preferences/PreferencesPage.tsx +++ b/src/pages/settings/Preferences/PreferencesPage.tsx @@ -13,28 +13,27 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as Browser from '@libs/Browser'; +import getPlatform from '@libs/getPlatform'; import LocaleUtils from '@libs/LocaleUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import getPlatform from '@libs/getPlatform'; -import * as Browser from '@libs/Browser'; function PreferencesPage() { const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); - + let platform = getPlatform(); if (Browser.isMobile()) { platform = CONST.PLATFORM.MOBILEWEB; } const [mutedPlatforms] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS); - const isPlatformMuted = mutedPlatforms.includes(platform); + const isPlatformMuted = mutedPlatforms?.includes(platform); const [user] = useOnyx(ONYXKEYS.USER); const [preferredTheme] = useOnyx(ONYXKEYS.PREFERRED_THEME); - const styles = useThemeStyles(); const {translate, preferredLocale} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); From 414b756994bb259cb8364c07edc6a604b7e3ab5e Mon Sep 17 00:00:00 2001 From: c3024 Date: Wed, 9 Oct 2024 22:24:19 +0530 Subject: [PATCH 022/312] fix lint --- src/ONYXKEYS.ts | 2 +- src/libs/API/parameters/TogglePlatformMuteParams.ts | 2 +- src/libs/API/types.ts | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index f3755f9acea3..8767db52ba22 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1,5 +1,5 @@ import type {ValueOf} from 'type-fest'; -import type Platform from '@libs/getPlatform/types'; +import type Platform from './libs/getPlatform/types'; import type CONST from './CONST'; import type {OnboardingPurposeType} from './CONST'; import type * as FormTypes from './types/form'; diff --git a/src/libs/API/parameters/TogglePlatformMuteParams.ts b/src/libs/API/parameters/TogglePlatformMuteParams.ts index a9d559f4e1d8..83de3609590a 100644 --- a/src/libs/API/parameters/TogglePlatformMuteParams.ts +++ b/src/libs/API/parameters/TogglePlatformMuteParams.ts @@ -1,4 +1,4 @@ -import Platform from '@libs/getPlatform/types'; +import type Platform from '@libs/getPlatform/types'; type TogglePlatformMuteParams = { authToken: string; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index e0c81a729568..72a7794aebbb 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -465,6 +465,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_PREFERRED_EMOJI_SKIN_TONE]: Parameters.UpdatePreferredEmojiSkinToneParams; [WRITE_COMMANDS.UPDATE_CHAT_PRIORITY_MODE]: Parameters.UpdateChatPriorityModeParams; [WRITE_COMMANDS.SET_CONTACT_METHOD_AS_DEFAULT]: Parameters.SetContactMethodAsDefaultParams; + [WRITE_COMMANDS.TOGGLE_PLATFORM_MUTE]: Parameters.TogglePlatformMuteParams; [WRITE_COMMANDS.UPDATE_THEME]: Parameters.UpdateThemeParams; [WRITE_COMMANDS.UPDATE_STATUS]: Parameters.UpdateStatusParams; [WRITE_COMMANDS.CLEAR_STATUS]: null; From 172368db5b20796019dc53d5b1c53973bfde81f0 Mon Sep 17 00:00:00 2001 From: c3024 Date: Wed, 9 Oct 2024 22:25:56 +0530 Subject: [PATCH 023/312] prettier --- src/ONYXKEYS.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 8767db52ba22..1272f6e59099 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1,7 +1,7 @@ import type {ValueOf} from 'type-fest'; -import type Platform from './libs/getPlatform/types'; import type CONST from './CONST'; import type {OnboardingPurposeType} from './CONST'; +import type Platform from './libs/getPlatform/types'; import type * as FormTypes from './types/form'; import type * as OnyxTypes from './types/onyx'; import type Onboarding from './types/onyx/Onboarding'; From 97b893762d12d48f0757dff876e498dbfc2222ad Mon Sep 17 00:00:00 2001 From: c3024 Date: Wed, 9 Oct 2024 22:38:24 +0530 Subject: [PATCH 024/312] prettier and typecheck --- src/libs/actions/User.ts | 1 - src/pages/settings/Preferences/PreferencesPage.tsx | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index f9aff6ed2f9f..5ddbd36df4de 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -1385,7 +1385,6 @@ export { subscribeToUserEvents, updatePreferredSkinTone, setShouldUseStagingServer, - // setMuteAllSounds, togglePlatformMute, clearUserErrorMessage, joinScreenShare, diff --git a/src/pages/settings/Preferences/PreferencesPage.tsx b/src/pages/settings/Preferences/PreferencesPage.tsx index 2fa592b7fbee..c30858833168 100755 --- a/src/pages/settings/Preferences/PreferencesPage.tsx +++ b/src/pages/settings/Preferences/PreferencesPage.tsx @@ -29,7 +29,7 @@ function PreferencesPage() { if (Browser.isMobile()) { platform = CONST.PLATFORM.MOBILEWEB; } - const [mutedPlatforms] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS); + const [mutedPlatforms = []] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS); const isPlatformMuted = mutedPlatforms?.includes(platform); const [user] = useOnyx(ONYXKEYS.USER); const [preferredTheme] = useOnyx(ONYXKEYS.PREFERRED_THEME); @@ -89,7 +89,6 @@ function PreferencesPage() { User.togglePlatformMute(platform, mutedPlatforms)} /> From 9151aa9aa17abc8f73f9d7f0d4d1bfb88f532e26 Mon Sep 17 00:00:00 2001 From: Mykhailo Kravchenko Date: Thu, 10 Oct 2024 15:40:06 +0200 Subject: [PATCH 025/312] integrate updateInvoiceCompanyName --- .../UpdateInvoiceCompanyNameParams.ts | 7 ++ src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 3 + src/libs/actions/Policy/Policy.ts | 67 +++++++++++++++++++ .../WorkspaceInvoicingDetailsName.tsx | 3 +- 5 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 src/libs/API/parameters/UpdateInvoiceCompanyNameParams.ts diff --git a/src/libs/API/parameters/UpdateInvoiceCompanyNameParams.ts b/src/libs/API/parameters/UpdateInvoiceCompanyNameParams.ts new file mode 100644 index 000000000000..15cc73c12cb1 --- /dev/null +++ b/src/libs/API/parameters/UpdateInvoiceCompanyNameParams.ts @@ -0,0 +1,7 @@ +type UpdateInvoiceCompanyNameParams = { + authToken: string; + policyID: string; + companyName: string; +}; + +export default UpdateInvoiceCompanyNameParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index ed4798515c38..d10268651ea1 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -331,3 +331,4 @@ export type {default as SetCompanyCardExportAccountParams} from './SetCompanyCar export type {default as SetMissingPersonalDetailsAndShipExpensifyCardParams} from './SetMissingPersonalDetailsAndShipExpensifyCardParams'; export type {default as SetInvoicingTransferBankAccountParams} from './SetInvoicingTransferBankAccountParams'; export type {default as ConnectPolicyToQuickBooksDesktopParams} from './ConnectPolicyToQuickBooksDesktopParams'; +export type {default as UpdateInvoiceCompanyNameParams} from './UpdateInvoiceCompanyNameParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index d4f6e581a42f..43624b3601d5 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -410,6 +410,7 @@ const WRITE_COMMANDS = { SET_CARD_EXPORT_ACCOUNT: 'SetCardExportAccount', SET_MISSING_PERSONAL_DETAILS_AND_SHIP_EXPENSIFY_CARD: 'SetMissingPersonalDetailsAndShipExpensifyCard', SET_INVOICING_TRANSFER_BANK_ACCOUNT: 'SetInvoicingTransferBankAccount', + UPDATE_INVOICE_COMPANY_NAME: 'UpdateInvoiceCompanyName', } as const; type WriteCommand = ValueOf; @@ -829,7 +830,9 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_XERO_SYNC_SYNC_REIMBURSED_REPORTS]: Parameters.UpdateXeroGenericTypeParams; [WRITE_COMMANDS.UPDATE_XERO_SYNC_REIMBURSEMENT_ACCOUNT_ID]: Parameters.UpdateXeroGenericTypeParams; + // Invoice API [WRITE_COMMANDS.SET_INVOICING_TRANSFER_BANK_ACCOUNT]: Parameters.SetInvoicingTransferBankAccountParams; + [WRITE_COMMANDS.UPDATE_INVOICE_COMPANY_NAME]: Parameters.UpdateInvoiceCompanyNameParams; }; const READ_COMMANDS = { diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index ec37b2c4e1fa..98c6f46cae6f 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -51,6 +51,7 @@ import type { SetWorkspacePayerParams, SetWorkspaceReimbursementParams, UpdateCompanyCardNameParams, + UpdateInvoiceCompanyNameParams, UpdatePolicyAddressParams, UpdateWorkspaceAvatarParams, UpdateWorkspaceDescriptionParams, @@ -4738,6 +4739,71 @@ function clearAllPolicies() { Object.keys(allPolicies).forEach((key) => delete allPolicies[key]); } +function updateInvoiceCompanyName(policyID: string, companyName: string) { + const authToken = NetworkStore.getAuthToken(); + + if (!authToken) { + return; + } + + const policy = getPolicy(policyID); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + invoice: { + companyName, + pendingFields: { + companyName: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + invoice: { + pendingFields: { + companyName: null, + }, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + invoice: { + companyName: policy?.invoice?.companyName, + pendingFields: { + companyName: null, + }, + }, + }, + }, + ]; + + const parameters: UpdateInvoiceCompanyNameParams = { + authToken, + policyID, + companyName, + }; + + // TODO: Remove dev log + console.debug('[TEST] Updating invoice company name', parameters, {optimisticData, successData, failureData}); + + API.write(WRITE_COMMANDS.UPDATE_INVOICE_COMPANY_NAME, parameters, {optimisticData, successData, failureData}); +} + export { leaveWorkspace, addBillingCardAndRequestPolicyOwnerChange, @@ -4840,6 +4906,7 @@ export { setCompanyCardExportAccount, clearCompanyCardErrorField, verifySetupIntentAndRequestPolicyOwnerChange, + updateInvoiceCompanyName, }; export type {NewCustomUnit}; diff --git a/src/pages/workspace/invoices/WorkspaceInvoicingDetailsName.tsx b/src/pages/workspace/invoices/WorkspaceInvoicingDetailsName.tsx index 80f323431baa..2b933a4ab695 100644 --- a/src/pages/workspace/invoices/WorkspaceInvoicingDetailsName.tsx +++ b/src/pages/workspace/invoices/WorkspaceInvoicingDetailsName.tsx @@ -14,6 +14,7 @@ import * as ValidationUtils from '@libs/ValidationUtils'; import Navigation from '@navigation/Navigation'; import type {SettingsNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as Policy from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -31,7 +32,7 @@ function WorkspaceInvoicingDetailsName({route}: WorkspaceInvoicingDetailsNamePro // eslint-disable-next-line @typescript-eslint/no-unused-vars const submit = (values: FormOnyxValues) => { - // TODO: implement UpdateInvoiceCompanyName API call when it's supported + Policy.updateInvoiceCompanyName(policyID, values[INPUT_IDS.COMPANY_NAME]); Navigation.goBack(); }; From 6819752ab154097e310e49f2fac8578cfb730df2 Mon Sep 17 00:00:00 2001 From: Mykhailo Kravchenko Date: Thu, 10 Oct 2024 15:43:48 +0200 Subject: [PATCH 026/312] integrate updateInvoiceCompanyWebsite --- .../UpdateInvoiceCompanyWebsiteParams.ts | 7 ++ src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 + src/libs/actions/Policy/Policy.ts | 67 +++++++++++++++++++ .../WorkspaceInvoicingDetailsWebsite.tsx | 3 +- 5 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 src/libs/API/parameters/UpdateInvoiceCompanyWebsiteParams.ts diff --git a/src/libs/API/parameters/UpdateInvoiceCompanyWebsiteParams.ts b/src/libs/API/parameters/UpdateInvoiceCompanyWebsiteParams.ts new file mode 100644 index 000000000000..68087364d05c --- /dev/null +++ b/src/libs/API/parameters/UpdateInvoiceCompanyWebsiteParams.ts @@ -0,0 +1,7 @@ +type UpdateInvoiceCompanyWebsiteParams = { + authToken: string; + policyID: string; + companyWebsite: string; +}; + +export default UpdateInvoiceCompanyWebsiteParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index d10268651ea1..181d02f1f739 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -332,3 +332,4 @@ export type {default as SetMissingPersonalDetailsAndShipExpensifyCardParams} fro export type {default as SetInvoicingTransferBankAccountParams} from './SetInvoicingTransferBankAccountParams'; export type {default as ConnectPolicyToQuickBooksDesktopParams} from './ConnectPolicyToQuickBooksDesktopParams'; export type {default as UpdateInvoiceCompanyNameParams} from './UpdateInvoiceCompanyNameParams'; +export type {default as UpdateInvoiceCompanyWebsiteParams} from './UpdateInvoiceCompanyWebsiteParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 43624b3601d5..0a6ff5e52664 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -411,6 +411,7 @@ const WRITE_COMMANDS = { SET_MISSING_PERSONAL_DETAILS_AND_SHIP_EXPENSIFY_CARD: 'SetMissingPersonalDetailsAndShipExpensifyCard', SET_INVOICING_TRANSFER_BANK_ACCOUNT: 'SetInvoicingTransferBankAccount', UPDATE_INVOICE_COMPANY_NAME: 'UpdateInvoiceCompanyName', + UPDATE_INVOICE_COMPANY_WEBSITE: 'UpdateInvoiceCompanyWebsite', } as const; type WriteCommand = ValueOf; @@ -833,6 +834,7 @@ type WriteCommandParameters = { // Invoice API [WRITE_COMMANDS.SET_INVOICING_TRANSFER_BANK_ACCOUNT]: Parameters.SetInvoicingTransferBankAccountParams; [WRITE_COMMANDS.UPDATE_INVOICE_COMPANY_NAME]: Parameters.UpdateInvoiceCompanyNameParams; + [WRITE_COMMANDS.UPDATE_INVOICE_COMPANY_WEBSITE]: Parameters.UpdateInvoiceCompanyWebsiteParams; }; const READ_COMMANDS = { diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 98c6f46cae6f..1375637bff4e 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -52,6 +52,7 @@ import type { SetWorkspaceReimbursementParams, UpdateCompanyCardNameParams, UpdateInvoiceCompanyNameParams, + UpdateInvoiceCompanyWebsiteParams, UpdatePolicyAddressParams, UpdateWorkspaceAvatarParams, UpdateWorkspaceDescriptionParams, @@ -4804,6 +4805,71 @@ function updateInvoiceCompanyName(policyID: string, companyName: string) { API.write(WRITE_COMMANDS.UPDATE_INVOICE_COMPANY_NAME, parameters, {optimisticData, successData, failureData}); } +function updateInvoiceCompanyWebsite(policyID: string, companyWebsite: string) { + const authToken = NetworkStore.getAuthToken(); + + if (!authToken) { + return; + } + + const policy = getPolicy(policyID); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + invoice: { + companyWebsite, + pendingFields: { + companyWebsite: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + invoice: { + pendingFields: { + companyWebsite: null, + }, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + invoice: { + companyWebsite: policy?.invoice?.companyWebsite, + pendingFields: { + companyWebsite: null, + }, + }, + }, + }, + ]; + + const parameters: UpdateInvoiceCompanyWebsiteParams = { + authToken, + policyID, + companyWebsite, + }; + + // TODO: Remove dev log + console.debug('[TEST] Updating invoice company website', parameters, {optimisticData, successData, failureData}); + + API.write(WRITE_COMMANDS.UPDATE_INVOICE_COMPANY_WEBSITE, parameters, {optimisticData, successData, failureData}); +} + export { leaveWorkspace, addBillingCardAndRequestPolicyOwnerChange, @@ -4907,6 +4973,7 @@ export { clearCompanyCardErrorField, verifySetupIntentAndRequestPolicyOwnerChange, updateInvoiceCompanyName, + updateInvoiceCompanyWebsite, }; export type {NewCustomUnit}; diff --git a/src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx b/src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx index 0427aef81db3..cd2f559da3fa 100644 --- a/src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx +++ b/src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx @@ -16,6 +16,7 @@ import * as ValidationUtils from '@libs/ValidationUtils'; import Navigation from '@navigation/Navigation'; import type {SettingsNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import * as Policy from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -33,7 +34,7 @@ function WorkspaceInvoicingDetailsWebsite({route}: WorkspaceInvoicingDetailsWebs // eslint-disable-next-line @typescript-eslint/no-unused-vars const submit = (values: FormOnyxValues) => { - // TODO: implement UpdateInvoiceCompanyWebsite API call when it's supported + Policy.updateInvoiceCompanyWebsite(policyID, values[INPUT_IDS.COMPANY_WEBSITE]); Navigation.goBack(); }; From 266996a7b791e0e105919c551d6a82a20ad54749 Mon Sep 17 00:00:00 2001 From: c3024 Date: Thu, 10 Oct 2024 23:05:55 +0530 Subject: [PATCH 027/312] chnage mutedPlatforms to object --- src/ONYXKEYS.ts | 2 +- src/libs/actions/User.ts | 7 ++++--- src/pages/settings/Preferences/PreferencesPage.tsx | 9 +++------ 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 1272f6e59099..26e915648c29 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -900,7 +900,7 @@ type OnyxValuesMapping = { [ONYXKEYS.USER_METADATA]: OnyxTypes.UserMetadata; [ONYXKEYS.STASHED_SESSION]: OnyxTypes.Session; [ONYXKEYS.BETAS]: OnyxTypes.Beta[]; - [ONYXKEYS.NVP_MUTED_PLATFORMS]: Platform[]; + [ONYXKEYS.NVP_MUTED_PLATFORMS]: Partial>; [ONYXKEYS.NVP_PRIORITY_MODE]: ValueOf; [ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE]: OnyxTypes.BlockedFromConcierge; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 5ddbd36df4de..51a389975769 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -986,9 +986,10 @@ function clearUserErrorMessage() { Onyx.merge(ONYXKEYS.USER, {error: ''}); } -function togglePlatformMute(platform: Platform, mutedPlatforms: Platform[]) { - const isPlatformMuted = mutedPlatforms?.includes(platform); - const newMutedPlatforms = isPlatformMuted ? mutedPlatforms.filter((mutedPlatform) => mutedPlatform !== platform) : [...mutedPlatforms, platform]; +function togglePlatformMute(platform: Platform, mutedPlatforms: Partial>) { + const newMutedPlatforms = mutedPlatforms?.[platform] + ? {...mutedPlatforms, [platform]: undefined} // Remove platform if it's muted + : {...mutedPlatforms, [platform]: true}; // Add platform if it's not muted const optimisticData: OnyxUpdate[] = [ { diff --git a/src/pages/settings/Preferences/PreferencesPage.tsx b/src/pages/settings/Preferences/PreferencesPage.tsx index c30858833168..5dee30518533 100755 --- a/src/pages/settings/Preferences/PreferencesPage.tsx +++ b/src/pages/settings/Preferences/PreferencesPage.tsx @@ -25,12 +25,9 @@ import ROUTES from '@src/ROUTES'; function PreferencesPage() { const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); - let platform = getPlatform(); - if (Browser.isMobile()) { - platform = CONST.PLATFORM.MOBILEWEB; - } - const [mutedPlatforms = []] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS); - const isPlatformMuted = mutedPlatforms?.includes(platform); + const platform = Browser.isMobile() ? CONST.PLATFORM.MOBILEWEB : getPlatform(); + const [mutedPlatforms = {}] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS); + const isPlatformMuted = mutedPlatforms[platform]; const [user] = useOnyx(ONYXKEYS.USER); const [preferredTheme] = useOnyx(ONYXKEYS.PREFERRED_THEME); From 3cec7fdda80b3c0ef5312c620d8048ad469092f8 Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Mon, 14 Oct 2024 13:49:23 +0900 Subject: [PATCH 028/312] Added keyFilter to not cache dynamic keys --- src/libs/Localize/index.ts | 7 +++++-- src/libs/memoize/index.ts | 11 +++++++++++ src/libs/memoize/types.ts | 7 +++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/libs/Localize/index.ts b/src/libs/Localize/index.ts index d342b158657f..4a8b0bf168d4 100644 --- a/src/libs/Localize/index.ts +++ b/src/libs/Localize/index.ts @@ -9,6 +9,7 @@ import translations from '@src/languages/translations'; import type {PluralForm, TranslationParameters, TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Locale} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import LocaleListener from './LocaleListener'; import BaseLocaleListener from './LocaleListener/BaseLocaleListener'; @@ -127,8 +128,10 @@ function getTranslatedPhrase( } const memoizedGetTranslatedPhrase = memoize(getTranslatedPhrase, { - monitoringName: 'getTranslatedPhrase', - transformKey: ([language, phraseKey, fallbackLanguage, ...parameters]) => `${language}-${phraseKey}-${fallbackLanguage}-${parameters.length > 0 ? JSON.stringify(parameters.at(0)) : ''}`, + maxArgs: 2, + equality: 'shallow', + // eslint-disable-next-line @typescript-eslint/no-unused-vars + keyFilter: ([language, phraseKey, fallbackLanguage, ...parameters]) => !isEmptyObject(parameters.at(0)), }); /** diff --git a/src/libs/memoize/index.ts b/src/libs/memoize/index.ts index f02b1adbf5ba..1b9c78e793db 100644 --- a/src/libs/memoize/index.ts +++ b/src/libs/memoize/index.ts @@ -60,6 +60,17 @@ function memoize; + + statsEntry.trackTime('processingTime', fnTimeStart); + statsEntry.track('didHit', false); + + return result; + } + const truncatedArgs = truncateArgs(args, options.maxArgs); const key = options.transformKey ? options.transformKey(truncatedArgs) : (truncatedArgs as Key); diff --git a/src/libs/memoize/types.ts b/src/libs/memoize/types.ts index 9ee48c9dc790..71479f637c1d 100644 --- a/src/libs/memoize/types.ts +++ b/src/libs/memoize/types.ts @@ -52,6 +52,13 @@ type Options = { * @returns Key to use for caching */ transformKey?: (truncatedArgs: TakeFirst, MaxArgs>) => Key; + + /** + * Checks if the cache should be skipped for the given arguments. + * @param args Tuple of arguments passed to the memoized function. Does not work with constructable (see description). + * @returns boolean to whether to skip cache lookup and execute the function if true + */ + keyFilter?: (args: IsomorphicParameters) => boolean; } & InternalOptions; type ClientOptions = Partial, keyof InternalOptions>>; From 433ec49e2abdaa7c3180c4fff81ac642c038bbc9 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Mon, 14 Oct 2024 11:12:58 +0200 Subject: [PATCH 029/312] autocomplete POC --- .eslintignore | 1 + package.json | 1 + src/CONST.ts | 5 + .../Search/SearchRouter/SearchRouter.tsx | 199 +++- .../Search/SearchRouter/SearchRouterList.tsx | 65 +- src/components/Search/types.ts | 10 + .../Search/SearchQueryListItem.tsx | 3 + .../AutocompleteParser/autocompleteParser.js | 995 ++++++++++++++++++ .../autocompleteParser.peggy | 104 ++ src/libs/Permissions.ts | 2 +- src/libs/SearchAutocompleteUtils.ts | 14 + 11 files changed, 1379 insertions(+), 20 deletions(-) create mode 100644 src/libs/AutocompleteParser/autocompleteParser.js create mode 100644 src/libs/AutocompleteParser/autocompleteParser.peggy create mode 100644 src/libs/SearchAutocompleteUtils.ts diff --git a/.eslintignore b/.eslintignore index 162cc816ea80..49d445ef74a2 100644 --- a/.eslintignore +++ b/.eslintignore @@ -12,4 +12,5 @@ docs/assets/** web/gtm.js **/.expo/** src/libs/SearchParser/searchParser.js +src/libs/AutocompleteParser/autocompleteParser.js help/_scripts/** diff --git a/package.json b/package.json index e691ae9075ed..de51f75dc645 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "react-compiler-healthcheck": "react-compiler-healthcheck --verbose", "react-compiler-healthcheck-test": "react-compiler-healthcheck --verbose &> react-compiler-output.txt", "generate-search-parser": "peggy --format es -o src/libs/SearchParser/searchParser.js src/libs/SearchParser/searchParser.peggy ", + "generate-autocomplete-parser": "peggy --format es -o src/libs/AutocompleteParser/autocompleteParser.js src/libs/AutocompleteParser/autocompleteParser.peggy ", "web:prod": "http-server ./dist --cors" }, "dependencies": { diff --git a/src/CONST.ts b/src/CONST.ts index 7131fab28bdb..9cfe20e11d8e 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5609,6 +5609,11 @@ const CONST = { KEYWORD: 'keyword', IN: 'in', }, + SEARCH_ROUTER_ITEM_TYPE: { + CONTEXTUAL_SUGGESTION: 'contextualSuggestion', + AUTOCOMPLETE_SUGGESTION: 'autocompleteSuggestion', + SEARCH: 'seearchItem', + }, }, REFERRER: { diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 8f5ad55bc0c9..d610fccb950f 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -3,18 +3,25 @@ import debounce from 'lodash/debounce'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import {usePersonalDetails} from '@components/OnyxProvider'; import {useOptionsList} from '@components/OptionListContextProvider'; import type {SearchQueryJSON} from '@components/Search/types'; import type {SelectionListHandle} from '@components/SelectionList/types'; +import useActiveWorkspaceFromNavigationState from '@hooks/useActiveWorkspaceFromNavigationState'; import useDebouncedState from '@hooks/useDebouncedState'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import Log from '@libs/Log'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import {getAllTaxRates, getTagNamesFromTagsLists} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; +import {parseForAutocomplete} from '@libs/SearchAutocompleteUtils'; import * as SearchUtils from '@libs/SearchUtils'; import Navigation from '@navigation/Navigation'; import variables from '@styles/variables'; @@ -22,18 +29,74 @@ import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Policy, PolicyCategories, PolicyTagLists} from '@src/types/onyx'; import {useSearchRouterContext} from './SearchRouterContext'; import SearchRouterInput from './SearchRouterInput'; import SearchRouterList from './SearchRouterList'; +import type {ItemWithQuery} from './SearchRouterList'; const SEARCH_DEBOUNCE_DELAY = 150; +function getAutoCompleteTagsList(allPoliciesTagsLists: OnyxCollection, policyID?: string) { + const singlePolicyTagsList: PolicyTagLists | undefined = allPoliciesTagsLists?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]; + if (!singlePolicyTagsList) { + const uniqueTagNames = new Set(); + const tagListsUnpacked = Object.values(allPoliciesTagsLists ?? {}).filter((item) => !!item) as PolicyTagLists[]; + tagListsUnpacked + .map((policyTagLists) => { + return getTagNamesFromTagsLists(policyTagLists); + }) + .flat() + .forEach((tag) => uniqueTagNames.add(tag)); + return Array.from(uniqueTagNames); + } + return getTagNamesFromTagsLists(singlePolicyTagsList); +} + +function getAutocompleteStatusesList(type?: ValueOf) { + switch (type) { + case CONST.SEARCH.DATA_TYPES.INVOICE: { + return Object.values(CONST.SEARCH.STATUS.INVOICE); + } + case CONST.SEARCH.DATA_TYPES.CHAT: { + return Object.values(CONST.SEARCH.STATUS.CHAT); + } + case CONST.SEARCH.DATA_TYPES.EXPENSE: { + return Object.values(CONST.SEARCH.STATUS.EXPENSE); + } + case CONST.SEARCH.DATA_TYPES.TRIP: { + return Object.values(CONST.SEARCH.STATUS.TRIP); + } + default: + return Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP}); + } +} + +function getAutocompleteCategoriesList(allPolicyCategories: OnyxCollection, policyID?: string) { + const singlePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]; + const categoryList = singlePolicyCategories + ? Object.values(singlePolicyCategories) + : Object.values(allPolicyCategories ?? {}) + .map((policyCategories) => Object.values(policyCategories ?? {})) + .flat(); + const filteredCategoryList = categoryList.filter((category) => !!category); + return filteredCategoryList.map((category) => category.name); +} + +function getAutocompleteTaxList(allTaxRates: Record, policy?: OnyxEntry) { + if (policy) { + return Object.keys(policy?.taxRates?.taxes ?? {}).map((taxRateName) => taxRateName); + } + return Object.keys(allTaxRates).map((taxRateName) => taxRateName); +} + function SearchRouter() { const styles = useThemeStyles(); const {translate} = useLocalize(); const [betas] = useOnyx(ONYXKEYS.BETAS); const [recentSearches] = useOnyx(ONYXKEYS.RECENT_SEARCHES); const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false}); + const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]); const {isSmallScreenWidth} = useResponsiveLayout(); const {isSearchRouterDisplayed, closeSearchRouter} = useSearchRouterContext(); @@ -44,6 +107,28 @@ function SearchRouter() { const contextualReportID = useNavigationState, string | undefined>((state) => { return state?.routes.at(-1)?.params?.reportID; }); + + const activeWorkspaceID = useActiveWorkspaceFromNavigationState(); + const policy = usePolicy(activeWorkspaceID); + const typesAutocompleteList = Object.values(CONST.SEARCH.DATA_TYPES); + const statusesAutocompleteList = useMemo(() => getAutocompleteStatusesList(userSearchQuery?.type), [userSearchQuery?.type]); + const expenseTypes = Object.values(CONST.SEARCH.TRANSACTION_TYPE); + const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); + const categoryAutocompleteList = useMemo(() => getAutocompleteCategoriesList(allPolicyCategories, activeWorkspaceID), [allPolicyCategories, activeWorkspaceID]); + const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST); + const currencyAutocompleteList = Object.keys(currencyList ?? {}); + const [allPoliciesTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); + const tagAutocompleteList = useMemo(() => getAutoCompleteTagsList(allPoliciesTagsLists, activeWorkspaceID), [allPoliciesTagsLists, activeWorkspaceID]); + const allTaxRates = getAllTaxRates(); + const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(allTaxRates, policy), [policy, allTaxRates]); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); + const cardsAutocompleteList = Object.values(cardList ?? {}).map((card) => card.bank); + const personalDetails = usePersonalDetails(); + const participantsAutocompleteList = Object.values(personalDetails) + .filter((details) => details && details?.login) + // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style + .map((details) => details?.login as string); + const sortedRecentSearches = useMemo(() => { return Object.values(recentSearches ?? {}).sort((a, b) => b.timestamp.localeCompare(a.timestamp)); }, [recentSearches]); @@ -103,6 +188,104 @@ function SearchRouter() { setUserSearchQuery(undefined); }; + const updateAutocomplete = useCallback( + (autocompleteValue: string, autocompleteType?: ValueOf) => { + switch (autocompleteType) { + case 'in': { + return; + } + case 'tag': { + const filteredTags = tagAutocompleteList.filter((tag) => tag?.includes(autocompleteValue)); + setAutocompleteSuggestions( + filteredTags.map((tagName) => ({ + text: `tag:${tagName}`, + query: `${tagName}`, + })), + ); + return; + } + case 'category': { + const filteredCategories = categoryAutocompleteList.filter((category) => category?.includes(autocompleteValue)); + setAutocompleteSuggestions( + filteredCategories.map((categoryName) => ({ + text: `currency:${categoryName}`, + query: `${categoryName}`, + })), + ); + return; + } + case 'currency': { + const filteredCurrencies = currencyAutocompleteList.filter((currency) => currency?.includes(autocompleteValue)); + setAutocompleteSuggestions( + filteredCurrencies.map((currencyName) => ({ + text: `currency:${currencyName}`, + query: `${currencyName}`, + })), + ); + return; + } + case 'taxRate': { + const filteredTaxRates = taxAutocompleteList.filter((tax) => tax.includes(autocompleteValue)); + setAutocompleteSuggestions(filteredTaxRates.map((tax) => ({text: `type:${tax}`, query: `${tax}`}))); + return; + } + case 'from': { + const filteredParticipants = participantsAutocompleteList.filter((participant) => participant.includes(autocompleteValue)); + setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `from:${participant}`, query: `${participant}`}))); + return; + } + case 'to': { + const filteredParticipants = participantsAutocompleteList.filter((participant) => participant.includes(autocompleteValue)); + setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `to:${participant}`, query: `${participant}`}))); + return; + } + case 'type': { + const filteredTypes = typesAutocompleteList.filter((type) => type.includes(autocompleteValue)); + setAutocompleteSuggestions(filteredTypes.map((type) => ({text: `type:${type}`, query: `${type}`}))); + return; + } + case 'status': { + const filteredStatuses = statusesAutocompleteList.filter((status) => status.includes(autocompleteValue)); + setAutocompleteSuggestions(filteredStatuses.map((status) => ({text: `status:${status}`, query: `${status}`}))); + return; + } + case 'expenseType': { + const filteredExpenseTypes = expenseTypes.filter((expenseType) => expenseType.includes(autocompleteValue)); + setAutocompleteSuggestions( + filteredExpenseTypes.map((expenseType) => ({ + text: `expenseType:${expenseType}`, + query: `${expenseType}`, + })), + ); + return; + } + case 'cardID': { + const filteredCards = cardsAutocompleteList.filter((card) => card.includes(autocompleteValue)); + setAutocompleteSuggestions( + filteredCards.map((card) => ({ + text: `expenseType:${card}`, + query: `${card}`, + })), + ); + return; + } + default: + setAutocompleteSuggestions(undefined); + } + }, + [ + tagAutocompleteList, + categoryAutocompleteList, + currencyAutocompleteList, + taxAutocompleteList, + participantsAutocompleteList, + typesAutocompleteList, + statusesAutocompleteList, + expenseTypes, + cardsAutocompleteList, + ], + ); + const onSearchChange = useMemo( // eslint-disable-next-line react-compiler/react-compiler () => @@ -112,6 +295,9 @@ function SearchRouter() { listRef.current?.updateAndScrollToFocusedIndex(-1); return; } + const autocompleteParsedQuery = parseForAutocomplete(userQuery); + updateAutocomplete(autocompleteParsedQuery?.autocomplete?.value ?? '', autocompleteParsedQuery?.autocomplete?.key); + listRef.current?.updateAndScrollToFocusedIndex(0); const queryJSON = SearchUtils.buildSearchQueryJSON(userQuery); @@ -123,12 +309,12 @@ function SearchRouter() { }, SEARCH_DEBOUNCE_DELAY), // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps - [], + [updateAutocomplete], ); - const updateUserSearchQuery = (newSearchQuery: string) => { - setTextInputValue(newSearchQuery); - onSearchChange(newSearchQuery); + const updateSearchInputValue = (newValue: string) => { + setTextInputValue(newValue); + onSearchChange(newValue); }; const closeAndClearRouter = useCallback(() => { @@ -179,12 +365,13 @@ function SearchRouter() { isSearchingForReports={isSearchingForReports} /> diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index 7d86ce1150d5..276abb0d88ad 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -17,16 +17,18 @@ import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; import * as SearchUtils from '@libs/SearchUtils'; import * as Report from '@userActions/Report'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; type ItemWithQuery = { query: string; + text?: string; }; type SearchRouterListProps = { - /** currentQuery value computed coming from parsed TextInput value */ - currentQuery: SearchQueryJSON | undefined; + /** Value of TextInput */ + textInputValue: string; /** Recent searches */ recentSearches: ItemWithQuery[] | undefined; @@ -34,6 +36,9 @@ type SearchRouterListProps = { /** Recent reports */ recentReports: OptionData[]; + /** Autocomplete items */ + autocompleteItems: ItemWithQuery[] | undefined; + /** Callback to submit query when selecting a list item */ onSearchSubmit: (query: SearchQueryJSON | undefined) => void; @@ -41,7 +46,7 @@ type SearchRouterListProps = { reportForContextualSearch?: OptionData; /** Callback to update search query when selecting contextual suggestion */ - updateUserSearchQuery: (newSearchQuery: string) => void; + updateSearchInputValue: (newSearchQuery: string) => void; /** Callback to close and clear SearchRouter */ closeAndClearRouter: () => void; @@ -80,7 +85,16 @@ function SearchRouterItem(props: UserListItemProps | SearchQueryList } function SearchRouterList( - {currentQuery, reportForContextualSearch, recentSearches, recentReports, onSearchSubmit, updateUserSearchQuery, closeAndClearRouter}: SearchRouterListProps, + { + textInputValue, + reportForContextualSearch, + recentSearches, + recentReports, + autocompleteItems, + onSearchSubmit, + updateSearchInputValue: updateUserSearchQuery, + closeAndClearRouter, + }: SearchRouterListProps, ref: ForwardedRef, ) { const styles = useThemeStyles(); @@ -94,21 +108,22 @@ function SearchRouterList( const contextualQuery = `in:${reportForContextualSearch?.reportID}`; const sections: Array> = []; - if (currentQuery?.inputQuery) { + if (textInputValue) { sections.push({ data: [ { - text: currentQuery?.inputQuery, + text: textInputValue, singleIcon: Expensicons.MagnifyingGlass, - query: currentQuery?.inputQuery, + query: textInputValue, itemStyle: styles.activeComponentBG, keyForList: 'findItem', + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, }, ], }); } - if (reportForContextualSearch && !currentQuery?.inputQuery?.includes(contextualQuery)) { + if (reportForContextualSearch && !textInputValue.includes(contextualQuery)) { sections.push({ data: [ { @@ -117,12 +132,26 @@ function SearchRouterList( query: SearchUtils.getContextualSuggestionQuery(reportForContextualSearch.reportID), itemStyle: styles.activeComponentBG, keyForList: 'contextualSearch', - isContextualSearchItem: true, + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION, }, ], }); } + const autocompleteData = autocompleteItems?.map(({text, query}) => { + return { + text, + singleIcon: Expensicons.MagnifyingGlass, + query, + keyForList: query, + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION, + }; + }); + + if (autocompleteData && autocompleteData.length > 0) { + sections.push({title: 'Autocomplete', data: autocompleteData}); + } + const recentSearchesData = recentSearches?.map(({query}) => { const searchQueryJSON = SearchUtils.buildSearchQueryJSON(query); return { @@ -130,10 +159,11 @@ function SearchRouterList( singleIcon: Expensicons.History, query, keyForList: query, + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, }; }); - if (!currentQuery?.inputQuery && recentSearchesData && recentSearchesData.length > 0) { + if (!textInputValue && recentSearchesData && recentSearchesData.length > 0) { sections.push({title: translate('search.recentSearches'), data: recentSearchesData}); } @@ -143,9 +173,18 @@ function SearchRouterList( const onSelectRow = useCallback( (item: OptionData | SearchQueryItem) => { if (isSearchQueryItem(item)) { - if (item.isContextualSearchItem) { + if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) { // Handle selection of "Contextual search suggestion" - updateUserSearchQuery(`${item?.query} ${currentQuery?.inputQuery ?? ''}`); + updateUserSearchQuery(`${item?.query} ${textInputValue ?? ''}`); + return; + } + + if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION) { + // Handle selection of "Autocomplete suggestion" + const lastColonIndex = textInputValue.lastIndexOf(':'); + const lastComaIndex = textInputValue.lastIndexOf(','); + const trimmedTextInputValue = lastColonIndex > lastComaIndex ? textInputValue.slice(0, lastColonIndex + 1) : textInputValue.slice(0, lastComaIndex + 1); + updateUserSearchQuery(`${trimmedTextInputValue}${item?.query}`); return; } @@ -164,7 +203,7 @@ function SearchRouterList( Report.navigateToAndOpenReport(item?.login ? [item.login] : []); } }, - [closeAndClearRouter, onSearchSubmit, currentQuery, updateUserSearchQuery], + [closeAndClearRouter, onSearchSubmit, textInputValue, updateUserSearchQuery], ); return ( diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 3d35190bf1a4..152607af0ede 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -77,6 +77,15 @@ type SearchQueryJSON = { flatFilters: QueryFilters; } & SearchQueryAST; +type SearchAutocompleteResult = { + autocomplete: { + key: ValueOf; + length: number; + start: number; + value: string; + }; +}; + export type { SelectedTransactionInfo, SelectedTransactions, @@ -95,4 +104,5 @@ export type { InvoiceSearchStatus, TripSearchStatus, ChatSearchStatus, + SearchAutocompleteResult, }; diff --git a/src/components/SelectionList/Search/SearchQueryListItem.tsx b/src/components/SelectionList/Search/SearchQueryListItem.tsx index 369f527cdeba..cf1b75d95f17 100644 --- a/src/components/SelectionList/Search/SearchQueryListItem.tsx +++ b/src/components/SelectionList/Search/SearchQueryListItem.tsx @@ -1,17 +1,20 @@ import React from 'react'; import {View} from 'react-native'; +import type {ValueOf} from 'type-fest'; import Icon from '@components/Icon'; import BaseListItem from '@components/SelectionList/BaseListItem'; import type {ListItem} from '@components/SelectionList/types'; import TextWithTooltip from '@components/TextWithTooltip'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import type CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; type SearchQueryItem = ListItem & { singleIcon?: IconAsset; query?: string; isContextualSearchItem?: boolean; + searchItemType: ValueOf; }; type SearchQueryListItemProps = { diff --git a/src/libs/AutocompleteParser/autocompleteParser.js b/src/libs/AutocompleteParser/autocompleteParser.js new file mode 100644 index 000000000000..be65d1699c59 --- /dev/null +++ b/src/libs/AutocompleteParser/autocompleteParser.js @@ -0,0 +1,995 @@ +// @generated by Peggy 4.0.3. +// +// https://peggyjs.org/ + + +function peg$subclass(child, parent) { + function C() { this.constructor = child; } + C.prototype = parent.prototype; + child.prototype = new C(); +} + +function peg$SyntaxError(message, expected, found, location) { + var self = Error.call(this, message); + // istanbul ignore next Check is a necessary evil to support older environments + if (Object.setPrototypeOf) { + Object.setPrototypeOf(self, peg$SyntaxError.prototype); + } + self.expected = expected; + self.found = found; + self.location = location; + self.name = "SyntaxError"; + return self; +} + +peg$subclass(peg$SyntaxError, Error); + +function peg$padEnd(str, targetLength, padString) { + padString = padString || " "; + if (str.length > targetLength) { return str; } + targetLength -= str.length; + padString += padString.repeat(targetLength); + return str + padString.slice(0, targetLength); +} + +peg$SyntaxError.prototype.format = function(sources) { + var str = "Error: " + this.message; + if (this.location) { + var src = null; + var k; + for (k = 0; k < sources.length; k++) { + if (sources[k].source === this.location.source) { + src = sources[k].text.split(/\r\n|\n|\r/g); + break; + } + } + var s = this.location.start; + var offset_s = (this.location.source && (typeof this.location.source.offset === "function")) + ? this.location.source.offset(s) + : s; + var loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; + if (src) { + var e = this.location.end; + var filler = peg$padEnd("", offset_s.line.toString().length, ' '); + var line = src[s.line - 1]; + var last = s.line === e.line ? e.column : line.length + 1; + var hatLen = (last - s.column) || 1; + str += "\n --> " + loc + "\n" + + filler + " |\n" + + offset_s.line + " | " + line + "\n" + + filler + " | " + peg$padEnd("", s.column - 1, ' ') + + peg$padEnd("", hatLen, "^"); + } else { + str += "\n at " + loc; + } + } + return str; +}; + +peg$SyntaxError.buildMessage = function(expected, found) { + var DESCRIBE_EXPECTATION_FNS = { + literal: function(expectation) { + return "\"" + literalEscape(expectation.text) + "\""; + }, + + class: function(expectation) { + var escapedParts = expectation.parts.map(function(part) { + return Array.isArray(part) + ? classEscape(part[0]) + "-" + classEscape(part[1]) + : classEscape(part); + }); + + return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]"; + }, + + any: function() { + return "any character"; + }, + + end: function() { + return "end of input"; + }, + + other: function(expectation) { + return expectation.description; + } + }; + + function hex(ch) { + return ch.charCodeAt(0).toString(16).toUpperCase(); + } + + function literalEscape(s) { + return s + .replace(/\\/g, "\\\\") + .replace(/"/g, "\\\"") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) + .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); + } + + function classEscape(s) { + return s + .replace(/\\/g, "\\\\") + .replace(/\]/g, "\\]") + .replace(/\^/g, "\\^") + .replace(/-/g, "\\-") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) + .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); + } + + function describeExpectation(expectation) { + return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); + } + + function describeExpected(expected) { + var descriptions = expected.map(describeExpectation); + var i, j; + + descriptions.sort(); + + if (descriptions.length > 0) { + for (i = 1, j = 1; i < descriptions.length; i++) { + if (descriptions[i - 1] !== descriptions[i]) { + descriptions[j] = descriptions[i]; + j++; + } + } + descriptions.length = j; + } + + switch (descriptions.length) { + case 1: + return descriptions[0]; + + case 2: + return descriptions[0] + " or " + descriptions[1]; + + default: + return descriptions.slice(0, -1).join(", ") + + ", or " + + descriptions[descriptions.length - 1]; + } + } + + function describeFound(found) { + return found ? "\"" + literalEscape(found) + "\"" : "end of input"; + } + + return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; +}; + +function peg$parse(input, options) { + options = options !== undefined ? options : {}; + + var peg$FAILED = {}; + var peg$source = options.grammarSource; + + var peg$startRuleFunctions = { query: peg$parsequery }; + var peg$startRuleFunction = peg$parsequery; + + var peg$c0 = "!="; + var peg$c1 = ">="; + var peg$c2 = ">"; + var peg$c3 = "<="; + var peg$c4 = "<"; + var peg$c5 = "in"; + var peg$c6 = "currency"; + var peg$c7 = "tag"; + var peg$c8 = "category"; + var peg$c9 = "to"; + var peg$c10 = "taxRate"; + var peg$c11 = "from"; + var peg$c12 = "expenseType"; + var peg$c13 = "type"; + var peg$c14 = "status"; + var peg$c15 = "\""; + + var peg$r0 = /^[:=]/; + var peg$r1 = /^[^"\r\n]/; + var peg$r2 = /^[A-Za-z0-9_@.\/#&+\-\\',;:%]/; + var peg$r3 = /^[ \t\r\n]/; + + var peg$e0 = peg$otherExpectation("operator"); + var peg$e1 = peg$classExpectation([":", "="], false, false); + var peg$e2 = peg$literalExpectation("!=", false); + var peg$e3 = peg$literalExpectation(">=", false); + var peg$e4 = peg$literalExpectation(">", false); + var peg$e5 = peg$literalExpectation("<=", false); + var peg$e6 = peg$literalExpectation("<", false); + var peg$e7 = peg$otherExpectation("key"); + var peg$e8 = peg$literalExpectation("in", false); + var peg$e9 = peg$literalExpectation("currency", false); + var peg$e10 = peg$literalExpectation("tag", false); + var peg$e11 = peg$literalExpectation("category", false); + var peg$e12 = peg$literalExpectation("to", false); + var peg$e13 = peg$literalExpectation("taxRate", false); + var peg$e14 = peg$literalExpectation("from", false); + var peg$e15 = peg$literalExpectation("expenseType", false); + var peg$e16 = peg$literalExpectation("type", false); + var peg$e17 = peg$literalExpectation("status", false); + var peg$e18 = peg$otherExpectation("quote"); + var peg$e19 = peg$literalExpectation("\"", false); + var peg$e20 = peg$classExpectation(["\"", "\r", "\n"], true, false); + var peg$e21 = peg$otherExpectation("word"); + var peg$e22 = peg$classExpectation([["A", "Z"], ["a", "z"], ["0", "9"], "_", "@", ".", "/", "#", "&", "+", "-", "\\", "'", ",", ";", ":", "%"], false, false); + var peg$e23 = peg$otherExpectation("whitespace"); + var peg$e24 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false); + + var peg$f0 = function(filters) { return applyAutocomplete(filters); }; + var peg$f1 = function(head, tail) { + const allFilters = [head, ...tail.map(([_, filter]) => filter)].filter( + Boolean + ); + return allFilters.flat(); + }; + var peg$f2 = function(key, op, value) { + if (!value) { + updateAutocomplete({ + key, + value: null, + start: location().end.offset, + length: 0, + }); + return; + } else { + updateAutocomplete({ + key, + ...value[value.length - 1], + }); + } + + return value.map(({ start, length }) => ({ + key, + start, + length, + })); + }; + var peg$f3 = function(value) { updateAutocomplete(null); }; + var peg$f4 = function() { return "eq"; }; + var peg$f5 = function() { return "neq"; }; + var peg$f6 = function() { return "gte"; }; + var peg$f7 = function() { return "gt"; }; + var peg$f8 = function() { return "lte"; }; + var peg$f9 = function() { return "lt"; }; + var peg$f10 = function(parts) { + const ends = location(); + const value = parts.flat(); + let count = ends.start.offset; + const result = []; + value.forEach((filter) => { + result.push({ + value: filter, + start: count, + length: filter.length, + }); + count += filter.length + 1; + }); + return result; + }; + var peg$f11 = function(chars) { return chars.join(""); }; + var peg$f12 = function(chars) { + return chars.join("").trim().split(","); + }; + var peg$f13 = function() { return "and"; }; + var peg$currPos = options.peg$currPos | 0; + var peg$savedPos = peg$currPos; + var peg$posDetailsCache = [{ line: 1, column: 1 }]; + var peg$maxFailPos = peg$currPos; + var peg$maxFailExpected = options.peg$maxFailExpected || []; + var peg$silentFails = options.peg$silentFails | 0; + + var peg$result; + + if (options.startRule) { + if (!(options.startRule in peg$startRuleFunctions)) { + throw new Error("Can't start parsing from rule \"" + options.startRule + "\"."); + } + + peg$startRuleFunction = peg$startRuleFunctions[options.startRule]; + } + + function text() { + return input.substring(peg$savedPos, peg$currPos); + } + + function offset() { + return peg$savedPos; + } + + function range() { + return { + source: peg$source, + start: peg$savedPos, + end: peg$currPos + }; + } + + function location() { + return peg$computeLocation(peg$savedPos, peg$currPos); + } + + function expected(description, location) { + location = location !== undefined + ? location + : peg$computeLocation(peg$savedPos, peg$currPos); + + throw peg$buildStructuredError( + [peg$otherExpectation(description)], + input.substring(peg$savedPos, peg$currPos), + location + ); + } + + function error(message, location) { + location = location !== undefined + ? location + : peg$computeLocation(peg$savedPos, peg$currPos); + + throw peg$buildSimpleError(message, location); + } + + function peg$literalExpectation(text, ignoreCase) { + return { type: "literal", text: text, ignoreCase: ignoreCase }; + } + + function peg$classExpectation(parts, inverted, ignoreCase) { + return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; + } + + function peg$anyExpectation() { + return { type: "any" }; + } + + function peg$endExpectation() { + return { type: "end" }; + } + + function peg$otherExpectation(description) { + return { type: "other", description: description }; + } + + function peg$computePosDetails(pos) { + var details = peg$posDetailsCache[pos]; + var p; + + if (details) { + return details; + } else { + if (pos >= peg$posDetailsCache.length) { + p = peg$posDetailsCache.length - 1; + } else { + p = pos; + while (!peg$posDetailsCache[--p]) {} + } + + details = peg$posDetailsCache[p]; + details = { + line: details.line, + column: details.column + }; + + while (p < pos) { + if (input.charCodeAt(p) === 10) { + details.line++; + details.column = 1; + } else { + details.column++; + } + + p++; + } + + peg$posDetailsCache[pos] = details; + + return details; + } + } + + function peg$computeLocation(startPos, endPos, offset) { + var startPosDetails = peg$computePosDetails(startPos); + var endPosDetails = peg$computePosDetails(endPos); + + var res = { + source: peg$source, + start: { + offset: startPos, + line: startPosDetails.line, + column: startPosDetails.column + }, + end: { + offset: endPos, + line: endPosDetails.line, + column: endPosDetails.column + } + }; + if (offset && peg$source && (typeof peg$source.offset === "function")) { + res.start = peg$source.offset(res.start); + res.end = peg$source.offset(res.end); + } + return res; + } + + function peg$fail(expected) { + if (peg$currPos < peg$maxFailPos) { return; } + + if (peg$currPos > peg$maxFailPos) { + peg$maxFailPos = peg$currPos; + peg$maxFailExpected = []; + } + + peg$maxFailExpected.push(expected); + } + + function peg$buildSimpleError(message, location) { + return new peg$SyntaxError(message, null, null, location); + } + + function peg$buildStructuredError(expected, found, location) { + return new peg$SyntaxError( + peg$SyntaxError.buildMessage(expected, found), + expected, + found, + location + ); + } + + function peg$parsequery() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = peg$parse_(); + s2 = peg$parsefilterList(); + if (s2 === peg$FAILED) { + s2 = null; + } + s3 = peg$parse_(); + peg$savedPos = s0; + s0 = peg$f0(s2); + + return s0; + } + + function peg$parsefilterList() { + var s0, s1, s2, s3, s4, s5; + + s0 = peg$currPos; + s1 = peg$parsefilter(); + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$currPos; + s4 = peg$parselogicalAnd(); + s5 = peg$parsefilter(); + if (s5 !== peg$FAILED) { + s4 = [s4, s5]; + s3 = s4; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$currPos; + s4 = peg$parselogicalAnd(); + s5 = peg$parsefilter(); + if (s5 !== peg$FAILED) { + s4 = [s4, s5]; + s3 = s4; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } + peg$savedPos = s0; + s0 = peg$f1(s1, s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsefilter() { + var s0, s1; + + s0 = peg$currPos; + s1 = peg$parsedefaultFilter(); + if (s1 === peg$FAILED) { + s1 = peg$parsefreeTextFilter(); + } + if (s1 !== peg$FAILED) { + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsedefaultFilter() { + var s0, s1, s2, s3, s4, s5, s6; + + s0 = peg$currPos; + s1 = peg$parse_(); + s2 = peg$parsekey(); + if (s2 !== peg$FAILED) { + s3 = peg$parse_(); + s4 = peg$parseoperator(); + if (s4 !== peg$FAILED) { + s5 = peg$parse_(); + s6 = peg$parseidentifier(); + if (s6 === peg$FAILED) { + s6 = null; + } + peg$savedPos = s0; + s0 = peg$f2(s2, s4, s6); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsefreeTextFilter() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = peg$parse_(); + s2 = peg$parseidentifier(); + if (s2 !== peg$FAILED) { + s3 = peg$parse_(); + peg$savedPos = s0; + s0 = peg$f3(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseoperator() { + var s0, s1; + + peg$silentFails++; + s0 = peg$currPos; + s1 = input.charAt(peg$currPos); + if (peg$r0.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f4(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c0) { + s1 = peg$c0; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e2); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f5(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c1) { + s1 = peg$c1; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e3); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f6(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 62) { + s1 = peg$c2; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e4); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f7(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c3) { + s1 = peg$c3; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e5); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f8(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 60) { + s1 = peg$c4; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e6); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f9(); + } + s0 = s1; + } + } + } + } + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + + return s0; + } + + function peg$parsekey() { + var s0, s1; + + peg$silentFails++; + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c5) { + s1 = peg$c5; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e8); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 8) === peg$c6) { + s1 = peg$c6; + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e9); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 3) === peg$c7) { + s1 = peg$c7; + peg$currPos += 3; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e10); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 8) === peg$c8) { + s1 = peg$c8; + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e11); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 2) === peg$c9) { + s1 = peg$c9; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e12); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 7) === peg$c10) { + s1 = peg$c10; + peg$currPos += 7; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e13); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 4) === peg$c11) { + s1 = peg$c11; + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e14); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 11) === peg$c12) { + s1 = peg$c12; + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e15); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 4) === peg$c13) { + s1 = peg$c13; + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e16); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 6) === peg$c14) { + s1 = peg$c14; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e17); } + } + } + } + } + } + } + } + } + } + } + if (s1 !== peg$FAILED) { + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e7); } + } + + return s0; + } + + function peg$parseidentifier() { + var s0, s1, s2; + + s0 = peg$currPos; + s1 = []; + s2 = peg$parsequotedString(); + if (s2 === peg$FAILED) { + s2 = peg$parsealphanumeric(); + } + if (s2 !== peg$FAILED) { + while (s2 !== peg$FAILED) { + s1.push(s2); + s2 = peg$parsequotedString(); + if (s2 === peg$FAILED) { + s2 = peg$parsealphanumeric(); + } + } + } else { + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f10(s1); + } + s0 = s1; + + return s0; + } + + function peg$parsequotedString() { + var s0, s1, s2, s3; + + peg$silentFails++; + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 34) { + s1 = peg$c15; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e19); } + } + if (s1 !== peg$FAILED) { + s2 = []; + s3 = input.charAt(peg$currPos); + if (peg$r1.test(s3)) { + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e20); } + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = input.charAt(peg$currPos); + if (peg$r1.test(s3)) { + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e20); } + } + } + if (input.charCodeAt(peg$currPos) === 34) { + s3 = peg$c15; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e19); } + } + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f11(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e18); } + } + + return s0; + } + + function peg$parsealphanumeric() { + var s0, s1, s2; + + peg$silentFails++; + s0 = peg$currPos; + s1 = []; + s2 = input.charAt(peg$currPos); + if (peg$r2.test(s2)) { + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e22); } + } + if (s2 !== peg$FAILED) { + while (s2 !== peg$FAILED) { + s1.push(s2); + s2 = input.charAt(peg$currPos); + if (peg$r2.test(s2)) { + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e22); } + } + } + } else { + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f12(s1); + } + s0 = s1; + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e21); } + } + + return s0; + } + + function peg$parselogicalAnd() { + var s0, s1; + + s0 = peg$currPos; + s1 = peg$parse_(); + peg$savedPos = s0; + s1 = peg$f13(); + s0 = s1; + + return s0; + } + + function peg$parse_() { + var s0, s1; + + peg$silentFails++; + s0 = []; + s1 = input.charAt(peg$currPos); + if (peg$r3.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e24); } + } + while (s1 !== peg$FAILED) { + s0.push(s1); + s1 = input.charAt(peg$currPos); + if (peg$r3.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e24); } + } + } + peg$silentFails--; + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e23); } + + return s0; + } + + + const defaults = { + autocomplete: null, + }; + + function applyAutocomplete(ranges) { + return { + ...defaults, + ranges, + }; + } + + function updateAutocomplete(value) { + defaults.autocomplete = value; + } + + peg$result = peg$startRuleFunction(); + + if (options.peg$library) { + return /** @type {any} */ ({ + peg$result, + peg$currPos, + peg$FAILED, + peg$maxFailExpected, + peg$maxFailPos + }); + } + if (peg$result !== peg$FAILED && peg$currPos === input.length) { + return peg$result; + } else { + if (peg$result !== peg$FAILED && peg$currPos < input.length) { + peg$fail(peg$endExpectation()); + } + + throw peg$buildStructuredError( + peg$maxFailExpected, + peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, + peg$maxFailPos < input.length + ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) + : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) + ); + } +} + +const peg$allowedStartRules = [ + "query" +]; + +export { + peg$allowedStartRules as StartRules, + peg$SyntaxError as SyntaxError, + peg$parse as parse +}; diff --git a/src/libs/AutocompleteParser/autocompleteParser.peggy b/src/libs/AutocompleteParser/autocompleteParser.peggy new file mode 100644 index 000000000000..c313cd2ff6c1 --- /dev/null +++ b/src/libs/AutocompleteParser/autocompleteParser.peggy @@ -0,0 +1,104 @@ +{ + const defaults = { + autocomplete: null, + }; + + function applyAutocomplete(ranges) { + return { + ...defaults, + ranges, + }; + } + + function updateAutocomplete(value) { + defaults.autocomplete = value; + } +} + +query = _ filters:filterList? _ { return applyAutocomplete(filters); } + +filterList + = head:filter tail:(logicalAnd filter)* { + const allFilters = [head, ...tail.map(([_, filter]) => filter)].filter( + Boolean + ); + return allFilters.flat(); + } + +filter = @(defaultFilter / freeTextFilter) + +defaultFilter + = _ key:key _ op:operator _ value:identifier? { + if (!value) { + updateAutocomplete({ + key, + value: null, + start: location().end.offset, + length: 0, + }); + return; + } else { + updateAutocomplete({ + key, + ...value[value.length - 1], + }); + } + + return value.map(({ start, length }) => ({ + key, + start, + length, + })); + } + +freeTextFilter = _ value:identifier _ { updateAutocomplete(null); } + +operator "operator" + = (":" / "=") { return "eq"; } + / "!=" { return "neq"; } + / ">=" { return "gte"; } + / ">" { return "gt"; } + / "<=" { return "lte"; } + / "<" { return "lt"; } + +key "key" + = @( + "in" + / "currency" + / "tag" + / "category" + / "to" + / "taxRate" + / "from" + / "expenseType" + / "type" + / "status" + ) + +identifier + = parts:(quotedString / alphanumeric)+ { + const ends = location(); + const value = parts.flat(); + let count = ends.start.offset; + const result = []; + value.forEach((filter) => { + result.push({ + value: filter, + start: count, + length: filter.length, + }); + count += filter.length + 1; + }); + return result; + } + +quotedString "quote" = "\"" chars:[^"\r\n]* "\"" { return chars.join(""); } + +alphanumeric "word" + = chars:[A-Za-z0-9_@./#&+\-\\',;:%]+ { + return chars.join("").trim().split(","); + } + +logicalAnd = _ { return "and"; } + +_ "whitespace" = [ \t\r\n]* \ No newline at end of file diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 9fd94dcb86b8..15bbc360e54f 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -70,7 +70,7 @@ function canUseNewDotQBD(betas: OnyxEntry): boolean { * After everything is implemented this function can be removed, as we will always use SearchRouter in the App. */ function canUseNewSearchRouter() { - return Environment.isDevelopment(); + return true; } /** diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts new file mode 100644 index 000000000000..cb6445a15689 --- /dev/null +++ b/src/libs/SearchAutocompleteUtils.ts @@ -0,0 +1,14 @@ +import type {SearchAutocompleteResult} from '@components/Search/types'; +import * as autocompleteParser from './AutocompleteParser/autocompleteParser'; + +function parseForAutocomplete(text: string) { + try { + const parsedAutocomplete = autocompleteParser.parse(text) as SearchAutocompleteResult; + return parsedAutocomplete; + } catch (e) { + console.error(`Error when parsing autocopmlete}"`, e); + } +} + +// eslint-disable-next-line import/prefer-default-export +export {parseForAutocomplete}; From 26cd6ae99f527a48fd091d9907c10322f90551b2 Mon Sep 17 00:00:00 2001 From: burczu Date: Mon, 14 Oct 2024 14:12:04 +0200 Subject: [PATCH 030/312] common full name step component created --- src/components/FullNameStep.tsx | 104 ++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/components/FullNameStep.tsx diff --git a/src/components/FullNameStep.tsx b/src/components/FullNameStep.tsx new file mode 100644 index 000000000000..035df1b246b6 --- /dev/null +++ b/src/components/FullNameStep.tsx @@ -0,0 +1,104 @@ +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; +import type {SubStepProps} from '@hooks/useSubStep/types'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import HelpLinks from '@pages/ReimbursementAccount/PersonalInfo/HelpLinks'; +import CONST from '@src/CONST'; +import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; +import FormProvider from './Form/FormProvider'; +import InputWrapper from './Form/InputWrapper'; +import type {FormOnyxKeys, FormOnyxValues} from './Form/types'; +import Text from './Text'; +import TextInput from './TextInput'; + +type FullNameStepProps = SubStepProps & { + /** The ID of the form */ + formID: keyof OnyxFormValuesMapping; + + /** The title of the form */ + formTitle: string; + + /** The validation function to call when the form is submitted */ + customValidate?: (values: FormOnyxValues) => Partial>; + + /** A function to call when the form is submitted */ + onSubmit: () => void; + + /** Fields list of the form */ + stepFields: Array>; + + /** The ID of the first name input */ + firstNameInputID: never; + + /** The ID of the last name input */ + lastNameInputID: never; + + /** The default values for the form */ + defaultValues: { + firstName: string; + lastName: string; + }; +}; + +function FullNameStep({formID, formTitle, customValidate, onSubmit, stepFields, firstNameInputID, lastNameInputID, defaultValues, isEditing}: FullNameStepProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const validate = useCallback( + (values: FormOnyxValues): Partial> => { + const errors: Partial> = ValidationUtils.getFieldRequiredErrors(values, stepFields); + if (values[firstNameInputID] && !ValidationUtils.isValidLegalName(values[firstNameInputID])) { + // @ts-expect-error type mismatch to be fixed + errors[firstNameInputID] = translate('common.error.fieldRequired'); + } + + if (values[lastNameInputID] && !ValidationUtils.isValidLegalName(values[lastNameInputID])) { + // @ts-expect-error type mismatch to be fixed + errors[lastNameInputID] = translate('common.error.fieldRequired'); + } + return errors; + }, + [firstNameInputID, lastNameInputID, stepFields, translate], + ); + + return ( + + + {formTitle} + + + + + + ); +} + +FullNameStep.displayName = 'FullNameStep'; + +export default FullNameStep; From 7e1c89e45cacbd023f769894380f3e205e7892d8 Mon Sep 17 00:00:00 2001 From: burczu Date: Tue, 15 Oct 2024 12:40:55 +0200 Subject: [PATCH 031/312] switched to use common full name step component in reimbursement account --- src/components/FullNameStep.tsx | 6 +- .../PersonalInfo/substeps/FullName.tsx | 82 ++++--------------- 2 files changed, 21 insertions(+), 67 deletions(-) diff --git a/src/components/FullNameStep.tsx b/src/components/FullNameStep.tsx index 035df1b246b6..d61658c5f448 100644 --- a/src/components/FullNameStep.tsx +++ b/src/components/FullNameStep.tsx @@ -24,16 +24,16 @@ type FullNameStepProps = SubStepProps & { customValidate?: (values: FormOnyxValues) => Partial>; /** A function to call when the form is submitted */ - onSubmit: () => void; + onSubmit: (values: FormOnyxValues) => void; /** Fields list of the form */ stepFields: Array>; /** The ID of the first name input */ - firstNameInputID: never; + firstNameInputID: keyof FormOnyxValues; /** The ID of the last name input */ - lastNameInputID: never; + lastNameInputID: keyof FormOnyxValues; /** The default values for the form */ defaultValues: { diff --git a/src/pages/ReimbursementAccount/PersonalInfo/substeps/FullName.tsx b/src/pages/ReimbursementAccount/PersonalInfo/substeps/FullName.tsx index 15c4114432da..9fbd70d095e8 100644 --- a/src/pages/ReimbursementAccount/PersonalInfo/substeps/FullName.tsx +++ b/src/pages/ReimbursementAccount/PersonalInfo/substeps/FullName.tsx @@ -1,20 +1,12 @@ -import React, {useCallback} from 'react'; -import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import Text from '@components/Text'; -import TextInput from '@components/TextInput'; +import type {FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; +import FullNameStep from '@components/FullNameStep'; import useLocalize from '@hooks/useLocalize'; import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit'; import type {SubStepProps} from '@hooks/useSubStep/types'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as ValidationUtils from '@libs/ValidationUtils'; -import HelpLinks from '@pages/ReimbursementAccount/PersonalInfo/HelpLinks'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import type {ReimbursementAccount} from '@src/types/onyx'; @@ -26,77 +18,39 @@ type FullNameOnyxProps = { type FullNameProps = FullNameOnyxProps & SubStepProps; const PERSONAL_INFO_STEP_KEY = INPUT_IDS.PERSONAL_INFO_STEP; -const STEP_FIELDS = [PERSONAL_INFO_STEP_KEY.FIRST_NAME, PERSONAL_INFO_STEP_KEY.LAST_NAME]; -function FullName({reimbursementAccount, onNext, isEditing}: FullNameProps) { +const STEP_FIELDS = [PERSONAL_INFO_STEP_KEY.FIRST_NAME, PERSONAL_INFO_STEP_KEY.LAST_NAME] as Array>; + +function FullName({reimbursementAccount, onNext, onMove, isEditing}: FullNameProps) { const {translate} = useLocalize(); - const styles = useThemeStyles(); const defaultValues = { firstName: reimbursementAccount?.achData?.[PERSONAL_INFO_STEP_KEY.FIRST_NAME] ?? '', lastName: reimbursementAccount?.achData?.[PERSONAL_INFO_STEP_KEY.LAST_NAME] ?? '', }; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS); - if (values.firstName && !ValidationUtils.isValidPersonName(values.firstName)) { - errors.firstName = translate('bankAccount.error.firstName'); - } - - if (values.lastName && !ValidationUtils.isValidPersonName(values.lastName)) { - errors.lastName = translate('bankAccount.error.lastName'); - } - return errors; - }, - [translate], - ); - const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: STEP_FIELDS, onNext, shouldSaveDraft: isEditing, - }); + }) as (values: FormOnyxValues) => void; return ( - - - {translate('personalInfoStep.enterYourLegalFirstAndLast')} - - - - - - - - - + stepFields={STEP_FIELDS} + firstNameInputID={PERSONAL_INFO_STEP_KEY.FIRST_NAME as keyof FormOnyxValues} + lastNameInputID={PERSONAL_INFO_STEP_KEY.LAST_NAME as keyof FormOnyxValues} + defaultValues={defaultValues} + /> ); } -FullName.displayName = 'FullName'; +FullName.defaultName = 'FullName'; export default withOnyx({ // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM From 1f849db85c47af15ae804736a9e158db14f180d0 Mon Sep 17 00:00:00 2001 From: burczu Date: Tue, 15 Oct 2024 12:55:17 +0200 Subject: [PATCH 032/312] switched to use common full name step component in enable payments page --- .../PersonalInfo/PersonalInfo.tsx | 4 +- .../PersonalInfo/substeps/FullNameStep.tsx | 92 ------------------- .../PersonalInfo/substeps/LegalNameStep.tsx | 48 ++++++++++ .../PersonalInfo/substeps/FullName.tsx | 1 + 4 files changed, 51 insertions(+), 94 deletions(-) delete mode 100644 src/pages/EnablePayments/PersonalInfo/substeps/FullNameStep.tsx create mode 100644 src/pages/EnablePayments/PersonalInfo/substeps/LegalNameStep.tsx diff --git a/src/pages/EnablePayments/PersonalInfo/PersonalInfo.tsx b/src/pages/EnablePayments/PersonalInfo/PersonalInfo.tsx index 2a91766b5203..b416578bdf99 100644 --- a/src/pages/EnablePayments/PersonalInfo/PersonalInfo.tsx +++ b/src/pages/EnablePayments/PersonalInfo/PersonalInfo.tsx @@ -18,7 +18,7 @@ import type {WalletAdditionalDetailsRefactor} from '@src/types/onyx/WalletAdditi import Address from './substeps/AddressStep'; import Confirmation from './substeps/ConfirmationStep'; import DateOfBirth from './substeps/DateOfBirthStep'; -import FullName from './substeps/FullNameStep'; +import LegalName from './substeps/LegalNameStep'; import PhoneNumber from './substeps/PhoneNumberStep'; import SocialSecurityNumber from './substeps/SocialSecurityNumberStep'; @@ -33,7 +33,7 @@ type PersonalInfoPageOnyxProps = { type PersonalInfoPageProps = PersonalInfoPageOnyxProps; const PERSONAL_INFO_STEP_KEYS = INPUT_IDS.PERSONAL_INFO_STEP; -const bodyContent: Array> = [FullName, DateOfBirth, Address, PhoneNumber, SocialSecurityNumber, Confirmation]; +const bodyContent: Array> = [LegalName, DateOfBirth, Address, PhoneNumber, SocialSecurityNumber, Confirmation]; function PersonalInfoPage({walletAdditionalDetails, walletAdditionalDetailsDraft}: PersonalInfoPageProps) { const {translate} = useLocalize(); diff --git a/src/pages/EnablePayments/PersonalInfo/substeps/FullNameStep.tsx b/src/pages/EnablePayments/PersonalInfo/substeps/FullNameStep.tsx deleted file mode 100644 index b40fb2202943..000000000000 --- a/src/pages/EnablePayments/PersonalInfo/substeps/FullNameStep.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React, {useCallback} from 'react'; -import {View} from 'react-native'; -import {useOnyx} from 'react-native-onyx'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import Text from '@components/Text'; -import TextInput from '@components/TextInput'; -import useLocalize from '@hooks/useLocalize'; -import type {SubStepProps} from '@hooks/useSubStep/types'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useWalletAdditionalDetailsStepFormSubmit from '@hooks/useWalletAdditionalDetailsStepFormSubmit'; -import * as ValidationUtils from '@libs/ValidationUtils'; -import HelpLinks from '@pages/ReimbursementAccount/PersonalInfo/HelpLinks'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import INPUT_IDS from '@src/types/form/WalletAdditionalDetailsForm'; - -const PERSONAL_INFO_STEP_KEY = INPUT_IDS.PERSONAL_INFO_STEP; -const STEP_FIELDS = [PERSONAL_INFO_STEP_KEY.FIRST_NAME, PERSONAL_INFO_STEP_KEY.LAST_NAME]; - -function FullNameStep({onNext, isEditing}: SubStepProps) { - const {translate} = useLocalize(); - const styles = useThemeStyles(); - - const [walletAdditionalDetails] = useOnyx(ONYXKEYS.WALLET_ADDITIONAL_DETAILS); - - const defaultValues = { - firstName: walletAdditionalDetails?.[PERSONAL_INFO_STEP_KEY.FIRST_NAME] ?? '', - lastName: walletAdditionalDetails?.[PERSONAL_INFO_STEP_KEY.LAST_NAME] ?? '', - }; - - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS); - if (values.legalFirstName && !ValidationUtils.isValidLegalName(values.legalFirstName)) { - errors.legalFirstName = translate('bankAccount.error.firstName'); - } - - if (values.legalLastName && !ValidationUtils.isValidLegalName(values.legalLastName)) { - errors.legalLastName = translate('bankAccount.error.lastName'); - } - return errors; - }, - [translate], - ); - - const handleSubmit = useWalletAdditionalDetailsStepFormSubmit({ - fieldIds: STEP_FIELDS, - onNext, - shouldSaveDraft: isEditing, - }); - - return ( - - - {translate('personalInfoStep.whatsYourLegalName')} - - - - - - ); -} - -FullNameStep.displayName = 'FullNameStep'; - -export default FullNameStep; diff --git a/src/pages/EnablePayments/PersonalInfo/substeps/LegalNameStep.tsx b/src/pages/EnablePayments/PersonalInfo/substeps/LegalNameStep.tsx new file mode 100644 index 000000000000..d15a8b6da0f7 --- /dev/null +++ b/src/pages/EnablePayments/PersonalInfo/substeps/LegalNameStep.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import {useOnyx} from 'react-native-onyx'; +import type {FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; +import FullNameStep from '@components/FullNameStep'; +import useLocalize from '@hooks/useLocalize'; +import type {SubStepProps} from '@hooks/useSubStep/types'; +import useWalletAdditionalDetailsStepFormSubmit from '@hooks/useWalletAdditionalDetailsStepFormSubmit'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/WalletAdditionalDetailsForm'; + +const PERSONAL_INFO_STEP_KEY = INPUT_IDS.PERSONAL_INFO_STEP; +const STEP_FIELDS = [PERSONAL_INFO_STEP_KEY.FIRST_NAME, PERSONAL_INFO_STEP_KEY.LAST_NAME] as Array>; + +function LegalNameStep({onNext, onMove, isEditing}: SubStepProps) { + const {translate} = useLocalize(); + const [walletAdditionalDetails] = useOnyx(ONYXKEYS.WALLET_ADDITIONAL_DETAILS); + + const defaultValues = { + firstName: walletAdditionalDetails?.[PERSONAL_INFO_STEP_KEY.FIRST_NAME] ?? '', + lastName: walletAdditionalDetails?.[PERSONAL_INFO_STEP_KEY.LAST_NAME] ?? '', + }; + + const handleSubmit = useWalletAdditionalDetailsStepFormSubmit({ + fieldIds: STEP_FIELDS, + onNext, + shouldSaveDraft: isEditing, + }) as (values: FormOnyxValues) => void; + + return ( + + ); +} + +LegalNameStep.defaultName = 'LegalNameStep'; + +export default LegalNameStep; diff --git a/src/pages/ReimbursementAccount/PersonalInfo/substeps/FullName.tsx b/src/pages/ReimbursementAccount/PersonalInfo/substeps/FullName.tsx index 9fbd70d095e8..21b041f49bd4 100644 --- a/src/pages/ReimbursementAccount/PersonalInfo/substeps/FullName.tsx +++ b/src/pages/ReimbursementAccount/PersonalInfo/substeps/FullName.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import type {FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; From b47e29aab19707e610cce55e1735697a9a4168fa Mon Sep 17 00:00:00 2001 From: burczu Date: Tue, 15 Oct 2024 14:47:01 +0200 Subject: [PATCH 033/312] switched to use common full name step component in missing personal details page --- src/components/FullNameStep.tsx | 6 +- .../substeps/LegalName.tsx | 79 +++++++------------ 2 files changed, 32 insertions(+), 53 deletions(-) diff --git a/src/components/FullNameStep.tsx b/src/components/FullNameStep.tsx index d61658c5f448..55b71fe29a24 100644 --- a/src/components/FullNameStep.tsx +++ b/src/components/FullNameStep.tsx @@ -40,9 +40,11 @@ type FullNameStepProps = SubStepProps & { firstName: string; lastName: string; }; + + shouldShowHelpLinks?: boolean; }; -function FullNameStep({formID, formTitle, customValidate, onSubmit, stepFields, firstNameInputID, lastNameInputID, defaultValues, isEditing}: FullNameStepProps) { +function FullNameStep({formID, formTitle, customValidate, onSubmit, stepFields, firstNameInputID, lastNameInputID, defaultValues, isEditing, shouldShowHelpLinks = true}: FullNameStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -93,7 +95,7 @@ function FullNameStep({formID, formTitle, customValidate, onSubmit, stepFields, shouldSaveDraft={!isEditing} containerStyles={[styles.mb6]} /> - + {shouldShowHelpLinks && } ); diff --git a/src/pages/MissingPersonalDetails/substeps/LegalName.tsx b/src/pages/MissingPersonalDetails/substeps/LegalName.tsx index 01d4f52dee7d..9c5fdd906223 100644 --- a/src/pages/MissingPersonalDetails/substeps/LegalName.tsx +++ b/src/pages/MissingPersonalDetails/substeps/LegalName.tsx @@ -1,25 +1,25 @@ import React, {useCallback} from 'react'; -import {View} from 'react-native'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import Text from '@components/Text'; -import TextInput from '@components/TextInput'; +import type {FormInputErrors, FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; +import FullNameStep from '@components/FullNameStep'; import useLocalize from '@hooks/useLocalize'; import usePersonalDetailsFormSubmit from '@hooks/usePersonalDetailsFormSubmit'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as ErrorUtils from '@libs/ErrorUtils'; -import * as ValidationUtils from '@libs/ValidationUtils'; import type {CustomSubStepProps} from '@pages/MissingPersonalDetails/types'; -import CONST from '@src/CONST'; +import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; +import * as ValidationUtils from "@libs/ValidationUtils"; +import CONST from "@src/CONST"; +import * as ErrorUtils from "@libs/ErrorUtils"; -const STEP_FIELDS = [INPUT_IDS.LEGAL_FIRST_NAME, INPUT_IDS.LEGAL_LAST_NAME]; +const STEP_FIELDS = [INPUT_IDS.LEGAL_FIRST_NAME, INPUT_IDS.LEGAL_LAST_NAME] as Array>; -function LegalNameStep({isEditing, onNext, personalDetailsValues}: CustomSubStepProps) { +function LegalName({isEditing, onNext, onMove, personalDetailsValues}: CustomSubStepProps) { const {translate} = useLocalize(); - const styles = useThemeStyles(); + + const defaultValues = { + firstName: personalDetailsValues[INPUT_IDS.LEGAL_FIRST_NAME], + lastName: personalDetailsValues[INPUT_IDS.LEGAL_LAST_NAME], + }; const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { @@ -59,49 +59,26 @@ function LegalNameStep({isEditing, onNext, personalDetailsValues}: CustomSubStep fieldIds: STEP_FIELDS, onNext, shouldSaveDraft: true, - }); + }) as (values: FormOnyxValues) => void; return ( - - - {translate('privatePersonalDetails.enterLegalName')} - - - - - - - - + customValidate={validate as (values: FormOnyxValues) => Partial>} + stepFields={STEP_FIELDS} + firstNameInputID={INPUT_IDS.LEGAL_FIRST_NAME as keyof FormOnyxValues} + lastNameInputID={INPUT_IDS.LEGAL_LAST_NAME as keyof FormOnyxValues} + defaultValues={defaultValues} + shouldShowHelpLinks={false} + /> ); } -LegalNameStep.displayName = 'LegalNameStep'; +LegalName.defaultName = 'LegalName'; -export default LegalNameStep; +export default LegalName; From b262e8e55420f33f8323eab0054473c128682ffb Mon Sep 17 00:00:00 2001 From: burczu Date: Tue, 15 Oct 2024 14:48:19 +0200 Subject: [PATCH 034/312] switched to use common full name step component in beneficial owners info --- .../LegalNameUBO.tsx | 70 ++++++------------- .../BeneficialOwnersStep.tsx | 6 -- 2 files changed, 23 insertions(+), 53 deletions(-) diff --git a/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/LegalNameUBO.tsx b/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/LegalNameUBO.tsx index b17bf641eca5..d3528eadb42c 100644 --- a/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/LegalNameUBO.tsx +++ b/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/LegalNameUBO.tsx @@ -1,18 +1,14 @@ import React from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import Text from '@components/Text'; -import TextInput from '@components/TextInput'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {FormOnyxValues} from '@components/Form/types'; +import FullNameStep from '@components/FullNameStep'; import useLocalize from '@hooks/useLocalize'; import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit'; import type {SubStepProps} from '@hooks/useSubStep/types'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as ValidationUtils from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; import type {ReimbursementAccountForm} from '@src/types/form'; const {FIRST_NAME, LAST_NAME} = CONST.BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA; @@ -24,60 +20,40 @@ type LegalNameUBOOnyxProps = { }; type LegalNameUBOProps = SubStepProps & LegalNameUBOOnyxProps & {beneficialOwnerBeingModifiedID: string}; -function LegalNameUBO({reimbursementAccountDraft, onNext, isEditing, beneficialOwnerBeingModifiedID}: LegalNameUBOProps) { +function LegalNameUBO({reimbursementAccountDraft, onNext, onMove, isEditing, beneficialOwnerBeingModifiedID}: LegalNameUBOProps) { const {translate} = useLocalize(); - const styles = useThemeStyles(); - const firstNameInputID = `${BENEFICIAL_OWNER_PREFIX}_${beneficialOwnerBeingModifiedID}_${FIRST_NAME}` as const; - const lastNameInputID = `${BENEFICIAL_OWNER_PREFIX}_${beneficialOwnerBeingModifiedID}_${LAST_NAME}` as const; + const firstNameInputID = `${BENEFICIAL_OWNER_PREFIX}_${beneficialOwnerBeingModifiedID}_${FIRST_NAME}` as keyof FormOnyxValues; + const lastNameInputID = `${BENEFICIAL_OWNER_PREFIX}_${beneficialOwnerBeingModifiedID}_${LAST_NAME}` as keyof FormOnyxValues; const stepFields = [firstNameInputID, lastNameInputID]; - const defaultFirstName = reimbursementAccountDraft?.[firstNameInputID] ?? ''; - const defaultLastName = reimbursementAccountDraft?.[lastNameInputID] ?? ''; - - const validate = (values: FormOnyxValues): FormInputErrors => - ValidationUtils.getFieldRequiredErrors(values, stepFields); + const defaultValues = { + firstName: reimbursementAccountDraft?.[firstNameInputID] ?? '', + lastName: reimbursementAccountDraft?.[lastNameInputID] ?? '', + }; const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: stepFields, onNext, shouldSaveDraft: isEditing, - }); + }) as (values: FormOnyxValues) => void; return ( - - {translate('beneficialOwnerInfoStep.enterLegalFirstAndLastName')} - - - + stepFields={stepFields} + firstNameInputID={firstNameInputID} + lastNameInputID={lastNameInputID} + defaultValues={defaultValues} + /> ); } -LegalNameUBO.displayName = 'LegalNameUBO'; +LegalNameUBO.defaultName = 'LegalNameUBO'; export default withOnyx({ reimbursementAccountDraft: { diff --git a/src/pages/ReimbursementAccount/BeneficialOwnersStep.tsx b/src/pages/ReimbursementAccount/BeneficialOwnersStep.tsx index 0e67a5f84c10..57d5f76cf8aa 100644 --- a/src/pages/ReimbursementAccount/BeneficialOwnersStep.tsx +++ b/src/pages/ReimbursementAccount/BeneficialOwnersStep.tsx @@ -1,16 +1,11 @@ import {Str} from 'expensify-common'; import React, {useState} from 'react'; -import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; -import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; import useSubStep from '@hooks/useSubStep'; import type {SubStepProps} from '@hooks/useSubStep/types'; -import useThemeStyles from '@hooks/useThemeStyles'; import * as BankAccounts from '@userActions/BankAccounts'; import * as FormActions from '@userActions/FormActions'; import CONST from '@src/CONST'; @@ -46,7 +41,6 @@ const bodyContent: Array> = [Le function BeneficialOwnersStep({reimbursementAccount, reimbursementAccountDraft, onBackButtonPress}: BeneficialOwnersStepProps) { const {translate} = useLocalize(); - const styles = useThemeStyles(); const companyName = reimbursementAccount?.achData?.companyName ?? ''; const policyID = reimbursementAccount?.achData?.policyID ?? '-1'; const defaultValues = { From c503b78badd18f4c8b4188580b3c5b74b6a246bf Mon Sep 17 00:00:00 2001 From: Eduardo Date: Tue, 15 Oct 2024 16:54:49 +0200 Subject: [PATCH 035/312] Adding conflict resolver for delete comment --- src/libs/Network/SequentialQueue.ts | 11 +- src/libs/actions/PersistedRequests.ts | 17 +- src/libs/actions/Report.ts | 61 +++- src/types/onyx/Request.ts | 22 +- tests/actions/ReportTest.ts | 471 ++++++++++++++++++++++++++ tests/unit/PersistedRequests.ts | 4 +- 6 files changed, 577 insertions(+), 9 deletions(-) diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index 35c7b2bf779a..a7cb948a1242 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -96,7 +96,7 @@ function process(): Promise { pause(); } - PersistedRequests.remove(requestToProcess); + PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess); RequestThrottle.clear(); return process(); }) @@ -104,7 +104,7 @@ function process(): Promise { // On sign out we cancel any in flight requests from the user. Since that user is no longer signed in their requests should not be retried. // Duplicate records don't need to be retried as they just mean the record already exists on the server if (error.name === CONST.ERROR.REQUEST_CANCELLED || error.message === CONST.ERROR.DUPLICATE_RECORD) { - PersistedRequests.remove(requestToProcess); + PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess); RequestThrottle.clear(); return process(); } @@ -113,7 +113,7 @@ function process(): Promise { .then(process) .catch(() => { Onyx.update(requestToProcess.failureData ?? []); - PersistedRequests.remove(requestToProcess); + PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess); RequestThrottle.clear(); return process(); }); @@ -220,6 +220,11 @@ function push(newRequest: OnyxRequest) { PersistedRequests.save(newRequest); } else if (conflictAction.type === 'replace') { PersistedRequests.update(conflictAction.index, newRequest); + } else if (conflictAction.type === 'delete') { + PersistedRequests.deleteRequestsByIndices(conflictAction.indices); + if (conflictAction.pushNewRequest) { + PersistedRequests.save(newRequest); + } } else { Log.info(`[SequentialQueue] No action performed to command ${newRequest.command} and it will be ignored.`); } diff --git a/src/libs/actions/PersistedRequests.ts b/src/libs/actions/PersistedRequests.ts index fc14e8c2303b..10003b8b4b5e 100644 --- a/src/libs/actions/PersistedRequests.ts +++ b/src/libs/actions/PersistedRequests.ts @@ -53,7 +53,7 @@ function save(requestToPersist: Request) { }); } -function remove(requestToRemove: Request) { +function endRequestAndRemoveFromQueue(requestToRemove: Request) { ongoingRequest = null; /** * We only remove the first matching request because the order of requests matters. @@ -76,6 +76,19 @@ function remove(requestToRemove: Request) { }); } +function deleteRequestsByIndices(indices: number[]) { + // Create a Set from the indices array for efficient lookup + const indicesSet = new Set(indices); + + // Create a new array excluding elements at the specified indices + persistedRequests = persistedRequests.filter((_, index) => !indicesSet.has(index)); + + // Update the persisted requests in storage or state as necessary + Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, persistedRequests).then(() => { + Log.info(`Multiple (${indices.length}) requests removed from the queue. Queue length is ${persistedRequests.length}`); + }); +} + function update(oldRequestIndex: number, newRequest: Request) { const requests = [...persistedRequests]; requests.splice(oldRequestIndex, 1, newRequest); @@ -131,4 +144,4 @@ function getOngoingRequest(): Request | null { return ongoingRequest; } -export {clear, save, getAll, remove, update, getLength, getOngoingRequest, processNextRequest, updateOngoingRequest, rollbackOngoingRequest}; +export {clear, save, getAll, endRequestAndRemoveFromQueue, update, getLength, getOngoingRequest, processNextRequest, updateOngoingRequest, rollbackOngoingRequest, deleteRequestsByIndices}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 95bd2aa0b834..b5e1825a4496 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1414,6 +1414,16 @@ function handleReportChanged(report: OnyxEntry) { } } } +const addNewMessage = new Set([WRITE_COMMANDS.ADD_COMMENT, WRITE_COMMANDS.ADD_ATTACHMENT, WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT]); + +const commentsToBeDeleted = new Set([ + WRITE_COMMANDS.ADD_COMMENT, + WRITE_COMMANDS.ADD_ATTACHMENT, + WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT, + WRITE_COMMANDS.UPDATE_COMMENT, + WRITE_COMMANDS.ADD_EMOJI_REACTION, + WRITE_COMMANDS.REMOVE_EMOJI_REACTION, +]); /** Deletes a comment from the report, basically sets it as empty string */ function deleteReportComment(reportID: string, reportAction: ReportAction) { @@ -1538,7 +1548,56 @@ function deleteReportComment(reportID: string, reportAction: ReportAction) { CachedPDFPaths.clearByKey(reportActionID); - API.write(WRITE_COMMANDS.DELETE_COMMENT, parameters, {optimisticData, successData, failureData}); + API.write( + WRITE_COMMANDS.DELETE_COMMENT, + parameters, + {optimisticData, successData, failureData}, + { + checkAndFixConflictingRequest: (persistedRequests) => { + const indices: number[] = []; + let addCommentFound = false; + + persistedRequests.forEach((request, index) => { + if (!commentsToBeDeleted.has(request.command) || request.data?.reportActionID !== reportActionID) { + return; + } + if (addNewMessage.has(request.command)) { + addCommentFound = true; + } + indices.push(index); + }); + + if (indices.length === 0) { + return { + conflictAction: { + type: 'push', + }, + }; + } + + if (addCommentFound) { + const rollbackData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`, + value: { + [reportActionID]: null, + }, + }, + ]; + Onyx.update(rollbackData); + } + + return { + conflictAction: { + type: 'delete', + indices, + pushNewRequest: !addCommentFound, + }, + }; + }, + }, + ); // if we are linking to the report action, and we are deleting it, and it's not a deleted parent action, // we should navigate to its report in order to not show not found page diff --git a/src/types/onyx/Request.ts b/src/types/onyx/Request.ts index 238e3a8c6a81..085100870943 100644 --- a/src/types/onyx/Request.ts +++ b/src/types/onyx/Request.ts @@ -70,6 +70,26 @@ type ConflictRequestReplace = { index: number; }; +/** + * Model of a conflict request that needs to be deleted from the request queue. + */ +type ConflictRequestDelete = { + /** + * The action to take in case of a conflict. + */ + type: 'delete'; + + /** + * The indices of the requests in the queue that are to be deleted. + */ + indices: number[]; + + /** + * A flag to mark if the new request should be pushed into the queue after deleting the conflicting requests. + */ + pushNewRequest: boolean; +}; + /** * Model of a conflict request that has to be enqueued at the end of request queue. */ @@ -97,7 +117,7 @@ type ConflictActionData = { /** * The action to take in case of a conflict. */ - conflictAction: ConflictRequestReplace | ConflictRequestPush | ConflictRequestNoAction; + conflictAction: ConflictRequestReplace | ConflictRequestDelete | ConflictRequestPush | ConflictRequestNoAction; }; /** diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 0ffb0ee9bc08..dc54c3730ad8 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -1,8 +1,11 @@ /* eslint-disable @typescript-eslint/naming-convention */ import {afterEach, beforeAll, beforeEach, describe, expect, it} from '@jest/globals'; +import {addSeconds, format, subMinutes} from 'date-fns'; import {toZonedTime} from 'date-fns-tz'; import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import * as EmojiUtils from '@libs/EmojiUtils'; import CONST from '@src/CONST'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import * as PersistedRequests from '@src/libs/actions/PersistedRequests'; @@ -757,4 +760,472 @@ describe('actions/Report', () => { expect(reportActionReaction?.[EMOJI.name].users[TEST_USER_ACCOUNT_ID]).toBeUndefined(); }); }); + + it('should remove AddComment and UpdateComment without sending any request when DeleteComment is set', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + + Report.addComment(REPORT_ID, 'Testing a comment'); + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(0); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + Report.editReportComment(REPORT_ID, reportAction, 'Testing an edited comment'); + + // wait for Onyx.connect execute the callback and start processing the queue + await Promise.resolve(); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_COMMENT); + expect(persistedRequests?.at(1)?.command).toBe(WRITE_COMMANDS.UPDATE_COMMENT); + + resolve(); + }, + }); + }); + + // Checking the Report Action exists before dleting it + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).not.toBeNull(); + expect(reportActions?.[reportActionID].reportActionID).toBe(reportActionID); + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(0); + + // Checking the Report Action doesn't exist after deleting it + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).toBeUndefined(); + }, + }); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.UPDATE_COMMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 0); + }); + + it('should send DeleteComment request and remove UpdateComment accordingly', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + + Report.addComment(REPORT_ID, 'Testing a comment'); + + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(1); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + + // wait for Onyx.connect execute the callback and start processing the queue + await Promise.resolve(); + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + + Report.editReportComment(REPORT_ID, reportAction, 'Testing an edited comment'); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.UPDATE_COMMENT); + + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(1); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.UPDATE_COMMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 1); + }); + + it('should send DeleteComment request and remove UpdateComment accordingly', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + + Report.addComment(REPORT_ID, 'Testing a comment'); + + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(1); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + + // wait for Onyx.connect execute the callback and start processing the queue + await Promise.resolve(); + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + + Report.editReportComment(REPORT_ID, reportAction, 'Testing an edited comment'); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.UPDATE_COMMENT); + + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(1); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.UPDATE_COMMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 1); + }); + + it('should send not DeleteComment request and remove AddAttachment accordingly', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + + const file = new File([''], 'test.txt', {type: 'text/plain'}); + Report.addAttachment(REPORT_ID, file); + + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(0); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + + // wait for Onyx.connect execute the callback and start processing the queue + await Promise.resolve(); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_ATTACHMENT); + resolve(); + }, + }); + }); + + // Checking the Report Action exists before dleting it + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).not.toBeNull(); + expect(reportActions?.[reportActionID].reportActionID).toBe(reportActionID); + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(0); + + // Checking the Report Action doesn't exist after deleting it + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).toBeUndefined(); + }, + }); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_ATTACHMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 0); + }, 2000); + + it('should send not DeleteComment request and remove AddTextAndAttachment accordingly', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + const file = new File([''], 'test.txt', {type: 'text/plain'}); + + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + + Report.addAttachment(REPORT_ID, file, 'Attachment with comment'); + + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(0); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + + // wait for Onyx.connect execute the callback and start processing the queue + await Promise.resolve(); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT); + resolve(); + }, + }); + }); + + // Checking the Report Action exists before dleting it + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).not.toBeNull(); + expect(reportActions?.[reportActionID].reportActionID).toBe(reportActionID); + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(0); + + // Checking the Report Action doesn't exist after deleting it + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).toBeUndefined(); + }, + }); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 0); + }); + + it('should not send DeleteComment request and remove any Reactions accordingly', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + jest.spyOn(EmojiUtils, 'hasAccountIDEmojiReacted').mockImplementation(() => true); + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + await Promise.resolve(); + + Report.addComment(REPORT_ID, 'reactions with comment'); + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(0); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + + await waitForBatchedUpdates(); + + Report.toggleEmojiReaction(REPORT_ID, reportAction, {name: 'smile', code: '😄'}, {}); + Report.toggleEmojiReaction( + REPORT_ID, + reportAction, + {name: 'smile', code: '😄'}, + { + smile: { + createdAt: '2024-10-14 14:58:12', + oldestTimestamp: '2024-10-14 14:58:12', + users: { + [`${TEST_USER_ACCOUNT_ID}`]: { + id: `${TEST_USER_ACCOUNT_ID}`, + oldestTimestamp: '2024-10-14 14:58:12', + skinTones: { + '-1': '2024-10-14 14:58:12', + }, + }, + }, + }, + }, + ); + + await waitForBatchedUpdates(); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_COMMENT); + expect(persistedRequests?.at(1)?.command).toBe(WRITE_COMMANDS.ADD_EMOJI_REACTION); + expect(persistedRequests?.at(2)?.command).toBe(WRITE_COMMANDS.REMOVE_EMOJI_REACTION); + resolve(); + }, + }); + }); + + // Checking the Report Action exists before deleting it + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).not.toBeNull(); + expect(reportActions?.[reportActionID].reportActionID).toBe(reportActionID); + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(0); + + // Checking the Report Action doesn't exist after deleting it + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).toBeUndefined(); + }, + }); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_EMOJI_REACTION, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.REMOVE_EMOJI_REACTION, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 0); + }); + + it('should send DeleteComment request and remove any Reactions accordingly', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + jest.spyOn(EmojiUtils, 'hasAccountIDEmojiReacted').mockImplementation(() => true); + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + Report.addComment(REPORT_ID, 'Attachment with comment'); + + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(0); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + + // wait for Onyx.connect execute the callback and start processing the queue + await Promise.resolve(); + + Report.toggleEmojiReaction(REPORT_ID, reportAction, {name: 'smile', code: '😄'}, {}); + Report.toggleEmojiReaction( + REPORT_ID, + reportAction, + {name: 'smile', code: '😄'}, + { + smile: { + createdAt: '2024-10-14 14:58:12', + oldestTimestamp: '2024-10-14 14:58:12', + users: { + [`${TEST_USER_ACCOUNT_ID}`]: { + id: `${TEST_USER_ACCOUNT_ID}`, + oldestTimestamp: '2024-10-14 14:58:12', + skinTones: { + '-1': '2024-10-14 14:58:12', + }, + }, + }, + }, + }, + ); + + await waitForBatchedUpdates(); + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_EMOJI_REACTION); + expect(persistedRequests?.at(1)?.command).toBe(WRITE_COMMANDS.REMOVE_EMOJI_REACTION); + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(1); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_EMOJI_REACTION, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.REMOVE_EMOJI_REACTION, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 1); + }); }); diff --git a/tests/unit/PersistedRequests.ts b/tests/unit/PersistedRequests.ts index 7d3a7288ed90..c488b36013ad 100644 --- a/tests/unit/PersistedRequests.ts +++ b/tests/unit/PersistedRequests.ts @@ -36,7 +36,7 @@ describe('PersistedRequests', () => { }); it('remove a request from the PersistedRequests array', () => { - PersistedRequests.remove(request); + PersistedRequests.endRequestAndRemoveFromQueue(request); expect(PersistedRequests.getAll().length).toBe(0); }); @@ -84,7 +84,7 @@ describe('PersistedRequests', () => { it('when removing a request should update the persistedRequests queue and clear the ongoing request', () => { PersistedRequests.processNextRequest(); expect(PersistedRequests.getOngoingRequest()).toEqual(request); - PersistedRequests.remove(request); + PersistedRequests.endRequestAndRemoveFromQueue(request); expect(PersistedRequests.getOngoingRequest()).toBeNull(); expect(PersistedRequests.getAll().length).toBe(0); }); From 4a7628a71a856e231b6ddebfa6eaca22c769a6ac Mon Sep 17 00:00:00 2001 From: truph01 Date: Wed, 16 Oct 2024 04:00:53 +0700 Subject: [PATCH 036/312] fix: Subject of the room briefly displayed as json string --- src/libs/ReportUtils.ts | 17 +++++++++++++++-- src/libs/actions/Report.ts | 6 +++++- src/pages/ReportDetailsPage.tsx | 2 +- src/pages/RoomDescriptionPage.tsx | 2 +- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 2623fab86a05..78dcfbea61f2 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4231,12 +4231,24 @@ function getUploadingAttachmentHtml(file?: FileObject): string { return `${file.name}`; } -function getReportDescriptionText(report: OnyxEntry): string { +function getReportDescription(report: OnyxEntry): string { if (!report?.description) { return ''; } + try { + const reportDescription = report?.description; + const objectDescription = JSON.parse(reportDescription); + return objectDescription.html ?? ''; + } catch (error) { + return report?.description ?? ''; + } +} - return Parser.htmlToText(report?.description); +function getReportDescriptionText(report: OnyxEntry): string { + if (!report?.description) { + return ''; + } + return Parser.htmlToText(getReportDescription(report)); } function getPolicyDescriptionText(policy: OnyxEntry): string { @@ -8282,6 +8294,7 @@ export { getReimbursementDeQueuedActionMessage, getReimbursementQueuedActionMessage, getReportActionActorAccountID, + getReportDescription, getReportDescriptionText, getReportFieldKey, getReportIDFromLink, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 87bec3bc30ea..06b750163cca 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -2110,7 +2110,11 @@ function updateDescription(reportID: string, previousValue: string, newValue: st }, ]; - const parameters: UpdateRoomDescriptionParams = {reportID, description: parsedDescription, reportActionID: optimisticDescriptionUpdatedReportAction.reportActionID}; + const parameters: UpdateRoomDescriptionParams = { + reportID, + description: JSON.stringify({html: parsedDescription}), + reportActionID: optimisticDescriptionUpdatedReportAction.reportActionID, + }; API.write(WRITE_COMMANDS.UPDATE_ROOM_DESCRIPTION, parameters, {optimisticData, failureData, successData}); } diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index 6bc3a25f28ef..c2a141d67ed7 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -761,7 +761,7 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) { >(); const backTo = route.params.backTo; const styles = useThemeStyles(); - const [description, setDescription] = useState(() => Parser.htmlToMarkdown(report?.description ?? '')); + const [description, setDescription] = useState(() => Parser.htmlToMarkdown(ReportUtils.getReportDescription(report))); const reportDescriptionInputRef = useRef(null); const focusTimeoutRef = useRef | null>(null); const {translate} = useLocalize(); From bf2c26706ca5dffc2a439e759f0db46348599895 Mon Sep 17 00:00:00 2001 From: truph01 Date: Wed, 16 Oct 2024 16:02:27 +0700 Subject: [PATCH 037/312] fix: revert sending object with html key to BE --- src/libs/actions/Report.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 06b750163cca..87bec3bc30ea 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -2110,11 +2110,7 @@ function updateDescription(reportID: string, previousValue: string, newValue: st }, ]; - const parameters: UpdateRoomDescriptionParams = { - reportID, - description: JSON.stringify({html: parsedDescription}), - reportActionID: optimisticDescriptionUpdatedReportAction.reportActionID, - }; + const parameters: UpdateRoomDescriptionParams = {reportID, description: parsedDescription, reportActionID: optimisticDescriptionUpdatedReportAction.reportActionID}; API.write(WRITE_COMMANDS.UPDATE_ROOM_DESCRIPTION, parameters, {optimisticData, failureData, successData}); } From 711c9fef6c30fe3fe726c7c44e1e1656ed369b22 Mon Sep 17 00:00:00 2001 From: truph01 Date: Wed, 16 Oct 2024 16:15:08 +0700 Subject: [PATCH 038/312] fix: lint --- src/libs/ReportUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 7ce60c657a7c..dda812de445c 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4239,7 +4239,7 @@ function getReportDescription(report: OnyxEntry): string { } try { const reportDescription = report?.description; - const objectDescription = JSON.parse(reportDescription); + const objectDescription = JSON.parse(reportDescription) as {html: string}; return objectDescription.html ?? ''; } catch (error) { return report?.description ?? ''; From ac3fdbed9bd8340da022928500dc87fffb28df49 Mon Sep 17 00:00:00 2001 From: burczu Date: Wed, 16 Oct 2024 11:27:59 +0200 Subject: [PATCH 039/312] common full name step moved to dedicated folder --- src/components/{ => SubStepForms}/FullNameStep.tsx | 10 +++++----- .../PersonalInfo/substeps/LegalNameStep.tsx | 2 +- .../MissingPersonalDetails/substeps/LegalName.tsx | 2 +- .../LegalNameUBO.tsx | 2 +- .../PersonalInfo/substeps/FullName.tsx | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) rename src/components/{ => SubStepForms}/FullNameStep.tsx (94%) diff --git a/src/components/FullNameStep.tsx b/src/components/SubStepForms/FullNameStep.tsx similarity index 94% rename from src/components/FullNameStep.tsx rename to src/components/SubStepForms/FullNameStep.tsx index 55b71fe29a24..80ab10bf553f 100644 --- a/src/components/FullNameStep.tsx +++ b/src/components/SubStepForms/FullNameStep.tsx @@ -7,11 +7,11 @@ import * as ValidationUtils from '@libs/ValidationUtils'; import HelpLinks from '@pages/ReimbursementAccount/PersonalInfo/HelpLinks'; import CONST from '@src/CONST'; import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; -import FormProvider from './Form/FormProvider'; -import InputWrapper from './Form/InputWrapper'; -import type {FormOnyxKeys, FormOnyxValues} from './Form/types'; -import Text from './Text'; -import TextInput from './TextInput'; +import FormProvider from '../Form/FormProvider'; +import InputWrapper from '../Form/InputWrapper'; +import type {FormOnyxKeys, FormOnyxValues} from '../Form/types'; +import Text from '../Text'; +import TextInput from '../TextInput'; type FullNameStepProps = SubStepProps & { /** The ID of the form */ diff --git a/src/pages/EnablePayments/PersonalInfo/substeps/LegalNameStep.tsx b/src/pages/EnablePayments/PersonalInfo/substeps/LegalNameStep.tsx index d15a8b6da0f7..10c3d5e9d4d4 100644 --- a/src/pages/EnablePayments/PersonalInfo/substeps/LegalNameStep.tsx +++ b/src/pages/EnablePayments/PersonalInfo/substeps/LegalNameStep.tsx @@ -1,7 +1,7 @@ import React from 'react'; import {useOnyx} from 'react-native-onyx'; import type {FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; -import FullNameStep from '@components/FullNameStep'; +import FullNameStep from '@components/SubStepForms/FullNameStep'; import useLocalize from '@hooks/useLocalize'; import type {SubStepProps} from '@hooks/useSubStep/types'; import useWalletAdditionalDetailsStepFormSubmit from '@hooks/useWalletAdditionalDetailsStepFormSubmit'; diff --git a/src/pages/MissingPersonalDetails/substeps/LegalName.tsx b/src/pages/MissingPersonalDetails/substeps/LegalName.tsx index 9c5fdd906223..5fd7a88290d3 100644 --- a/src/pages/MissingPersonalDetails/substeps/LegalName.tsx +++ b/src/pages/MissingPersonalDetails/substeps/LegalName.tsx @@ -1,6 +1,6 @@ import React, {useCallback} from 'react'; import type {FormInputErrors, FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; -import FullNameStep from '@components/FullNameStep'; +import FullNameStep from '@components/SubStepForms/FullNameStep'; import useLocalize from '@hooks/useLocalize'; import usePersonalDetailsFormSubmit from '@hooks/usePersonalDetailsFormSubmit'; import type {CustomSubStepProps} from '@pages/MissingPersonalDetails/types'; diff --git a/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/LegalNameUBO.tsx b/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/LegalNameUBO.tsx index d3528eadb42c..e8d444e23fd9 100644 --- a/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/LegalNameUBO.tsx +++ b/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/LegalNameUBO.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import type {FormOnyxValues} from '@components/Form/types'; -import FullNameStep from '@components/FullNameStep'; +import FullNameStep from '@components/SubStepForms/FullNameStep'; import useLocalize from '@hooks/useLocalize'; import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit'; import type {SubStepProps} from '@hooks/useSubStep/types'; diff --git a/src/pages/ReimbursementAccount/PersonalInfo/substeps/FullName.tsx b/src/pages/ReimbursementAccount/PersonalInfo/substeps/FullName.tsx index 21b041f49bd4..34459187630f 100644 --- a/src/pages/ReimbursementAccount/PersonalInfo/substeps/FullName.tsx +++ b/src/pages/ReimbursementAccount/PersonalInfo/substeps/FullName.tsx @@ -2,7 +2,7 @@ import React from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import type {FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; -import FullNameStep from '@components/FullNameStep'; +import FullNameStep from '@components/SubStepForms/FullNameStep'; import useLocalize from '@hooks/useLocalize'; import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit'; import type {SubStepProps} from '@hooks/useSubStep/types'; From 6377b87b4a678b360a0a74f391c9b9a4439dd4d8 Mon Sep 17 00:00:00 2001 From: burczu Date: Wed, 16 Oct 2024 11:37:25 +0200 Subject: [PATCH 040/312] common date of birth step component added --- .../SubStepForms/DateOfBirthStep.tsx | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/components/SubStepForms/DateOfBirthStep.tsx diff --git a/src/components/SubStepForms/DateOfBirthStep.tsx b/src/components/SubStepForms/DateOfBirthStep.tsx new file mode 100644 index 000000000000..188c23bed785 --- /dev/null +++ b/src/components/SubStepForms/DateOfBirthStep.tsx @@ -0,0 +1,92 @@ +import {subYears} from 'date-fns'; +import React, {useCallback} from 'react'; +import DatePicker from '@components/DatePicker'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import type {SubStepProps} from '@hooks/useSubStep/types'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import HelpLinks from '@pages/ReimbursementAccount/PersonalInfo/HelpLinks'; +import CONST from '@src/CONST'; +import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; + +type DateOfBirthStepProps = SubStepProps & { + /** The ID of the form */ + formID: keyof OnyxFormValuesMapping; + + /** The title of the form */ + formTitle: string; + + /** The validation function to call when the form is submitted */ + customValidate?: (values: FormOnyxValues) => Partial>; + + /** A function to call when the form is submitted */ + onSubmit: (values: FormOnyxValues) => void; + + /** Fields list of the form */ + stepFields: Array>; + + /** The ID of the date of birth input */ + dobInputID: keyof FormOnyxValues; + + /** The default value for the date of birth input */ + dobDefaultValue: string; +}; + +function DateOfBirthStep({formID, formTitle, customValidate, onSubmit, stepFields, dobInputID, dobDefaultValue, isEditing}: DateOfBirthStepProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const minDate = subYears(new Date(), CONST.DATE_BIRTH.MAX_AGE); + const maxDate = subYears(new Date(), CONST.DATE_BIRTH.MIN_AGE_FOR_PAYMENT); + + const validate = useCallback( + (values: FormOnyxValues): Partial> => { + const errors = ValidationUtils.getFieldRequiredErrors(values, stepFields); + + if (values[dobInputID]) { + if (!ValidationUtils.isValidPastDate(values[dobInputID]) || !ValidationUtils.meetsMaximumAgeRequirement(values[dobInputID])) { + // @ts-expect-error type mismatch to be fixed + errors[dobInputID] = translate('bankAccount.error.dob'); + } else if (!ValidationUtils.meetsMinimumAgeRequirement(values[dobInputID])) { + // @ts-expect-error type mismatch to be fixed + errors[dobInputID] = translate('bankAccount.error.age'); + } + } + + return errors; + }, + [dobInputID, stepFields, translate], + ); + + return ( + + {formTitle} + + + + ); +} + +DateOfBirthStep.defaultName = 'DateOfBirthStep'; + +export default DateOfBirthStep; From 4f1ee1e7b97fd57e54f8f63be0942e6b42e458db Mon Sep 17 00:00:00 2001 From: truph01 Date: Wed, 16 Oct 2024 16:59:13 +0700 Subject: [PATCH 041/312] fix: update getRoomWelcomeMessage function --- src/libs/SidebarUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index eb5b3c58cdef..c63d8e80d811 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -585,7 +585,7 @@ function getRoomWelcomeMessage(report: OnyxEntry): WelcomeMessage { const workspaceName = ReportUtils.getPolicyName(report); if (report?.description) { - welcomeMessage.messageHtml = report.description; + welcomeMessage.messageHtml = ReportUtils.getReportDescription(report); welcomeMessage.messageText = Parser.htmlToText(welcomeMessage.messageHtml); return welcomeMessage; } From bae4112f2f8401a20f469ff215ca19c6f8378f69 Mon Sep 17 00:00:00 2001 From: burczu Date: Wed, 16 Oct 2024 12:49:17 +0200 Subject: [PATCH 042/312] common date of birth step used in the enable payments page --- .../PersonalInfo/substeps/DateOfBirthStep.tsx | 72 +++++-------------- 1 file changed, 17 insertions(+), 55 deletions(-) diff --git a/src/pages/EnablePayments/PersonalInfo/substeps/DateOfBirthStep.tsx b/src/pages/EnablePayments/PersonalInfo/substeps/DateOfBirthStep.tsx index d476fdcc5c86..6c91715e8eb9 100644 --- a/src/pages/EnablePayments/PersonalInfo/substeps/DateOfBirthStep.tsx +++ b/src/pages/EnablePayments/PersonalInfo/substeps/DateOfBirthStep.tsx @@ -1,82 +1,44 @@ -import {subYears} from 'date-fns'; -import React, {useCallback} from 'react'; +import React from 'react'; import {useOnyx} from 'react-native-onyx'; -import DatePicker from '@components/DatePicker'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import Text from '@components/Text'; +import type {FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; +import CommonDateOfBirthStep from '@components/SubStepForms/DateOfBirthStep'; import useLocalize from '@hooks/useLocalize'; import type {SubStepProps} from '@hooks/useSubStep/types'; -import useThemeStyles from '@hooks/useThemeStyles'; import useWalletAdditionalDetailsStepFormSubmit from '@hooks/useWalletAdditionalDetailsStepFormSubmit'; -import * as ValidationUtils from '@libs/ValidationUtils'; -import HelpLinks from '@pages/ReimbursementAccount/PersonalInfo/HelpLinks'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/WalletAdditionalDetailsForm'; const PERSONAL_INFO_DOB_KEY = INPUT_IDS.PERSONAL_INFO_STEP.DOB; -const STEP_FIELDS = [PERSONAL_INFO_DOB_KEY]; +const STEP_FIELDS = [PERSONAL_INFO_DOB_KEY] as Array>; -const minDate = subYears(new Date(), CONST.DATE_BIRTH.MAX_AGE); -const maxDate = subYears(new Date(), CONST.DATE_BIRTH.MIN_AGE_FOR_PAYMENT); - -function DateOfBirthStep({onNext, isEditing}: SubStepProps) { +function DateOfBirthStep({onNext, onMove, isEditing}: SubStepProps) { const {translate} = useLocalize(); - const styles = useThemeStyles(); - - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS); - - if (values.dob) { - if (!ValidationUtils.isValidPastDate(values.dob) || !ValidationUtils.meetsMaximumAgeRequirement(values.dob)) { - errors.dob = translate('bankAccount.error.dob'); - } else if (!ValidationUtils.meetsMinimumAgeRequirement(values.dob)) { - errors.dob = translate('bankAccount.error.age'); - } - } - - return errors; - }, - [translate], - ); const [walletAdditionalDetails] = useOnyx(ONYXKEYS.WALLET_ADDITIONAL_DETAILS); - const dobDefaultValue = walletAdditionalDetails?.[PERSONAL_INFO_DOB_KEY] ?? walletAdditionalDetails?.[PERSONAL_INFO_DOB_KEY] ?? ''; const handleSubmit = useWalletAdditionalDetailsStepFormSubmit({ fieldIds: STEP_FIELDS, onNext, shouldSaveDraft: isEditing, - }); + }) as (values: FormOnyxValues) => void; return ( - - {translate('personalInfoStep.whatsYourDOB')} - - - + stepFields={STEP_FIELDS} + dobInputID={PERSONAL_INFO_DOB_KEY as keyof FormOnyxValues} + dobDefaultValue={dobDefaultValue} + /> ); } -DateOfBirthStep.displayName = 'DateOfBirthStep'; +DateOfBirthStep.defaultName = 'DateOfBirthStep'; export default DateOfBirthStep; From 544754cc4f0bee839297693f136ccad4c6de4bff Mon Sep 17 00:00:00 2001 From: burczu Date: Wed, 16 Oct 2024 13:12:50 +0200 Subject: [PATCH 043/312] common date of birth step component used in missing personal details page --- .../substeps/DateOfBirth.tsx | 71 +++++-------------- 1 file changed, 18 insertions(+), 53 deletions(-) diff --git a/src/pages/MissingPersonalDetails/substeps/DateOfBirth.tsx b/src/pages/MissingPersonalDetails/substeps/DateOfBirth.tsx index f9b54c1b4758..a31146a0d13b 100644 --- a/src/pages/MissingPersonalDetails/substeps/DateOfBirth.tsx +++ b/src/pages/MissingPersonalDetails/substeps/DateOfBirth.tsx @@ -1,74 +1,39 @@ -import {subYears} from 'date-fns'; -import React, {useCallback} from 'react'; -import {View} from 'react-native'; -import DatePicker from '@components/DatePicker'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import Text from '@components/Text'; +import React from 'react'; +import type {FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; +import DateOfBirthStep from '@components/SubStepForms/DateOfBirthStep'; import useLocalize from '@hooks/useLocalize'; import usePersonalDetailsFormSubmit from '@hooks/usePersonalDetailsFormSubmit'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as ValidationUtils from '@libs/ValidationUtils'; import type {CustomSubStepProps} from '@pages/MissingPersonalDetails/types'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; -const STEP_FIELDS = [INPUT_IDS.DATE_OF_BIRTH]; +const STEP_FIELDS = [INPUT_IDS.DATE_OF_BIRTH] as Array>; -function DateOfBirthStep({isEditing, onNext, personalDetailsValues}: CustomSubStepProps) { +function DateOfBirth({isEditing, onNext, onMove, personalDetailsValues}: CustomSubStepProps) { const {translate} = useLocalize(); - const styles = useThemeStyles(); - - const minDate = subYears(new Date(), CONST.DATE_BIRTH.MAX_AGE); - const maxDate = subYears(new Date(), CONST.DATE_BIRTH.MIN_AGE_FOR_PAYMENT); const handleSubmit = usePersonalDetailsFormSubmit({ fieldIds: STEP_FIELDS, onNext, shouldSaveDraft: true, - }); - - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; - if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.DATE_OF_BIRTH])) { - errors[INPUT_IDS.DATE_OF_BIRTH] = translate('common.error.fieldRequired'); - } else if (!ValidationUtils.isValidPastDate(values[INPUT_IDS.DATE_OF_BIRTH]) || !ValidationUtils.meetsMaximumAgeRequirement(values[INPUT_IDS.DATE_OF_BIRTH])) { - errors[INPUT_IDS.DATE_OF_BIRTH] = translate('bankAccount.error.dob'); - } - return errors; - }, - [translate], - ); + }) as (values: FormOnyxValues) => void; return ( - - - {translate('privatePersonalDetails.enterDateOfBirth')} - - - + stepFields={STEP_FIELDS} + dobInputID={INPUT_IDS.DATE_OF_BIRTH as keyof FormOnyxValues} + dobDefaultValue={personalDetailsValues[INPUT_IDS.DATE_OF_BIRTH]} + /> ); } -DateOfBirthStep.displayName = 'DateOfBirthStep'; +DateOfBirth.defaultName = 'DateOfBirth'; -export default DateOfBirthStep; +export default DateOfBirth; From 77fe4453387ba5de2164fa43ebca6c02acae91d0 Mon Sep 17 00:00:00 2001 From: burczu Date: Wed, 16 Oct 2024 14:12:49 +0200 Subject: [PATCH 044/312] common date of birth step component added to reimbursement account --- .../PersonalInfo/substeps/DateOfBirth.tsx | 74 +++++-------------- 1 file changed, 18 insertions(+), 56 deletions(-) diff --git a/src/pages/ReimbursementAccount/PersonalInfo/substeps/DateOfBirth.tsx b/src/pages/ReimbursementAccount/PersonalInfo/substeps/DateOfBirth.tsx index 8c68380d6e55..efeab25b146b 100644 --- a/src/pages/ReimbursementAccount/PersonalInfo/substeps/DateOfBirth.tsx +++ b/src/pages/ReimbursementAccount/PersonalInfo/substeps/DateOfBirth.tsx @@ -1,20 +1,13 @@ -import {subYears} from 'date-fns'; -import React, {useCallback} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; +import React from 'react'; import {withOnyx} from 'react-native-onyx'; -import DatePicker from '@components/DatePicker'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import Text from '@components/Text'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; +import DateOfBirthStep from '@components/SubStepForms/DateOfBirthStep'; import useLocalize from '@hooks/useLocalize'; import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit'; import type {SubStepProps} from '@hooks/useSubStep/types'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as ValidationUtils from '@libs/ValidationUtils'; -import HelpLinks from '@pages/ReimbursementAccount/PersonalInfo/HelpLinks'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; import type {ReimbursementAccountForm} from '@src/types/form'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import type {ReimbursementAccount} from '@src/types/onyx'; @@ -30,66 +23,35 @@ type DateOfBirthOnyxProps = { type DateOfBirthProps = DateOfBirthOnyxProps & SubStepProps; const PERSONAL_INFO_DOB_KEY = INPUT_IDS.PERSONAL_INFO_STEP.DOB; -const STEP_FIELDS = [PERSONAL_INFO_DOB_KEY]; +const STEP_FIELDS = [PERSONAL_INFO_DOB_KEY] as Array>; -function DateOfBirth({reimbursementAccount, reimbursementAccountDraft, onNext, isEditing}: DateOfBirthProps) { +function DateOfBirth({reimbursementAccount, reimbursementAccountDraft, onNext, onMove, isEditing}: DateOfBirthProps) { const {translate} = useLocalize(); - const styles = useThemeStyles(); - - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS); - - if (values.dob) { - if (!ValidationUtils.isValidPastDate(values.dob) || !ValidationUtils.meetsMaximumAgeRequirement(values.dob)) { - errors.dob = translate('bankAccount.error.dob'); - } else if (!ValidationUtils.meetsMinimumAgeRequirement(values.dob)) { - errors.dob = translate('bankAccount.error.age'); - } - } - - return errors; - }, - [translate], - ); const dobDefaultValue = reimbursementAccount?.achData?.[PERSONAL_INFO_DOB_KEY] ?? reimbursementAccountDraft?.[PERSONAL_INFO_DOB_KEY] ?? ''; - const minDate = subYears(new Date(), CONST.DATE_BIRTH.MAX_AGE); - const maxDate = subYears(new Date(), CONST.DATE_BIRTH.MIN_AGE_FOR_PAYMENT); - const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: STEP_FIELDS, onNext, shouldSaveDraft: isEditing, - }); + }) as (values: FormOnyxValues) => void; return ( - - {translate('personalInfoStep.enterYourDateOfBirth')} - - - + stepFields={STEP_FIELDS} + dobInputID={PERSONAL_INFO_DOB_KEY as keyof FormOnyxValues} + dobDefaultValue={dobDefaultValue} + /> ); } -DateOfBirth.displayName = 'DateOfBirth'; +DateOfBirth.defaultName = 'DateOfBirth'; export default withOnyx({ // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM From 23e579f967a7439eaa02e88975cf9d85b827404c Mon Sep 17 00:00:00 2001 From: burczu Date: Wed, 16 Oct 2024 14:25:41 +0200 Subject: [PATCH 045/312] common date of birth component used in beneficial owner page --- .../DateOfBirthUBO.tsx | 63 +++++-------------- 1 file changed, 15 insertions(+), 48 deletions(-) diff --git a/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/DateOfBirthUBO.tsx b/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/DateOfBirthUBO.tsx index b5a4a6a94bed..4d1bd17d83e7 100644 --- a/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/DateOfBirthUBO.tsx +++ b/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/DateOfBirthUBO.tsx @@ -1,19 +1,14 @@ -import {subYears} from 'date-fns'; import React from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import DatePicker from '@components/DatePicker'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import Text from '@components/Text'; +import type {FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; +import DateOfBirthStep from '@components/SubStepForms/DateOfBirthStep'; import useLocalize from '@hooks/useLocalize'; import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit'; import type {SubStepProps} from '@hooks/useSubStep/types'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as ValidationUtils from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; import type {ReimbursementAccountForm} from '@src/types/form'; const DOB = CONST.BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA.DOB; @@ -25,62 +20,34 @@ type DateOfBirthUBOOnyxProps = { }; type DateOfBirthUBOProps = SubStepProps & DateOfBirthUBOOnyxProps & {beneficialOwnerBeingModifiedID: string}; -function DateOfBirthUBO({reimbursementAccountDraft, onNext, isEditing, beneficialOwnerBeingModifiedID}: DateOfBirthUBOProps) { +function DateOfBirthUBO({reimbursementAccountDraft, onNext, onMove, isEditing, beneficialOwnerBeingModifiedID}: DateOfBirthUBOProps) { const {translate} = useLocalize(); - const styles = useThemeStyles(); const dobInputID = `${BENEFICIAL_OWNER_PREFIX}_${beneficialOwnerBeingModifiedID}_${DOB}` as const; const dobDefaultValue = reimbursementAccountDraft?.[dobInputID] ?? ''; - const minDate = subYears(new Date(), CONST.DATE_BIRTH.MAX_AGE); - const maxDate = subYears(new Date(), CONST.DATE_BIRTH.MIN_AGE_FOR_PAYMENT); - - const validate = (values: FormOnyxValues): FormInputErrors => { - const errors = ValidationUtils.getFieldRequiredErrors(values, [dobInputID]); - - if (values[dobInputID]) { - if (!ValidationUtils.isValidPastDate(values[dobInputID]) || !ValidationUtils.meetsMaximumAgeRequirement(values[dobInputID])) { - errors[dobInputID] = translate('bankAccount.error.dob'); - } else if (!ValidationUtils.meetsMinimumAgeRequirement(values[dobInputID])) { - errors[dobInputID] = translate('bankAccount.error.age'); - } - } - - return errors; - }; - const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: [dobInputID], onNext, shouldSaveDraft: isEditing, - }); + }) as (values: FormOnyxValues) => void; return ( - - {translate('beneficialOwnerInfoStep.enterTheDateOfBirthOfTheOwner')} - - + stepFields={[dobInputID] as Array>} + dobInputID={dobInputID as keyof FormOnyxValues} + dobDefaultValue={dobDefaultValue} + /> ); } -DateOfBirthUBO.displayName = 'DateOfBirthUBO'; +DateOfBirthUBO.defaultName = 'DateOfBirthUBO'; export default withOnyx({ reimbursementAccountDraft: { From 699acb12b9ab204f90256db11f00503864d8b4db Mon Sep 17 00:00:00 2001 From: shahinyan11 Date: Wed, 16 Oct 2024 18:10:08 +0400 Subject: [PATCH 046/312] Fix cuting blue border --- src/components/SelectionList/Search/ReportListItem.tsx | 3 ++- src/styles/index.ts | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index 147e1686be5b..069970e9dbc9 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {View} from 'react-native'; +import {Platform, View} from 'react-native'; import Checkbox from '@components/Checkbox'; import BaseListItem from '@components/SelectionList/BaseListItem'; import type {ListItem, ReportListItemProps, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; @@ -91,6 +91,7 @@ function ReportListItem({ item.isSelected && styles.activeComponentBG, isFocused && styles.sidebarLinkActive, styles.mh0, + Platform.OS === CONST.PLATFORM.WEB && isFocused && styles.outsetShadow, ]; const handleOnButtonPress = () => { diff --git a/src/styles/index.ts b/src/styles/index.ts index 4738844f314f..ab372a8001da 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5277,6 +5277,10 @@ const styles = (theme: ThemeColors) => borderColor: theme.border, padding: 16, }, + + outsetShadow: { + boxShadow: `0px 0px 0px 1px ${colors.blue300}`, + }, } satisfies Styles); type ThemeStyles = ReturnType; From ecb7aaa4ee279520e671b7b497078fd1ca356b95 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Wed, 16 Oct 2024 17:33:08 +0200 Subject: [PATCH 047/312] Adding case for OpenReport when creating a thread --- src/libs/actions/RequestConflictUtils.ts | 15 ++++- tests/actions/ReportTest.ts | 80 ++++++++++++++++++++---- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts index f8aefbe73d87..022105237210 100644 --- a/src/libs/actions/RequestConflictUtils.ts +++ b/src/libs/actions/RequestConflictUtils.ts @@ -1,5 +1,6 @@ import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {WriteCommand} from '@libs/API/types'; import {WRITE_COMMANDS} from '@libs/API/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type OnyxRequest from '@src/types/onyx/Request'; @@ -43,14 +44,25 @@ function resolveDuplicationConflictAction(persistedRequests: OnyxRequest[], comm function resolveCommentDeletionConflicts(persistedRequests: OnyxRequest[], reportActionID: string, originalReportID: string): ConflictActionData { const indices: number[] = []; + const commentCouldBeThread: Record = {}; let addCommentFound = false; - persistedRequests.forEach((request, index) => { + // If the request will open a Thread, we should not delete the comment and we should send all the requests + if (request.command === WRITE_COMMANDS.OPEN_REPORT && request.data?.parentReportActionID === reportActionID && reportActionID in commentCouldBeThread) { + const indexToRemove = commentCouldBeThread[reportActionID]; + indices.splice(indexToRemove, 1); + return; + } + if (!commentsToBeDeleted.has(request.command) || request.data?.reportActionID !== reportActionID) { return; } + + // If we find a new message, we probably want to remove it and not perform any request given that the server + // doesn't know about it yet. if (addNewMessage.has(request.command)) { addCommentFound = true; + commentCouldBeThread[reportActionID] = index; } indices.push(index); }); @@ -64,6 +76,7 @@ function resolveCommentDeletionConflicts(persistedRequests: OnyxRequest[], repor } if (addCommentFound) { + // The new message performs some changes in Onyx, so we need to rollback those changes. const rollbackData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index dc54c3730ad8..bdb1b0c5e598 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -778,8 +778,7 @@ describe('actions/Report', () => { const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); Report.editReportComment(REPORT_ID, reportAction, 'Testing an edited comment'); - // wait for Onyx.connect execute the callback and start processing the queue - await Promise.resolve(); + await waitForBatchedUpdates(); await new Promise((resolve) => { const connection = Onyx.connect({ @@ -795,12 +794,13 @@ describe('actions/Report', () => { }); }); - // Checking the Report Action exists before dleting it + // Checking the Report Action exists before deleting it await new Promise((resolve) => { const connection = Onyx.connect({ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, callback: (reportActions) => { Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).not.toBeNull(); expect(reportActions?.[reportActionID].reportActionID).toBe(reportActionID); resolve(); @@ -859,9 +859,7 @@ describe('actions/Report', () => { key: ONYXKEYS.PERSISTED_REQUESTS, callback: (persistedRequests) => { Onyx.disconnect(connection); - expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.UPDATE_COMMENT); - resolve(); }, }); @@ -909,9 +907,7 @@ describe('actions/Report', () => { key: ONYXKEYS.PERSISTED_REQUESTS, callback: (persistedRequests) => { Onyx.disconnect(connection); - expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.UPDATE_COMMENT); - resolve(); }, }); @@ -950,21 +946,20 @@ describe('actions/Report', () => { const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); // wait for Onyx.connect execute the callback and start processing the queue - await Promise.resolve(); + await waitForBatchedUpdates(); await new Promise((resolve) => { const connection = Onyx.connect({ key: ONYXKEYS.PERSISTED_REQUESTS, callback: (persistedRequests) => { Onyx.disconnect(connection); - expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_ATTACHMENT); resolve(); }, }); }); - // Checking the Report Action exists before dleting it + // Checking the Report Action exists before deleting it await new Promise((resolve) => { const connection = Onyx.connect({ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, @@ -1018,21 +1013,20 @@ describe('actions/Report', () => { const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); // wait for Onyx.connect execute the callback and start processing the queue - await Promise.resolve(); + await waitForBatchedUpdates(); await new Promise((resolve) => { const connection = Onyx.connect({ key: ONYXKEYS.PERSISTED_REQUESTS, callback: (persistedRequests) => { Onyx.disconnect(connection); - expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT); resolve(); }, }); }); - // Checking the Report Action exists before dleting it + // Checking the Report Action exists before deleting it await new Promise((resolve) => { const connection = Onyx.connect({ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, @@ -1228,4 +1222,64 @@ describe('actions/Report', () => { TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.REMOVE_EMOJI_REACTION, 0); TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 1); }); + + it('should create and delete thread processing all the requests', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + await waitForBatchedUpdates(); + + Report.addComment(REPORT_ID, 'Testing a comment'); + + const newComment = PersistedRequests.getAll().at(0); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + + Report.openReport( + REPORT_ID, + undefined, + ['test@user.com'], + { + isOptimisticReport: true, + parentReportID: REPORT_ID, + parentReportActionID: reportActionID, + reportID: '2', + }, + reportActionID, + ); + + Report.deleteReportComment(REPORT_ID, reportAction); + + expect(PersistedRequests.getAll().length).toBe(3); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + if (persistedRequests?.length !== 3) { + return; + } + Onyx.disconnect(connection); + + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_COMMENT); + expect(persistedRequests?.at(1)?.command).toBe(WRITE_COMMANDS.OPEN_REPORT); + expect(persistedRequests?.at(2)?.command).toBe(WRITE_COMMANDS.DELETE_COMMENT); + resolve(); + }, + }); + }); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 1); + }); }); From eb511fd436319891706599c43ab67cfb893a9868 Mon Sep 17 00:00:00 2001 From: shahinyan11 Date: Wed, 16 Oct 2024 20:55:24 +0400 Subject: [PATCH 048/312] Change style name --- src/components/SelectionList/Search/ReportListItem.tsx | 2 +- src/styles/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index 069970e9dbc9..d74b054136c7 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -91,7 +91,7 @@ function ReportListItem({ item.isSelected && styles.activeComponentBG, isFocused && styles.sidebarLinkActive, styles.mh0, - Platform.OS === CONST.PLATFORM.WEB && isFocused && styles.outsetShadow, + Platform.OS === CONST.PLATFORM.WEB && isFocused && styles.selectionOuterBorder, ]; const handleOnButtonPress = () => { diff --git a/src/styles/index.ts b/src/styles/index.ts index ab372a8001da..a4cf3f1465d2 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5278,7 +5278,7 @@ const styles = (theme: ThemeColors) => padding: 16, }, - outsetShadow: { + selectionOuterBorder: { boxShadow: `0px 0px 0px 1px ${colors.blue300}`, }, } satisfies Styles); From 04e7035dc9c39b17b538545111df1647ba25f239 Mon Sep 17 00:00:00 2001 From: Mykhailo Kravchenko Date: Thu, 17 Oct 2024 13:01:24 +0200 Subject: [PATCH 049/312] remove debug logs --- src/libs/actions/Policy/Policy.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 1b78de93e6cb..fa4dd201d7e3 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -4827,9 +4827,6 @@ function updateInvoiceCompanyName(policyID: string, companyName: string) { companyName, }; - // TODO: Remove dev log - console.debug('[TEST] Updating invoice company name', parameters, {optimisticData, successData, failureData}); - API.write(WRITE_COMMANDS.UPDATE_INVOICE_COMPANY_NAME, parameters, {optimisticData, successData, failureData}); } @@ -4892,9 +4889,6 @@ function updateInvoiceCompanyWebsite(policyID: string, companyWebsite: string) { companyWebsite, }; - // TODO: Remove dev log - console.debug('[TEST] Updating invoice company website', parameters, {optimisticData, successData, failureData}); - API.write(WRITE_COMMANDS.UPDATE_INVOICE_COMPANY_WEBSITE, parameters, {optimisticData, successData, failureData}); } From 118bb207db78a324ebce78e0cb000d84ba988852 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Thu, 17 Oct 2024 19:39:35 +0700 Subject: [PATCH 050/312] feat: add translations --- src/languages/en.ts | 8 ++++++++ src/languages/es.ts | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/src/languages/en.ts b/src/languages/en.ts index d73015693e7a..d1ef9cbff396 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3177,6 +3177,14 @@ const translations = { issuedCardNoShippingDetails: ({assignee}: AssigneeParams) => `issued ${assignee} an Expensify Card! The card will be shipped once shipping details are added.`, issuedCardVirtual: ({assignee, link}: IssueVirtualCardParams) => `issued ${assignee} a virtual ${link}! The card can be used right away.`, addedShippingDetails: ({assignee}: AssigneeParams) => `${assignee} added shipping details. Expensify Card will arrive in 2-3 business days.`, + verifyingHeader: 'Verifying', + bankAccountVerifiedHeader: 'Bank account verified', + verifyingBankAccount: 'Verifying bank account...', + verifyingBankAccountDescription: 'Hold on while we check that this account can be used for issuing Expensify Cards', + bankAccountVerified: 'Bank account verified!', + bankAccountVerifiedDescription: 'You can now issue Expensify Cards to your workspace members.', + oneMoreStep: 'One more step...', + oneMoreStepDescription: 'Looks like we need to manually verify your bank account. Please head on over to Concierge where your instructions are waiting for you.', }, categories: { deleteCategories: 'Delete categories', diff --git a/src/languages/es.ts b/src/languages/es.ts index a9d35a6f8228..d11806c1db32 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3217,6 +3217,14 @@ const translations = { issuedCardNoShippingDetails: ({assignee}: AssigneeParams) => `¡emitió a ${assignee} una Tarjeta Expensify! La tarjeta se enviará una vez que se agreguen los detalles de envío.`, issuedCardVirtual: ({assignee, link}: IssueVirtualCardParams) => `¡emitió a ${assignee} una ${link} virtual! La tarjeta puede utilizarse inmediatamente.`, addedShippingDetails: ({assignee}: AssigneeParams) => `${assignee} agregó los detalles de envío. La Tarjeta Expensify llegará en 2-3 días hábiles.`, + verifyingHeader: 'Verificando', + bankAccountVerifiedHeader: 'Cuenta bancaria verificada', + verifyingBankAccount: 'Verificando cuenta bancaria...', + verifyingBankAccountDescription: 'Espera mientras comprobamos que esta cuenta se puede utilizar para emitir tarjetas Expensify.', + bankAccountVerified: '¡Cuenta bancaria verificada!', + bankAccountVerifiedDescription: 'Ahora puedes emitir tarjetas de Expensify para los miembros de tu espacio de trabajo.', + oneMoreStep: 'Un paso más', + oneMoreStepDescription: 'Parece que tenemos que verificar manualmente tu cuenta bancaria. Dirígete a Concierge, donde te esperan las instrucciones.', }, categories: { deleteCategories: 'Eliminar categorías', From bba14053a3b22496c2f00d62c0af730d8e361a11 Mon Sep 17 00:00:00 2001 From: burczu Date: Fri, 18 Oct 2024 11:15:34 +0200 Subject: [PATCH 051/312] common address step component created --- src/components/SubStepForms/AddressStep.tsx | 109 ++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/components/SubStepForms/AddressStep.tsx diff --git a/src/components/SubStepForms/AddressStep.tsx b/src/components/SubStepForms/AddressStep.tsx new file mode 100644 index 000000000000..1ad1f2794078 --- /dev/null +++ b/src/components/SubStepForms/AddressStep.tsx @@ -0,0 +1,109 @@ +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import FormProvider from '@components/Form/FormProvider'; +import type {FormInputErrors, FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import type {SubStepProps} from '@hooks/useSubStep/types'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import AddressFormFields from '@pages/ReimbursementAccount/AddressFormFields'; +import HelpLinks from '@pages/ReimbursementAccount/PersonalInfo/HelpLinks'; +import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; + +type AddressValues = { + street: string; + city: string; + state: string; + zipCode: string; +}; + +type AddressStepProps = SubStepProps & { + /** The ID of the form */ + formID: TFormID; + + /** The title of the form */ + formTitle: string; + + /** The disclaimer informing that PO box is not allowed */ + formPOBoxDisclaimer?: string; + + /** The validation function to call when the form is submitted */ + customValidate?: (values: FormOnyxValues) => FormInputErrors; + + /** A function to call when the form is submitted */ + onSubmit: (values: FormOnyxValues) => void; + + /** Fields list of the form */ + stepFields: Array>; + + /* The IDs of the input fields */ + inputFieldsIDs: AddressValues; + + /** The default values for the form */ + defaultValues: AddressValues; + + /** Should show help links */ + shouldShowHelpLinks?: boolean; +}; + +function AddressStep({ + formID, + formTitle, + formPOBoxDisclaimer, + customValidate, + onSubmit, + stepFields, + inputFieldsIDs, + defaultValues, + shouldShowHelpLinks, + isEditing, +}: AddressStepProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const validate = useCallback( + (values: FormOnyxValues): FormInputErrors => { + const errors = ValidationUtils.getFieldRequiredErrors(values, stepFields); + + if (values[inputFieldsIDs.street] && !ValidationUtils.isValidAddress(values[inputFieldsIDs.street])) { + // @ts-expect-error type mismatch to be fixed + errors[inputFieldsIDs.street] = translate('bankAccount.error.addressStreet'); + } + + if (values[inputFieldsIDs.zipCode] && !ValidationUtils.isValidZipCode(values[inputFieldsIDs.zipCode])) { + // @ts-expect-error type mismatch to be fixed + errors[inputFieldsIDs.street] = translate('bankAccount.error.zipCode'); + } + + return errors; + }, + [inputFieldsIDs.street, inputFieldsIDs.zipCode, stepFields, translate], + ); + + return ( + + + {formTitle} + {formPOBoxDisclaimer && {formPOBoxDisclaimer}} + + {shouldShowHelpLinks && } + + + ); +} + +AddressStep.displayName = 'AddressStep'; + +export default AddressStep; From 9ecd3e18942dce6834d29fd3ab9b355315cbac08 Mon Sep 17 00:00:00 2001 From: burczu Date: Fri, 18 Oct 2024 14:49:24 +0200 Subject: [PATCH 052/312] common address step component typings fixed and improved --- src/components/SubStepForms/AddressStep.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/SubStepForms/AddressStep.tsx b/src/components/SubStepForms/AddressStep.tsx index 1ad1f2794078..d32c4afa9a6e 100644 --- a/src/components/SubStepForms/AddressStep.tsx +++ b/src/components/SubStepForms/AddressStep.tsx @@ -1,7 +1,7 @@ import React, {useCallback} from 'react'; import {View} from 'react-native'; import FormProvider from '@components/Form/FormProvider'; -import type {FormInputErrors, FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; +import type {FormInputErrors, FormOnyxKeys, FormOnyxValues, FormValue} from '@components/Form/types'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import type {SubStepProps} from '@hooks/useSubStep/types'; @@ -18,7 +18,7 @@ type AddressValues = { zipCode: string; }; -type AddressStepProps = SubStepProps & { +type AddressStepProps = SubStepProps & { /** The ID of the form */ formID: TFormID; @@ -35,7 +35,7 @@ type AddressStepProps = SubStepProps & { onSubmit: (values: FormOnyxValues) => void; /** Fields list of the form */ - stepFields: Array>; + stepFields: Array>; /* The IDs of the input fields */ inputFieldsIDs: AddressValues; @@ -47,7 +47,7 @@ type AddressStepProps = SubStepProps & { shouldShowHelpLinks?: boolean; }; -function AddressStep({ +function AddressStep({ formID, formTitle, formPOBoxDisclaimer, @@ -66,12 +66,14 @@ function AddressStep({ (values: FormOnyxValues): FormInputErrors => { const errors = ValidationUtils.getFieldRequiredErrors(values, stepFields); - if (values[inputFieldsIDs.street] && !ValidationUtils.isValidAddress(values[inputFieldsIDs.street])) { + const street = values[inputFieldsIDs.street as keyof typeof values]; + if (street && !ValidationUtils.isValidAddress(street as FormValue)) { // @ts-expect-error type mismatch to be fixed errors[inputFieldsIDs.street] = translate('bankAccount.error.addressStreet'); } - if (values[inputFieldsIDs.zipCode] && !ValidationUtils.isValidZipCode(values[inputFieldsIDs.zipCode])) { + const zipCode = values[inputFieldsIDs.zipCode as keyof typeof values]; + if (zipCode && !ValidationUtils.isValidZipCode(zipCode as string)) { // @ts-expect-error type mismatch to be fixed errors[inputFieldsIDs.street] = translate('bankAccount.error.zipCode'); } From eb12fa5dd59f0edb8c90815f6c08017f39dc537d Mon Sep 17 00:00:00 2001 From: Yauheni Pasiukevich Date: Fri, 18 Oct 2024 20:12:01 +0200 Subject: [PATCH 053/312] feat: create upload picker component --- src/CONST.ts | 5 + .../AttachmentPicker/index.native.tsx | 182 +++++++++++------- src/components/AttachmentPicker/index.tsx | 41 +++- src/components/AttachmentPicker/types.ts | 8 +- src/components/AvatarWithImagePicker.tsx | 8 +- src/components/Form/types.ts | 8 +- .../ImportOnyxState/BaseImportOnyxState.tsx | 2 +- src/components/UploadFile.tsx | 113 +++++++++++ src/languages/en.ts | 4 + src/languages/es.ts | 4 + src/languages/params.ts | 5 + .../AttachmentPickerWithMenuItems.tsx | 2 +- .../step/IOURequestStepScan/index.native.tsx | 2 +- .../request/step/IOURequestStepScan/index.tsx | 4 +- src/styles/variables.ts | 1 + 15 files changed, 299 insertions(+), 90 deletions(-) create mode 100644 src/components/UploadFile.tsx diff --git a/src/CONST.ts b/src/CONST.ts index 399535412f0e..88075d6f6aa8 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -478,6 +478,11 @@ const CONST = { PERSONAL: 'PERSONAL', }, }, + NON_USD_BANK_ACCOUNT: { + ALLOWED_FILE_TYPES: ['pdf', 'jpg', 'jpeg', 'png'], + FILE_LIMIT: 10, + TOTAL_FILES_SIZE_LIMIT_IN_MB: 5, + }, INCORPORATION_TYPES: { LLC: 'LLC', CORPORATION: 'Corp', diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 975ea6c548c0..6b63888609e2 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -33,46 +33,46 @@ type Item = { pickAttachment: () => Promise; }; -/** - * See https://github.com/react-native-image-picker/react-native-image-picker/#options - * for ImagePicker configuration options - */ -const imagePickerOptions: Partial = { - includeBase64: false, - saveToPhotos: false, - selectionLimit: 1, - includeExtra: false, - assetRepresentationMode: 'current', -}; - /** * Return imagePickerOptions based on the type */ -const getImagePickerOptions = (type: string): CameraOptions => { +const getImagePickerOptions = (type: string, fileLimit: number): CameraOptions | ImageLibraryOptions => { // mediaType property is one of the ImagePicker configuration to restrict types' const mediaType = type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE ? 'photo' : 'mixed'; + + /** + * See https://github.com/react-native-image-picker/react-native-image-picker/#options + * for ImagePicker configuration options + */ return { mediaType, - ...imagePickerOptions, + includeBase64: false, + saveToPhotos: false, + includeExtra: false, + assetRepresentationMode: 'current', + selectionLimit: fileLimit, }; }; /** * Return documentPickerOptions based on the type * @param {String} type + * @param {Number} fileLimit * @returns {Object} */ -const getDocumentPickerOptions = (type: string): DocumentPickerOptions => { +const getDocumentPickerOptions = (type: string, fileLimit: number): DocumentPickerOptions => { if (type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE) { return { type: [RNDocumentPicker.types.images], copyTo: 'cachesDirectory', + allowMultiSelection: fileLimit !== 1, }; } return { type: [RNDocumentPicker.types.allFiles], copyTo: 'cachesDirectory', + allowMultiSelection: fileLimit !== 1, }; }; @@ -111,16 +111,19 @@ function AttachmentPicker({ type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, shouldHideCameraOption = false, - shouldHideGalleryOption = false, shouldValidateImage = true, + shouldHideGalleryOption = false, + fileLimit = 1, + totalFilesSizeLimitInMB = 0, }: AttachmentPickerProps) { const styles = useThemeStyles(); const [isVisible, setIsVisible] = useState(false); - const completeAttachmentSelection = useRef<(data: FileObject) => void>(() => {}); + const completeAttachmentSelection = useRef<(data: FileObject[]) => void>(() => {}); const onModalHide = useRef<() => void>(); const onCanceled = useRef<() => void>(() => {}); const popoverRef = useRef(null); + const totalFilesSizeLimitInBytes = totalFilesSizeLimitInMB * 1024 * 1024; const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -135,6 +138,13 @@ function AttachmentPicker({ [translate], ); + const showFilesTooBigAlert = useCallback( + (message = translate('attachmentPicker.filesTooBig')) => { + Alert.alert(translate('attachmentPicker.filesTooBigMessage'), message); + }, + [translate], + ); + /** * Common image picker handling * @@ -143,7 +153,7 @@ function AttachmentPicker({ const showImagePicker = useCallback( (imagePickerFunc: (options: CameraOptions, callback: Callback) => Promise): Promise => new Promise((resolve, reject) => { - imagePickerFunc(getImagePickerOptions(type), (response: ImagePickerResponse) => { + imagePickerFunc(getImagePickerOptions(type, fileLimit), (response: ImagePickerResponse) => { if (response.didCancel) { // When the user cancelled resolve with no attachment return resolve(); @@ -200,7 +210,7 @@ function AttachmentPicker({ } }); }), - [showGeneralAlert, type], + [fileLimit, showGeneralAlert, type], ); /** * Launch the DocumentPicker. Results are in the same format as ImagePicker @@ -209,7 +219,7 @@ function AttachmentPicker({ */ const showDocumentPicker = useCallback( (): Promise => - RNDocumentPicker.pick(getDocumentPickerOptions(type)).catch((error: Error) => { + RNDocumentPicker.pick(getDocumentPickerOptions(type, fileLimit)).catch((error: Error) => { if (RNDocumentPicker.isCancel(error)) { return; } @@ -217,7 +227,7 @@ function AttachmentPicker({ showGeneralAlert(error.message); throw error; }), - [showGeneralAlert, type], + [fileLimit, showGeneralAlert, type], ); const menuItemData: Item[] = useMemo(() => { @@ -261,7 +271,7 @@ function AttachmentPicker({ * @param onPickedHandler A callback that will be called with the selected attachment * @param onCanceledHandler A callback that will be called without a selected attachment */ - const open = (onPickedHandler: (file: FileObject) => void, onCanceledHandler: () => void = () => {}) => { + const open = (onPickedHandler: (files: FileObject[]) => void, onCanceledHandler: () => void = () => {}) => { // eslint-disable-next-line react-compiler/react-compiler completeAttachmentSelection.current = onPickedHandler; onCanceled.current = onCanceledHandler; @@ -286,7 +296,7 @@ function AttachmentPicker({ } return getDataForUpload(fileData) .then((result) => { - completeAttachmentSelection.current(result); + completeAttachmentSelection.current([result]); }) .catch((error: Error) => { showGeneralAlert(error.message); @@ -301,63 +311,91 @@ function AttachmentPicker({ * sends the selected attachment to the caller (parent component) */ const pickAttachment = useCallback( - (attachments: Asset[] | DocumentPickerResponse[] | void = []): Promise | undefined => { + (attachments: Asset[] | DocumentPickerResponse[] | void = []): Promise | undefined => { if (!attachments || attachments.length === 0) { onCanceled.current(); - return Promise.resolve(); + return Promise.resolve([]); } - const fileData = attachments[0]; - if (!fileData) { - onCanceled.current(); - return Promise.resolve(); - } - /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ - const fileDataName = ('fileName' in fileData && fileData.fileName) || ('name' in fileData && fileData.name) || ''; - const fileDataUri = ('fileCopyUri' in fileData && fileData.fileCopyUri) || ('uri' in fileData && fileData.uri) || ''; - - const fileDataObject: FileResponse = { - name: fileDataName ?? '', - uri: fileDataUri, - size: ('size' in fileData && fileData.size) || ('fileSize' in fileData && fileData.fileSize) || null, - type: fileData.type ?? '', - width: ('width' in fileData && fileData.width) || undefined, - height: ('height' in fileData && fileData.height) || undefined, - }; + if (totalFilesSizeLimitInMB) { + const totalFileSize = attachments.reduce((total, fileData) => { + /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ + const size = ('size' in fileData && fileData.size) || ('fileSize' in fileData && fileData.fileSize) || 0; + return total + size; + }, 0); - if (!shouldValidateImage && fileDataName && Str.isImage(fileDataName)) { - ImageSize.getSize(fileDataUri) - .then(({width, height}) => { - fileDataObject.width = width; - fileDataObject.height = height; - return fileDataObject; - }) - .then((file) => { - getDataForUpload(file) - .then((result) => { - completeAttachmentSelection.current(result); - }) - .catch((error: Error) => { - showGeneralAlert(error.message); - throw error; - }); - }); - return; + if (totalFileSize > totalFilesSizeLimitInBytes) { + showFilesTooBigAlert(); + return Promise.resolve([]); + } } - /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ - if (fileDataName && Str.isImage(fileDataName)) { - ImageSize.getSize(fileDataUri) - .then(({width, height}) => { - fileDataObject.width = width; - fileDataObject.height = height; - validateAndCompleteAttachmentSelection(fileDataObject); - }) - .catch(() => showImageCorruptionAlert()); - } else { + + const filesToProcess = attachments.map((fileData) => { + if (!fileData) { + onCanceled.current(); + return Promise.resolve(); + } + + /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ + const fileDataName = ('fileName' in fileData && fileData.fileName) || ('name' in fileData && fileData.name) || ''; + const fileDataUri = ('fileCopyUri' in fileData && fileData.fileCopyUri) || ('uri' in fileData && fileData.uri) || ''; + + const fileDataObject: FileResponse = { + name: fileDataName ?? '', + uri: fileDataUri, + size: ('size' in fileData && fileData.size) || ('fileSize' in fileData && fileData.fileSize) || null, + type: fileData.type ?? '', + width: ('width' in fileData && fileData.width) || undefined, + height: ('height' in fileData && fileData.height) || undefined, + }; + + if (!shouldValidateImage && fileDataName && Str.isImage(fileDataName)) { + return ImageSize.getSize(fileDataUri) + .then(({width, height}) => { + fileDataObject.width = width; + fileDataObject.height = height; + return fileDataObject; + }) + .then((file) => { + return getDataForUpload(file) + .then((result) => completeAttachmentSelection.current([result])) + .catch((error) => { + if (error instanceof Error) { + showGeneralAlert(error.message); + } else { + showGeneralAlert('An unknown error occurred'); + } + throw error; + }); + }) + .catch(() => { + showImageCorruptionAlert(); + }); + } + + if (fileDataName && Str.isImage(fileDataName)) { + return ImageSize.getSize(fileDataUri) + .then(({width, height}) => { + fileDataObject.width = width; + fileDataObject.height = height; + + if (fileDataObject.width <= 0 || fileDataObject.height <= 0) { + showImageCorruptionAlert(); + return Promise.resolve(); // Skip processing this corrupted file + } + + return validateAndCompleteAttachmentSelection(fileDataObject); + }) + .catch(() => { + showImageCorruptionAlert(); + }); + } return validateAndCompleteAttachmentSelection(fileDataObject); - } + }); + + return Promise.all(filesToProcess); }, - [validateAndCompleteAttachmentSelection, showImageCorruptionAlert, shouldValidateImage, showGeneralAlert], + [totalFilesSizeLimitInMB, totalFilesSizeLimitInBytes, showFilesTooBigAlert, shouldValidateImage, validateAndCompleteAttachmentSelection, showGeneralAlert, showImageCorruptionAlert], ); /** diff --git a/src/components/AttachmentPicker/index.tsx b/src/components/AttachmentPicker/index.tsx index c4979f544080..69990e7fbf28 100644 --- a/src/components/AttachmentPicker/index.tsx +++ b/src/components/AttachmentPicker/index.tsx @@ -1,5 +1,7 @@ import React, {useRef} from 'react'; import type {ValueOf} from 'type-fest'; +import type {FileObject} from '@components/AttachmentModal'; +import useLocalize from '@hooks/useLocalize'; import * as Browser from '@libs/Browser'; import Visibility from '@libs/Visibility'; import CONST from '@src/CONST'; @@ -42,10 +44,12 @@ function getAcceptableFileTypesFromAList(fileTypes: Array