diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2dfd9348d961..b8de634a489f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -23,9 +23,9 @@ PROPOSAL: diff --git a/.github/actions/composite/setupNode/action.yml b/.github/actions/composite/setupNode/action.yml index cfa3f9fc191e..51fed0a6a26d 100644 --- a/.github/actions/composite/setupNode/action.yml +++ b/.github/actions/composite/setupNode/action.yml @@ -6,6 +6,10 @@ inputs: description: "Indicates if node is set up for hybrid app" required: false default: 'false' + IS_DESKTOP_BUILD: + description: "Indicates if node is set up for desktop app" + required: false + default: 'false' outputs: cache-hit: @@ -41,6 +45,7 @@ runs: key: ${{ runner.os }}-node-modules-${{ hashFiles('Mobile-Expensify/package-lock.json', 'Mobile-Expensify/patches/**') }} - id: cache-desktop-node-modules + if: inputs.IS_DESKTOP_BUILD == 'true' uses: actions/cache@v4 with: path: desktop/node_modules @@ -60,7 +65,7 @@ runs: command: npm ci - name: Install node packages for desktop submodule - if: steps.cache-desktop-node-modules.outputs.cache-hit != 'true' + if: inputs.IS_DESKTOP_BUILD == 'true' && steps.cache-desktop-node-modules.outputs.cache-hit != 'true' uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847 with: timeout_minutes: 30 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0c6ffd4306f7..e7ee3ac4d973 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -301,6 +301,8 @@ jobs: - name: Setup Node uses: ./.github/actions/composite/setupNode + with: + IS_DESKTOP_BUILD: true - name: Decrypt Developer ID Certificate run: cd desktop && gpg --quiet --batch --yes --decrypt --passphrase="$DEVELOPER_ID_SECRET_PASSPHRASE" --output developer_id.p12 developer_id.p12.gpg diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index 869db3d04be7..1bd4282b2830 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -247,6 +247,8 @@ jobs: - name: Setup Node uses: ./.github/actions/composite/setupNode + with: + IS_DESKTOP_BUILD: true - name: Decrypt Developer ID Certificate run: cd desktop && gpg --quiet --batch --yes --decrypt --passphrase="$DEVELOPER_ID_SECRET_PASSPHRASE" --output developer_id.p12 developer_id.p12.gpg diff --git a/.github/workflows/testBuildHybrid.yml b/.github/workflows/testBuildHybrid.yml index d958e0958083..6a8a0d5884bf 100644 --- a/.github/workflows/testBuildHybrid.yml +++ b/.github/workflows/testBuildHybrid.yml @@ -81,7 +81,7 @@ jobs: }); const body = pullRequest.data.body; - const regex = /MOBILE-EXPENSIFY:(?\d+)/; + const regex = /MOBILE-EXPENSIFY:\s*https:\/\/github.com\/Expensify\/Mobile-Expensify\/pull\/(?\d+)/; const found = body.match(regex)?.groups?.prNumber || ""; return found.trim(); diff --git a/README.md b/README.md index 3b55f54bead2..de5c746a964d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ * [Running The Tests](#running-the-tests) * [Debugging](#debugging) * [App Structure and Conventions](#app-structure-and-conventions) +* [HybridApp](#HybridApp) * [Philosophy](#Philosophy) * [Security](#Security) * [Internationalization](#Internationalization) diff --git a/docs/articles/expensify-classic/connect-credit-cards/Assign-Company-Cards.md b/docs/articles/expensify-classic/connect-credit-cards/Assign-Company-Cards.md deleted file mode 100644 index 54bd12ce5c49..000000000000 --- a/docs/articles/expensify-classic/connect-credit-cards/Assign-Company-Cards.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -title: Assign Company Cards -description: How to assign company cards to employees in Expensify once they have been connected or imported ---- - -After connecting or importing your company cards to Expensify, you can assign each card to its respective cardholder. - -# Assign new cards - -If you're assigning cards via CSV upload for the first time, - -1. Hover over **Settings** and click **Domains**. -2. Select the desired domain. -3. Click the card dropdown menu and select the desired feed from the list. -![Click the dropdown located right below the Imported Cards title near the top of the page. Then select a card from the list.](https://help.expensify.com/assets/images/csv-03.png){:width="100%"} - -{:start="4"} -4. Click **Assign New Cards**. - -![Under the Company Cards tab on the left, you'll use the dropdown menu to select a card and beneath that, you'll click Assign New Cards]({{site.url}}/assets/images/CompanyCards_Assign.png){:width="100%"} - -{:start="5"} -5. Enter the employee's email address and/or select it from the dropdown list. *Note: Employees must have an email address under this domain in order to assign a card to them.* -![Below the Assign a Card header, enter or select the employee's email address]({{site.url}}/assets/images/CompanyCards_EmailAssign.png){:width="100%"} - -{:start="6"} -6. Enter the last four digits of the card number and/or select it from the dropdown list. - - If no transactions have been posted on the card, the card number will not appear in the list and you'll need to enter the full card number into the field. Then press ENTER on your keyboard. The field may clear itself after you press ENTER, but you can disregard this and continue to the next step. -7. (Optional) Set the transaction start date. Any transactions that were posted before this date will not be imported into Expensify. If you do not make a selection, it will default to the earliest available transactions from the card. *Note: Expensify can only import data for the time period released by the bank. Most banks only provide a certain amount of historical data, averaging 30-90 days into the past. It's not possible to override the start date the bank has provided via this tool.* -8. Click **Assign**. - -Once assigned, you will see each cardholder associated with their card and the start date listed. The transactions will now be imported to the cardholder's account, where they can add receipts, code the expenses, and submit them for review and approval. - -![Expensify domain assigned cards](https://help.expensify.com/assets/images/ExpensifyHelp_AssignedCard.png){:width="100%"} - -# Upload new expenses for existing assigned cards - -To add new expenses to an existing uploaded and assigned card, - -1. Hover over **Settings** and click **Domains**. -2. Select the desired domain name. -3. Click **Manage/Import CSV**. -![Click Manage/Import CSV located in the top right between the Issue Virtual Card button and the Import Card button.](https://help.expensify.com/assets/images/csv-02.png){:width="100%"} - -{:start="4"} -4. Select the saved layout from the drop-down list. -5. Click **Upload CSV**. -6. Click **Update All Cards** to retrieve the new expenses for the assigned cards. - -# Unassign company cards - -{% include info.html %} -Unassigning a company card will delete any unsubmitted (Open or Unreported) expenses in the cardholder's account. -{% include end-info.html %} - -To unassign a specific card, click the Actions button to the right of the card and click **Unassign**. - -![Click the Actions button to the right of the card and select Unassign.]({{site.url}}/assets/images/CompanyCards_Unassign.png){:width="100%"} - -To completely remove the card connection, unassign every card from the list and then refresh the page. - -*Note: If expenses are Processing and then rejected, they will also be deleted when they're returned to an Open state, as the card they're linked to no longer exists.* - -{% include faq-begin.md %} - -**My Commercial Card Feed is set up. Why is a specific card not coming up when I try to assign it to an employee?** - -Cards will appear in the dropdown when they are activated and have at least one posted transaction. If the card is activated and has been used for a while and you're still not seeing it, reach out to your Account Manager or message concierge@expensify.com for further assistance. - -{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/connect-credit-cards/Configure-Company-Card-Settings.md b/docs/articles/expensify-classic/connect-credit-cards/Configure-Company-Card-Settings.md deleted file mode 100644 index 75580b94f1ad..000000000000 --- a/docs/articles/expensify-classic/connect-credit-cards/Configure-Company-Card-Settings.md +++ /dev/null @@ -1,122 +0,0 @@ ---- -title: Configure Company Card Settings -description: How to customize your company card settings ---- - -Once you’ve imported your company cards via [commercial card feed](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Commercial-Card-Feeds), [direct bank feed](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Direct-Bank-Connections), or [CSV import](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/CSV-Import), the next step is to configure the card settings. - -{% include info.html %} -You must be a Domain Admin to complete this process. -{% include end-info.html %} - -# Configure company card settings - -1. Hover over **Settings** and click **Domains**. -2. Select the desired domain. -3. Click the **Settings** tab located at the top of the Company Cards tab. -![Near the top right, click the Settings tab that is located between the Card List and Reconciliation tabs.](https://help.expensify.com/assets/images/compcard-01.png){:width="100%"} -5. Set the following preferences, then click **Save**. - -## Preferred Workspace - -Setting a preferred Workspace for a company card feed ensures that the imported transactions are added to a report for that Workspace. This is useful when members are on multiple Workspaces and need to ensure their company card expenses are reported to a particular Workspace. - -## Reimbursable preference - -You can control how your employees' company card expenses are flagged for reimbursement: - -- **Force Yes**: All expenses will be marked as reimbursable. Employees cannot change this setting. -- **Force No**: All expenses will be marked as non-reimbursable. Employees cannot change this setting. -- **Do Not Force**: Expenses will default to either reimbursable or non-reimbursable (your choice), but employees can adjust if necessary. - -## Liability type - -Choose the liability type that suits your needs: - -- **Corporate Liability**: Users cannot delete company card expenses. -- **Personal Liability**: Users are allowed to delete company card expenses. - -If you update the settings on an existing company card feed, the changes will apply to expenses imported after the date that the setting is saved. The update will not affect previously imported expenses. - -# Use Scheduled Submit with company cards - -With Scheduled Submit, employees no longer have to create their expenses, add them to a report, and submit them manually. All they need to do is SmartScan their receipts and Concierge will take care of the rest using a variety of schedules that you can set according to your preferences. - -{% include info.html %} -Concierge won't automatically submit expenses on reports that have expense violations. These expenses will be moved to a new report for the current reporting period. -{% include end-info.html %} - -To enable Scheduled Submit, - -1. Hover over **Settings** and click **Workspaces**. -2. Select the desired Workspace. -3. Click the **Reports** tab on the left. -4. Enable the Scheduled Submit toggle. -5. Select the report submission frequency. -6. Select the date that reports will be submitted. - -# Connect company cards to an accounting integration - -If you're using a connected accounting system such as NetSuite, Xero, Intacct, Quickbooks Desktop, or QuickBooks Online, you can also connect the card to export to a specific credit card GL account. First, connect the card itself, and once completed, follow the steps below: - -1. Hover over **Settings** and click **Domains** -2. Select the desired domain. -3. Click **Edit Exports** near the top right and select the general ledger (GL) account you want to export expenses to. -![Find the desired card in the table. In that same row, click Edit Exports.](https://help.expensify.com/assets/images/cardfeeds-02.png){:width="100%"} - -Once the account is set, exported expenses will be mapped to the selected account when exported by a Domain Admin. - -# Export company card expenses to a connected accounting integration - -## Pooled GL account - -To export credit card expenses to a pooled GL account, - -1. Hover over **Settings** and click **Workspaces**. -2. Select the desired Workspace. -3. Click the **Connections** tab on the left. -4. Under Accounting Integrations, click **Configure** next to the desired accounting integration. -5. For Non-reimbursable export, select **Credit Card / Charge Card / Bank Transaction**. -6. Review the Export Settings page for exporting Expense Reports to NetSuite. -7. Select the Vendor/liability account you want to export all non-reimbursable expenses to. - -## Individual GL account - -1. Hover over **Settings** and click **Domains**. -2. Select the desired Domain. -3. Click the **Edit Exports** to the right of the desired card. Then select the general ledger (GL) account you want to export expenses to. -![Find the desired card in the table. In that same row, click Edit Exports.](https://help.expensify.com/assets/images/cardfeeds-02.png){:width="100%"} - -Once the account is set, exported expenses will be mapped to the selected account. - -# Identify company card transactions - -When you link your credit cards to Expensify, the transactions will appear in each user's account on the Expenses page as soon as they're posted. Transactions from centrally managed cards have a locked card icon next to them to indicate that they’re company card expenses. - -# Import historical transactions - -Once a card is connected via direct connection or via Approved! banks, Expensify will import 30-90 days of historical transactions to your account (based on your bank's discretion). Any historical expenses beyond that date range can be imported using the [CSV import](https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/CSV-Import). - -# Use eReceipts - -Expensify eReceipts are digital substitutes for paper receipts, eliminating the need to keep physical receipts or use SmartScan for receipts. For Expensify Card transactions, eReceipts are automatically generated for all amounts in these categories: Airlines, Commuter expenses, Gas, Groceries, Mail, Meals, Car rental, Taxis, and Utilities. For other card programs, eReceipts are generated for USD purchases of $75 or less. - -{% include info.html %} -To ensure seamless automatic importation, it is key that you maintain your transactions in US Dollars. eReceipts can also be directly imported from your bank account. CSV/OFX imported files of bank transactions do not support eReceipts. eReceipts are not generated for lodging expenses. Due to incomplete or inaccurate category information from certain banks, there may be instances of invalid eReceipts being generated for hotel purchases. If you choose to re-categorize expenses, a similar situation may arise. It's crucial to remember that our Expensify eReceipt Guarantee excludes coverage for hotel and motel expenses. -{% include end-info.html %} - -{% include faq-begin.md %} - -**What plan/subscription is required in order to manage corporate cards?** - -A Group Workspace is required. - -**When do my company card transactions import to Expensify?** - -Credit card transactions are imported to Expensify once they’re posted to the bank account. This usually takes 1-3 business days between the point of purchase and when the transactions populate in your account. - -**Scheduled Submit is disabled. Why are reports still being submitted automatically?** - -If Scheduled Submit is disabled at the Group Workspace level or set to a manual frequency but expense reports are still being automatically submitted, Scheduled Submit is most likely enabled on the user’s Individual Workspace settings. - -{% include faq-end.md %} diff --git a/docs/redirects.csv b/docs/redirects.csv index 9ccef010ec96..ab7abe782458 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -624,3 +624,5 @@ https://help.expensify.com/articles/expensify-classic/expensify-card/Request-the https://help.expensify.com/articles/expensify-classic/settings/Change-or-add-email-address,https://help.expensify.com/articles/expensify-classic/settings/Managing-Primary-and-Secondary-Logins-in-Expensify https://help.expensify.com/articles/expensify-classic/domains/SAML-SSO,https://help.expensify.com/articles/expensify-classic/domains/Managing-Single-Sign-On-(SSO)-in-Expensify https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Direct-Bank-Connections,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Connect-Company-Cards +https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Assign-Company-Cards,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Manage-Company-Cards +https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Configure-Company-Card-Settings,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Manage-Company-Cards diff --git a/ios/NewApp_AdHoc_Share_Extension.mobileprovision.gpg b/ios/NewApp_AdHoc_Share_Extension.mobileprovision.gpg new file mode 100644 index 000000000000..c9b3eb213f79 Binary files /dev/null and b/ios/NewApp_AdHoc_Share_Extension.mobileprovision.gpg differ diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b0e84d3d033f..395e6b1c618a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -166,6 +166,7 @@ PODS: - GoogleUtilities/Environment (~> 7.7) - "GoogleUtilities/NSData+zlib (~> 7.7)" - fmt (9.1.0) + - ForkInputMask (7.3.3) - FullStory (1.52.0) - fullstory_react-native (1.7.2): - DoubleConversion @@ -1604,6 +1605,28 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - react-native-advanced-input-mask (1.2.1): + - DoubleConversion + - ForkInputMask (~> 7.3.2) + - glog + - hermes-engine + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - react-native-airship (19.2.1): - AirshipFrameworkProxy (= 7.1.2) - DoubleConversion @@ -2491,7 +2514,7 @@ PODS: - RNGoogleSignin (10.0.1): - GoogleSignIn (~> 7.0) - React-Core - - RNLiveMarkdown (0.1.221): + - RNLiveMarkdown (0.1.223): - DoubleConversion - glog - hermes-engine @@ -2511,10 +2534,10 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/newarch (= 0.1.221) + - RNLiveMarkdown/newarch (= 0.1.223) - RNReanimated/worklets - Yoga - - RNLiveMarkdown/newarch (0.1.221): + - RNLiveMarkdown/newarch (0.1.223): - DoubleConversion - glog - hermes-engine @@ -2880,6 +2903,7 @@ DEPENDENCIES: - React-logger (from `../node_modules/react-native/ReactCommon/logger`) - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) + - react-native-advanced-input-mask (from `../node_modules/react-native-advanced-input-mask`) - "react-native-airship (from `../node_modules/@ua/react-native-airship`)" - react-native-app-logs (from `../node_modules/react-native-app-logs`) - react-native-blob-util (from `../node_modules/react-native-blob-util`) @@ -2968,6 +2992,7 @@ SPEC REPOS: - FirebaseInstallations - FirebasePerformance - FirebaseRemoteConfig + - ForkInputMask - GoogleAppMeasurement - GoogleDataTransport - GoogleSignIn @@ -3093,6 +3118,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon" React-microtasksnativemodule: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + react-native-advanced-input-mask: + :path: "../node_modules/react-native-advanced-input-mask" react-native-airship: :path: "../node_modules/@ua/react-native-airship" react-native-app-logs: @@ -3270,6 +3297,7 @@ SPEC CHECKSUMS: FirebasePerformance: 0c01a7a496657d7cea86d40c0b1725259d164c6c FirebaseRemoteConfig: 2d6e2cfdb49af79535c8af8a80a4a5009038ec2b fmt: 10c6e61f4be25dc963c36bd73fc7b1705fe975be + ForkInputMask: 55e3fbab504b22da98483e9f9a6514b98fdd2f3c FullStory: c8a10b2358c0d33c57be84d16e4c440b0434b33d fullstory_react-native: 63a803cca04b0447a71daa73e4df3f7b56e1919d glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a @@ -3323,6 +3351,7 @@ SPEC CHECKSUMS: React-logger: 26155dc23db5c9038794db915f80bd2044512c2e React-Mapbuffer: ad1ba0205205a16dbff11b8ade6d1b3959451658 React-microtasksnativemodule: e771eb9eb6ace5884ee40a293a0e14a9d7a4343c + react-native-advanced-input-mask: 22e3bd2a0f38fada50b475c98bf39d39053097a3 react-native-airship: e10f6823d8da49bbcb2db4bdb16ff954188afccc react-native-app-logs: ee32b6e80bf8d1b883dfc5ac96efa7c1bd9a06a5 react-native-blob-util: 221c61c98ae507b758472ac4d2d489119d1a6c44 @@ -3383,7 +3412,7 @@ SPEC CHECKSUMS: RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNGestureHandler: 364e6862a112045bb5c5d35601f0bdb0304af979 RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0 - RNLiveMarkdown: 9940212ca19bf54101b585178e691ee040b82c35 + RNLiveMarkdown: 5c76c659b125006ff525a095b65184ecb72392f3 RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 rnmapbox-maps: d184c8d3213acf4c97ec71fbbb6f9d4954552d80 RNPermissions: 0b1429b55af59d1d08b75a8be2459f65a8ac3f28 diff --git a/package-lock.json b/package-lock.json index b5c7a747a48b..a8ebb84643b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@dotlottie/react-player": "^1.6.3", "@expensify/react-native-background-task": "file:./modules/background-task", - "@expensify/react-native-live-markdown": "0.1.221", + "@expensify/react-native-live-markdown": "0.1.223", "@expo/metro-runtime": "^4.0.0", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -79,6 +79,7 @@ "react-fast-pdf": "^1.0.22", "react-map-gl": "^7.1.3", "react-native": "0.76.3", + "react-native-advanced-input-mask": "1.2.1", "react-native-android-location-enabler": "^2.0.1", "react-native-app-logs": "0.3.1", "react-native-blob-util": "0.19.4", @@ -3641,9 +3642,9 @@ "link": true }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.221", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.221.tgz", - "integrity": "sha512-2CeBE1LsNvslaqYmPlf1hsl5gqG3eMsn+7jUSAZ4YmQqz1iLKJn+ryQVE4Rl0eLeeikWDlKvqX9isQHgKofLgw==", + "version": "0.1.223", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.223.tgz", + "integrity": "sha512-rE5cQ9lBDP2tqtR4Tta3PNx2i5K83sdht1meYMvmLPqFVy7C9A743wzZe6oudVnhSDem8MbU4NMJStadp9xn6Q==", "license": "MIT", "workspaces": [ "./example", @@ -32027,6 +32028,23 @@ } } }, + "node_modules/react-native-advanced-input-mask": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/react-native-advanced-input-mask/-/react-native-advanced-input-mask-1.2.1.tgz", + "integrity": "sha512-qXK6l8f5zOLrWxhrtA2od4R2UsV8OEcvFlZlX5VTp3sB/JlHW/iJd15m8Rgn/mcJFfvnKlHmVVHJefDrUOJFvA==", + "license": "MIT", + "workspaces": [ + "example", + "WebExample" + ], + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-android-location-enabler": { "version": "2.0.1", "license": "MIT", diff --git a/package.json b/package.json index a8fda427668a..986ffa5a8a00 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "dependencies": { "@dotlottie/react-player": "^1.6.3", "@expensify/react-native-background-task": "file:./modules/background-task", - "@expensify/react-native-live-markdown": "0.1.221", + "@expensify/react-native-live-markdown": "0.1.223", "@expo/metro-runtime": "^4.0.0", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -144,6 +144,7 @@ "react-fast-pdf": "^1.0.22", "react-map-gl": "^7.1.3", "react-native": "0.76.3", + "react-native-advanced-input-mask": "1.2.1", "react-native-android-location-enabler": "^2.0.1", "react-native-app-logs": "0.3.1", "react-native-blob-util": "0.19.4", diff --git a/scripts/run-build.sh b/scripts/run-build.sh index 70e0dcf7c586..0abbd4530adf 100755 --- a/scripts/run-build.sh +++ b/scripts/run-build.sh @@ -38,6 +38,9 @@ NEW_DOT_FLAG="${STANDALONE_NEW_DOT:-false}" SCHEME="Expensify Dev" APP_ID="org.me.mobiexpensifyg.dev" + # Build Yapl JS + cd Mobile-Expensify && npm run grunt:build:shared && cd .. + echo -e "\n${GREEN}Starting a HybridApp build!${NC}" PROJECT_ROOT_PATH="Mobile-Expensify/" export CUSTOM_APK_NAME="Expensify-debug.apk" diff --git a/src/components/AmountWithoutCurrencyInput.tsx b/src/components/AmountWithoutCurrencyInput.tsx new file mode 100644 index 000000000000..4d54258dbef0 --- /dev/null +++ b/src/components/AmountWithoutCurrencyInput.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import type {ForwardedRef} from 'react'; +import CONST from '@src/CONST'; +import TextInput from './TextInput'; +import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types'; + +type AmountFormProps = { + /** Amount supplied by the FormProvider */ + value?: string; + + /** Callback to update the amount in the FormProvider */ + onInputChange?: (value: string) => void; + + /** Should we allow negative number as valid input */ + shouldAllowNegative?: boolean; +} & Partial; + +function AmountWithoutCurrencyInput( + {value: amount, shouldAllowNegative = false, inputID, name, defaultValue, accessibilityLabel, role, label, ...rest}: AmountFormProps, + ref: ForwardedRef, +) { + return ( + + ); +} + +AmountWithoutCurrencyInput.displayName = 'AmountWithoutCurrencyForm'; + +export default React.forwardRef(AmountWithoutCurrencyInput); diff --git a/src/components/BrokenConnectionDescription.tsx b/src/components/BrokenConnectionDescription.tsx index 078cbed25631..ed5ecf41078a 100644 --- a/src/components/BrokenConnectionDescription.tsx +++ b/src/components/BrokenConnectionDescription.tsx @@ -1,13 +1,12 @@ import React from 'react'; import type {OnyxEntry} from 'react-native-onyx'; -import {useOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import {isInstantSubmitEnabled, isPolicyAdmin as isPolicyAdminPolicyUtils} from '@libs/PolicyUtils'; import {isCurrentUserSubmitter, isProcessingReport, isReportApproved, isReportManuallyReimbursed} from '@libs/ReportUtils'; +import {getTransactionViolations} from '@libs/TransactionUtils'; import Navigation from '@navigation/Navigation'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Policy, Report} from '@src/types/onyx'; import TextLink from './TextLink'; @@ -15,7 +14,6 @@ import TextLink from './TextLink'; type BrokenConnectionDescriptionProps = { /** Transaction id of the corresponding report */ transactionID: string | undefined; - /** Current report */ report: OnyxEntry; @@ -26,7 +24,7 @@ type BrokenConnectionDescriptionProps = { function BrokenConnectionDescription({transactionID, policy, report}: BrokenConnectionDescriptionProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const [transactionViolations] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID ?? CONST.DEFAULT_NUMBER_ID}`); + const transactionViolations = getTransactionViolations(transactionID); const brokenConnection530Error = transactionViolations?.find((violation) => violation.data?.rterType === CONST.RTER_VIOLATION_TYPES.BROKEN_CARD_CONNECTION_530); const brokenConnectionError = transactionViolations?.find((violation) => violation.data?.rterType === CONST.RTER_VIOLATION_TYPES.BROKEN_CARD_CONNECTION); @@ -46,7 +44,12 @@ function BrokenConnectionDescription({transactionID, policy, report}: BrokenConn {`${translate('violations.adminBrokenConnectionError')}`} Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policy?.id))} + onPress={() => { + if (!policy?.id) { + return; + } + Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policy?.id)); + }} >{`${translate('workspace.common.companyCards')}`} . diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index 0b84f0034035..bf3746b61776 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -326,7 +326,8 @@ function FormProvider( value: inputValues[inputID], // As the text input is controlled, we never set the defaultValue prop // as this is already happening by the value prop. - defaultValue: undefined, + // If it's uncontrolled, then we set the `defaultValue` prop to actual value + defaultValue: inputProps.uncontrolled ? inputProps.defaultValue : undefined, onTouched: (event) => { if (!inputProps.shouldSetTouchedOnBlurOnly) { setTimeout(() => { diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index d6bcc28e09bf..02cc4e899b32 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -118,6 +118,7 @@ type InputComponentBaseProps = Input autoGrowHeight?: boolean; blurOnSubmit?: boolean; shouldSubmitForm?: boolean; + uncontrolled?: boolean; }; type FormOnyxValues = Omit; diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index 268ab770059e..0d0e862f36f5 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -8,6 +8,7 @@ import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {markAsCash as markAsCashUtil} from '@libs/actions/Transaction'; import Navigation from '@libs/Navigation/Navigation'; import {isPolicyAdmin} from '@libs/PolicyUtils'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; @@ -25,7 +26,6 @@ import { shouldShowBrokenConnectionViolation as shouldShowBrokenConnectionViolationTransactionUtils, } from '@libs/TransactionUtils'; import variables from '@styles/variables'; -import {markAsCash as markAsCashAction} from '@userActions/Transaction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -61,13 +61,12 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const route = useRoute(); - const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID ?? CONST.DEFAULT_NUMBER_ID}`); const [transaction] = useOnyx( `${ONYXKEYS.COLLECTION.TRANSACTION}${ isMoneyRequestAction(parentReportAction) ? getOriginalMessage(parentReportAction)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID : CONST.DEFAULT_NUMBER_ID }`, ); - const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [dismissedHoldUseExplanation, dismissedHoldUseExplanationResult] = useOnyx(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, {initialValue: true}); const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA); const isLoadingHoldUseExplained = isLoadingOnyxValue(dismissedHoldUseExplanationResult); @@ -88,7 +87,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre const shouldShowMarkAsCashButton = hasAllPendingRTERViolations || (shouldShowBrokenConnectionViolation && (!isPolicyAdmin(policy) || isCurrentUserSubmitter(parentReport?.reportID))); const markAsCash = useCallback(() => { - markAsCashAction(transaction?.transactionID, reportID); + markAsCashUtil(transaction?.transactionID, reportID); }, [reportID, transaction?.transactionID]); const isScanning = hasReceipt(transaction) && isReceiptBeingScanned(transaction); @@ -122,7 +121,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre ), }; } - if (hasPendingRTERViolation(getTransactionViolations(transaction?.transactionID, transactionViolations))) { + if (hasPendingRTERViolation(getTransactionViolations(transaction?.transactionID))) { return {icon: getStatusIcon(Expensicons.Hourglass), description: translate('iou.pendingMatchWithCreditCardDescription')}; } if (isScanning) { diff --git a/src/components/RNMaskedTextInput.tsx b/src/components/RNMaskedTextInput.tsx new file mode 100644 index 000000000000..22a69d2c7fbd --- /dev/null +++ b/src/components/RNMaskedTextInput.tsx @@ -0,0 +1,39 @@ +import type {ForwardedRef} from 'react'; +import React from 'react'; +import type {TextInput} from 'react-native'; +import type {MaskedTextInputProps} from 'react-native-advanced-input-mask'; +import {MaskedTextInput} from 'react-native-advanced-input-mask'; +import Animated from 'react-native-reanimated'; +import useTheme from '@hooks/useTheme'; + +// Convert the underlying TextInput into an Animated component so that we can take an animated ref and pass it to a worklet +const AnimatedTextInput = Animated.createAnimatedComponent(MaskedTextInput); + +type AnimatedTextInputRef = typeof AnimatedTextInput & TextInput & HTMLInputElement; + +function RNMaskedTextInputWithRef(props: MaskedTextInputProps, ref: ForwardedRef) { + const theme = useTheme(); + + return ( + { + if (typeof ref !== 'function') { + return; + } + ref(refHandle as AnimatedTextInputRef); + }} + // eslint-disable-next-line + {...props} + /> + ); +} + +RNMaskedTextInputWithRef.displayName = 'RNMaskedTextInputWithRef'; + +export default React.forwardRef(RNMaskedTextInputWithRef); +export type {AnimatedTextInputRef}; diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index e20fe09058e1..c70bda657e67 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -112,7 +112,8 @@ function MoneyRequestPreviewContent({ const transactionID = isMoneyRequestAction ? getOriginalMessage(action)?.IOUTransactionID : undefined; const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); const [walletTerms] = useOnyx(ONYXKEYS.WALLET_TERMS); - const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [allViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const transactionViolations = getTransactionViolations(transaction?.transactionID); const sessionAccountID = session?.accountID; const managerID = iouReport?.managerID ?? CONST.DEFAULT_NUMBER_ID; @@ -145,9 +146,9 @@ function MoneyRequestPreviewContent({ const isOnHold = isOnHoldTransactionUtils(transaction); const isSettlementOrApprovalPartial = !!iouReport?.pendingFields?.partial; const isPartialHold = isSettlementOrApprovalPartial && isOnHold; - const hasViolations = hasViolationTransactionUtils(transaction?.transactionID, transactionViolations, true); - const hasNoticeTypeViolations = hasNoticeTypeViolationTransactionUtils(transaction?.transactionID, transactionViolations, true) && isPaidGroupPolicy(iouReport); - const hasWarningTypeViolations = hasWarningTypeViolationTransactionUtils(transaction?.transactionID, transactionViolations, true); + const hasViolations = hasViolationTransactionUtils(transaction?.transactionID, allViolations, true); + const hasNoticeTypeViolations = hasNoticeTypeViolationTransactionUtils(transaction?.transactionID, allViolations, true) && isPaidGroupPolicy(iouReport); + const hasWarningTypeViolations = hasWarningTypeViolationTransactionUtils(transaction?.transactionID, allViolations, true); const hasFieldErrors = hasMissingSmartscanFields(transaction); const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); const isPerDiemRequest = isPerDiemRequestTransactionUtils(transaction); @@ -163,11 +164,8 @@ function MoneyRequestPreviewContent({ // Get transaction violations for given transaction id from onyx, find duplicated transactions violations and get duplicates const allDuplicates = useMemo( - () => - transactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction?.transactionID}`]?.find( - (violation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION, - )?.data?.duplicates ?? [], - [transaction?.transactionID, transactionViolations], + () => transactionViolations?.find((violation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION)?.data?.duplicates ?? [], + [transactionViolations], ); // Remove settled transactions from duplicates @@ -240,14 +238,13 @@ function MoneyRequestPreviewContent({ } if (shouldShowRBR && transaction) { - const violations = getTransactionViolations(transaction.transactionID, transactionViolations); if (shouldShowHoldMessage) { return `${message} ${CONST.DOT_SEPARATOR} ${translate('violations.hold')}`; } - const firstViolation = violations?.at(0); + const firstViolation = transactionViolations?.at(0); if (firstViolation) { const violationMessage = ViolationsUtils.getViolationTranslation(firstViolation, translate); - const violationsCount = violations?.filter((v) => v.type === CONST.VIOLATION_TYPES.VIOLATION).length ?? 0; + const violationsCount = transactionViolations?.filter((v) => v.type === CONST.VIOLATION_TYPES.VIOLATION).length ?? 0; const isTooLong = violationsCount > 1 || violationMessage.length > 15; const hasViolationsAndFieldErrors = violationsCount > 0 && hasFieldErrors; @@ -287,7 +284,7 @@ function MoneyRequestPreviewContent({ if (shouldShowBrokenConnectionViolation(transaction ? [transaction.transactionID] : [], iouReport, policy)) { return {shouldShow: true, messageIcon: Expensicons.Hourglass, messageDescription: translate('violations.brokenConnection530Error')}; } - if (hasPendingUI(transaction, getTransactionViolations(transaction?.transactionID, transactionViolations))) { + if (hasPendingUI(transaction, transactionViolations)) { return {shouldShow: true, messageIcon: Expensicons.Hourglass, messageDescription: translate('iou.pendingMatchWithCreditCard')}; } return {shouldShow: false}; diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 10c72dbd8841..54f3a8b6907f 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -49,6 +49,7 @@ import { getDistanceInMeters, getTagForDisplay, getTaxName, + getTransactionViolations, hasMissingSmartscanFields, hasReceipt as hasReceiptTransactionUtils, hasReservationList, @@ -133,7 +134,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${linkedTransactionID ?? CONST.DEFAULT_NUMBER_ID}`); const [transactionBackup] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${linkedTransactionID ?? CONST.DEFAULT_NUMBER_ID}`); - const [transactionViolations] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${linkedTransactionID ?? CONST.DEFAULT_NUMBER_ID}`); + const transactionViolations = getTransactionViolations(linkedTransactionID); const { created: transactionDate, @@ -698,7 +699,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals {shouldShowTax && ( ({...getThumbnailAndImageURIs(transaction), transaction})); const transactionIDList = transactions?.map((reportTransaction) => reportTransaction.transactionID) ?? []; - const showRTERViolationMessage = numberOfRequests === 1 && hasPendingUI(lastTransaction, getTransactionViolations(lastTransaction?.transactionID, transactionViolations)); + const showRTERViolationMessage = numberOfRequests === 1 && hasPendingUI(lastTransaction, getTransactionViolations(lastTransaction?.transactionID)); const shouldShowBrokenConnectionViolation = numberOfRequests === 1 && shouldShowBrokenConnectionViolationTransactionUtils(transactionIDList, iouReport, policy); let formattedMerchant = numberOfRequests === 1 ? getMerchant(lastTransaction) : null; const formattedDescription = numberOfRequests === 1 ? getDescription(lastTransaction) : null; @@ -250,7 +250,7 @@ function ReportPreview({ const isArchived = isArchivedReportWithID(iouReport?.reportID); const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; const filteredTransactions = transactions?.filter((transaction) => transaction) ?? []; - const shouldShowSubmitButton = canSubmitReport(iouReport, policy, filteredTransactions, transactionViolations); + const shouldShowSubmitButton = canSubmitReport(iouReport, policy, filteredTransactions); const shouldDisableSubmitButton = shouldShowSubmitButton && !isAllowedToSubmitDraftExpenseReport(iouReport); diff --git a/src/components/Search/SearchAutocompleteInput.tsx b/src/components/Search/SearchAutocompleteInput.tsx index ae18442745de..2fea54f5d0ce 100644 --- a/src/components/Search/SearchAutocompleteInput.tsx +++ b/src/components/Search/SearchAutocompleteInput.tsx @@ -138,7 +138,7 @@ function SearchAutocompleteInput( isLoading={!!isSearchingForReports} ref={ref} onKeyPress={handleKeyPress(onSubmit)} - isMarkdownEnabled + type="markdown" multiline={false} parser={(input: string) => { 'worklet'; diff --git a/src/components/TextInput/BaseTextInput/implementations.ts b/src/components/TextInput/BaseTextInput/implementations.ts new file mode 100644 index 000000000000..80b91ace7cff --- /dev/null +++ b/src/components/TextInput/BaseTextInput/implementations.ts @@ -0,0 +1,14 @@ +import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; +import RNMaskedTextInput from '@components/RNMaskedTextInput'; +import RNTextInput from '@components/RNTextInput'; +import type {BaseTextInputProps, InputType} from './types'; + +type InputComponentType = React.ComponentType; + +const InputComponentMap = new Map([ + ['default', RNTextInput], + ['mask', RNMaskedTextInput as InputComponentType], + ['markdown', RNMarkdownTextInput], +]); + +export default InputComponentMap; diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/index.native.tsx index 8d1800ce3b65..af537a981cee 100644 --- a/src/components/TextInput/BaseTextInput/index.native.tsx +++ b/src/components/TextInput/BaseTextInput/index.native.tsx @@ -10,7 +10,6 @@ import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; -import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; import RNTextInput from '@components/RNTextInput'; import Text from '@components/Text'; @@ -26,6 +25,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import isInputAutoFilled from '@libs/isInputAutoFilled'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import InputComponentMap from './implementations'; import type {BaseTextInputProps, BaseTextInputRef} from './types'; function BaseTextInput( @@ -62,7 +62,7 @@ function BaseTextInput( prefixCharacter = '', suffixCharacter = '', inputID, - isMarkdownEnabled = false, + type = 'default', excludedMarkdownStyles = [], shouldShowClearButton = false, prefixContainerStyle = [], @@ -71,11 +71,13 @@ function BaseTextInput( suffixStyle = [], contentWidth, loadingSpinnerStyle, + uncontrolled, ...props }: BaseTextInputProps, ref: ForwardedRef, ) { - const InputComponent = isMarkdownEnabled ? RNMarkdownTextInput : RNTextInput; + const InputComponent = InputComponentMap.get(type) ?? RNTextInput; + const isMarkdownEnabled = type === 'markdown'; const isAutoGrowHeightMarkdown = isMarkdownEnabled && autoGrowHeight; const inputProps = {shouldSaveDraft: false, shouldUseDefaultValue: false, ...props}; @@ -379,7 +381,7 @@ function BaseTextInput( showSoftInputOnFocus={!disableKeyboard} keyboardType={inputProps.keyboardType} inputMode={!disableKeyboard ? inputProps.inputMode : CONST.INPUT_MODE.NONE} - value={value} + value={uncontrolled ? undefined : value} selection={inputProps.selection} readOnly={isReadOnly} defaultValue={defaultValue} diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index 7c16345f0709..ec9f7a7249c0 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -10,7 +10,6 @@ import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; -import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; import RNTextInput from '@components/RNTextInput'; import SwipeInterceptPanResponder from '@components/SwipeInterceptPanResponder'; @@ -29,6 +28,7 @@ import {scrollToRight} from '@libs/InputUtils'; import isInputAutoFilled from '@libs/isInputAutoFilled'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import InputComponentMap from './implementations'; import type {BaseTextInputProps, BaseTextInputRef} from './types'; function BaseTextInput( @@ -65,7 +65,7 @@ function BaseTextInput( prefixCharacter = '', suffixCharacter = '', inputID, - isMarkdownEnabled = false, + type = 'default', excludedMarkdownStyles = [], shouldShowClearButton = false, shouldUseDisabledStyles = true, @@ -75,11 +75,13 @@ function BaseTextInput( suffixStyle = [], contentWidth, loadingSpinnerStyle, + uncontrolled = false, ...inputProps }: BaseTextInputProps, ref: ForwardedRef, ) { - const InputComponent = isMarkdownEnabled ? RNMarkdownTextInput : RNTextInput; + const InputComponent = InputComponentMap.get(type) ?? RNTextInput; + const isMarkdownEnabled = type === 'markdown'; const isAutoGrowHeightMarkdown = isMarkdownEnabled && autoGrowHeight; const theme = useTheme(); @@ -382,7 +384,7 @@ function BaseTextInput( onPressOut={inputProps.onPress} showSoftInputOnFocus={!disableKeyboard} inputMode={inputProps.inputMode} - value={value} + value={uncontrolled ? undefined : value} selection={inputProps.selection} readOnly={isReadOnly} defaultValue={defaultValue} diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index af4631604579..105d48625131 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -1,8 +1,10 @@ import type {MarkdownRange, MarkdownStyle} from '@expensify/react-native-live-markdown'; import type {GestureResponderEvent, StyleProp, TextInputProps, TextStyle, ViewStyle} from 'react-native'; +import type {MaskedTextInputOwnProps} from 'react-native-advanced-input-mask/lib/typescript/src/types'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; import type IconAsset from '@src/types/utils/IconAsset'; +type InputType = 'markdown' | 'mask' | 'default'; type CustomBaseTextInputProps = { /** Input label */ label?: string; @@ -116,12 +118,12 @@ type CustomBaseTextInputProps = { /** Type of autocomplete */ autoCompleteType?: string; - /** Should live markdown be enabled. Changes RNTextInput component to RNMarkdownTextInput */ - isMarkdownEnabled?: boolean; - /** List of markdowns that won't be styled as a markdown */ excludedMarkdownStyles?: Array; + /** A set of styles for markdown elements (such as link, h1, emoji etc.) */ + markdownStyle?: MarkdownStyle; + /** Custom parser function for RNMarkdownTextInput */ parser?: (input: string) => MarkdownRange[]; @@ -148,10 +150,22 @@ type CustomBaseTextInputProps = { /** The width of inner content */ contentWidth?: number; + + /** The type (internal implementation) of input. Can be one of: `default`, `mask`, `markdown` */ + type?: InputType; + + /** The mask of the masked input */ + mask?: MaskedTextInputOwnProps['mask']; + + /** A set of permitted characters for the input */ + allowedKeys?: MaskedTextInputOwnProps['allowedKeys']; + + /** Whether the input should be enforced to be uncontrolled. Default is `false` */ + uncontrolled?: boolean; }; type BaseTextInputRef = HTMLFormElement | AnimatedTextInputRef; type BaseTextInputProps = CustomBaseTextInputProps & TextInputProps; -export type {BaseTextInputProps, BaseTextInputRef, CustomBaseTextInputProps}; +export type {BaseTextInputProps, BaseTextInputRef, CustomBaseTextInputProps, InputType}; diff --git a/src/languages/es.ts b/src/languages/es.ts index 60174deb1a50..71690615ac5c 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4532,8 +4532,8 @@ const translations = { benefit2: 'Reglas inteligentes de gastos', benefit3: 'Flujos de aprobación de varios niveles', benefit4: 'Controles de seguridad mejorados', - toUpgrade: 'Para mejorar, haz clic', - selectWorkspace: 'selecciona un espacio de trabajo y cambia el tipo de plan a Controlar.', + toUpgrade: 'Para mejorar, haz clic en', + selectWorkspace: 'selecciona un espacio de trabajo y cambia el tipo de plan a', }, }, }, @@ -4552,8 +4552,8 @@ const translations = { benefit4: 'Controles de seguridad mejorados', headsUp: '¡Atención!', multiWorkspaceNote: - 'Tendrás que bajar de categoría todos tus espacios de trabajo antes de tu primer pago mensual para comenzar una suscripción con la tasa del plan Recopilar. Haz clic', - selectStep: '> selecciona cada espacio de trabajo > cambia el tipo de plan a Recopilar.', + 'Tendrás que bajar de categoría todos tus espacios de trabajo antes de tu primer pago mensual para comenzar una suscripción con la tasa del plan Recopilar. Haz clic en', + selectStep: '> selecciona cada espacio de trabajo > cambia el tipo de plan a', }, }, completed: { diff --git a/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts b/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts index cd5beea8fa3a..4b1d40dadaa3 100644 --- a/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts +++ b/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts @@ -11,7 +11,7 @@ type CategorizeTrackedExpenseParams = { moneyRequestPreviewReportActionID: string | undefined; moneyRequestReportID: string | undefined; moneyRequestCreatedReportActionID: string | undefined; - actionableWhisperReportActionID: string; + actionableWhisperReportActionID: string | undefined; modifiedExpenseReportActionID: string; reportPreviewReportActionID: string | undefined; category?: string; diff --git a/src/libs/API/parameters/ShareTrackedExpenseParams.ts b/src/libs/API/parameters/ShareTrackedExpenseParams.ts index d87f78525008..c851af4e227b 100644 --- a/src/libs/API/parameters/ShareTrackedExpenseParams.ts +++ b/src/libs/API/parameters/ShareTrackedExpenseParams.ts @@ -11,7 +11,7 @@ type ShareTrackedExpenseParams = { moneyRequestPreviewReportActionID: string | undefined; moneyRequestReportID: string | undefined; moneyRequestCreatedReportActionID: string | undefined; - actionableWhisperReportActionID: string; + actionableWhisperReportActionID: string | undefined; modifiedExpenseReportActionID: string; reportPreviewReportActionID: string | undefined; category?: string; diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 095ed2684a6d..f68eaf5d57b0 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -11,7 +11,7 @@ import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import type {HybridAppRoute, Route} from '@src/ROUTES'; import ROUTES, {HYBRID_APP_ROUTES} from '@src/ROUTES'; -import {PROTECTED_SCREENS} from '@src/SCREENS'; +import SCREENS, {PROTECTED_SCREENS} from '@src/SCREENS'; import type {Screen} from '@src/SCREENS'; import type {Report} from '@src/types/onyx'; import originalCloseRHPFlow from './closeRHPFlow'; @@ -428,6 +428,13 @@ function getTopMostCentralPaneRouteFromRootState() { return getTopmostCentralPaneRoute(navigationRef.getRootState() as State); } +function getReportRouteByID(reportID?: string) { + if (!reportID) { + return null; + } + return navigationRef.getRootState().routes.find((r) => r.name === SCREENS.REPORT && !!r.params && 'reportID' in r.params && r.params.reportID === reportID); +} + function removeScreenFromNavigationState(screen: Screen) { isNavigationReady().then(() => { navigationRef.dispatch((state) => { @@ -442,6 +449,19 @@ function removeScreenFromNavigationState(screen: Screen) { }); } +function removeScreenFromNavigationStateByKey(key: string) { + const state = navigationRef.getRootState(); + const routes = state.routes.filter((item) => item.key !== key); + + navigationRef.current?.dispatch(() => { + return CommonActions.reset({ + ...state, + routes, + index: routes.length < state.routes.length ? state.index - 1 : state.index, + }); + }); +} + export default { setShouldPopAllStateOnUP, navigate, @@ -467,6 +487,8 @@ export default { setNavigationActionToMicrotaskQueue, getTopMostCentralPaneRouteFromRootState, removeScreenFromNavigationState, + getReportRouteByID, + removeScreenFromNavigationStateByKey, }; export {navigationRef}; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index e6f9810ab336..b959479d8e63 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -1614,9 +1614,9 @@ function wasActionTakenByCurrentUser(reportAction: OnyxInputOrEntry { - if (!reportID) { - return; +function getIOUActionForReportID(reportID: string | undefined, transactionID: string | undefined): OnyxEntry { + if (!reportID || !transactionID) { + return undefined; } const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const reportActions = getAllReportActions(report?.reportID); diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index f91df123c2a1..a84b437a65f7 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2011,9 +2011,6 @@ function canAddOrDeleteTransactions(moneyRequestReport: OnyxEntry): bool } const policy = getPolicy(moneyRequestReport?.policyID); - if (isInstantSubmitEnabled(policy) && isSubmitAndClose(policy) && hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID)) { - return false; - } if (isInstantSubmitEnabled(policy) && isSubmitAndClose(policy) && !arePaymentsEnabled(policy)) { return false; @@ -2046,6 +2043,11 @@ function canAddTransaction(moneyRequestReport: OnyxEntry): boolean { return false; } + const policy = getPolicy(moneyRequestReport?.policyID); + if (isInstantSubmitEnabled(policy) && isSubmitAndClose(policy) && hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID)) { + return false; + } + return canAddOrDeleteTransactions(moneyRequestReport); } @@ -4472,7 +4474,7 @@ function goBackToDetailsPage(report: OnyxEntry, backTo?: string) { } } -function navigateBackOnDeleteTransaction(backRoute: Route | undefined, isFromRHP?: boolean) { +function navigateBackOnDeleteTransaction(backRoute: Route | undefined, isFromRHP?: boolean, reportIDToRemove?: string) { if (!backRoute) { return; } @@ -4482,7 +4484,14 @@ function navigateBackOnDeleteTransaction(backRoute: Route | undefined, isFromRHP return; } if (isFromRHP) { - Navigation.dismissModal(); + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportIDToRemove}`]; + if (report && isTrackExpenseReport(report)) { + const trackReportRoute = Navigation.getReportRouteByID(reportIDToRemove); + if (trackReportRoute?.key) { + Navigation.removeScreenFromNavigationStateByKey(trackReportRoute.key); + } + } + Navigation.isNavigationReady().then(() => Navigation.dismissModal()); } Navigation.isNavigationReady().then(() => { Navigation.goBack(backRoute); @@ -8543,7 +8552,7 @@ function createDraftTransactionAndNavigateToParticipantSelector( actionName: IOUAction, reportActionID: string | undefined, ): void { - if (!transactionID || !reportID || !reportActionID) { + if (!transactionID || !reportID) { return; } diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index f47a0d56001d..51f8e9428bdb 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -340,7 +340,7 @@ function getAction(data: OnyxTypes.SearchResults['data'], key: string): SearchTr } // We check for isAllowedToApproveExpenseReport because if the policy has preventSelfApprovals enabled, we disable the Submit action and in that case we want to show the View action instead - if (canSubmitReport(report, policy, allReportTransactions, allViolations) && isAllowedToApproveExpenseReport) { + if (canSubmitReport(report, policy, allReportTransactions) && isAllowedToApproveExpenseReport) { return CONST.SEARCH.ACTION_TYPES.SUBMIT; } diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index a8ffc240aa6f..474329bd6951 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -766,8 +766,11 @@ function hasMissingSmartscanFields(transaction: OnyxInputOrEntry): /** * Get all transaction violations of the transaction with given tranactionID. */ -function getTransactionViolations(transactionID: string | undefined, transactionViolations: OnyxCollection | null): TransactionViolations | null { - return transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID] ?? null; +function getTransactionViolations(transactionID: string | undefined): TransactionViolations | null { + if (!transactionID) { + return null; + } + return allTransactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.filter((violation) => !isViolationDismissed(transactionID, violation)) ?? null; } /** @@ -786,8 +789,8 @@ function hasPendingRTERViolation(transactionViolations?: TransactionViolations | /** * Check if there is broken connection violation. */ -function hasBrokenConnectionViolation(transactionID?: string, allViolations?: OnyxCollection): boolean { - const violations = getTransactionViolations(transactionID, allViolations ?? allTransactionViolations); +function hasBrokenConnectionViolation(transactionID?: string): boolean { + const violations = getTransactionViolations(transactionID); return !!violations?.find( (violation) => violation.name === CONST.VIOLATIONS.RTER && @@ -798,13 +801,8 @@ function hasBrokenConnectionViolation(transactionID?: string, allViolations?: On /** * Check if user should see broken connection violation warning. */ -function shouldShowBrokenConnectionViolation( - transactionIDList: string[] | undefined, - report: OnyxEntry | SearchReport, - policy: OnyxEntry | SearchPolicy, - allViolations?: OnyxCollection, -): boolean { - const transactionsWithBrokenConnectionViolation = transactionIDList?.map((transactionID) => hasBrokenConnectionViolation(transactionID, allViolations)) ?? []; +function shouldShowBrokenConnectionViolation(transactionIDList: string[] | undefined, report: OnyxEntry | SearchReport, policy: OnyxEntry | SearchPolicy): boolean { + const transactionsWithBrokenConnectionViolation = transactionIDList?.map((transactionID) => hasBrokenConnectionViolation(transactionID)) ?? []; return ( transactionsWithBrokenConnectionViolation.length > 0 && transactionsWithBrokenConnectionViolation?.some((value) => value === true) && @@ -815,9 +813,9 @@ function shouldShowBrokenConnectionViolation( /** * Check if there is pending rter violation in all transactionViolations with given transactionIDs. */ -function allHavePendingRTERViolation(transactionIds: string[], allViolations?: OnyxCollection): boolean { +function allHavePendingRTERViolation(transactionIds: string[]): boolean { const transactionsWithRTERViolations = transactionIds.map((transactionId) => { - const transactionViolations = getTransactionViolations(transactionId, allViolations ?? allTransactionViolations); + const transactionViolations = getTransactionViolations(transactionId); return hasPendingRTERViolation(transactionViolations); }); return transactionsWithRTERViolations.length > 0 && transactionsWithRTERViolations.every((value) => value === true); @@ -908,14 +906,20 @@ function getRecentTransactions(transactions: Record, size = 2): * @param checkDismissed - whether to check if the violation has already been dismissed as well */ function isDuplicate(transactionID: string | undefined, checkDismissed = false): boolean { - const hasDuplicatedViolation = !!allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]?.some( + if (!transactionID) { + return false; + } + const duplicateViolation = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]?.find( (violation: TransactionViolation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION, ); + + const hasDuplicatedViolation = !!duplicateViolation; if (!checkDismissed) { - return hasDuplicatedViolation; + return !!duplicateViolation; } - const didDismissedViolation = - allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]?.comment?.dismissedViolations?.duplicatedTransaction?.[currentUserEmail] === `${currentUserAccountID}`; + + const didDismissedViolation = isViolationDismissed(transactionID, duplicateViolation); + return hasDuplicatedViolation && !didDismissedViolation; } @@ -941,16 +945,32 @@ function isOnHoldByTransactionID(transactionID: string | undefined | null): bool return isOnHold(allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]); } +/** + * Checks if a violation is dismissed for the given transaction + */ +function isViolationDismissed(transactionID: string | undefined, violation: TransactionViolation | undefined): boolean { + if (!transactionID || !violation) { + return false; + } + return allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]?.comment?.dismissedViolations?.[violation.name]?.[currentUserEmail] === `${currentUserAccountID}`; +} + /** * Checks if any violations for the provided transaction are of type 'violation' */ function hasViolation(transactionID: string | undefined, transactionViolations: OnyxCollection, showInReview?: boolean): boolean { + if (!transactionID) { + return false; + } const transaction = getTransaction(transactionID); if (isExpensifyCardTransaction(transaction) && isPending(transaction)) { return false; } return !!transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some( - (violation: TransactionViolation) => violation.type === CONST.VIOLATION_TYPES.VIOLATION && (showInReview === undefined || showInReview === (violation.showInReview ?? false)), + (violation: TransactionViolation) => + violation.type === CONST.VIOLATION_TYPES.VIOLATION && + (showInReview === undefined || showInReview === (violation.showInReview ?? false)) && + !isViolationDismissed(transactionID, violation), ); } @@ -958,12 +978,18 @@ function hasViolation(transactionID: string | undefined, transactionViolations: * Checks if any violations for the provided transaction are of type 'notice' */ function hasNoticeTypeViolation(transactionID: string | undefined, transactionViolations: OnyxCollection, showInReview?: boolean): boolean { + if (!transactionID) { + return false; + } const transaction = getTransaction(transactionID); if (isExpensifyCardTransaction(transaction) && isPending(transaction)) { return false; } return !!transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some( - (violation: TransactionViolation) => violation.type === CONST.VIOLATION_TYPES.NOTICE && (showInReview === undefined || showInReview === (violation.showInReview ?? false)), + (violation: TransactionViolation) => + violation.type === CONST.VIOLATION_TYPES.NOTICE && + (showInReview === undefined || showInReview === (violation.showInReview ?? false)) && + !isViolationDismissed(transactionID, violation), ); } @@ -971,6 +997,9 @@ function hasNoticeTypeViolation(transactionID: string | undefined, transactionVi * Checks if any violations for the provided transaction are of type 'warning' */ function hasWarningTypeViolation(transactionID: string | undefined, transactionViolations: OnyxCollection, showInReview?: boolean): boolean { + if (!transactionID) { + return false; + } const transaction = getTransaction(transactionID); if (isExpensifyCardTransaction(transaction) && isPending(transaction)) { return false; @@ -978,7 +1007,10 @@ function hasWarningTypeViolation(transactionID: string | undefined, transactionV const violations = transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]; const warningTypeViolations = violations?.filter( - (violation: TransactionViolation) => violation.type === CONST.VIOLATION_TYPES.WARNING && (showInReview === undefined || showInReview === (violation.showInReview ?? false)), + (violation: TransactionViolation) => + violation.type === CONST.VIOLATION_TYPES.WARNING && + (showInReview === undefined || showInReview === (violation.showInReview ?? false)) && + !isViolationDismissed(transactionID, violation), ) ?? []; const hasOnlyDupeDetectionViolation = warningTypeViolations?.every((violation: TransactionViolation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION); @@ -1451,6 +1483,7 @@ export { getFormattedPostedDate, getCategoryTaxCodeAndAmount, isPerDiemRequest, + isViolationDismissed, }; export type {TransactionChanges}; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 4723009822df..731f8751f6d3 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -243,7 +243,7 @@ type TrackedExpenseReportInformation = { moneyRequestPreviewReportActionID: string | undefined; moneyRequestReportID: string | undefined; moneyRequestCreatedReportActionID: string | undefined; - actionableWhisperReportActionID: string; + actionableWhisperReportActionID: string | undefined; linkedTrackedExpenseReportAction: OnyxTypes.ReportAction; linkedTrackedExpenseReportID: string; transactionThreadReportID: string | undefined; @@ -3854,7 +3854,7 @@ function updateMoneyRequestAttendees( policy: OnyxEntry, policyTagList: OnyxEntry, policyCategories: OnyxEntry, - violations: OnyxEntry, + violations?: OnyxEntry, ) { const transactionChanges: TransactionChanges = { attendees, @@ -4082,7 +4082,7 @@ function updateMoneyRequestDistanceRate( const getConvertTrackedExpenseInformation = ( transactionID: string | undefined, - actionableWhisperReportActionID: string, + actionableWhisperReportActionID: string | undefined, moneyRequestReportID: string | undefined, linkedTrackedExpenseReportAction: OnyxTypes.ReportAction, linkedTrackedExpenseReportID: string, @@ -4738,7 +4738,7 @@ function trackExpense( switch (action) { case CONST.IOU.ACTION.CATEGORIZE: { - if (!linkedTrackedExpenseReportAction || !actionableWhisperReportActionID || !linkedTrackedExpenseReportID) { + if (!linkedTrackedExpenseReportAction || !linkedTrackedExpenseReportID) { return; } const transactionParams: TrackedExpenseTransactionParams = { @@ -4783,7 +4783,7 @@ function trackExpense( break; } case CONST.IOU.ACTION.SHARE: { - if (!linkedTrackedExpenseReportAction || !actionableWhisperReportActionID || !linkedTrackedExpenseReportID) { + if (!linkedTrackedExpenseReportAction || !linkedTrackedExpenseReportID) { return; } const transactionParams: TrackedExpenseTransactionParams = { @@ -8011,15 +8011,14 @@ function canSubmitReport( report: OnyxEntry | SearchReport, policy: OnyxEntry | SearchPolicy, transactions: OnyxTypes.Transaction[] | SearchTransaction[], - allViolations?: OnyxCollection, ) { const currentUserAccountID = getCurrentUserAccountID(); const isOpenExpenseReport = isOpenExpenseReportReportUtils(report); const isArchived = isArchivedReportWithID(report?.reportID); const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; const transactionIDList = transactions.map((transaction) => transaction.transactionID); - const hasAllPendingRTERViolations = allHavePendingRTERViolation(transactionIDList, allViolations); - const hasBrokenConnectionViolation = shouldShowBrokenConnectionViolation(transactionIDList, report, policy, allViolations); + const hasAllPendingRTERViolations = allHavePendingRTERViolation(transactionIDList); + const hasBrokenConnectionViolation = shouldShowBrokenConnectionViolation(transactionIDList, report, policy); const hasOnlyPendingCardOrScanFailTransactions = transactions.length > 0 && diff --git a/src/pages/Debug/Transaction/DebugTransactionViolations.tsx b/src/pages/Debug/Transaction/DebugTransactionViolations.tsx index e13fd01fdcd7..5190d6c76e50 100644 --- a/src/pages/Debug/Transaction/DebugTransactionViolations.tsx +++ b/src/pages/Debug/Transaction/DebugTransactionViolations.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import ScrollView from '@components/ScrollView'; @@ -7,7 +6,7 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import ONYXKEYS from '@src/ONYXKEYS'; +import {getTransactionViolations} from '@libs/TransactionUtils'; import ROUTES from '@src/ROUTES'; import type {TransactionViolation} from '@src/types/onyx'; @@ -17,7 +16,8 @@ type DebugTransactionViolationsProps = { }; function DebugTransactionViolations({transactionID}: DebugTransactionViolationsProps) { - const [transactionViolations] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); + const transactionViolations = getTransactionViolations(transactionID); + const styles = useThemeStyles(); const {translate} = useLocalize(); diff --git a/src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx b/src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx index b5f3d0d603d5..452925da8b86 100644 --- a/src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx +++ b/src/pages/Debug/TransactionViolation/DebugTransactionViolationCreatePage.tsx @@ -1,6 +1,5 @@ import React, {useCallback, useState} from 'react'; import {View} from 'react-native'; -import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -10,10 +9,11 @@ import TextInput from '@components/TextInput'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import DebugUtils from '@libs/DebugUtils'; -import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {DebugParamList} from '@libs/Navigation/types'; +import {getTransactionViolations} from '@libs/TransactionUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import Debug from '@userActions/Debug'; import CONST from '@src/CONST'; @@ -62,7 +62,7 @@ function DebugTransactionViolationCreatePage({ }: DebugTransactionViolationCreatePageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const [transactionViolations] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); + const transactionViolations = getTransactionViolations(transactionID); const [draftTransactionViolation, setDraftTransactionViolation] = useState(() => getInitialTransactionViolation()); const [error, setError] = useState(); @@ -95,7 +95,7 @@ function DebugTransactionViolationCreatePage({ {({safeAreaPaddingBottomStyle}) => ( diff --git a/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx b/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx index 9db84c341d59..1b26b0c5f72a 100644 --- a/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx +++ b/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx @@ -1,18 +1,18 @@ import React, {useCallback, useMemo} from 'react'; import {InteractionManager, View} from 'react-native'; -import {useOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Debug from '@libs/actions/Debug'; import DebugUtils from '@libs/DebugUtils'; -import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import type {DebugTabNavigatorRoutes} from '@libs/Navigation/DebugTabNavigator'; import DebugTabNavigator from '@libs/Navigation/DebugTabNavigator'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {DebugParamList} from '@libs/Navigation/types'; +import {getTransactionViolations} from '@libs/TransactionUtils'; import DebugDetails from '@pages/Debug/DebugDetails'; import DebugJSON from '@pages/Debug/DebugJSON'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; @@ -29,7 +29,7 @@ function DebugTransactionViolationPage({ }, }: DebugTransactionViolationPageProps) { const {translate} = useLocalize(); - const [transactionViolations] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); + const transactionViolations = getTransactionViolations(transactionID); const transactionViolation = useMemo(() => transactionViolations?.[Number(index)], [index, transactionViolations]); const styles = useThemeStyles(); @@ -84,7 +84,7 @@ function DebugTransactionViolationPage({ {({safeAreaPaddingBottomStyle}) => ( diff --git a/src/pages/PrivateNotes/PrivateNotesEditPage.tsx b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx index deab122e3006..42e83dbff6b0 100644 --- a/src/pages/PrivateNotes/PrivateNotesEditPage.tsx +++ b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx @@ -18,12 +18,12 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {PrivateNotesNavigatorParamList} from '@libs/Navigation/types'; import Parser from '@libs/Parser'; -import * as ReportUtils from '@libs/ReportUtils'; +import {goBackFromPrivateNotes, navigateToDetailsPage} from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import type {WithReportAndPrivateNotesOrNotFoundProps} from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; import withReportAndPrivateNotesOrNotFound from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; import variables from '@styles/variables'; -import * as ReportActions from '@userActions/Report'; +import {clearPrivateNotesError, getDraftPrivateNote, handleUserDeletedLinksInHtml, savePrivateNotesDraft, updatePrivateNotes} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -46,7 +46,7 @@ function PrivateNotesEditPage({route, report, accountID}: PrivateNotesEditPagePr // We need to edit the note in markdown format, but display it in HTML format const [privateNote, setPrivateNote] = useState( - () => ReportActions.getDraftPrivateNote(report.reportID).trim() || Parser.htmlToMarkdown(report?.privateNotes?.[Number(route.params.accountID)]?.note ?? '').trim(), + () => getDraftPrivateNote(report.reportID).trim() || Parser.htmlToMarkdown(report?.privateNotes?.[Number(route.params.accountID)]?.note ?? '').trim(), ); /** @@ -56,7 +56,7 @@ function PrivateNotesEditPage({route, report, accountID}: PrivateNotesEditPagePr const debouncedSavePrivateNote = useMemo( () => lodashDebounce((text: string) => { - ReportActions.savePrivateNotesDraft(report.reportID, text); + savePrivateNotesDraft(report.reportID, text); }, 1000), [report.reportID], ); @@ -85,8 +85,8 @@ function PrivateNotesEditPage({route, report, accountID}: PrivateNotesEditPagePr const originalNote = report?.privateNotes?.[Number(route.params.accountID)]?.note ?? ''; let editedNote = ''; if (privateNote.trim() !== originalNote.trim()) { - editedNote = ReportActions.handleUserDeletedLinksInHtml(privateNote.trim(), Parser.htmlToMarkdown(originalNote).trim()); - ReportActions.updatePrivateNotes(report.reportID, Number(route.params.accountID), editedNote); + editedNote = handleUserDeletedLinksInHtml(privateNote.trim(), Parser.htmlToMarkdown(originalNote).trim()); + updatePrivateNotes(report.reportID, Number(route.params.accountID), editedNote); } // We want to delete saved private note draft after saving the note @@ -94,7 +94,7 @@ function PrivateNotesEditPage({route, report, accountID}: PrivateNotesEditPagePr Keyboard.dismiss(); if (!Object.values({...report.privateNotes, [route.params.accountID]: {note: editedNote}}).some((item) => item.note)) { - ReportUtils.navigateToDetailsPage(report, backTo); + navigateToDetailsPage(report, backTo); } else { Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.PRIVATE_NOTES_LIST.getRoute(report.reportID, backTo))); } @@ -108,7 +108,7 @@ function PrivateNotesEditPage({route, report, accountID}: PrivateNotesEditPagePr > ReportUtils.goBackFromPrivateNotes(report, accountID, backTo)} + onBackButtonPress={() => goBackFromPrivateNotes(report, accountID, backTo)} shouldShowBackButton onCloseButtonPress={() => Navigation.dismissModal()} /> @@ -130,7 +130,7 @@ function PrivateNotesEditPage({route, report, accountID}: PrivateNotesEditPagePr errors={{ ...(report?.privateNotes?.[Number(route.params.accountID)]?.errors ?? ''), }} - onClose={() => ReportActions.clearPrivateNotesError(report.reportID, Number(route.params.accountID))} + onClose={() => clearPrivateNotesError(report.reportID, Number(route.params.accountID))} style={[styles.mb3]} > diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index 66b66d482bba..eb9ee37f55b9 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -969,9 +969,9 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta Navigation.dismissModal(); } else { setDeleteTransactionNavigateBackUrl(urlToNavigateBack); - navigateBackOnDeleteTransaction(urlToNavigateBack as Route, true); + navigateBackOnDeleteTransaction(urlToNavigateBack as Route, true, report.reportID); } - }, [iouTransactionID, requestParentReportAction, isSingleTransactionView, isTransactionDeleted, moneyRequestReport?.reportID]); + }, [iouTransactionID, requestParentReportAction, isSingleTransactionView, isTransactionDeleted, moneyRequestReport?.reportID, report.reportID]); const mentionReportContextValue = useMemo(() => ({currentReportID: report.reportID, exactlyMatch: true}), [report.reportID]); diff --git a/src/pages/RoomDescriptionPage.tsx b/src/pages/RoomDescriptionPage.tsx index 69faef68f766..9017b3f8ed7e 100644 --- a/src/pages/RoomDescriptionPage.tsx +++ b/src/pages/RoomDescriptionPage.tsx @@ -17,10 +17,10 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportDescriptionNavigatorParamList} from '@libs/Navigation/types'; import Parser from '@libs/Parser'; -import * as ReportUtils from '@libs/ReportUtils'; +import {canEditReportDescription, getReportDescription} from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import variables from '@styles/variables'; -import * as Report from '@userActions/Report'; +import {updateDescription} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -40,7 +40,7 @@ function RoomDescriptionPage({report, policies}: RoomDescriptionPageProps) { const route = useRoute>(); const backTo = route.params.backTo; const styles = useThemeStyles(); - const [description, setDescription] = useState(() => Parser.htmlToMarkdown(ReportUtils.getReportDescription(report))); + const [description, setDescription] = useState(() => Parser.htmlToMarkdown(getReportDescription(report))); const reportDescriptionInputRef = useRef(null); const focusTimeoutRef = useRef | null>(null); const {translate} = useLocalize(); @@ -58,7 +58,7 @@ function RoomDescriptionPage({report, policies}: RoomDescriptionPageProps) { const previousValue = report?.description ?? ''; const newValue = description.trim(); - Report.updateDescription(report.reportID, previousValue, newValue); + updateDescription(report.reportID, previousValue, newValue); goBack(); }, [report.reportID, report.description, description, goBack]); @@ -76,7 +76,7 @@ function RoomDescriptionPage({report, policies}: RoomDescriptionPageProps) { }, []), ); - const canEdit = ReportUtils.canEditReportDescription(report, policy); + const canEdit = canEditReportDescription(report, policy); return ( diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersAmountPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersAmountPage.tsx index 6473da9fc9f5..913387703053 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersAmountPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersAmountPage.tsx @@ -1,7 +1,7 @@ import React from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; -import AmountWithoutCurrencyForm from '@components/AmountWithoutCurrencyForm'; +import AmountWithoutCurrencyInput from '@components/AmountWithoutCurrencyInput'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormOnyxValues} from '@components/Form/types'; @@ -61,7 +61,7 @@ function SearchFiltersAmountPage() { > diff --git a/src/pages/TransactionDuplicate/Review.tsx b/src/pages/TransactionDuplicate/Review.tsx index cb27ecfcbb3c..883b7e2d8031 100644 --- a/src/pages/TransactionDuplicate/Review.tsx +++ b/src/pages/TransactionDuplicate/Review.tsx @@ -10,13 +10,13 @@ import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import {dismissDuplicateTransactionViolation} from '@libs/actions/Transaction'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types'; -import * as ReportActionsUtils from '@libs/ReportActionsUtils'; -import * as ReportUtils from '@libs/ReportUtils'; -import * as TransactionUtils from '@libs/TransactionUtils'; -import * as Transaction from '@userActions/Transaction'; +import {getLinkedTransactionID, getReportAction} from '@libs/ReportActionsUtils'; +import {isReportApproved, isSettled} from '@libs/ReportUtils'; +import {getTransaction, getTransactionViolations} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -28,23 +28,24 @@ function TransactionDuplicateReview() { const route = useRoute>(); const currentPersonalDetails = useCurrentUserPersonalDetails(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.threadReportID}`); - const reportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '-1', report?.parentReportActionID ?? '-1'); - const transactionID = ReportActionsUtils.getLinkedTransactionID(reportAction, report?.reportID ?? '-1') ?? '-1'; - const [transactionViolations] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); + const reportAction = getReportAction(report?.parentReportID, report?.parentReportActionID); + const transactionID = getLinkedTransactionID(reportAction, report?.reportID) ?? undefined; + const transactionViolations = getTransactionViolations(transactionID); + const duplicateTransactionIDs = useMemo( () => transactionViolations?.find((violation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION)?.data?.duplicates ?? [], [transactionViolations], ); - const transactionIDs = [transactionID, ...duplicateTransactionIDs]; + const transactionIDs = transactionID ? [transactionID, ...duplicateTransactionIDs] : [...duplicateTransactionIDs]; - const transactions = transactionIDs.map((item) => TransactionUtils.getTransaction(item)).sort((a, b) => new Date(a?.created ?? '').getTime() - new Date(b?.created ?? '').getTime()); + const transactions = transactionIDs.map((item) => getTransaction(item)).sort((a, b) => new Date(a?.created ?? '').getTime() - new Date(b?.created ?? '').getTime()); const keepAll = () => { - Transaction.dismissDuplicateTransactionViolation(transactionIDs, currentPersonalDetails); + dismissDuplicateTransactionViolation(transactionIDs, currentPersonalDetails); Navigation.goBack(); }; - const hasSettledOrApprovedTransaction = transactions.some((transaction) => ReportUtils.isSettled(transaction?.reportID) || ReportUtils.isReportApproved(transaction?.reportID)); + const hasSettledOrApprovedTransaction = transactions.some((transaction) => isSettled(transaction?.reportID) || isReportApproved(transaction?.reportID)); return ( diff --git a/src/pages/iou/request/step/IOURequestStepAttendees.tsx b/src/pages/iou/request/step/IOURequestStepAttendees.tsx index 409ef1cfe02d..2e64e55fb8a1 100644 --- a/src/pages/iou/request/step/IOURequestStepAttendees.tsx +++ b/src/pages/iou/request/step/IOURequestStepAttendees.tsx @@ -4,10 +4,10 @@ import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; +import {setMoneyRequestAttendees, updateMoneyRequestAttendees} from '@libs/actions/IOU'; import Navigation from '@libs/Navigation/Navigation'; -import * as TransactionUtils from '@libs/TransactionUtils'; +import {getAttendees, getTransactionViolations} from '@libs/TransactionUtils'; import MoneyRequestAttendeeSelector from '@pages/iou/request/MoneyRequestAttendeeSelector'; -import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -39,20 +39,20 @@ function IOURequestStepAttendees({ policyCategories, }: IOURequestStepAttendeesProps) { const isEditing = action === CONST.IOU.ACTION.EDIT; - const [transaction] = useOnyx(`${isEditing ? ONYXKEYS.COLLECTION.TRANSACTION : ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID || -1}`); - const [attendees, setAttendees] = useState(() => TransactionUtils.getAttendees(transaction)); + const [transaction] = useOnyx(`${isEditing ? ONYXKEYS.COLLECTION.TRANSACTION : ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID || CONST.DEFAULT_NUMBER_ID}`); + const [attendees, setAttendees] = useState(() => getAttendees(transaction)); const previousAttendees = usePrevious(attendees); const {translate} = useLocalize(); - const [violations] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); + const violations = getTransactionViolations(transactionID); const saveAttendees = useCallback(() => { if (attendees.length <= 0) { return; } if (!lodashIsEqual(previousAttendees, attendees)) { - IOU.setMoneyRequestAttendees(transactionID, attendees, !isEditing); + setMoneyRequestAttendees(transactionID, attendees, !isEditing); if (isEditing) { - IOU.updateMoneyRequestAttendees(transactionID, reportID, attendees, policy, policyTags, policyCategories, violations); + updateMoneyRequestAttendees(transactionID, reportID, attendees, policy, policyTags, policyCategories, violations ?? undefined); } } diff --git a/src/pages/iou/request/step/IOURequestStepDescription.tsx b/src/pages/iou/request/step/IOURequestStepDescription.tsx index f4a8ee827ff1..7006b66ed3b9 100644 --- a/src/pages/iou/request/step/IOURequestStepDescription.tsx +++ b/src/pages/iou/request/step/IOURequestStepDescription.tsx @@ -11,14 +11,14 @@ import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as ErrorUtils from '@libs/ErrorUtils'; -import * as IOUUtils from '@libs/IOUUtils'; +import {addErrorMessage} from '@libs/ErrorUtils'; +import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; -import * as ReportActionsUtils from '@libs/ReportActionsUtils'; -import * as ReportUtils from '@libs/ReportUtils'; -import * as TransactionUtils from '@libs/TransactionUtils'; +import {isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {canEditMoneyRequest} from '@libs/ReportUtils'; +import {areRequiredFieldsEmpty} from '@libs/TransactionUtils'; import variables from '@styles/variables'; -import * as IOU from '@userActions/IOU'; +import {setDraftSplitTransaction, setMoneyRequestDescription, updateMoneyRequestDescription} from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -76,7 +76,7 @@ function IOURequestStepDescription({ const errors = {}; if (values.moneyRequestComment.length > CONST.DESCRIPTION_LIMIT) { - ErrorUtils.addErrorMessage( + addErrorMessage( errors, 'moneyRequestComment', translate('common.error.characterLimitExceedCounter', {length: values.moneyRequestComment.length, limit: CONST.DESCRIPTION_LIMIT}), @@ -112,16 +112,16 @@ function IOURequestStepDescription({ // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value if (isEditingSplitBill) { - IOU.setDraftSplitTransaction(transaction?.transactionID, {comment: newComment}); + setDraftSplitTransaction(transaction?.transactionID, {comment: newComment}); navigateBack(); return; } - const isTransactionDraft = IOUUtils.shouldUseTransactionDraft(action); + const isTransactionDraft = shouldUseTransactionDraft(action); - IOU.setMoneyRequestDescription(transaction?.transactionID, newComment, isTransactionDraft); + setMoneyRequestDescription(transaction?.transactionID, newComment, isTransactionDraft); if (action === CONST.IOU.ACTION.EDIT) { - IOU.updateMoneyRequestDescription(transaction?.transactionID, reportID, newComment, policy, policyTags, policyCategories); + updateMoneyRequestDescription(transaction?.transactionID, reportID, newComment, policy, policyTags, policyCategories); } navigateBack(); @@ -129,9 +129,9 @@ function IOURequestStepDescription({ const isEditing = action === CONST.IOU.ACTION.EDIT; const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT; - const canEditSplitBill = isSplitBill && reportAction && session?.accountID === reportAction.actorAccountID && TransactionUtils.areRequiredFieldsEmpty(transaction); + const canEditSplitBill = isSplitBill && reportAction && session?.accountID === reportAction.actorAccountID && areRequiredFieldsEmpty(transaction); // eslint-disable-next-line rulesdir/no-negated-variables - const shouldShowNotFoundPage = isEditing && (isSplitBill ? !canEditSplitBill : !ReportActionsUtils.isMoneyRequestAction(reportAction) || !ReportUtils.canEditMoneyRequest(reportAction)); + const shouldShowNotFoundPage = isEditing && (isSplitBill ? !canEditSplitBill : !isMoneyRequestAction(reportAction) || !canEditMoneyRequest(reportAction)); const isReportInGroupPolicy = !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE; return ( @@ -164,7 +164,7 @@ function IOURequestStepDescription({ autoGrowHeight maxAutoGrowHeight={variables.textInputAutoGrowMaxHeight} shouldSubmitForm - isMarkdownEnabled + type="markdown" excludedMarkdownStyles={!isReportInGroupPolicy ? ['mentionReport'] : []} ref={inputCallbackRef} /> diff --git a/src/pages/tasks/NewTaskDescriptionPage.tsx b/src/pages/tasks/NewTaskDescriptionPage.tsx index 4b2c08c95fb1..e70b6ea15d20 100644 --- a/src/pages/tasks/NewTaskDescriptionPage.tsx +++ b/src/pages/tasks/NewTaskDescriptionPage.tsx @@ -11,15 +11,15 @@ import TextInput from '@components/TextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as ErrorUtils from '@libs/ErrorUtils'; +import {addErrorMessage} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {NewTaskNavigatorParamList} from '@libs/Navigation/types'; import Parser from '@libs/Parser'; -import * as ReportUtils from '@libs/ReportUtils'; +import {getCommentLength} from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import variables from '@styles/variables'; -import * as TaskActions from '@userActions/Task'; +import {setDescriptionValue} from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -41,15 +41,15 @@ function NewTaskDescriptionPage({task, route}: NewTaskDescriptionPageProps) { const goBack = () => Navigation.goBack(ROUTES.NEW_TASK.getRoute(route.params?.backTo)); const onSubmit = (values: FormOnyxValues) => { - TaskActions.setDescriptionValue(values.taskDescription); + setDescriptionValue(values.taskDescription); goBack(); }; const validate = (values: FormOnyxValues): FormInputErrors => { const errors = {}; - const taskDescriptionLength = ReportUtils.getCommentLength(values.taskDescription); + const taskDescriptionLength = getCommentLength(values.taskDescription); if (taskDescriptionLength > CONST.DESCRIPTION_LIMIT) { - ErrorUtils.addErrorMessage(errors, 'taskDescription', translate('common.error.characterLimitExceedCounter', {length: taskDescriptionLength, limit: CONST.DESCRIPTION_LIMIT})); + addErrorMessage(errors, 'taskDescription', translate('common.error.characterLimitExceedCounter', {length: taskDescriptionLength, limit: CONST.DESCRIPTION_LIMIT})); } return errors; @@ -91,7 +91,7 @@ function NewTaskDescriptionPage({task, route}: NewTaskDescriptionPageProps) { autoGrowHeight maxAutoGrowHeight={variables.textInputAutoGrowMaxHeight} shouldSubmitForm - isMarkdownEnabled + type="markdown" /> diff --git a/src/pages/tasks/NewTaskDetailsPage.tsx b/src/pages/tasks/NewTaskDetailsPage.tsx index ffb199c6108d..f225727f10c3 100644 --- a/src/pages/tasks/NewTaskDetailsPage.tsx +++ b/src/pages/tasks/NewTaskDetailsPage.tsx @@ -11,15 +11,15 @@ import TextInput from '@components/TextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as ErrorUtils from '@libs/ErrorUtils'; +import {addErrorMessage} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {NewTaskNavigatorParamList} from '@libs/Navigation/types'; import Parser from '@libs/Parser'; -import * as ReportUtils from '@libs/ReportUtils'; +import {getCommentLength} from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import variables from '@styles/variables'; -import * as TaskActions from '@userActions/Task'; +import {createTaskAndNavigate, dismissModalAndClearOutTaskInfo, setDetailsValue, setShareDestinationValue} from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -56,13 +56,13 @@ function NewTaskDetailsPage({task, route}: NewTaskDetailsPageProps) { if (!values.taskTitle) { // We error if the user doesn't enter a task name - ErrorUtils.addErrorMessage(errors, 'taskTitle', translate('newTaskPage.pleaseEnterTaskName')); + addErrorMessage(errors, 'taskTitle', translate('newTaskPage.pleaseEnterTaskName')); } else if (values.taskTitle.length > CONST.TITLE_CHARACTER_LIMIT) { - ErrorUtils.addErrorMessage(errors, 'taskTitle', translate('common.error.characterLimitExceedCounter', {length: values.taskTitle.length, limit: CONST.TITLE_CHARACTER_LIMIT})); + addErrorMessage(errors, 'taskTitle', translate('common.error.characterLimitExceedCounter', {length: values.taskTitle.length, limit: CONST.TITLE_CHARACTER_LIMIT})); } - const taskDescriptionLength = ReportUtils.getCommentLength(values.taskDescription); + const taskDescriptionLength = getCommentLength(values.taskDescription); if (taskDescriptionLength > CONST.DESCRIPTION_LIMIT) { - ErrorUtils.addErrorMessage(errors, 'taskDescription', translate('common.error.characterLimitExceedCounter', {length: taskDescriptionLength, limit: CONST.DESCRIPTION_LIMIT})); + addErrorMessage(errors, 'taskDescription', translate('common.error.characterLimitExceedCounter', {length: taskDescriptionLength, limit: CONST.DESCRIPTION_LIMIT})); } return errors; @@ -71,19 +71,12 @@ function NewTaskDetailsPage({task, route}: NewTaskDetailsPageProps) { // On submit, we want to call the assignTask function and wait to validate // the response const onSubmit = (values: FormOnyxValues) => { - TaskActions.setDetailsValue(values.taskTitle, values.taskDescription); + setDetailsValue(values.taskTitle, values.taskDescription); if (skipConfirmation) { - TaskActions.setShareDestinationValue(task?.parentReportID ?? '-1'); + setShareDestinationValue(task?.parentReportID); playSound(SOUNDS.DONE); - TaskActions.createTaskAndNavigate( - task?.parentReportID ?? '-1', - values.taskTitle, - values.taskDescription ?? '', - task?.assignee ?? '', - task.assigneeAccountID, - task.assigneeChatReport, - ); + createTaskAndNavigate(task?.parentReportID ?? '-1', values.taskTitle, values.taskDescription ?? '', task?.assignee ?? '', task.assigneeAccountID, task.assigneeChatReport); } else { Navigation.navigate(ROUTES.NEW_TASK.getRoute(backTo)); } @@ -98,7 +91,7 @@ function NewTaskDetailsPage({task, route}: NewTaskDetailsPageProps) { TaskActions.dismissModalAndClearOutTaskInfo(backTo)} + onBackButtonPress={() => dismissModalAndClearOutTaskInfo(backTo)} /> diff --git a/src/pages/tasks/TaskDescriptionPage.tsx b/src/pages/tasks/TaskDescriptionPage.tsx index 86a7cbc54a23..9fdf1757ca96 100644 --- a/src/pages/tasks/TaskDescriptionPage.tsx +++ b/src/pages/tasks/TaskDescriptionPage.tsx @@ -13,17 +13,17 @@ import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalD import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as ErrorUtils from '@libs/ErrorUtils'; +import {addErrorMessage} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportDescriptionNavigatorParamList} from '@libs/Navigation/types'; import Parser from '@libs/Parser'; -import * as ReportUtils from '@libs/ReportUtils'; +import {getCommentLength, getParsedComment, isOpenTaskReport, isTaskReport} from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import withReportOrNotFound from '@pages/home/report/withReportOrNotFound'; import type {WithReportOrNotFoundProps} from '@pages/home/report/withReportOrNotFound'; import variables from '@styles/variables'; -import * as Task from '@userActions/Task'; +import {canModifyTask, editTask} from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -40,10 +40,10 @@ function TaskDescriptionPage({report, currentUserPersonalDetails}: TaskDescripti const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { const errors = {}; - const parsedDescription = ReportUtils.getParsedComment(values?.description); - const taskDescriptionLength = ReportUtils.getCommentLength(parsedDescription); + const parsedDescription = getParsedComment(values?.description); + const taskDescriptionLength = getCommentLength(parsedDescription); if (values?.description && taskDescriptionLength > CONST.DESCRIPTION_LIMIT) { - ErrorUtils.addErrorMessage(errors, 'description', translate('common.error.characterLimitExceedCounter', {length: taskDescriptionLength, limit: CONST.DESCRIPTION_LIMIT})); + addErrorMessage(errors, 'description', translate('common.error.characterLimitExceedCounter', {length: taskDescriptionLength, limit: CONST.DESCRIPTION_LIMIT})); } return errors; @@ -56,7 +56,7 @@ function TaskDescriptionPage({report, currentUserPersonalDetails}: TaskDescripti if (values.description !== Parser.htmlToMarkdown(report?.description ?? '') && !isEmptyObject(report)) { // Set the description of the report in the store and then call EditTask API // to update the description of the report on the server - Task.editTask(report, {description: values.description}); + editTask(report, {description: values.description}); } Navigation.dismissModal(report?.reportID); @@ -64,7 +64,7 @@ function TaskDescriptionPage({report, currentUserPersonalDetails}: TaskDescripti [report], ); - if (!ReportUtils.isTaskReport(report)) { + if (!isTaskReport(report)) { Navigation.isNavigationReady().then(() => { Navigation.dismissModal(report?.reportID); }); @@ -72,9 +72,9 @@ function TaskDescriptionPage({report, currentUserPersonalDetails}: TaskDescripti const inputRef = useRef(null); const focusTimeoutRef = useRef(null); - const isOpen = ReportUtils.isOpenTaskReport(report); - const canModifyTask = Task.canModifyTask(report, currentUserPersonalDetails.accountID); - const isTaskNonEditable = ReportUtils.isTaskReport(report) && (!canModifyTask || !isOpen); + const isOpen = isOpenTaskReport(report); + const canActuallyModifyTask = canModifyTask(report, currentUserPersonalDetails.accountID); + const isTaskNonEditable = isTaskReport(report) && (!canActuallyModifyTask || !isOpen); useFocusEffect( useCallback(() => { @@ -132,7 +132,7 @@ function TaskDescriptionPage({report, currentUserPersonalDetails}: TaskDescripti autoGrowHeight maxAutoGrowHeight={variables.textInputAutoGrowMaxHeight} shouldSubmitForm - isMarkdownEnabled + type="markdown" /> diff --git a/src/pages/workspace/WorkspaceNewRoomPage.tsx b/src/pages/workspace/WorkspaceNewRoomPage.tsx index c64c53306a1f..0ce996d1a2e6 100644 --- a/src/pages/workspace/WorkspaceNewRoomPage.tsx +++ b/src/pages/workspace/WorkspaceNewRoomPage.tsx @@ -24,14 +24,14 @@ import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as ErrorUtils from '@libs/ErrorUtils'; +import {addErrorMessage} from '@libs/ErrorUtils'; import localeCompare from '@libs/LocaleCompare'; import Navigation from '@libs/Navigation/Navigation'; -import * as PolicyUtils from '@libs/PolicyUtils'; -import * as ReportUtils from '@libs/ReportUtils'; -import * as ValidationUtils from '@libs/ValidationUtils'; +import {getActivePolicies} from '@libs/PolicyUtils'; +import {buildOptimisticChatReport, getCommentLength, getParsedComment, isPolicyAdmin} from '@libs/ReportUtils'; +import {isExistingRoomName, isReservedRoomName, isValidRoomName} from '@libs/ValidationUtils'; import variables from '@styles/variables'; -import * as Report from '@userActions/Report'; +import {addPolicyReport, clearNewRoomFormError} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -64,7 +64,7 @@ function WorkspaceNewRoomPage() { const workspaceOptions = useMemo( () => - PolicyUtils.getActivePolicies(policies, session?.email) + getActivePolicies(policies, session?.email) ?.filter((policy) => policy.type !== CONST.POLICY.TYPE.PERSONAL) .map((policy) => ({ label: policy.name, @@ -79,12 +79,12 @@ function WorkspaceNewRoomPage() { } return ''; }); - const isPolicyAdmin = useMemo(() => { + const isAdminPolicy = useMemo(() => { if (!policyID) { return false; } - return ReportUtils.isPolicyAdmin(policyID, policies); + return isPolicyAdmin(policyID, policies); }, [policyID, policies]); const [newRoomReportID, setNewRoomReportID] = useState(); @@ -92,9 +92,9 @@ function WorkspaceNewRoomPage() { * @param values - form input values passed by the Form component */ const submit = (values: FormOnyxValues) => { - const participants = [session?.accountID ?? -1]; - const parsedDescription = ReportUtils.getParsedComment(values.reportDescription ?? '', {policyID}); - const policyReport = ReportUtils.buildOptimisticChatReport( + const participants = [session?.accountID ?? CONST.DEFAULT_NUMBER_ID]; + const parsedDescription = getParsedComment(values.reportDescription ?? '', {policyID}); + const policyReport = buildOptimisticChatReport( participants, values.roomName, CONST.REPORT.CHAT_TYPE.POLICY_ROOM, @@ -110,11 +110,11 @@ function WorkspaceNewRoomPage() { parsedDescription, ); setNewRoomReportID(policyReport.reportID); - Report.addPolicyReport(policyReport); + addPolicyReport(policyReport); }; useEffect(() => { - Report.clearNewRoomFormError(); + clearNewRoomFormError(); }, []); useEffect(() => { @@ -140,12 +140,12 @@ function WorkspaceNewRoomPage() { }, [isLoading, errorFields]); useEffect(() => { - if (isPolicyAdmin) { + if (isAdminPolicy) { return; } setWriteCapability(CONST.REPORT.WRITE_CAPABILITIES.ALL); - }, [isPolicyAdmin]); + }, [isAdminPolicy]); /** * @param values - form input values passed by the Form component @@ -157,27 +157,23 @@ function WorkspaceNewRoomPage() { if (!values.roomName || values.roomName === CONST.POLICY.ROOM_PREFIX) { // We error if the user doesn't enter a room name or left blank - ErrorUtils.addErrorMessage(errors, 'roomName', translate('newRoomPage.pleaseEnterRoomName')); - } else if (values.roomName !== CONST.POLICY.ROOM_PREFIX && !ValidationUtils.isValidRoomName(values.roomName)) { + addErrorMessage(errors, 'roomName', translate('newRoomPage.pleaseEnterRoomName')); + } else if (values.roomName !== CONST.POLICY.ROOM_PREFIX && !isValidRoomName(values.roomName)) { // We error if the room name has invalid characters - ErrorUtils.addErrorMessage(errors, 'roomName', translate('newRoomPage.roomNameInvalidError')); - } else if (ValidationUtils.isReservedRoomName(values.roomName)) { + addErrorMessage(errors, 'roomName', translate('newRoomPage.roomNameInvalidError')); + } else if (isReservedRoomName(values.roomName)) { // Certain names are reserved for default rooms and should not be used for policy rooms. - ErrorUtils.addErrorMessage(errors, 'roomName', translate('newRoomPage.roomNameReservedError', {reservedName: values.roomName})); - } else if (ValidationUtils.isExistingRoomName(values.roomName, reports, values.policyID ?? '-1')) { + addErrorMessage(errors, 'roomName', translate('newRoomPage.roomNameReservedError', {reservedName: values.roomName})); + } else if (isExistingRoomName(values.roomName, reports, values.policyID)) { // Certain names are reserved for default rooms and should not be used for policy rooms. - ErrorUtils.addErrorMessage(errors, 'roomName', translate('newRoomPage.roomAlreadyExistsError')); + addErrorMessage(errors, 'roomName', translate('newRoomPage.roomAlreadyExistsError')); } else if (values.roomName.length > CONST.TITLE_CHARACTER_LIMIT) { - ErrorUtils.addErrorMessage(errors, 'roomName', translate('common.error.characterLimitExceedCounter', {length: values.roomName.length, limit: CONST.TITLE_CHARACTER_LIMIT})); + addErrorMessage(errors, 'roomName', translate('common.error.characterLimitExceedCounter', {length: values.roomName.length, limit: CONST.TITLE_CHARACTER_LIMIT})); } - const descriptionLength = ReportUtils.getCommentLength(values.reportDescription, {policyID}); + const descriptionLength = getCommentLength(values.reportDescription, {policyID}); if (descriptionLength > CONST.REPORT_DESCRIPTION.MAX_LENGTH) { - ErrorUtils.addErrorMessage( - errors, - 'reportDescription', - translate('common.error.characterLimitExceedCounter', {length: descriptionLength, limit: CONST.REPORT_DESCRIPTION.MAX_LENGTH}), - ); + addErrorMessage(errors, 'reportDescription', translate('common.error.characterLimitExceedCounter', {length: descriptionLength, limit: CONST.REPORT_DESCRIPTION.MAX_LENGTH})); } if (!values.policyID) { @@ -289,7 +285,7 @@ function WorkspaceNewRoomPage() { maxLength={CONST.REPORT_DESCRIPTION.MAX_LENGTH} autoCapitalize="none" shouldInterceptSwipe - isMarkdownEnabled + type="markdown" /> @@ -302,7 +298,7 @@ function WorkspaceNewRoomPage() { onValueChange={(value) => setPolicyID(value as typeof policyID)} /> - {isPolicyAdmin && ( + {isAdminPolicy && ( { if (!isInputInitializedRef.current) { updateMultilineInputRange(el); diff --git a/src/stories/Composer.stories.tsx b/src/stories/Composer.stories.tsx index a92dc0e789a0..efb8e05614e2 100644 --- a/src/stories/Composer.stories.tsx +++ b/src/stories/Composer.stories.tsx @@ -5,7 +5,7 @@ import React, {useState} from 'react'; import {Image, View} from 'react-native'; import type {FileObject} from '@components/AttachmentModal'; import Composer from '@components/Composer'; -import type {ComposerProps} from '@components/Composer/types'; +import type {ComposerProps, CustomSelectionChangeEvent, TextSelection} from '@components/Composer/types'; import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; import withNavigationFallback from '@components/withNavigationFallback'; @@ -28,11 +28,17 @@ const story: Meta = { const parser = new ExpensiMark(); +const DEFAULT_VALUE = `Composer can do the following: + + * It can contain MD e.g. *bold* _italic_ + * Supports Pasted Images via Ctrl+V`; + function Default(props: ComposerProps) { const StyleUtils = useStyleUtils(); const [pastedFile, setPastedFile] = useState(null); - const [comment, setComment] = useState(props.defaultValue); + const [comment, setComment] = useState(DEFAULT_VALUE); const renderedHTML = parser.replace(comment ?? ''); + const [selection, setSelection] = useState(() => ({start: DEFAULT_VALUE.length, end: DEFAULT_VALUE.length, positionX: 0, positionY: 0})); return ( @@ -41,8 +47,13 @@ function Default(props: ComposerProps) { // eslint-disable-next-line react/jsx-props-no-spreading {...props} multiline + value={comment} onChangeText={setComment} onPasteFile={setPastedFile} + selection={selection} + onSelectionChange={(e: CustomSelectionChangeEvent) => { + setSelection(e.nativeEvent.selection); + }} style={[defaultStyles.textInputCompose, defaultStyles.w100, defaultStyles.verticalAlignTop]} /> @@ -73,10 +84,6 @@ Default.args = { autoFocus: true, placeholder: 'Compose Text Here', placeholderTextColor: defaultTheme.placeholderText, - defaultValue: `Composer can do the following: - - * It can contain MD e.g. *bold* _italic_ - * Supports Pasted Images via Ctrl+V`, isDisabled: false, maxLines: 16, }; diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 968b9a4dea4b..a573bce74e27 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -82,7 +82,7 @@ type Comment = { splits?: Split[]; /** Violations that were dismissed */ - dismissedViolations?: Record>; + dismissedViolations?: Partial>>; }; /** Model of transaction custom unit */ diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 291d2d4ac1e3..eebaed8a607d 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -77,8 +77,19 @@ jest.mock('@src/libs/Navigation/Navigation', () => ({ goBack: jest.fn(), getTopmostReportId: jest.fn(() => topMostReportID), setNavigationActionToMicrotaskQueue: jest.fn(), + removeScreenByKey: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + getPreviousTrackReport: jest.fn(), })); +jest.mock('@src/libs/Navigation/navigationRef', () => ({ + getRootState: () => ({ + routes: [], + }), +})); + +jest.mock('@react-navigation/native'); + jest.mock('@src/libs/actions/Report', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const originalModule = jest.requireActual('@src/libs/actions/Report'); @@ -88,6 +99,7 @@ jest.mock('@src/libs/actions/Report', () => { notifyNewAction: jest.fn(), }; }); + jest.mock('@src/libs/Navigation/isSearchTopmostCentralPane', () => jest.fn()); const CARLOS_EMAIL = 'cmartins@expensifail.com'; diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 659240cc0c30..b161ea8fe40c 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -1,9 +1,12 @@ import {beforeEach} from '@jest/globals'; import Onyx from 'react-native-onyx'; +import {getTransactionViolations, hasWarningTypeViolation, isViolationDismissed} from '@libs/TransactionUtils'; import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyTagLists, Transaction, TransactionViolation} from '@src/types/onyx'; +import type {TransactionCollectionDataSet} from '@src/types/onyx/Transaction'; +import type {TransactionViolationsCollectionDataSet} from '@src/types/onyx/TransactionViolation'; const categoryOutOfPolicyViolation = { name: CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY, @@ -30,6 +33,16 @@ const tagOutOfPolicyViolation = { type: CONST.VIOLATION_TYPES.VIOLATION, }; +const smartScanFailedViolation = { + name: CONST.VIOLATIONS.SMARTSCAN_FAILED, + type: CONST.VIOLATION_TYPES.WARNING, +}; + +const duplicatedTransactionViolation = { + name: CONST.VIOLATIONS.DUPLICATED_TRANSACTION, + type: CONST.VIOLATION_TYPES.WARNING, +}; + describe('getViolationsOnyxData', () => { let transaction: Transaction; let transactionViolations: TransactionViolation[]; @@ -349,3 +362,91 @@ describe('getViolationsOnyxData', () => { }); }); }); + +const getFakeTransaction = (transactionID: string, comment?: Transaction['comment']) => ({ + transactionID, + attendees: [{email: 'text@expensify.com'}], + reportID: '1234', + amount: 100, + comment: comment ?? {}, + created: '2023-07-24 13:46:20', + merchant: 'United Airlines', + currency: 'USD', +}); + +const CARLOS_EMAIL = 'cmartins@expensifail.com'; +const CARLOS_ACCOUNT_ID = 1; + +describe('getViolations', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: { + email: CARLOS_EMAIL, + accountID: CARLOS_ACCOUNT_ID, + }, + }, + }); + }); + + afterEach(() => Onyx.clear()); + + it('should check if violation is dismissed or not', async () => { + const transaction = getFakeTransaction('123', { + dismissedViolations: {smartscanFailed: {[CARLOS_EMAIL]: CARLOS_ACCOUNT_ID.toString()}}, + }); + + const transactionCollectionDataSet: TransactionCollectionDataSet = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: transaction, + }; + + await Onyx.multiSet({...transactionCollectionDataSet}); + + const isSmartScanDismissed = isViolationDismissed(transaction.transactionID, smartScanFailedViolation); + const isDuplicateViolationDismissed = isViolationDismissed(transaction.transactionID, duplicatedTransactionViolation); + + expect(isSmartScanDismissed).toBeTruthy(); + expect(isDuplicateViolationDismissed).toBeFalsy(); + }); + + it('should return filtered out dismissed violations', async () => { + const transaction = getFakeTransaction('123', { + dismissedViolations: {smartscanFailed: {[CARLOS_EMAIL]: CARLOS_ACCOUNT_ID.toString()}}, + }); + + const transactionCollectionDataSet: TransactionCollectionDataSet = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: transaction, + }; + + const transactionViolationsCollection: TransactionViolationsCollectionDataSet = { + [`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]: [duplicatedTransactionViolation, smartScanFailedViolation, tagOutOfPolicyViolation], + }; + + await Onyx.multiSet({...transactionCollectionDataSet, ...transactionViolationsCollection}); + + // Should filter out the smartScanFailedViolation + const filteredViolations = getTransactionViolations(transaction.transactionID); + expect(filteredViolations).toEqual([duplicatedTransactionViolation, tagOutOfPolicyViolation]); + }); + + it('checks if transaction has warning type violation after filtering dismissed violations', async () => { + const transaction = getFakeTransaction('123', { + dismissedViolations: {smartscanFailed: {[CARLOS_EMAIL]: CARLOS_ACCOUNT_ID.toString()}}, + }); + + const transactionCollectionDataSet: TransactionCollectionDataSet = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: transaction, + }; + + const transactionViolationsCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]: [duplicatedTransactionViolation, smartScanFailedViolation, tagOutOfPolicyViolation], + }; + + await Onyx.multiSet({...transactionCollectionDataSet}); + + // Should filter out the smartScanFailedViolation and return true, duplicatedTransactionViolation is a warning type violation but it's not considered in hasWarningTypeViolation + const hasWarningTypeViolationRes = hasWarningTypeViolation(transaction.transactionID, transactionViolationsCollection); + expect(hasWarningTypeViolationRes).toBeFalsy(); + }); +});