diff --git a/.env.example b/.env.example index 944da2aa9296..f398a72aa0af 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,7 @@ EXPENSIFY_ACCOUNT_ID_RECEIPTS=-1 EXPENSIFY_ACCOUNT_ID_REWARDS=-1 EXPENSIFY_ACCOUNT_ID_STUDENT_AMBASSADOR=-1 EXPENSIFY_ACCOUNT_ID_SVFG=-1 + +FB_API_KEY=YOUR_API_KEY +FB_APP_ID=YOUR_APP_ID +FB_PROJECT_ID=YOUR_PROJECT_ID diff --git a/.env.production b/.env.production index 5e676134d681..bb925eb70d39 100644 --- a/.env.production +++ b/.env.production @@ -7,3 +7,7 @@ PUSHER_APP_KEY=268df511a204fbb60884 USE_WEB_PROXY=false ENVIRONMENT=production SEND_CRASH_REPORTS=true + +FB_API_KEY=AIzaSyDxzigVLZl4G8MP7jACQ0qpmADMzmrrON0 +FB_APP_ID=1:921154746561:web:1583e882584cf151027c40 +FB_PROJECT_ID=expensify-chat diff --git a/android/app/build.gradle b/android/app/build.gradle index 7634aea8f5f9..11430a438fc9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009003601 - versionName "9.0.36-1" + versionCode 1009003700 + versionName "9.0.37-0" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md index 4f949aa6b8b4..82e368214223 100644 --- a/contributingGuides/CONTRIBUTING.md +++ b/contributingGuides/CONTRIBUTING.md @@ -4,7 +4,7 @@ Welcome! Thanks for checking out the New Expensify app and taking the time to co ## Getting Started If you would like to become an Expensify contributor, the first step is to read this document in its **entirety**. The second step is to review the README guidelines [here](https://github.com/Expensify/App/blob/main/README.md) to understand our coding philosophy and for a general overview of the code repository (i.e. how to run the app locally, testing, storage, our app philosophy, etc). Please read both documents before asking questions, as it may be covered within the documentation. -#### Test Accounts +### Test Accounts You can create as many accounts as needed in order to test your changes directly from [the app](https://new.expensify.com/). An initial account can be created when logging in for the first time, and additional accounts can be created by opening the "New Chat" or "Group Chat" pages via the Global Create menu, inputting a valid email or phone number, and tapping the user's avatar. Do not use Expensify employee or customer accounts for testing. **Notes**: @@ -12,17 +12,17 @@ You can create as many accounts as needed in order to test your changes directly 1. When testing chat functionality in the app please do this between accounts you or your fellow contributors own - **do not test chatting with Concierge**, as this diverts to our customer support team. Thank you. 2. A member of our customer onboarding team gets auto-assigned to every new policy created by a non-paying account to help them set up. Please **do not interact with these teams, ask for calls, or support on your issues.** If you do need to test functionality inside the defaultRooms (#admins & #announce) for any issues you’re working on, please let them know that you are a contributor and don’t need assistance. They will proceed to ignore the chat. -##### Generating Multiple Test Accounts +#### Generating Multiple Test Accounts You can generate multiple test accounts by using a `+` postfix, for example if your email is test@test.com, you can create multiple New Expensify accounts connected to the same email address by using test+123@test.com, test+456@test.com, etc. -##### High Traffic Accounts +#### High Traffic Accounts All internal engineers, contributors, and C+ members are required to test with a “high traffic” account against the staging or production web servers. Use these Google forms to manage your high-traffic accounts. You'll need to authenticate via Google first. 1. [Make an account high-traffic](https://docs.google.com/forms/d/e/1FAIpQLScpiS0Mo-HA5xHPsvDow79yTsMBgF0wjuqc0K37lTK5fheB8Q/viewform) 2. [Remove a high-traffic account](https://docs.google.com/forms/d/e/1FAIpQLSd9_FDav83pnhhtu1KGAKIpf2yttQ_0Bvq1b9nuFM1-wbL11Q/viewform) -#### Working on beta features +### Working on beta features Some features are locked behind beta flags while development is ongoing. As a contributor you can work on these beta features locally by overriding the [`Permissions.canUseAllBetas` function](https://github.com/Expensify/App/blob/5e268df7f2989ed04bc64c0c86ed77faf134554d/src/libs/Permissions.js#L10-L12) to return `true`. ## Code of Conduct @@ -67,35 +67,76 @@ The 168 hours (aka 7 days) will be measured by calculating the time between when ## Finding Jobs A job could be fixing a bug or working on a new feature. There are two ways you can find a job that you can contribute to: -#### Finding a job that Expensify posted +### Finding a job that Expensify posted This is the most common scenario for contributors. The Expensify team posts new jobs to the Upwork job list [here](https://www.upwork.com/nx/search/jobs/?nbs=1&q=expensify%20react%20native&sort=recency&user_location_match=2) (you must be signed in to Upwork to view jobs). Each job in Upwork has a corresponding GitHub issue, which will include instructions to follow. You can also view all open jobs in the Expensify/App GH repository by searching for GH issues with the [`Help Wanted` label](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22). Lastly, you can follow the [@ExpensifyOSS](https://twitter.com/ExpensifyOSS) Twitter account to see a live feed of jobs that are posted. ->**Note:** Our problem solving approach at Expensify is to focus on high value problems and avoid small optimizations with results that are difficult to measure. We also prefer to identify and solve problems at their root. Given that, please ensure all proposed jobs fix a specific problem in a measurable way with evidence so they are easy to evaluate. Here's an example of a good problem/solution: +### Posting proposals for new projects +Our problem solving approach at Expensify is to focus on high value problems and avoid small optimizations with results that are difficult to measure. We also prefer to identify and solve problems at their root. Given that, please ensure all proposed jobs fix a specific problem in a measurable way with evidence so they are easy to evaluate. If you want to propose that Expensify implement some idea you have, you should post it in the #expensify-open-source slack room with the `Strategy`/`Problem`/`Solution` format. + +#### How to write a good problem statement +A good problem statement is in this format: + +> **Problem**: When X happens, it causes Y, which prevents us from Z. + +Just state the direct cause and effect, with minimal fluff and analysis. + +In short, a good problem statement makes no mention whatsoever of the desired solution. This sounds obvious, but is easier said than done. The point of not mentioning the solution is to force you to identify the actual problem you are trying to solve, and not waving your hands with a "reverse solution statement". For example: + +**Bad:** +> **Problem:** We don't have a car +> +> **Solution:** Buy a car. + + +**Good:** +> **Problem:** I want to buy a new chair, but it's too heavy for me to carry it home, so I can't sit in it. +> +> **Solution:** Buy a truck. > ->**Problem:** The app start up time has regressed because we introduced "New Feature" in PR #12345 and is now 1042ms slower because `SomeComponent` is re-rendering 42 times. +> **Solution:** Rent a truck. > ->**Solution:** Start up time will perceptibly decrease by 1042ms if we prevent the unnecessary re-renders of this component. +> **Solution:** Hire movers. +> +> **Solution:** Buy a bean bag. -## Working on Expensify Jobs -*Reminder: For technical guidance, please refer to the [README](https://github.com/Expensify/App/blob/main/README.md)*. -## Posting Ideas +A real problem description enables a much richer discussion to find more creative solutions. Having multiple viable solutions is a good indicator that your problem statement is *NOT* a reverse solution description. + +#### How to write a bad problem statement + +Similarly, a sign that you are actually doing a reverse solution statement is if it contains phrases like: + +* **Problem:** We don't do/have X; **Solution:** Build X + * Rewrite the problem statement to explain the consequence of not doing/having X. You might think it is incredibly obvious -- so obvious that it doesn't need to be stated. But just state it outright. An obvious problem statement is the goal: this is not a place to be clever, it's a place to be clear. +* **Problem:** We lack insight/knowledge/awareness/visibility; **Solution:** Create insight + * Information isn't itself valuable. Not having it isn't inherently a problem; having it isn't itself a solution. Information is only valuable when it enables us to act -- so the problem isn't the lack of information, but the problem is that there is something we aren't doing. Part of the solution might be getting more information -- but the information isn't the whole solution, the solution is that we are doing something with that information that we weren't doing before. +* **Problem:** There are problems A, B, C, D, and E. **Solution:** Do F. + * Focus on one problem at a time, to enable a collaborative conversation about each of the problems in isolation. Are all of A-E equally important? If one stands out, lead with it and focus on it. Otherwise, it's likely you are just "fitting the problem to the solution" and trying to invent a justification for a solution you've already mentally committed to, and aren't genuinely trying to create a collaborative, problem-focused solution. +* **Problem:** X is inefficient/error-prone. **Solution:** Do Y. + * Everything is inefficient and error prone. Everything is cumbersome. Without some kind of quantification, they are not problems, they are just statements of reality. Without some kind of measurable improvement, it's not a solution: it's just a change. Focus on the actual, tangible, measurable problems that can be provably solved. + +Basically, a bad problem statement (ie, a reverse solution statement) is written in such a fashion that it only allows for a single solution. To enable the most creative, most collaborative discussion, equip your peers with the tools to engage by listing all the key assumptions that went into your understanding of the problem, and connecting that directly to the solution. + +### Posting Ideas Additionally, if you want to discuss an idea with the open source community without having a P/S statement yet, you can post it in #expensify-open-source with the prefix `IDEA:`. All ideas to build the future of Expensify are always welcome! i.e.: "`IDEA:` I don't have a P/S for this yet, but just kicking the idea around... what if we [insert crazy idea]?". -#### Make sure you can test on all platforms +## Working on Expensify Jobs +*Reminder: For technical guidance, please refer to the [README](https://github.com/Expensify/App/blob/main/README.md)*. + +### Make sure you can test on all platforms * Expensify requires that you can test the app on iOS, MacOS, Android, Web, and mWeb. * You'll need a Mac to test the iOS and MacOS app. * In case you don't have one, here's a helpful [document](https://github.com/Expensify/App/blob/main/contributingGuides/TESTING_MACOS_AND_IOS.md) on how you might test all platforms on a Windows/Linux device. -#### Check GitHub for existing proposals from other users +### Check GitHub for existing proposals from other users 1. Expensify reviews all solution proposals on a first come first serve basis. If you see other contributors have already proposed a solution, you can still provide a solution proposal and we will review it. We look for the earliest provided, best proposed solution that addresses the job. -#### Make sure you can reproduce the problem +### Make sure you can reproduce the problem 2. Use your test account(s) to reproduce the problem by following the steps in the GitHub issue. 3. If you cannot reproduce the problem, pause on this step and add a comment to the issue explaining where you are stuck or that you don't think the issue can be reproduced. -#### Propose a solution for the job +### Propose a solution for the job 4. You can propose solutions on any issue at any time, but if you propose solutions to jobs before the `Help Wanted` label is applied, you do so at your own risk. Proposals will not be reviewed until the label is added and there is always a chance that we might not add the label or hire an external contributor for the job. 5. Contributors should **not** submit proposals on issues when they have assigned issues or PRs that are awaiting an action from them. If so, they will be in violation of Rule #1 (Get Shit Done) in our [Code of Conduct](https://github.com/Expensify/App/blob/main/CODE_OF_CONDUCT.md) and will receive a warning. Multiple warnings can lead to removal from the program. 6. After you reproduce the issue, complete the [proposal template here](./PROPOSAL_TEMPLATE.md) and post it as a comment in the corresponding GitHub issue (linked in the Upwork job). @@ -111,7 +152,7 @@ Additionally, if you want to discuss an idea with the open source community with 8. If your proposal is accepted by the Expensify engineer assigned to the issue, Expensify will hire you on Upwork and assign the GitHub issue to you. 9. Once hired, post a comment in the Github issue stating when you expect to have your PR ready for review. -#### Begin coding your solution in a pull request +### Begin coding your solution in a pull request 9. When you are ready to start, fork the repository and create a new branch. 10. Before you begin writing any code, please be aware that we require all commits to be [signed](https://docs.github.com/en/github/authenticating-to-github/signing-commits). The easiest way to do that is to [generate a new GPG key](https://docs.github.com/en/github/authenticating-to-github/generating-a-new-gpg-key) and [add it to your GitHub account](https://docs.github.com/en/github/authenticating-to-github/adding-a-new-gpg-key-to-your-github-account). Once you've done that, you can automatically sign all your commits by adding the following to your `.gitconfig`: ``` @@ -128,7 +169,7 @@ Additionally, if you want to discuss an idea with the open source community with 12. An Expensify engineer and a member from the Contributor-Plus team will be assigned to your pull request automatically to review. 13. Daily updates on weekdays are highly recommended. If you know you won’t be able to provide updates within 48 hours, please comment on the PR or issue stating how long you plan to be out so that we may plan accordingly. We understand everyone needs a little vacation here and there. Any issue that doesn't receive an update for 5 days (including weekend days) may be considered abandoned and the original contract terminated. -#### Submit your pull request for final review +### Submit your pull request for final review 14. When you are ready to submit your pull request for final review, make sure the following checks pass: 1. CLA - You must sign our [Contributor License Agreement](https://github.com/Expensify/App/blob/main/contributingGuides/CLA.md) by following the CLA bot instructions that will be posted on your PR 2. Tests - All tests must pass before a merge of a pull request @@ -138,7 +179,7 @@ Additionally, if you want to discuss an idea with the open source community with 17. Upon submission of a PR, please include a numbered list of explicit testing steps for each platform (Web, Desktop, iOS, Android, and Mobile Web) to confirm the fix works as expected and there are no regressions. 18. Please add a screenshot of the app running on each platform (Web, Desktop, iOS, Android, Mobile Web). -#### Completing the final checklist +### Completing the final checklist 19. Once your PR has been deployed to production, a checklist will automatically be commented in the GH issue. You're required to complete the steps that have your name mentioned before payment will be issued. 20. The items requiring your completion consist of: 1. Proposing steps to take for a regression test to ensure the bug doesn't occur again (For information on how to successfully complete this, head [here](https://github.com/Expensify/App/blob/main/contributingGuides/REGRESSION_TEST_BEST_PRACTICES.md)). @@ -147,23 +188,23 @@ Additionally, if you want to discuss an idea with the open source community with 4. Starting a conversation on if any additional steps should be taken to prevent further bugs similar to the one fixed from occurring again. 21. Once the above items have been successfully completed, then payments will begin to be issued. -#### Timeline expectations and asking for help along the way +### Timeline expectations and asking for help along the way - If you have made a change to your pull request and are ready for another review, leave a comment that says "Updated" on the pull request itself. - Please keep the conversation in GitHub, and do not ping individual reviewers in Slack or Upwork to get their attention. - Pull Request reviews can sometimes take a few days. If your pull request has not been addressed after four days, please let us know via the #expensify-open-source Slack channel. - On occasion, our engineers will need to focus on a feature release and choose to place a hold on the review of your PR. -#### Important note about JavaScript Style +### Important note about JavaScript Style - Read our official [JavaScript and React style guide](https://github.com/Expensify/App/blob/main/contributingGuides/STYLE.md). Please refer to our Style Guide before asking for a review. -#### For external agencies that Expensify partners with +### For external agencies that Expensify partners with Follow all the above above steps and processes. When you find a job you'd like to work on: - Post “I’m from [agency], I’d like to work on this job” - If no proposals have been submitted by other contributors, BugZero (BZ) team member or an internal engineer will assign the issue to you. - If there are existing proposals, BZ will put the issue on hold. [Contributor+](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_BECOME_A_CONTRIBUTOR_PLUS.md) will review the existing proposals. If a contributor’s proposal is accepted then the contributor will be assigned to the issue. If not the issue will be assigned to the agency-employee. - Once assigned follow the steps [here](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#propose-a-solution-for-the-job) to submit your proposal -#### Guide on Acronyms used within Expensify Communication +## Guide on Acronyms used within Expensify Communication During communication with Expensify, you will come across a variety of acronyms used by our team. While acronyms can be useful, they cease to be the moment they are not known to the receiver. As such, we wanted to create a list here of our most commonly used acronyms and what they're referring to. Lastly, please never hesitate to ask in Slack or the GH issue if there are any that are not understood/known! - **ND/NewDot:** new.expensify.com - **OD/OldDot:** expensify.com diff --git a/docs/articles/expensify-classic/connections/sage-intacct/Sage-Intacct-Troubleshooting.md b/docs/articles/expensify-classic/connections/sage-intacct/Sage-Intacct-Troubleshooting.md index e12f8028c4c8..6cf9181966d0 100644 --- a/docs/articles/expensify-classic/connections/sage-intacct/Sage-Intacct-Troubleshooting.md +++ b/docs/articles/expensify-classic/connections/sage-intacct/Sage-Intacct-Troubleshooting.md @@ -87,7 +87,7 @@ For multiple workspaces connected to the same accounting system: ## Credit Card Configuration is Missing -**Sage Intacct: Credit Card Configuration is Missing** +**Sage Intacct: Credit Card Configuration is Missing / You haven't yet set up credit cards in Sage Intacct** When attempting to export non-reimbursable (company card) expenses to Sage Intacct, you may encounter an error stating “Credit Card Configuration is Missing” or “Charge Card Configuration is Missing.” This occurs because Sage Intacct requires a credit card account to be set up in order to export these expenses as credit card transactions. diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 717ffc789054..3747b826c5f8 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.36 + 9.0.37 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.36.1 + 9.0.37.0 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index ee36b22be568..8adae57b17ac 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.36 + 9.0.37 CFBundleSignature ???? CFBundleVersion - 9.0.36.1 + 9.0.37.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 39dbe2194b38..edfb80d0153b 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.36 + 9.0.37 CFBundleVersion - 9.0.36.1 + 9.0.37.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index a8d151d3811d..0f1a42791d1e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1774,7 +1774,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-pdf (6.7.5): + - react-native-pdf (6.7.3): - DoubleConversion - glog - hermes-engine @@ -3182,7 +3182,7 @@ SPEC CHECKSUMS: react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d react-native-netinfo: fb5112b1fa754975485884ae85a3fb6a684f49d5 react-native-pager-view: 94195f1bf32e7f78359fa20057c97e632364a08b - react-native-pdf: 2e2591ebd39422163850403b1c0cd7d6b351e168 + react-native-pdf: dd6ae39a93607a80919bef9f3499e840c693989d react-native-performance: 3c608307be10964f8a97d3af462f37125b6d8fa5 react-native-plaid-link-sdk: f91a22b45b7c3d4cd6c47273200dc57df35068b0 react-native-quick-sqlite: 7c793c9f5834e756b336257a8d8b8239b7ceb451 @@ -3246,7 +3246,7 @@ SPEC CHECKSUMS: SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d Turf: aa2ede4298009639d10db36aba1a7ebaad072a5e VisionCamera: c6c8aa4b028501fc87644550fbc35a537d4da3fb - Yoga: 2a45d7e59592db061217551fd3bbe2dd993817ae + Yoga: a1d7895431387402a674fd0d1c04ec85e87909b8 PODFILE CHECKSUM: e479ec84cb53e5fd463486d71dfee91708d3fd9a diff --git a/package-lock.json b/package-lock.json index 8d484d2a7e28..d21d80353080 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,20 @@ { "name": "new.expensify", - "version": "9.0.36-1", + "version": "9.0.37-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.36-1", + "version": "9.0.37-0", "hasInstallScript": true, "license": "MIT", "dependencies": { "@dotlottie/react-player": "^1.6.3", "@expensify/react-native-live-markdown": "0.1.143", "@expo/metro-runtime": "~3.2.3", + "@firebase/app": "^0.10.10", + "@firebase/performance": "^0.6.8", "@formatjs/intl-datetimeformat": "^6.12.5", "@formatjs/intl-listformat": "^7.5.7", "@formatjs/intl-locale": "^4.0.0", @@ -97,7 +99,7 @@ "react-native-modal": "^13.0.0", "react-native-onyx": "2.0.68", "react-native-pager-view": "6.4.1", - "react-native-pdf": "6.7.5", + "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.10.0", "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf", @@ -5413,6 +5415,72 @@ "node": ">=8" } }, + "node_modules/@firebase/app": { + "version": "0.10.10", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.10.10.tgz", + "integrity": "sha512-sDqkdeFdVn5uygQm5EuIKOQ6/wxTcX/qKfm0MR46AiwLRHGLCDUMrXBkc8GhkK3ca2d6mPUSfPmndggo43D6PQ==", + "dependencies": { + "@firebase/component": "0.6.8", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.9.7", + "idb": "7.1.1", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/component": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.8.tgz", + "integrity": "sha512-LcNvxGLLGjBwB0dJUsBGCej2fqAepWyBubs4jt1Tiuns7QLbXHuyObZ4aMeBjZjWx4m8g1LoVI9QFpSaq/k4/g==", + "dependencies": { + "@firebase/util": "1.9.7", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/installations": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.8.tgz", + "integrity": "sha512-57V374qdb2+wT5v7+ntpLXBjZkO6WRgmAUbVkRfFTM/4t980p0FesbqTAcOIiM8U866UeuuuF8lYH70D3jM/jQ==", + "dependencies": { + "@firebase/component": "0.6.8", + "@firebase/util": "1.9.7", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.2.tgz", + "integrity": "sha512-Q1VuA5M1Gjqrwom6I6NUU4lQXdo9IAQieXlujeHZWvRt1b7qQ0KwBaNAjgxG27jgF9/mUwsNmO8ptBCGVYhB0A==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/performance": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.6.8.tgz", + "integrity": "sha512-F+alziiIZ6Yn8FG47mxwljq+4XkgkT2uJIFRlkyViUQRLzrogaUJW6u/+6ZrePXnouKlKIwzqos3PVJraPEcCA==", + "dependencies": { + "@firebase/component": "0.6.8", + "@firebase/installations": "0.6.8", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.9.7", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.9.7.tgz", + "integrity": "sha512-fBVNH/8bRbYjqlbIhZ+lBtdAAS4WqZumx03K06/u7fJSpz1TGjEMm1ImvKD47w+xaFKIP2ori6z8BrbakRfjJA==", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/@formatjs/ecma402-abstract": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.0.0.tgz", @@ -7189,9 +7257,10 @@ } }, "node_modules/@polka/url": { - "version": "1.0.0-next.21", - "dev": true, - "license": "MIT" + "version": "1.0.0-next.25", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", + "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==", + "dev": true }, "node_modules/@radix-ui/primitive": { "version": "1.1.0", @@ -25269,8 +25338,9 @@ }, "node_modules/gzip-size": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", "dev": true, - "license": "MIT", "dependencies": { "duplexer": "^0.1.2" }, @@ -25895,6 +25965,11 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" + }, "node_modules/idb-keyval": { "version": "6.2.1", "license": "Apache-2.0" @@ -31185,9 +31260,10 @@ "peer": true }, "node_modules/mrmime": { - "version": "1.0.1", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", + "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" } @@ -34720,9 +34796,8 @@ } }, "node_modules/react-native-pdf": { - "version": "6.7.5", - "resolved": "https://registry.npmjs.org/react-native-pdf/-/react-native-pdf-6.7.5.tgz", - "integrity": "sha512-d1S76p2Vwax2iG+kTnjINiUMvpjtJJvtMiYwHRbgGczT8GJjtXH49YCWOd+HfnUAU29cB+knzsKGYoZBMQM8Ow==", + "version": "6.7.3", + "license": "MIT", "dependencies": { "crypto-js": "4.2.0", "deprecated-react-native-prop-types": "^2.3.0" @@ -37445,13 +37520,14 @@ } }, "node_modules/sirv": { - "version": "1.0.19", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", "dev": true, - "license": "MIT", "dependencies": { - "@polka/url": "^1.0.0-next.20", - "mrmime": "^1.0.0", - "totalist": "^1.0.0" + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" }, "engines": { "node": ">= 10" @@ -38839,9 +38915,10 @@ } }, "node_modules/totalist": { - "version": "1.1.0", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -40033,18 +40110,22 @@ } }, "node_modules/webpack-bundle-analyzer": { - "version": "4.5.0", + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", + "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", "dev": true, - "license": "MIT", "dependencies": { + "@discoveryjs/json-ext": "0.5.7", "acorn": "^8.0.4", "acorn-walk": "^8.0.0", - "chalk": "^4.1.0", "commander": "^7.2.0", + "debounce": "^1.2.1", + "escape-string-regexp": "^4.0.0", "gzip-size": "^6.0.0", - "lodash": "^4.17.20", + "html-escaper": "^2.0.2", "opener": "^1.5.2", - "sirv": "^1.0.7", + "picocolors": "^1.0.0", + "sirv": "^2.0.3", "ws": "^7.3.1" }, "bin": { @@ -40055,9 +40136,10 @@ } }, "node_modules/webpack-bundle-analyzer/node_modules/acorn": { - "version": "8.10.0", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, - "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -40066,85 +40148,26 @@ } }, "node_modules/webpack-bundle-analyzer/node_modules/acorn-walk": { - "version": "8.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/webpack-bundle-analyzer/node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/webpack-bundle-analyzer/node_modules/chalk": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/webpack-bundle-analyzer/node_modules/color-convert": { - "version": "2.0.1", + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz", + "integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==", "dev": true, - "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "acorn": "^8.11.0" }, "engines": { - "node": ">=7.0.0" + "node": ">=0.4.0" } }, - "node_modules/webpack-bundle-analyzer/node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "license": "MIT" - }, "node_modules/webpack-bundle-analyzer/node_modules/commander": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "dev": true, - "license": "MIT", "engines": { "node": ">= 10" } }, - "node_modules/webpack-bundle-analyzer/node_modules/has-flag": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/webpack-bundle-analyzer/node_modules/supports-color": { - "version": "7.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/webpack-bundle-analyzer/node_modules/ws": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", diff --git a/package.json b/package.json index 55cbca9bcb6d..0158dfbefdba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.36-1", + "version": "9.0.37-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -70,6 +70,8 @@ "@dotlottie/react-player": "^1.6.3", "@expensify/react-native-live-markdown": "0.1.143", "@expo/metro-runtime": "~3.2.3", + "@firebase/app": "^0.10.10", + "@firebase/performance": "^0.6.8", "@formatjs/intl-datetimeformat": "^6.12.5", "@formatjs/intl-listformat": "^7.5.7", "@formatjs/intl-locale": "^4.0.0", @@ -154,7 +156,7 @@ "react-native-modal": "^13.0.0", "react-native-onyx": "2.0.68", "react-native-pager-view": "6.4.1", - "react-native-pdf": "6.7.5", + "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.10.0", "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf", diff --git a/patches/react-native-pdf+6.7.3.patch b/patches/react-native-pdf+6.7.3.patch new file mode 100644 index 000000000000..0f0f270cefd1 --- /dev/null +++ b/patches/react-native-pdf+6.7.3.patch @@ -0,0 +1,53 @@ +diff --git a/node_modules/react-native-pdf/react-native-pdf.podspec b/node_modules/react-native-pdf/react-native-pdf.podspec +index fb36140..5d5f19e 100644 +--- a/node_modules/react-native-pdf/react-native-pdf.podspec ++++ b/node_modules/react-native-pdf/react-native-pdf.podspec +@@ -17,24 +17,11 @@ Pod::Spec.new do |s| + s.framework = "PDFKit" + + if fabric_enabled +- folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32' +- +- s.pod_target_xcconfig = { +- 'HEADER_SEARCH_PATHS' => '"$(PODS_ROOT)/boost" "$(PODS_ROOT)/boost-for-react-native" "$(PODS_ROOT)/RCT-Folly"', +- "CLANG_CXX_LANGUAGE_STANDARD" => "c++17", +- } + s.platforms = { ios: '11.0', tvos: '11.0' } +- s.compiler_flags = folly_compiler_flags + ' -DRCT_NEW_ARCH_ENABLED' + s.source_files = 'ios/**/*.{h,m,mm,cpp}' + s.requires_arc = true + +- s.dependency "React" +- s.dependency "React-RCTFabric" +- s.dependency "React-Codegen" +- s.dependency "RCT-Folly" +- s.dependency "RCTRequired" +- s.dependency "RCTTypeSafety" +- s.dependency "ReactCommon/turbomodule/core" ++ install_modules_dependencies(s) + else + s.platform = :ios, '8.0' + s.source_files = 'ios/**/*.{h,m,mm}' +diff --git a/node_modules/react-native-pdf/index.js b/node_modules/react-native-pdf/index.js +index c05de52..bea7af8 100644 +--- a/node_modules/react-native-pdf/index.js ++++ b/node_modules/react-native-pdf/index.js +@@ -367,11 +367,17 @@ export default class Pdf extends Component { + message[4] = message.splice(4).join('|'); + } + if (message[0] === 'loadComplete') { ++ let tableContents; ++ try { ++ tableContents = message[4]&&JSON.parse(message[4]); ++ } catch(e) { ++ tableContents = message[4]; ++ } + this.props.onLoadComplete && this.props.onLoadComplete(Number(message[1]), this.state.path, { + width: Number(message[2]), + height: Number(message[3]), + }, +- message[4]&&JSON.parse(message[4])); ++ tableContents); + } else if (message[0] === 'pageChanged') { + this.props.onPageChanged && this.props.onPageChanged(Number(message[1]), Number(message[2])); + } else if (message[0] === 'error') { diff --git a/patches/react-native-pdf+6.7.5.patch b/patches/react-native-pdf+6.7.5.patch deleted file mode 100644 index 0cdcf4d5b0c9..000000000000 --- a/patches/react-native-pdf+6.7.5.patch +++ /dev/null @@ -1,12 +0,0 @@ -diff --git a/node_modules/react-native-pdf/fabric/RNPDFPdfNativeComponent.js b/node_modules/react-native-pdf/fabric/RNPDFPdfNativeComponent.js -index 596d796..4e47061 100644 ---- a/node_modules/react-native-pdf/fabric/RNPDFPdfNativeComponent.js -+++ b/node_modules/react-native-pdf/fabric/RNPDFPdfNativeComponent.js -@@ -22,6 +22,7 @@ - enablePaging: ?boolean, - enableRTL: ?boolean, - enableAnnotationRendering: ?boolean, -+ enableDoubleTapZoom: ?boolean, - showsHorizontalScrollIndicator: ?boolean, - showsVerticalScrollIndicator: ?boolean, - enableAntialiasing: ?boolean, diff --git a/src/App.tsx b/src/App.tsx index cf0fd5528eec..35254fa29b2a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import OnyxProvider from './components/OnyxProvider'; import PopoverContextProvider from './components/PopoverProvider'; import SafeArea from './components/SafeArea'; import ScrollOffsetContextProvider from './components/ScrollOffsetContextProvider'; +import {SearchRouterContextProvider} from './components/Search/SearchRouter/SearchRouterContext'; import ThemeIllustrationsProvider from './components/ThemeIllustrationsProvider'; import ThemeProvider from './components/ThemeProvider'; import ThemeStylesProvider from './components/ThemeStylesProvider'; @@ -94,6 +95,7 @@ function App({url}: AppProps) { VolumeContextProvider, VideoPopoverMenuContextProvider, KeyboardProvider, + SearchRouterContextProvider, ]} > diff --git a/src/CONFIG.ts b/src/CONFIG.ts index 047d4dc823fd..d82a261c2ec6 100644 --- a/src/CONFIG.ts +++ b/src/CONFIG.ts @@ -96,6 +96,11 @@ export default { IOS_CLIENT_ID: '921154746561-s3uqn2oe4m85tufi6mqflbfbuajrm2i3.apps.googleusercontent.com', }, GCP_GEOLOCATION_API_KEY: googleGeolocationAPIKey, + FIREBASE_WEB_CONFIG: { + apiKey: get(Config, 'FB_API_KEY', 'AIzaSyDxzigVLZl4G8MP7jACQ0qpmADMzmrrON0'), + appId: get(Config, 'FB_APP_ID', '1:921154746561:web:7b8213357d07d6e4027c40'), + projectId: get(Config, 'FB_PROJECT_ID', 'expensify-chat'), + }, // to read more about StrictMode see: contributingGuides/STRICT_MODE.md USE_REACT_STRICT_MODE_IN_DEV: false, } as const; diff --git a/src/CONST.ts b/src/CONST.ts index b38aa590914a..30c233e6847b 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1055,7 +1055,6 @@ const CONST = { BOTTOM_DOCKED: 'bottom_docked', POPOVER: 'popover', RIGHT_DOCKED: 'right_docked', - ONBOARDING: 'onboarding', }, ANCHOR_ORIGIN_VERTICAL: { TOP: 'top', @@ -1152,7 +1151,6 @@ const CONST = { BAD_REQUEST: 400, NOT_AUTHENTICATED: 407, EXP_ERROR: 666, - MANY_WRITES_ERROR: 665, UNABLE_TO_RETRY: 'unableToRetry', UPDATE_REQUIRED: 426, }, diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx index 09b5fd0cf7d6..564077387d5b 100644 --- a/src/components/AmountForm.tsx +++ b/src/components/AmountForm.tsx @@ -256,6 +256,7 @@ function AmountForm( prefixStyle={styles.colorMuted} keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD} inputMode={CONST.INPUT_MODE.DECIMAL} + errorText={errorText} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} /> diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 000445abb46d..7324a9dd9fbe 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -332,6 +332,9 @@ function Button( /> )} { diff --git a/src/components/CategoryPicker.tsx b/src/components/CategoryPicker.tsx index 7955e5993ba9..e5c85a8f5f6d 100644 --- a/src/components/CategoryPicker.tsx +++ b/src/components/CategoryPicker.tsx @@ -24,12 +24,9 @@ type CategoryPickerProps = CategoryPickerOnyxProps & { policyID: string; selectedCategory?: string; onSubmit: (item: ListItem) => void; - - /** Whether SectionList should use custom ScrollView */ - shouldUseCustomScrollView?: boolean; }; -function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedCategories, policyCategoriesDraft, onSubmit, shouldUseCustomScrollView = false}: CategoryPickerProps) { +function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedCategories, policyCategoriesDraft, onSubmit}: CategoryPickerProps) { const {translate} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); @@ -87,7 +84,6 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC ListItem={RadioListItem} initiallyFocusedOptionKey={selectedOptionKey ?? undefined} isRowMultilineSupported - shouldUseCustomScrollView={shouldUseCustomScrollView} /> ); } diff --git a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx index d91779540447..a51a7d7456e1 100644 --- a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx @@ -51,8 +51,11 @@ function FocusTrapForScreen({children, focusTrapSettings}: FocusTrapProps) { return false; } - const isFocusedElementInsideContainer = focusTrapContainers?.some((container) => container.contains(document.activeElement)); - if (isFocusedElementInsideContainer) { + const isFocusedElementInsideContainer = !!focusTrapContainers?.some((container) => container.contains(document.activeElement)); + const hasButtonWithEnterListener = !!focusTrapContainers?.some( + (container) => !!container.querySelector(`button[data-listener="${CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey}"]`), + ); + if (isFocusedElementInsideContainer || hasButtonWithEnterListener) { return false; } return undefined; diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 436cdba91af2..a76ca767ae2a 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -104,19 +104,20 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: true, ); - const isCurrentUserSelected = selectedOptions.find((option) => option.accountID === chatOptions.currentUserOption?.accountID); + const selectedCurrentUser = formattedResults.section.data.find((option) => option.accountID === chatOptions.currentUserOption?.accountID); - newSections.push(formattedResults.section); - - if (chatOptions.currentUserOption && !isCurrentUserSelected) { + if (chatOptions.currentUserOption) { const formattedName = ReportUtils.getDisplayNameForParticipant(chatOptions.currentUserOption.accountID, false, true, true, personalDetails); - newSections.push({ - title: '', - data: [{...chatOptions.currentUserOption, text: formattedName}], - shouldShow: true, - }); + if (selectedCurrentUser) { + selectedCurrentUser.text = formattedName; + } else { + chatOptions.currentUserOption.text = formattedName; + chatOptions.recentReports = [chatOptions.currentUserOption, ...chatOptions.recentReports]; + } } + newSections.push(formattedResults.section); + newSections.push({ title: '', data: chatOptions.recentReports, @@ -136,7 +137,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: sections: newSections, headerMessage: message, }; - }, [areOptionsInitialized, cleanSearchTerm, selectedOptions, chatOptions.recentReports, chatOptions.personalDetails, chatOptions.currentUserOption, personalDetails, translate]); + }, [areOptionsInitialized, cleanSearchTerm, selectedOptions, chatOptions, personalDetails, translate]); // This effect handles setting initial selectedOptions based on accountIDs saved in onyx form useEffect(() => { diff --git a/src/components/Search/SearchRouter/SearchButton.tsx b/src/components/Search/SearchRouter/SearchButton.tsx new file mode 100644 index 000000000000..4948c90ce3d1 --- /dev/null +++ b/src/components/Search/SearchRouter/SearchButton.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import {PressableWithoutFeedback} from '@components/Pressable'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Permissions from '@libs/Permissions'; +import {useSearchRouterContext} from './SearchRouterContext'; + +function SearchButton() { + const styles = useThemeStyles(); + const theme = useTheme(); + const {openSearchRouter} = useSearchRouterContext(); + + if (!Permissions.canUseNewSearchRouter()) { + return; + } + + return ( + { + openSearchRouter(); + }} + > + + + ); +} + +SearchButton.displayName = 'SearchButton'; + +export default SearchButton; diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx new file mode 100644 index 000000000000..b38c55279c1e --- /dev/null +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -0,0 +1,102 @@ +import debounce from 'lodash/debounce'; +import React, {useCallback, useState} from 'react'; +import {View} from 'react-native'; +import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; +import Modal from '@components/Modal'; +import type {SearchQueryJSON} from '@components/Search/types'; +import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as SearchUtils from '@libs/SearchUtils'; +import Navigation from '@navigation/Navigation'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import {useSearchRouterContext} from './SearchRouterContext'; +import SearchRouterInput from './SearchRouterInput'; + +const SEARCH_DEBOUNCE_DELAY = 200; + +function SearchRouter() { + const styles = useThemeStyles(); + + const {isSmallScreenWidth} = useResponsiveLayout(); + const {isSearchRouterDisplayed, closeSearchRouter} = useSearchRouterContext(); + const [currentQuery, setCurrentQuery] = useState(undefined); + + const clearUserQuery = () => { + setCurrentQuery(undefined); + }; + + const onSearchChange = debounce((userQuery: string) => { + if (!userQuery) { + clearUserQuery(); + return; + } + + const queryJSON = SearchUtils.buildSearchQueryJSON(userQuery); + + if (queryJSON) { + // eslint-disable-next-line + console.log('parsedQuery', queryJSON); + + setCurrentQuery(queryJSON); + } else { + // Handle query parsing error + } + }, SEARCH_DEBOUNCE_DELAY); + + const onSearchSubmit = useCallback(() => { + closeSearchRouter(); + + const query = SearchUtils.buildSearchQueryString(currentQuery); + Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query})); + clearUserQuery(); + }, [currentQuery, closeSearchRouter]); + + useKeyboardShortcut( + CONST.KEYBOARD_SHORTCUTS.ENTER, + () => { + if (!currentQuery) { + return; + } + + onSearchSubmit(); + }, + { + captureOnInputs: true, + shouldBubble: false, + }, + ); + + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => { + closeSearchRouter(); + clearUserQuery(); + }); + + const modalType = isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.CENTERED : CONST.MODAL.MODAL_TYPE.POPOVER; + const isFullWidth = isSmallScreenWidth; + + return ( + + + + + + + + ); +} + +SearchRouter.displayName = 'SearchRouter'; + +export default SearchRouter; diff --git a/src/components/Search/SearchRouter/SearchRouterContext.tsx b/src/components/Search/SearchRouter/SearchRouterContext.tsx new file mode 100644 index 000000000000..d935fff110a4 --- /dev/null +++ b/src/components/Search/SearchRouter/SearchRouterContext.tsx @@ -0,0 +1,37 @@ +import React, {useContext, useMemo, useState} from 'react'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; + +const defaultSearchContext = { + isSearchRouterDisplayed: false, + openSearchRouter: () => {}, + closeSearchRouter: () => {}, +}; + +type SearchRouterContext = typeof defaultSearchContext; + +const Context = React.createContext(defaultSearchContext); + +function SearchRouterContextProvider({children}: ChildrenProps) { + const [isSearchRouterDisplayed, setIsSearchRouterDisplayed] = useState(false); + + const routerContext = useMemo(() => { + const openSearchRouter = () => setIsSearchRouterDisplayed(true); + const closeSearchRouter = () => setIsSearchRouterDisplayed(false); + + return { + isSearchRouterDisplayed, + openSearchRouter, + closeSearchRouter, + }; + }, [isSearchRouterDisplayed, setIsSearchRouterDisplayed]); + + return {children}; +} + +function useSearchRouterContext() { + return useContext(Context); +} + +SearchRouterContextProvider.displayName = 'SearchRouterContextProvider'; + +export {SearchRouterContextProvider, useSearchRouterContext}; diff --git a/src/components/Search/SearchRouter/SearchRouterInput.tsx b/src/components/Search/SearchRouter/SearchRouterInput.tsx new file mode 100644 index 000000000000..860a46239d21 --- /dev/null +++ b/src/components/Search/SearchRouter/SearchRouterInput.tsx @@ -0,0 +1,41 @@ +import React, {useState} from 'react'; +import BaseTextInput from '@components/TextInput/BaseTextInput'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; + +type SearchRouterInputProps = { + isFullWidth: boolean; + onChange: (searchTerm: string) => void; + onSubmit: () => void; +}; + +function SearchRouterInput({isFullWidth, onChange, onSubmit}: SearchRouterInputProps) { + const styles = useThemeStyles(); + + const [value, setValue] = useState(''); + + const onChangeText = (text: string) => { + setValue(text); + onChange(text); + }; + + const modalWidth = isFullWidth ? styles.w100 : {width: variables.popoverWidth}; + + return ( + + ); +} + +SearchRouterInput.displayName = 'SearchRouterInput'; + +export default SearchRouterInput; diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx index a78944b50112..31207fdbf1d7 100644 --- a/src/components/SelectionList/BaseListItem.tsx +++ b/src/components/SelectionList/BaseListItem.tsx @@ -30,6 +30,7 @@ function BaseListItem({ children, isFocused, shouldSyncFocus = true, + shouldDisplayRBR = true, onFocus = () => {}, hoverStyle, onLongPressRow, @@ -115,7 +116,7 @@ function BaseListItem({ )} - {!item.isSelected && !!item.brickRoadIndicator && ( + {!item.isSelected && !!item.brickRoadIndicator && shouldDisplayRBR && ( ( shouldStopPropagation = false, shouldShowTooltips = true, shouldUseDynamicMaxToRenderPerBatch = false, - shouldUseCustomScrollView = false, rightHandSideComponent, isLoadingNewOptions = false, onLayout, @@ -426,9 +424,6 @@ function BaseSelectionList( ); - // eslint-disable-next-line react/jsx-props-no-spreading - const scrollComponent = shouldUseCustomScrollView ? (props: ScrollViewProps) => : undefined; - const renderItem = ({item, index, section}: SectionListRenderItemInfo>) => { const normalizedIndex = index + (section?.indexOffset ?? 0); const isDisabled = !!section.isDisabled || item.isDisabled; @@ -708,7 +703,6 @@ function BaseSelectionList( {!listHeaderContent && header()} ({ const subscriptAvatarBorderColor = isFocused ? focusedBackgroundColor : theme.sidebar; const hoveredBackgroundColor = !!styles.sidebarLinkHover && 'backgroundColor' in styles.sidebarLinkHover ? styles.sidebarLinkHover.backgroundColor : theme.sidebar; + const shouldShowCheckBox = canSelectMultiple && !item.isDisabled; + const handleCheckboxPress = useCallback(() => { if (onCheckboxPress) { onCheckboxPress(item); @@ -68,6 +70,7 @@ function InviteMemberListItem({ keyForList={item.keyForList} onFocus={onFocus} shouldSyncFocus={shouldSyncFocus} + shouldDisplayRBR={!shouldShowCheckBox} > {(hovered?: boolean) => ( <> @@ -113,7 +116,7 @@ function InviteMemberListItem({ )} {!!item.rightElement && item.rightElement} - {canSelectMultiple && !item.isDisabled && ( + {shouldShowCheckBox && ( = CommonListItemProps & { * When we type something into the text input, the first element found is focused, in this situation we should not synchronize the focus on the element because we will lose the focus from the text input. */ shouldSyncFocus?: boolean; + + /** Whether to show RBR */ + shouldDisplayRBR?: boolean; }; type BaseListItemProps = CommonListItemProps & { @@ -273,6 +276,7 @@ type BaseListItemProps = CommonListItemProps & { children?: ReactElement> | ((hovered: boolean) => ReactElement>); shouldSyncFocus?: boolean; hoverStyle?: StyleProp; + shouldDisplayRBR?: boolean; }; type UserListItemProps = ListItemProps & { @@ -456,9 +460,6 @@ type BaseSelectionListProps = Partial & { /** Whether to use dynamic maxToRenderPerBatch depending on the visible number of elements */ shouldUseDynamicMaxToRenderPerBatch?: boolean; - /** Whether SectionList should use custom ScrollView */ - shouldUseCustomScrollView?: boolean; - /** Whether keyboard shortcuts should be disabled */ disableKeyboardShortcuts?: boolean; diff --git a/src/hooks/useAutoTurnSelectionModeOffWhenHasNoActiveOption/index.ts b/src/hooks/useAutoTurnSelectionModeOffWhenHasNoActiveOption/index.ts new file mode 100644 index 000000000000..cdc18868fac7 --- /dev/null +++ b/src/hooks/useAutoTurnSelectionModeOffWhenHasNoActiveOption/index.ts @@ -0,0 +1,18 @@ +import {useEffect} from 'react'; +import type {ListItem} from '@components/SelectionList/types'; +import usePrevious from '@hooks/usePrevious'; +import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; + +const useAutoTurnSelectionModeOffWhenHasNoActiveOption = (listItem: ListItem[]) => { + const hasActiveOption = listItem.some((item) => !item.isDisabled); + + const prevHasActiveOption = usePrevious(hasActiveOption); + useEffect(() => { + if (hasActiveOption || !prevHasActiveOption) { + return; + } + turnOffMobileSelectionMode(); + }, [hasActiveOption, prevHasActiveOption]); +}; + +export default useAutoTurnSelectionModeOffWhenHasNoActiveOption; diff --git a/src/languages/en.ts b/src/languages/en.ts index 9c453c524c3b..a7ddea880161 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3856,6 +3856,7 @@ export default { autoPayReportsUnderDescription: 'Fully compliant expense reports under this amount will be automatically paid. ', unlockFeatureGoToSubtitle: 'Go to', unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `and enable workflows, then add ${featureName} to unlock this feature.`, + enableFeatureSubtitle: (featureName: string) => `and enable ${featureName} to unlock this feature.`, }, categoryRules: { title: 'Category rules', diff --git a/src/languages/es.ts b/src/languages/es.ts index 51d4b2ce8fcc..a8481ec305fc 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3906,6 +3906,7 @@ export default { autoPayReportsUnderDescription: 'Los informes de gastos totalmente conformes por debajo de esta cantidad se pagarán automáticamente.', unlockFeatureGoToSubtitle: 'Ir a', unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `y habilita flujos de trabajo, luego agrega ${featureName} para desbloquear esta función.`, + enableFeatureSubtitle: (featureName: string) => `y habilita ${featureName} para desbloquear esta función.`, }, categoryRules: { title: 'Reglas de categoría', diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 4ccbce269948..5c6102da81d8 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -242,6 +242,10 @@ const WRITE_COMMANDS = { UPDATE_QUICKBOOKS_ONLINE_ENABLE_NEW_CATEGORIES: 'UpdateQuickbooksOnlineEnableNewCategories', UPDATE_QUICKBOOKS_ONLINE_AUTO_CREATE_VENDOR: 'UpdateQuickbooksOnlineAutoCreateVendor', UPDATE_QUICKBOOKS_ONLINE_REIMBURSABLE_EXPENSES_ACCOUNT: 'UpdateQuickbooksOnlineReimbursableExpensesAccount', + UPDATE_QUICKBOOKS_ONLINE_RECEIVABLE_ACCOUNT: 'UpdateQuickbooksOnlineReceivableAccount', + UPDATE_QUICKBOOKS_ONLINE_EXPORT_DATE: 'UpdateQuickbooksOnlineExportDate', + UPDATE_QUICKBOOKS_ONLINE_NON_REIMBURSABLE_EXPENSES_ACCOUNT: 'UpdateQuickbooksOnlineNonReimbursableExpensesAccount', + UPDATE_QUICKBOOKS_ONLINE_COLLECTION_ACCOUNT_ID: 'UpdateQuickbooksOnlineCollectionAccountID', UPDATE_QUICKBOOKS_ONLINE_SYNC_TAX: 'UpdateQuickbooksOnlineSyncTax', UPDATE_QUICKBOOKS_ONLINE_SYNC_LOCATIONS: 'UpdateQuickbooksOnlineSyncLocations', UPDATE_QUICKBOOKS_ONLINE_SYNC_CUSTOMERS: 'UpdateQuickbooksOnlineSyncCustomers', @@ -663,6 +667,10 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_SYNC_CLASSES]: Parameters.UpdateQuickbooksOnlineGenericTypeParams; [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: Parameters.UpdateQuickbooksOnlineGenericTypeParams; [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_REIMBURSABLE_EXPENSES_ACCOUNT]: Parameters.UpdateQuickbooksOnlineGenericTypeParams; + [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_RECEIVABLE_ACCOUNT]: Parameters.UpdateQuickbooksOnlineGenericTypeParams; + [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_EXPORT_DATE]: Parameters.UpdateQuickbooksOnlineGenericTypeParams; + [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_NON_REIMBURSABLE_EXPENSES_ACCOUNT]: Parameters.UpdateQuickbooksOnlineGenericTypeParams; + [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_COLLECTION_ACCOUNT_ID]: Parameters.UpdateQuickbooksOnlineGenericTypeParams; [WRITE_COMMANDS.UPDATE_POLICY_CONNECTION_CONFIG]: Parameters.UpdatePolicyConnectionConfigParams; [WRITE_COMMANDS.UPDATE_MANY_POLICY_CONNECTION_CONFIGS]: Parameters.UpdateManyPolicyConnectionConfigurationsParams; [WRITE_COMMANDS.REMOVE_POLICY_CONNECTION]: Parameters.RemovePolicyConnectionParams; diff --git a/src/libs/Firebase/firebaseWebConfig.ts b/src/libs/Firebase/firebaseWebConfig.ts new file mode 100644 index 000000000000..98437441ccc3 --- /dev/null +++ b/src/libs/Firebase/firebaseWebConfig.ts @@ -0,0 +1,12 @@ +import {initializeApp} from '@firebase/app'; +import {getPerformance, initializePerformance} from '@firebase/performance'; +import Config from '@src/CONFIG'; + +const firebaseConfig = Config.FIREBASE_WEB_CONFIG; +const firebaseApp = initializeApp(firebaseConfig); + +initializePerformance(firebaseApp, {dataCollectionEnabled: true}); + +const firebasePerfWeb = getPerformance(firebaseApp); + +export {firebaseApp, firebasePerfWeb}; diff --git a/src/libs/Firebase/index.web.ts b/src/libs/Firebase/index.web.ts new file mode 100644 index 000000000000..e63ea24b4a09 --- /dev/null +++ b/src/libs/Firebase/index.web.ts @@ -0,0 +1,76 @@ +import {trace} from '@firebase/performance'; +import * as Environment from '@libs/Environment/Environment'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import * as ReportConnection from '@libs/ReportConnection'; +import * as SessionUtils from '@libs/SessionUtils'; +import {firebasePerfWeb} from './firebaseWebConfig'; +import type {FirebaseAttributes, Log, StartTrace, StopTrace, TraceMap} from './types'; + +const traceMap: TraceMap = {}; + +const startTrace: StartTrace = (customEventName) => { + const start = global.performance.now(); + + if (Environment.isDevelopment()) { + return; + } + + if (traceMap[customEventName]) { + return; + } + + const perfTrace = trace(firebasePerfWeb, customEventName); + + const attributes = getAttributes(); + + Object.entries(attributes).forEach(([name, value]) => { + perfTrace.putAttribute(name, value); + }); + + traceMap[customEventName] = { + trace: perfTrace, + start, + }; + + perfTrace.start(); +}; + +const stopTrace: StopTrace = (customEventName) => { + if (Environment.isDevelopment()) { + return; + } + + const perfTrace = traceMap[customEventName]?.trace; + + if (!perfTrace) { + return; + } + + perfTrace.stop(); + + delete traceMap[customEventName]; +}; + +const log: Log = () => { + // crashlytics is not supported on WEB +}; + +function getAttributes(): FirebaseAttributes { + const session = SessionUtils.getSession(); + + const accountId = session?.accountID?.toString() ?? 'N/A'; + const reportsLength = ReportConnection.getAllReportsLength().toString(); + const personalDetailsLength = PersonalDetailsUtils.getPersonalDetailsLength().toString(); + + return { + accountId, + reportsLength, + personalDetailsLength, + }; +} + +export default { + startTrace, + stopTrace, + log, +}; diff --git a/src/libs/Firebase/types.ts b/src/libs/Firebase/types.ts index e262e3c73e81..bb212596b535 100644 --- a/src/libs/Firebase/types.ts +++ b/src/libs/Firebase/types.ts @@ -1,7 +1,8 @@ +import type {PerformanceTrace} from '@firebase/performance'; import type {FirebasePerformanceTypes} from '@react-native-firebase/perf'; type Trace = { - trace: FirebasePerformanceTypes.Trace; + trace: FirebasePerformanceTypes.Trace | PerformanceTrace; start: number; }; type TraceMap = Record; diff --git a/src/libs/HttpUtils.ts b/src/libs/HttpUtils.ts index 550a75c3d361..de6aea96cb47 100644 --- a/src/libs/HttpUtils.ts +++ b/src/libs/HttpUtils.ts @@ -130,15 +130,12 @@ function processHTTPRequest(url: string, method: RequestType = 'get', body: Form }); } - if (response.jsonCode === CONST.JSON_CODE.MANY_WRITES_ERROR) { - if (response.data) { - const {phpCommandName, authWriteCommands} = response.data; - // eslint-disable-next-line max-len - const message = `The API call (${phpCommandName}) did more Auth write requests than allowed. Count ${authWriteCommands.length}, commands: ${authWriteCommands.join( - ', ', - )}. Check the APIWriteCommands class in Web-Expensify`; - alert('Too many auth writes', message); - } + if (response.data && (response.data?.authWriteCommands?.length ?? 0)) { + const {phpCommandName, authWriteCommands} = response.data; + const message = `The API command ${phpCommandName} is doing too many Auth writes. Count ${authWriteCommands.length}, commands: ${authWriteCommands.join( + ', ', + )}. If you modified this command, you MUST refactor it to remove the extra Auth writes. Otherwise, update the allowed write count in Web-Expensify APIWriteCommands.`; + alert('Too many auth writes', message); } if (response.jsonCode === CONST.JSON_CODE.UPDATE_REQUIRED) { // Trigger a modal and disable the app as the user needs to upgrade to the latest minimum version to continue diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 5e7d6428d5bb..b68b9441c38c 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -6,6 +6,7 @@ import type {ValueOf} from 'type-fest'; import ComposeProviders from '@components/ComposeProviders'; import OptionsListContextProvider from '@components/OptionListContextProvider'; import {SearchContextProvider} from '@components/Search/SearchContext'; +import SearchRouter from '@components/Search/SearchRouter/SearchRouter'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -559,6 +560,7 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie ); })} + ); diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx index f4728a80f2ca..985c16d50c22 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx @@ -5,6 +5,7 @@ import Breadcrumbs from '@components/Breadcrumbs'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import {PressableWithoutFeedback} from '@components/Pressable'; +import SearchButton from '@components/Search/SearchRouter/SearchButton'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import WorkspaceSwitcherButton from '@components/WorkspaceSwitcherButton'; @@ -73,6 +74,8 @@ function TopBar({breadcrumbLabel, activeWorkspaceID, shouldDisplaySearch = true, {translate('common.cancel')} )} + {/* This is only temporary for development and will be cleaned up in: https://github.com/Expensify/App/issues/49122 */} + {displaySearch && ( { const isEmail = Str.isValidEmail(part.text); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing let tagType = part.type ?? 'span'; let content = Str.safeEscape(part.text); - if (isEmail) { + if (currentUserEmail === part.text || part.clickToCopyText === currentUserEmail) { + tagType = 'strong'; + content = 'You'; + } else if (isEmail) { tagType = 'next-step-email'; content = EmailUtils.prefixMailSeparatorsWithBreakOpportunities(content); } diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 8c47100e465b..7f7e89ad3585 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -2,6 +2,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; import type {IOUType} from '@src/CONST'; import type Beta from '@src/types/onyx/Beta'; +import * as Environment from './Environment/Environment'; function canUseAllBetas(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.ALL); @@ -49,6 +50,17 @@ function canUseCombinedTrackSubmit(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.COMBINED_TRACK_SUBMIT); } +/** + * New Search Router is under construction and for now should be displayed only in dev to allow developers to work on it. + * We are not using BETA for this feature, as betas are heavier to cleanup, + * and the development of new router is expected to take 2-3 weeks at most + * + * After everything is implemented this function can be removed, as we will always use SearchRouter in the App. + */ +function canUseNewSearchRouter() { + return Environment.isDevelopment(); +} + /** * Link previews are temporarily disabled. */ @@ -68,4 +80,5 @@ export default { canUseNewDotCopilot, canUseWorkspaceRules, canUseCombinedTrackSubmit, + canUseNewSearchRouter, }; diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 665a4925bfd4..9760ff80ca19 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -529,45 +529,59 @@ function buildSearchQueryString(queryJSON?: SearchQueryJSON) { * Given object with chosen search filters builds correct query string from them */ function buildQueryStringFromFilterValues(filterValues: Partial) { - const filtersString = Object.entries(filterValues).map(([filterKey, filterValue]) => { - if ((filterKey === FILTER_KEYS.MERCHANT || filterKey === FILTER_KEYS.DESCRIPTION || filterKey === FILTER_KEYS.REPORT_ID) && filterValue) { - const keyInCorrectForm = (Object.keys(CONST.SEARCH.SYNTAX_FILTER_KEYS) as FilterKeys[]).find((key) => CONST.SEARCH.SYNTAX_FILTER_KEYS[key] === filterKey); - if (keyInCorrectForm) { - return `${CONST.SEARCH.SYNTAX_FILTER_KEYS[keyInCorrectForm]}:${sanitizeString(filterValue as string)}`; + // We separate type and status filters from other filters to maintain hashes consistency for saved searches + const {type, status, ...otherFilters} = filterValues; + const filtersString: string[] = []; + + if (type) { + const sanitizedType = sanitizeString(type); + filtersString.push(`${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${sanitizedType}`); + } + + if (status) { + const sanitizedStatus = sanitizeString(status); + filtersString.push(`${CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS}:${sanitizedStatus}`); + } + + const mappedFilters = Object.entries(otherFilters) + .map(([filterKey, filterValue]) => { + if ((filterKey === FILTER_KEYS.MERCHANT || filterKey === FILTER_KEYS.DESCRIPTION || filterKey === FILTER_KEYS.REPORT_ID) && filterValue) { + const keyInCorrectForm = (Object.keys(CONST.SEARCH.SYNTAX_FILTER_KEYS) as FilterKeys[]).find((key) => CONST.SEARCH.SYNTAX_FILTER_KEYS[key] === filterKey); + if (keyInCorrectForm) { + return `${CONST.SEARCH.SYNTAX_FILTER_KEYS[keyInCorrectForm]}:${sanitizeString(filterValue as string)}`; + } } - } - if (filterKey === FILTER_KEYS.KEYWORD && filterValue) { - const value = (filterValue as string).split(' ').map(sanitizeString).join(' '); - return `${value}`; - } - if (filterKey === FILTER_KEYS.TYPE && filterValue) { - return `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${sanitizeString(filterValue as string)}`; - } - if (filterKey === FILTER_KEYS.STATUS && filterValue) { - return `${CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS}:${sanitizeString(filterValue as string)}`; - } - if ( - (filterKey === FILTER_KEYS.CATEGORY || - filterKey === FILTER_KEYS.CARD_ID || - filterKey === FILTER_KEYS.TAX_RATE || - filterKey === FILTER_KEYS.EXPENSE_TYPE || - filterKey === FILTER_KEYS.TAG || - filterKey === FILTER_KEYS.CURRENCY || - filterKey === FILTER_KEYS.FROM || - filterKey === FILTER_KEYS.TO || - filterKey === FILTER_KEYS.IN) && - Array.isArray(filterValue) && - filterValue.length > 0 - ) { - const filterValueArray = [...new Set(filterValues[filterKey] ?? [])]; - const keyInCorrectForm = (Object.keys(CONST.SEARCH.SYNTAX_FILTER_KEYS) as FilterKeys[]).find((key) => CONST.SEARCH.SYNTAX_FILTER_KEYS[key] === filterKey); - if (keyInCorrectForm) { - return `${CONST.SEARCH.SYNTAX_FILTER_KEYS[keyInCorrectForm]}:${filterValueArray.map(sanitizeString).join(',')}`; + + if (filterKey === FILTER_KEYS.KEYWORD && filterValue) { + const value = (filterValue as string).split(' ').map(sanitizeString).join(' '); + return `${value}`; } - } - return undefined; - }); + if ( + (filterKey === FILTER_KEYS.CATEGORY || + filterKey === FILTER_KEYS.CARD_ID || + filterKey === FILTER_KEYS.TAX_RATE || + filterKey === FILTER_KEYS.EXPENSE_TYPE || + filterKey === FILTER_KEYS.TAG || + filterKey === FILTER_KEYS.CURRENCY || + filterKey === FILTER_KEYS.FROM || + filterKey === FILTER_KEYS.TO || + filterKey === FILTER_KEYS.IN) && + Array.isArray(filterValue) && + filterValue.length > 0 + ) { + const filterValueArray = [...new Set(filterValue)]; + const keyInCorrectForm = (Object.keys(CONST.SEARCH.SYNTAX_FILTER_KEYS) as FilterKeys[]).find((key) => CONST.SEARCH.SYNTAX_FILTER_KEYS[key] === filterKey); + if (keyInCorrectForm) { + return `${CONST.SEARCH.SYNTAX_FILTER_KEYS[keyInCorrectForm]}:${filterValueArray.map(sanitizeString).join(',')}`; + } + } + + return undefined; + }) + .filter((filter): filter is string => !!filter); + + filtersString.push(...mappedFilters); const dateFilter = buildDateFilterQuery(filterValues); filtersString.push(dateFilter); @@ -575,7 +589,7 @@ function buildQueryStringFromFilterValues(filterValues: Partial( + policyID: string, + settingValue: TSettingValue, + oldSettingValue?: TSettingValue, +) { + const {optimisticData, failureData, successData} = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_CONFIG.RECEIVABLE_ACCOUNT, settingValue, oldSettingValue); + + const parameters: UpdateQuickbooksOnlineGenericTypeParams = { + policyID, + settingValue: JSON.stringify(settingValue), + idempotencyKey: String(CONST.QUICKBOOKS_CONFIG.RECEIVABLE_ACCOUNT), + }; + API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_RECEIVABLE_ACCOUNT, parameters, {optimisticData, failureData, successData}); +} + +function updateQuickbooksOnlineExportDate(policyID: string, settingValue: TSettingValue, oldSettingValue?: TSettingValue) { + const {optimisticData, failureData, successData} = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_CONFIG.EXPORT_DATE, settingValue, oldSettingValue); + + const parameters: UpdateQuickbooksOnlineGenericTypeParams = { + policyID, + settingValue: JSON.stringify(settingValue), + idempotencyKey: String(CONST.QUICKBOOKS_CONFIG.EXPORT_DATE), + }; + API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_EXPORT_DATE, parameters, {optimisticData, failureData, successData}); +} + +function updateQuickbooksOnlineNonReimbursableExpensesAccount( + policyID: string, + settingValue: TSettingValue, + oldSettingValue?: TSettingValue, +) { + const {optimisticData, failureData, successData} = buildOnyxDataForQuickbooksConfiguration( + policyID, + CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_ACCOUNT, + settingValue, + oldSettingValue, + ); + + const parameters: UpdateQuickbooksOnlineGenericTypeParams = { + policyID, + settingValue: JSON.stringify(settingValue), + idempotencyKey: String(CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_ACCOUNT), + }; + API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_NON_REIMBURSABLE_EXPENSES_ACCOUNT, parameters, {optimisticData, failureData, successData}); +} + +function updateQuickbooksOnlineCollectionAccountID( + policyID: string, + settingValue: TSettingValue, + oldSettingValue?: TSettingValue, +) { + const {optimisticData, failureData, successData} = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID, settingValue, oldSettingValue); + + const parameters: UpdateQuickbooksOnlineGenericTypeParams = { + policyID, + settingValue: JSON.stringify(settingValue), + idempotencyKey: String(CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID), + }; + API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_COLLECTION_ACCOUNT_ID, parameters, {optimisticData, failureData, successData}); +} + function updateQuickbooksOnlineSyncTax(policyID: string, settingValue: TSettingValue) { const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_CONFIG.SYNC_TAX, settingValue, !settingValue); @@ -282,6 +343,10 @@ export { updateQuickbooksOnlineEnableNewCategories, updateQuickbooksOnlineAutoCreateVendor, updateQuickbooksOnlineReimbursableExpensesAccount, + updateQuickbooksOnlineReceivableAccount, + updateQuickbooksOnlineExportDate, + updateQuickbooksOnlineNonReimbursableExpensesAccount, + updateQuickbooksOnlineCollectionAccountID, updateQuickbooksOnlineNonReimbursableBillDefaultVendor, updateQuickbooksOnlineSyncTax, updateQuickbooksOnlineSyncClasses, diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx index 49052208bfdc..75559aa54803 100644 --- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx +++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx @@ -5,7 +5,6 @@ import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import OfflineIndicator from '@components/OfflineIndicator'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; @@ -113,67 +112,63 @@ function BaseOnboardingPersonalDetails({ return errors; }; - const PersonalDetailsFooterInstance = ; - return ( - - - - - {translate('onboarding.whatsYourName')} - - - - - - - - - + + + + {translate('onboarding.whatsYourName')} + + + + + + + + ); } diff --git a/src/pages/OnboardingWork/BaseOnboardingWork.tsx b/src/pages/OnboardingWork/BaseOnboardingWork.tsx index f803b4e34a65..1e8406b62c44 100644 --- a/src/pages/OnboardingWork/BaseOnboardingWork.tsx +++ b/src/pages/OnboardingWork/BaseOnboardingWork.tsx @@ -5,7 +5,6 @@ import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import OfflineIndicator from '@components/OfflineIndicator'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; @@ -28,7 +27,6 @@ import type {BaseOnboardingWorkOnyxProps, BaseOnboardingWorkProps} from './types function BaseOnboardingWork({shouldUseNativeStyles, onboardingPurposeSelected, onboardingPolicyID, route}: BaseOnboardingWorkProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); const {inputCallbackRef} = useAutoFocusInput(); @@ -66,53 +64,49 @@ function BaseOnboardingWork({shouldUseNativeStyles, onboardingPurposeSelected, o return errors; }; - const WorkFooterInstance = ; - return ( - - - - - {translate('onboarding.whereYouWork')} - - - - - - + + + + {translate('onboarding.whereYouWork')} + + + + + ); } diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index ad6416896447..8f08b128619a 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -222,6 +222,7 @@ function AdvancedSearchFilters() { const {singleExecution} = useSingleExecution(); const waitForNavigate = useWaitForNavigation(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); const [searchAdvancedFilters = {} as SearchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); const taxRates = getAllTaxRates(); @@ -242,6 +243,13 @@ function AdvancedSearchFilters() { }; const onSaveSearch = () => { + const savedSearchKeys = Object.keys(savedSearches ?? {}); + if (savedSearches && savedSearchKeys.includes(String(queryJSON.hash))) { + // If the search is already saved, return early to prevent unnecessary API calls + Navigation.dismissModal(); + return; + } + SearchActions.saveSearch({ queryJSON, }); diff --git a/src/pages/Search/SavedSearchItemThreeDotMenu.tsx b/src/pages/Search/SavedSearchItemThreeDotMenu.tsx new file mode 100644 index 000000000000..fdb06828901e --- /dev/null +++ b/src/pages/Search/SavedSearchItemThreeDotMenu.tsx @@ -0,0 +1,37 @@ +import React, {useRef, useState} from 'react'; +import {View} from 'react-native'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; +import ThreeDotsMenu from '@components/ThreeDotsMenu'; +import CONST from '@src/CONST'; + +type SavedSearchItemThreeDotMenuProps = { + menuItems: PopoverMenuItem[]; +}; + +function SavedSearchItemThreeDotMenu({menuItems}: SavedSearchItemThreeDotMenuProps) { + const threeDotsMenuContainerRef = useRef(null); + const [threeDotsMenuPosition, setThreeDotsMenuPosition] = useState({horizontal: 0, vertical: 0}); + + return ( + + { + threeDotsMenuContainerRef.current?.measureInWindow((x, y, width) => { + setThreeDotsMenuPosition({ + horizontal: x + width, + vertical: y, + }); + }); + }} + anchorPosition={threeDotsMenuPosition} + anchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, + }} + /> + + ); +} + +export default SavedSearchItemThreeDotMenu; diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index a773ef674543..7c8af2388f52 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -13,7 +13,6 @@ import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import ScrollView from '@components/ScrollView'; import type {SearchQueryJSON} from '@components/Search/types'; import Text from '@components/Text'; -import ThreeDotsMenu from '@components/ThreeDotsMenu'; import useDeleteSavedSearch from '@hooks/useDeleteSavedSearch'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -32,6 +31,7 @@ import ROUTES from '@src/ROUTES'; import type {SaveSearchItem} from '@src/types/onyx/SaveSearch'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type IconAsset from '@src/types/utils/IconAsset'; +import SavedSearchItemThreeDotMenu from './SavedSearchItemThreeDotMenu'; import SearchTypeMenuNarrow from './SearchTypeMenuNarrow'; type SavedSearchMenuItem = MenuItemBaseProps & { @@ -105,6 +105,7 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { const jsonQuery = SearchUtils.buildSearchQueryJSON(item.query) ?? ({} as SearchQueryJSON); title = SearchUtils.getSearchHeaderTitle(jsonQuery, personalDetails, cardList, reports, taxRates); } + const baseMenuItem: SavedSearchMenuItem = { key, title, @@ -116,16 +117,7 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { SearchActions.clearAllFilters(); Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: item?.query ?? ''})); }, - rightComponent: ( - - ), + rightComponent: , styles: [styles.alignItemsCenter], }; diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 9da19f192c1d..484cadd5c1a2 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -121,7 +121,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {allowStaleData: true, initialValue: {}}); const [betas] = useOnyx(ONYXKEYS.BETAS); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const [parentReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportOnyx?.parentReportID || 0}`, { + const [parentReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportOnyx?.parentReportID || -1}`, { canEvict: false, selector: (parentReportActions) => getParentReportAction(parentReportActions, reportOnyx?.parentReportActionID ?? ''), }); @@ -391,8 +391,14 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro */ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const isLoading = isLoadingApp || !reportIDFromRoute || (!isSidebarLoaded && !isInNarrowPaneModal) || PersonalDetailsUtils.isPersonalDetailsEmpty(); + const shouldShowSkeleton = - (isLinkingToMessage && !isLinkedMessagePageReady) || (!isLinkingToMessage && !isInitialPageReady) || isLoadingReportOnyx || !isCurrentReportLoadedFromOnyx || isLoading; + (isLinkingToMessage && !isLinkedMessagePageReady) || + (!isLinkingToMessage && !isInitialPageReady) || + isEmptyObject(reportOnyx) || + isLoadingReportOnyx || + !isCurrentReportLoadedFromOnyx || + isLoading; // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundLinkedAction = diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index 8b83b3f00ce1..9b91c5aba8bf 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -188,7 +188,7 @@ function ReportActionItem({ }); const theme = useTheme(); const styles = useThemeStyles(); - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID || -1}`); const StyleUtils = useStyleUtils(); const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const [isContextMenuActive, setIsContextMenuActive] = useState(() => ReportActionContextMenu.isActiveReportAction(action.reportActionID)); diff --git a/src/pages/workspace/accounting/qbo/advanced/QuickbooksAdvancedPage.tsx b/src/pages/workspace/accounting/qbo/advanced/QuickbooksAdvancedPage.tsx index 35cb2c36d915..6b4d395832bf 100644 --- a/src/pages/workspace/accounting/qbo/advanced/QuickbooksAdvancedPage.tsx +++ b/src/pages/workspace/accounting/qbo/advanced/QuickbooksAdvancedPage.tsx @@ -148,10 +148,8 @@ function QuickbooksAdvancedPage({policy}: WithPolicyConnectionsProps) { switchAccessibilityLabel: translate('workspace.qbo.advancedConfig.reimbursedReportsDescription'), isActive: isSyncReimbursedSwitchOn, onToggle: () => - Connections.updatePolicyConnectionConfig( + QuickbooksOnline.updateQuickbooksOnlineCollectionAccountID( policyID, - CONST.POLICY.CONNECTIONS.NAME.QBO, - CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID, isSyncReimbursedSwitchOn ? '' : [...qboAccountOptions, ...invoiceAccountCollectionOptions][0].id, qboConfig?.collectionAccountID, ), diff --git a/src/pages/workspace/accounting/qbo/advanced/QuickbooksInvoiceAccountSelectPage.tsx b/src/pages/workspace/accounting/qbo/advanced/QuickbooksInvoiceAccountSelectPage.tsx index 29741e93d97d..76b2046281b1 100644 --- a/src/pages/workspace/accounting/qbo/advanced/QuickbooksInvoiceAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/advanced/QuickbooksInvoiceAccountSelectPage.tsx @@ -8,7 +8,7 @@ import SelectionScreen from '@components/SelectionScreen'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as Connections from '@libs/actions/connections'; +import * as QuickbooksOnline from '@libs/actions/connections/QuickbooksOnline'; import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import {settingsPendingAction} from '@libs/PolicyUtils'; @@ -56,7 +56,7 @@ function QuickbooksInvoiceAccountSelectPage({policy}: WithPolicyConnectionsProps const updateAccount = useCallback( ({value}: SelectorType) => { - Connections.updatePolicyConnectionConfig(policyID, CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.QUICKBOOKS_CONFIG.COLLECTION_ACCOUNT_ID, value, qboConfig?.collectionAccountID); + QuickbooksOnline.updateQuickbooksOnlineCollectionAccountID(policyID, value, qboConfig?.collectionAccountID); Navigation.goBack(ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED.getRoute(policyID)); }, [policyID, qboConfig?.collectionAccountID], diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountSelectPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountSelectPage.tsx index 5506be9c1ab0..66a7ea442148 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountSelectPage.tsx @@ -7,7 +7,7 @@ import SelectionScreen from '@components/SelectionScreen'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as Connections from '@libs/actions/connections'; +import * as QuickbooksOnline from '@libs/actions/connections/QuickbooksOnline'; import * as ConnectionUtils from '@libs/ConnectionUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; @@ -58,13 +58,7 @@ function QuickbooksCompanyCardExpenseAccountSelectPage({policy}: WithPolicyConne const selectExportAccount = useCallback( (row: CardListItem) => { if (row.value.id !== qboConfig?.nonReimbursableExpensesAccount?.id) { - Connections.updatePolicyConnectionConfig( - policyID, - CONST.POLICY.CONNECTIONS.NAME.QBO, - CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_EXPENSES_ACCOUNT, - row.value, - qboConfig?.nonReimbursableExpensesAccount, - ); + QuickbooksOnline.updateQuickbooksOnlineNonReimbursableExpensesAccount(policyID, row.value, qboConfig?.nonReimbursableExpensesAccount); } Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT.getRoute(policyID)); }, diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksExportDateSelectPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksExportDateSelectPage.tsx index a48a56b7710b..e094fe355218 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksExportDateSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksExportDateSelectPage.tsx @@ -6,7 +6,7 @@ import SelectionScreen from '@components/SelectionScreen'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as Connections from '@libs/actions/connections'; +import * as QuickbooksOnline from '@libs/actions/connections/QuickbooksOnline'; import * as ErrorUtils from '@libs/ErrorUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; @@ -37,7 +37,7 @@ function QuickbooksExportDateSelectPage({policy}: WithPolicyConnectionsProps) { const selectExportDate = useCallback( (row: CardListItem) => { if (row.value !== exportDate) { - Connections.updatePolicyConnectionConfig(policyID, CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.QUICKBOOKS_CONFIG.EXPORT_DATE, row.value, exportDate); + QuickbooksOnline.updateQuickbooksOnlineExportDate(policyID, row.value, exportDate); } Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_DATE_SELECT.getRoute(policyID)); }, diff --git a/src/pages/workspace/accounting/qbo/export/QuickbooksExportInvoiceAccountSelectPage.tsx b/src/pages/workspace/accounting/qbo/export/QuickbooksExportInvoiceAccountSelectPage.tsx index 69efae3e558b..bb82901c9d77 100644 --- a/src/pages/workspace/accounting/qbo/export/QuickbooksExportInvoiceAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/qbo/export/QuickbooksExportInvoiceAccountSelectPage.tsx @@ -7,7 +7,7 @@ import SelectionScreen from '@components/SelectionScreen'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as Connections from '@libs/actions/connections'; +import * as QuickbooksOnline from '@libs/actions/connections/QuickbooksOnline'; import * as ErrorUtils from '@libs/ErrorUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; @@ -44,7 +44,7 @@ function QuickbooksExportInvoiceAccountSelectPage({policy}: WithPolicyConnection const selectExportInvoice = useCallback( (row: CardListItem) => { if (row.value.id !== qboConfig?.receivableAccount?.id) { - Connections.updatePolicyConnectionConfig(policyID, CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.QUICKBOOKS_CONFIG.RECEIVABLE_ACCOUNT, row.value, qboConfig?.receivableAccount); + QuickbooksOnline.updateQuickbooksOnlineReceivableAccount(policyID, row.value, qboConfig?.receivableAccount); } Navigation.goBack(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECT.getRoute(policyID)); }, diff --git a/src/pages/workspace/categories/SpendCategorySelectorListItem.tsx b/src/pages/workspace/categories/SpendCategorySelectorListItem.tsx index fd958ebc181c..a86a64c2a0cb 100644 --- a/src/pages/workspace/categories/SpendCategorySelectorListItem.tsx +++ b/src/pages/workspace/categories/SpendCategorySelectorListItem.tsx @@ -1,56 +1,35 @@ -import React, {useState} from 'react'; -import type {SetOptional} from 'type-fest'; +import React from 'react'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import BaseListItem from '@components/SelectionList/BaseListItem'; import type {BaseListItemProps, ListItem} from '@components/SelectionList/types'; import useThemeStyles from '@hooks/useThemeStyles'; -import CategorySelector from '@pages/workspace/distanceRates/CategorySelector'; -import * as Policy from '@userActions/Policy/Policy'; -type SpendCategorySelectorListItemProps = SetOptional, 'onSelectRow'>; - -function SpendCategorySelectorListItem({item, onSelectRow = () => {}, isFocused}: SpendCategorySelectorListItemProps) { +function SpendCategorySelectorListItem({item, onSelectRow, isFocused}: BaseListItemProps) { const styles = useThemeStyles(); - const [isCategoryPickerVisible, setIsCategoryPickerVisible] = useState(false); - const {policyID, groupID, categoryID} = item; + const {groupID, categoryID} = item; - if (!policyID || !groupID) { + if (!groupID) { return; } - const onSelect = (data: TItem) => { - setIsCategoryPickerVisible(true); - onSelectRow(data); - }; - - const setNewCategory = (selectedCategory: ListItem) => { - if (!selectedCategory.keyForList) { - return; - } - Policy.setWorkspaceDefaultSpendCategory(policyID, groupID, selectedCategory.keyForList); - }; - return ( - onSelectRow(item)} focused={isFocused} - policyID={policyID} - label={groupID[0].toUpperCase() + groupID.slice(1)} - defaultValue={categoryID} - setNewCategory={setNewCategory} - isPickerVisible={isCategoryPickerVisible} - showPickerModal={() => setIsCategoryPickerVisible(true)} - hidePickerModal={() => { - setIsCategoryPickerVisible(false); - }} /> ); diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 267b8ec52019..35ff78adba00 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -20,6 +20,7 @@ import SelectionListWithModal from '@components/SelectionListWithModal'; import TableListItemSkeleton from '@components/Skeletons/TableRowSkeleton'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; +import useAutoTurnSelectionModeOffWhenHasNoActiveOption from '@hooks/useAutoTurnSelectionModeOffWhenHasNoActiveOption'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -110,6 +111,8 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { [policyCategories, selectedCategories, canSelectMultiple, translate], ); + useAutoTurnSelectionModeOffWhenHasNoActiveOption(categoryList); + const toggleCategory = useCallback((category: PolicyOption) => { setSelectedCategories((prev) => { if (prev[category.keyForList]) { diff --git a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx index 02547090cfe6..5a048e3bdc36 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx @@ -1,9 +1,9 @@ -import React, {useMemo} from 'react'; +import React, {useMemo, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; +import SelectionList from '@components/SelectionList'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; @@ -12,6 +12,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import CategorySelectorModal from '@pages/workspace/distanceRates/CategorySelector/CategorySelectorModal'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; @@ -32,6 +33,9 @@ function WorkspaceCategoriesSettingsPage({policy, route}: WorkspaceCategoriesSet const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const [currentPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const currentConnectionName = PolicyUtils.getCurrentConnectionName(policy); + const [isSelectorModalVisible, setIsSelectorModalVisible] = useState(false); + const [categoryID, setCategoryID] = useState(); + const [groupID, setGroupID] = useState(); const toggleSubtitle = isConnectedToAccounting && currentConnectionName ? `${translate('workspace.categories.needCategoryForExportToIntegration')} ${currentConnectionName}.` : undefined; @@ -39,35 +43,39 @@ function WorkspaceCategoriesSettingsPage({policy, route}: WorkspaceCategoriesSet setWorkspaceRequiresCategory(policyID, value); }; - const policyMccGroup = currentPolicy?.mccGroup; - const listItems = useMemo(() => { - let data: ListItem[] = []; - - if (policyMccGroup) { - data = Object.entries(policyMccGroup).map( - ([mccKey, mccGroup]) => - ({ - categoryID: mccGroup.category, - keyForList: mccKey, - groupID: mccKey, - policyID, - tabIndex: -1, - } as ListItem), - ); + const {sections} = useMemo(() => { + if (!(currentPolicy && currentPolicy.mccGroup)) { + return {sections: [{data: []}]}; } - return data.map((item) => ( - - )); - }, [policyMccGroup, policyID]); + return { + sections: [ + { + data: Object.entries(currentPolicy.mccGroup).map( + ([mccKey, mccGroup]) => + ({ + categoryID: mccGroup.category, + keyForList: mccKey, + groupID: mccKey, + tabIndex: -1, + } as ListItem), + ), + }, + ], + }; + }, [currentPolicy]); const hasEnabledOptions = OptionsListUtils.hasEnabledOptions(policyCategories ?? {}); const isToggleDisabled = !policy?.areCategoriesEnabled || !hasEnabledOptions || isConnectedToAccounting; + const setNewCategory = (selectedCategory: ListItem) => { + if (!selectedCategory.keyForList || !groupID) { + return; + } + Policy.setWorkspaceDefaultSpendCategory(policyID, groupID, selectedCategory.keyForList); + setIsSelectorModalVisible(false); + }; + return ( - {!!currentPolicy && listItems.length > 0 && canUseWorkspaceRules && ( - <> - - {translate('workspace.categories.defaultSpendCategories')} - {translate('workspace.categories.spendCategoriesDescription')} - - {listItems} - + {canUseWorkspaceRules && !!currentPolicy && sections[0].data.length > 0 && ( + + {translate('workspace.categories.defaultSpendCategories')} + {translate('workspace.categories.spendCategoriesDescription')} + + } + sections={sections} + ListItem={SpendCategorySelectorListItem} + onSelectRow={(item) => { + if (!item.groupID || !item.categoryID) { + return; + } + setIsSelectorModalVisible(true); + setCategoryID(item.categoryID); + setGroupID(item.groupID); + }} + /> + )} + {canUseWorkspaceRules && categoryID && groupID && ( + setIsSelectorModalVisible(false)} + onCategorySelected={setNewCategory} + label={groupID[0].toUpperCase() + groupID.slice(1)} + /> )} diff --git a/src/pages/workspace/distanceRates/CategorySelector/CategorySelectorModal.tsx b/src/pages/workspace/distanceRates/CategorySelector/CategorySelectorModal.tsx index ce40753a23fc..b48456ecce79 100644 --- a/src/pages/workspace/distanceRates/CategorySelector/CategorySelectorModal.tsx +++ b/src/pages/workspace/distanceRates/CategorySelector/CategorySelectorModal.tsx @@ -25,12 +25,9 @@ type CategorySelectorModalProps = { /** Label to display on field */ label: string; - - /** Whether SectionList should use custom ScrollView */ - shouldUseCustomScrollView: boolean; }; -function CategorySelectorModal({policyID, isVisible, currentCategory, onCategorySelected, onClose, label, shouldUseCustomScrollView}: CategorySelectorModalProps) { +function CategorySelectorModal({policyID, isVisible, currentCategory, onCategorySelected, onClose, label}: CategorySelectorModalProps) { const styles = useThemeStyles(); return ( @@ -57,7 +54,6 @@ function CategorySelectorModal({policyID, isVisible, currentCategory, onCategory policyID={policyID} selectedCategory={currentCategory} onSubmit={onCategorySelected} - shouldUseCustomScrollView={shouldUseCustomScrollView} /> diff --git a/src/pages/workspace/distanceRates/CategorySelector/index.tsx b/src/pages/workspace/distanceRates/CategorySelector/index.tsx index 8628a4df0178..965204b0429d 100644 --- a/src/pages/workspace/distanceRates/CategorySelector/index.tsx +++ b/src/pages/workspace/distanceRates/CategorySelector/index.tsx @@ -33,23 +33,9 @@ type CategorySelectorProps = { /** Callback to hide category picker */ hidePickerModal: () => void; - - /** Whether SectionList should use custom ScrollView */ - shouldUseCustomScrollView?: boolean; }; -function CategorySelector({ - defaultValue = '', - wrapperStyle, - label, - setNewCategory, - policyID, - focused, - isPickerVisible, - showPickerModal, - hidePickerModal, - shouldUseCustomScrollView = false, -}: CategorySelectorProps) { +function CategorySelector({defaultValue = '', wrapperStyle, label, setNewCategory, policyID, focused, isPickerVisible, showPickerModal, hidePickerModal}: CategorySelectorProps) { const styles = useThemeStyles(); const updateCategoryInput = (categoryItem: ListItem) => { @@ -78,7 +64,6 @@ function CategorySelector({ onClose={hidePickerModal} onCategorySelected={updateCategoryInput} label={label} - shouldUseCustomScrollView={shouldUseCustomScrollView} /> ); diff --git a/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx b/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx index d04dcfd2d0cc..0ffdb362ae99 100644 --- a/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx +++ b/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx @@ -19,6 +19,7 @@ import SelectionListWithModal from '@components/SelectionListWithModal'; import TableListItemSkeleton from '@components/Skeletons/TableRowSkeleton'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; +import useAutoTurnSelectionModeOffWhenHasNoActiveOption from '@hooks/useAutoTurnSelectionModeOffWhenHasNoActiveOption'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -126,6 +127,8 @@ function WorkspaceReportFieldsPage({ ]; }, [filteredPolicyFieldList, policy, selectedReportFields, canSelectMultiple, translate]); + useAutoTurnSelectionModeOffWhenHasNoActiveOption(reportFieldsSections[0].data); + const updateSelectedReportFields = (item: ReportFieldForList) => { const fieldKey = ReportUtils.getReportFieldKey(item.fieldID); const updatedReportFields = selectedReportFields.find((selectedReportField) => selectedReportField.name === item.value) diff --git a/src/pages/workspace/rules/ExpenseReportRulesSection.tsx b/src/pages/workspace/rules/ExpenseReportRulesSection.tsx index 71fdc0a29eeb..a950567e903e 100644 --- a/src/pages/workspace/rules/ExpenseReportRulesSection.tsx +++ b/src/pages/workspace/rules/ExpenseReportRulesSection.tsx @@ -24,11 +24,12 @@ function ExpenseReportRulesSection({policyID}: ExpenseReportRulesSectionProps) { const styles = useThemeStyles(); const policy = usePolicy(policyID); + const customReportNamesUnavailable = !policy?.areReportFieldsEnabled; // Auto-approvals and self-approvals are unavailable due to the policy workflows settings const workflowApprovalsUnavailable = PolicyUtils.getWorkflowApprovalsUnavailable(policy); const autoPayApprovedReportsUnavailable = policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; - const renderFallbackSubtitle = (featureName: string) => { + const renderFallbackSubtitle = ({featureName, variant = 'unlock'}: {featureName: string; variant?: 'unlock' | 'enable'}) => { return ( {translate('workspace.rules.expenseReportRules.unlockFeatureGoToSubtitle')}{' '} @@ -38,7 +39,11 @@ function ExpenseReportRulesSection({policyID}: ExpenseReportRulesSectionProps) { > {translate('workspace.common.moreFeatures').toLowerCase()} {' '} - {translate('workspace.rules.expenseReportRules.unlockFeatureEnableWorkflowsSubtitle', featureName)} + {variant === 'unlock' ? ( + {translate('workspace.rules.expenseReportRules.unlockFeatureEnableWorkflowsSubtitle', featureName)} + ) : ( + {translate('workspace.rules.expenseReportRules.enableFeatureSubtitle', featureName)} + )} ); }; @@ -46,9 +51,13 @@ function ExpenseReportRulesSection({policyID}: ExpenseReportRulesSectionProps) { const optionItems = [ { title: translate('workspace.rules.expenseReportRules.customReportNamesTitle'), - subtitle: translate('workspace.rules.expenseReportRules.customReportNamesSubtitle'), + subtitle: customReportNamesUnavailable + ? renderFallbackSubtitle({featureName: translate('workspace.common.reportFields').toLowerCase(), variant: 'enable'}) + : translate('workspace.rules.expenseReportRules.customReportNamesSubtitle'), switchAccessibilityLabel: translate('workspace.rules.expenseReportRules.customReportNamesTitle'), isActive: policy?.shouldShowCustomReportTitleOption, + disabled: customReportNamesUnavailable, + showLockIcon: customReportNamesUnavailable, pendingAction: policy?.pendingFields?.shouldShowCustomReportTitleOption, onToggle: (isEnabled: boolean) => PolicyActions.enablePolicyDefaultReportTitle(policyID, isEnabled), subMenuItems: [ @@ -58,9 +67,9 @@ function ExpenseReportRulesSection({policyID}: ExpenseReportRulesSectionProps) { ? policy?.fieldList?.[CONST.POLICY.FIELD_LIST_TITLE_FIELD_ID].pendingFields?.defaultValue : null } + key="customName" > { @@ -161,9 +170,9 @@ function ExpenseReportRulesSection({policyID}: ExpenseReportRulesSectionProps) { ? policy?.autoReimbursement?.pendingFields?.limit : null } + key="autoPayReportsUnder" > {individualExpenseRulesItems.map((item) => ( - + flex: 1, }, - searchPressable: { - height: variables.componentSizeNormal, - }, - - searchContainer: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - gap: 8, - paddingHorizontal: 24, - backgroundColor: theme.hoverComponentBG, - borderRadius: variables.componentBorderRadiusRounded, - justifyContent: 'center', - }, - - searchContainerHovered: { - backgroundColor: theme.border, - }, - searchInputStyle: { color: theme.textSupporting, fontSize: 13, lineHeight: 16, }, + searchRouterInputStyle: { + borderRadius: variables.componentBorderRadiusSmall, + borderWidth: 2, + borderColor: theme.borderFocus, + paddingHorizontal: 8, + }, + searchTableHeaderActive: { fontWeight: FontUtils.fontWeight.bold, }, diff --git a/src/types/onyx/ReportNextStep.ts b/src/types/onyx/ReportNextStep.ts index 2107cbfccaac..a3994f71c7c4 100644 --- a/src/types/onyx/ReportNextStep.ts +++ b/src/types/onyx/ReportNextStep.ts @@ -11,6 +11,9 @@ type Message = { /** Action for the user to take */ action?: string; + + /** The text to be copied when the user clicks this section */ + clickToCopyText?: string; }; /** Model of report next step button data */