diff --git a/.github/actions/composite/buildAndroidE2EAPK/action.yml b/.github/actions/composite/buildAndroidE2EAPK/action.yml index 0c5f70929c27..146ddb3a1a66 100644 --- a/.github/actions/composite/buildAndroidE2EAPK/action.yml +++ b/.github/actions/composite/buildAndroidE2EAPK/action.yml @@ -58,21 +58,15 @@ runs: - name: Append environment variables to env file shell: bash run: | - echo "EXPENSIFY_PARTNER_NAME=${EXPENSIFY_PARTNER_NAME}" >> ${{ inputs.PATH_ENV_FILE }} - echo "EXPENSIFY_PARTNER_PASSWORD=${EXPENSIFY_PARTNER_PASSWORD}" >> ${{ inputs.PATH_ENV_FILE }} - echo "EXPENSIFY_PARTNER_USER_ID=${EXPENSIFY_PARTNER_USER_ID}" >> ${{ inputs.PATH_ENV_FILE }} - echo "EXPENSIFY_PARTNER_USER_SECRET=${EXPENSIFY_PARTNER_USER_SECRET}" >> ${{ inputs.PATH_ENV_FILE }} - echo "EXPENSIFY_PARTNER_PASSWORD_EMAIL=${EXPENSIFY_PARTNER_PASSWORD_EMAIL}" >> ${{ inputs.PATH_ENV_FILE }} + echo "EXPENSIFY_PARTNER_NAME=${{ inputs.EXPENSIFY_PARTNER_NAME }}" >> ${{ inputs.PATH_ENV_FILE }} + echo "EXPENSIFY_PARTNER_PASSWORD=${{ inputs.EXPENSIFY_PARTNER_PASSWORD }}" >> ${{ inputs.PATH_ENV_FILE }} + echo "EXPENSIFY_PARTNER_USER_ID=${{ inputs.EXPENSIFY_PARTNER_USER_ID }}" >> ${{ inputs.PATH_ENV_FILE }} + echo "EXPENSIFY_PARTNER_USER_SECRET=${{ inputs.EXPENSIFY_PARTNER_USER_SECRET }}" >> ${{ inputs.PATH_ENV_FILE }} + echo "EXPENSIFY_PARTNER_PASSWORD_EMAIL=${{ inputs.EXPENSIFY_PARTNER_PASSWORD_EMAIL }}" >> ${{ inputs.PATH_ENV_FILE }} - name: Build APK run: npm run ${{ inputs.PACKAGE_SCRIPT_NAME }} shell: bash - env: - EXPENSIFY_PARTNER_NAME: ${{ inputs.EXPENSIFY_PARTNER_NAME }} - EXPENSIFY_PARTNER_PASSWORD: ${{ inputs.EXPENSIFY_PARTNER_PASSWORD }} - EXPENSIFY_PARTNER_USER_ID: ${{ inputs.EXPENSIFY_PARTNER_USER_ID }} - EXPENSIFY_PARTNER_USER_SECRET: ${{ inputs.EXPENSIFY_PARTNER_USER_SECRET }} - EXPENSIFY_PARTNER_PASSWORD_EMAIL: ${{ inputs.EXPENSIFY_PARTNER_PASSWORD_EMAIL }} - name: Upload APK uses: actions/upload-artifact@65d862660abb392b8c4a3d1195a2108db131dd05 diff --git a/android/app/build.gradle b/android/app/build.gradle index b67cb294e29b..4ea2098787f6 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001043400 - versionName "1.4.34-0" + versionCode 1001043500 + versionName "1.4.35-0" } flavorDimensions "default" diff --git a/docs/_includes/end-info.html b/docs/_includes/end-info.html new file mode 100644 index 000000000000..50b9e11847ef --- /dev/null +++ b/docs/_includes/end-info.html @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/docs/_includes/info.html b/docs/_includes/info.html new file mode 100644 index 000000000000..c253f3cbc1de --- /dev/null +++ b/docs/_includes/info.html @@ -0,0 +1,3 @@ +
+ +
\ No newline at end of file diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss index cfdf4ff3a2bc..e05f7d4c08ea 100644 --- a/docs/_sass/_main.scss +++ b/docs/_sass/_main.scss @@ -458,6 +458,26 @@ button { opacity: 0.8; } } + + .info { + padding: 12px; + border-radius: 8px; + background-color: $color-highlightBG; + color: $color-text; + display: flex; + gap: 12px; + align-items: center; + + img { + height: 16px; + width: 16px; + } + + * { + padding: 0; + margin: 0; + } + } } } diff --git a/docs/articles/new-expensify/account-settings/Preferences.md b/docs/articles/new-expensify/account-settings/Preferences.md new file mode 100644 index 000000000000..b94c9d35c1a1 --- /dev/null +++ b/docs/articles/new-expensify/account-settings/Preferences.md @@ -0,0 +1,21 @@ +--- +title: Preferences +description: How to manage your Expensify Preferences +--- +# Overview +Your Preferences in Expensify allow you to customize how you use New Expensify. + +- Set your theme preference + +# How to set your theme preference in New Expensify + +To set or update your theme preference in New Expensify: +1. Go to **Settings > Preferences** +2. Tap on **Theme** +3. You can choose between the _Dark_ theme, the _Light_ theme, or _Use Device Settings_ + +_Use Device Settings_ is the default setting. + +Selecting _Use Device Settings_ will use your device's theme settings. For example, if your device is set to adjust the appearance from light to dark during the day, we'll match that. + +Your theme preference will sync across all your New Expensify apps (mobile, web, or OSX desktop apps). diff --git a/docs/assets/images/info.svg b/docs/assets/images/info.svg new file mode 100644 index 000000000000..96924fbb6cf7 --- /dev/null +++ b/docs/assets/images/info.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/docs/redirects.csv b/docs/redirects.csv index 2571cb1156eb..df615431533f 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -33,3 +33,19 @@ https://community.expensify.com/discussion/6794/how-to-change-your-email-in-expe https://help.expensify.com/articles/expensify-classic/expensify-card/Expensify-Card-Perks.html,https://use.expensify.com/company-credit-card https://help.expensify.com/articles/expensify-classic/expensify-partner-program/How-to-Join-the-ExpensifyApproved!-Partner-Program.html,https://use.expensify.com/accountants-program https://help.expensify.com/articles/expensify-classic/getting-started/approved-accountants/Card-Revenue-Share-For-Expensify-Approved-Partners, https://use.expensify.com/blog/maximizing-rewards-expensifyapproved-accounting-partners-now-earn-0-5-revenue-share +https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/International-Reimbursements,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/Global-Reimbursements +https://community.expensify.com/discussion/4452/how-to-merge-accounts,https://help.expensify.com/articles/expensify-classic/account-settings/Merge-Accounts#gsc.tab=0 +https://community.expensify.com/discussion/4783/how-to-add-or-remove-a-copilot#latest,https://help.expensify.com/articles/expensify-classic/account-settings/Copilot#gsc.tab=0 +https://community.expensify.com/discussion/4343/expensify-anz-partnership-announcement,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Connect-ANZ +https://community.expensify.com/discussion/7318/deep-dive-company-credit-card-import-options,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards +https://community.expensify.com/discussion/2673/personalize-your-commercial-card-feed-name,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Commercial-Card-Feeds +https://community.expensify.com/discussion/6569/how-to-import-and-assign-company-cards-from-csv-file,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import +https://community.expensify.com/discussion/4714/how-to-set-up-a-direct-bank-connection-for-company-cards,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Direct-Bank-Connections +https://community.expensify.com/discussion/4828/how-to-reconcile-your-company-cards-statement-in-expensify,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Reconciliation +https://community.expensify.com/discussion/5366/deep-dive-troubleshooting-credit-card-issues-in-expensify,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Troubleshooting +https://community.expensify.com/discussion/9554/how-to-set-up-global-reimbursemen,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/Global-Reimbursements +https://community.expensify.com/discussion/4463/how-to-remove-or-manage-settings-for-imported-personal-cards,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards +https://community.expensify.com/discussion/5793/how-to-connect-your-personal-card-to-import-expenses,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards +https://community.expensify.com/discussion/4826/how-to-set-your-annual-subscription-size,https://help.expensify.com/articles/expensify-classic/billing-and-subscriptions/Annual-Subscription +https://community.expensify.com/discussion/5667/deep-dive-how-does-the-annual-subscription-billing-work,https://help.expensify.com/articles/expensify-classic/billing-and-subscriptions/Annual-Subscription +https://help.expensify.com/expensify-classic/hubs/getting-started/plan-types,https://www.expensify.com/pricing diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 364f33a02c30..341edd0c9dfe 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.34 + 1.4.35 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.34.0 + 1.4.35.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 6a90575d81fd..6b39db8b2f27 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.34 + 1.4.35 CFBundleSignature ???? CFBundleVersion - 1.4.34.0 + 1.4.35.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index e90a61461c2b..ff9b56d72408 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.34 + 1.4.35 CFBundleVersion - 1.4.34.0 + 1.4.35.0 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index e31aa2e80538..acadf891d8d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.34-0", + "version": "1.4.35-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.34-0", + "version": "1.4.35-0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 92c906251fdb..bdbdb27dbf59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.34-0", + "version": "1.4.35-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/patches/react-native-web+0.19.9+005+image-header-support.patch b/patches/react-native-web+0.19.9+005+image-header-support.patch new file mode 100644 index 000000000000..4652e22662f0 --- /dev/null +++ b/patches/react-native-web+0.19.9+005+image-header-support.patch @@ -0,0 +1,200 @@ +diff --git a/node_modules/react-native-web/dist/exports/Image/index.js b/node_modules/react-native-web/dist/exports/Image/index.js +index 95355d5..19109fc 100644 +--- a/node_modules/react-native-web/dist/exports/Image/index.js ++++ b/node_modules/react-native-web/dist/exports/Image/index.js +@@ -135,7 +135,22 @@ function resolveAssetUri(source) { + } + return uri; + } +-var Image = /*#__PURE__*/React.forwardRef((props, ref) => { ++function raiseOnErrorEvent(uri, _ref) { ++ var onError = _ref.onError, ++ onLoadEnd = _ref.onLoadEnd; ++ if (onError) { ++ onError({ ++ nativeEvent: { ++ error: "Failed to load resource " + uri + " (404)" ++ } ++ }); ++ } ++ if (onLoadEnd) onLoadEnd(); ++} ++function hasSourceDiff(a, b) { ++ return a.uri !== b.uri || JSON.stringify(a.headers) !== JSON.stringify(b.headers); ++} ++var BaseImage = /*#__PURE__*/React.forwardRef((props, ref) => { + var ariaLabel = props['aria-label'], + blurRadius = props.blurRadius, + defaultSource = props.defaultSource, +@@ -236,16 +251,10 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => { + } + }, function error() { + updateState(ERRORED); +- if (onError) { +- onError({ +- nativeEvent: { +- error: "Failed to load resource " + uri + " (404)" +- } +- }); +- } +- if (onLoadEnd) { +- onLoadEnd(); +- } ++ raiseOnErrorEvent(uri, { ++ onError, ++ onLoadEnd ++ }); + }); + } + function abortPendingRequest() { +@@ -277,10 +286,78 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => { + suppressHydrationWarning: true + }), hiddenImage, createTintColorSVG(tintColor, filterRef.current)); + }); +-Image.displayName = 'Image'; ++BaseImage.displayName = 'Image'; ++ ++/** ++ * This component handles specifically loading an image source with headers ++ * default source is never loaded using headers ++ */ ++var ImageWithHeaders = /*#__PURE__*/React.forwardRef((props, ref) => { ++ // $FlowIgnore: This component would only be rendered when `source` matches `ImageSource` ++ var nextSource = props.source; ++ var _React$useState3 = React.useState(''), ++ blobUri = _React$useState3[0], ++ setBlobUri = _React$useState3[1]; ++ var request = React.useRef({ ++ cancel: () => {}, ++ source: { ++ uri: '', ++ headers: {} ++ }, ++ promise: Promise.resolve('') ++ }); ++ var onError = props.onError, ++ onLoadStart = props.onLoadStart, ++ onLoadEnd = props.onLoadEnd; ++ React.useEffect(() => { ++ if (!hasSourceDiff(nextSource, request.current.source)) { ++ return; ++ } ++ ++ // When source changes we want to clean up any old/running requests ++ request.current.cancel(); ++ if (onLoadStart) { ++ onLoadStart(); ++ } ++ ++ // Store a ref for the current load request so we know what's the last loaded source, ++ // and so we can cancel it if a different source is passed through props ++ request.current = ImageLoader.loadWithHeaders(nextSource); ++ request.current.promise.then(uri => setBlobUri(uri)).catch(() => raiseOnErrorEvent(request.current.source.uri, { ++ onError, ++ onLoadEnd ++ })); ++ }, [nextSource, onLoadStart, onError, onLoadEnd]); ++ ++ // Cancel any request on unmount ++ React.useEffect(() => request.current.cancel, []); ++ var propsToPass = _objectSpread(_objectSpread({}, props), {}, { ++ // `onLoadStart` is called from the current component ++ // We skip passing it down to prevent BaseImage raising it a 2nd time ++ onLoadStart: undefined, ++ // Until the current component resolves the request (using headers) ++ // we skip forwarding the source so the base component doesn't attempt ++ // to load the original source ++ source: blobUri ? _objectSpread(_objectSpread({}, nextSource), {}, { ++ uri: blobUri ++ }) : undefined ++ }); ++ return /*#__PURE__*/React.createElement(BaseImage, _extends({ ++ ref: ref ++ }, propsToPass)); ++}); + + // $FlowIgnore: This is the correct type, but casting makes it unhappy since the variables aren't defined yet +-var ImageWithStatics = Image; ++var ImageWithStatics = /*#__PURE__*/React.forwardRef((props, ref) => { ++ if (props.source && props.source.headers) { ++ return /*#__PURE__*/React.createElement(ImageWithHeaders, _extends({ ++ ref: ref ++ }, props)); ++ } ++ return /*#__PURE__*/React.createElement(BaseImage, _extends({ ++ ref: ref ++ }, props)); ++}); + ImageWithStatics.getSize = function (uri, success, failure) { + ImageLoader.getSize(uri, success, failure); + }; +diff --git a/node_modules/react-native-web/dist/modules/ImageLoader/index.js b/node_modules/react-native-web/dist/modules/ImageLoader/index.js +index bc06a87..e309394 100644 +--- a/node_modules/react-native-web/dist/modules/ImageLoader/index.js ++++ b/node_modules/react-native-web/dist/modules/ImageLoader/index.js +@@ -76,7 +76,7 @@ var ImageLoader = { + var image = requests["" + requestId]; + if (image) { + var naturalHeight = image.naturalHeight, +- naturalWidth = image.naturalWidth; ++ naturalWidth = image.naturalWidth; + if (naturalHeight && naturalWidth) { + success(naturalWidth, naturalHeight); + complete = true; +@@ -102,11 +102,19 @@ var ImageLoader = { + id += 1; + var image = new window.Image(); + image.onerror = onError; +- image.onload = e => { ++ image.onload = nativeEvent => { + // avoid blocking the main thread +- var onDecode = () => onLoad({ +- nativeEvent: e +- }); ++ var onDecode = () => { ++ // Append `source` to match RN's ImageLoadEvent interface ++ nativeEvent.source = { ++ uri: image.src, ++ width: image.naturalWidth, ++ height: image.naturalHeight ++ }; ++ onLoad({ ++ nativeEvent ++ }); ++ }; + if (typeof image.decode === 'function') { + // Safari currently throws exceptions when decoding svgs. + // We want to catch that error and allow the load handler +@@ -120,6 +128,32 @@ var ImageLoader = { + requests["" + id] = image; + return id; + }, ++ loadWithHeaders(source) { ++ var uri; ++ var abortController = new AbortController(); ++ var request = new Request(source.uri, { ++ headers: source.headers, ++ signal: abortController.signal ++ }); ++ request.headers.append('accept', 'image/*'); ++ var promise = fetch(request).then(response => response.blob()).then(blob => { ++ uri = URL.createObjectURL(blob); ++ return uri; ++ }).catch(error => { ++ if (error.name === 'AbortError') { ++ return ''; ++ } ++ throw error; ++ }); ++ return { ++ promise, ++ source, ++ cancel: () => { ++ abortController.abort(); ++ URL.revokeObjectURL(uri); ++ } ++ }; ++ }, + prefetch(uri) { + return new Promise((resolve, reject) => { + ImageLoader.load(uri, () => { diff --git a/src/CONST.ts b/src/CONST.ts index 9d0a90a6a53d..69933a623bed 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1304,6 +1304,7 @@ const CONST = { USER: 'user', }, AUTO_REPORTING_FREQUENCIES: { + INSTANT: 'instant', IMMEDIATE: 'immediate', WEEKLY: 'weekly', SEMI_MONTHLY: 'semimonthly', @@ -1484,6 +1485,10 @@ const CONST = { OTHER_INVISIBLE_CHARACTERS: /[\u3164]/g, REPORT_FIELD_TITLE: /{report:([a-zA-Z]+)}/g, + + PATH_WITHOUT_POLICY_ID: /\/w\/[a-zA-Z0-9]+(\/|$)/, + + POLICY_ID_FROM_PATH: /\/w\/([a-zA-Z0-9]+)(\/|$)/, }, PRONOUNS: { @@ -3104,10 +3109,6 @@ const CONST = { DEFAULT: 5, CAROUSEL: 3, }, - BRICK_ROAD: { - GBR: 'info', - RBR: 'error', - }, /** * Constants for types of violations. @@ -3172,6 +3173,7 @@ const CONST = { SUBSCRIPT_ICON_SIZE: 8, MINIMUM_WORKSPACES_TO_SHOW_SEARCH: 8, }, + REPORT_FIELD_TITLE_FIELD_ID: 'text_title', } as const; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 7841137d0371..88b740e0e6c8 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -246,6 +246,9 @@ const ONYXKEYS = { // Stores last visited path LAST_VISITED_PATH: 'lastVisitedPath', + // Stores the recently used report fields + RECENTLY_USED_REPORT_FIELDS: 'recentlyUsedReportFields', + /** Indicates whether an forced upgrade is required */ UPDATE_REQUIRED: 'updateRequired', @@ -262,7 +265,6 @@ const ONYXKEYS = { POLICY_TAX_RATE: 'policyTaxRates_', POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_', POLICY_REPORT_FIELDS: 'policyReportFields_', - POLICY_RECENTLY_USED_REPORT_FIELDS: 'policyRecentlyUsedReportFields_', WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_', WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_', REPORT: 'report_', @@ -359,8 +361,8 @@ const ONYXKEYS = { REPORT_VIRTUAL_CARD_FRAUD_DRAFT: 'reportVirtualCardFraudFormDraft', GET_PHYSICAL_CARD_FORM: 'getPhysicalCardForm', GET_PHYSICAL_CARD_FORM_DRAFT: 'getPhysicalCardFormDraft', - POLICY_REPORT_FIELD_EDIT_FORM: 'policyReportFieldEditForm', - POLICY_REPORT_FIELD_EDIT_FORM_DRAFT: 'policyReportFieldEditFormDraft', + REPORT_FIELD_EDIT_FORM: 'reportFieldEditForm', + REPORT_FIELD_EDIT_FORM_DRAFT: 'reportFieldEditFormDraft', REIMBURSEMENT_ACCOUNT_FORM: 'reimbursementAccount', REIMBURSEMENT_ACCOUNT_FORM_DRAFT: 'reimbursementAccountDraft', PERSONAL_BANK_ACCOUNT: 'personalBankAccountForm', @@ -449,6 +451,7 @@ type OnyxValues = { [ONYXKEYS.MAX_CANVAS_HEIGHT]: number; [ONYXKEYS.MAX_CANVAS_WIDTH]: number; [ONYXKEYS.LAST_VISITED_PATH]: string | undefined; + [ONYXKEYS.RECENTLY_USED_REPORT_FIELDS]: OnyxTypes.RecentlyUsedReportFields; [ONYXKEYS.UPDATE_REQUIRED]: boolean; // Collections @@ -461,7 +464,6 @@ type OnyxValues = { [ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS]: OnyxTypes.PolicyMember; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES]: OnyxTypes.RecentlyUsedCategories; [ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS]: OnyxTypes.PolicyReportFields; - [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_REPORT_FIELDS]: OnyxTypes.RecentlyUsedReportFields; [ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST]: OnyxTypes.PolicyMembers; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: Record; [ONYXKEYS.COLLECTION.REPORT]: OnyxTypes.Report; @@ -546,8 +548,8 @@ type OnyxValues = { [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.POLICY_REPORT_FIELD_EDIT_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.POLICY_REPORT_FIELD_EDIT_FORM_DRAFT]: OnyxTypes.Form; + [ONYXKEYS.FORMS.REPORT_FIELD_EDIT_FORM]: OnyxTypes.ReportFieldEditForm; + [ONYXKEYS.FORMS.REPORT_FIELD_EDIT_FORM_DRAFT]: OnyxTypes.Form; // @ts-expect-error Different values are defined under the same key: ReimbursementAccount and ReimbursementAccountForm [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT]: OnyxTypes.Form; diff --git a/src/components/AnonymousReportFooter.tsx b/src/components/AnonymousReportFooter.tsx index 7d00bac54dca..078b850de5ff 100644 --- a/src/components/AnonymousReportFooter.tsx +++ b/src/components/AnonymousReportFooter.tsx @@ -1,27 +1,31 @@ import React from 'react'; import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Session from '@userActions/Session'; -import type {PersonalDetailsList, Report} from '@src/types/onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report} from '@src/types/onyx'; import AvatarWithDisplayName from './AvatarWithDisplayName'; import Button from './Button'; import ExpensifyWordmark from './ExpensifyWordmark'; import Text from './Text'; -type AnonymousReportFooterProps = { +type AnonymousReportFooterPropsWithOnyx = { + /** The policy which the user has access to and which the report is tied to */ + policy: OnyxEntry; +}; + +type AnonymousReportFooterProps = AnonymousReportFooterPropsWithOnyx & { /** The report currently being looked at */ report: OnyxEntry; /** Whether the small screen size layout should be used */ isSmallSizeLayout?: boolean; - - /** Personal details of all the users */ - personalDetails: OnyxEntry; }; -function AnonymousReportFooter({isSmallSizeLayout = false, personalDetails, report}: AnonymousReportFooterProps) { +function AnonymousReportFooter({isSmallSizeLayout = false, report, policy}: AnonymousReportFooterProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -30,9 +34,9 @@ function AnonymousReportFooter({isSmallSizeLayout = false, personalDetails, repo @@ -57,4 +61,8 @@ function AnonymousReportFooter({isSmallSizeLayout = false, personalDetails, repo AnonymousReportFooter.displayName = 'AnonymousReportFooter'; -export default AnonymousReportFooter; +export default withOnyx({ + policy: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`, + }, +})(AnonymousReportFooter); diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index f3e8ed316c52..90954c63b751 100755 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -1,5 +1,5 @@ import Str from 'expensify-common/lib/str'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {memo, useCallback, useEffect, useMemo, useState} from 'react'; import {Animated, Keyboard, View} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; import {withOnyx} from 'react-native-onyx'; @@ -130,6 +130,9 @@ type AttachmentModalProps = AttachmentModalOnyxProps & { /** Denotes whether it is a workspace avatar or not */ isWorkspaceAvatar?: boolean; + /** Denotes whether it can be an icon (ex: SVG) */ + maybeIcon?: boolean; + /** Whether it is a receipt attachment or not */ isReceiptAttachment?: boolean; @@ -154,6 +157,7 @@ function AttachmentModal({ onCarouselAttachmentChange = () => {}, isReceiptAttachment = false, isWorkspaceAvatar = false, + maybeIcon = false, transaction, parentReport, parentReportActions, @@ -531,6 +535,7 @@ function AttachmentModal({ file={file} onToggleKeyboard={updateConfirmButtonVisibility} isWorkspaceAvatar={isWorkspaceAvatar} + maybeIcon={maybeIcon} fallbackSource={fallbackSource} isUsedInAttachmentModal transactionID={transaction?.transactionID} @@ -610,6 +615,6 @@ export default withOnyx({ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`, canEvict: false, }, -})(AttachmentModal); +})(memo(AttachmentModal)); export type {Attachment}; diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js index 67f6dd95568e..33eab13f3851 100755 --- a/src/components/Attachments/AttachmentView/index.js +++ b/src/components/Attachments/AttachmentView/index.js @@ -48,6 +48,9 @@ const propTypes = { /** Denotes whether it is a workspace avatar or not */ isWorkspaceAvatar: PropTypes.bool, + /** Denotes whether it is an icon (ex: SVG) */ + maybeIcon: PropTypes.bool, + /** The id of the transaction related to the attachment */ // eslint-disable-next-line react/no-unused-prop-types transactionID: PropTypes.string, @@ -60,6 +63,7 @@ const defaultProps = { onToggleKeyboard: () => {}, containerStyles: [], isWorkspaceAvatar: false, + maybeIcon: false, transactionID: '', }; @@ -80,6 +84,7 @@ function AttachmentView({ carouselActiveItemIndex, isUsedInAttachmentModal, isWorkspaceAvatar, + maybeIcon, fallbackSource, transaction, }) { @@ -91,8 +96,9 @@ function AttachmentView({ useNetwork({onReconnect: () => setImageError(false)}); - // Handles case where source is a component (ex: SVG) - if (_.isFunction(source)) { + // Handles case where source is a component (ex: SVG) or a number + // Number may represent a SVG or an image + if ((maybeIcon && typeof source === 'number') || _.isFunction(source)) { let iconFillColor = ''; let additionalStyles = []; if (isWorkspaceAvatar) { diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index d42d47caafc9..16f31b9c7eba 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -12,7 +12,7 @@ import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {PersonalDetails, PersonalDetailsList, Policy, Report, ReportActions} from '@src/types/onyx'; +import type {PersonalDetails, Policy, Report, ReportActions} from '@src/types/onyx'; import DisplayNames from './DisplayNames'; import MultipleAvatars from './MultipleAvatars'; import ParentNavigationSubtitle from './ParentNavigationSubtitle'; @@ -23,6 +23,9 @@ import Text from './Text'; type AvatarWithDisplayNamePropsWithOnyx = { /** All of the actions of the report */ parentReportActions: OnyxEntry; + + /** Personal details of all users */ + personalDetails: OnyxCollection; }; type AvatarWithDisplayNameProps = AvatarWithDisplayNamePropsWithOnyx & { @@ -35,9 +38,6 @@ type AvatarWithDisplayNameProps = AvatarWithDisplayNamePropsWithOnyx & { /** The size of the avatar */ size?: ValueOf; - /** Personal details of all the users */ - personalDetails: OnyxEntry; - /** Whether if it's an unauthenticated user */ isAnonymous?: boolean; @@ -46,13 +46,13 @@ type AvatarWithDisplayNameProps = AvatarWithDisplayNamePropsWithOnyx & { }; function AvatarWithDisplayName({ - personalDetails, policy, report, parentReportActions, isAnonymous = false, size = CONST.AVATAR_SIZE.DEFAULT, shouldEnableDetailPageNavigation = false, + personalDetails = CONST.EMPTY_OBJECT, }: AvatarWithDisplayNameProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -181,4 +181,7 @@ export default withOnyx `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`, canEvict: false, }, + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, })(AvatarWithDisplayName); diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js index 0048a183a2af..010d074d1da6 100644 --- a/src/components/AvatarWithImagePicker.js +++ b/src/components/AvatarWithImagePicker.js @@ -91,6 +91,7 @@ const propTypes = { /** Indicates if picker feature should be disabled */ disabled: PropTypes.bool, + /** Executed once click on view photo option */ onViewPhotoPress: PropTypes.func, @@ -368,6 +369,7 @@ function AvatarWithImagePicker({ source={previewSource} originalFileName={originalFileName} fallbackSource={fallbackIcon} + maybeIcon={isUsingDefaultAvatar} > {({show}) => ( diff --git a/src/components/ButtonWithDropdownMenu.tsx b/src/components/ButtonWithDropdownMenu.tsx index 466c68229a32..676912de6b60 100644 --- a/src/components/ButtonWithDropdownMenu.tsx +++ b/src/components/ButtonWithDropdownMenu.tsx @@ -32,6 +32,9 @@ type ButtonWithDropdownMenuProps = { /** Callback to execute when the main button is pressed */ onPress: (event: GestureResponderEvent | KeyboardEvent | undefined, value: string) => void; + /** Callback to execute when a dropdown option is selected */ + onOptionSelected?: (option: DropdownOption) => void; + /** Call the onPress function on main button when Enter key is pressed */ pressOnEnter?: boolean; @@ -72,6 +75,7 @@ function ButtonWithDropdownMenu({ buttonRef, onPress, options, + onOptionSelected, }: ButtonWithDropdownMenuProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -174,6 +178,7 @@ function ButtonWithDropdownMenu({ menuItems={options.map((item, index) => ({ ...item, onSelected: () => { + onOptionSelected?.(item); setSelectedItemIndex(index); }, }))} diff --git a/src/components/EmojiPicker/EmojiPickerButton.js b/src/components/EmojiPicker/EmojiPickerButton.js index 832715e3214c..438deb7e53d9 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.js +++ b/src/components/EmojiPicker/EmojiPickerButton.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React, {useEffect, useRef} from 'react'; +import React, {memo, useEffect, useRef} from 'react'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; @@ -87,4 +87,4 @@ function EmojiPickerButton(props) { EmojiPickerButton.propTypes = propTypes; EmojiPickerButton.defaultProps = defaultProps; EmojiPickerButton.displayName = 'EmojiPickerButton'; -export default compose(withLocalize, withNavigationFocus)(EmojiPickerButton); +export default compose(withLocalize, withNavigationFocus)(memo(EmojiPickerButton)); diff --git a/src/components/ExceededCommentLength.tsx b/src/components/ExceededCommentLength.tsx index 6cd11cc44a5c..2f0887afc8f1 100644 --- a/src/components/ExceededCommentLength.tsx +++ b/src/components/ExceededCommentLength.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {memo} from 'react'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; @@ -20,4 +20,4 @@ function ExceededCommentLength() { ExceededCommentLength.displayName = 'ExceededCommentLength'; -export default ExceededCommentLength; +export default memo(ExceededCommentLength); diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 6c89221d9217..078cb37c7e0d 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -30,7 +30,6 @@ function HeaderWithBackButton({ onThreeDotsButtonPress = () => {}, report = null, policy, - personalDetails = null, shouldShowAvatarWithDisplay = false, shouldShowBackButton = true, shouldShowBorderBottom = false, @@ -104,7 +103,6 @@ function HeaderWithBackButton({ ) : ( diff --git a/src/components/Image/BaseImage.native.tsx b/src/components/Image/BaseImage.native.tsx new file mode 100644 index 000000000000..c517efd04515 --- /dev/null +++ b/src/components/Image/BaseImage.native.tsx @@ -0,0 +1,32 @@ +import {Image as ExpoImage} from 'expo-image'; +import type {ImageProps as ExpoImageProps, ImageLoadEventData} from 'expo-image'; +import {useCallback} from 'react'; +import type {BaseImageProps} from './types'; + +function BaseImage({onLoad, ...props}: ExpoImageProps & BaseImageProps) { + const imageLoadedSuccessfully = useCallback( + (event: ImageLoadEventData) => { + if (!onLoad) { + return; + } + + // We override `onLoad`, so both web and native have the same signature + const {width, height} = event.source; + onLoad({nativeEvent: {width, height}}); + }, + [onLoad], + ); + + return ( + + ); +} + +BaseImage.displayName = 'BaseImage'; + +export default BaseImage; diff --git a/src/components/Image/BaseImage.tsx b/src/components/Image/BaseImage.tsx new file mode 100644 index 000000000000..ebdd76840267 --- /dev/null +++ b/src/components/Image/BaseImage.tsx @@ -0,0 +1,32 @@ +import React, {useCallback} from 'react'; +import {Image as RNImage} from 'react-native'; +import type {ImageLoadEventData, ImageProps as WebImageProps} from 'react-native'; +import type {BaseImageProps} from './types'; + +function BaseImage({onLoad, ...props}: WebImageProps & BaseImageProps) { + const imageLoadedSuccessfully = useCallback( + (event: {nativeEvent: ImageLoadEventData}) => { + if (!onLoad) { + return; + } + + // We override `onLoad`, so both web and native have the same signature + const {width, height} = event.nativeEvent.source; + onLoad({nativeEvent: {width, height}}); + }, + [onLoad], + ); + + return ( + + ); +} + +BaseImage.displayName = 'BaseImage'; + +export default BaseImage; diff --git a/src/components/Image/index.js b/src/components/Image/index.js index ef1a69e19c12..8cee1cf95e14 100644 --- a/src/components/Image/index.js +++ b/src/components/Image/index.js @@ -1,51 +1,35 @@ import lodashGet from 'lodash/get'; -import React, {useEffect, useMemo} from 'react'; -import {Image as RNImage} from 'react-native'; +import React, {useMemo} from 'react'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import BaseImage from './BaseImage'; import {defaultProps, imagePropTypes} from './imagePropTypes'; import RESIZE_MODES from './resizeModes'; -function Image(props) { - const {source: propsSource, isAuthTokenRequired, onLoad, session} = props; - /** - * Check if the image source is a URL - if so the `encryptedAuthToken` is appended - * to the source. - */ +function Image({source: propsSource, isAuthTokenRequired, session, ...forwardedProps}) { + // Update the source to include the auth token if required const source = useMemo(() => { - if (isAuthTokenRequired) { - // There is currently a `react-native-web` bug preventing the authToken being passed - // in the headers of the image request so the authToken is added as a query param. - // On native the authToken IS passed in the image request headers - const authToken = lodashGet(session, 'encryptedAuthToken', null); - return {uri: `${propsSource.uri}?encryptedAuthToken=${encodeURIComponent(authToken)}`}; + if (typeof lodashGet(propsSource, 'uri') === 'number') { + return propsSource.uri; } + if (typeof propsSource !== 'number' && isAuthTokenRequired) { + const authToken = lodashGet(session, 'encryptedAuthToken'); + return { + ...propsSource, + headers: { + [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken, + }, + }; + } + return propsSource; // The session prop is not required, as it causes the image to reload whenever the session changes. For more information, please refer to issue #26034. // eslint-disable-next-line react-hooks/exhaustive-deps }, [propsSource, isAuthTokenRequired]); - /** - * The natural image dimensions are retrieved using the updated source - * and as a result the `onLoad` event needs to be manually invoked to return these dimensions - */ - useEffect(() => { - // If an onLoad callback was specified then manually call it and pass - // the natural image dimensions to match the native API - if (onLoad == null) { - return; - } - RNImage.getSize(source.uri, (width, height) => { - onLoad({nativeEvent: {width, height}}); - }); - }, [onLoad, source]); - - // Omit the props which the underlying RNImage won't use - const forwardedProps = _.omit(props, ['source', 'onLoad', 'session', 'isAuthTokenRequired']); - return ( - { - const {width, height, url} = evt.source; - dimensionsCache.set(url, {width, height}); - if (props.onLoad) { - props.onLoad({nativeEvent: {width, height}}); - } - }} - /> - ); -} - -Image.propTypes = imagePropTypes; -Image.defaultProps = defaultProps; -Image.displayName = 'Image'; -const ImageWithOnyx = withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, -})(Image); -ImageWithOnyx.resizeMode = RESIZE_MODES; -ImageWithOnyx.resolveDimensions = resolveDimensions; - -export default ImageWithOnyx; diff --git a/src/components/Image/types.ts b/src/components/Image/types.ts new file mode 100644 index 000000000000..5a4c94364a46 --- /dev/null +++ b/src/components/Image/types.ts @@ -0,0 +1,8 @@ +type BaseImageProps = { + /** Event called with image dimensions when image is loaded */ + onLoad?: (event: {nativeEvent: {width: number; height: number}}) => void; +}; + +export type {BaseImageProps}; + +export default BaseImageProps; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 4b4e3915f969..5209ab894828 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -22,6 +22,7 @@ import type DeepValueOf from '@src/types/utils/DeepValueOf'; import Button from './Button'; import HeaderWithBackButton from './HeaderWithBackButton'; import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar'; +import {usePersonalDetails} from './OnyxProvider'; import SettlementButton from './SettlementButton'; type PaymentType = DeepValueOf; @@ -43,12 +44,10 @@ type MoneyReportHeaderProps = MoneyReportHeaderOnyxProps & { /** The policy tied to the money request report */ policy: OnyxTypes.Policy; - - /** Personal details so we can get the ones for the report participants */ - personalDetails: OnyxTypes.PersonalDetailsList; }; -function MoneyReportHeader({session, personalDetails, policy, chatReport, nextStep, report: moneyRequestReport}: MoneyReportHeaderProps) { +function MoneyReportHeader({session, policy, chatReport, nextStep, report: moneyRequestReport}: MoneyReportHeaderProps) { + const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const styles = useThemeStyles(); const {translate} = useLocalize(); const {windowWidth, isSmallScreenWidth} = useWindowDimensions(); @@ -86,8 +85,8 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt // The submit button should be success green colour only if the user is submitter and the policy does not have Scheduled Submit turned on const isWaitingForSubmissionFromCurrentUser = useMemo( - () => chatReport?.isOwnPolicyExpenseChat && !policy.isHarvestingEnabled, - [chatReport?.isOwnPolicyExpenseChat, policy.isHarvestingEnabled], + () => chatReport?.isOwnPolicyExpenseChat && !(policy.harvesting?.enabled ?? policy.isHarvestingEnabled), + [chatReport?.isOwnPolicyExpenseChat, policy.harvesting?.enabled, policy.isHarvestingEnabled], ); const threeDotsMenuItems = [HeaderUtils.getPinMenuItem(moneyRequestReport)]; diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index d967d04ab94b..afabb40fd9f4 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -731,8 +731,14 @@ function MoneyRequestConfirmationList(props) { }} disabled={didConfirm} interactive={!props.isReadOnly} - brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} - error={shouldDisplayMerchantError || (shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction)) ? translate('common.error.enterMerchant') : ''} + brickRoadIndicator={ + props.isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '' + } + error={ + shouldDisplayMerchantError || (props.isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction)) + ? translate('common.error.enterMerchant') + : '' + } /> )} {shouldShowCategories && ( diff --git a/src/components/MoneyRequestHeader.js b/src/components/MoneyRequestHeader.js index be42abc797dd..e907f798051b 100644 --- a/src/components/MoneyRequestHeader.js +++ b/src/components/MoneyRequestHeader.js @@ -22,7 +22,7 @@ import ConfirmModal from './ConfirmModal'; import HeaderWithBackButton from './HeaderWithBackButton'; import * as Expensicons from './Icon/Expensicons'; import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar'; -import participantPropTypes from './participantPropTypes'; +import {usePersonalDetails} from './OnyxProvider'; import transactionPropTypes from './transactionPropTypes'; const propTypes = { @@ -35,9 +35,6 @@ const propTypes = { name: PropTypes.string, }), - /** Personal details so we can get the ones for the report participants */ - personalDetails: PropTypes.objectOf(participantPropTypes).isRequired, - /* Onyx Props */ /** Session info for the currently logged in user. */ session: PropTypes.shape({ @@ -65,7 +62,8 @@ const defaultProps = { policy: {}, }; -function MoneyRequestHeader({session, parentReport, report, parentReportAction, transaction, policy, personalDetails}) { +function MoneyRequestHeader({session, parentReport, report, parentReportAction, transaction, policy}) { + const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const styles = useThemeStyles(); const {translate} = useLocalize(); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); diff --git a/src/components/PurposeForUsingExpensifyModal.tsx b/src/components/PurposeForUsingExpensifyModal.tsx index 6b268115970e..e65646aeac84 100644 --- a/src/components/PurposeForUsingExpensifyModal.tsx +++ b/src/components/PurposeForUsingExpensifyModal.tsx @@ -1,6 +1,8 @@ import {useNavigation} from '@react-navigation/native'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {ScrollView, View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -11,6 +13,7 @@ import * as Report from '@userActions/Report'; import * as Welcome from '@userActions/Welcome'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import HeaderWithBackButton from './HeaderWithBackButton'; import * as Expensicons from './Icon/Expensicons'; @@ -78,8 +81,12 @@ const menuIcons = { [CONST.INTRO_CHOICES.MANAGE_TEAM]: Expensicons.MoneyBag, [CONST.INTRO_CHOICES.CHAT_SPLIT]: Expensicons.Briefcase, }; +type PurposeForUsingExpensifyModalOnyxProps = { + isLoadingApp: OnyxEntry; +}; +type PurposeForUsingExpensifyModalProps = PurposeForUsingExpensifyModalOnyxProps; -function PurposeForUsingExpensifyModal() { +function PurposeForUsingExpensifyModal({isLoadingApp = false}: PurposeForUsingExpensifyModalProps) { const {translate} = useLocalize(); const StyleUtils = useStyleUtils(); const styles = useThemeStyles(); @@ -98,7 +105,7 @@ function PurposeForUsingExpensifyModal() { Welcome.show(routes, () => setIsModalOpen(true)); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [isLoadingApp]); const closeModal = useCallback(() => { Report.dismissEngagementModal(); @@ -174,5 +181,8 @@ function PurposeForUsingExpensifyModal() { } PurposeForUsingExpensifyModal.displayName = 'PurposeForUsingExpensifyModal'; - -export default PurposeForUsingExpensifyModal; +export default withOnyx({ + isLoadingApp: { + key: ONYXKEYS.IS_LOADING_APP, + }, +})(PurposeForUsingExpensifyModal); diff --git a/src/components/ReportActionItem/ChronosOOOListActions.js b/src/components/ReportActionItem/ChronosOOOListActions.tsx similarity index 58% rename from src/components/ReportActionItem/ChronosOOOListActions.js rename to src/components/ReportActionItem/ChronosOOOListActions.tsx index f90ae67796b9..52e0c52873d6 100644 --- a/src/components/ReportActionItem/ChronosOOOListActions.js +++ b/src/components/ReportActionItem/ChronosOOOListActions.tsx @@ -1,58 +1,57 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; -import _ from 'underscore'; import Button from '@components/Button'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; -import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; import * as Chronos from '@userActions/Chronos'; +import type {OriginalMessageChronosOOOList} from '@src/types/onyx/OriginalMessage'; +import type {ReportActionBase} from '@src/types/onyx/ReportAction'; -const propTypes = { +type ChronosOOOListActionsProps = { /** The ID of the report */ - reportID: PropTypes.string.isRequired, + reportID: string; /** All the data of the action */ - action: PropTypes.shape(reportActionPropTypes).isRequired, - - ...withLocalizePropTypes, + action: ReportActionBase & OriginalMessageChronosOOOList; }; -function ChronosOOOListActions(props) { +function ChronosOOOListActions({reportID, action}: ChronosOOOListActionsProps) { const styles = useThemeStyles(); - const events = lodashGet(props.action, 'originalMessage.events', []); + + const {translate, preferredLocale} = useLocalize(); + + const events = action.originalMessage?.events ?? []; if (!events.length) { return ( - + You haven't created any events ); } return ( - - - {_.map(events, (event) => { - const start = DateUtils.getLocalDateFromDatetime(props.preferredLocale, lodashGet(event, 'start.date', '')); - const end = DateUtils.getLocalDateFromDatetime(props.preferredLocale, lodashGet(event, 'end.date', '')); + + + {events.map((event) => { + const start = DateUtils.getLocalDateFromDatetime(preferredLocale, event?.start?.date ?? ''); + const end = DateUtils.getLocalDateFromDatetime(preferredLocale, event?.end?.date ?? ''); return ( - + {event.lengthInDays > 0 - ? props.translate('chronos.oooEventSummaryFullDay', { + ? translate('chronos.oooEventSummaryFullDay', { summary: event.summary, dayCount: event.lengthInDays, date: DateUtils.formatToLongDateWithWeekday(end), }) - : props.translate('chronos.oooEventSummaryPartialDay', { + : translate('chronos.oooEventSummaryPartialDay', { summary: event.summary, timePeriod: `${DateUtils.formatToLocalTime(start)} - ${DateUtils.formatToLocalTime(end)}`, date: DateUtils.formatToLongDateWithWeekday(end), @@ -60,10 +59,10 @@ function ChronosOOOListActions(props) { ); @@ -73,7 +72,6 @@ function ChronosOOOListActions(props) { ); } -ChronosOOOListActions.propTypes = propTypes; ChronosOOOListActions.displayName = 'ChronosOOOListActions'; -export default withLocalize(ChronosOOOListActions); +export default ChronosOOOListActions; diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index ed7c05b828a9..3c0e50b2c940 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -1,8 +1,7 @@ +import Str from 'expensify-common/lib/str'; import React, {useMemo} from 'react'; import type {StyleProp, TextStyle} from 'react-native'; import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import type {OnyxCollection} from 'react-native-onyx'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; @@ -20,14 +19,16 @@ import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateBackground'; import variables from '@styles/variables'; -import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Policy, PolicyReportField, Report} from '@src/types/onyx'; -type MoneyReportViewComponentProps = { +type MoneyReportViewProps = { /** The report currently being looked at */ report: Report; + /** Policy that the report belongs to */ + policy: Policy; + /** Policy report fields */ policyReportFields: PolicyReportField[]; @@ -35,14 +36,7 @@ type MoneyReportViewComponentProps = { shouldShowHorizontalRule: boolean; }; -type MoneyReportViewOnyxProps = { - /** Policies that the user is part of */ - policies: OnyxCollection; -}; - -type MoneyReportViewProps = MoneyReportViewComponentProps & MoneyReportViewOnyxProps; - -function MoneyReportView({report, policyReportFields, shouldShowHorizontalRule, policies}: MoneyReportViewProps) { +function MoneyReportView({report, policy, policyReportFields, shouldShowHorizontalRule}: MoneyReportViewProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -65,30 +59,34 @@ function MoneyReportView({report, policyReportFields, shouldShowHorizontalRule, StyleUtils.getColorStyle(theme.textSupporting), ]; - const sortedPolicyReportFields = useMemo( - () => policyReportFields.sort(({orderWeight: firstOrderWeight}, {orderWeight: secondOrderWeight}) => firstOrderWeight - secondOrderWeight), - [policyReportFields], - ); - const isAdmin = ReportUtils.isPolicyAdmin(report.policyID ?? '', policies); + const sortedPolicyReportFields = useMemo((): PolicyReportField[] => { + const fields = ReportUtils.getAvailableReportFields(report, policyReportFields); + return fields.sort(({orderWeight: firstOrderWeight}, {orderWeight: secondOrderWeight}) => firstOrderWeight - secondOrderWeight); + }, [policyReportFields, report]); + return ( {canUseReportFields && sortedPolicyReportFields.map((reportField) => { - const title = ReportUtils.getReportFieldTitle(report, reportField); - const isDisabled = !isAdmin || isSettled || ReportUtils.isReportFieldOfTypeTitle(reportField); + const isTitleField = ReportUtils.isReportFieldOfTypeTitle(reportField); + const fieldValue = isTitleField ? report.reportName : reportField.value ?? reportField.defaultValue; + const isFieldDisabled = ReportUtils.isReportFieldDisabled(report, reportField, policy); + return ( Navigation.navigate(ROUTES.EDIT_REPORT_FIELD_REQUEST.getRoute(report.reportID, report.policyID ?? '', reportField.fieldID))} shouldShowRightIcon - disabled={isDisabled} + disabled={isFieldDisabled} wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} shouldGreyOutWhenDisabled={false} numberOfLinesTitle={0} @@ -178,8 +176,4 @@ function MoneyReportView({report, policyReportFields, shouldShowHorizontalRule, MoneyReportView.displayName = 'MoneyReportView'; -export default withOnyx({ - policies: { - key: ONYXKEYS.COLLECTION.POLICY, - }, -})(MoneyReportView); +export default MoneyReportView; diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index b2fece085f57..52e9d94eaefd 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -159,8 +159,8 @@ function ReportPreview({ // The submit button should be success green colour only if the user is submitter and the policy does not have Scheduled Submit turned on const isWaitingForSubmissionFromCurrentUser = useMemo( - () => chatReport?.isOwnPolicyExpenseChat && !policy?.isHarvestingEnabled, - [chatReport?.isOwnPolicyExpenseChat, policy?.isHarvestingEnabled], + () => chatReport?.isOwnPolicyExpenseChat && !(policy?.harvesting?.enabled ?? policy?.isHarvestingEnabled), + [chatReport?.isOwnPolicyExpenseChat, policy?.harvesting?.enabled, policy?.isHarvestingEnabled], ); const getDisplayAmount = (): string => { diff --git a/src/components/SettlementButton.js b/src/components/SettlementButton.js index 0c8e193af4cc..493b8767ee9b 100644 --- a/src/components/SettlementButton.js +++ b/src/components/SettlementButton.js @@ -177,8 +177,9 @@ function SettlementButton({ return [approveButtonOption]; } - // To achieve the one tap pay experience we need to choose the correct payment type as default, - // if user already paid for some request or expense, let's use the last payment method or use default. + // To achieve the one tap pay experience we need to choose the correct payment type as default. + // If the user has previously chosen a specific payment option or paid for some request or expense, + // let's use the last payment method or use default. const paymentMethod = nvp_lastPaymentMethod[policyID] || ''; if (canUseWallet) { buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.EXPENSIFY]); @@ -192,12 +193,14 @@ function SettlementButton({ buttonOptions.push(approveButtonOption); } - // Put the preferred payment method to the front of the array so its shown as default + // Put the preferred payment method to the front of the array, so it's shown as default if (paymentMethod) { return _.sortBy(buttonOptions, (method) => (method.value === paymentMethod ? 0 : 1)); } return buttonOptions; - }, [currency, formattedAmount, iouReport, nvp_lastPaymentMethod, policyID, translate, shouldHidePaymentOptions, shouldShowApproveButton]); + // We don't want to reorder the options when the preferred payment method changes while the button is still visible + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currency, formattedAmount, iouReport, policyID, translate, shouldHidePaymentOptions, shouldShowApproveButton]); const selectPaymentType = (event, iouPaymentType, triggerKYCFlow) => { if (iouPaymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY || iouPaymentType === CONST.IOU.PAYMENT_TYPE.VBBA) { @@ -235,6 +238,7 @@ function SettlementButton({ onPress={(event, iouPaymentType) => selectPaymentType(event, iouPaymentType, triggerKYCFlow)} pressOnEnter={pressOnEnter} options={paymentButtonOptions} + onOptionSelected={(option) => IOU.savePreferredPaymentMethod(policyID, option.value)} style={style} buttonSize={buttonSize} anchorAlignment={paymentMethodDropdownAnchorAlignment} diff --git a/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx index 21e19ac7c2e8..4535874c3fac 100644 --- a/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx +++ b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx @@ -37,9 +37,14 @@ function BaseUserDetailsTooltip({accountID, fallbackUserDetails, icon, delegateA } let title = String(userDisplayName).trim() ? userDisplayName : ''; - const subtitle = userLogin.trim() && LocalePhoneNumber.formatPhoneNumber(userLogin) !== userDisplayName ? Str.removeSMSDomain(userLogin) : ''; + let subtitle = userLogin.trim() && LocalePhoneNumber.formatPhoneNumber(userLogin) !== userDisplayName ? Str.removeSMSDomain(userLogin) : ''; if (icon && (icon.type === CONST.ICON_TYPE_WORKSPACE || !title)) { title = icon.name ?? ''; + + // We need to clear the subtitle for workspaces so that we don't display any user details under the workspace name + if (icon.type === CONST.ICON_TYPE_WORKSPACE) { + subtitle = ''; + } } const renderTooltipContent = useCallback( () => ( diff --git a/src/hooks/useSearchTermAndSearch.ts b/src/hooks/useSearchTermAndSearch.ts new file mode 100644 index 000000000000..827b6c6d8bd1 --- /dev/null +++ b/src/hooks/useSearchTermAndSearch.ts @@ -0,0 +1,22 @@ +import type {Dispatch} from 'react'; +import {useCallback} from 'react'; +import * as Report from '@userActions/Report'; + +/** + * Hook for fetching reports when user updated search term and hasn't selected max number of participants + */ +const useSearchTermAndSearch = (setSearchTerm: Dispatch>, maxParticipantsReached: boolean) => { + const setSearchTermAndSearchInServer = useCallback( + (text = '') => { + if (text && !maxParticipantsReached) { + Report.searchInServer(text); + } + setSearchTerm(text); + }, + [maxParticipantsReached, setSearchTerm], + ); + + return setSearchTermAndSearchInServer; +}; + +export default useSearchTermAndSearch; diff --git a/src/languages/en.ts b/src/languages/en.ts index fac9c4af7c75..145d8414c1e3 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1906,6 +1906,8 @@ export default { report: { genericCreateReportFailureMessage: 'Unexpected error creating this chat, please try again later', genericAddCommentFailureMessage: 'Unexpected error while posting the comment, please try again later', + genericUpdateReportFieldFailureMessage: 'Unexpected error while updating the field, please try again later', + genericUpdateReporNameEditFailureMessage: 'Unexpected error while renaming the report, please try again later', noActivityYet: 'No activity yet', }, chronos: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 873f8dcefde3..517b3ba1e2f9 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1932,6 +1932,8 @@ export default { report: { genericCreateReportFailureMessage: 'Error inesperado al crear el chat. Por favor, inténtalo más tarde', genericAddCommentFailureMessage: 'Error inesperado al añadir el comentario. Por favor, inténtalo más tarde', + genericUpdateReportFieldFailureMessage: 'Error inesperado al actualizar el campo. Por favor, inténtalo más tarde', + genericUpdateReporNameEditFailureMessage: 'Error inesperado al cambiar el nombre del informe. Vuelva a intentarlo más tarde.', noActivityYet: 'Sin actividad todavía', }, chronos: { diff --git a/src/libs/API/parameters/SetReportFieldParams.ts b/src/libs/API/parameters/SetReportFieldParams.ts new file mode 100644 index 000000000000..8b6c8682d657 --- /dev/null +++ b/src/libs/API/parameters/SetReportFieldParams.ts @@ -0,0 +1,6 @@ +type SetReportFieldParams = { + reportID: string; + reportFields: string; +}; + +export default SetReportFieldParams; diff --git a/src/libs/API/parameters/SetReportNameParams.ts b/src/libs/API/parameters/SetReportNameParams.ts new file mode 100644 index 000000000000..784674e1486e --- /dev/null +++ b/src/libs/API/parameters/SetReportNameParams.ts @@ -0,0 +1,6 @@ +type SetReportNameParams = { + reportID: string; + reportName: string; +}; + +export default SetReportNameParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 039398c0fbf6..8c0c2fde17cf 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -122,3 +122,5 @@ export type {default as ReopenTaskParams} from './ReopenTaskParams'; export type {default as CompleteTaskParams} from './CompleteTaskParams'; export type {default as CompleteEngagementModalParams} from './CompleteEngagementModalParams'; export type {default as SetNameValuePairParams} from './SetNameValuePairParams'; +export type {default as SetReportFieldParams} from './SetReportFieldParams'; +export type {default as SetReportNameParams} from './SetReportNameParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index f58ebc30b4a2..05b658ee0702 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -113,6 +113,8 @@ const WRITE_COMMANDS = { COMPLETE_TASK: 'CompleteTask', COMPLETE_ENGAGEMENT_MODAL: 'CompleteEngagementModal', SET_NAME_VALUE_PAIR: 'SetNameValuePair', + SET_REPORT_FIELD: 'Report_SetFields', + SET_REPORT_NAME: 'RenameReport', } as const; type WriteCommand = ValueOf; @@ -223,6 +225,8 @@ type WriteCommandParameters = { [WRITE_COMMANDS.COMPLETE_TASK]: Parameters.CompleteTaskParams; [WRITE_COMMANDS.COMPLETE_ENGAGEMENT_MODAL]: Parameters.CompleteEngagementModalParams; [WRITE_COMMANDS.SET_NAME_VALUE_PAIR]: Parameters.SetNameValuePairParams; + [WRITE_COMMANDS.SET_REPORT_FIELD]: Parameters.SetReportFieldParams; + [WRITE_COMMANDS.SET_REPORT_NAME]: Parameters.SetReportNameParams; }; const READ_COMMANDS = { diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index dde9cf28148a..9cb08556f082 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -256,7 +256,7 @@ function getZoneAbbreviation(datetime: string | Date, selectedTimezone: Selected * * @returns Sunday, July 9, 2023 */ -function formatToLongDateWithWeekday(datetime: string): string { +function formatToLongDateWithWeekday(datetime: string | Date): string { return format(new Date(datetime), CONST.DATE.LONG_DATE_FORMAT_WITH_WEEKDAY); } diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx index 448606d85d99..e6ecfccb47ee 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx @@ -17,6 +17,7 @@ type CustomNavigatorProps = DefaultNavigatorOptions): StackNavigationState { const routesToRender = [state.routes.at(-1)] as NavigationStateRoute[]; + // We need to render at least one HOME screen to make sure everything load properly. if (routesToRender[0].name !== SCREENS.HOME) { const routeToRender = state.routes.find((route) => route.name === SCREENS.HOME); diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts index c175620ec24e..e69045822253 100644 --- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts +++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts @@ -38,7 +38,7 @@ function compareAndAdaptState(state: StackNavigationState) { const topmostBottomTabRoute = getTopmostBottomTabRoute(state); const isSmallScreenWidth = getIsSmallScreenWidth(); - // This solutions is heurestis and will work for our cases. We may need to improve it in the future if we will have more cases to handle. + // This solutions is heuristics and will work for our cases. We may need to improve it in the future if we will have more cases to handle. if (topmostBottomTabRoute && !isSmallScreenWidth) { const fullScreenRoute = state.routes.find((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR); @@ -84,7 +84,7 @@ function compareAndAdaptState(state: StackNavigationState) { // If there is central pane route in state and template state has one, we need to check if they are the same. if (topmostCentralPaneRouteExtracted && templateCentralPaneRouteExtracted && topmostCentralPaneRouteExtracted.name !== templateCentralPaneRouteExtracted.name) { // Not every RHP screen has matching central pane defined. In that case we use the REPORT screen as default for initial screen. - // But we don't want to ovverride the central pane for those screens as they may be opened with different central panes under the overlay. + // But we don't want to override the central pane for those screens as they may be opened with different central panes under the overlay. // e.g. i-know-a-teacher may be opened with different central panes under the overlay if (templateCentralPaneRouteExtracted.name === SCREENS.REPORT) { return; diff --git a/src/libs/Navigation/AppNavigator/getPartialStateDiff.ts b/src/libs/Navigation/AppNavigator/getPartialStateDiff.ts index fddb10038156..a5cecf276e9e 100644 --- a/src/libs/Navigation/AppNavigator/getPartialStateDiff.ts +++ b/src/libs/Navigation/AppNavigator/getPartialStateDiff.ts @@ -23,11 +23,11 @@ type GetPartialStateDiffReturnType = { }; /** - * This function returns partial additive diff beteween the two states. - * The partial diff have information which bottom tab, central pane and full screen screens we need to push to go from state to templateState + * This function returns partial additive diff between the two states. + * The partial diff has information which bottom tab, central pane and full screen screens we need to push to go from state to templateState. * @param state - Current state. * @param templateState - Desired state generated with getAdaptedStateFromPath. - * @param metainfo - Additional info from getAdaptedStateFromPath funciton. + * @param metainfo - Additional info from getAdaptedStateFromPath function. * @returns The screen options object */ function getPartialStateDiff(state: State, templateState: State, metainfo: Metainfo): GetPartialStateDiffReturnType { @@ -60,8 +60,8 @@ function getPartialStateDiff(state: State, templateState: St } } - // This one is heurestic and may need to improved if we will be able to navigate from modal screen with full screen in background to another modal screen with full screen in background. - // For now this simple check is enought. + // This one is heuristic and may need to be improved if we will be able to navigate from modal screen with full screen in background to another modal screen with full screen in background. + // For now this simple check is enough. if (metainfo.isFullScreenNavigatorMandatory) { const stateTopmostFullScreen = state.routes.filter((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR).at(-1); const templateStateTopmostFullScreen = templateState.routes.filter((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR).at(-1) as NavigationPartialRoute; diff --git a/src/libs/Navigation/linkTo.ts b/src/libs/Navigation/linkTo.ts index d43f69eaa28e..2c7c9357af26 100644 --- a/src/libs/Navigation/linkTo.ts +++ b/src/libs/Navigation/linkTo.ts @@ -226,7 +226,7 @@ export default function linkTo(navigation: NavigationContainerRef = {}, shouldGetOptionDetails = false, - indexOffset = 0, ): SectionForSearchTerm { - // We show the selected participants at the top of the list when there is no search term + // We show the selected participants at the top of the list when there is no search term or maximum number of participants has already been selected // However, if there is a search term we remove the selected participants from the top of the list unless they are part of the search results // This clears up space on mobile views, where if you create a group with 4+ people you can't see the selected participants and the search results at the same time - if (searchTerm === '') { + if (searchTerm === '' || maxOptionsSelected) { return { section: { title: undefined, diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 78cde95fb0e4..071cf0050688 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -195,7 +195,15 @@ function getEffectiveDisplayName(personalDetail?: PersonalDetails): string | und return undefined; } +/** + * Whether personal details is empty + */ +function isPersonalDetailsEmpty() { + return !personalDetails.length; +} + export { + isPersonalDetailsEmpty, getDisplayNameOrDefault, getPersonalDetailsByIDs, getAccountIDsByLogins, diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index e5e21170bf88..b6ee4ab3a353 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -208,11 +208,11 @@ function isPaidGroupPolicy(policy: OnyxEntry): boolean { } function extractPolicyIDFromPath(path: string) { - return path.match(/\/w\/([a-zA-Z0-9]+)(\/|$)/)?.[1]; + return path.match(CONST.REGEX.POLICY_ID_FROM_PATH)?.[1]; } function getPathWithoutPolicyID(path: string) { - return path.replace(/\/w\/[a-zA-Z0-9]+(\/|$)/, '/'); + return path.replace(CONST.REGEX.PATH_WITHOUT_POLICY_ID, '/'); } function getPolicyMembersByIdWithoutCurrentUser(policyMembers: OnyxCollection, currentPolicyID?: string, currentUserAccountID?: number) { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 617e8ec0a786..dd979e52bebf 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -14,7 +14,20 @@ import CONST from '@src/CONST'; import type {ParentNavigationSummaryParams, TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Beta, PersonalDetails, PersonalDetailsList, Policy, PolicyReportField, Report, ReportAction, ReportMetadata, Session, Transaction, TransactionViolation} from '@src/types/onyx'; +import type { + Beta, + PersonalDetails, + PersonalDetailsList, + Policy, + PolicyReportField, + PolicyReportFields, + Report, + ReportAction, + ReportMetadata, + Session, + Transaction, + TransactionViolation, +} from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {ChangeLog, IOUMessage, OriginalMessageActionName, OriginalMessageCreated, PaymentMethodType} from '@src/types/onyx/OriginalMessage'; @@ -439,8 +452,21 @@ Onyx.connect({ callback: (value) => (allPolicies = value), }); -let allTransactions: OnyxCollection = {}; +let allPolicyReportFields: OnyxCollection = {}; + +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS, + waitForCollectionCallback: true, + callback: (value) => (allPolicyReportFields = value), +}); +let allBetas: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.BETAS, + callback: (value) => (allBetas = value), +}); + +let allTransactions: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.TRANSACTION, waitForCollectionCallback: true, @@ -1850,10 +1876,74 @@ function getPolicyExpenseChatName(report: OnyxEntry, policy: OnyxEntry

): boolean { + return reportField?.type === 'formula' && reportField?.fieldID === CONST.REPORT_FIELD_TITLE_FIELD_ID; +} + +/** + * Given a report field, check if the field can be edited or not. + * For title fields, its considered disabled if `deletable` prop is `true` (https://github.com/Expensify/App/issues/35043#issuecomment-1911275433) + * For non title fields, its considered disabled if: + * 1. The user is not admin of the report + * 2. Report is settled or it is closed + */ +function isReportFieldDisabled(report: OnyxEntry, reportField: OnyxEntry, policy: OnyxEntry): boolean { + const isReportSettled = isSettled(report?.reportID); + const isReportClosed = report?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED; + const isTitleField = isReportFieldOfTypeTitle(reportField); + const isAdmin = isPolicyAdmin(report?.policyID ?? '', {[`${ONYXKEYS.COLLECTION.POLICY}${policy?.id ?? ''}`]: policy}); + return isTitleField ? !reportField?.deletable : !isAdmin && (isReportSettled || isReportClosed); +} + +/** + * Given a set of report fields, return the field of type formula + */ +function getFormulaTypeReportField(reportFields: PolicyReportFields) { + return Object.values(reportFields).find((field) => field.type === 'formula'); +} + +/** + * Get the report fields attached to the policy given policyID + */ +function getReportFieldsByPolicyID(policyID: string) { + return Object.entries(allPolicyReportFields ?? {}).find(([key]) => key.replace(ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS, '') === policyID)?.[1]; +} + +/** + * Get the report fields that we should display a MoneyReportView gets opened + */ + +function getAvailableReportFields(report: Report, policyReportFields: PolicyReportField[]): PolicyReportField[] { + // Get the report fields that are attached to a report. These will persist even if a field is deleted from the policy. + const reportFields = Object.values(report.reportFields ?? {}); + const reportIsSettled = isSettled(report.reportID); + + // If the report is settled, we don't want to show any new field that gets added to the policy. + if (reportIsSettled) { + return reportFields; + } + + // If the report is unsettled, we want to merge the new fields that get added to the policy with the fields that + // are attached to the report. + const mergedFieldIds = Array.from(new Set([...policyReportFields.map(({fieldID}) => fieldID), ...reportFields.map(({fieldID}) => fieldID)])); + return mergedFieldIds.map((id) => report?.reportFields?.[id] ?? policyReportFields.find(({fieldID}) => fieldID === id)) as PolicyReportField[]; +} + /** * Get the title for an IOU or expense chat which will be showing the payer and the amount */ function getMoneyRequestReportName(report: OnyxEntry, policy: OnyxEntry | undefined = undefined): string { + const isReportSettled = isSettled(report?.reportID ?? ''); + const reportFields = isReportSettled ? report?.reportFields : getReportFieldsByPolicyID(report?.policyID ?? ''); + const titleReportField = getFormulaTypeReportField(reportFields ?? {}); + + if (titleReportField && report?.reportName && Permissions.canUseReportFields(allBetas ?? [])) { + return report.reportName; + } + const moneyRequestTotal = getMoneyRequestSpendBreakdown(report).totalDisplaySpend; const formattedAmount = CurrencyUtils.convertToDisplayString(moneyRequestTotal, report?.currency, hasOnlyDistanceRequestTransactions(report?.reportID)); const payerOrApproverName = isExpenseReport(report) ? getPolicyName(report, false, policy) : getDisplayNameForParticipant(report?.managerID) ?? ''; @@ -4540,32 +4630,6 @@ function navigateToPrivateNotes(report: Report, session: Session) { Navigation.navigate(ROUTES.PRIVATE_NOTES_LIST.getRoute(report.reportID)); } -/** - * Given a report field and a report, get the title of the field. - * This is specially useful when we have a report field of type formula. - */ -function getReportFieldTitle(report: OnyxEntry, reportField: PolicyReportField): string { - const value = report?.reportFields?.[reportField.fieldID] ?? reportField.defaultValue; - - if (reportField.type !== 'formula') { - return value; - } - - return value.replaceAll(CONST.REGEX.REPORT_FIELD_TITLE, (match, property) => { - if (report && property in report) { - return report[property as keyof Report]?.toString() ?? match; - } - return match; - }); -} - -/** - * Given a report field, check if the field is for the report title. - */ -function isReportFieldOfTypeTitle(reportField: PolicyReportField): boolean { - return reportField.type === 'formula' && reportField.fieldID === CONST.REPORT_FIELD_TITLE_FIELD_ID; -} - /** * Checks if thread replies should be displayed */ @@ -4792,7 +4856,6 @@ export { canEditWriteCapability, hasSmartscanError, shouldAutoFocusOnKeyPress, - getReportFieldTitle, shouldDisplayThreadReplies, shouldDisableThread, doesReportBelongToWorkspace, @@ -4800,6 +4863,8 @@ export { isReportParticipant, isValidReport, isReportFieldOfTypeTitle, + isReportFieldDisabled, + getAvailableReportFields, }; export type { diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 01d157a4cf3c..02c32e089016 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -373,7 +373,7 @@ function getOptionData({ ? Localize.translate(preferredLocale, 'workspace.invite.invited') : Localize.translate(preferredLocale, 'workspace.invite.removed'); const users = Localize.translate(preferredLocale, targetAccountIDs.length > 1 ? 'workspace.invite.users' : 'workspace.invite.user'); - result.alternateText = `${verb} ${targetAccountIDs.length} ${users}`; + result.alternateText = `${lastActorDisplayName} ${verb} ${targetAccountIDs.length} ${users}`.trim(); const roomName = lastAction?.originalMessage?.roomName ?? ''; if (roomName) { diff --git a/src/libs/WorkspacesUtils.ts b/src/libs/WorkspacesUtils.ts index f0d93183e480..c41393cb75f7 100644 --- a/src/libs/WorkspacesUtils.ts +++ b/src/libs/WorkspacesUtils.ts @@ -13,7 +13,7 @@ type CheckingMethod = () => boolean; let allReports: OnyxCollection; -type BrickRoad = ValueOf | undefined; +type BrickRoad = ValueOf | undefined; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, @@ -57,7 +57,7 @@ const getBrickRoadForPolicy = (report: Report): BrickRoad => { const reportErrors = OptionsListUtils.getAllReportErrors(report, reportActions); const doesReportContainErrors = Object.keys(reportErrors ?? {}).length !== 0 ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined; if (doesReportContainErrors) { - return CONST.BRICK_ROAD.RBR; + return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; } // To determine if the report requires attention from the current user, we need to load the parent report action @@ -68,7 +68,7 @@ const getBrickRoadForPolicy = (report: Report): BrickRoad => { } const reportOption = {...report, isUnread: ReportUtils.isUnread(report), isUnreadWithMention: ReportUtils.isUnreadWithMention(report)}; const shouldShowGreenDotIndicator = ReportUtils.requiresAttentionFromCurrentUser(reportOption, itemParentReportAction); - return shouldShowGreenDotIndicator ? CONST.BRICK_ROAD.GBR : undefined; + return shouldShowGreenDotIndicator ? CONST.BRICK_ROAD_INDICATOR_STATUS.INFO : undefined; }; function hasGlobalWorkspaceSettingsRBR(policies: OnyxCollection, policyMembers: OnyxCollection) { @@ -96,36 +96,28 @@ function getChatTabBrickRoad(policyID?: string): BrickRoad | undefined { return undefined; } - let brickRoad: BrickRoad | undefined; + // If policyID is undefined, then all reports are checked whether they contain any brick road + const policyReports = policyID ? Object.values(allReports).filter((report) => report?.policyID === policyID) : Object.values(allReports); - Object.keys(allReports).forEach((report) => { - if (brickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR) { - return; - } + let hasChatTabGBR = false; - if (policyID && policyID !== allReports?.[report]?.policyID) { - return; + const hasChatTabRBR = policyReports.some((report) => { + const brickRoad = report ? getBrickRoadForPolicy(report) : undefined; + if (!hasChatTabGBR && brickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO) { + hasChatTabGBR = true; } + return brickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; + }); - const policyReport = allReports ? allReports[report] : null; - - if (!policyReport) { - return; - } - - const workspaceBrickRoad = getBrickRoadForPolicy(policyReport); - - if (workspaceBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR) { - brickRoad = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; - return; - } + if (hasChatTabRBR) { + return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; + } - if (!brickRoad && workspaceBrickRoad) { - brickRoad = workspaceBrickRoad; - } - }); + if (hasChatTabGBR) { + return CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; + } - return brickRoad; + return undefined; } function checkIfWorkspaceSettingsTabHasRBR(policyID?: string) { @@ -152,25 +144,22 @@ function getWorkspacesBrickRoads(): Record { // The key in this map is the workspace id const workspacesBrickRoadsMap: Record = {}; - const cleanPolicies = Object.fromEntries(Object.entries(allPolicies ?? {}).filter(([, policy]) => !!policy)); - - Object.values(cleanPolicies).forEach((policy) => { + Object.values(allPolicies ?? {}).forEach((policy) => { if (!policy) { return; } if (hasWorkspaceSettingsRBR(policy)) { - workspacesBrickRoadsMap[policy.id] = CONST.BRICK_ROAD.RBR; + workspacesBrickRoadsMap[policy.id] = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; } }); - Object.keys(allReports).forEach((report) => { - const policyID = allReports?.[report]?.policyID ?? CONST.POLICY.EMPTY; - const policyReport = allReports ? allReports[report] : null; - if (!policyReport || workspacesBrickRoadsMap[policyID] === CONST.BRICK_ROAD.RBR) { + Object.values(allReports).forEach((report) => { + const policyID = report?.policyID ?? CONST.POLICY.EMPTY; + if (!report || workspacesBrickRoadsMap[policyID] === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR) { return; } - const workspaceBrickRoad = getBrickRoadForPolicy(policyReport); + const workspaceBrickRoad = getBrickRoadForPolicy(report); if (!workspaceBrickRoad && !!workspacesBrickRoadsMap[policyID]) { return; @@ -192,20 +181,13 @@ function getWorkspacesUnreadStatuses(): Record { const workspacesUnreadStatuses: Record = {}; - Object.keys(allReports).forEach((report) => { - const policyID = allReports?.[report]?.policyID; - const policyReport = allReports ? allReports[report] : null; - if (!policyID || !policyReport) { + Object.values(allReports).forEach((report) => { + const policyID = report?.policyID; + if (!policyID || workspacesUnreadStatuses[policyID]) { return; } - const unreadStatus = ReportUtils.isUnread(policyReport); - - if (unreadStatus) { - workspacesUnreadStatuses[policyID] = true; - } else { - workspacesUnreadStatuses[policyID] = false; - } + workspacesUnreadStatuses[policyID] = ReportUtils.isUnread(report); }); return workspacesUnreadStatuses; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 5e7396e5cd8f..f2bdb097497e 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -349,7 +349,7 @@ function getOutstandingChildRequest(policy, needsToBeManuallySubmitted) { * @param {Array} optimisticPolicyRecentlyUsedCategories * @param {Array} optimisticPolicyRecentlyUsedTags * @param {boolean} isNewChatReport - * @param {boolean} isNewIOUReport + * @param {boolean} shouldCreateNewMoneyRequestReport * @param {Object} policy - May be undefined, an empty object, or an object matching the Policy type (src/types/onyx/Policy.ts) * @param {Array} policyTags * @param {Array} policyCategories @@ -368,7 +368,7 @@ function buildOnyxDataForMoneyRequest( optimisticPolicyRecentlyUsedCategories, optimisticPolicyRecentlyUsedTags, isNewChatReport, - isNewIOUReport, + shouldCreateNewMoneyRequestReport, policy, policyTags, policyCategories, @@ -391,14 +391,14 @@ function buildOnyxDataForMoneyRequest( }, }, { - onyxMethod: isNewIOUReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, + onyxMethod: shouldCreateNewMoneyRequestReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, value: { ...iouReport, lastMessageText: iouAction.message[0].text, lastMessageHtml: iouAction.message[0].html, pendingFields: { - ...(isNewIOUReport ? {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : {preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), + ...(shouldCreateNewMoneyRequestReport ? {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : {preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), }, }, }, @@ -416,10 +416,10 @@ function buildOnyxDataForMoneyRequest( }, }, { - onyxMethod: isNewIOUReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, + onyxMethod: shouldCreateNewMoneyRequestReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, value: { - ...(isNewIOUReport ? {[iouCreatedAction.reportActionID]: iouCreatedAction} : {}), + ...(shouldCreateNewMoneyRequestReport ? {[iouCreatedAction.reportActionID]: iouCreatedAction} : {}), [iouAction.reportActionID]: iouAction, }, }, @@ -507,7 +507,7 @@ function buildOnyxDataForMoneyRequest( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, value: { - ...(isNewIOUReport + ...(shouldCreateNewMoneyRequestReport ? { [iouCreatedAction.reportActionID]: { pendingAction: null, @@ -547,7 +547,7 @@ function buildOnyxDataForMoneyRequest( value: { pendingFields: null, errorFields: { - ...(isNewIOUReport ? {createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage')} : {}), + ...(shouldCreateNewMoneyRequestReport ? {createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage')} : {}), }, }, }, @@ -593,7 +593,7 @@ function buildOnyxDataForMoneyRequest( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, value: { - ...(isNewIOUReport + ...(shouldCreateNewMoneyRequestReport ? { [iouCreatedAction.reportActionID]: { errors: getReceiptError(transaction.receipt, transaction.filename || transaction.receipt.filename, isScanRequest), @@ -634,7 +634,7 @@ function buildOnyxDataForMoneyRequest( * Gathers all the data needed to make a money request. It attempts to find existing reports, iouReports, and receipts. If it doesn't find them, then * it creates optimistic versions of them and uses those instead * - * @param {Object} report + * @param {Object} parentChatReport * @param {Object} participant * @param {String} comment * @param {Number} amount @@ -651,6 +651,7 @@ function buildOnyxDataForMoneyRequest( * @param {Object} [policy] * @param {Object} [policyTags] * @param {Object} [policyCategories] + * @param {Number} [moneyRequestReportID] - If user requests money via the report composer on some money request report, we always add a request to that specific report. * @returns {Object} data * @returns {String} data.payerEmail * @returns {Object} data.iouReport @@ -666,7 +667,7 @@ function buildOnyxDataForMoneyRequest( * @returns {Object} data.onyxData.failureData */ function getMoneyRequestInformation( - report, + parentChatReport, participant, comment, amount, @@ -683,6 +684,7 @@ function getMoneyRequestInformation( policy = undefined, policyTags = undefined, policyCategories = undefined, + moneyRequestReportID = 0, ) { const payerEmail = OptionsListUtils.addSMSDomainIfPhoneNumber(participant.login); const payerAccountID = Number(participant.accountID); @@ -690,7 +692,7 @@ function getMoneyRequestInformation( // STEP 1: Get existing chat report OR build a new optimistic one let isNewChatReport = false; - let chatReport = lodashGet(report, 'reportID', null) ? report : null; + let chatReport = lodashGet(parentChatReport, 'reportID', null) ? parentChatReport : null; // If this is a policyExpenseChat, the chatReport must exist and we can get it from Onyx. // report is null if the flow is initiated from the global create menu. However, participant always stores the reportID if it exists, which is the case for policyExpenseChats @@ -708,9 +710,15 @@ function getMoneyRequestInformation( chatReport = ReportUtils.buildOptimisticChatReport([payerAccountID]); } - // STEP 2: Get existing IOU report and update its total OR build a new optimistic one - const isNewIOUReport = !chatReport.iouReportID || ReportUtils.hasIOUWaitingOnCurrentUserBankAccount(chatReport); - let iouReport = isNewIOUReport ? null : allReports[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`]; + // STEP 2: Get the money request report. If the moneyRequestReportID has been provided, we want to add the transaction to this specific report. + // If no such reportID has been provided, let's use the chatReport.iouReportID property. In case that is not present, build a new optimistic money request report. + let iouReport = null; + const shouldCreateNewMoneyRequestReport = !moneyRequestReportID && (!chatReport.iouReportID || ReportUtils.hasIOUWaitingOnCurrentUserBankAccount(chatReport)); + if (moneyRequestReportID > 0) { + iouReport = allReports[`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReportID}`]; + } else if (!shouldCreateNewMoneyRequestReport) { + iouReport = allReports[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`]; + } // Check if the Scheduled Submit is enabled in case of expense report let needsToBeManuallySubmitted = true; @@ -719,7 +727,7 @@ function getMoneyRequestInformation( isFromPaidPolicy = PolicyUtils.isPaidGroupPolicy(policy); // If the scheduled submit is turned off on the policy, user needs to manually submit the report which is indicated by GBR in LHN - needsToBeManuallySubmitted = isFromPaidPolicy && !(policy.isHarvestingEnabled || false); + needsToBeManuallySubmitted = isFromPaidPolicy && !(lodashGet(policy, 'harvesting.enabled', policy.isHarvestingEnabled) || false); // If the linked expense report on paid policy is not draft, we need to create a new draft expense report if (iouReport && isFromPaidPolicy && !ReportUtils.isDraftExpenseReport(iouReport)) { @@ -807,7 +815,7 @@ function getMoneyRequestInformation( currentTime, ); - let reportPreviewAction = isNewIOUReport ? null : ReportActionsUtils.getReportPreviewAction(chatReport.reportID, iouReport.reportID); + let reportPreviewAction = shouldCreateNewMoneyRequestReport ? null : ReportActionsUtils.getReportPreviewAction(chatReport.reportID, iouReport.reportID); if (reportPreviewAction) { reportPreviewAction = ReportUtils.updateReportPreview(iouReport, reportPreviewAction, false, comment, optimisticTransaction); } else { @@ -845,7 +853,7 @@ function getMoneyRequestInformation( optimisticPolicyRecentlyUsedCategories, optimisticPolicyRecentlyUsedTags, isNewChatReport, - isNewIOUReport, + shouldCreateNewMoneyRequestReport, policy, policyTags, policyCategories, @@ -860,7 +868,7 @@ function getMoneyRequestInformation( transaction: optimisticTransaction, iouAction, createdChatReportActionID: isNewChatReport ? optimisticCreatedActionForChat.reportActionID : 0, - createdIOUReportActionID: isNewIOUReport ? optimisticCreatedActionForIOU.reportActionID : 0, + createdIOUReportActionID: shouldCreateNewMoneyRequestReport ? optimisticCreatedActionForIOU.reportActionID : 0, reportPreviewAction, onyxData: { optimisticData, @@ -892,6 +900,7 @@ function createDistanceRequest(report, participant, comment, created, category, // If the report is an iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); const currentChatReport = isMoneyRequestReport ? ReportUtils.getReport(report.chatReportID) : report; + const moneyRequestReportID = isMoneyRequestReport ? report.reportID : 0; const currentCreated = DateUtils.enrichMoneyRequestTimestamp(created); const optimisticReceipt = { @@ -916,6 +925,7 @@ function createDistanceRequest(report, participant, comment, created, category, policy, policyTags, policyCategories, + moneyRequestReportID, ); API.write( 'CreateDistanceRequest', @@ -1313,6 +1323,7 @@ function requestMoney( // If the report is iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); const currentChatReport = isMoneyRequestReport ? ReportUtils.getReport(report.chatReportID) : report; + const moneyRequestReportID = isMoneyRequestReport ? report.reportID : 0; const currentCreated = DateUtils.enrichMoneyRequestTimestamp(created); const {payerAccountID, payerEmail, iouReport, chatReport, transaction, iouAction, createdChatReportActionID, createdIOUReportActionID, reportPreviewAction, onyxData} = getMoneyRequestInformation( @@ -1333,6 +1344,7 @@ function requestMoney( policy, policyTags, policyCategories, + moneyRequestReportID, ); const activeReportID = isMoneyRequestReport ? report.reportID : chatReport.reportID; @@ -1389,10 +1401,11 @@ function requestMoney( * @param {String} category * @param {String} tag * @param {String} existingSplitChatReportID - the report ID where the split bill happens, could be a group chat or a workspace chat + * @param {Boolean} billable * * @return {Object} */ -function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, merchant, category, tag, existingSplitChatReportID = '') { +function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, merchant, category, tag, existingSplitChatReportID = '', billable = false) { const currentUserEmailForIOUSplit = OptionsListUtils.addSMSDomainIfPhoneNumber(currentUserLogin); const participantAccountIDs = _.map(participants, (participant) => Number(participant.accountID)); const existingSplitChatReport = @@ -1416,6 +1429,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco undefined, category, tag, + billable, ); // Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat @@ -1617,6 +1631,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco undefined, category, tag, + billable, ); // STEP 4: Build optimistic reportActions. We need: @@ -1734,8 +1749,9 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco * @param {String} category * @param {String} tag * @param {String} existingSplitChatReportID - Either a group DM or a workspace chat + * @param {Boolean} billable */ -function splitBill(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, merchant, category, tag, existingSplitChatReportID = '') { +function splitBill(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, merchant, category, tag, existingSplitChatReportID = '', billable = false) { const {splitData, splits, onyxData} = createSplitsAndOnyxData( participants, currentUserLogin, @@ -1747,6 +1763,7 @@ function splitBill(participants, currentUserLogin, currentUserAccountID, amount, category, tag, existingSplitChatReportID, + billable, ); API.write( 'SplitBill', @@ -1759,6 +1776,7 @@ function splitBill(participants, currentUserLogin, currentUserAccountID, amount, category, merchant, tag, + billable, transactionID: splitData.transactionID, reportActionID: splitData.reportActionID, createdReportActionID: splitData.createdReportActionID, @@ -1782,9 +1800,10 @@ function splitBill(participants, currentUserLogin, currentUserAccountID, amount, * @param {String} merchant * @param {String} category * @param {String} tag + * @param {Boolean} billable */ -function splitBillAndOpenReport(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, merchant, category, tag) { - const {splitData, splits, onyxData} = createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, merchant, category, tag); +function splitBillAndOpenReport(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, merchant, category, tag, billable) { + const {splitData, splits, onyxData} = createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, merchant, category, tag, billable); API.write( 'SplitBillAndOpenReport', @@ -1797,6 +1816,7 @@ function splitBillAndOpenReport(participants, currentUserLogin, currentUserAccou comment, category, tag, + billable, transactionID: splitData.transactionID, reportActionID: splitData.reportActionID, createdReportActionID: splitData.createdReportActionID, @@ -1821,8 +1841,9 @@ function splitBillAndOpenReport(participants, currentUserLogin, currentUserAccou * @param {String} tag * @param {Object} receipt * @param {String} existingSplitChatReportID - Either a group DM or a workspace chat + * @param {Boolean} billable */ -function startSplitBill(participants, currentUserLogin, currentUserAccountID, comment, category, tag, receipt, existingSplitChatReportID = '') { +function startSplitBill(participants, currentUserLogin, currentUserAccountID, comment, category, tag, receipt, existingSplitChatReportID = '', billable = false) { const currentUserEmailForIOUSplit = OptionsListUtils.addSMSDomainIfPhoneNumber(currentUserLogin); const participantAccountIDs = _.map(participants, (participant) => Number(participant.accountID)); const existingSplitChatReport = @@ -1850,6 +1871,7 @@ function startSplitBill(participants, currentUserLogin, currentUserAccountID, co undefined, category, tag, + billable, ); // Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat @@ -2063,6 +2085,7 @@ function startSplitBill(participants, currentUserLogin, currentUserAccountID, co category, tag, isFromGroupDM: !existingSplitChatReport, + billable, ...(existingSplitChatReport ? {} : {createdReportActionID: splitChatCreatedReportAction.reportActionID}), }, {optimisticData, successData, failureData}, @@ -3752,6 +3775,15 @@ function navigateToStartStepIfScanFileCannotBeRead(receiptFilename, receiptPath, FileUtils.readFileAsync(receiptPath, receiptFilename, onSuccess, onFailure); } +/** + * Save the preferred payment method for a policy + * @param {String} policyID + * @param {String} paymentMethod + */ +function savePreferredPaymentMethod(policyID, paymentMethod) { + Onyx.merge(`${ONYXKEYS.NVP_LAST_PAYMENT_METHOD}`, {[policyID]: paymentMethod}); +} + export { setMoneyRequestParticipants, createDistanceRequest, @@ -3812,4 +3844,5 @@ export { getIOUReportID, editMoneyRequest, navigateToStartStepIfScanFileCannotBeRead, + savePreferredPaymentMethod, }; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 028c409ee8fa..b4a7a12ccfca 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -66,7 +66,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; -import type {PersonalDetails, PersonalDetailsList, ReportActionReactions, ReportMetadata, ReportUserIsTyping} from '@src/types/onyx'; +import type {PersonalDetails, PersonalDetailsList, PolicyReportField, RecentlyUsedReportFields, ReportActionReactions, ReportMetadata, ReportUserIsTyping} from '@src/types/onyx'; import type {Decision, OriginalMessageIOU} from '@src/types/onyx/OriginalMessage'; import type {NotificationPreference, WriteCapability} from '@src/types/onyx/Report'; import type Report from '@src/types/onyx/Report'; @@ -189,6 +189,12 @@ Onyx.connect({ }, }); +let allRecentlyUsedReportFields: OnyxEntry = {}; +Onyx.connect({ + key: ONYXKEYS.RECENTLY_USED_REPORT_FIELDS, + callback: (val) => (allRecentlyUsedReportFields = val), +}); + /** Get the private pusher channel name for a Report. */ function getReportChannelName(reportID: string): string { return `${CONST.PUSHER.PRIVATE_REPORT_CHANNEL_PREFIX}${reportID}${CONFIG.PUSHER.SUFFIX}`; @@ -1477,6 +1483,137 @@ function toggleSubscribeToChildReport(childReportID = '0', parentReportAction: P } } +function updateReportName(reportID: string, value: string, previousValue: string) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + reportName: value, + pendingFields: { + reportName: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + reportName: previousValue, + pendingFields: { + reportName: null, + }, + errorFields: { + reportName: ErrorUtils.getMicroSecondOnyxError('report.genericUpdateReporNameEditFailureMessage'), + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + pendingFields: { + reportName: null, + }, + errorFields: { + reportName: null, + }, + }, + }, + ]; + + const parameters = { + reportID, + reportName: value, + }; + + API.write(WRITE_COMMANDS.SET_REPORT_NAME, parameters, {optimisticData, failureData, successData}); +} + +function updateReportField(reportID: string, reportField: PolicyReportField, previousReportField: PolicyReportField) { + const recentlyUsedValues = allRecentlyUsedReportFields?.[reportField.fieldID] ?? []; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + reportFields: { + [reportField.fieldID]: reportField, + }, + pendingFields: { + [reportField.fieldID]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + + if (reportField.type === 'dropdown') { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.RECENTLY_USED_REPORT_FIELDS, + value: { + [reportField.fieldID]: [...new Set([...recentlyUsedValues, reportField.value])], + }, + }); + } + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + reportFields: { + [reportField.fieldID]: previousReportField, + }, + pendingFields: { + [reportField.fieldID]: null, + }, + errorFields: { + [reportField.fieldID]: ErrorUtils.getMicroSecondOnyxError('report.genericUpdateReportFieldFailureMessage'), + }, + }, + }, + ]; + + if (reportField.type === 'dropdown') { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.RECENTLY_USED_REPORT_FIELDS, + value: { + [reportField.fieldID]: recentlyUsedValues, + }, + }); + } + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + pendingFields: { + [reportField.fieldID]: null, + }, + errorFields: { + [reportField.fieldID]: null, + }, + }, + }, + ]; + + const parameters = { + reportID, + reportFields: JSON.stringify({[reportField.fieldID]: reportField}), + }; + + API.write(WRITE_COMMANDS.SET_REPORT_FIELD, parameters, {optimisticData, failureData, successData}); +} + function updateWelcomeMessage(reportID: string, previousValue: string, newValue: string) { // No change needed, navigate back if (previousValue === newValue) { @@ -1830,7 +1967,7 @@ function showReportActionNotification(reportID: string, reportAction: ReportActi Modal.close(() => { const policyID = lastVisitedPath && extractPolicyIDFromPath(lastVisitedPath); const policyMembersAccountIDs = policyID ? getPolicyMemberAccountIDs(policyID) : []; - const reportBelongsToWorkspace = policyID ? doesReportBelongToWorkspace(report, policyMembersAccountIDs, policyID) : true; + const reportBelongsToWorkspace = policyID ? doesReportBelongToWorkspace(report, policyMembersAccountIDs, policyID) : false; if (!reportBelongsToWorkspace) { Navigation.navigateWithSwitchPolicyID({policyID: undefined, route: ROUTES.HOME}); } @@ -2745,5 +2882,7 @@ export { getDraftPrivateNote, updateLastVisitTime, clearNewRoomFormError, + updateReportField, + updateReportName, resolveActionableMentionWhisper, }; diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index a7aab98f02c6..60c05d0cb677 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -729,7 +729,6 @@ function deleteTask(taskReportID: string, taskTitle: string, originalStateNum: n ], errors: undefined, linkMetadata: [], - reportActionID: '', }; const optimisticReportActions = { [parentReportAction.reportActionID]: optimisticReportAction, @@ -751,8 +750,7 @@ function deleteTask(taskReportID: string, taskTitle: string, originalStateNum: n key: `${ONYXKEYS.COLLECTION.REPORT}${parentReport?.reportID}`, value: { lastMessageText: ReportActionsUtils.getLastVisibleMessage(parentReport?.reportID ?? '', optimisticReportActions as OnyxTypes.ReportActions).lastMessageText ?? '', - lastVisibleActionCreated: - ReportActionsUtils.getLastVisibleAction(parentReport?.reportID ?? '', optimisticReportActions as OnyxTypes.ReportActions)?.childLastVisibleActionCreated ?? 'created', + lastVisibleActionCreated: ReportActionsUtils.getLastVisibleAction(parentReport?.reportID ?? '', optimisticReportActions as OnyxTypes.ReportActions)?.created, }, }, { diff --git a/src/pages/DetailsPage.js b/src/pages/DetailsPage.js index 99db8b420d33..cdf5dc5a0502 100755 --- a/src/pages/DetailsPage.js +++ b/src/pages/DetailsPage.js @@ -136,8 +136,8 @@ function DetailsPage(props) { {({show}) => ( void; + onSubmit: (form: OnyxFormValuesFields) => void; }; -function EditReportFieldDatePage({fieldName, onSubmit, fieldValue, fieldID}: EditReportFieldDatePageProps) { +function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, fieldID}: EditReportFieldDatePageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const inputRef = useRef(null); const validate = useCallback( - (values: OnyxFormValuesFields) => { + (value: OnyxFormValuesFields) => { const errors: Errors = {}; - const value = values[fieldID]; - if (typeof value === 'string' && value.trim() === '') { + if (isRequired && value[fieldID].trim() === '') { errors[fieldID] = 'common.error.fieldRequired'; } return errors; }, - [fieldID], + [fieldID, isRequired], ); return ( inputRef.current?.focus()} + onEntryTransitionEnd={() => { + inputRef.current?.focus(); + }} testID={EditReportFieldDatePage.displayName} > void; + onSubmit: (form: Record) => void; +}; + +type EditReportFieldDropdownPageOnyxProps = { + recentlyUsedReportFields: OnyxEntry; }; -function EditReportFieldDropdownPage({fieldName, onSubmit, fieldValue, fieldOptions}: EditReportFieldDropdownPageProps) { +type EditReportFieldDropdownPageProps = EditReportFieldDropdownPageComponentProps & EditReportFieldDropdownPageOnyxProps; + +function EditReportFieldDropdownPage({fieldName, onSubmit, fieldID, fieldValue, fieldOptions, recentlyUsedReportFields}: EditReportFieldDropdownPageProps) { const [searchValue, setSearchValue] = useState(''); const styles = useThemeStyles(); const {getSafeAreaMargins} = useStyleUtils(); const {translate} = useLocalize(); + const recentlyUsedOptions = useMemo(() => recentlyUsedReportFields?.[fieldID] ?? [], [recentlyUsedReportFields, fieldID]); const sections = useMemo(() => { - const filteredOptions = fieldOptions.filter((option) => option.toLowerCase().includes(searchValue.toLowerCase())); + const filteredRecentOptions = recentlyUsedOptions.filter((option) => option.toLowerCase().includes(searchValue.toLowerCase())); + const filteredRestOfOptions = fieldOptions.filter((option) => !filteredRecentOptions.includes(option) && option.toLowerCase().includes(searchValue.toLowerCase())); + return [ { title: translate('common.recents'), shouldShow: true, - data: [], + data: filteredRecentOptions.map((option) => ({ + text: option, + keyForList: option, + searchText: option, + tooltipText: option, + })), }, { title: translate('common.all'), shouldShow: true, - data: filteredOptions.map((option) => ({ + data: filteredRestOfOptions.map((option) => ({ text: option, keyForList: option, searchText: option, @@ -45,7 +70,7 @@ function EditReportFieldDropdownPage({fieldName, onSubmit, fieldValue, fieldOpti })), }, ]; - }, [fieldOptions, searchValue, translate]); + }, [fieldOptions, recentlyUsedOptions, searchValue, translate]); return ( ) => onSubmit({[fieldID]: option.text})} onChangeText={setSearchValue} highlightSelectedOptions isRowMultilineSupported @@ -79,4 +104,8 @@ function EditReportFieldDropdownPage({fieldName, onSubmit, fieldValue, fieldOpti EditReportFieldDropdownPage.displayName = 'EditReportFieldDropdownPage'; -export default EditReportFieldDropdownPage; +export default withOnyx({ + recentlyUsedReportFields: { + key: () => ONYXKEYS.RECENTLY_USED_REPORT_FIELDS, + }, +})(EditReportFieldDropdownPage); diff --git a/src/pages/EditReportFieldPage.tsx b/src/pages/EditReportFieldPage.tsx index d74582708995..5bb53ef9122e 100644 --- a/src/pages/EditReportFieldPage.tsx +++ b/src/pages/EditReportFieldPage.tsx @@ -1,10 +1,15 @@ -import React, {useEffect} from 'react'; +import Str from 'expensify-common/lib/str'; +import React from 'react'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import type {OnyxFormValuesFields} from '@components/Form/types'; import ScreenWrapper from '@components/ScreenWrapper'; +import Navigation from '@libs/Navigation/Navigation'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as ReportActions from '@src/libs/actions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PolicyReportFields, Report} from '@src/types/onyx'; +import type {Policy, PolicyReportFields, Report} from '@src/types/onyx'; import EditReportFieldDatePage from './EditReportFieldDatePage'; import EditReportFieldDropdownPage from './EditReportFieldDropdownPage'; import EditReportFieldTextPage from './EditReportFieldTextPage'; @@ -15,6 +20,9 @@ type EditReportFieldPageOnyxProps = { /** Policy report fields */ policyReportFields: OnyxEntry; + + /** Policy to which the report belongs to */ + policy: OnyxEntry; }; type EditReportFieldPageProps = EditReportFieldPageOnyxProps & { @@ -34,61 +42,77 @@ type EditReportFieldPageProps = EditReportFieldPageOnyxProps & { }; }; -function EditReportFieldPage({route, report, policyReportFields}: EditReportFieldPageProps) { - const policyReportField = policyReportFields?.[route.params.fieldID]; - const reportFieldValue = report?.reportFields?.[policyReportField?.fieldID ?? '']; - - // Decides whether to allow or disallow editing a money request - useEffect(() => {}, []); - - if (policyReportField) { - if (policyReportField.type === 'text' || policyReportField.type === 'formula') { - return ( - {}} - /> - ); - } +function EditReportFieldPage({route, policy, report, policyReportFields}: EditReportFieldPageProps) { + const reportField = report?.reportFields?.[route.params.fieldID] ?? policyReportFields?.[route.params.fieldID]; + const isDisabled = ReportUtils.isReportFieldDisabled(report, reportField ?? null, policy); - if (policyReportField.type === 'date') { - return ( - {}} + if (!reportField || !report || isDisabled) { + return ( + + {}} + onLinkPress={() => {}} /> - ); - } + + ); + } - if (policyReportField.type === 'dropdown') { - return ( - {}} - /> - ); + const isReportFieldTitle = ReportUtils.isReportFieldOfTypeTitle(reportField); + + const handleReportFieldChange = (form: OnyxFormValuesFields) => { + const value = form[reportField.fieldID] || ''; + if (isReportFieldTitle) { + ReportActions.updateReportName(report.reportID, value, report.reportName ?? ''); + } else { + ReportActions.updateReportField(report.reportID, {...reportField, value}, reportField); } + + Navigation.dismissModal(report?.reportID); + }; + + const fieldValue = isReportFieldTitle ? report.reportName ?? '' : reportField.value ?? reportField.defaultValue; + + if (reportField.type === 'text' || isReportFieldTitle) { + return ( + + ); + } + + if (reportField.type === 'date') { + return ( + + ); } - return ( - - {}} - onLinkPress={() => {}} + if (reportField.type === 'dropdown') { + return ( + - - ); + ); + } } EditReportFieldPage.displayName = 'EditReportFieldPage'; @@ -100,4 +124,7 @@ export default withOnyx( policyReportFields: { key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS}${route.params.policyID}`, }, + policy: { + key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`, + }, })(EditReportFieldPage); diff --git a/src/pages/EditReportFieldTextPage.tsx b/src/pages/EditReportFieldTextPage.tsx index 733bfd6e5fee..ea9d2d3bed6d 100644 --- a/src/pages/EditReportFieldTextPage.tsx +++ b/src/pages/EditReportFieldTextPage.tsx @@ -23,38 +23,42 @@ type EditReportFieldTextPageProps = { /** ID of the policy report field */ fieldID: string; + /** Flag to indicate if the field can be left blank */ + isRequired: boolean; + /** Callback to fire when the Save button is pressed */ - onSubmit: () => void; + onSubmit: (form: OnyxFormValuesFields) => void; }; -function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, fieldID}: EditReportFieldTextPageProps) { +function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, fieldID}: EditReportFieldTextPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const inputRef = useRef(null); const validate = useCallback( - (values: OnyxFormValuesFields) => { + (values: OnyxFormValuesFields) => { const errors: Errors = {}; - const value = values[fieldID]; - if (typeof value === 'string' && value.trim() === '') { + if (isRequired && values[fieldID].trim() === '') { errors[fieldID] = 'common.error.fieldRequired'; } return errors; }, - [fieldID], + [fieldID, isRequired], ); return ( inputRef.current?.focus()} + onEntryTransitionEnd={() => { + inputRef.current?.focus(); + }} testID={EditReportFieldTextPage.displayName} > { - Report.searchInServer(text); - setSearchTerm(text); - }, []); - const {inputCallbackRef} = useAutoFocusInput(); return ( diff --git a/src/pages/ReportAvatar.tsx b/src/pages/ReportAvatar.tsx index c61a0a748f7d..29142294084c 100644 --- a/src/pages/ReportAvatar.tsx +++ b/src/pages/ReportAvatar.tsx @@ -35,6 +35,7 @@ function ReportAvatar({report = {} as Report, policies, isLoadingApp = true}: Re Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report?.reportID ?? '')); }} isWorkspaceAvatar + maybeIcon originalFileName={policy?.originalFileName ?? policyName} shouldShowNotFoundPage={!report?.reportID && !isLoadingApp} isLoading={(!report?.reportID || !policy?.id) && !!isLoadingApp} diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js index 3e682d592370..513ccbbe307c 100644 --- a/src/pages/ReportDetailsPage.js +++ b/src/pages/ReportDetailsPage.js @@ -165,7 +165,7 @@ function ReportDetailsPage(props) { return ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs(participants, props.personalDetails), hasMultipleParticipants); }, [participants, props.personalDetails]); - const icons = useMemo(() => ReportUtils.getIcons(props.report, props.personalDetails, props.policies), [props.report, props.personalDetails, props.policies]); + const icons = useMemo(() => ReportUtils.getIcons(props.report, props.personalDetails, null, '', -1, policy), [props.report, props.personalDetails, policy]); const chatRoomSubtitleText = chatRoomSubtitle ? ( (undefined); - const [searchTerm, setSearchTerm] = useState(); + const [selectedOption, setSelectedOption] = useState(); + const [searchTerm, setSearchTerm] = useState(''); const {inputCallbackRef} = useAutoFocusInput(); const {translate} = useLocalize(); const {activeWorkspaceID, setActiveWorkspaceID} = useActiveWorkspace(); @@ -70,12 +70,12 @@ function WorkspaceSwitcherPage({policies}: WorkspaceSwitcherPageProps) { return brickRoadsForPolicies[policyId]; } - if (Object.values(brickRoadsForPolicies).includes(CONST.BRICK_ROAD.RBR)) { - return CONST.BRICK_ROAD.RBR; + if (Object.values(brickRoadsForPolicies).includes(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR)) { + return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; } - if (Object.values(brickRoadsForPolicies).includes(CONST.BRICK_ROAD.GBR)) { - return CONST.BRICK_ROAD.GBR; + if (Object.values(brickRoadsForPolicies).includes(CONST.BRICK_ROAD_INDICATOR_STATUS.INFO)) { + return CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; } return undefined; @@ -161,6 +161,7 @@ function WorkspaceSwitcherPage({policies}: WorkspaceSwitcherPageProps) { const everythingSection = useMemo(() => { const option = { + reportID: '', text: CONST.WORKSPACE_SWITCHER.NAME, icons: [ { @@ -185,7 +186,7 @@ function WorkspaceSwitcherPage({policies}: WorkspaceSwitcherPageProps) { _.pick(policy, ['role']), }, + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, })(HeaderView), ); diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 4d043f12351e..f28e418865ff 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -1,7 +1,7 @@ import {useIsFocused} from '@react-navigation/native'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; @@ -11,7 +11,6 @@ import DragAndDropProvider from '@components/DragAndDrop/Provider'; import MoneyReportHeader from '@components/MoneyReportHeader'; import MoneyRequestHeader from '@components/MoneyRequestHeader'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {usePersonalDetails} from '@components/OnyxProvider'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import ScreenWrapper from '@components/ScreenWrapper'; import TaskHeaderActionButton from '@components/TaskHeaderActionButton'; @@ -28,14 +27,13 @@ import Navigation from '@libs/Navigation/Navigation'; import clearReportNotifications from '@libs/Notification/clearReportNotifications'; import reportWithoutHasDraftSelector from '@libs/OnyxSelectors/reportWithoutHasDraftSelector'; import Performance from '@libs/Performance'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; -import personalDetailsPropType from '@pages/personalDetailsPropType'; import reportMetadataPropTypes from '@pages/reportMetadataPropTypes'; import reportPropTypes from '@pages/reportPropTypes'; import * as ComposerActions from '@userActions/Composer'; import * as Report from '@userActions/Report'; -import * as Task from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -93,9 +91,6 @@ const propTypes = { /** The account manager report ID */ accountManagerReportID: PropTypes.string, - /** All of the personal details for everyone */ - personalDetails: PropTypes.objectOf(personalDetailsPropType), - /** Onyx function that marks the component ready for hydration */ markReadyForHydration: PropTypes.func, @@ -121,7 +116,6 @@ const defaultProps = { policies: {}, accountManagerReportID: null, userLeavingStatus: false, - personalDetails: {}, markReadyForHydration: null, ...withCurrentReportIDDefaultProps, }; @@ -146,12 +140,11 @@ function getReportID(route) { function ReportScreen({ betas, route, - report, + report: reportProp, reportMetadata, reportActions, parentReportAction, accountManagerReportID, - personalDetails, markReadyForHydration, policies, isSidebarLoaded, @@ -169,6 +162,77 @@ function ReportScreen({ const firstRenderRef = useRef(true); const flatListRef = useRef(); const reactionListRef = useRef(); + /** + * Create a lightweight Report so as to keep the re-rendering as light as possible by + * passing in only the required props. + * + * Also, this plays nicely in contrast with Onyx, + * which creates a new object every time collection changes. Because of this we can't + * put this into onyx selector as it will be the same. + */ + const report = useMemo( + () => ({ + lastReadTime: reportProp.lastReadTime, + reportID: reportProp.reportID, + policyID: reportProp.policyID, + lastVisibleActionCreated: reportProp.lastVisibleActionCreated, + statusNum: reportProp.statusNum, + stateNum: reportProp.stateNum, + writeCapability: reportProp.writeCapability, + type: reportProp.type, + errorFields: reportProp.errorFields, + isPolicyExpenseChat: reportProp.isPolicyExpenseChat, + parentReportID: reportProp.parentReportID, + parentReportActionID: reportProp.parentReportActionID, + chatType: reportProp.chatType, + pendingFields: reportProp.pendingFields, + isDeletedParentAction: reportProp.isDeletedParentAction, + reportName: reportProp.reportName, + description: reportProp.description, + managerID: reportProp.managerID, + total: reportProp.total, + nonReimbursableTotal: reportProp.nonReimbursableTotal, + reportFields: reportProp.reportFields, + ownerAccountID: reportProp.ownerAccountID, + currency: reportProp.currency, + participantAccountIDs: reportProp.participantAccountIDs, + isWaitingOnBankAccount: reportProp.isWaitingOnBankAccount, + iouReportID: reportProp.iouReportID, + isOwnPolicyExpenseChat: reportProp.isOwnPolicyExpenseChat, + notificationPreference: reportProp.notificationPreference, + }), + [ + reportProp.lastReadTime, + reportProp.reportID, + reportProp.policyID, + reportProp.lastVisibleActionCreated, + reportProp.statusNum, + reportProp.stateNum, + reportProp.writeCapability, + reportProp.type, + reportProp.errorFields, + reportProp.isPolicyExpenseChat, + reportProp.parentReportID, + reportProp.parentReportActionID, + reportProp.chatType, + reportProp.pendingFields, + reportProp.isDeletedParentAction, + reportProp.reportName, + reportProp.description, + reportProp.managerID, + reportProp.total, + reportProp.nonReimbursableTotal, + reportProp.reportFields, + reportProp.ownerAccountID, + reportProp.currency, + reportProp.participantAccountIDs, + reportProp.isWaitingOnBankAccount, + reportProp.iouReportID, + reportProp.isOwnPolicyExpenseChat, + reportProp.notificationPreference, + ], + ); + const prevReport = usePrevious(report); const prevUserLeavingStatus = usePrevious(userLeavingStatus); const [isBannerVisible, setIsBannerVisible] = useState(true); @@ -184,12 +248,20 @@ function ReportScreen({ const reportID = getReportID(route); const {addWorkspaceRoomOrChatPendingAction, addWorkspaceRoomOrChatErrors} = ReportUtils.getReportOfflinePendingActionAndErrors(report); const screenWrapperStyle = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}]; - + const isEmptyChat = useMemo(() => _.isEmpty(reportActions), [reportActions]); // There are no reportActions at all to display and we are still in the process of loading the next set of actions. const isLoadingInitialReportActions = _.isEmpty(reportActions) && reportMetadata.isLoadingInitialReportActions; const isOptimisticDelete = lodashGet(report, 'statusNum') === CONST.REPORT.STATUS_NUM.CLOSED; const shouldHideReport = !ReportUtils.canAccessReport(report, policies, betas); - const isLoading = !reportID || !isSidebarLoaded || _.isEmpty(personalDetails); + + const isLoading = !reportID || !isSidebarLoaded || PersonalDetailsUtils.isPersonalDetailsEmpty(); + const lastReportAction = useMemo( + () => + reportActions.length + ? _.find([...reportActions, parentReportAction], (action) => ReportUtils.canEditReportAction(action) && !ReportActionsUtils.isMoneyRequestAction(action)) + : {}, + [reportActions, parentReportAction], + ); const isSingleTransactionView = ReportUtils.isMoneyRequest(report); const policy = policies[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`] || {}; const isTopMostReportId = currentReportID === getReportID(route); @@ -210,7 +282,6 @@ function ReportScreen({ ); @@ -220,7 +291,6 @@ function ReportScreen({ @@ -232,7 +302,6 @@ function ReportScreen({ ); @@ -286,54 +355,6 @@ function ReportScreen({ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(accountManagerReportID)); }, [accountManagerReportID]); - const allPersonalDetails = usePersonalDetails(); - - /** - * @param {String} text - */ - const handleCreateTask = useCallback( - (text) => { - /** - * Matching task rule by group - * Group 1: Start task rule with [] - * Group 2: Optional email group between \s+....\s* start rule with @+valid email - * Group 3: Title is remaining characters - */ - const taskRegex = /^\[\]\s+(?:@([^\s@]+@[\w.-]+\.[a-zA-Z]{2,}))?\s*([\s\S]*)/; - - const match = text.match(taskRegex); - if (!match) { - return false; - } - const title = match[2] ? match[2].trim().replace(/\n/g, ' ') : undefined; - if (!title) { - return false; - } - const email = match[1] ? match[1].trim() : undefined; - let assignee = {}; - if (email) { - assignee = _.find(_.values(allPersonalDetails), (p) => p.login === email) || {}; - } - Task.createTaskAndNavigate(getReportID(route), title, '', assignee.login, assignee.accountID, assignee.assigneeChatReport, report.policyID); - return true; - }, - [allPersonalDetails, report.policyID, route], - ); - - /** - * @param {String} text - */ - const onSubmitComment = useCallback( - (text) => { - const isTaskCreated = handleCreateTask(text); - if (isTaskCreated) { - return; - } - Report.addComment(getReportID(route), text); - }, - [route, handleCreateTask], - ); - // Clear notifications for the current report when it's opened and re-focused const clearNotifications = useCallback(() => { // Check if this is the top-most ReportScreen since the Navigator preserves multiple at a time @@ -538,14 +559,12 @@ function ReportScreen({ {isReportReadyForDisplay ? ( ) : ( @@ -604,9 +623,6 @@ export default compose( key: ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID, initialValue: null, }, - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, userLeavingStatus: { key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${getReportID(route)}`, initialValue: false, @@ -625,4 +641,23 @@ export default compose( }, true, ), -)(ReportScreen); +)( + memo( + ReportScreen, + (prevProps, nextProps) => + prevProps.isSidebarLoaded === nextProps.isSidebarLoaded && + _.isEqual(prevProps.reportActions, nextProps.reportActions) && + _.isEqual(prevProps.reportMetadata, nextProps.reportMetadata) && + prevProps.isComposerFullSize === nextProps.isComposerFullSize && + _.isEqual(prevProps.betas, nextProps.betas) && + _.isEqual(prevProps.policies, nextProps.policies) && + prevProps.accountManagerReportID === nextProps.accountManagerReportID && + prevProps.userLeavingStatus === nextProps.userLeavingStatus && + prevProps.report.reportID === nextProps.report.reportID && + prevProps.report.policyID === nextProps.report.policyID && + prevProps.report.isOptimisticReport === nextProps.report.isOptimisticReport && + prevProps.report.statusNum === nextProps.report.statusNum && + _.isEqual(prevProps.report.pendingFields, nextProps.report.pendingFields) && + prevProps.currentReportID === nextProps.currentReportID, + ), +); diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js index 444dd939142b..df5645ae61ad 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js @@ -59,7 +59,7 @@ const propTypes = { isBlockedFromConcierge: PropTypes.bool.isRequired, /** Whether or not the attachment picker is disabled */ - disabled: PropTypes.bool.isRequired, + disabled: PropTypes.bool, /** Sets the menu visibility */ setMenuVisibility: PropTypes.func.isRequired, @@ -100,6 +100,7 @@ const propTypes = { const defaultProps = { reportParticipantIDs: [], + disabled: false, policy: {}, }; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js index 90c2ba0b42cf..4b5a54a6c428 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js @@ -1,6 +1,6 @@ import {useIsFocused, useNavigation} from '@react-navigation/native'; import lodashGet from 'lodash/get'; -import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {findNodeHandle, InteractionManager, NativeModules, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; @@ -74,8 +74,10 @@ function ComposerWithSuggestions({ isKeyboardShown, // Props: Report reportID, - report, - reportActions, + includeChronos, + isEmptyChat, + lastReportAction, + parentReportActionID, // Focus onFocus, onBlur, @@ -114,12 +116,13 @@ function ComposerWithSuggestions({ const isFocused = useIsFocused(); const navigation = useNavigation(); const emojisPresentBefore = useRef([]); + + const draftComment = getDraftComment(reportID) || ''; const [value, setValue] = useState(() => { - const draft = getDraftComment(reportID) || ''; - if (draft) { - emojisPresentBefore.current = EmojiUtils.extractEmojis(draft); + if (draftComment) { + emojisPresentBefore.current = EmojiUtils.extractEmojis(draftComment); } - return draft; + return draftComment; }); const commentRef = useRef(value); const lastTextRef = useRef(value); @@ -127,8 +130,7 @@ function ComposerWithSuggestions({ const {isSmallScreenWidth} = useWindowDimensions(); const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; - const isEmptyChat = useMemo(() => _.size(reportActions) === 1, [reportActions]); - const parentReportAction = lodashGet(parentReportActions, [report.parentReportActionID]); + const parentReportAction = lodashGet(parentReportActions, [parentReportActionID]); const shouldAutoFocus = !modal.isVisible && (shouldFocusInputOnScreenFocus || (isEmptyChat && !ReportActionsUtils.isTransactionThread(parentReportAction))) && shouldShowComposeInput; const valueRef = useRef(value); @@ -384,18 +386,14 @@ function ComposerWithSuggestions({ // Trigger the edit box for last sent message if ArrowUp is pressed and the comment is empty and Chronos is not in the participants const valueLength = valueRef.current.length; - if (e.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey && textInputRef.current.selectionStart === 0 && valueLength === 0 && !ReportUtils.chatIncludesChronos(report)) { + if (e.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey && textInputRef.current.selectionStart === 0 && valueLength === 0 && !includeChronos) { e.preventDefault(); - const lastReportAction = _.find( - [...reportActions, parentReportAction], - (action) => ReportUtils.canEditReportAction(action) && !ReportActionsUtils.isMoneyRequestAction(action), - ); if (lastReportAction) { Report.saveReportActionDraft(reportID, lastReportAction, _.last(lastReportAction.message).html); } } }, - [isKeyboardShown, isSmallScreenWidth, parentReportAction, report, reportActions, reportID, handleSendMessage, suggestionsRef, valueRef], + [isSmallScreenWidth, isKeyboardShown, suggestionsRef, includeChronos, handleSendMessage, lastReportAction, reportID], ); const onChangeText = useCallback( @@ -574,6 +572,22 @@ function ComposerWithSuggestions({ onValueChange(value); }, [onValueChange, value]); + const onLayout = useCallback( + (e) => { + const composerLayoutHeight = e.nativeEvent.layout.height; + if (composerHeight === composerLayoutHeight) { + return; + } + setComposerHeight(composerLayoutHeight); + }, + [composerHeight], + ); + + const onClear = useCallback(() => { + setTextInputShouldClear(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( <> @@ -594,7 +608,7 @@ function ComposerWithSuggestions({ onClick={setShouldBlockSuggestionCalcToFalse} onPasteFile={displayFileInModal} shouldClear={textInputShouldClear} - onClear={() => setTextInputShouldClear(false)} + onClear={onClear} isDisabled={isBlockedFromConcierge || disabled} isReportActionCompose selection={selection} @@ -607,13 +621,7 @@ function ComposerWithSuggestions({ numberOfLines={numberOfLines} onNumberOfLinesChange={updateNumberOfLines} shouldCalculateCaretPosition - onLayout={(e) => { - const composerLayoutHeight = e.nativeEvent.layout.height; - if (composerHeight === composerLayoutHeight) { - return; - } - setComposerHeight(composerLayoutHeight); - }} + onLayout={onLayout} onScroll={hideSuggestionMenu} shouldContainScroll={Browser.isMobileSafari()} /> @@ -637,7 +645,6 @@ function ComposerWithSuggestions({ `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, + key: ({parentReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, canEvict: false, initWithStoredValues: false, }, }), -)(ComposerWithSuggestionsWithRef); +)(memo(ComposerWithSuggestionsWithRef)); diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js index e2aa1d86af03..9d05db572949 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js @@ -1,5 +1,4 @@ import PropTypes from 'prop-types'; -import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; import CONST from '@src/CONST'; const propTypes = { @@ -18,20 +17,9 @@ const propTypes = { /** Whether the keyboard is open or not */ isKeyboardShown: PropTypes.bool.isRequired, - /** The actions from the parent report */ - parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), - - /** Array of report actions for this report */ - reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), - /** The ID of the report */ reportID: PropTypes.string.isRequired, - /** The report currently being looked at */ - report: PropTypes.shape({ - parentReportID: PropTypes.string, - }).isRequired, - /** Callback when the input is focused */ onFocus: PropTypes.func.isRequired, @@ -60,7 +48,7 @@ const propTypes = { isBlockedFromConcierge: PropTypes.bool.isRequired, /** Whether the input is disabled or not */ - disabled: PropTypes.bool.isRequired, + disabled: PropTypes.bool, /** Whether the full composer is available or not */ isFullComposerAvailable: PropTypes.bool.isRequired, @@ -121,6 +109,7 @@ const defaultProps = { reportActions: [], forwardedRef: null, measureParentContainer: () => {}, + disabled: false, }; export {propTypes, defaultProps}; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index c52b8ec6760a..cc07716209a2 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -1,7 +1,7 @@ import {PortalHost} from '@gorhom/portal'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import {runOnJS, setNativeProps, useAnimatedRef} from 'react-native-reanimated'; @@ -26,7 +26,6 @@ import getModalState from '@libs/getModalState'; import * as ReportUtils from '@libs/ReportUtils'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; import ParticipantLocalTime from '@pages/home/report/ParticipantLocalTime'; -import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; import ReportDropUI from '@pages/home/report/ReportDropUI'; import ReportTypingIndicator from '@pages/home/report/ReportTypingIndicator'; import reportPropTypes from '@pages/reportPropTypes'; @@ -46,9 +45,6 @@ const propTypes = { /** The ID of the report actions will be created for */ reportID: PropTypes.string.isRequired, - /** Array of report actions for this report */ - reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), - /** The report currently being looked at */ report: reportPropTypes, @@ -106,7 +102,8 @@ function ReportActionCompose({ pendingAction, report, reportID, - reportActions, + isEmptyChat, + lastReportAction, listHeight, shouldShowComposeInput, isReportReadyForDisplay, @@ -117,6 +114,7 @@ function ReportActionCompose({ const animatedRef = useAnimatedRef(); const actionButtonRef = useRef(null); const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; + /** * Updates the Highlight state of the composer */ @@ -180,7 +178,9 @@ function ReportActionCompose({ [personalDetails, report, currentUserPersonalDetails.accountID, isComposerFullSize], ); - const isBlockedFromConcierge = useMemo(() => ReportUtils.chatIncludesConcierge(report) && User.isBlockedFromConcierge(blockedFromConcierge), [report, blockedFromConcierge]); + const includesConcierge = useMemo(() => ReportUtils.chatIncludesConcierge({participantAccountIDs: report.participantAccountIDs}), [report.participantAccountIDs]); + const userBlockedFromConcierge = useMemo(() => User.isBlockedFromConcierge(blockedFromConcierge), [blockedFromConcierge]); + const isBlockedFromConcierge = useMemo(() => includesConcierge && userBlockedFromConcierge, [includesConcierge, userBlockedFromConcierge]); // If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions const conciergePlaceholderRandomIndex = useMemo( @@ -191,8 +191,8 @@ function ReportActionCompose({ // Placeholder to display in the chat input. const inputPlaceholder = useMemo(() => { - if (ReportUtils.chatIncludesConcierge(report)) { - if (User.isBlockedFromConcierge(blockedFromConcierge)) { + if (includesConcierge) { + if (userBlockedFromConcierge) { return translate('reportActionCompose.blockedFromConcierge'); } @@ -200,7 +200,7 @@ function ReportActionCompose({ } return translate('reportActionCompose.writeSomething'); - }, [report, blockedFromConcierge, translate, conciergePlaceholderRandomIndex]); + }, [includesConcierge, translate, userBlockedFromConcierge, conciergePlaceholderRandomIndex]); const focus = () => { if (composerRef === null || composerRef.current === null) { @@ -421,8 +421,11 @@ function ReportActionCompose({ isScrollLikelyLayoutTriggered={isScrollLikelyLayoutTriggered} raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLikelyLayoutTriggered} reportID={reportID} - report={report} - reportActions={reportActions} + parentReportID={report.parentReportID} + parentReportActionID={report.parentReportActionID} + includesChronos={ReportUtils.chatIncludesChronos(report)} + isEmptyChat={isEmptyChat} + lastReportAction={lastReportAction} isMenuVisible={isMenuVisible} inputPlaceholder={inputPlaceholder} isComposerFullSize={isComposerFullSize} @@ -501,4 +504,4 @@ export default compose( key: ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, }, }), -)(ReportActionCompose); +)(memo(ReportActionCompose)); diff --git a/src/pages/home/report/ReportActionCompose/SendButton.js b/src/pages/home/report/ReportActionCompose/SendButton.js index d0b0453ace2f..e9e3ef244f9c 100644 --- a/src/pages/home/report/ReportActionCompose/SendButton.js +++ b/src/pages/home/report/ReportActionCompose/SendButton.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, {memo} from 'react'; import {View} from 'react-native'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import Icon from '@components/Icon'; @@ -63,4 +63,4 @@ function SendButton({isDisabled: isDisabledProp, handleSendMessage}) { SendButton.propTypes = propTypes; SendButton.displayName = 'SendButton'; -export default SendButton; +export default memo(SendButton); diff --git a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.js b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.js index 9aa997a892f4..23d69ec7defc 100644 --- a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.js +++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.js @@ -9,12 +9,6 @@ const propTypes = { /** The comment of the report */ comment: PropTypes.string, - /** The report associated with the comment */ - report: PropTypes.shape({ - /** The ID of the report */ - reportID: PropTypes.string, - }).isRequired, - /** The value of the comment */ value: PropTypes.string.isRequired, @@ -26,6 +20,8 @@ const propTypes = { /** Updates the comment */ updateComment: PropTypes.func.isRequired, + + reportID: PropTypes.string.isRequired, }; const defaultProps = { @@ -38,9 +34,9 @@ const defaultProps = { * re-rendering a UI component for that. That's why the side effect was moved down to a separate component. * @returns {null} */ -function SilentCommentUpdater({comment, commentRef, report, value, updateComment}) { +function SilentCommentUpdater({comment, commentRef, reportID, value, updateComment}) { const prevCommentProp = usePrevious(comment); - const prevReportId = usePrevious(report.reportID); + const prevReportId = usePrevious(reportID); const {preferredLocale} = useLocalize(); const prevPreferredLocale = usePrevious(preferredLocale); @@ -56,12 +52,12 @@ function SilentCommentUpdater({comment, commentRef, report, value, updateComment // As the report IDs change, make sure to update the composer comment as we need to make sure // we do not show incorrect data in there (ie. draft of message from other report). - if (preferredLocale === prevPreferredLocale && report.reportID === prevReportId && !shouldSyncComment) { + if (preferredLocale === prevPreferredLocale && reportID === prevReportId && !shouldSyncComment) { return; } updateComment(comment); - }, [prevCommentProp, prevPreferredLocale, prevReportId, comment, preferredLocale, report.reportID, updateComment, value, commentRef]); + }, [prevCommentProp, prevPreferredLocale, prevReportId, comment, preferredLocale, reportID, updateComment, value, commentRef]); return null; } diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 141b2b8a5a6d..c290c9378e65 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -107,7 +107,6 @@ const propTypes = { /** Stores user's preferred skin tone */ preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - ...windowDimensionsPropTypes, emojiReactions: EmojiReactionsPropTypes, /** IOU report for this action, if any */ @@ -150,7 +149,7 @@ function ReportActionItem(props) { const prevDraftMessage = usePrevious(props.draftMessage); const originalReportID = ReportUtils.getOriginalReportID(props.report.reportID, props.action); const originalReport = props.report.reportID === originalReportID ? props.report : ReportUtils.getReport(originalReportID); - const isReportActionLinked = props.linkedReportActionID === props.action.reportActionID; + const isReportActionLinked = props.linkedReportActionID && props.action.reportActionID && props.linkedReportActionID === props.action.reportActionID; const highlightedBackgroundColorIfNeeded = useMemo( () => (isReportActionLinked ? StyleUtils.getBackgroundColorStyle(theme.hoverComponentBG) : {}), @@ -509,7 +508,6 @@ function ReportActionItem(props) { reportID={props.report.reportID} index={props.index} ref={textInputRef} - report={props.report} // Avoid defining within component due to an existing Onyx bug preferredSkinTone={props.preferredSkinTone} shouldDisableEmojiPicker={ @@ -652,6 +650,7 @@ function ReportActionItem(props) { @@ -807,6 +806,10 @@ export default compose( key: ({report}) => (report && 'policyID' in report ? `${ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS}${report.policyID}` : undefined), initialValue: [], }, + policy: { + key: ({report}) => (report && 'policyID' in report ? `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}` : undefined), + initialValue: {}, + }, emojiReactions: { key: ({action}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${action.reportActionID}`, initialValue: {}, @@ -850,6 +853,7 @@ export default compose( lodashGet(prevProps.report, 'nonReimbursableTotal', 0) === lodashGet(nextProps.report, 'nonReimbursableTotal', 0) && prevProps.linkedReportActionID === nextProps.linkedReportActionID && _.isEqual(prevProps.policyReportFields, nextProps.policyReportFields) && - _.isEqual(prevProps.report.reportFields, nextProps.report.reportFields), + _.isEqual(prevProps.report.reportFields, nextProps.report.reportFields) && + _.isEqual(prevProps.policy, nextProps.policy), ), ); diff --git a/src/pages/home/report/ReportActionItemCreated.tsx b/src/pages/home/report/ReportActionItemCreated.tsx index 82c6bebd9ba1..f8345ca7d2d0 100644 --- a/src/pages/home/report/ReportActionItemCreated.tsx +++ b/src/pages/home/report/ReportActionItemCreated.tsx @@ -115,6 +115,7 @@ export default withOnyx ({ + reportActionID: reportAction.reportActionID, + message: reportAction.message, + pendingAction: reportAction.pendingAction, + actionName: reportAction.actionName, + errors: reportAction.errors, + originalMessage: reportAction.originalMessage, + childCommenterCount: reportAction.childCommenterCount, + linkMetadata: reportAction.linkMetadata, + childReportID: reportAction.childReportID, + childLastVisibleActionCreated: reportAction.childLastVisibleActionCreated, + whisperedToAccountIDs: reportAction.whisperedToAccountIDs, + error: reportAction.error, + created: reportAction.created, + actorAccountID: reportAction.actorAccountID, + childVisibleActionCount: reportAction.childVisibleActionCount, + childOldestFourAccountIDs: reportAction.childOldestFourAccountIDs, + childType: reportAction.childType, + person: reportAction.person, + isOptimisticAction: reportAction.isOptimisticAction, + delegateAccountID: reportAction.delegateAccountID, + previousMessage: reportAction.previousMessage, + attachmentInfo: reportAction.attachmentInfo, + childStateNum: reportAction.childStateNum, + childStatusNum: reportAction.childStatusNum, + childReportName: reportAction.childReportName, + childManagerAccountID: reportAction.childManagerAccountID, + childMoneyRequestCount: reportAction.childMoneyRequestCount, + }), + [ + reportAction.actionName, + reportAction.childCommenterCount, + reportAction.childLastVisibleActionCreated, + reportAction.childReportID, + reportAction.created, + reportAction.error, + reportAction.errors, + reportAction.linkMetadata, + reportAction.message, + reportAction.originalMessage, + reportAction.pendingAction, + reportAction.reportActionID, + reportAction.whisperedToAccountIDs, + reportAction.actorAccountID, + reportAction.childVisibleActionCount, + reportAction.childOldestFourAccountIDs, + reportAction.person, + reportAction.isOptimisticAction, + reportAction.childType, + reportAction.delegateAccountID, + reportAction.previousMessage, + reportAction.attachmentInfo, + reportAction.childStateNum, + reportAction.childStatusNum, + reportAction.childReportName, + reportAction.childManagerAccountID, + reportAction.childMoneyRequestCount, + ], + ); + return shouldDisplayParentAction ? ( { const wasLoginChangedDetected = prevAuthTokenType === 'anonymousAccount' && !props.session.authTokenType; @@ -159,7 +159,7 @@ function ReportActionsView(props) { // update ref with current state prevIsSmallScreenWidthRef.current = props.isSmallScreenWidth; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.isSmallScreenWidth, props.report, props.reportActions, isReportFullyVisible]); + }, [props.isSmallScreenWidth, props.reportActions, isReportFullyVisible]); useEffect(() => { // Ensures subscription event succeeds when the report/workspace room is created optimistically. @@ -171,7 +171,7 @@ function ReportActionsView(props) { Report.subscribeToReportTypingEvents(reportID); didSubscribeToReportTypingEvents.current = true; } - }, [props.report, didSubscribeToReportTypingEvents, reportID]); + }, [props.report.pendingFields, didSubscribeToReportTypingEvents, reportID]); /** * Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently @@ -278,14 +278,6 @@ function arePropsEqual(oldProps, newProps) { return false; } - if (!_.isEqual(oldProps.report.pendingFields, newProps.report.pendingFields)) { - return false; - } - - if (!_.isEqual(oldProps.report.errorFields, newProps.report.errorFields)) { - return false; - } - if (lodashGet(oldProps.network, 'isOffline') !== lodashGet(newProps.network, 'isOffline')) { return false; } @@ -306,10 +298,6 @@ function arePropsEqual(oldProps, newProps) { return false; } - if (oldProps.report.lastReadTime !== newProps.report.lastReadTime) { - return false; - } - if (newProps.isSmallScreenWidth !== oldProps.isSmallScreenWidth) { return false; } @@ -318,10 +306,6 @@ function arePropsEqual(oldProps, newProps) { return false; } - if (lodashGet(newProps.report, 'statusNum') !== lodashGet(oldProps.report, 'statusNum') || lodashGet(newProps.report, 'stateNum') !== lodashGet(oldProps.report, 'stateNum')) { - return false; - } - if (lodashGet(newProps, 'policy.avatar') !== lodashGet(oldProps, 'policy.avatar')) { return false; } @@ -330,35 +314,14 @@ function arePropsEqual(oldProps, newProps) { return false; } - if (lodashGet(newProps, 'report.reportName') !== lodashGet(oldProps, 'report.reportName')) { - return false; - } - - if (lodashGet(newProps, 'report.description') !== lodashGet(oldProps, 'report.description')) { - return false; - } - - if (lodashGet(newProps, 'report.managerID') !== lodashGet(oldProps, 'report.managerID')) { - return false; - } - - if (lodashGet(newProps, 'report.total') !== lodashGet(oldProps, 'report.total')) { - return false; - } - - if (lodashGet(newProps, 'report.nonReimbursableTotal') !== lodashGet(oldProps, 'report.nonReimbursableTotal')) { - return false; - } - - if (lodashGet(newProps, 'report.writeCapability') !== lodashGet(oldProps, 'report.writeCapability')) { - return false; - } - - if (lodashGet(newProps, 'report.participantAccountIDs', 0) !== lodashGet(oldProps, 'report.participantAccountIDs', 0)) { - return false; - } - - return _.isEqual(lodashGet(newProps.report, 'icons', []), lodashGet(oldProps.report, 'icons', [])); + return ( + oldProps.report.lastReadTime === newProps.report.lastReadTime && + oldProps.report.reportID === newProps.report.reportID && + oldProps.report.policyID === newProps.report.policyID && + oldProps.report.lastVisibleActionCreated === newProps.report.lastVisibleActionCreated && + oldProps.report.isOptimisticReport === newProps.report.isOptimisticReport && + _.isEqual(oldProps.report.pendingFields, newProps.report.pendingFields) + ); } const MemoizedReportActionsView = React.memo(ReportActionsView, arePropsEqual); diff --git a/src/pages/home/report/ReportFooter.js b/src/pages/home/report/ReportFooter.js index 48bfd5d18bcc..1761e135481a 100644 --- a/src/pages/home/report/ReportFooter.js +++ b/src/pages/home/report/ReportFooter.js @@ -1,11 +1,12 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, {memo, useCallback} from 'react'; import {Keyboard, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import _, {isEqual} from 'underscore'; import AnonymousReportFooter from '@components/AnonymousReportFooter'; import ArchivedReportFooter from '@components/ArchivedReportFooter'; import OfflineIndicator from '@components/OfflineIndicator'; -import participantPropTypes from '@components/participantPropTypes'; +import {usePersonalDetails} from '@components/OnyxProvider'; import SwipeableView from '@components/SwipeableView'; import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; import useNetwork from '@hooks/useNetwork'; @@ -14,7 +15,9 @@ import compose from '@libs/compose'; import * as ReportUtils from '@libs/ReportUtils'; import reportPropTypes from '@pages/reportPropTypes'; import variables from '@styles/variables'; +import * as Report from '@userActions/Report'; import * as Session from '@userActions/Session'; +import * as Task from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ReportActionCompose from './ReportActionCompose/ReportActionCompose'; @@ -24,43 +27,33 @@ const propTypes = { /** Report object for the current report */ report: reportPropTypes, - /** Report actions for the current report */ - reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), + lastReportAction: PropTypes.shape(reportActionPropTypes), - /** Callback fired when the comment is submitted */ - onSubmitComment: PropTypes.func, + isEmptyChat: PropTypes.bool, /** The pending action when we are adding a chat */ pendingAction: PropTypes.string, - /** Personal details of all the users */ - personalDetails: PropTypes.objectOf(participantPropTypes), - - /** Whether the composer input should be shown */ - shouldShowComposeInput: PropTypes.bool, - - /** Whether user interactions should be disabled */ - shouldDisableCompose: PropTypes.bool, - /** Height of the list which the composer is part of */ listHeight: PropTypes.number, /** Whetjer the report is ready for display */ isReportReadyForDisplay: PropTypes.bool, + /** Whether to show the compose input */ + shouldShowComposeInput: PropTypes.bool, + ...windowDimensionsPropTypes, }; const defaultProps = { report: {reportID: '0'}, - reportActions: [], - onSubmitComment: () => {}, pendingAction: null, - personalDetails: {}, - shouldShowComposeInput: true, - shouldDisableCompose: false, listHeight: 0, isReportReadyForDisplay: true, + lastReportAction: null, + isEmptyChat: true, + shouldShowComposeInput: false, }; function ReportFooter(props) { @@ -73,6 +66,52 @@ function ReportFooter(props) { const isSmallSizeLayout = props.windowWidth - (props.isSmallScreenWidth ? 0 : variables.sideBarWidth) < variables.anonymousReportFooterBreakpoint; const hideComposer = !ReportUtils.canUserPerformWriteAction(props.report); + const allPersonalDetails = usePersonalDetails(); + + /** + * @param {String} text + */ + const handleCreateTask = useCallback( + (text) => { + /** + * Matching task rule by group + * Group 1: Start task rule with [] + * Group 2: Optional email group between \s+....\s* start rule with @+valid email + * Group 3: Title is remaining characters + */ + const taskRegex = /^\[\]\s+(?:@([^\s@]+@[\w.-]+\.[a-zA-Z]{2,}))?\s*([\s\S]*)/; + + const match = text.match(taskRegex); + if (!match) { + return false; + } + const title = match[2] ? match[2].trim().replace(/\n/g, ' ') : undefined; + if (!title) { + return false; + } + const email = match[1] ? match[1].trim() : undefined; + let assignee = {}; + if (email) { + assignee = _.find(_.values(allPersonalDetails), (p) => p.login === email) || {}; + } + Task.createTaskAndNavigate(props.report.reportID, title, '', assignee.login, assignee.accountID, assignee.assigneeChatReport, props.report.policyID); + return true; + }, + [allPersonalDetails, props.report.policyID, props.report.reportID], + ); + + const onSubmitComment = useCallback( + (text) => { + const isTaskCreated = handleCreateTask(text); + if (isTaskCreated) { + return; + } + Report.addComment(props.report.reportID, text); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [props.report.reportID, handleCreateTask], + ); + return ( <> {hideComposer && ( @@ -81,7 +120,6 @@ function ReportFooter(props) { )} {isArchivedRoom && } @@ -94,13 +132,13 @@ function ReportFooter(props) { @@ -122,4 +160,19 @@ export default compose( initialValue: false, }, }), -)(ReportFooter); +)( + memo( + ReportFooter, + (prevProps, nextProps) => + isEqual(prevProps.report, nextProps.report) && + prevProps.pendingAction === nextProps.pendingAction && + prevProps.listHeight === nextProps.listHeight && + prevProps.isComposerFullSize === nextProps.isComposerFullSize && + prevProps.isEmptyChat === nextProps.isEmptyChat && + prevProps.lastReportAction === nextProps.lastReportAction && + prevProps.shouldShowComposeInput === nextProps.shouldShowComposeInput && + prevProps.windowWidth === nextProps.windowWidth && + prevProps.isSmallScreenWidth === nextProps.isSmallScreenWidth && + prevProps.isReportReadyForDisplay === nextProps.isReportReadyForDisplay, + ), +); diff --git a/src/pages/home/report/ReportTypingIndicator.js b/src/pages/home/report/ReportTypingIndicator.js index 785f1e3f6a1e..41471eaa50de 100755 --- a/src/pages/home/report/ReportTypingIndicator.js +++ b/src/pages/home/report/ReportTypingIndicator.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React, {useMemo} from 'react'; +import React, {memo, useMemo} from 'react'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import Text from '@components/Text'; @@ -68,4 +68,4 @@ export default withOnyx({ key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`, initialValue: {}, }, -})(ReportTypingIndicator); +})(memo(ReportTypingIndicator)); diff --git a/src/pages/home/sidebar/AllSettingsScreen.tsx b/src/pages/home/sidebar/AllSettingsScreen.tsx index 200c7ca634b7..6e7447244a4d 100644 --- a/src/pages/home/sidebar/AllSettingsScreen.tsx +++ b/src/pages/home/sidebar/AllSettingsScreen.tsx @@ -33,7 +33,7 @@ function AllSettingsScreen({policies, policyMembers}: AllSettingsScreenProps) { const {isSmallScreenWidth} = useWindowDimensions(); /** - * Retuns a list of menu items data for "everything" settings + * Retuns a list of menu items data for All workspaces settings * @returns {Object} object with translationKey, style and items */ const menuItems = useMemo(() => { diff --git a/src/pages/iou/SplitBillDetailsPage.js b/src/pages/iou/SplitBillDetailsPage.js index 94c2f1c31242..be3afb822723 100644 --- a/src/pages/iou/SplitBillDetailsPage.js +++ b/src/pages/iou/SplitBillDetailsPage.js @@ -102,6 +102,7 @@ function SplitBillDetailsPage(props) { created: splitCreated, category: splitCategory, tag: splitTag, + billable: splitBillable, } = isEditingSplitBill && props.draftTransaction ? ReportUtils.getTransactionDetails(props.draftTransaction) : ReportUtils.getTransactionDetails(props.transaction); const onConfirm = useCallback( @@ -133,6 +134,7 @@ function SplitBillDetailsPage(props) { iouMerchant={splitMerchant} iouCategory={splitCategory} iouTag={splitTag} + iouIsBillable={splitBillable} iouType={CONST.IOU.TYPE.SPLIT} isReadOnly={!isEditingSplitBill} shouldShowSmartScanFields @@ -146,7 +148,7 @@ function SplitBillDetailsPage(props) { transaction={isEditingSplitBill ? props.draftTransaction || props.transaction : props.transaction} onConfirm={onConfirm} isPolicyExpenseChat={ReportUtils.isPolicyExpenseChat(props.report)} - policyID={ReportUtils.isPolicyExpenseChat(props.report) && props.report.policyID} + policyID={ReportUtils.isPolicyExpenseChat(props.report) ? props.report.policyID : null} /> )} diff --git a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js index 15f98205839e..e47b0b8198ea 100644 --- a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js +++ b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js @@ -13,8 +13,8 @@ import SelectCircle from '@components/SelectCircle'; import SelectionList from '@components/SelectionList'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import useSearchTermAndSearch from '@hooks/useSearchTermAndSearch'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as Report from '@libs/actions/Report'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; @@ -88,6 +88,7 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({ const offlineMessage = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; const maxParticipantsReached = participants.length === CONST.REPORT.MAXIMUM_PARTICIPANTS; + const setSearchTermAndSearchInServer = useSearchTermAndSearch(setSearchTerm, maxParticipantsReached); /** * Returns the sections needed for the OptionsSelector @@ -133,9 +134,10 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({ participants, chatOptions.recentReports, chatOptions.personalDetails, + maxParticipantsReached, + indexOffset, personalDetails, true, - indexOffset, ); newSections.push(formatResults.section); indexOffset = formatResults.newIndexOffset; @@ -243,14 +245,6 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({ [maxParticipantsReached, newChatOptions, participants, searchTerm], ); - // When search term updates we will fetch any reports - const setSearchTermAndSearchInServer = useCallback((text = '') => { - if (text.length) { - Report.searchInServer(text); - } - setSearchTerm(text); - }, []); - // Right now you can't split a request with a workspace and other additional participants // This is getting properly fixed in https://github.com/Expensify/App/issues/27508, but as a stop-gap to prevent // the app from crashing on native when you try to do this, we'll going to hide the button if you have a workspace and other participants diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.js b/src/pages/iou/request/step/IOURequestStepConfirmation.js index 6028a735d132..0f1c2b27ad2e 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.js +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.js @@ -226,6 +226,7 @@ function IOURequestStepConfirmation({ transaction.tag, receiptFile, report.reportID, + transaction.billable, ); return; } @@ -244,6 +245,7 @@ function IOURequestStepConfirmation({ transaction.category, transaction.tag, report.reportID, + transaction.billable, ); return; } @@ -260,6 +262,7 @@ function IOURequestStepConfirmation({ transaction.merchant, transaction.category, transaction.tag, + transaction.billable, ); return; } diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js index 59081599736c..daaa63aae147 100755 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js @@ -13,8 +13,8 @@ import SelectCircle from '@components/SelectCircle'; import SelectionList from '@components/SelectionList'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import useSearchTermAndSearch from '@hooks/useSearchTermAndSearch'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as Report from '@libs/actions/Report'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import reportPropTypes from '@pages/reportPropTypes'; @@ -89,6 +89,9 @@ function MoneyRequestParticipantsSelector({ const {isOffline} = useNetwork(); const personalDetails = usePersonalDetails(); + const maxParticipantsReached = participants.length === CONST.REPORT.MAXIMUM_PARTICIPANTS; + const setSearchTermAndSearchInServer = useSearchTermAndSearch(setSearchTerm, maxParticipantsReached); + const offlineMessage = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; const newChatOptions = useMemo(() => { @@ -124,8 +127,6 @@ function MoneyRequestParticipantsSelector({ }; }, [betas, reports, participants, personalDetails, searchTerm, iouType, isDistanceRequest]); - const maxParticipantsReached = participants.length === CONST.REPORT.MAXIMUM_PARTICIPANTS; - /** * Returns the sections needed for the OptionsSelector * @@ -140,9 +141,10 @@ function MoneyRequestParticipantsSelector({ participants, newChatOptions.recentReports, newChatOptions.personalDetails, + maxParticipantsReached, + indexOffset, personalDetails, true, - indexOffset, ); newSections.push(formatResults.section); indexOffset = formatResults.newIndexOffset; @@ -259,12 +261,6 @@ function MoneyRequestParticipantsSelector({ [maxParticipantsReached, newChatOptions.personalDetails.length, newChatOptions.recentReports.length, newChatOptions.userToInvite, participants, searchTerm], ); - // When search term updates we will fetch any reports - const setSearchTermAndSearchInServer = useCallback((text = '') => { - Report.searchInServer(text); - setSearchTerm(text); - }, []); - // Right now you can't split a request with a workspace and other additional participants // This is getting properly fixed in https://github.com/Expensify/App/issues/27508, but as a stop-gap to prevent // the app from crashing on native when you try to do this, we'll going to show error message if you have a workspace and other participants diff --git a/src/pages/workspace/WorkspacePageWithSections.tsx b/src/pages/workspace/WorkspacePageWithSections.tsx index 27a27766136a..70198f38f18c 100644 --- a/src/pages/workspace/WorkspacePageWithSections.tsx +++ b/src/pages/workspace/WorkspacePageWithSections.tsx @@ -1,4 +1,3 @@ -import type {RouteProp} from '@react-navigation/native'; import type {ReactNode} from 'react'; import React, {useEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; @@ -22,6 +21,7 @@ import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type {Policy, ReimbursementAccount, User} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {PolicyRoute} from './withPolicy'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; @@ -42,10 +42,10 @@ type WorkspacePageWithSectionsProps = WithPolicyAndFullscreenLoadingProps & headerText: string; /** The route object passed to this page from the navigator */ - route: RouteProp<{params: {policyID: string}}>; + route: PolicyRoute; /** Main content of the page */ - children: (hasVBA?: boolean, policyID?: string, isUsingECard?: boolean) => ReactNode; + children: (hasVBA: boolean, policyID: string, isUsingECard: boolean) => ReactNode; /** Content to be added as fixed footer */ footer?: ReactNode; @@ -65,6 +65,7 @@ type WorkspacePageWithSectionsProps = WithPolicyAndFullscreenLoadingProps & /** Should show the back button. It is used when in RHP. */ shouldShowBackButton?: boolean; + /** Whether the offline indicator should be shown in wide screen devices */ shouldShowOfflineIndicatorInWideScreen?: boolean; /** Whether to show this page to non admin policy members */ @@ -106,7 +107,7 @@ function WorkspacePageWithSections({ const isLoading = reimbursementAccount?.isLoading ?? true; const achState = reimbursementAccount?.achData?.state ?? ''; const isUsingECard = user?.isUsingExpensifyCard ?? false; - const policyID = route.params.policyID; + const policyID = route.params?.policyID ?? ''; const hasVBA = achState === BankAccount.STATE.OPEN; const content = children(hasVBA, policyID, isUsingECard); const {isSmallScreenWidth} = useWindowDimensions(); diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 5b499fe7a3ee..9b763120b30d 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -300,8 +300,8 @@ function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, r iconFill: theme.textLight, fallbackIcon: Expensicons.FallbackWorkspaceAvatar, policyID: policy.id, - adminRoom: policyRooms?.[policy.id].adminRoom ?? policy.chatReportIDAdmins?.toString(), - announceRoom: policyRooms?.[policy.id].announceRoom ?? policy.chatReportIDAnnounce?.toString(), + adminRoom: policyRooms?.[policy.id]?.adminRoom ?? policy.chatReportIDAdmins?.toString(), + announceRoom: policyRooms?.[policy.id]?.announceRoom ?? policy.chatReportIDAnnounce?.toString(), ownerAccountID: policy.ownerAccountID, role: policy.role, type: policy.type, diff --git a/src/pages/workspace/bills/WorkspaceBillsFirstSection.js b/src/pages/workspace/bills/WorkspaceBillsFirstSection.tsx similarity index 54% rename from src/pages/workspace/bills/WorkspaceBillsFirstSection.js rename to src/pages/workspace/bills/WorkspaceBillsFirstSection.tsx index 0007c301482b..638ab9d58c31 100644 --- a/src/pages/workspace/bills/WorkspaceBillsFirstSection.js +++ b/src/pages/workspace/bills/WorkspaceBillsFirstSection.tsx @@ -1,57 +1,48 @@ import Str from 'expensify-common/lib/str'; -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import CopyTextToClipboard from '@components/CopyTextToClipboard'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import Section from '@components/Section'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; -import userPropTypes from '@pages/settings/userPropTypes'; import * as Link from '@userActions/Link'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Session, User} from '@src/types/onyx'; -const propTypes = { - /** The policy ID currently being configured */ - policyID: PropTypes.string.isRequired, - - ...withLocalizePropTypes, - - /* From Onyx */ +type WorkspaceBillsFirstSectionOnyxProps = { /** Session of currently logged in user */ - session: PropTypes.shape({ - /** Email address */ - email: PropTypes.string.isRequired, - }), + session: OnyxEntry; /** Information about the logged in user's account */ - user: userPropTypes, + user: OnyxEntry; }; -const defaultProps = { - session: { - email: null, - }, - user: {}, +type WorkspaceBillsFirstSectionProps = WorkspaceBillsFirstSectionOnyxProps & { + /** The policy ID currently being configured */ + policyID: string; }; -function WorkspaceBillsFirstSection(props) { +function WorkspaceBillsFirstSection({session, policyID, user}: WorkspaceBillsFirstSectionProps) { const styles = useThemeStyles(); - const emailDomain = Str.extractEmailDomain(props.session.email); - const manageYourBillsUrl = `reports?policyID=${props.policyID}&from=all&type=bill&showStates=Open,Processing,Approved,Reimbursed,Archived&isAdvancedFilterMode=true`; + const {translate} = useLocalize(); + + const emailDomain = Str.extractEmailDomain(session?.email ?? ''); + const manageYourBillsUrl = `reports?policyID=${policyID}&from=all&type=bill&showStates=Open,Processing,Approved,Reimbursed,Archived&isAdvancedFilterMode=true`; + return (

Link.openOldDotLink(manageYourBillsUrl), icon: Expensicons.Bill, shouldShowRightIcon: true, @@ -60,40 +51,35 @@ function WorkspaceBillsFirstSection(props) { link: () => Link.buildOldDotURL(manageYourBillsUrl), }, ]} - containerStyles={[styles.cardSectionContainer]} + containerStyles={styles.cardSectionContainer} > - + - {props.translate('workspace.bills.askYourVendorsBeforeEmail')} - {props.user.isFromPublicDomain ? ( + {translate('workspace.bills.askYourVendorsBeforeEmail')} + {user?.isFromPublicDomain ? ( Link.openExternalLink('https://community.expensify.com/discussion/7500/how-to-pay-your-company-bills-in-expensify/')}> example.com@expensify.cash ) : ( )} - {props.translate('workspace.bills.askYourVendorsAfterEmail')} + {translate('workspace.bills.askYourVendorsAfterEmail')}
); } -WorkspaceBillsFirstSection.propTypes = propTypes; -WorkspaceBillsFirstSection.defaultProps = defaultProps; WorkspaceBillsFirstSection.displayName = 'WorkspaceBillsFirstSection'; -export default compose( - withLocalize, - withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, - user: { - key: ONYXKEYS.USER, - }, - }), -)(WorkspaceBillsFirstSection); +export default withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, + user: { + key: ONYXKEYS.USER, + }, +})(WorkspaceBillsFirstSection); diff --git a/src/pages/workspace/bills/WorkspaceBillsNoVBAView.js b/src/pages/workspace/bills/WorkspaceBillsNoVBAView.tsx similarity index 53% rename from src/pages/workspace/bills/WorkspaceBillsNoVBAView.js rename to src/pages/workspace/bills/WorkspaceBillsNoVBAView.tsx index 01a054df94ce..3503d8565a39 100644 --- a/src/pages/workspace/bills/WorkspaceBillsNoVBAView.js +++ b/src/pages/workspace/bills/WorkspaceBillsNoVBAView.tsx @@ -1,45 +1,43 @@ -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; import ConnectBankAccountButton from '@components/ConnectBankAccountButton'; import * as Illustrations from '@components/Icon/Illustrations'; import Section from '@components/Section'; import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import WorkspaceBillsFirstSection from './WorkspaceBillsFirstSection'; -const propTypes = { +type WorkspaceBillsNoVBAViewProps = { /** The policy ID currently being configured */ - policyID: PropTypes.string.isRequired, - - ...withLocalizePropTypes, + policyID: string; }; -function WorkspaceBillsNoVBAView(props) { +function WorkspaceBillsNoVBAView({policyID}: WorkspaceBillsNoVBAViewProps) { const styles = useThemeStyles(); + const {translate} = useLocalize(); + return ( <> - +
- - {props.translate('workspace.bills.unlockNoVBACopy')} + + {translate('workspace.bills.unlockNoVBACopy')}
); } -WorkspaceBillsNoVBAView.propTypes = propTypes; WorkspaceBillsNoVBAView.displayName = 'WorkspaceBillsNoVBAView'; -export default withLocalize(WorkspaceBillsNoVBAView); +export default WorkspaceBillsNoVBAView; diff --git a/src/pages/workspace/bills/WorkspaceBillsPage.js b/src/pages/workspace/bills/WorkspaceBillsPage.tsx similarity index 60% rename from src/pages/workspace/bills/WorkspaceBillsPage.js rename to src/pages/workspace/bills/WorkspaceBillsPage.tsx index 3a23c3f17f47..85cceb29b661 100644 --- a/src/pages/workspace/bills/WorkspaceBillsPage.js +++ b/src/pages/workspace/bills/WorkspaceBillsPage.tsx @@ -1,40 +1,32 @@ -import PropTypes from 'prop-types'; +import type {StackScreenProps} from '@react-navigation/stack'; import React from 'react'; import {View} from 'react-native'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import type {CentralPaneNavigatorParamList} from '@navigation/types'; import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; import WorkspaceBillsNoVBAView from './WorkspaceBillsNoVBAView'; import WorkspaceBillsVBAView from './WorkspaceBillsVBAView'; -const propTypes = { - /** The route object passed to this page from the navigator */ - route: PropTypes.shape({ - /** Each parameter passed via the URL */ - params: PropTypes.shape({ - /** The policyID that is being configured */ - policyID: PropTypes.string.isRequired, - }).isRequired, - }).isRequired, +type WorkspaceBillsPageProps = StackScreenProps; - ...withLocalizePropTypes, -}; - -function WorkspaceBillsPage(props) { +function WorkspaceBillsPage({route}: WorkspaceBillsPageProps) { + const {translate} = useLocalize(); const styles = useThemeStyles(); const {isSmallScreenWidth} = useWindowDimensions(); return ( - {(hasVBA, policyID) => ( + {(hasVBA: boolean, policyID: string) => ( {!hasVBA && } {hasVBA && } @@ -44,6 +36,6 @@ function WorkspaceBillsPage(props) { ); } -WorkspaceBillsPage.propTypes = propTypes; WorkspaceBillsPage.displayName = 'WorkspaceBillsPage'; -export default withLocalize(WorkspaceBillsPage); + +export default WorkspaceBillsPage; diff --git a/src/pages/workspace/bills/WorkspaceBillsVBAView.js b/src/pages/workspace/bills/WorkspaceBillsVBAView.tsx similarity index 60% rename from src/pages/workspace/bills/WorkspaceBillsVBAView.js rename to src/pages/workspace/bills/WorkspaceBillsVBAView.tsx index 467f3583b7ba..4709061b8567 100644 --- a/src/pages/workspace/bills/WorkspaceBillsVBAView.js +++ b/src/pages/workspace/bills/WorkspaceBillsVBAView.tsx @@ -1,37 +1,36 @@ -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import Section from '@components/Section'; import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Link from '@userActions/Link'; import WorkspaceBillsFirstSection from './WorkspaceBillsFirstSection'; -const propTypes = { +type WorkspaceBillsVBAViewProps = { /** The policy ID currently being configured */ - policyID: PropTypes.string.isRequired, - - ...withLocalizePropTypes, + policyID: string; }; -function WorkspaceBillsVBAView(props) { +function WorkspaceBillsVBAView({policyID}: WorkspaceBillsVBAViewProps) { const styles = useThemeStyles(); - const reportsUrl = `reports?policyID=${props.policyID}&from=all&type=bill&showStates=Processing,Approved&isAdvancedFilterMode=true`; + const {translate} = useLocalize(); + + const reportsUrl = `reports?policyID=${policyID}&from=all&type=bill&showStates=Processing,Approved&isAdvancedFilterMode=true`; return ( <> - +
Link.openOldDotLink(reportsUrl), icon: Expensicons.Bill, shouldShowRightIcon: true, @@ -41,15 +40,14 @@ function WorkspaceBillsVBAView(props) { }, ]} > - - {props.translate('workspace.bills.VBACopy')} + + {translate('workspace.bills.VBACopy')}
); } -WorkspaceBillsVBAView.propTypes = propTypes; WorkspaceBillsVBAView.displayName = 'WorkspaceBillsVBAView'; -export default withLocalize(WorkspaceBillsVBAView); +export default WorkspaceBillsVBAView; diff --git a/src/pages/workspace/invoices/WorkspaceInvoicesPage.tsx b/src/pages/workspace/invoices/WorkspaceInvoicesPage.tsx index 8885d9b1e3af..96aa350496b5 100644 --- a/src/pages/workspace/invoices/WorkspaceInvoicesPage.tsx +++ b/src/pages/workspace/invoices/WorkspaceInvoicesPage.tsx @@ -1,18 +1,17 @@ -import type {RouteProp} from '@react-navigation/native'; +import type {StackScreenProps} from '@react-navigation/stack'; import React from 'react'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import type {CentralPaneNavigatorParamList} from '@navigation/types'; import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; import WorkspaceInvoicesNoVBAView from './WorkspaceInvoicesNoVBAView'; import WorkspaceInvoicesVBAView from './WorkspaceInvoicesVBAView'; -/** Defined route object that contains the policyID param, WorkspacePageWithSections is a common component for Workspaces and expect the route prop that includes the policyID */ -type WorkspaceInvoicesPageProps = { - route: RouteProp<{params: {policyID: string}}>; -}; +type WorkspaceInvoicesPageProps = StackScreenProps; function WorkspaceInvoicesPage({route}: WorkspaceInvoicesPageProps) { const {translate} = useLocalize(); diff --git a/src/pages/workspace/withPolicy.tsx b/src/pages/workspace/withPolicy.tsx index ec38b61fb0dc..8764412c87ad 100644 --- a/src/pages/workspace/withPolicy.tsx +++ b/src/pages/workspace/withPolicy.tsx @@ -5,13 +5,17 @@ import type {ComponentType, ForwardedRef, RefAttributes} from 'react'; import React, {forwardRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import type {BottomTabNavigatorParamList, CentralPaneNavigatorParamList, SettingsNavigatorParamList} from '@navigation/types'; import policyMemberPropType from '@pages/policyMemberPropType'; import * as Policy from '@userActions/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; -type PolicyRoute = RouteProp<{params: {policyID: string}}>; +type WorkspaceParamList = BottomTabNavigatorParamList & CentralPaneNavigatorParamList & SettingsNavigatorParamList; +type PolicyRoute = RouteProp>; function getPolicyIDFromRoute(route: PolicyRoute): string { return route?.params?.policyID ?? ''; @@ -131,4 +135,4 @@ export default function (WrappedComponent: } export {policyPropTypes, policyDefaultProps}; -export type {WithPolicyOnyxProps, WithPolicyProps}; +export type {WithPolicyOnyxProps, WithPolicyProps, PolicyRoute}; diff --git a/src/types/onyx/Form.ts b/src/types/onyx/Form.ts index 4312b7adb453..3235f340e723 100644 --- a/src/types/onyx/Form.ts +++ b/src/types/onyx/Form.ts @@ -61,6 +61,8 @@ type WorkspaceSettingsForm = Form<{ name: string; }>; +type ReportFieldEditForm = Form>; + export default Form; export type { @@ -75,4 +77,5 @@ export type { IntroSchoolPrincipalForm, PersonalBankAccountForm, WorkspaceSettingsForm, + ReportFieldEditForm, }; diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 070b91e2d920..98732df57e38 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -284,6 +284,7 @@ export type { OriginalMessageIOU, OriginalMessageCreated, OriginalMessageAddComment, + OriginalMessageChronosOOOList, OriginalMessageSource, OriginalMessageReimbursementDequeued, PaymentMethodType, diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 53347f3318c2..f55b3b797bf0 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -85,13 +85,18 @@ type Policy = { /** Whether the auto reporting is enabled */ autoReporting?: boolean; - /** The scheduled submit frequency set up on the this policy */ + /** The scheduled submit frequency set up on this policy */ autoReportingFrequency?: ValueOf; - /** Whether the scheduled submit is enabled */ + /** @deprecated Whether the scheduled submit is enabled */ isHarvestingEnabled?: boolean; /** Whether the scheduled submit is enabled */ + harvesting?: { + enabled: boolean; + }; + + /** Whether the self approval or submitting is enabled */ isPreventSelfApprovalEnabled?: boolean; /** When the monthly scheduled submit should happen */ @@ -151,8 +156,10 @@ type Policy = { /** When tax tracking is enabled */ isTaxTrackingEnabled?: boolean; + /** ReportID of the admins room for this workspace */ chatReportIDAdmins?: number; + /** ReportID of the announce room for this workspace */ chatReportIDAnnounce?: number; }; diff --git a/src/types/onyx/PolicyReportField.ts b/src/types/onyx/PolicyReportField.ts index a1724a9ff52f..de385070aa25 100644 --- a/src/types/onyx/PolicyReportField.ts +++ b/src/types/onyx/PolicyReportField.ts @@ -19,6 +19,9 @@ type PolicyReportField = { /** Tells if the field is required or not */ deletable: boolean; + /** Value of the field */ + value: string; + /** Options to select from if field is of type dropdown */ values: string[]; }; diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index f3b20c68038e..d26bdd4f282e 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -2,6 +2,7 @@ import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; import type * as OnyxCommon from './OnyxCommon'; import type PersonalDetails from './PersonalDetails'; +import type {PolicyReportField} from './PolicyReportField'; type NotificationPreference = ValueOf; @@ -170,7 +171,7 @@ type Report = { selected?: boolean; /** If the report contains reportFields, save the field id and its value */ - reportFields?: Record; + reportFields?: Record; }; export default Report; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index d742814061d5..939793b3b4a8 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -9,7 +9,17 @@ import type Credentials from './Credentials'; import type Currency from './Currency'; import type CustomStatusDraft from './CustomStatusDraft'; import type Download from './Download'; -import type {AddDebitCardForm, DateOfBirthForm, DisplayNameForm, IKnowATeacherForm, IntroSchoolPrincipalForm, NewRoomForm, PrivateNotesForm, WorkspaceSettingsForm} from './Form'; +import type { + AddDebitCardForm, + DateOfBirthForm, + DisplayNameForm, + IKnowATeacherForm, + IntroSchoolPrincipalForm, + NewRoomForm, + PrivateNotesForm, + ReportFieldEditForm, + WorkspaceSettingsForm, +} from './Form'; import type Form from './Form'; import type FrequentlyUsedEmoji from './FrequentlyUsedEmoji'; import type {FundList} from './Fund'; @@ -152,4 +162,5 @@ export type { IKnowATeacherForm, IntroSchoolPrincipalForm, PrivateNotesForm, + ReportFieldEditForm, }; diff --git a/tests/perf-test/SignInPage.perf-test.tsx b/tests/perf-test/SignInPage.perf-test.tsx index 80964c3c49cd..dde8596fb2ae 100644 --- a/tests/perf-test/SignInPage.perf-test.tsx +++ b/tests/perf-test/SignInPage.perf-test.tsx @@ -18,17 +18,6 @@ import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; -jest.mock('../../src/libs/Navigation/Navigation', () => { - const actualNav = jest.requireActual('../../src/libs/Navigation/Navigation'); - return { - ...actualNav, - navigationRef: { - addListener: () => jest.fn(), - removeListener: () => jest.fn(), - }, - } as typeof Navigation; -}); - const mockedNavigate = jest.fn(); jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); @@ -43,7 +32,10 @@ jest.mock('@react-navigation/native', () => { navigate: jest.fn(), addListener: () => jest.fn(), }), - createNavigationContainerRef: jest.fn(), + createNavigationContainerRef: () => ({ + addListener: () => jest.fn(), + removeListener: () => jest.fn(), + }), } as typeof NativeNavigation; }); diff --git a/tests/unit/ReportActionItemSingleTest.js b/tests/unit/ReportActionItemSingleTest.js index 55cae01c19f1..08fac8e77551 100644 --- a/tests/unit/ReportActionItemSingleTest.js +++ b/tests/unit/ReportActionItemSingleTest.js @@ -1,4 +1,4 @@ -import {cleanup, screen} from '@testing-library/react-native'; +import {cleanup, screen, waitFor} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; import * as LHNTestUtils from '../utils/LHNTestUtils'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -70,7 +70,9 @@ describe('ReportActionItemSingle', () => { const expectedSecondaryIconTestId = 'SvgDefaultAvatar_w Icon'; return setup().then(() => { - expect(screen.getByTestId(expectedSecondaryIconTestId)).toBeDefined(); + waitFor(() => { + expect(screen.getByTestId(expectedSecondaryIconTestId)).toBeDefined(); + }); }); }); diff --git a/tests/utils/LHNTestUtils.js b/tests/utils/LHNTestUtils.js index 6c72558e5df3..04246c1c438a 100644 --- a/tests/utils/LHNTestUtils.js +++ b/tests/utils/LHNTestUtils.js @@ -256,7 +256,9 @@ function getFakePolicy(id = 1, name = 'Workspace-Test-001') { lastModified: 1697323926777105, autoReporting: true, autoReportingFrequency: 'immediate', - isHarvestingEnabled: true, + harvesting: { + enabled: true, + }, autoReportingOffset: 1, isPreventSelfApprovalEnabled: true, submitsTo: 123456, diff --git a/tests/utils/collections/policies.ts b/tests/utils/collections/policies.ts index 8547c171c7a7..4223c7e41941 100644 --- a/tests/utils/collections/policies.ts +++ b/tests/utils/collections/policies.ts @@ -11,7 +11,9 @@ export default function createRandomPolicy(index: number): Policy { autoReporting: randBoolean(), isPolicyExpenseChatEnabled: randBoolean(), autoReportingFrequency: rand(Object.values(CONST.POLICY.AUTO_REPORTING_FREQUENCIES)), - isHarvestingEnabled: randBoolean(), + harvesting: { + enabled: randBoolean(), + }, autoReportingOffset: 1, isPreventSelfApprovalEnabled: randBoolean(), submitsTo: index, diff --git a/web/index.html b/web/index.html index aff3feb87dbb..49ffd0d0a62f 100644 --- a/web/index.html +++ b/web/index.html @@ -111,10 +111,6 @@ transition-property: opacity; } - .splash-logo > svg { - color: #03d47c; - } - .animation { display: flex; }