diff --git a/.env.example b/.env.example
index f398a72aa0af..c4adc4f98b65 100644
--- a/.env.example
+++ b/.env.example
@@ -11,6 +11,7 @@ USE_WEB_PROXY=false
USE_WDYR=false
CAPTURE_METRICS=false
ONYX_METRICS=false
+USE_THIRD_PARTY_SCRIPTS=false
EXPENSIFY_ACCOUNT_ID_ACCOUNTING=-1
EXPENSIFY_ACCOUNT_ID_ADMIN=-1
diff --git a/.env.production b/.env.production
index bb925eb70d39..cca9adf26f52 100644
--- a/.env.production
+++ b/.env.production
@@ -8,6 +8,6 @@ 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
+FB_API_KEY=AIzaSyBrLKgCuo6Vem6Xi5RPokdumssW8HaWBow
+FB_APP_ID=1:1008697809946:web:08de4ecb7656b7235445a3
+FB_PROJECT_ID=expensify-mobile-app
diff --git a/.eslintrc.js b/.eslintrc.js
index cfbfdcc8fe91..fefad92ce29d 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -294,6 +294,7 @@ module.exports = {
files: ['*.ts', '*.tsx'],
rules: {
'rulesdir/prefer-at': 'error',
+ 'rulesdir/boolean-conditional-rendering': 'error',
},
},
],
diff --git a/.github/ISSUE_TEMPLATE/Internal.md b/.github/ISSUE_TEMPLATE/Internal.md
new file mode 100644
index 000000000000..c4fde407df13
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/Internal.md
@@ -0,0 +1,25 @@
+---
+name: Open an internal issue for a backend fix
+about: Use this template to report a backend issue that an internal Expensify employee needs to fix
+labels: Hot Pick, Daily, Internal, AutoAssignerNewDotQuality
+---
+
+
+**Original GH:**
+
+## Action Performed:
+Break down in numbered steps
+
+## Expected Result:
+Describe what you think the backend _SHOULD_ have done
+
+## Actual Result:
+Describe what the backend _ACTUALLY_ did
+
+## Screenshots/Videos
+
+
+ Add any screenshot/video evidence
+
+
+
diff --git a/.github/ISSUE_TEMPLATE/Standard.md b/.github/ISSUE_TEMPLATE/Standard.md
index fa50d48b341b..7ae439777e78 100644
--- a/.github/ISSUE_TEMPLATE/Standard.md
+++ b/.github/ISSUE_TEMPLATE/Standard.md
@@ -16,7 +16,7 @@ ___
**Logs:** https://stackoverflow.com/c/expensify/questions/4856
**Expensify/Expensify Issue URL:**
**Issue reported by:**
-**Slack conversation:**
+**Slack conversation** (hyperlinked to channel name):
## Action Performed:
Break down in numbered steps
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 36b921570e7f..c1238d6805aa 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,7 +1,7 @@
-### Details
-
+### Explanation of Change
+
### Fixed Issues
-
-
-
-
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/images/bookmark.svg b/assets/images/bookmark.svg
index d7c1a8397b37..7e1cb61e40bf 100644
--- a/assets/images/bookmark.svg
+++ b/assets/images/bookmark.svg
@@ -1 +1 @@
-
+
\ No newline at end of file
diff --git a/assets/images/caret-up-down.svg b/assets/images/caret-up-down.svg
index d08aa2a1ebbd..054aa53e8f75 100644
--- a/assets/images/caret-up-down.svg
+++ b/assets/images/caret-up-down.svg
@@ -1,17 +1 @@
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/images/companyCards/amex.svg b/assets/images/companyCards/amex.svg
index 73e8164cdc63..61a7561a0622 100644
--- a/assets/images/companyCards/amex.svg
+++ b/assets/images/companyCards/amex.svg
@@ -1,40 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/assets/images/companyCards/card-amex.svg b/assets/images/companyCards/card-amex.svg
index 0e8b2d22e9b4..816b3ce3d9f3 100644
--- a/assets/images/companyCards/card-amex.svg
+++ b/assets/images/companyCards/card-amex.svg
@@ -1,32 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/assets/images/companyCards/card-bofa.svg b/assets/images/companyCards/card-bofa.svg
index 469142e4d6ff..3cc7cf1de2cc 100644
--- a/assets/images/companyCards/card-bofa.svg
+++ b/assets/images/companyCards/card-bofa.svg
@@ -1,32 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/assets/images/companyCards/card-brex.svg b/assets/images/companyCards/card-brex.svg
index dd19403d5837..d2511fb4bf31 100644
--- a/assets/images/companyCards/card-brex.svg
+++ b/assets/images/companyCards/card-brex.svg
@@ -1,27 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/assets/images/companyCards/card-capital_one.svg b/assets/images/companyCards/card-capital_one.svg
index 0a324710ae5d..64e79b8745db 100644
--- a/assets/images/companyCards/card-capital_one.svg
+++ b/assets/images/companyCards/card-capital_one.svg
@@ -1,42 +1 @@
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/images/companyCards/card-capitalone.svg b/assets/images/companyCards/card-capitalone.svg
index 95948992383b..a7c54c7bf529 100644
--- a/assets/images/companyCards/card-capitalone.svg
+++ b/assets/images/companyCards/card-capitalone.svg
@@ -1,27 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/assets/images/companyCards/card-chase.svg b/assets/images/companyCards/card-chase.svg
index 7bea71bd66ec..e0f539eeb766 100644
--- a/assets/images/companyCards/card-chase.svg
+++ b/assets/images/companyCards/card-chase.svg
@@ -1,24 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/assets/images/companyCards/card-citi.svg b/assets/images/companyCards/card-citi.svg
index c8d71afd7798..9c35e1b1ea4f 100644
--- a/assets/images/companyCards/card-citi.svg
+++ b/assets/images/companyCards/card-citi.svg
@@ -1,32 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/assets/images/companyCards/card-expensify.svg b/assets/images/companyCards/card-expensify.svg
index 9fd29b511c7b..3763b50e4b8a 100644
--- a/assets/images/companyCards/card-expensify.svg
+++ b/assets/images/companyCards/card-expensify.svg
@@ -1,99 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/assets/images/companyCards/card-mastercard.svg b/assets/images/companyCards/card-mastercard.svg
index e8d3cf8f4096..d8f90ea1f186 100644
--- a/assets/images/companyCards/card-mastercard.svg
+++ b/assets/images/companyCards/card-mastercard.svg
@@ -1,27 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/assets/images/companyCards/card-stripe.svg b/assets/images/companyCards/card-stripe.svg
index 608f067a1854..a618dc96af78 100644
--- a/assets/images/companyCards/card-stripe.svg
+++ b/assets/images/companyCards/card-stripe.svg
@@ -1,39 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/assets/images/companyCards/card-visa.svg b/assets/images/companyCards/card-visa.svg
index 9e2eae97ba90..dd8ca795403d 100644
--- a/assets/images/companyCards/card-visa.svg
+++ b/assets/images/companyCards/card-visa.svg
@@ -1,73 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/assets/images/companyCards/card-wells_fargo.svg b/assets/images/companyCards/card-wells_fargo.svg
index 66402710de97..8bb8b54bbbd4 100644
--- a/assets/images/companyCards/card-wells_fargo.svg
+++ b/assets/images/companyCards/card-wells_fargo.svg
@@ -1,35 +1 @@
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/images/companyCards/card-wellsfargo.svg b/assets/images/companyCards/card-wellsfargo.svg
index 086f66cc0423..bf9ea49ee2bd 100644
--- a/assets/images/companyCards/card-wellsfargo.svg
+++ b/assets/images/companyCards/card-wellsfargo.svg
@@ -1,57 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/assets/images/companyCards/card=-generic.svg b/assets/images/companyCards/card=-generic.svg
index 61e4296f7779..192c194da9e7 100644
--- a/assets/images/companyCards/card=-generic.svg
+++ b/assets/images/companyCards/card=-generic.svg
@@ -1,25 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/assets/images/companyCards/emptystate__card-pos.svg b/assets/images/companyCards/emptystate__card-pos.svg
index 6a6fbae74a04..e7f8429c254c 100644
--- a/assets/images/companyCards/emptystate__card-pos.svg
+++ b/assets/images/companyCards/emptystate__card-pos.svg
@@ -1,643 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/assets/images/companyCards/mastercard.svg b/assets/images/companyCards/mastercard.svg
index dcfac5eb33dd..24ff5d159c0b 100644
--- a/assets/images/companyCards/mastercard.svg
+++ b/assets/images/companyCards/mastercard.svg
@@ -1,40 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/assets/images/companyCards/pending-bank.svg b/assets/images/companyCards/pending-bank.svg
index dc265466d53f..58b7b96dab28 100644
--- a/assets/images/companyCards/pending-bank.svg
+++ b/assets/images/companyCards/pending-bank.svg
@@ -1,263 +1 @@
-
-
+
\ No newline at end of file
diff --git a/assets/images/companyCards/pendingstate_laptop-with-hourglass-and-cards.svg b/assets/images/companyCards/pendingstate_laptop-with-hourglass-and-cards.svg
index 0f40859c8839..258b0d0bb7b4 100644
--- a/assets/images/companyCards/pendingstate_laptop-with-hourglass-and-cards.svg
+++ b/assets/images/companyCards/pendingstate_laptop-with-hourglass-and-cards.svg
@@ -1,244 +1 @@
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/images/companyCards/visa.svg b/assets/images/companyCards/visa.svg
index 4a7a73b66639..4195eb76442a 100644
--- a/assets/images/companyCards/visa.svg
+++ b/assets/images/companyCards/visa.svg
@@ -1,74 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/assets/images/expensify-card-icon.svg b/assets/images/expensify-card-icon.svg
index 8680b7a22878..ab78635a8d23 100644
--- a/assets/images/expensify-card-icon.svg
+++ b/assets/images/expensify-card-icon.svg
@@ -1,16 +1 @@
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/images/expensify-card.svg b/assets/images/expensify-card.svg
index 2989f5025ae4..9614ef4955cc 100644
--- a/assets/images/expensify-card.svg
+++ b/assets/images/expensify-card.svg
@@ -1 +1 @@
-
+
\ No newline at end of file
diff --git a/assets/images/gallery-not-found.svg b/assets/images/gallery-not-found.svg
index 25da973ce9cb..87231be3741b 100644
--- a/assets/images/gallery-not-found.svg
+++ b/assets/images/gallery-not-found.svg
@@ -1,18 +1 @@
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/images/laptop-with-second-screen-sync.svg b/assets/images/laptop-with-second-screen-sync.svg
index a74048795dbf..153825d36415 100644
--- a/assets/images/laptop-with-second-screen-sync.svg
+++ b/assets/images/laptop-with-second-screen-sync.svg
@@ -1,213 +1 @@
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/images/laptop-with-second-screen-x.svg b/assets/images/laptop-with-second-screen-x.svg
index f4b6b77f70f1..8d051989bca4 100644
--- a/assets/images/laptop-with-second-screen-x.svg
+++ b/assets/images/laptop-with-second-screen-x.svg
@@ -1,150 +1 @@
-
+
\ No newline at end of file
diff --git a/assets/images/product-illustrations/broken-magnifying-glass.svg b/assets/images/product-illustrations/broken-magnifying-glass.svg
index 0b85744c1869..14de9eff24c1 100644
--- a/assets/images/product-illustrations/broken-magnifying-glass.svg
+++ b/assets/images/product-illustrations/broken-magnifying-glass.svg
@@ -1,28 +1 @@
-
+
\ No newline at end of file
diff --git a/assets/images/simple-illustrations/emptystate__puzzlepieces.svg b/assets/images/simple-illustrations/emptystate__puzzlepieces.svg
new file mode 100644
index 000000000000..d137ce5dcff2
--- /dev/null
+++ b/assets/images/simple-illustrations/emptystate__puzzlepieces.svg
@@ -0,0 +1,93 @@
+
+
+
diff --git a/assets/images/simple-illustrations/simple-illustration__commentbubbles_blue.svg b/assets/images/simple-illustrations/simple-illustration__commentbubbles_blue.svg
index 9c0711fcaedc..1a99094d07d9 100644
--- a/assets/images/simple-illustrations/simple-illustration__commentbubbles_blue.svg
+++ b/assets/images/simple-illustrations/simple-illustration__commentbubbles_blue.svg
@@ -1,22 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/assets/images/simple-illustrations/simple-illustration__envelopereceipt.svg b/assets/images/simple-illustrations/simple-illustration__envelopereceipt.svg
index eb2bad31620d..496255692f8c 100644
--- a/assets/images/simple-illustrations/simple-illustration__envelopereceipt.svg
+++ b/assets/images/simple-illustrations/simple-illustration__envelopereceipt.svg
@@ -1,49 +1 @@
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/images/simple-illustrations/simple-illustration__magnifyingglass-money.svg b/assets/images/simple-illustrations/simple-illustration__magnifyingglass-money.svg
index e7f64f69305a..3bb3514f1ebc 100644
--- a/assets/images/simple-illustrations/simple-illustration__magnifyingglass-money.svg
+++ b/assets/images/simple-illustrations/simple-illustration__magnifyingglass-money.svg
@@ -1,49 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/assets/images/simple-illustrations/simple-illustration__rules.svg b/assets/images/simple-illustrations/simple-illustration__rules.svg
index 6432f26d9ac6..5646cc0f5c2a 100644
--- a/assets/images/simple-illustrations/simple-illustration__rules.svg
+++ b/assets/images/simple-illustrations/simple-illustration__rules.svg
@@ -1,10 +1 @@
-
+
\ No newline at end of file
diff --git a/assets/images/spreadsheet-computer.svg b/assets/images/spreadsheet-computer.svg
index 74cac455537a..1a42220c8d86 100644
--- a/assets/images/spreadsheet-computer.svg
+++ b/assets/images/spreadsheet-computer.svg
@@ -1,186 +1 @@
-
+
\ No newline at end of file
diff --git a/assets/images/table.svg b/assets/images/table.svg
index dea1e990b97d..8a77919aa5a5 100644
--- a/assets/images/table.svg
+++ b/assets/images/table.svg
@@ -1,3 +1 @@
-
+
\ No newline at end of file
diff --git a/assets/images/turtle-in-shell.svg b/assets/images/turtle-in-shell.svg
index 6c5a8e74bb31..631aeb6b0940 100644
--- a/assets/images/turtle-in-shell.svg
+++ b/assets/images/turtle-in-shell.svg
@@ -1,87 +1 @@
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/images/user-eye.svg b/assets/images/user-eye.svg
index 2265b4892ded..7aa640b180d1 100644
--- a/assets/images/user-eye.svg
+++ b/assets/images/user-eye.svg
@@ -1,12 +1 @@
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/images/user-plus.svg b/assets/images/user-plus.svg
index bd49633bf738..84af850da735 100644
--- a/assets/images/user-plus.svg
+++ b/assets/images/user-plus.svg
@@ -1,11 +1 @@
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/config/webpack/CustomVersionFilePlugin.ts b/config/webpack/CustomVersionFilePlugin.ts
index 96ab8e61e480..1e442d55325e 100644
--- a/config/webpack/CustomVersionFilePlugin.ts
+++ b/config/webpack/CustomVersionFilePlugin.ts
@@ -4,23 +4,31 @@ import type {Compiler} from 'webpack';
import {version as APP_VERSION} from '../../package.json';
/**
- * Simple webpack plugin that writes the app version (from package.json) and the webpack hash to './version.json'
+ * Custom webpack plugin that writes the app version (from package.json) and the webpack hash to './version.json'
*/
class CustomVersionFilePlugin {
apply(compiler: Compiler) {
compiler.hooks.done.tap(this.constructor.name, () => {
const versionPath = path.join(__dirname, '/../../dist/version.json');
- fs.mkdir(path.dirname(versionPath), {recursive: true}, (directoryError) => {
- if (directoryError) {
- throw directoryError;
- }
- fs.writeFile(versionPath, JSON.stringify({version: APP_VERSION}), {encoding: 'utf8'}, (error) => {
- if (!error) {
- return;
+
+ fs.promises
+ .mkdir(path.dirname(versionPath), {recursive: true})
+ .then(() => fs.promises.readFile(versionPath, 'utf8'))
+ .then((existingVersion) => {
+ const {version} = JSON.parse(existingVersion) as {version: string};
+
+ if (version !== APP_VERSION) {
+ fs.promises.writeFile(versionPath, JSON.stringify({version: APP_VERSION}), 'utf8');
+ }
+ })
+ .catch((err: NodeJS.ErrnoException) => {
+ if (err.code === 'ENOENT') {
+ // if file doesn't exist
+ fs.promises.writeFile(versionPath, JSON.stringify({version: APP_VERSION}), 'utf8');
+ } else {
+ throw err;
}
- throw error;
});
- });
});
}
}
diff --git a/config/webpack/webpack.common.ts b/config/webpack/webpack.common.ts
index 2d8e27fd453e..ab5c304fcd1e 100644
--- a/config/webpack/webpack.common.ts
+++ b/config/webpack/webpack.common.ts
@@ -11,6 +11,8 @@ import {BundleAnalyzerPlugin} from 'webpack-bundle-analyzer';
import CustomVersionFilePlugin from './CustomVersionFilePlugin';
import type Environment from './types';
+dotenv.config();
+
type Options = {
rel: string;
as: string;
@@ -82,6 +84,7 @@ const getCommonConfiguration = ({file = '.env', platform = 'web'}: Environment):
isWeb: platform === 'web',
isProduction: file === '.env.production',
isStaging: file === '.env.staging',
+ useThirdPartyScripts: process.env.USE_THIRD_PARTY_SCRIPTS === 'true' || (platform === 'web' && file === '.env.production'),
}),
new PreloadWebpackPlugin({
rel: 'preload',
diff --git a/config/webpack/webpack.dev.ts b/config/webpack/webpack.dev.ts
index 80813adc1e3a..2279082024d1 100644
--- a/config/webpack/webpack.dev.ts
+++ b/config/webpack/webpack.dev.ts
@@ -1,3 +1,5 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin';
import path from 'path';
import portfinder from 'portfinder';
import {TimeAnalyticsPlugin} from 'time-analytics-webpack-plugin';
@@ -54,15 +56,15 @@ const getConfiguration = (environment: Environment): Promise =>
},
},
headers: {
- // eslint-disable-next-line @typescript-eslint/naming-convention
'Document-Policy': 'js-profiling',
},
},
plugins: [
new DefinePlugin({
- // eslint-disable-next-line @typescript-eslint/naming-convention
'process.env.PORT': port,
+ 'process.env.NODE_ENV': JSON.stringify('development'),
}),
+ new ReactRefreshWebpackPlugin({overlay: {sockProtocol: 'wss'}}),
],
cache: {
type: 'filesystem',
@@ -82,7 +84,7 @@ const getConfiguration = (environment: Environment): Promise =>
},
});
- return TimeAnalyticsPlugin.wrap(config);
+ return TimeAnalyticsPlugin.wrap(config, {plugin: {exclude: ['ReactRefreshPlugin']}});
});
export default getConfiguration;
diff --git a/contributingGuides/BUGZERO_CHECKLIST.md b/contributingGuides/BUGZERO_CHECKLIST.md
new file mode 100644
index 000000000000..00075620641c
--- /dev/null
+++ b/contributingGuides/BUGZERO_CHECKLIST.md
@@ -0,0 +1,62 @@
+# BugZero Checklist:
+
+- [ ] **[Contributor]** Classify the bug:
+
+
+Bug classification
+
+
+Source of bug:
+ - [ ] 1a. Result of the original design (eg. a case wasn't considered)
+ - [ ] 1b. Mistake during implementation
+ - [ ] 1c. Backend bug
+ - [ ] 1z. Other:
+
+Where bug was reported:
+ - [ ] 2a. Reported on production
+ - [ ] 2b. Reported on staging (deploy blocker)
+ - [ ] 2c. Reported on a PR
+ - [ ] 2z. Other:
+
+Who reported the bug:
+ - [ ] 3a. Expensify user
+ - [ ] 3b. Expensify employee
+ - [ ] 3c. Contributor
+ - [ ] 3d. QA
+ - [ ] 3z. Other:
+
+
+
+- [ ] **[Contributor]** The offending PR has been commented on, pointing out the bug it caused and why, so the author and reviewers can learn from the mistake.
+
+ Link to comment:
+
+- [ ] **[Contributor]** If the regression was CRITICAL (e.g. interrupts a core flow) A discussion in [#expensify-open-source](https://app.slack.com/client/E047TPA624F/C01GTK53T8Q) has been started about whether any other steps should be taken (e.g. updating the PR review checklist) in order to catch this type of bug sooner.
+
+ Link to discussion:
+
+- [ ] **[Contributor]** If it was decided to create a regression test for the bug, please propose the [regression test](https://github.com/Expensify/App/blob/main/contributingGuides/REGRESSION_TEST_BEST_PRACTICES.md) steps using the template below to ensure the same bug will not reach production again.
+
+
+Regression Test Proposal Template
+
+
+- [ ] **[BugZero Assignee]** Create a GH issue for creating/updating the regression test once above steps have been agreed upon.
+
+ Link to issue:
+
+## Regression Test Proposal
+### Precondition:
+
+
+-
+
+### Test:
+
+
+1.
+
+Do we agree 👍 or 👎
+
+
+
diff --git a/tests/perf-test/README.md b/contributingGuides/REASSURE_PERFORMANCE_TEST.md
similarity index 90%
rename from tests/perf-test/README.md
rename to contributingGuides/REASSURE_PERFORMANCE_TEST.md
index 2b66f7c147f3..0de450b78875 100644
--- a/tests/perf-test/README.md
+++ b/contributingGuides/REASSURE_PERFORMANCE_TEST.md
@@ -7,8 +7,11 @@ We use Reassure for monitoring performance regression. It helps us check if our
- Reassure builds on the existing React Testing Library setup and adds a performance measurement functionality. It's intended to be used on local machine and on a remote server as part of your continuous integration setup.
- To make sure the results are reliable and consistent, Reassure runs tests twice – once for the current branch and once for the base branch.
-## Performance Testing Strategy (`measurePerformance`)
+## Performance Testing Strategy (`measureRenders`)
+- Before adding new tests, check if the proposed scenario or component is already covered in existing tests. Duplicate tests can slow down the CI suite, making it harder to spot meaningful regressions.
+- Test only scenarios that cover new or unique interactions. Avoid testing repetitive user actions that could be captured within a single, comprehensive scenario.
+- Where applicable, use utility functions and helper methods to consolidate common actions (e.g., data mocking, scenario setup) across tests. This reduces redundancy and allows tests to be more focused and reusable.
- The primary focus is on testing business cases rather than small, reusable parts that typically don't introduce regressions, although some tests in that area are still necessary.
- To achieve this goal, it's recommended to stay relatively high up in the React tree, targeting whole screens to recreate real-life scenarios that users may encounter.
- For example, consider scenarios where an additional `useMemo` call could impact performance negatively.
@@ -84,7 +87,7 @@ test('Count increments on press', async () => {
await screen.findByText('Count: 2');
};
- await measurePerformance(
+ await measureRenders(
,
{ scenario, runs: 20 }
);
diff --git a/contributingGuides/REVIEWER_CHECKLIST.md b/contributingGuides/REVIEWER_CHECKLIST.md
index 4ff1f01b1475..5fc14328f3b4 100644
--- a/contributingGuides/REVIEWER_CHECKLIST.md
+++ b/contributingGuides/REVIEWER_CHECKLIST.md
@@ -19,7 +19,6 @@
- [ ] If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
- [ ] I verified proper code patterns were followed (see [Reviewing the code](https://github.com/Expensify/App/blob/main/contributingGuides/PR_REVIEW_GUIDELINES.md#reviewing-the-code))
- [ ] I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. `toggleReport` and not `onIconClick`).
- - [ ] I verified that the left part of a conditional rendering a React component is a boolean and NOT a string, e.g. `myBool && `.
- [ ] I verified that comments were added to code that is not self explanatory
- [ ] I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
- [ ] I verified any copy / text shown in the product is localized by adding it to `src/languages/*` files and using the [translation method](https://github.com/Expensify/App/blob/4bd99402cebdf4d7394e0d1f260879ea238197eb/src/components/withLocalize.js#L60)
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-an-Invoice.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-an-Invoice.md
index 84fafc949527..18020402f7de 100644
--- a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-an-Invoice.md
+++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-an-Invoice.md
@@ -10,9 +10,15 @@ There are multiple ways to pay Invoices in Expensify. Let’s go over each metho
# How to Pay Invoices
1. Sign in to your [Expensify web account](www.expensify.com).
-2. Click on the Invoice you’d like to pay to see the details.
-3. Click on the **Pay** button.
-4. Follow the prompts to pay through one of the following methods.
+2. Click on **Home** and find the pending Invoice payment
+3. Click **Pay** to be redirected to the Invoice
+4. Review the Invoice
+5. When you are ready to pay, click the **Pay** button at the top of the Invoice
+6. Follow the prompts to pay through one of the following methods.
+
+![Click Home and Pay on the invoice](https://help.expensify.com/assets/images/ExpensifyHelp_OldDot_PayInvoice_1.png){:width="100%"}
+
+![Click Pay on Invoice and choose a method of payment](https://help.expensify.com/assets/images/ExpensifyHelp_OldDot_PayInvoice_2.png){:width="100%"}
### ACH bank-to-bank transfer
diff --git a/docs/articles/expensify-classic/expenses/Add-Invoices-in-Bulk.md b/docs/articles/expensify-classic/expenses/Add-Invoices-in-Bulk.md
index 6257c1e6d84d..3e9b6c0397db 100644
--- a/docs/articles/expensify-classic/expenses/Add-Invoices-in-Bulk.md
+++ b/docs/articles/expensify-classic/expenses/Add-Invoices-in-Bulk.md
@@ -14,6 +14,13 @@ Expensify offers importing multiple invoices (bulk import) via CSV to save you f
5. Add the invoice details following the formatting rules (see below **CSV formatting guide** section)
6. Click **Upload CSV**
+![Click Reports, New Reports, choose Bulk Import Invoices](https://help.expensify.com/assets/images/invoice-bulk-01.png){:width="100%"}
+
+![Download Sample CSV](https://help.expensify.com/assets/images/invoice-bulk-02.png){:width="100%"}
+
+![Format CSV following our guidelines](https://help.expensify.com/assets/images/invoice-bulk-03.png){:width="100%"}
+
+
## CSV formatting guide
- Send to: recipient's email address (ex: john.smith@companydomain.com)
@@ -27,10 +34,15 @@ Expensify offers importing multiple invoices (bulk import) via CSV to save you f
## After the Invoices are uploaded
- After you click **Upload**, the invoices will automatically be created and viewable on the **Reports** page.
+- Set the **Reports page** filter to Invoices to narrow down your search.
- The **Send To** contact will get an email notifying them of the invoice you sent.
- You can manually edit the invoice details.
- You can manually upload a PDF of the invoice to the report.
+![Search for Invoices on Reports page](https://help.expensify.com/assets/images/invoice-bulk-04.png){:width="100%"}
+
+![Invoices will indicate next steps at the top of each report](https://help.expensify.com/assets/images/invoice-bulk-05.png){:width="100%"}
+
{% include faq-begin.md %}
## Are there any fees associated with Invoices in Expensify?
diff --git a/docs/articles/expensify-classic/expenses/Add-an-expense.md b/docs/articles/expensify-classic/expenses/Add-an-expense.md
index 461748c6af9e..92a96e989013 100644
--- a/docs/articles/expensify-classic/expenses/Add-an-expense.md
+++ b/docs/articles/expensify-classic/expenses/Add-an-expense.md
@@ -88,7 +88,7 @@ You can also email receipts to SmartScan by sending them to receipts@expensify.c
If you are an employee under a company workspace, you may not see all of the different expense type options depending on your company’s workspace settings.
{% include end-info.html %}
-# FAQs
+{% include faq-begin.md %}
**What’s the difference between a reimbursable and non-reimbursable expense?**
@@ -99,4 +99,5 @@ If you are an employee under a company workspace, you may not see all of the dif
If you are an employee under a company workspace, your expenses may automatically be configured as reimbursable or non-reimbursable depending on the details that are entered. If an expense is incorrectly labeled, you must reach out to an admin to have it corrected.
{% include end-info.html %}
+{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/expenses/Send-and-Receive-Payment-for-Invoices.md b/docs/articles/expensify-classic/expenses/Send-and-Receive-Payment-for-Invoices.md
index c2ebb64b0af6..fde2c43e9d95 100644
--- a/docs/articles/expensify-classic/expenses/Send-and-Receive-Payment-for-Invoices.md
+++ b/docs/articles/expensify-classic/expenses/Send-and-Receive-Payment-for-Invoices.md
@@ -11,9 +11,18 @@ Invoices can be sent to anyone with or without an Expensify account and paid dir
1. Sign in to your [Expensify web account](www.expensify.com)
2. Customize your company invoices following the steps in this [help article](https://help.expensify.com/articles/expensify-classic/workspaces/Set-Up-Invoicing). (Optional)
3. From the **Reports** page, click the drop-down and select **Invoice**.
-4. Upload a PDF/image of the invoice.
-5. Add applicable tags and categories based on your workspace settings.
-6. Click **Send**.
+4. Click **Add Expense** to upload an invoice or drag and drop the invoice as a pdf into the report to start the SmartScan process.
+5. Once the SmartScan process is complete, the invoice PDF will be added as a receipt to the expense
+6. Add applicable tags and categories based on your workspace settings.
+7. Click **Send**
+8. Enter the recipient's email address
+9. Add a memo, due date, attach a PDF of the invoice (Optional)
+10. Click **Send**
+11. The recipient will receive an email about the invoice and can pay through Expensify following these [steps](https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-an-Invoice).
+
+![From the Reports page, click New Report and select Invoice](https://help.expensify.com/assets/images/ExpensifyHelp_OldDot_SendInvoice.png){:width="100%"}
+
+![Click Send and enter the recipients email address](https://help.expensify.com/assets/images/ExpensifyHelp_OldDot_SendInvoice_02.png){:width="100%"}
## How to Receive an Invoice Payment in Expensify
diff --git a/docs/articles/expensify-classic/workspaces/Personal-and-Corporate-Karma.md b/docs/articles/expensify-classic/workspaces/Personal-and-Corporate-Karma.md
index 5c146b279163..ca6d9cf52f47 100644
--- a/docs/articles/expensify-classic/workspaces/Personal-and-Corporate-Karma.md
+++ b/docs/articles/expensify-classic/workspaces/Personal-and-Corporate-Karma.md
@@ -23,6 +23,16 @@ For every $500 of expenses added, you’ll donate $1 to a related Expensify.org
The fund from your Personal Karma is determined by the expense's MCC (Merchant Category Code). Each MCC supports one of Expensify.org's funds: Climate Justice, Food Security, Housing Equity, Reentry Services, and Youth Advocacy.
+## How do I opt-in to Personal Karma donations?
+
+You can enable Personal Karma donations from your personal workspace settings.
+
+- Sign in to your account at www.expensify.com.
+- Go to **Settings** > **Workspaces** > click on your **Individual** workspace settings.
+- Click Opt-in to Karma donations.
+
+![Settings > Workspaces > Individual workspace > enable Personal Karma in settings](https://help.expensify.com/assets/images/ExpensifyHelp_OldDot_Karma_Individual.png){:width="100%"}
+
## What is Corporate Karma?
Corporate Karma is for companies that want to engage in social responsibility. Each month, the donation is calculated based on the total amount of all approved expense reports, including invoices, across all Workspace.
@@ -31,12 +41,12 @@ For every $500 your team spends monthly, your company will donate $1 to a relate
The fund to which your Corporate Karma goes is determined by the expense's MCC (Merchant Category Code). Each MCC supports one of Expensify.org's funds: Climate Justice, Food Security, Housing Equity, Reentry Services, and Youth Advocacy.
-{% include faq-begin.md %}
-
-**How do I opt-in to Personal or Corporate Karma donations?**
+## How do I opt-in to Corporate Karma donations?
-You can donate Personal and Corporate Karma to Expensify.org in your company or personal workspace settings.
+As a [workspace billing owner](https://help.expensify.com/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account), you can enable Corporate Karma from the group workspace settings.
-Go to **Settings** > **Workspaces** > click on your Individual or Group workspace settings and Opt-in to Karma donations.
+- Sign in to your account at www.expensify.com.
+- Go to **Settings** > **Workspaces** > **Subscription**.
+- Toggle on Karma donations.
-{% include faq-end.md %}
+![Settings > Workspaces > Group > enable Corporate Karma in subscription settings](https://help.expensify.com/assets/images/ExpensifyHelp_OldDot_Karma_Group.png){:width="100%"}
diff --git a/docs/articles/new-expensify/billing-and-subscriptions/Billing-page-coming-soon.md b/docs/articles/new-expensify/billing-and-subscriptions/Billing-page-coming-soon.md
deleted file mode 100644
index 2ae2fcd2426d..000000000000
--- a/docs/articles/new-expensify/billing-and-subscriptions/Billing-page-coming-soon.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: Billing and Subscriptions
-description: Coming soon
----
-
-# Coming Soon
diff --git a/docs/articles/new-expensify/billing-and-subscriptions/Billing-page.md b/docs/articles/new-expensify/billing-and-subscriptions/Billing-page.md
new file mode 100644
index 000000000000..f945840d65da
--- /dev/null
+++ b/docs/articles/new-expensify/billing-and-subscriptions/Billing-page.md
@@ -0,0 +1,6 @@
+---
+title: Billing and Subscriptions
+description: An overview of how billing works in Expensify.
+---
+
+# Coming Soon
diff --git a/docs/articles/new-expensify/connections/quickbooks-online/Connect-to-QuickBooks-Online.md b/docs/articles/new-expensify/connections/quickbooks-online/Connect-to-QuickBooks-Online.md
index 60fdbe94b33b..192f7bf172b6 100644
--- a/docs/articles/new-expensify/connections/quickbooks-online/Connect-to-QuickBooks-Online.md
+++ b/docs/articles/new-expensify/connections/quickbooks-online/Connect-to-QuickBooks-Online.md
@@ -56,73 +56,6 @@ Log in to QuickBooks Online and ensure all of your employees are setup as either
![The QuickBooks Online Connect Connect button]({{site.url}}/assets/images/ExpensifyHelp-QBO-5.png){:width="100%"}
-
-
-# Step 3: Configure import settings
-
-The following steps help you determine how data will be imported from QuickBooks Online to Expensify.
-
-
-
Under the Accounting settings for your workspace, click Import under the QuickBooks Online connection.
-
Review each of the following import settings:
-
-
Chart of accounts: The chart of accounts are automatically imported from QuickBooks Online as categories. This cannot be amended.
-
Classes: Choose whether to import classes, which will be shown in Expensify as tags for expense-level coding.
-
Customers/projects: Choose whether to import customers/projects, which will be shown in Expensify as tags for expense-level coding.
-
Locations: Choose whether to import locations, which will be shown in Expensify as tags for expense-level coding.
-{% include info.html %}
-As Locations are only configurable as tags, you cannot export expense reports as vendor bills or checks to QuickBooks Online. To unlock these export options, either disable locations import or upgrade to the Control Plan to export locations encoded as a report field.
-{% include end-info.html %}
-
Taxes: Choose whether to import tax rates and defaults.
-
-
-
-# Step 4: Configure export settings
-
-The following steps help you determine how data will be exported from Expensify to QuickBooks Online.
-
-
-
Under the Accounting settings for your workspace, click Export under the QuickBooks Online connection.
-
Review each of the following export settings:
-
-
Preferred Exporter: Choose whether to assign a Workspace Admin as the Preferred Exporter. Once selected, the Preferred Exporter automatically receives reports for export in their account to help automate the exporting process.
-
-{% include info.html %}
-* Other Workspace Admins will still be able to export to QuickBooks Online.
-* If you set different export accounts for individual company cards under your domain settings, then your Preferred Exporter must be a Domain Admin.
-{% include end-info.html %}
-
-
Date: Choose whether to use the date of last expense, export date, or submitted date.
-
Export Out-of-Pocket Expenses as: Select whether out-of-pocket expenses will be exported as a check, journal entry, or vendor bill.
-
-{% include info.html %}
-These settings may vary based on whether tax is enabled for your workspace.
-* If tax is not enabled on the workspace, you’ll also select the Accounts Payable/AP.
-* If tax is enabled on the workspace, journal entry will not be available as an option. If you select the journal entries option first and later enable tax on the workspace, you will see a red dot and an error message under the “Export Out-of-Pocket Expenses as” options. To resolve this error, you must change your export option to vendor bill or check to successfully code and export expense reports.
-{% include end-info.html %}
-
-
Invoices: Select the QuickBooks Online invoice account that invoices will be exported to.
-
Export as: Select whether company cards export to QuickBooks Online as a credit card (the default), debit card, or vendor bill. Then select the account they will export to.
-
If you select vendor bill, you’ll also select the accounts payable account that vendor bills will be created from, as well as whether to set a default vendor for credit card transactions upon export. If this option is enabled, you will select the vendor that all credit card transactions will be applied to.
-
-
-
-# Step 5: Configure advanced settings
-
-The following steps help you determine the advanced settings for your connection, like auto-sync and employee invitation settings.
-
-
-
Under the Accounting settings for your workspace, click Advanced under the QuickBooks Online connection.
-
Select an option for each of the following settings:
-
-
Auto-sync: Choose whether to enable QuickBooks Online to automatically communicate changes with Expensify to ensure that the data shared between the two systems is up-to-date. New report approvals/reimbursements will be synced during the next auto-sync period.
-
Invite Employees: Choose whether to enable Expensify to import employee records from QuickBooks Online and invite them to this workspace.
-
Automatically Create Entities: Choose whether to enable Expensify to automatically create vendors and customers in QuickBooks Online if a matching vendor or customer does not exist.
-
Sync Reimbursed Reports: Choose whether to enable report syncing for reimbursed expenses. If enabled, all reports that are marked as Paid in QuickBooks Online will also show in Expensify as Paid. If enabled, you must also select the QuickBooks Online account that reimbursements are coming out of, and Expensify will automatically create the payment in QuickBooks Online.
-
Invoice Collection Account: Select the invoice collection account that you want invoices to appear under once the invoice is marked as paid.
-
-
-
{% include faq-begin.md %}
**Why do I see a red dot next to my connection?**
diff --git a/docs/articles/new-expensify/connections/xero/Configure-Xero.md b/docs/articles/new-expensify/connections/xero/Configure-Xero.md
index 218e81c98707..b417d6169a1e 100644
--- a/docs/articles/new-expensify/connections/xero/Configure-Xero.md
+++ b/docs/articles/new-expensify/connections/xero/Configure-Xero.md
@@ -1,11 +1,75 @@
---
title: Configure Xero
-description: Coming soon
+description: How to configure your settings for Xero
---
+
+To configure your Xero settings, complete the steps below.
-# FAQ
+# Step 1: Configure import settings
-## How do I know if a report successfully exported to Xero?
+The following steps help you determine how data will be imported from Xero to Expensify.
+
+
+
Under the Accounting settings for your workspace, click Import under the Xero connection.
+
Select an option for each of the following settings to determine what information will be imported from Xero into Expensify:
+
+
Xero organization: Select which Xero organization your Expensify workspace is connected to. Each organization can only be connected to one workspace at a time.
+
Chart of Accounts: Your Xero chart of accounts and any accounts marked as “Show In Expense Claims” will be automatically imported into Expensify as Categories. This cannot be amended.
+
Tracking Categories: Choose whether to import your Xero categories for cost centers and regions as tags in Expensify.
+
Re-bill Customers: When enabled, Xero customer contacts are imported into Expensify as tags for expense tracking. After exporting to Xero, tagged billable expenses can be included on a sales invoice to your customer.
+
Taxes: Choose whether to import tax rates and tax defaults from Xero.
+
+
+
+# Step 2: Configure export settings
+The following steps help you determine how data will be exported from Expensify to Xero.
+
+
+
Under the Accounting settings for your workspace, click Export under the Xero connection.
+
Review each of the following export settings:
+
+
Preferred Exporter: Choose whether to assign a Workspace Admin as the Preferred Exporter. Once selected, the Preferred Exporter automatically receives reports for export in their account to help automate the exporting process.
+
+
+{% include info.html %}
+- Other Workspace Admins will still be able to export to Xero.
+- If you set different export accounts for individual company cards under your domain settings, then your Preferred Exporter must be a Domain Admin.
+{% include end-info.html %}
+
+
+
+
Export Out-of-Pocket Expenses as: All out-of-pocket expenses will be exported as purchase bills. This cannot be amended.
+
Purchase Bill Date: Choose whether to use the date of the last expense, export date, or submitted date.
+
Export invoices as: All invoices exported to Xero will be as sales invoices. This cannot be amended.
+
Export company card expenses as: All company card expenses are exported to Xero as bank transactions. This cannot be amended.
+
Xero Bank Account: Select which bank account will be used to post bank transactions when non-reimbursable expenses are exported.
+
+
+
+# Step 3: Configure advanced settings
+
+The following steps help you determine the advanced settings for your connection, like auto-sync.
+
+
+
Under the Accounting settings for your workspace, click Advanced under the Xero connection.
+
Select an option for each of the following settings:
+
+
Auto-sync: Choose whether to enable Xero to automatically communicate changes with Expensify to ensure that the data shared between the two systems is up-to-date. New report approvals/reimbursements will be synced during the next auto-sync period. Once you’ve added a business bank account for ACH reimbursement, any reimbursable expenses will be sent to Xero automatically when the report is reimbursed. For non-reimbursable reports, Expensify automatically queues the report to export to Xero after it has completed the approval workflow in Expensify.
+
Set Purchase Bill Status: Choose the status of your purchase bills:
+
+
Draft
+
Awaiting Approval
+
Awaiting Payment
+
+
Sync Reimbursed Reports: Choose whether to enable report syncing for reimbursed expenses. If enabled, all reports that are marked as Paid in Xero will also show in Expensify as Paid. If enabled, you must also select the Xero account that reimbursements are coming out of, and Expensify will automatically create the payment in Xero.
+
Xero Bill Payment Account: If you enable Sync Reimbursed Reports, you must select the Xero Bill Payment account your reimbursements will come from.
+
Xero Invoice Collections Account: If you are exporting invoices from Expensify, select the invoice collection account that you want invoices to appear under once they are marked as paid.
+
+
+
+{% include faq-begin.md %}
+
+## How do I know if a report is successfully exported to Xero?
When a report exports successfully, a message is posted in the related Expensify Chat room.
@@ -23,3 +87,5 @@ When an admin manually exports a report, Expensify will warn them if the report
- If a report has been exported and reimbursed via ACH, it will be automatically marked as paid in Xero during the next sync.
- If a report has been exported and marked as paid in Xero, it will be automatically marked as reimbursed in Expensify during the next sync.
- If a report has not yet been exported to Xero, it won’t be automatically exported.
+
+{% include faq-end.md %}
diff --git a/docs/articles/new-expensify/workspaces/Require-tags-and-categories-for-expenses.md b/docs/articles/new-expensify/workspaces/Require-tags-and-categories-for-expenses.md
index df77ed3b5b01..f2fd6970f5af 100644
--- a/docs/articles/new-expensify/workspaces/Require-tags-and-categories-for-expenses.md
+++ b/docs/articles/new-expensify/workspaces/Require-tags-and-categories-for-expenses.md
@@ -30,7 +30,7 @@ To require workspace members to add tags and/or categories to their expenses,
{% include end-selector.html %}
-![In the Workspace > Categories setting, the right-hand panel is open and the toggle to require categories on expenses is highlighted.]({{site.url}}/assets/images/workspace_category_toggle.png){:width="100%"}
+![In the Workspace, Categories setting, the right-hand panel is open and the toggle to require categories on expenses is highlighted.]({{site.url}}/assets/images/Workspace_category_toggle.png){:width="100%"}
This will highlight the tag and/or category field as required on all expenses.
diff --git a/docs/assets/images/ExpensifyHelp_OldDot_Karma_Group.png b/docs/assets/images/ExpensifyHelp_OldDot_Karma_Group.png
new file mode 100644
index 000000000000..e0d5d406ba2f
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_OldDot_Karma_Group.png differ
diff --git a/docs/assets/images/ExpensifyHelp_OldDot_Karma_Individual.png b/docs/assets/images/ExpensifyHelp_OldDot_Karma_Individual.png
new file mode 100644
index 000000000000..d3115469350f
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_OldDot_Karma_Individual.png differ
diff --git a/docs/redirects.csv b/docs/redirects.csv
index d3672618cfad..06fd7c1ef502 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -590,3 +590,4 @@ https://help.expensify.com/articles/expensify-classic/articles/expensify-classic
https://help.expensify.com/articles/expensify-classic/articles/expensify-classic/expenses/Bulk-Upload-Multiple-Invoices,https://help.expensify.com/articles/expensify-classic/articles/expensify-classic/expenses/Add-Invoices-in-Bulk
https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills
https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/add-a-payment-card-and-view-your-subscription,https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription
+https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Billing-page-coming-soon,https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Billing-page
diff --git a/fastlane/Appfile b/fastlane/Appfile
index 66955822aab7..42f887a827d1 100644
--- a/fastlane/Appfile
+++ b/fastlane/Appfile
@@ -1,5 +1,4 @@
-app_identifier("com.chat.expensify.chat") # The bundle identifier of your app
-apple_id("ios@expensify.com") # Your Apple email address
-
-itc_team_id("152696") # App Store Connect Team ID
-team_id("368M544MTT") # Developer Portal Team ID
+# See https://docs.fastlane.tools/advanced/Appfile/
+apple_id("ios@expensify.com")
+itc_team_id("152696")
+team_id("368M544MTT")
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index eed84acdc916..74bcff5bf320 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -68,6 +68,23 @@ platform :android do
setGradleOutputsInEnv()
end
+ desc "Generate a production HybridApp AAB"
+ lane :build_hybrid do
+ ENV["ENVFILE"]="../.env.production.hybridapp"
+ gradle(
+ project_dir: '../Android',
+ task: "bundleRelease",
+ flags: "--refresh-dependencies",
+ properties: {
+ "android.injected.signing.store.file" => './upload-key.keystore',
+ "android.injected.signing.store.password" => ENV["ANDROID_UPLOAD_KEYSTORE_PASSWORD"],
+ "android.injected.signing.key.alias" => ENV["ANDROID_UPLOAD_KEYSTORE_ALIAS"],
+ "android.injected.signing.key.password" => ENV["ANDROID_UPLOAD_KEY_PASSWORD"],
+ }
+ )
+ setGradleOutputsInEnv()
+ end
+
desc "Generate a new local APK"
lane :build_local do
ENV["ENVFILE"]=".env.production"
@@ -80,6 +97,18 @@ platform :android do
setGradleOutputsInEnv()
end
+ desc "Generate a new local HybridApp APK"
+ lane :build_local_hybrid do
+ ENV["ENVFILE"]=".env.production"
+ gradle(
+ project_dir: '../Android',
+ task: 'assemble',
+ flavor: 'Production',
+ build_type: 'Release',
+ )
+ setGradleOutputsInEnv()
+ end
+
desc "Generate a new local APK for e2e testing"
lane :build_e2e do
ENV["ENVFILE"]="tests/e2e/.env.e2e"
@@ -151,6 +180,38 @@ platform :android do
)
end
+ desc "Upload HybridApp to Google Play for internal testing"
+ lane :upload_google_play_internal_hybrid do
+ # Google is very unreliable, so we retry a few times
+ ENV["SUPPLY_UPLOAD_MAX_RETRIES"]="5"
+ upload_to_play_store(
+ package_name: "org.me.mobiexpensifyg",
+ json_key: './android-fastlane-json-key.json',
+ aab: ENV[KEY_GRADLE_AAB_PATH],
+ track: 'alpha',
+ rollout: '1.0'
+ )
+
+ # Update the internal testing group "beta" with the latest version
+ upload_to_play_store(
+ package_name: "org.me.mobiexpensifyg",
+ json_key: './android-fastlane-json-key.json',
+ track: 'alpha',
+ track_promote_to: 'beta',
+ skip_upload_aab: true
+ )
+
+ # Update the internal testing group "Internal Testers" with the latest version
+ upload_to_play_store(
+ package_name: "org.me.mobiexpensifyg",
+ json_key: './android-fastlane-json-key.json',
+ track: 'alpha',
+ track_promote_to: 'Internal Testers',
+ skip_upload_aab: true
+ )
+
+ end
+
desc "Deploy app to Google Play production"
lane :upload_google_play_production do
# Google is very unreliable, so we retry a few times
@@ -228,6 +289,37 @@ platform :ios do
setIOSBuildOutputsInEnv()
end
+ desc "Build an iOS HybridApp production build"
+ lane :build_hybrid do
+ ENV["ENVFILE"]="../.env.production.hybridapp"
+
+ setupIOSSigningCertificate()
+
+ install_provisioning_profile(
+ path: "./OldApp_AppStore.mobileprovision"
+ )
+
+ install_provisioning_profile(
+ path: "./OldApp_AppStore_Share_Extension.mobileprovision"
+ )
+
+ build_app(
+ workspace: "../iOS/Expensify.xcworkspace",
+ scheme: "Expensify",
+ output_name: "Expensify.ipa",
+ export_method: "app-store",
+ export_options: {
+ manageAppVersionAndBuildNumber: false,
+ provisioningProfiles: {
+ "com.expensify.expensifylite" => "(OldApp) AppStore",
+ "com.expensify.expensifylite.SmartScanExtension" => "(OldApp) AppStore: Share Extension"
+ }
+ }
+ )
+
+ setIOSBuildOutputsInEnv()
+ end
+
desc "Build an unsigned iOS production build"
lane :build_unsigned do
ENV["ENVFILE"]=".env.production"
@@ -238,6 +330,16 @@ platform :ios do
setIOSBuildOutputsInEnv()
end
+ desc "Build an unsigned iOS HybridApp production build"
+ lane :build_unsigned_hybrid do
+ ENV["ENVFILE"]="../Mobile-Expensify/.env.production.hybridapp"
+ build_app(
+ workspace: "../Mobile-Expensify/iOS/Expensify.xcworkspace",
+ scheme: "Expensify"
+ )
+ setIOSBuildOutputsInEnv()
+ end
+
desc "Build AdHoc app for testing"
lane :build_adhoc do
ENV["ENVFILE"]=".env.adhoc"
@@ -286,6 +388,7 @@ platform :ios do
desc "Upload app to TestFlight"
lane :upload_testflight do
upload_to_testflight(
+ app_identifier: "com.chat.expensify.chat",
api_key_path: "./ios/ios-fastlane-json-key.json",
distribute_external: true,
notify_external_testers: true,
@@ -316,9 +419,45 @@ platform :ios do
)
end
- desc "Submit app to App Store Review"
+ desc "Upload HybridApp to TestFlight"
+ lane :upload_testflight_hybrid do
+ upload_to_testflight(
+ app_identifier: "com.expensify.expensifylite",
+ api_key_path: "./ios/ios-fastlane-json-key.json",
+ distribute_external: true,
+ notify_external_testers: true,
+ changelog: "Thank you for beta testing New Expensify, this version includes bug fixes and improvements.",
+ groups: ["Applause", "Beta Testers", "Expensify Employees"],
+ demo_account_required: true,
+ beta_app_review_info: {
+ contact_email: ENV["APPLE_CONTACT_EMAIL"],
+ contact_first_name: "Andrew",
+ contact_last_name: "Gable",
+ contact_phone: ENV["APPLE_CONTACT_PHONE"],
+ demo_account_name: ENV["APPLE_DEMO_EMAIL"],
+ demo_account_password: ENV["APPLE_DEMO_PASSWORD"],
+ notes: "1. In the Expensify app, enter the email 'appletest.expensify@proton.me'. This will trigger a sign-in link to be sent to 'appletest.expensify@proton.me'
+ 2. Navigate to https://account.proton.me/login, log into Proton Mail using 'appletest.expensify@proton.me' as email and the password associated with 'appletest.expensify@proton.me', provided above
+ 3. Once logged into Proton Mail, navigate to your inbox and locate the email triggered in step 1. The email subject should be 'Your magic sign-in link for Expensify'
+ 4. Open the email and copy the 6-digit sign-in code provided within
+ 5. Return to the Expensify app and enter the copied 6-digit code in the designated login field"
+ }
+ )
+
+ puts "dsym path: #{ENV[KEY_DSYM_PATH]}"
+ upload_symbols_to_crashlytics(
+ app_id: "1:1008697809946:ios:3ffad71f664f2886",
+ dsym_path: ENV[KEY_DSYM_PATH],
+ gsp_path: "./ios/GoogleService-Info.plist",
+ # Assuming we are running this from the react-native submodule directory for HybridApp
+ binary_path: "../iOS/Pods/FirebaseCrashlytics/upload-symbols"
+ )
+ end
+
+ desc "Submit app for production App Store Review"
lane :submit_for_review do
deliver(
+ app_identifier: "com.chat.expensify.chat",
api_key_path: "./ios/ios-fastlane-json-key.json",
# Skip HTMl report verification
diff --git a/ios/GoogleService-Info-DEV.plist b/ios/GoogleService-Info-DEV.plist
new file mode 100644
index 000000000000..5bfb1a332dfc
--- /dev/null
+++ b/ios/GoogleService-Info-DEV.plist
@@ -0,0 +1,38 @@
+
+
+
+
+ CLIENT_ID
+ 921154746561-8niu5ba8g4dgsqsqso3lugdhe6vikqpq.apps.googleusercontent.com
+ REVERSED_CLIENT_ID
+ com.googleusercontent.apps.921154746561-8niu5ba8g4dgsqsqso3lugdhe6vikqpq
+ ANDROID_CLIENT_ID
+ 921154746561-cbegir0tnc2gan6k1gre5vtn75p60hom.apps.googleusercontent.com
+ API_KEY
+ AIzaSyA9Qn7q5Iw26gTzjI7012C4PaFrFagpC_I
+ GCM_SENDER_ID
+ 921154746561
+ PLIST_VERSION
+ 1
+ BUNDLE_ID
+ com.expensify.chat.dev
+ PROJECT_ID
+ expensify-chat
+ STORAGE_BUCKET
+ expensify-chat.appspot.com
+ IS_ADS_ENABLED
+
+ IS_ANALYTICS_ENABLED
+
+ IS_APPINVITE_ENABLED
+
+ IS_GCM_ENABLED
+
+ IS_SIGNIN_ENABLED
+
+ GOOGLE_APP_ID
+ 1:921154746561:ios:12c3a0b9276d7d2f027c40
+ DATABASE_URL
+ https://expensify-chat.firebaseio.com
+
+
diff --git a/ios/GoogleService-Info.plist b/ios/GoogleService-Info.plist
index 147bec8c2875..e8549ed328fd 100644
--- a/ios/GoogleService-Info.plist
+++ b/ios/GoogleService-Info.plist
@@ -6,6 +6,8 @@
921154746561-s3uqn2oe4m85tufi6mqflbfbuajrm2i3.apps.googleusercontent.comREVERSED_CLIENT_IDcom.googleusercontent.apps.921154746561-s3uqn2oe4m85tufi6mqflbfbuajrm2i3
+ ANDROID_CLIENT_ID
+ 921154746561-cbegir0tnc2gan6k1gre5vtn75p60hom.apps.googleusercontent.comAPI_KEYAIzaSyA9Qn7q5Iw26gTzjI7012C4PaFrFagpC_IGCM_SENDER_ID
@@ -21,7 +23,7 @@
IS_ADS_ENABLEDIS_ANALYTICS_ENABLED
-
+ IS_APPINVITE_ENABLEDIS_GCM_ENABLED
@@ -33,4 +35,4 @@
DATABASE_URLhttps://expensify-chat.firebaseio.com
-
\ No newline at end of file
+
diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj
index b3ec8febb1df..d8eceab72b95 100644
--- a/ios/NewExpensify.xcodeproj/project.pbxproj
+++ b/ios/NewExpensify.xcodeproj/project.pbxproj
@@ -18,7 +18,6 @@
0CDA8E38287DD6A0004ECBEC /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0CDA8E36287DD6A0004ECBEC /* Images.xcassets */; };
0DFC45942C884E0A00B56C91 /* RCTShortcutManagerModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 0DFC45932C884E0A00B56C91 /* RCTShortcutManagerModule.m */; };
0DFC45952C884E0A00B56C91 /* RCTShortcutManagerModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 0DFC45932C884E0A00B56C91 /* RCTShortcutManagerModule.m */; };
- 0F5BE0CE252686330097D869 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 0F5BE0CD252686320097D869 /* GoogleService-Info.plist */; };
0F5E5350263B73FD004CA14F /* EnvironmentChecker.m in Sources */ = {isa = PBXBuildFile; fileRef = 0F5E534F263B73FD004CA14F /* EnvironmentChecker.m */; };
0F5E5351263B73FD004CA14F /* EnvironmentChecker.m in Sources */ = {isa = PBXBuildFile; fileRef = 0F5E534F263B73FD004CA14F /* EnvironmentChecker.m */; };
1246A3EF20E54E7A9494C8B9 /* ExpensifyNeue-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = F4F8A052A22040339996324B /* ExpensifyNeue-Regular.otf */; };
@@ -34,6 +33,9 @@
70CF6E82262E297300711ADC /* BootSplash.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 70CF6E81262E297300711ADC /* BootSplash.storyboard */; };
7F5E81F06BCCF61AD02CEA06 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCD444BEDDB0AF1745B39049 /* ExpoModulesProvider.swift */; };
7F9DD8DA2B2A445B005E3AFA /* ExpError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F9DD8D92B2A445B005E3AFA /* ExpError.swift */; };
+ 7FB680AE2CC94EDA006693CF /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 7FB680AD2CC94EDA006693CF /* GoogleService-Info.plist */; };
+ 7FB680AF2CC94EDA006693CF /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 7FB680AD2CC94EDA006693CF /* GoogleService-Info.plist */; };
+ 7FB680B02CC94EDA006693CF /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 7FB680AD2CC94EDA006693CF /* GoogleService-Info.plist */; };
7FD73C9E2B23CE9500420AF3 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FD73C9D2B23CE9500420AF3 /* NotificationService.swift */; };
7FD73CA22B23CE9500420AF3 /* NotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7FD73C9B2B23CE9500420AF3 /* NotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
8744C5400E24E379441C04A4 /* libPods-NewExpensify.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 59A21B2405370FDDD847C813 /* libPods-NewExpensify.a */; };
@@ -43,7 +45,7 @@
D27CE6B77196EF3EF450EEAC /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 0D3F9E814828D91464DF9D35 /* PrivacyInfo.xcprivacy */; };
DD79042B2792E76D004484B4 /* RCTBootSplash.mm in Sources */ = {isa = PBXBuildFile; fileRef = DD79042A2792E76D004484B4 /* RCTBootSplash.mm */; };
DDCB2E57F334C143AC462B43 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D20D83B0E39BA6D21761E72 /* ExpoModulesProvider.swift */; };
- E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */ = {isa = PBXBuildFile; };
+ E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */ = {isa = PBXBuildFile; };
E9DF872D2525201700607FDC /* AirshipConfig.plist in Resources */ = {isa = PBXBuildFile; fileRef = E9DF872C2525201700607FDC /* AirshipConfig.plist */; };
ED222ED90E074A5481A854FA /* ExpensifyNeue-BoldItalic.otf in Resources */ = {isa = PBXBuildFile; fileRef = 8B28D84EF339436DBD42A203 /* ExpensifyNeue-BoldItalic.otf */; };
F0C450EA2705020500FD2970 /* colors.json in Resources */ = {isa = PBXBuildFile; fileRef = F0C450E92705020500FD2970 /* colors.json */; };
@@ -94,7 +96,6 @@
0D3F9E814828D91464DF9D35 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = NewExpensify/PrivacyInfo.xcprivacy; sourceTree = ""; };
0DFC45922C884D7900B56C91 /* RCTShortcutManagerModule.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RCTShortcutManagerModule.h; sourceTree = ""; };
0DFC45932C884E0A00B56C91 /* RCTShortcutManagerModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCTShortcutManagerModule.m; sourceTree = ""; };
- 0F5BE0CD252686320097D869 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; };
0F5E534E263B73D5004CA14F /* EnvironmentChecker.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = EnvironmentChecker.h; sourceTree = ""; };
0F5E534F263B73FD004CA14F /* EnvironmentChecker.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = EnvironmentChecker.m; sourceTree = ""; };
13B07F961A680F5B00A75B9A /* New Expensify Dev.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "New Expensify Dev.app"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -133,6 +134,7 @@
7F3784A72C75131000063508 /* NewExpensifyReleaseProduction.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = NewExpensifyReleaseProduction.entitlements; path = NewExpensify/NewExpensifyReleaseProduction.entitlements; sourceTree = ""; };
7F9C91352CA5EC4900FC4DC1 /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = ""; };
7F9DD8D92B2A445B005E3AFA /* ExpError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpError.swift; sourceTree = ""; };
+ 7FB680AD2CC94EDA006693CF /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; };
7FD73C9B2B23CE9500420AF3 /* NotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
7FD73C9D2B23CE9500420AF3 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; };
7FD73C9F2B23CE9500420AF3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
@@ -176,8 +178,8 @@
buildActionMask = 2147483647;
files = (
383643682B6D4AE2005BB9AE /* DeviceCheck.framework in Frameworks */,
- E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */,
- E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */,
+ E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */,
+ E51DC681C7DEE40AEBDDFBFE /* (null) in Frameworks */,
8744C5400E24E379441C04A4 /* libPods-NewExpensify.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -212,13 +214,13 @@
13B07FAE1A68108700A75B9A /* NewExpensify */ = {
isa = PBXGroup;
children = (
+ 7FB680AD2CC94EDA006693CF /* GoogleService-Info.plist */,
7F3784A72C75131000063508 /* NewExpensifyReleaseProduction.entitlements */,
7F3784A62C7512D900063508 /* NewExpensifyReleaseAdHoc.entitlements */,
7F3784A52C7512CF00063508 /* NewExpensifyReleaseDevelopment.entitlements */,
7F3784A42C7512BF00063508 /* NewExpensifyDebugProduction.entitlements */,
7F3784A32C75129D00063508 /* NewExpensifyDebugAdHoc.entitlements */,
7F3784A22C75103800063508 /* NewExpensifyDebugDevelopment.entitlements */,
- 0F5BE0CD252686320097D869 /* GoogleService-Info.plist */,
E9DF872C2525201700607FDC /* AirshipConfig.plist */,
0CDA8E36287DD6A0004ECBEC /* Images.xcassets */,
008F07F21AC5B25A0029DE68 /* main.jsbundle */,
@@ -500,6 +502,7 @@
buildActionMask = 2147483647;
files = (
0CDA8E38287DD6A0004ECBEC /* Images.xcassets in Resources */,
+ 7FB680B02CC94EDA006693CF /* GoogleService-Info.plist in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -507,7 +510,6 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
- 0F5BE0CE252686330097D869 /* GoogleService-Info.plist in Resources */,
E9DF872D2525201700607FDC /* AirshipConfig.plist in Resources */,
F0C450EA2705020500FD2970 /* colors.json in Resources */,
083353EB2B5AB22A00C603C0 /* attention.mp3 in Resources */,
@@ -519,6 +521,7 @@
083353EE2B5AB22A00C603C0 /* success.mp3 in Resources */,
0C7C65547D7346EB923BE808 /* ExpensifyMono-Regular.otf in Resources */,
2A9F8CDA983746B0B9204209 /* ExpensifyNeue-Bold.otf in Resources */,
+ 7FB680AE2CC94EDA006693CF /* GoogleService-Info.plist in Resources */,
083353EC2B5AB22A00C603C0 /* done.mp3 in Resources */,
083353ED2B5AB22A00C603C0 /* receive.mp3 in Resources */,
ED222ED90E074A5481A854FA /* ExpensifyNeue-BoldItalic.otf in Resources */,
@@ -532,6 +535,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 7FB680AF2CC94EDA006693CF /* GoogleService-Info.plist in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
diff --git a/ios/NewExpensify.xcodeproj/xcshareddata/xcschemes/New Expensify Dev.xcscheme b/ios/NewExpensify.xcodeproj/xcshareddata/xcschemes/New Expensify Dev.xcscheme
index 93d775217f11..f9acbe8abe4f 100644
--- a/ios/NewExpensify.xcodeproj/xcshareddata/xcschemes/New Expensify Dev.xcscheme
+++ b/ios/NewExpensify.xcodeproj/xcshareddata/xcschemes/New Expensify Dev.xcscheme
@@ -60,6 +60,16 @@
ReferencedContainer = "container:NewExpensify.xcodeproj">
+
+
+
+
+
+ CFBundlePackageType
APPLCFBundleShortVersionString
- 9.0.54
+ 9.0.56CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 9.0.54.1
+ 9.0.56.7FullStoryOrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index cb867d7af0b5..f95e27e3a725 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 9.0.54
+ 9.0.56CFBundleSignature????CFBundleVersion
- 9.0.54.1
+ 9.0.56.7
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index c7c9879bb2ab..9ed1578badb6 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName$(PRODUCT_NAME)CFBundleShortVersionString
- 9.0.54
+ 9.0.56CFBundleVersion
- 9.0.54.1
+ 9.0.56.7NSExtensionNSExtensionPointIdentifier
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 9a706cc4e8aa..d1851cbce1af 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -2329,10 +2329,6 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- - RNDevMenu (4.1.1):
- - React-Core
- - React-Core/DevSupport
- - React-RCTNetwork
- RNFBAnalytics (12.9.3):
- Firebase/Analytics (= 8.8.0)
- React-Core
@@ -2395,7 +2391,7 @@ PODS:
- RNGoogleSignin (10.0.1):
- GoogleSignIn (~> 7.0)
- React-Core
- - RNLiveMarkdown (0.1.176):
+ - RNLiveMarkdown (0.1.179):
- DoubleConversion
- glog
- hermes-engine
@@ -2415,9 +2411,9 @@ PODS:
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- - RNLiveMarkdown/newarch (= 0.1.176)
+ - RNLiveMarkdown/newarch (= 0.1.179)
- Yoga
- - RNLiveMarkdown/newarch (0.1.176):
+ - RNLiveMarkdown/newarch (0.1.179):
- DoubleConversion
- glog
- hermes-engine
@@ -2824,7 +2820,6 @@ DEPENDENCIES:
- "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)"
- "RNCPicker (from `../node_modules/@react-native-picker/picker`)"
- RNDeviceInfo (from `../node_modules/react-native-device-info`)
- - RNDevMenu (from `../node_modules/react-native-dev-menu`)
- "RNFBAnalytics (from `../node_modules/@react-native-firebase/analytics`)"
- "RNFBApp (from `../node_modules/@react-native-firebase/app`)"
- "RNFBCrashlytics (from `../node_modules/@react-native-firebase/crashlytics`)"
@@ -3085,8 +3080,6 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-picker/picker"
RNDeviceInfo:
:path: "../node_modules/react-native-device-info"
- RNDevMenu:
- :path: "../node_modules/react-native-dev-menu"
RNFBAnalytics:
:path: "../node_modules/@react-native-firebase/analytics"
RNFBApp:
@@ -3263,7 +3256,6 @@ SPEC CHECKSUMS:
RNCClipboard: c84275d07e3f73ff296b17e6c27e9ccdc194a0bb
RNCPicker: 21ae0659666767a5c1253aef985ee5b7c527e345
RNDeviceInfo: 130237d8e97a89b68f2202d5dd18ac6bb68e7648
- RNDevMenu: 72807568fe4188bd4c40ce32675d82434b43c45d
RNFBAnalytics: f76bfa164ac235b00505deb9fc1776634056898c
RNFBApp: 729c0666395b1953198dc4a1ec6deb8fbe1c302e
RNFBCrashlytics: 2061ca863e8e2fa1aae9b12477d7dfa8e88ca0f9
@@ -3272,7 +3264,7 @@ SPEC CHECKSUMS:
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
RNGestureHandler: 8781e2529230a1bc3ea8d75e5c3cd071b6c6aed7
RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0
- RNLiveMarkdown: 0b8756147a5e8eeea98d3e1187c0c27d5a96d1ff
+ RNLiveMarkdown: 7acba70803223c6fa369c32cd2673c415ae3b5c4
RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81
rnmapbox-maps: 460d6ff97ae49c7d5708c3212c6521697c36a0c4
RNPermissions: 0b1429b55af59d1d08b75a8be2459f65a8ac3f28
diff --git a/package-lock.json b/package-lock.json
index 7202ef76ab66..c3af1507f286 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,17 +1,17 @@
{
"name": "new.expensify",
- "version": "9.0.54-1",
+ "version": "9.0.56-7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.0.54-1",
+ "version": "9.0.56-7",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@dotlottie/react-player": "^1.6.3",
- "@expensify/react-native-live-markdown": "0.1.176",
+ "@expensify/react-native-live-markdown": "0.1.179",
"@expo/metro-runtime": "~3.2.3",
"@firebase/app": "^0.10.10",
"@firebase/performance": "^0.6.8",
@@ -51,7 +51,7 @@
"date-fns-tz": "^3.2.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "2.0.100",
+ "expensify-common": "2.0.101",
"expo": "51.0.31",
"expo-av": "14.0.7",
"expo-image": "1.12.15",
@@ -77,11 +77,10 @@
"react-map-gl": "^7.1.3",
"react-native": "0.75.2",
"react-native-android-location-enabler": "^2.0.1",
- "react-native-app-logs": "git+https://github.com/margelo/react-native-app-logs#7e9c311bffdc6a9eeb69d90d30ead47e01c3552a",
+ "react-native-app-logs": "0.3.1",
"react-native-blob-util": "0.19.4",
"react-native-collapsible": "^1.6.2",
"react-native-config": "1.5.3",
- "react-native-dev-menu": "^4.1.1",
"react-native-device-info": "10.3.1",
"react-native-document-picker": "^9.3.1",
"react-native-draggable-flatlist": "^4.0.1",
@@ -157,6 +156,7 @@
"@perf-profiler/profiler": "^0.10.10",
"@perf-profiler/reporter": "^0.9.0",
"@perf-profiler/types": "^0.8.0",
+ "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@react-native-community/eslint-config": "3.2.0",
"@react-native/babel-preset": "0.75.2",
"@react-native/metro-config": "0.75.2",
@@ -219,7 +219,7 @@
"electron-builder": "25.0.0",
"eslint": "^8.57.0",
"eslint-config-airbnb-typescript": "^18.0.0",
- "eslint-config-expensify": "^2.0.66",
+ "eslint-config-expensify": "^2.0.73",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-deprecation": "^3.0.0",
"eslint-plugin-jest": "^28.6.0",
@@ -252,6 +252,7 @@
"react-compiler-runtime": "^19.0.0-beta-8a03594-20241020",
"react-is": "^18.3.1",
"react-native-clean-project": "^4.0.0-alpha4.0",
+ "react-refresh": "^0.14.2",
"react-test-renderer": "18.3.1",
"reassure": "^1.0.0-rc.4",
"semver": "7.5.2",
@@ -3630,9 +3631,9 @@
}
},
"node_modules/@expensify/react-native-live-markdown": {
- "version": "0.1.176",
- "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.176.tgz",
- "integrity": "sha512-0IS0Rfl0qYqrE2V8jsVX58c4K/zxeNC7o1CAL9Xu+HTbTtD58Yu5gOOwp5AljkS2qdPR86swGRZyLXGkGRKkPg==",
+ "version": "0.1.179",
+ "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.179.tgz",
+ "integrity": "sha512-TzXEMPZQRBFOFquu0a9sybaDn513JnqxrfkUgqcFJZuJtvOTs6f29Aj2BG/HfDQMSnO/V3elZP1RaodBPlBMmA==",
"license": "MIT",
"workspaces": [
"parser",
@@ -7431,6 +7432,85 @@
"node": ">=14"
}
},
+ "node_modules/@pmmmwh/react-refresh-webpack-plugin": {
+ "version": "0.5.15",
+ "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.15.tgz",
+ "integrity": "sha512-LFWllMA55pzB9D34w/wXUCf8+c+IYKuJDgxiZ3qMhl64KRMBHYM1I3VdGaD2BV5FNPV2/S2596bppxHbv2ZydQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-html": "^0.0.9",
+ "core-js-pure": "^3.23.3",
+ "error-stack-parser": "^2.0.6",
+ "html-entities": "^2.1.0",
+ "loader-utils": "^2.0.4",
+ "schema-utils": "^4.2.0",
+ "source-map": "^0.7.3"
+ },
+ "engines": {
+ "node": ">= 10.13"
+ },
+ "peerDependencies": {
+ "@types/webpack": "4.x || 5.x",
+ "react-refresh": ">=0.10.0 <1.0.0",
+ "sockjs-client": "^1.4.0",
+ "type-fest": ">=0.17.0 <5.0.0",
+ "webpack": ">=4.43.0 <6.0.0",
+ "webpack-dev-server": "3.x || 4.x || 5.x",
+ "webpack-hot-middleware": "2.x",
+ "webpack-plugin-serve": "0.x || 1.x"
+ },
+ "peerDependenciesMeta": {
+ "@types/webpack": {
+ "optional": true
+ },
+ "sockjs-client": {
+ "optional": true
+ },
+ "type-fest": {
+ "optional": true
+ },
+ "webpack-dev-server": {
+ "optional": true
+ },
+ "webpack-hot-middleware": {
+ "optional": true
+ },
+ "webpack-plugin-serve": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/ajv-keywords": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
+ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
+ "dev": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3"
+ },
+ "peerDependencies": {
+ "ajv": "^8.8.2"
+ }
+ },
+ "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/schema-utils": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz",
+ "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.9",
+ "ajv": "^8.9.0",
+ "ajv-formats": "^2.1.1",
+ "ajv-keywords": "^5.1.0"
+ },
+ "engines": {
+ "node": ">= 12.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
"node_modules/@polka/url": {
"version": "1.0.0-next.25",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz",
@@ -17361,6 +17441,18 @@
"node": ">=6"
}
},
+ "node_modules/ansi-html": {
+ "version": "0.0.9",
+ "resolved": "https://registry.npmjs.org/ansi-html/-/ansi-html-0.0.9.tgz",
+ "integrity": "sha512-ozbS3LuenHVxNRh/wdnN16QapUHzauqSomAl1jwwJRRsGwFwtj644lIhxfWu0Fy0acCij2+AEgHvjscq3dlVXg==",
+ "dev": true,
+ "engines": [
+ "node >= 0.8.0"
+ ],
+ "bin": {
+ "ansi-html": "bin/ansi-html"
+ }
+ },
"node_modules/ansi-html-community": {
"version": "0.0.8",
"dev": true,
@@ -20713,6 +20805,17 @@
"url": "https://opencollective.com/core-js"
}
},
+ "node_modules/core-js-pure": {
+ "version": "3.38.1",
+ "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.38.1.tgz",
+ "integrity": "sha512-BY8Etc1FZqdw1glX0XNOq2FDwfrg/VGqoZOZCdaL+UmdaqDwQwYXkMJT4t6In+zfEfOJDcM9T0KdbBeJg8KKCQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
"node_modules/core-util-is": {
"version": "1.0.2",
"license": "MIT"
@@ -22800,10 +22903,11 @@
}
},
"node_modules/eslint-config-expensify": {
- "version": "2.0.66",
- "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.66.tgz",
- "integrity": "sha512-6L9EIAiOxZnqOcFEsIwEUmX0fvglvboyqQh7LTqy+1O2h2W3mmrMSx87ymXeyFMg1nJQtqkFnrLv5ENGS0QC3Q==",
+ "version": "2.0.73",
+ "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.73.tgz",
+ "integrity": "sha512-LHHyujwjTBizm9mIQMv6g/MsAbYdeOLZrOBdFqY/LyGPUJxOr9jt22xlmTFSdKhieLrbDwkcgkXjM38Z46Nb9A==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"@babel/eslint-parser": "^7.25.7",
"@lwc/eslint-plugin-lwc": "^1.7.2",
@@ -24050,9 +24154,10 @@
}
},
"node_modules/expensify-common": {
- "version": "2.0.100",
- "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.100.tgz",
- "integrity": "sha512-mektI+OuTywYU47Valjsn2+kLQ1/Wc9sWCY1/a0Vo8IHTXroQWvbKs5IXlkiqODi4SRonVZwOL3ha/oJD7o7nQ==",
+ "version": "2.0.101",
+ "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.101.tgz",
+ "integrity": "sha512-5TStDQGsXGJjdk64PBhEdXz/3H6QDlgoanEWI076okL5un4Qd2sSRfxHRiH61foHGsswXJFIZBHK3sysKDOJ4A==",
+ "license": "MIT",
"dependencies": {
"awesome-phonenumber": "^5.4.0",
"classnames": "2.5.0",
@@ -34477,13 +34582,6 @@
}
}
},
- "node_modules/react-native-dev-menu": {
- "version": "4.1.1",
- "license": "MIT",
- "peerDependencies": {
- "react-native": ">=0.61.0"
- }
- },
"node_modules/react-native-device-info": {
"version": "10.3.1",
"license": "MIT",
@@ -36745,7 +36843,8 @@
},
"node_modules/react-refresh": {
"version": "0.14.2",
- "license": "MIT",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
+ "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
"engines": {
"node": ">=0.10.0"
}
diff --git a/package.json b/package.json
index f0425a747967..295066f32b9b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.0.54-1",
+ "version": "9.0.56-7",
"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.",
@@ -33,6 +33,7 @@
"ios-build": "bundle exec fastlane ios build_unsigned",
"android-build": "bundle exec fastlane android build_local",
"test": "TZ=utc NODE_OPTIONS=--experimental-vm-modules jest",
+ "perf-test": "NODE_OPTIONS=--experimental-vm-modules npx reassure",
"typecheck": "NODE_OPTIONS=--max_old_space_size=8192 tsc",
"lint": "NODE_OPTIONS=--max_old_space_size=8192 eslint . --max-warnings=0 --cache --cache-location=node_modules/.cache/eslint",
"lint-changed": "NODE_OPTIONS=--max_old_space_size=8192 eslint --max-warnings=0 --config ./.eslintrc.changed.js $(git diff --diff-filter=AM --name-only origin/main HEAD -- \"*.ts\" \"*.tsx\")",
@@ -67,7 +68,7 @@
},
"dependencies": {
"@dotlottie/react-player": "^1.6.3",
- "@expensify/react-native-live-markdown": "0.1.176",
+ "@expensify/react-native-live-markdown": "0.1.179",
"@expo/metro-runtime": "~3.2.3",
"@firebase/app": "^0.10.10",
"@firebase/performance": "^0.6.8",
@@ -107,7 +108,7 @@
"date-fns-tz": "^3.2.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "2.0.100",
+ "expensify-common": "2.0.101",
"expo": "51.0.31",
"expo-av": "14.0.7",
"expo-image": "1.12.15",
@@ -133,11 +134,10 @@
"react-map-gl": "^7.1.3",
"react-native": "0.75.2",
"react-native-android-location-enabler": "^2.0.1",
- "react-native-app-logs": "git+https://github.com/margelo/react-native-app-logs#7e9c311bffdc6a9eeb69d90d30ead47e01c3552a",
+ "react-native-app-logs": "0.3.1",
"react-native-blob-util": "0.19.4",
"react-native-collapsible": "^1.6.2",
"react-native-config": "1.5.3",
- "react-native-dev-menu": "^4.1.1",
"react-native-device-info": "10.3.1",
"react-native-document-picker": "^9.3.1",
"react-native-draggable-flatlist": "^4.0.1",
@@ -213,6 +213,7 @@
"@perf-profiler/profiler": "^0.10.10",
"@perf-profiler/reporter": "^0.9.0",
"@perf-profiler/types": "^0.8.0",
+ "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@react-native-community/eslint-config": "3.2.0",
"@react-native/babel-preset": "0.75.2",
"@react-native/metro-config": "0.75.2",
@@ -275,7 +276,7 @@
"electron-builder": "25.0.0",
"eslint": "^8.57.0",
"eslint-config-airbnb-typescript": "^18.0.0",
- "eslint-config-expensify": "^2.0.66",
+ "eslint-config-expensify": "^2.0.73",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-deprecation": "^3.0.0",
"eslint-plugin-jest": "^28.6.0",
@@ -308,6 +309,7 @@
"react-compiler-runtime": "^19.0.0-beta-8a03594-20241020",
"react-is": "^18.3.1",
"react-native-clean-project": "^4.0.0-alpha4.0",
+ "react-refresh": "^0.14.2",
"react-test-renderer": "18.3.1",
"reassure": "^1.0.0-rc.4",
"semver": "7.5.2",
@@ -367,13 +369,11 @@
},
"electronmon": {
"patterns": [
- "!node_modules",
- "!node_modules/**/*",
- "!**/*.map",
+ "!src/**",
"!ios/**",
"!android/**",
- "*.test.*",
- "*.spec.*"
+ "!tests/**",
+ "*.test.*"
]
},
"engines": {
diff --git a/patches/react-native+0.75.2+018+Add-regex-to-TextInput.patch b/patches/react-native+0.75.2+018+Add-regex-to-TextInput.patch
new file mode 100644
index 000000000000..6c511d8cbec1
--- /dev/null
+++ b/patches/react-native+0.75.2+018+Add-regex-to-TextInput.patch
@@ -0,0 +1,299 @@
+diff --git a/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js b/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js
+index 770dfee..73e439b 100644
+--- a/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js
++++ b/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js
+@@ -329,6 +329,12 @@ export type NativeProps = $ReadOnly<{|
+ */
+ returnKeyType?: WithDefault,
+
++ /**
++ * Restricts the text value to match the specified regular expression. Use this
++ * instead of implementing the logic in JS to avoid flicker.
++ */
++ regex?: ?string,
++
+ /**
+ * Limits the maximum number of characters that can be entered. Use this
+ * instead of implementing the logic in JS to avoid flicker.
+@@ -699,6 +705,7 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = {
+ process: require('../../StyleSheet/processColor').default,
+ },
+ maxLength: true,
++ regex: true,
+ selectTextOnFocus: true,
+ textShadowRadius: true,
+ underlineColorAndroid: {
+diff --git a/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js b/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js
+index dbfe5d5..1f359ba 100644
+--- a/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js
++++ b/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js
+@@ -151,6 +151,7 @@ const RCTTextInputViewConfig = {
+ autoFocus: true,
+ lineBreakStrategyIOS: true,
+ smartInsertDelete: true,
++ regex: true,
+ ...ConditionallyIgnoredEventHandlers({
+ onClear: true,
+ onChange: true,
+diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts b/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts
+index 20501f7..76f30b9 100644
+--- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts
++++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts
+@@ -701,6 +701,12 @@ export interface TextInputProps
+ */
+ inputMode?: InputModeOptions | undefined;
+
++ /**
++ * Restricts the text value to match the specified regular expression. Use this
++ * instead of implementing the logic in JS to avoid flicker.
++ */
++ regex?: string | undefined;
++
+ /**
+ * Limits the maximum number of characters that can be entered.
+ * Use this instead of implementing the logic in JS to avoid flicker.
+diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js b/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js
+index 2f35731..5bb94bc 100644
+--- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js
++++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js
+@@ -697,6 +697,12 @@ export type Props = $ReadOnly<{|
+ */
+ maxFontSizeMultiplier?: ?number,
+
++ /**
++ * Restricts the text value to match the specified regular expression. Use this
++ * instead of implementing the logic in JS to avoid flicker.
++ */
++ regex?: ?string,
++
+ /**
+ * Limits the maximum number of characters that can be entered. Use this
+ * instead of implementing the logic in JS to avoid flicker.
+diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.js b/node_modules/react-native/Libraries/Components/TextInput/TextInput.js
+index 8cfde15..4f3345c 100644
+--- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.js
++++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.js
+@@ -731,6 +731,12 @@ export type Props = $ReadOnly<{|
+ */
+ maxFontSizeMultiplier?: ?number,
+
++ /**
++ * Restricts the text value to match the specified regular expression. Use this
++ * instead of implementing the logic in JS to avoid flicker.
++ */
++ regex?: ?string,
++
+ /**
+ * Limits the maximum number of characters that can be entered. Use this
+ * instead of implementing the logic in JS to avoid flicker.
+diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm
+index e367394..95f21f2 100644
+--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm
++++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm
+@@ -59,6 +59,7 @@ @implementation RCTBaseTextInputViewManager {
+ RCT_EXPORT_VIEW_PROPERTY(inputAccessoryViewID, NSString)
+ RCT_EXPORT_VIEW_PROPERTY(textContentType, NSString)
+ RCT_EXPORT_VIEW_PROPERTY(passwordRules, NSString)
++RCT_EXPORT_VIEW_PROPERTY(regex, NSString)
+
+ RCT_EXPORT_VIEW_PROPERTY(onChange, RCTBubblingEventBlock)
+ RCT_EXPORT_VIEW_PROPERTY(onKeyPressSync, RCTDirectEventBlock)
+diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
+index db7cba4..f85f95a 100644
+--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
++++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
+@@ -34,6 +34,7 @@ @implementation RCTTextInputComponentView {
+ UIView *_backedTextInputView;
+ NSUInteger _mostRecentEventCount;
+ NSAttributedString *_lastStringStateWasUpdatedWith;
++ NSRegularExpression *_regex;
+
+ /*
+ * UIKit uses either UITextField or UITextView as its UIKit element for . UITextField is for single line
+@@ -224,6 +225,13 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
+ if (newTextInputProps.inputAccessoryViewID != oldTextInputProps.inputAccessoryViewID) {
+ _backedTextInputView.inputAccessoryViewID = RCTNSStringFromString(newTextInputProps.inputAccessoryViewID);
+ }
++
++ if (newTextInputProps.regex != oldTextInputProps.regex) {
++ _regex = [NSRegularExpression regularExpressionWithPattern:RCTNSStringFromString(newTextInputProps.regex)
++ options:0
++ error:nil];
++ }
++
+ [super updateProps:props oldProps:oldProps];
+
+ [self setDefaultInputAccessoryView];
+@@ -359,6 +367,14 @@ - (NSString *)textInputShouldChangeText:(NSString *)text inRange:(NSRange)range
+ }
+ }
+
++ if (_regex) {
++ NSMutableString *newString = [_backedTextInputView.attributedText.string mutableCopy];
++ [newString replaceCharactersInRange:range withString:text];
++ if ([_regex numberOfMatchesInString:newString options:0 range:NSMakeRange(0, newString.length)] == 0) {
++ return nil;
++ }
++ }
++
+ if (props.maxLength) {
+ NSInteger allowedLength = props.maxLength - _backedTextInputView.attributedText.string.length + range.length;
+
+diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java
+index 2cceb14..8fdc0c1 100644
+--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java
++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java
+@@ -824,6 +824,47 @@ public class ReactTextInputManager extends BaseViewManager 0) {
++ LinkedList list = new LinkedList<>();
++ for (InputFilter currentFilter : currentFilters) {
++ if (!(currentFilter instanceof RegexFilter)) {
++ list.add(currentFilter);
++ }
++ }
++ if (!list.isEmpty()) {
++ newFilters = (InputFilter[]) list.toArray(new InputFilter[list.size()]);
++ }
++ }
++ } else {
++ if (currentFilters.length > 0) {
++ newFilters = currentFilters;
++ boolean replaced = false;
++ for (int i = 0; i < currentFilters.length; i++) {
++ if (currentFilters[i] instanceof RegexFilter) {
++ currentFilters[i] = new RegexFilter(regex);
++ replaced = true;
++ }
++ }
++ if (!replaced) {
++ newFilters = new InputFilter[currentFilters.length + 1];
++ System.arraycopy(currentFilters, 0, newFilters, 0, currentFilters.length);
++ newFilters[currentFilters.length] = new RegexFilter(regex);
++ }
++ } else {
++ newFilters = new InputFilter[1];
++ newFilters[0] = new RegexFilter(regex);
++ }
++ }
++
++ view.setFilters(newFilters);
++ }
++
+ @ReactProp(name = "maxLength")
+ public void setMaxLength(ReactEditText view, @Nullable Integer maxLength) {
+ InputFilter[] currentFilters = view.getFilters();
+@@ -854,7 +895,7 @@ public class ReactTextInputManager extends BaseViewManager *Bank accounts* > *+ Add bank account*.\n' +
+ '3. Connect your bank account.\n' +
+ '\n' +
+ 'Once that’s done, you can request money from anyone and get paid back right into your personal bank account.',
+ },
+ ],
+};
+
+const onboardingPersonalSpendMessage: OnboardingMessageType = {
+ message: 'Here’s how to track your spend in a few clicks.',
+ video: {
+ url: `${CLOUDFRONT_URL}/videos/guided-setup-track-personal-v2.mp4`,
+ thumbnailUrl: `${CLOUDFRONT_URL}/images/guided-setup-track-personal.jpg`,
+ duration: 55,
+ width: 1280,
+ height: 960,
+ },
+ tasks: [
+ {
+ type: 'trackExpense',
+ autoCompleted: false,
+ title: 'Track an expense',
+ description:
+ '*Track an expense* in any currency, whether you have a receipt or not.\n' +
+ '\n' +
+ 'Here’s how to track an expense:\n' +
+ '\n' +
+ '1. Click the green *+* button.\n' +
+ '2. Choose *Track expense*.\n' +
+ '3. Enter an amount or scan a receipt.\n' +
+ '4. Click *Track*.\n' +
+ '\n' +
+ 'And you’re done! Yep, it’s that easy.',
+ },
+ ],
+};
+const combinedTrackSubmitOnboardingPersonalSpendMessage: OnboardingMessageType = {
+ ...onboardingPersonalSpendMessage,
+ tasks: [
+ {
+ type: 'trackExpense',
+ autoCompleted: false,
+ title: 'Track an expense',
+ description:
+ '*Track an expense* in any currency, whether you have a receipt or not.\n' +
+ '\n' +
+ 'Here’s how to track an expense:\n' +
+ '\n' +
+ '1. Click the green *+* button.\n' +
+ '2. Choose *Create expense*.\n' +
+ '3. Enter an amount or scan a receipt.\n' +
+ '4. Click "Just track it (don\'t submit it)".\n' +
+ '5. Click *Track*.\n' +
+ '\n' +
+ 'And you’re done! Yep, it’s that easy.',
+ },
+ ],
+};
+
type OnboardingPurposeType = ValueOf;
type OnboardingCompanySizeType = ValueOf;
@@ -152,8 +247,25 @@ type OnboardingInviteType = ValueOf;
type OnboardingTaskType = {
type: string;
autoCompleted: boolean;
- title: string;
- description: string | ((params: Partial<{adminsRoomLink: string; workspaceCategoriesLink: string; workspaceMoreFeaturesLink: string; workspaceMembersLink: string}>) => string);
+ title:
+ | string
+ | ((
+ params: Partial<{
+ integrationName: string;
+ }>,
+ ) => string);
+ description:
+ | string
+ | ((
+ params: Partial<{
+ adminsRoomLink: string;
+ workspaceCategoriesLink: string;
+ workspaceMoreFeaturesLink: string;
+ workspaceMembersLink: string;
+ integrationName: string;
+ workspaceAccountingLink: string;
+ }>,
+ ) => string);
};
type OnboardingMessageType = {
@@ -475,6 +587,9 @@ const CONST = {
},
},
NON_USD_BANK_ACCOUNT: {
+ ALLOWED_FILE_TYPES: ['pdf', 'jpg', 'jpeg', 'png'],
+ FILE_LIMIT: 10,
+ TOTAL_FILES_SIZE_LIMIT: 5242880,
STEP: {
COUNTRY: 'CountryStep',
BANK_INFO: 'BankInfoStep',
@@ -506,10 +621,9 @@ const CONST = {
DIRECT_FEEDS: 'directFeeds',
NETSUITE_USA_TAX: 'netsuiteUsaTax',
NEW_DOT_COPILOT: 'newDotCopilot',
- WORKSPACE_RULES: 'workspaceRules',
COMBINED_TRACK_SUBMIT: 'combinedTrackSubmit',
CATEGORY_AND_TAG_APPROVERS: 'categoryAndTagApprovers',
- NEW_DOT_QBD: 'quickbooksDesktopOnNewDot',
+ PER_DIEM: 'newDotPerDiem',
},
BUTTON_STATES: {
DEFAULT: 'default',
@@ -542,6 +656,7 @@ const CONST = {
ANDROID: 'android',
WEB: 'web',
DESKTOP: 'desktop',
+ MOBILEWEB: 'mobileweb',
},
PLATFORM_SPECIFIC_KEYS: {
CTRL: {
@@ -1099,6 +1214,7 @@ const CONST = {
MODAL_TYPE: {
CONFIRM: 'confirm',
CENTERED: 'centered',
+ CENTERED_SWIPABLE_TO_RIGHT: 'centered_swipable_to_right',
CENTERED_UNSWIPEABLE: 'centered_unswipeable',
CENTERED_SMALL: 'centered_small',
BOTTOM_DOCKED: 'bottom_docked',
@@ -1146,9 +1262,6 @@ const CONST = {
SEARCH_OPTION_LIST_DEBOUNCE_TIME: 300,
RESIZE_DEBOUNCE_TIME: 100,
UNREAD_UPDATE_DEBOUNCE_TIME: 300,
- SEARCH_CONVERT_SEARCH_VALUES: 'search_convert_search_values',
- SEARCH_MAKE_TREE: 'search_make_tree',
- SEARCH_BUILD_TREE: 'search_build_tree',
SEARCH_FILTER_OPTIONS: 'search_filter_options',
USE_DEBOUNCED_STATE_DELAY: 300,
},
@@ -1182,7 +1295,13 @@ const CONST = {
PENDING: 'Pending',
POSTED: 'Posted',
},
+ STATE: {
+ CURRENT: 'current',
+ DRAFT: 'draft',
+ BACKUP: 'backup',
+ },
},
+
MCC_GROUPS: {
AIRLINES: 'Airlines',
COMMUTER: 'Commuter',
@@ -2580,6 +2699,11 @@ const CONST = {
INDIVIDUAL: 'individual',
NONE: 'none',
},
+ VERIFICATION_STATE: {
+ LOADING: 'loading',
+ VERIFIED: 'verified',
+ ON_WAITLIST: 'onWaitlist',
+ },
STATE: {
STATE_NOT_ISSUED: 2,
OPEN: 3,
@@ -2594,6 +2718,7 @@ const CONST = {
MONTHLY: 'monthly',
FIXED: 'fixed',
},
+ LIMIT_VALUE: 21474836,
STEP_NAMES: ['1', '2', '3', '4', '5', '6'],
STEP: {
ASSIGNEE: 'Assignee',
@@ -2841,6 +2966,7 @@ const CONST = {
SHORT_MENTION: new RegExp(`@[\\w\\-\\+\\'#@]+(?:\\.[\\w\\-\\'\\+]+)*(?![^\`]*\`)`, 'gim'),
REPORT_ID_FROM_PATH: /\/r\/(\d+)/,
DISTANCE_MERCHANT: /^[0-9.]+ \w+ @ (-|-\()?[^0-9.\s]{1,3} ?[0-9.]+\)? \/ \w+$/,
+ WHITESPACE: /\s+/g,
get EXPENSIFY_POLICY_DOMAIN_NAME() {
return new RegExp(`${EXPENSIFY_POLICY_DOMAIN}([a-zA-Z0-9]+)\\${EXPENSIFY_POLICY_DOMAIN_EXTENSION}`);
@@ -2929,6 +3055,7 @@ const CONST = {
// Character Limits
FORM_CHARACTER_LIMIT: 50,
+ STANDARD_LENGTH_LIMIT: 100,
LEGAL_NAMES_CHARACTER_LIMIT: 150,
LOGIN_CHARACTER_LIMIT: 254,
CATEGORY_NAME_LIMIT: 256,
@@ -3325,6 +3452,63 @@ const CONST = {
ZW: 'Zimbabwe',
},
+ ALL_EUROPEAN_COUNTRIES: {
+ AL: 'Albania',
+ AD: 'Andorra',
+ AT: 'Austria',
+ BY: 'Belarus',
+ BE: 'Belgium',
+ BA: 'Bosnia & Herzegovina',
+ BG: 'Bulgaria',
+ HR: 'Croatia',
+ CY: 'Cyprus',
+ CZ: 'Czech Republic',
+ DK: 'Denmark',
+ EE: 'Estonia',
+ FO: 'Faroe Islands',
+ FI: 'Finland',
+ FR: 'France',
+ GE: 'Georgia',
+ DE: 'Germany',
+ GI: 'Gibraltar',
+ GR: 'Greece',
+ GL: 'Greenland',
+ HU: 'Hungary',
+ IS: 'Iceland',
+ IE: 'Ireland',
+ IM: 'Isle of Man',
+ IT: 'Italy',
+ JE: 'Jersey',
+ XK: 'Kosovo',
+ LV: 'Latvia',
+ LI: 'Liechtenstein',
+ LT: 'Lithuania',
+ LU: 'Luxembourg',
+ MT: 'Malta',
+ MD: 'Moldova',
+ MC: 'Monaco',
+ ME: 'Montenegro',
+ NL: 'Netherlands',
+ MK: 'North Macedonia',
+ NO: 'Norway',
+ PL: 'Poland',
+ PT: 'Portugal',
+ RO: 'Romania',
+ RU: 'Russia',
+ SM: 'San Marino',
+ RS: 'Serbia',
+ SK: 'Slovakia',
+ SI: 'Slovenia',
+ ES: 'Spain',
+ SJ: 'Svalbard & Jan Mayen',
+ SE: 'Sweden',
+ CH: 'Switzerland',
+ TR: 'Turkey',
+ UA: 'Ukraine',
+ GB: 'United Kingdom',
+ VA: 'Vatican City',
+ },
+
// Sources: https://github.com/Expensify/App/issues/14958#issuecomment-1442138427
// https://github.com/Expensify/App/issues/14958#issuecomment-1456026810
COUNTRY_ZIP_REGEX_DATA: {
@@ -4186,6 +4370,7 @@ const CONST = {
// The attribute used in the SelectionScraper.js helper to query all the DOM elements
// that should be removed from the copied contents in the getHTMLOfSelection() method
SELECTION_SCRAPER_HIDDEN_ELEMENT: 'selection-scrapper-hidden-element',
+ INNER_BOX_SHADOW_ELEMENT: 'inner-box-shadow-element',
MODERATION: {
MODERATOR_DECISION_PENDING: 'pending',
MODERATOR_DECISION_PENDING_HIDE: 'pendingHide',
@@ -4619,6 +4804,7 @@ const CONST = {
ONBOARDING_INTRODUCTION: 'Let’s get you set up 🔧',
ONBOARDING_CHOICES: {...onboardingChoices},
SELECTABLE_ONBOARDING_CHOICES: {...selectableOnboardingChoices},
+ COMBINED_TRACK_SUBMIT_ONBOARDING_CHOICES: {...combinedTrackSubmitOnboardingChoices},
ONBOARDING_SIGNUP_QUALIFIERS: {...signupQualifiers},
ONBOARDING_INVITE_TYPES: {...onboardingInviteTypes},
ONBOARDING_COMPANY_SIZE: {...onboardingCompanySize},
@@ -4662,7 +4848,13 @@ const CONST = {
'\n' +
"We'll send a request to each person so they can pay you back. Let me know if you have any questions!",
},
-
+ ONBOARDING_ACCOUNTING_MAPPING: {
+ quickbooksOnline: 'QuickBooks Online',
+ xero: 'Xero',
+ netsuite: 'NetSuite',
+ intacct: 'Sage Intacct',
+ quickbooksDesktop: 'QuickBooks Desktop',
+ },
ONBOARDING_MESSAGES: {
[onboardingChoices.EMPLOYER]: onboardingEmployerOrSubmitMessage,
[onboardingChoices.SUBMIT]: onboardingEmployerOrSubmitMessage,
@@ -4774,36 +4966,27 @@ const CONST = {
'\n' +
`[Take me to workspace members](${workspaceMembersLink}). That’s it, happy expensing! :)`,
},
- ],
- },
- [onboardingChoices.PERSONAL_SPEND]: {
- message: 'Here’s how to track your spend in a few clicks.',
- video: {
- url: `${CLOUDFRONT_URL}/videos/guided-setup-track-personal-v2.mp4`,
- thumbnailUrl: `${CLOUDFRONT_URL}/images/guided-setup-track-personal.jpg`,
- duration: 55,
- width: 1280,
- height: 960,
- },
- tasks: [
{
- type: 'trackExpense',
+ type: 'addAccountingIntegration',
autoCompleted: false,
- title: 'Track an expense',
- description:
- '*Track an expense* in any currency, whether you have a receipt or not.\n' +
+ title: ({integrationName}) => `Connect to ${integrationName}`,
+ description: ({integrationName, workspaceAccountingLink}) =>
+ `Connect to ${integrationName} for automatic expense coding and syncing that makes month-end close a breeze.\n` +
'\n' +
- 'Here’s how to track an expense:\n' +
+ `Here’s how to connect to ${integrationName}:\n` +
'\n' +
- '1. Click the green *+* button.\n' +
- '2. Choose *Track expense*.\n' +
- '3. Enter an amount or scan a receipt.\n' +
- '4. Click *Track*.\n' +
+ '1. Click your profile photo.\n' +
+ '2. Go to Workspaces.\n' +
+ '3. Select your workspace.\n' +
+ '4. Click Accounting.\n' +
+ `5. Find ${integrationName}.\n` +
+ '6. Click Connect.\n' +
'\n' +
- 'And you’re done! Yep, it’s that easy.',
+ `[Take me to Accounting!](${workspaceAccountingLink})`,
},
],
},
+ [onboardingChoices.PERSONAL_SPEND]: onboardingPersonalSpendMessage,
[onboardingChoices.CHAT_SPLIT]: {
message: 'Splitting bills with friends is as easy as sending a message. Here’s how.',
video: {
@@ -4872,6 +5055,12 @@ const CONST = {
},
} satisfies Record,
+ COMBINED_TRACK_SUBMIT_ONBOARDING_MESSAGES: {
+ [combinedTrackSubmitOnboardingChoices.PERSONAL_SPEND]: combinedTrackSubmitOnboardingPersonalSpendMessage,
+ [combinedTrackSubmitOnboardingChoices.EMPLOYER]: combinedTrackSubmitOnboardingEmployerOrSubmitMessage,
+ [combinedTrackSubmitOnboardingChoices.SUBMIT]: combinedTrackSubmitOnboardingEmployerOrSubmitMessage,
+ } satisfies Record, OnboardingMessageType>,
+
REPORT_FIELD_TITLE_FIELD_ID: 'text_title',
MOBILE_PAGINATION_SIZE: 15,
@@ -5746,6 +5935,11 @@ const CONST = {
IN: 'in',
},
EMPTY_VALUE: 'none',
+ SEARCH_ROUTER_ITEM_TYPE: {
+ CONTEXTUAL_SUGGESTION: 'contextualSuggestion',
+ AUTOCOMPLETE_SUGGESTION: 'autocompleteSuggestion',
+ SEARCH: 'searchItem',
+ },
},
REFERRER: {
@@ -5960,6 +6154,7 @@ const CONST = {
HAS_WALLET_TERMS_ERRORS: 'hasWalletTermsErrors',
HAS_LOGIN_LIST_INFO: 'hasLoginListInfo',
HAS_SUBSCRIPTION_INFO: 'hasSubscriptionInfo',
+ HAS_PHONE_NUMBER_ERROR: 'hasPhoneNumberError',
},
DEBUG: {
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 427e05052ae3..7d3d0edef36e 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -1,6 +1,7 @@
import type {ValueOf} from 'type-fest';
import type CONST from './CONST';
import type {OnboardingCompanySizeType, OnboardingPurposeType} from './CONST';
+import type Platform from './libs/getPlatform/types';
import type * as FormTypes from './types/form';
import type * as OnyxTypes from './types/onyx';
import type {Attendee} from './types/onyx/IOU';
@@ -122,6 +123,9 @@ const ONYXKEYS = {
/** This NVP contains data associated with HybridApp */
NVP_TRYNEWDOT: 'nvp_tryNewDot',
+ /** Contains the platforms for which the user muted the sounds */
+ NVP_MUTED_PLATFORMS: 'nvp_mutedPlatforms',
+
/** Contains the user preference for the LHN priority mode */
NVP_PRIORITY_MODE: 'nvp_priorityMode',
@@ -527,6 +531,9 @@ const ONYXKEYS = {
/** Currently displaying feed */
LAST_SELECTED_FEED: 'lastSelectedFeed_',
+
+ /** Whether the bank account chosen for Expensify Card in on verification waitlist */
+ NVP_EXPENSIFY_ON_CARD_WAITLIST: 'nvp_expensify_onCardWaitlist_',
},
/** List of Form ids */
@@ -857,6 +864,7 @@ type OnyxCollectionValuesMapping = {
[ONYXKEYS.COLLECTION.EXPENSIFY_CARD_CONTINUOUS_RECONCILIATION_CONNECTION]: OnyxTypes.PolicyConnectionName;
[ONYXKEYS.COLLECTION.EXPENSIFY_CARD_USE_CONTINUOUS_RECONCILIATION]: boolean;
[ONYXKEYS.COLLECTION.LAST_SELECTED_FEED]: OnyxTypes.CompanyCardFeed;
+ [ONYXKEYS.COLLECTION.NVP_EXPENSIFY_ON_CARD_WAITLIST]: OnyxTypes.CardOnWaitlist;
};
type OnyxValuesMapping = {
@@ -903,6 +911,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.USER_METADATA]: OnyxTypes.UserMetadata;
[ONYXKEYS.STASHED_SESSION]: OnyxTypes.Session;
[ONYXKEYS.BETAS]: OnyxTypes.Beta[];
+ [ONYXKEYS.NVP_MUTED_PLATFORMS]: Partial>;
[ONYXKEYS.NVP_PRIORITY_MODE]: ValueOf;
[ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE]: OnyxTypes.BlockedFromConcierge;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index c346da6cadcb..45501bf46374 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -208,6 +208,7 @@ const ROUTES = {
},
SETTINGS_LEGAL_NAME: 'settings/profile/legal-name',
SETTINGS_DATE_OF_BIRTH: 'settings/profile/date-of-birth',
+ SETTINGS_PHONE_NUMBER: 'settings/profile/phone',
SETTINGS_ADDRESS: 'settings/profile/address',
SETTINGS_ADDRESS_COUNTRY: {
route: 'settings/profile/address/country',
@@ -950,6 +951,10 @@ const ROUTES = {
getRoute: (policyID: string, featureName: string, backTo?: string) =>
getUrlWithBackToParam(`settings/workspaces/${policyID}/upgrade/${encodeURIComponent(featureName)}` as const, backTo),
},
+ WORKSPACE_DOWNGRADE: {
+ route: 'settings/workspaces/:policyID/downgrade/',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/downgrade/` as const,
+ },
WORKSPACE_CATEGORIES_SETTINGS: {
route: 'settings/workspaces/:policyID/categories/settings',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/categories/settings` as const,
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 2e44c5ed5695..dea0f028e1a0 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -84,6 +84,7 @@ const SCREENS = {
TIMEZONE_SELECT: 'Settings_Timezone_Select',
LEGAL_NAME: 'Settings_LegalName',
DATE_OF_BIRTH: 'Settings_DateOfBirth',
+ PHONE_NUMBER: 'Settings_PhoneNumber',
ADDRESS: 'Settings_Address',
ADDRESS_COUNTRY: 'Settings_Address_Country',
ADDRESS_STATE: 'Settings_Address_State',
@@ -531,6 +532,7 @@ const SCREENS = {
DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT: 'Distance_Rate_Tax_Reclaimable_On_Edit',
DISTANCE_RATE_TAX_RATE_EDIT: 'Distance_Rate_Tax_Rate_Edit',
UPGRADE: 'Workspace_Upgrade',
+ DOWNGRADE: 'Workspace_Downgrade',
RULES: 'Policy_Rules',
RULES_CUSTOM_NAME: 'Rules_Custom_Name',
RULES_AUTO_APPROVE_REPORTS_UNDER: 'Rules_Auto_Approve_Reports_Under',
diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx
index 8ccab44a2cb9..ad58294c0cc8 100644
--- a/src/components/AccountSwitcher.tsx
+++ b/src/components/AccountSwitcher.tsx
@@ -152,7 +152,7 @@ function AccountSwitcher() {
>
{currentUserPersonalDetails?.displayName}
- {canSwitchAccounts && (
+ {!!canSwitchAccounts && (
- {canSwitchAccounts && (
+ {!!canSwitchAccounts && (
{
diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx
index 4848577bdea0..de3a1fe39829 100644
--- a/src/components/AmountForm.tsx
+++ b/src/components/AmountForm.tsx
@@ -238,6 +238,7 @@ function AmountForm(
forwardDeletePressedRef.current = key === 'delete' || (allowedOS.includes(operatingSystem ?? '') && event.nativeEvent.ctrlKey && key === 'd');
};
+ const regex = useMemo(() => MoneyRequestUtils.amountRegex(decimals, amountMaxLength), [decimals, amountMaxLength]);
const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit);
const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen();
@@ -261,6 +262,7 @@ function AmountForm(
keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD}
inputMode={CONST.INPUT_MODE.DECIMAL}
errorText={errorText}
+ regex={regex}
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
/>
@@ -300,6 +302,7 @@ function AmountForm(
isCurrencyPressable={isCurrencyPressable}
style={[styles.iouAmountTextInput]}
containerStyle={[styles.iouAmountTextInputContainer]}
+ regex={regex}
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
/>
diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx
index 52c32ce1f584..2e0d3e62afa0 100644
--- a/src/components/AmountTextInput.tsx
+++ b/src/components/AmountTextInput.tsx
@@ -39,7 +39,7 @@ type AmountTextInputProps = {
/** Hide the focus styles on TextInput */
hideFocusedState?: boolean;
-} & Pick;
+} & Pick;
function AmountTextInput(
{
diff --git a/src/components/AmountWithoutCurrencyForm.tsx b/src/components/AmountWithoutCurrencyForm.tsx
index 78b7c84ecb54..6a9fc22f68f8 100644
--- a/src/components/AmountWithoutCurrencyForm.tsx
+++ b/src/components/AmountWithoutCurrencyForm.tsx
@@ -1,7 +1,8 @@
-import React, {useCallback, useMemo} from 'react';
+import React, {useCallback, useMemo, useState} from 'react';
import type {ForwardedRef} from 'react';
+import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native';
import useLocalize from '@hooks/useLocalize';
-import {addLeadingZero, replaceAllDigits, replaceCommasWithPeriod, stripSpacesFromAmount, validateAmount} from '@libs/MoneyRequestUtils';
+import {addLeadingZero, amountRegex, replaceAllDigits, replaceCommasWithPeriod, stripSpacesFromAmount, validateAmount} from '@libs/MoneyRequestUtils';
import CONST from '@src/CONST';
import TextInput from './TextInput';
import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types';
@@ -21,6 +22,11 @@ function AmountWithoutCurrencyForm(
const {toLocaleDigit} = useLocalize();
const currentAmount = useMemo(() => (typeof amount === 'string' ? amount : ''), [amount]);
+ const [selection, setSelection] = useState({
+ start: currentAmount.length,
+ end: currentAmount.length,
+ });
+ const decimals = 2;
/**
* Sets the selection and the amount accordingly to the value passed to the input
@@ -33,7 +39,10 @@ function AmountWithoutCurrencyForm(
const newAmountWithoutSpaces = stripSpacesFromAmount(newAmount);
const replacedCommasAmount = replaceCommasWithPeriod(newAmountWithoutSpaces);
const withLeadingZero = addLeadingZero(replacedCommasAmount);
- if (!validateAmount(withLeadingZero, 2)) {
+ if (!validateAmount(withLeadingZero, decimals)) {
+ // Use a shallow copy of selection to trigger setSelection
+ // More info: https://github.com/Expensify/App/issues/16385
+ setSelection((prevSelection) => ({...prevSelection}));
return;
}
onInputChange?.(withLeadingZero);
@@ -41,12 +50,17 @@ function AmountWithoutCurrencyForm(
[onInputChange],
);
+ const regex = useMemo(() => amountRegex(decimals), [decimals]);
const formattedAmount = replaceAllDigits(currentAmount, toLocaleDigit);
return (
) => {
+ setSelection(e.nativeEvent.selection);
+ }}
inputID={inputID}
name={name}
label={label}
@@ -55,6 +69,7 @@ function AmountWithoutCurrencyForm(
role={role}
ref={ref}
keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD}
+ regex={regex}
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
/>
diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx
index d3a51c7fc0f0..bc4467e82f01 100644
--- a/src/components/AttachmentPicker/index.native.tsx
+++ b/src/components/AttachmentPicker/index.native.tsx
@@ -33,46 +33,46 @@ type Item = {
pickAttachment: () => Promise;
};
-/**
- * See https://github.com/react-native-image-picker/react-native-image-picker/#options
- * for ImagePicker configuration options
- */
-const imagePickerOptions: Partial = {
- includeBase64: false,
- saveToPhotos: false,
- selectionLimit: 1,
- includeExtra: false,
- assetRepresentationMode: 'current',
-};
-
/**
* Return imagePickerOptions based on the type
*/
-const getImagePickerOptions = (type: string): CameraOptions => {
+const getImagePickerOptions = (type: string, fileLimit: number): CameraOptions | ImageLibraryOptions => {
// mediaType property is one of the ImagePicker configuration to restrict types'
const mediaType = type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE ? 'photo' : 'mixed';
+
+ /**
+ * See https://github.com/react-native-image-picker/react-native-image-picker/#options
+ * for ImagePicker configuration options
+ */
return {
mediaType,
- ...imagePickerOptions,
+ includeBase64: false,
+ saveToPhotos: false,
+ includeExtra: false,
+ assetRepresentationMode: 'current',
+ selectionLimit: fileLimit,
};
};
/**
* Return documentPickerOptions based on the type
* @param {String} type
+ * @param {Number} fileLimit
* @returns {Object}
*/
-const getDocumentPickerOptions = (type: string): DocumentPickerOptions => {
+const getDocumentPickerOptions = (type: string, fileLimit: number): DocumentPickerOptions => {
if (type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE) {
return {
type: [RNDocumentPicker.types.images],
copyTo: 'cachesDirectory',
+ allowMultiSelection: fileLimit !== 1,
};
}
return {
type: [RNDocumentPicker.types.allFiles],
copyTo: 'cachesDirectory',
+ allowMultiSelection: fileLimit !== 1,
};
};
@@ -111,13 +111,14 @@ function AttachmentPicker({
type = CONST.ATTACHMENT_PICKER_TYPE.FILE,
children,
shouldHideCameraOption = false,
- shouldHideGalleryOption = false,
shouldValidateImage = true,
+ shouldHideGalleryOption = false,
+ fileLimit = 1,
}: AttachmentPickerProps) {
const styles = useThemeStyles();
const [isVisible, setIsVisible] = useState(false);
- const completeAttachmentSelection = useRef<(data: FileObject) => void>(() => {});
+ const completeAttachmentSelection = useRef<(data: FileObject[]) => void>(() => {});
const onModalHide = useRef<() => void>();
const onCanceled = useRef<() => void>(() => {});
const popoverRef = useRef(null);
@@ -143,7 +144,7 @@ function AttachmentPicker({
const showImagePicker = useCallback(
(imagePickerFunc: (options: CameraOptions, callback: Callback) => Promise): Promise =>
new Promise((resolve, reject) => {
- imagePickerFunc(getImagePickerOptions(type), (response: ImagePickerResponse) => {
+ imagePickerFunc(getImagePickerOptions(type, fileLimit), (response: ImagePickerResponse) => {
if (response.didCancel) {
// When the user cancelled resolve with no attachment
return resolve();
@@ -200,7 +201,7 @@ function AttachmentPicker({
}
});
}),
- [showGeneralAlert, type],
+ [fileLimit, showGeneralAlert, type],
);
/**
* Launch the DocumentPicker. Results are in the same format as ImagePicker
@@ -209,7 +210,7 @@ function AttachmentPicker({
*/
const showDocumentPicker = useCallback(
(): Promise =>
- RNDocumentPicker.pick(getDocumentPickerOptions(type)).catch((error: Error) => {
+ RNDocumentPicker.pick(getDocumentPickerOptions(type, fileLimit)).catch((error: Error) => {
if (RNDocumentPicker.isCancel(error)) {
return;
}
@@ -217,7 +218,7 @@ function AttachmentPicker({
showGeneralAlert(error.message);
throw error;
}),
- [showGeneralAlert, type],
+ [fileLimit, showGeneralAlert, type],
);
const menuItemData: Item[] = useMemo(() => {
@@ -261,7 +262,7 @@ function AttachmentPicker({
* @param onPickedHandler A callback that will be called with the selected attachment
* @param onCanceledHandler A callback that will be called without a selected attachment
*/
- const open = (onPickedHandler: (file: FileObject) => void, onCanceledHandler: () => void = () => {}) => {
+ const open = (onPickedHandler: (files: FileObject[]) => void, onCanceledHandler: () => void = () => {}) => {
// eslint-disable-next-line react-compiler/react-compiler
completeAttachmentSelection.current = onPickedHandler;
onCanceled.current = onCanceledHandler;
@@ -286,7 +287,7 @@ function AttachmentPicker({
}
return getDataForUpload(fileData)
.then((result) => {
- completeAttachmentSelection.current(result);
+ completeAttachmentSelection.current([result]);
})
.catch((error: Error) => {
showGeneralAlert(error.message);
@@ -301,63 +302,78 @@ function AttachmentPicker({
* sends the selected attachment to the caller (parent component)
*/
const pickAttachment = useCallback(
- (attachments: Asset[] | DocumentPickerResponse[] | void = []): Promise | undefined => {
+ (attachments: Asset[] | DocumentPickerResponse[] | void = []): Promise | undefined => {
if (!attachments || attachments.length === 0) {
onCanceled.current();
- return Promise.resolve();
+ return Promise.resolve([]);
}
- const fileData = attachments[0];
- if (!fileData) {
- onCanceled.current();
- return Promise.resolve();
- }
- /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
- const fileDataName = ('fileName' in fileData && fileData.fileName) || ('name' in fileData && fileData.name) || '';
- const fileDataUri = ('fileCopyUri' in fileData && fileData.fileCopyUri) || ('uri' in fileData && fileData.uri) || '';
-
- const fileDataObject: FileResponse = {
- name: fileDataName ?? '',
- uri: fileDataUri,
- size: ('size' in fileData && fileData.size) || ('fileSize' in fileData && fileData.fileSize) || null,
- type: fileData.type ?? '',
- width: ('width' in fileData && fileData.width) || undefined,
- height: ('height' in fileData && fileData.height) || undefined,
- };
+ const filesToProcess = attachments.map((fileData) => {
+ if (!fileData) {
+ onCanceled.current();
+ return Promise.resolve();
+ }
- if (!shouldValidateImage && fileDataName && Str.isImage(fileDataName)) {
- ImageSize.getSize(fileDataUri)
- .then(({width, height}) => {
- fileDataObject.width = width;
- fileDataObject.height = height;
- return fileDataObject;
- })
- .then((file) => {
- getDataForUpload(file)
- .then((result) => {
- completeAttachmentSelection.current(result);
- })
- .catch((error: Error) => {
- showGeneralAlert(error.message);
- throw error;
- });
- });
- return;
- }
- /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */
- if (fileDataName && Str.isImage(fileDataName)) {
- ImageSize.getSize(fileDataUri)
- .then(({width, height}) => {
- fileDataObject.width = width;
- fileDataObject.height = height;
- validateAndCompleteAttachmentSelection(fileDataObject);
- })
- .catch(() => showImageCorruptionAlert());
- } else {
+ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
+ const fileDataName = ('fileName' in fileData && fileData.fileName) || ('name' in fileData && fileData.name) || '';
+ const fileDataUri = ('fileCopyUri' in fileData && fileData.fileCopyUri) || ('uri' in fileData && fileData.uri) || '';
+
+ const fileDataObject: FileResponse = {
+ name: fileDataName ?? '',
+ uri: fileDataUri,
+ size: ('size' in fileData && fileData.size) || ('fileSize' in fileData && fileData.fileSize) || null,
+ type: fileData.type ?? '',
+ width: ('width' in fileData && fileData.width) || undefined,
+ height: ('height' in fileData && fileData.height) || undefined,
+ };
+
+ if (!shouldValidateImage && fileDataName && Str.isImage(fileDataName)) {
+ return ImageSize.getSize(fileDataUri)
+ .then(({width, height}) => {
+ fileDataObject.width = width;
+ fileDataObject.height = height;
+ return fileDataObject;
+ })
+ .then((file) => {
+ return getDataForUpload(file)
+ .then((result) => completeAttachmentSelection.current([result]))
+ .catch((error) => {
+ if (error instanceof Error) {
+ showGeneralAlert(error.message);
+ } else {
+ showGeneralAlert('An unknown error occurred');
+ }
+ throw error;
+ });
+ })
+ .catch(() => {
+ showImageCorruptionAlert();
+ });
+ }
+
+ if (fileDataName && Str.isImage(fileDataName)) {
+ return ImageSize.getSize(fileDataUri)
+ .then(({width, height}) => {
+ fileDataObject.width = width;
+ fileDataObject.height = height;
+
+ if (fileDataObject.width <= 0 || fileDataObject.height <= 0) {
+ showImageCorruptionAlert();
+ return Promise.resolve(); // Skip processing this corrupted file
+ }
+
+ return validateAndCompleteAttachmentSelection(fileDataObject);
+ })
+ .catch(() => {
+ showImageCorruptionAlert();
+ });
+ }
return validateAndCompleteAttachmentSelection(fileDataObject);
- }
+ });
+
+ return Promise.all(filesToProcess);
},
- [validateAndCompleteAttachmentSelection, showImageCorruptionAlert, shouldValidateImage, showGeneralAlert],
+ [shouldValidateImage, validateAndCompleteAttachmentSelection, showGeneralAlert, showImageCorruptionAlert],
);
/**
diff --git a/src/components/AttachmentPicker/index.tsx b/src/components/AttachmentPicker/index.tsx
index f3c880fcb835..2484198d3916 100644
--- a/src/components/AttachmentPicker/index.tsx
+++ b/src/components/AttachmentPicker/index.tsx
@@ -1,5 +1,6 @@
import React, {useRef} from 'react';
import type {ValueOf} from 'type-fest';
+import type {FileObject} from '@components/AttachmentModal';
import * as Browser from '@libs/Browser';
import Visibility from '@libs/Visibility';
import CONST from '@src/CONST';
@@ -42,9 +43,9 @@ function getAcceptableFileTypesFromAList(fileTypes: Array(null);
- const onPicked = useRef<(file: File) => void>(() => {});
+ const onPicked = useRef<(files: FileObject[]) => void>(() => {});
const onCanceled = useRef<() => void>(() => {});
return (
@@ -62,7 +63,7 @@ function AttachmentPicker({children, type = CONST.ATTACHMENT_PICKER_TYPE.FILE, a
if (file) {
file.uri = URL.createObjectURL(file);
- onPicked.current(file);
+ onPicked.current([file]);
}
// Cleanup after selecting a file to start from a fresh state
@@ -97,6 +98,7 @@ function AttachmentPicker({children, type = CONST.ATTACHMENT_PICKER_TYPE.FILE, a
);
}}
accept={acceptedFileTypes ? getAcceptableFileTypesFromAList(acceptedFileTypes) : getAcceptableFileTypes(type)}
+ multiple={allowMultiple}
/>
{/* eslint-disable-next-line react-compiler/react-compiler */}
{children({
diff --git a/src/components/AttachmentPicker/types.ts b/src/components/AttachmentPicker/types.ts
index ee9d39aabef3..1e2e65761527 100644
--- a/src/components/AttachmentPicker/types.ts
+++ b/src/components/AttachmentPicker/types.ts
@@ -4,8 +4,8 @@ import type {FileObject} from '@components/AttachmentModal';
import type CONST from '@src/CONST';
type PickerOptions = {
- /** A callback that will be called with the selected attachment. */
- onPicked: (file: FileObject) => void;
+ /** A callback that will be called with the selected attachments. */
+ onPicked: (files: FileObject[]) => void;
/** A callback that will be called without a selected attachment. */
onCanceled?: () => void;
};
@@ -49,6 +49,12 @@ type AttachmentPickerProps = {
/** Whether to validate the image and show the alert or not. */
shouldValidateImage?: boolean;
+
+ /** Allow multiple file selection */
+ allowMultiple?: boolean;
+
+ /** Whether to allow multiple files to be selected. */
+ fileLimit?: number;
};
export default AttachmentPickerProps;
diff --git a/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx b/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx
index 103abb2df1bb..4de43a763231 100644
--- a/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx
+++ b/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx
@@ -86,7 +86,7 @@ function CarouselItem({item, onPress, isFocused, isModalHovered}: CarouselItemPr
/>
- {item.hasBeenFlagged && (
+ {!!item.hasBeenFlagged && (
{({safeAreaPaddingBottomStyle}) => {renderButton([styles.m4, styles.alignSelfCenter])}}
diff --git a/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx b/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx
index 23e13833df64..8f149182d9a6 100644
--- a/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx
+++ b/src/components/Attachments/AttachmentView/DefaultAttachmentView/index.tsx
@@ -47,7 +47,7 @@ function DefaultAttachmentView({fileName = '', shouldShowLoadingSpinnerIcon = fa
{fileName}
- {!shouldShowLoadingSpinnerIcon && shouldShowDownloadIcon && (
+ {!shouldShowLoadingSpinnerIcon && !!shouldShowDownloadIcon && (
- {report && !!title && (
+ {!!report && !!title && (
void;
+ onPicked: (image: FileObject[]) => void;
};
type OpenPicker = (args: OpenPickerParams) => void;
@@ -278,7 +278,7 @@ function AvatarWithImagePicker({
return;
}
openPicker({
- onPicked: showAvatarCropModal,
+ onPicked: (data) => showAvatarCropModal(data.at(0) ?? {}),
});
},
shouldCallAfterModalHide: true,
@@ -324,7 +324,7 @@ function AvatarWithImagePicker({
}
if (isUsingDefaultAvatar) {
openPicker({
- onPicked: showAvatarCropModal,
+ onPicked: (data) => showAvatarCropModal(data.at(0) ?? {}),
});
return;
}
@@ -426,7 +426,7 @@ function AvatarWithImagePicker({
// by the user on Safari.
if (index === 0 && Browser.isSafari()) {
openPicker({
- onPicked: showAvatarCropModal,
+ onPicked: (data) => showAvatarCropModal(data.at(0) ?? {}),
});
}
}}
@@ -443,7 +443,7 @@ function AvatarWithImagePicker({
)}
- {errorData.validationError && (
+ {!!errorData.validationError && (
- {icon && (
+ {!!icon && (
- {shouldShowIcon && icon && (
+ {shouldShowIcon && !!icon && (
(
<>
- {subtitle && (
+ {!!subtitle && (
- {animation && (
+ {!!animation && (
)}
- {icon && (
+ {!!icon && (
- {icon && (
+ {!!icon && (
({
enterKeyEventListenerPriority={enterKeyEventListenerPriority}
/>
)}
- {(shouldAlwaysShowDropdownMenu || options.length > 1) && popoverAnchorPosition && (
+ {(shouldAlwaysShowDropdownMenu || options.length > 1) && !!popoverAnchorPosition && (
{
diff --git a/src/components/CheckboxWithLabel.tsx b/src/components/CheckboxWithLabel.tsx
index db62aa9e1441..0647b495bd33 100644
--- a/src/components/CheckboxWithLabel.tsx
+++ b/src/components/CheckboxWithLabel.tsx
@@ -95,8 +95,8 @@ function CheckboxWithLabel(
style={[styles.flexRow, styles.alignItemsCenter, styles.noSelect, styles.w100]}
wrapperStyle={[styles.ml3, styles.pr2, styles.w100, styles.flexWrap, styles.flexShrink1]}
>
- {label && {label}}
- {LabelComponent && }
+ {!!label && {label}}
+ {!!LabelComponent && }
diff --git a/src/components/CollapsibleSection/index.tsx b/src/components/CollapsibleSection/index.tsx
index d339f005e3d3..3776dfa2cf9b 100644
--- a/src/components/CollapsibleSection/index.tsx
+++ b/src/components/CollapsibleSection/index.tsx
@@ -63,7 +63,7 @@ function CollapsibleSection({title, children, titleStyle, textStyle, wrapperStyl
src={src}
/>
- {shouldShowSectionBorder && }
+ {!!shouldShowSectionBorder && }
{children}
diff --git a/src/components/ConfirmContent.tsx b/src/components/ConfirmContent.tsx
index bda78b9b320d..cb0fc6e8e8cb 100644
--- a/src/components/ConfirmContent.tsx
+++ b/src/components/ConfirmContent.tsx
@@ -168,7 +168,7 @@ function ConfirmContent({
)}
- {iconSource && (
+ {!!iconSource && (
- {title && {titleAlreadyTranslated ?? translate(title)}}
+ {!!title && {titleAlreadyTranslated ?? translate(title)}}
{children}
>
);
diff --git a/src/components/CustomDevMenu/index.native.tsx b/src/components/CustomDevMenu/index.native.tsx
index 968f97b9e91f..55ab64205587 100644
--- a/src/components/CustomDevMenu/index.native.tsx
+++ b/src/components/CustomDevMenu/index.native.tsx
@@ -1,12 +1,12 @@
import {useEffect} from 'react';
-import DevMenu from 'react-native-dev-menu';
+import {DevSettings} from 'react-native';
import toggleTestToolsModal from '@userActions/TestTool';
import type CustomDevMenuElement from './types';
const CustomDevMenu: CustomDevMenuElement = Object.assign(
() => {
useEffect(() => {
- DevMenu.addItem('Open Test Preferences', toggleTestToolsModal);
+ DevSettings.addMenuItem('Open Test Preferences', toggleTestToolsModal);
}, []);
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>>;
diff --git a/src/components/DecisionModal.tsx b/src/components/DecisionModal.tsx
index a9bd0b204d79..927ba1ecab11 100644
--- a/src/components/DecisionModal.tsx
+++ b/src/components/DecisionModal.tsx
@@ -55,7 +55,7 @@ function DecisionModal({title, prompt = '', firstOptionText, secondOptionText, o
{prompt}
- {firstOptionText && (
+ {!!firstOptionText && (
- {isReceiptThumbnail && fileExtension && (
+ {isReceiptThumbnail && !!fileExtension && (
)}
- {ctaErrorMessage && (
+ {!!ctaErrorMessage && (
;
diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx
index b71f4db246a8..0d307aa8728d 100755
--- a/src/components/HeaderWithBackButton/index.tsx
+++ b/src/components/HeaderWithBackButton/index.tsx
@@ -1,5 +1,5 @@
import React, {useMemo} from 'react';
-import {Keyboard, StyleSheet, View} from 'react-native';
+import {ActivityIndicator, Keyboard, StyleSheet, View} from 'react-native';
import Avatar from '@components/Avatar';
import AvatarWithDisplayName from '@components/AvatarWithDisplayName';
import Header from '@components/Header';
@@ -39,6 +39,7 @@ function HeaderWithBackButton({
shouldShowBorderBottom = false,
shouldShowCloseButton = false,
shouldShowDownloadButton = false,
+ isDownloading = false,
shouldShowGetAssistanceButton = false,
shouldDisableGetAssistanceButton = false,
shouldShowPinButton = false,
@@ -173,7 +174,7 @@ function HeaderWithBackButton({
)}
- {icon && (
+ {!!icon && (
)}
- {policyAvatar && (
+ {!!policyAvatar && (
{children}
- {shouldShowDownloadButton && (
-
- {
- // Blur the pressable in case this button triggers a Growl notification
- // We do not want to overlap Growl with the Tooltip (#15271)
- (event?.currentTarget as HTMLElement)?.blur();
+ {shouldShowDownloadButton &&
+ (!isDownloading ? (
+
+ {
+ // Blur the pressable in case this button triggers a Growl notification
+ // We do not want to overlap Growl with the Tooltip (#15271)
+ (event?.currentTarget as HTMLElement)?.blur();
- if (!isDownloadButtonActive) {
- return;
- }
+ if (!isDownloadButtonActive) {
+ return;
+ }
- onDownloadButtonPress();
- temporarilyDisableDownloadButton();
- }}
+ onDownloadButtonPress();
+ temporarilyDisableDownloadButton();
+ }}
+ style={[styles.touchableButtonImage]}
+ role="button"
+ accessibilityLabel={translate('common.download')}
+ >
+
+
+
+ ) : (
+
-
-
-
- )}
+ size="small"
+ color={theme.spinner}
+ />
+ ))}
{shouldShowGetAssistanceButton && (
& {
/** Whether we should show a download button */
shouldShowDownloadButton?: boolean;
+ /** Whether we should show a loading indicator replacing the download button */
+ isDownloading?: boolean;
+
/** Whether we should show a get assistance (question mark) button */
shouldShowGetAssistanceButton?: boolean;
diff --git a/src/components/Hoverable/ActiveHoverable.tsx b/src/components/Hoverable/ActiveHoverable.tsx
index 9bc0e846aaf1..eda42a703d65 100644
--- a/src/components/Hoverable/ActiveHoverable.tsx
+++ b/src/components/Hoverable/ActiveHoverable.tsx
@@ -81,7 +81,7 @@ function ActiveHoverable({onHoverIn, onHoverOut, shouldHandleScroll, shouldFreez
setIsHovered(false);
};
- document.addEventListener('mouseover', unsetHoveredIfOutside);
+ document.addEventListener('mouseover', unsetHoveredIfOutside, true);
return () => document.removeEventListener('mouseover', unsetHoveredIfOutside);
}, [isHovered, elementRef, shouldFreezeCapture]);
@@ -126,13 +126,13 @@ function ActiveHoverable({onHoverIn, onHoverOut, shouldHandleScroll, shouldFreez
(event: MouseEvent) => {
// Check if the blur event occurred due to clicking outside the element
// and the wrapperView contains the element that caused the blur and reset isHovered
- if (!elementRef.current?.contains(event.target as Node) && !elementRef.current?.contains(event.relatedTarget as Node)) {
+ if (!elementRef.current?.contains(event.target as Node) && !elementRef.current?.contains(event.relatedTarget as Node) && !shouldFreezeCapture) {
setIsHovered(false);
}
onBlur?.(event);
},
- [onBlur],
+ [onBlur, shouldFreezeCapture],
);
const handleAndForwardOnMouseMove = useCallback(
diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts
index 18ae1792686f..0efb65ed7a61 100644
--- a/src/components/Icon/Illustrations.ts
+++ b/src/components/Icon/Illustrations.ts
@@ -54,6 +54,7 @@ import ThreeLeggedLaptopWoman from '@assets/images/product-illustrations/three_l
import ToddBehindCloud from '@assets/images/product-illustrations/todd-behind-cloud.svg';
import ToddWithPhones from '@assets/images/product-illustrations/todd-with-phones.svg';
import BigVault from '@assets/images/simple-illustrations/emptystate__big-vault.svg';
+import Puzzle from '@assets/images/simple-illustrations/emptystate__puzzlepieces.svg';
import Abacus from '@assets/images/simple-illustrations/simple-illustration__abacus.svg';
import Accounting from '@assets/images/simple-illustrations/simple-illustration__accounting.svg';
import Alert from '@assets/images/simple-illustrations/simple-illustration__alert.svg';
@@ -200,6 +201,7 @@ export {
TrashCan,
TeleScope,
Profile,
+ Puzzle,
PalmTree,
LockClosed,
Gears,
diff --git a/src/components/ImportOnyxState/BaseImportOnyxState.tsx b/src/components/ImportOnyxState/BaseImportOnyxState.tsx
index c6c80d03b58f..3362bc764446 100644
--- a/src/components/ImportOnyxState/BaseImportOnyxState.tsx
+++ b/src/components/ImportOnyxState/BaseImportOnyxState.tsx
@@ -39,7 +39,7 @@ function BaseImportOnyxState({
wrapperStyle={[styles.sectionMenuItemTopDescription]}
onPress={() => {
openPicker({
- onPicked: onFileRead,
+ onPicked: (data) => onFileRead(data.at(0) ?? {}),
});
}}
/>
diff --git a/src/components/InlineCodeBlock/WrappedText.tsx b/src/components/InlineCodeBlock/WrappedText.tsx
index 00ab8ec20906..9e7c7291a9a6 100644
--- a/src/components/InlineCodeBlock/WrappedText.tsx
+++ b/src/components/InlineCodeBlock/WrappedText.tsx
@@ -1,9 +1,11 @@
-import React, {Fragment} from 'react';
+import React, {Fragment, useMemo} from 'react';
import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
import {View} from 'react-native';
import Text from '@components/Text';
import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
import {containsOnlyEmojis} from '@libs/EmojiUtils';
+import variables from '@styles/variables';
import CONST from '@src/CONST';
import type ChildrenProps from '@src/types/utils/ChildrenProps';
@@ -40,15 +42,59 @@ function containsEmoji(text: string): boolean {
return CONST.REGEX.EMOJIS.test(text);
}
+/**
+ * Takes a long word and splits it into an array of sub-strings.
+ *
+ * The function tests whether the length of the provided word exceeds the provided maximum length.
+ * If the word's length is less than or equal to `maxLength`, it returns an array with the original word.
+ * If the word's length exceeds 'maxLength', it utilizes a regular expression to split the word into
+ * substrings with a specified 'maxLength' and returns them as an array of strings.
+ *
+ * @param word The original word to be split.
+ * @param maxLength The maximum length of each substring.
+ * @return An array of substrings derived from the original word.
+ *
+ * @example
+ * splitLongWord('longteststring', 4);
+ * // Output: ['long', 'test', 'stri', 'ng']
+ */
+function splitLongWord(word: string, maxLength: number): string[] {
+ if (word.length <= maxLength) {
+ return [word];
+ }
+
+ return word.match(new RegExp(`.{1,${maxLength}}`, 'g')) ?? [];
+}
+
+function getFontSizeFromStyles(textStyles: StyleProp): number {
+ if (Array.isArray(textStyles)) {
+ for (const style of textStyles) {
+ if (style && 'fontSize' in style && style.fontSize) {
+ return style.fontSize;
+ }
+ }
+ } else if (textStyles && 'fontSize' in textStyles && textStyles.fontSize) {
+ return textStyles.fontSize;
+ }
+
+ // if we cannot infer fontSize from styles, a default value is returned
+ return variables.fontSizeLabel;
+}
+
function WrappedText({children, wordStyles, textStyles}: WrappedTextProps) {
const styles = useThemeStyles();
+ const {windowWidth} = useWindowDimensions();
+
+ const fontSize = useMemo(() => getFontSizeFromStyles(textStyles), [textStyles]);
+ const childrenString = typeof children === 'string' ? children : '';
+ const charsPerLine = useMemo(() => Math.floor(windowWidth / (fontSize * variables.fontSizeToWidthRatio)), [windowWidth, fontSize]);
+
+ const textMatrix = getTextMatrix(childrenString).map((row) => row.flatMap((word) => splitLongWord(word, charsPerLine)));
if (typeof children !== 'string') {
return null;
}
- const textMatrix = getTextMatrix(children);
-
return textMatrix.map((rowText, rowIndex) => (
;
};
-function InteractiveStepWrapper({children, wrapperID, handleBackButtonPress, headerTitle, startStepIndex, stepNames}: InteractiveStepWrapperProps) {
+function InteractiveStepWrapper(
+ {
+ children,
+ wrapperID,
+ handleBackButtonPress,
+ headerTitle,
+ startStepIndex,
+ stepNames,
+ shouldEnableMaxHeight,
+ shouldShowOfflineIndicator,
+ shouldEnablePickerAvoiding = false,
+ guidesCallTaskID,
+ offlineIndicatorStyle,
+ }: InteractiveStepWrapperProps,
+ ref: React.ForwardedRef,
+) {
const styles = useThemeStyles();
return (
- {stepNames && (
+ {!!stepNames && (
{}, opti
ReportUtils.isSystemChat(report)
}
/>
- {ReportUtils.isChatUsedForOnboarding(report) && }
+ {ReportUtils.isChatUsedForOnboarding(report) && }
{isStatusVisible && (
{}, opti
/>
)}
- {hasDraftComment && optionItem.isAllowedToComment && (
+ {hasDraftComment && !!optionItem.isAllowedToComment && (
{}, opti
/>
)}
- {!shouldShowGreenDotIndicator && !hasBrickError && optionItem.isPinned && (
+ {!shouldShowGreenDotIndicator && !hasBrickError && !!optionItem.isPinned && (
))}
- {errorText && (
+ {!!errorText && (
- {coordinates && (
+ {!!coordinates && (
+ User.requestValidateCodeAction()}
+ handleSubmitForm={(validateCode) => User.validateSecondaryLogin(loginList, contactMethod, validateCode)}
+ validateError={!isEmptyObject(validateLoginError) ? validateLoginError : ErrorUtils.getLatestErrorField(loginData, 'validateCodeSent')}
+ clearError={() => User.clearContactMethodErrors(contactMethod, !isEmptyObject(validateLoginError) ? 'validateLogin' : 'validateCodeSent')}
+ onClose={() => toggleValidateCodeActionModal?.(false)}
+ />
diff --git a/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx b/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx
index 888ad24ba2be..8bb02fdda4d0 100644
--- a/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx
+++ b/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx
@@ -1,13 +1,9 @@
import React, {useCallback, useEffect, useMemo} from 'react';
-import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
-import ScreenWrapper from '@components/ScreenWrapper';
+import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
import useLocalize from '@hooks/useLocalize';
import useSubStep from '@hooks/useSubStep';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
import getPlaidOAuthReceivedRedirectURI from '@libs/getPlaidOAuthReceivedRedirectURI';
import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues';
import * as BankAccounts from '@userActions/BankAccounts';
@@ -37,7 +33,6 @@ function BankInfo({onBackButtonPress, policyID}: BankInfoProps) {
const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
const [plaidLinkToken] = useOnyx(ONYXKEYS.PLAID_LINK_TOKEN);
const {translate} = useLocalize();
- const styles = useThemeStyles();
const [redirectedFromPlaidToManual, setRedirectedFromPlaidToManual] = React.useState(false);
const values = useMemo(() => getSubstepValues(BANK_INFO_STEP_KEYS, reimbursementAccountDraft, reimbursementAccount ?? {}), [reimbursementAccount, reimbursementAccountDraft]);
@@ -125,28 +120,20 @@ function BankInfo({onBackButtonPress, policyID}: BankInfoProps) {
};
return (
-
-
-
-
-
-
+
);
}
diff --git a/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/AddressUBO.tsx b/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/AddressUBO.tsx
index 1b03432b7f3e..99191db675a9 100644
--- a/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/AddressUBO.tsx
+++ b/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/AddressUBO.tsx
@@ -1,31 +1,21 @@
import React from 'react';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
-import FormProvider from '@components/Form/FormProvider';
-import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
-import Text from '@components/Text';
+import {useOnyx} from 'react-native-onyx';
+import AddressStep from '@components/SubStepForms/AddressStep';
import useLocalize from '@hooks/useLocalize';
import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
-import * as ValidationUtils from '@libs/ValidationUtils';
-import AddressFormFields from '@pages/ReimbursementAccount/AddressFormFields';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {ReimbursementAccountForm} from '@src/types/form';
const BENEFICIAL_OWNER_INFO_KEY = CONST.BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA;
const BENEFICIAL_OWNER_PREFIX = CONST.BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA.PREFIX;
-type AddressUBOOnyxProps = {
- /** The draft values of the bank account being setup */
- reimbursementAccountDraft: OnyxEntry;
-};
-type AddressUBOProps = SubStepProps & AddressUBOOnyxProps & {beneficialOwnerBeingModifiedID: string};
+type AddressUBOProps = SubStepProps & {beneficialOwnerBeingModifiedID: string};
-function AddressUBO({reimbursementAccountDraft, onNext, isEditing, beneficialOwnerBeingModifiedID}: AddressUBOProps) {
+function AddressUBO({onNext, onMove, isEditing, beneficialOwnerBeingModifiedID}: AddressUBOProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
+
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
const inputKeys = {
street: `${BENEFICIAL_OWNER_PREFIX}_${beneficialOwnerBeingModifiedID}_${BENEFICIAL_OWNER_INFO_KEY.STREET}`,
@@ -34,8 +24,6 @@ function AddressUBO({reimbursementAccountDraft, onNext, isEditing, beneficialOwn
zipCode: `${BENEFICIAL_OWNER_PREFIX}_${beneficialOwnerBeingModifiedID}_${BENEFICIAL_OWNER_INFO_KEY.ZIP_CODE}`,
} as const;
- const stepFields = [inputKeys.street, inputKeys.city, inputKeys.state, inputKeys.zipCode];
-
const defaultValues = {
street: reimbursementAccountDraft?.[inputKeys.street] ?? '',
city: reimbursementAccountDraft?.[inputKeys.city] ?? '',
@@ -43,19 +31,7 @@ function AddressUBO({reimbursementAccountDraft, onNext, isEditing, beneficialOwn
zipCode: reimbursementAccountDraft?.[inputKeys.zipCode] ?? '',
};
- const validate = (values: FormOnyxValues): FormInputErrors => {
- const errors = ValidationUtils.getFieldRequiredErrors(values, stepFields);
-
- if (values[inputKeys.street] && !ValidationUtils.isValidAddress(values[inputKeys.street])) {
- errors[inputKeys.street] = translate('bankAccount.error.addressStreet');
- }
-
- if (values[inputKeys.zipCode] && !ValidationUtils.isValidZipCode(values[inputKeys.zipCode])) {
- errors[inputKeys.zipCode] = translate('bankAccount.error.zipCode');
- }
-
- return errors;
- };
+ const stepFields = [inputKeys.street, inputKeys.city, inputKeys.state, inputKeys.zipCode];
const handleSubmit = useReimbursementAccountStepFormSubmit({
fieldIds: stepFields,
@@ -64,30 +40,22 @@ function AddressUBO({reimbursementAccountDraft, onNext, isEditing, beneficialOwn
});
return (
-
+ isEditing={isEditing}
+ onNext={onNext}
+ onMove={onMove}
formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM}
- submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')}
- validate={validate}
+ formTitle={translate('beneficialOwnerInfoStep.enterTheOwnersAddress')}
+ formPOBoxDisclaimer={translate('common.noPO')}
onSubmit={handleSubmit}
- submitButtonStyles={[styles.mb0]}
- style={[styles.mh5, styles.flexGrow1]}
- >
- {translate('beneficialOwnerInfoStep.enterTheOwnersAddress')}
- {translate('common.noPO')}
-
-
+ stepFields={stepFields}
+ inputFieldsIDs={inputKeys}
+ defaultValues={defaultValues}
+ shouldShowHelpLinks={false}
+ />
);
}
AddressUBO.displayName = 'AddressUBO';
-export default withOnyx({
- reimbursementAccountDraft: {
- key: ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT,
- },
-})(AddressUBO);
+export default AddressUBO;
diff --git a/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/ConfirmationUBO.tsx b/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/ConfirmationUBO.tsx
index 13aa2b4056bc..47f3327648d4 100644
--- a/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/ConfirmationUBO.tsx
+++ b/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/ConfirmationUBO.tsx
@@ -1,139 +1,75 @@
import React from 'react';
-import {View} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
-import Button from '@components/Button';
-import DotIndicatorMessage from '@components/DotIndicatorMessage';
-import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
-import SafeAreaConsumer from '@components/SafeAreaConsumer';
-import ScrollView from '@components/ScrollView';
-import Text from '@components/Text';
-import TextLink from '@components/TextLink';
+import {useOnyx} from 'react-native-onyx';
+import ConfirmationStep from '@components/SubStepForms/ConfirmationStep';
import useLocalize from '@hooks/useLocalize';
-import useNetwork from '@hooks/useNetwork';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
import * as ErrorUtils from '@libs/ErrorUtils';
import getValuesForBeneficialOwner from '@pages/ReimbursementAccount/utils/getValuesForBeneficialOwner';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {ReimbursementAccountForm} from '@src/types/form';
-import type {ReimbursementAccount} from '@src/types/onyx';
-type ConfirmationUBOOnyxProps = {
- /** Reimbursement account from ONYX */
- reimbursementAccount: OnyxEntry;
-
- /** The draft values of the bank account being setup */
- reimbursementAccountDraft: OnyxEntry;
-};
-type ConfirmationUBOProps = SubStepProps & ConfirmationUBOOnyxProps & {beneficialOwnerBeingModifiedID: string};
+type ConfirmationUBOProps = SubStepProps & {beneficialOwnerBeingModifiedID: string};
const UBO_STEP_INDEXES = CONST.REIMBURSEMENT_ACCOUNT.SUBSTEP_INDEX.UBO;
-function ConfirmationUBO({reimbursementAccount, reimbursementAccountDraft, onNext, onMove, beneficialOwnerBeingModifiedID}: ConfirmationUBOProps) {
+function ConfirmationUBO({onNext, onMove, isEditing, beneficialOwnerBeingModifiedID}: ConfirmationUBOProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
- const {isOffline} = useNetwork();
+
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
const values = getValuesForBeneficialOwner(beneficialOwnerBeingModifiedID, reimbursementAccountDraft);
const error = reimbursementAccount ? ErrorUtils.getLatestErrorMessage(reimbursementAccount) : '';
- return (
-
- {({safeAreaPaddingBottomStyle}) => (
-
- {translate('beneficialOwnerInfoStep.letsDoubleCheck')}
- {
- onMove(UBO_STEP_INDEXES.LEGAL_NAME);
- }}
- />
- {
- onMove(UBO_STEP_INDEXES.DATE_OF_BIRTH);
- }}
- />
- {
- onMove(UBO_STEP_INDEXES.SSN);
- }}
- />
- {
- onMove(UBO_STEP_INDEXES.ADDRESS);
- }}
- />
+ const summaryItems = [
+ {
+ description: translate('beneficialOwnerInfoStep.legalName'),
+ title: `${values.firstName} ${values.lastName}`,
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(UBO_STEP_INDEXES.LEGAL_NAME);
+ },
+ },
+ {
+ description: translate('common.dob'),
+ title: values.dob,
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(UBO_STEP_INDEXES.DATE_OF_BIRTH);
+ },
+ },
+ {
+ description: translate('beneficialOwnerInfoStep.last4SSN'),
+ title: values.ssnLast4,
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(UBO_STEP_INDEXES.SSN);
+ },
+ },
+ {
+ description: translate('beneficialOwnerInfoStep.address'),
+ title: `${values.street}, ${values.city}, ${values.state} ${values.zipCode}`,
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(UBO_STEP_INDEXES.ADDRESS);
+ },
+ },
+ ];
-
- {`${translate('beneficialOwnerInfoStep.byAddingThisBankAccount')} `}
-
- {translate('onfidoStep.facialScan')}
-
- {', '}
-
- {translate('common.privacy')}
-
- {` ${translate('common.and')} `}
-
- {translate('common.termsOfService')}
-
-
-
- {error && error.length > 0 && (
-
- )}
-
-
-
- )}
-
+ return (
+
);
}
ConfirmationUBO.displayName = 'ConfirmationUBO';
-export default withOnyx({
- // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM
- reimbursementAccount: {
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- },
- reimbursementAccountDraft: {
- key: ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT,
- },
-})(ConfirmationUBO);
+export default ConfirmationUBO;
diff --git a/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/DateOfBirthUBO.tsx b/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/DateOfBirthUBO.tsx
index b5a4a6a94bed..c2cd95784596 100644
--- a/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/DateOfBirthUBO.tsx
+++ b/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/DateOfBirthUBO.tsx
@@ -1,54 +1,25 @@
-import {subYears} from 'date-fns';
import React from 'react';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
-import DatePicker from '@components/DatePicker';
-import FormProvider from '@components/Form/FormProvider';
-import InputWrapper from '@components/Form/InputWrapper';
-import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
-import Text from '@components/Text';
+import {useOnyx} from 'react-native-onyx';
+import DateOfBirthStep from '@components/SubStepForms/DateOfBirthStep';
import useLocalize from '@hooks/useLocalize';
import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
-import * as ValidationUtils from '@libs/ValidationUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {ReimbursementAccountForm} from '@src/types/form';
const DOB = CONST.BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA.DOB;
const BENEFICIAL_OWNER_PREFIX = CONST.BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA.PREFIX;
-type DateOfBirthUBOOnyxProps = {
- /** The draft values of the bank account being setup */
- reimbursementAccountDraft: OnyxEntry;
-};
-type DateOfBirthUBOProps = SubStepProps & DateOfBirthUBOOnyxProps & {beneficialOwnerBeingModifiedID: string};
+type DateOfBirthUBOProps = SubStepProps & {beneficialOwnerBeingModifiedID: string};
-function DateOfBirthUBO({reimbursementAccountDraft, onNext, isEditing, beneficialOwnerBeingModifiedID}: DateOfBirthUBOProps) {
+function DateOfBirthUBO({onNext, onMove, isEditing, beneficialOwnerBeingModifiedID}: DateOfBirthUBOProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
+
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
const dobInputID = `${BENEFICIAL_OWNER_PREFIX}_${beneficialOwnerBeingModifiedID}_${DOB}` as const;
const dobDefaultValue = reimbursementAccountDraft?.[dobInputID] ?? '';
- const minDate = subYears(new Date(), CONST.DATE_BIRTH.MAX_AGE);
- const maxDate = subYears(new Date(), CONST.DATE_BIRTH.MIN_AGE_FOR_PAYMENT);
-
- const validate = (values: FormOnyxValues): FormInputErrors => {
- const errors = ValidationUtils.getFieldRequiredErrors(values, [dobInputID]);
-
- if (values[dobInputID]) {
- if (!ValidationUtils.isValidPastDate(values[dobInputID]) || !ValidationUtils.meetsMaximumAgeRequirement(values[dobInputID])) {
- errors[dobInputID] = translate('bankAccount.error.dob');
- } else if (!ValidationUtils.meetsMinimumAgeRequirement(values[dobInputID])) {
- errors[dobInputID] = translate('bankAccount.error.age');
- }
- }
-
- return errors;
- };
-
const handleSubmit = useReimbursementAccountStepFormSubmit({
fieldIds: [dobInputID],
onNext,
@@ -56,34 +27,21 @@ function DateOfBirthUBO({reimbursementAccountDraft, onNext, isEditing, beneficia
});
return (
-
+ isEditing={isEditing}
+ onNext={onNext}
+ onMove={onMove}
formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM}
- submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')}
- validate={validate}
+ formTitle={translate('beneficialOwnerInfoStep.enterTheDateOfBirthOfTheOwner')}
onSubmit={handleSubmit}
- style={[styles.mh5, styles.flexGrow2, styles.justifyContentBetween]}
- submitButtonStyles={[styles.mb0]}
- >
- {translate('beneficialOwnerInfoStep.enterTheDateOfBirthOfTheOwner')}
-
-
+ stepFields={[dobInputID]}
+ dobInputID={dobInputID}
+ dobDefaultValue={dobDefaultValue}
+ shouldShowHelpLinks={false}
+ />
);
}
DateOfBirthUBO.displayName = 'DateOfBirthUBO';
-export default withOnyx({
- reimbursementAccountDraft: {
- key: ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT,
- },
-})(DateOfBirthUBO);
+export default DateOfBirthUBO;
diff --git a/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/LegalNameUBO.tsx b/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/LegalNameUBO.tsx
index b17bf641eca5..074874795d5e 100644
--- a/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/LegalNameUBO.tsx
+++ b/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/LegalNameUBO.tsx
@@ -1,41 +1,30 @@
import React from 'react';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
-import FormProvider from '@components/Form/FormProvider';
-import InputWrapper from '@components/Form/InputWrapper';
-import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
-import Text from '@components/Text';
-import TextInput from '@components/TextInput';
+import {useOnyx} from 'react-native-onyx';
+import type {FormOnyxValues} from '@components/Form/types';
+import FullNameStep from '@components/SubStepForms/FullNameStep';
import useLocalize from '@hooks/useLocalize';
import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
-import * as ValidationUtils from '@libs/ValidationUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {ReimbursementAccountForm} from '@src/types/form';
const {FIRST_NAME, LAST_NAME} = CONST.BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA;
const BENEFICIAL_OWNER_PREFIX = CONST.BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA.PREFIX;
-type LegalNameUBOOnyxProps = {
- /** The draft values of the bank account being setup */
- reimbursementAccountDraft: OnyxEntry;
-};
-type LegalNameUBOProps = SubStepProps & LegalNameUBOOnyxProps & {beneficialOwnerBeingModifiedID: string};
+type LegalNameUBOProps = SubStepProps & {beneficialOwnerBeingModifiedID: string};
-function LegalNameUBO({reimbursementAccountDraft, onNext, isEditing, beneficialOwnerBeingModifiedID}: LegalNameUBOProps) {
+function LegalNameUBO({onNext, onMove, isEditing, beneficialOwnerBeingModifiedID}: LegalNameUBOProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
- const firstNameInputID = `${BENEFICIAL_OWNER_PREFIX}_${beneficialOwnerBeingModifiedID}_${FIRST_NAME}` as const;
- const lastNameInputID = `${BENEFICIAL_OWNER_PREFIX}_${beneficialOwnerBeingModifiedID}_${LAST_NAME}` as const;
- const stepFields = [firstNameInputID, lastNameInputID];
- const defaultFirstName = reimbursementAccountDraft?.[firstNameInputID] ?? '';
- const defaultLastName = reimbursementAccountDraft?.[lastNameInputID] ?? '';
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
- const validate = (values: FormOnyxValues): FormInputErrors =>
- ValidationUtils.getFieldRequiredErrors(values, stepFields);
+ const firstNameInputID = `${BENEFICIAL_OWNER_PREFIX}_${beneficialOwnerBeingModifiedID}_${FIRST_NAME}` as keyof FormOnyxValues;
+ const lastNameInputID = `${BENEFICIAL_OWNER_PREFIX}_${beneficialOwnerBeingModifiedID}_${LAST_NAME}` as keyof FormOnyxValues;
+ const stepFields = [firstNameInputID, lastNameInputID];
+ const defaultValues = {
+ firstName: reimbursementAccountDraft?.[firstNameInputID] ?? '',
+ lastName: reimbursementAccountDraft?.[lastNameInputID] ?? '',
+ };
const handleSubmit = useReimbursementAccountStepFormSubmit({
fieldIds: stepFields,
@@ -44,43 +33,21 @@ function LegalNameUBO({reimbursementAccountDraft, onNext, isEditing, beneficialO
});
return (
-
+ isEditing={isEditing}
+ onNext={onNext}
+ onMove={onMove}
formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM}
- submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')}
- validate={validate}
+ formTitle={translate('beneficialOwnerInfoStep.enterLegalFirstAndLastName')}
onSubmit={handleSubmit}
- style={[styles.mh5, styles.flexGrow1]}
- submitButtonStyles={[styles.mb0]}
- >
- {translate('beneficialOwnerInfoStep.enterLegalFirstAndLastName')}
-
-
-
+ stepFields={stepFields}
+ firstNameInputID={firstNameInputID}
+ lastNameInputID={lastNameInputID}
+ defaultValues={defaultValues}
+ />
);
}
LegalNameUBO.displayName = 'LegalNameUBO';
-export default withOnyx({
- reimbursementAccountDraft: {
- key: ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT,
- },
-})(LegalNameUBO);
+export default LegalNameUBO;
diff --git a/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/SocialSecurityNumberUBO.tsx b/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/SocialSecurityNumberUBO.tsx
index 483d2750f399..7646a51f6c5f 100644
--- a/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/SocialSecurityNumberUBO.tsx
+++ b/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/BeneficialOwnerDetailsFormSubsteps/SocialSecurityNumberUBO.tsx
@@ -1,33 +1,23 @@
import React from 'react';
-import {View} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
-import FormProvider from '@components/Form/FormProvider';
-import InputWrapper from '@components/Form/InputWrapper';
+import {useOnyx} from 'react-native-onyx';
import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
-import Text from '@components/Text';
-import TextInput from '@components/TextInput';
+import SingleFieldStep from '@components/SubStepForms/SingleFieldStep';
import useLocalize from '@hooks/useLocalize';
import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
import * as ValidationUtils from '@libs/ValidationUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {ReimbursementAccountForm} from '@src/types/form';
const SSN_LAST_4 = CONST.BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA.SSN_LAST_4;
const BENEFICIAL_OWNER_PREFIX = CONST.BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.BENEFICIAL_OWNER_DATA.PREFIX;
-type SocialSecurityNumberUBOOnyxProps = {
- /** The draft values of the bank account being setup */
- reimbursementAccountDraft: OnyxEntry;
-};
-type SocialSecurityNumberUBOProps = SubStepProps & SocialSecurityNumberUBOOnyxProps & {beneficialOwnerBeingModifiedID: string};
+type SocialSecurityNumberUBOProps = SubStepProps & {beneficialOwnerBeingModifiedID: string};
-function SocialSecurityNumberUBO({reimbursementAccountDraft, onNext, isEditing, beneficialOwnerBeingModifiedID}: SocialSecurityNumberUBOProps) {
+function SocialSecurityNumberUBO({onNext, onMove, isEditing, beneficialOwnerBeingModifiedID}: SocialSecurityNumberUBOProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
+
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
const ssnLast4InputID = `${BENEFICIAL_OWNER_PREFIX}_${beneficialOwnerBeingModifiedID}_${SSN_LAST_4}` as const;
const defaultSsnLast4 = reimbursementAccountDraft?.[ssnLast4InputID] ?? '';
@@ -48,40 +38,25 @@ function SocialSecurityNumberUBO({reimbursementAccountDraft, onNext, isEditing,
});
return (
-
+ isEditing={isEditing}
+ onNext={onNext}
+ onMove={onMove}
formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM}
- submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')}
+ formTitle={translate('beneficialOwnerInfoStep.enterTheLast4')}
+ formDisclaimer={translate('beneficialOwnerInfoStep.dontWorry')}
validate={validate}
onSubmit={handleSubmit}
- style={[styles.mh5, styles.flexGrow1]}
- submitButtonStyles={[styles.mb0]}
- >
-
- {translate('beneficialOwnerInfoStep.enterTheLast4')}
- {translate('beneficialOwnerInfoStep.dontWorry')}
-
-
-
-
-
+ inputId={ssnLast4InputID}
+ inputLabel={translate('beneficialOwnerInfoStep.last4SSN')}
+ inputMode={CONST.INPUT_MODE.NUMERIC}
+ defaultValue={defaultSsnLast4}
+ shouldShowHelpLinks={false}
+ maxLength={CONST.BANK_ACCOUNT.MAX_LENGTH.SSN}
+ />
);
}
SocialSecurityNumberUBO.displayName = 'SocialSecurityNumberUBO';
-export default withOnyx({
- reimbursementAccountDraft: {
- key: ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT,
- },
-})(SocialSecurityNumberUBO);
+export default SocialSecurityNumberUBO;
diff --git a/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/CompanyOwnersListUBO.tsx b/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/CompanyOwnersListUBO.tsx
index 16cbfef2a994..d425ca9a0079 100644
--- a/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/CompanyOwnersListUBO.tsx
+++ b/src/pages/ReimbursementAccount/BeneficialOwnerInfo/substeps/CompanyOwnersListUBO.tsx
@@ -132,7 +132,7 @@ function CompanyOwnersListUBO({
- {error && error.length > 0 && (
+ {!!error && error.length > 0 && (
;
-
- /** The draft values of the bank account being setup */
- reimbursementAccountDraft: OnyxEntry;
-};
-
-type BeneficialOwnersStepProps = BeneficialOwnerInfoOnyxProps & {
+type BeneficialOwnersStepProps = {
/** Goes to the previous step */
onBackButtonPress: () => void;
};
@@ -43,9 +28,12 @@ const SUBSTEP = CONST.BANK_ACCOUNT.BENEFICIAL_OWNER_INFO_STEP.SUBSTEP;
const MAX_NUMBER_OF_UBOS = 4;
const bodyContent: Array> = [LegalNameUBO, DateOfBirthUBO, SocialSecurityNumberUBO, AddressUBO, ConfirmationUBO];
-function BeneficialOwnersStep({reimbursementAccount, reimbursementAccountDraft, onBackButtonPress}: BeneficialOwnersStepProps) {
+function BeneficialOwnersStep({onBackButtonPress}: BeneficialOwnersStepProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
+
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
+
const companyName = reimbursementAccount?.achData?.companyName ?? '';
const policyID = reimbursementAccount?.achData?.policyID ?? '-1';
const defaultValues = {
@@ -216,23 +204,15 @@ function BeneficialOwnersStep({reimbursementAccount, reimbursementAccountDraft,
};
return (
-
-
-
-
-
-
{currentUBOSubstep === SUBSTEP.IS_USER_UBO && (
)}
-
+
);
}
BeneficialOwnersStep.displayName = 'BeneficialOwnersStep';
-export default withOnyx({
- // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM
- reimbursementAccount: {
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- },
- reimbursementAccountDraft: {
- key: ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT,
- },
-})(BeneficialOwnersStep);
+export default BeneficialOwnersStep;
diff --git a/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx b/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx
index 7aadcfc56b95..0a94e22cde1f 100644
--- a/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx
+++ b/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx
@@ -1,14 +1,10 @@
import lodashPick from 'lodash/pick';
import React, {useCallback, useMemo} from 'react';
-import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
-import ScreenWrapper from '@components/ScreenWrapper';
+import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
import useLocalize from '@hooks/useLocalize';
import useSubStep from '@hooks/useSubStep';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
import {parsePhoneNumber} from '@libs/PhoneNumber';
import * as ValidationUtils from '@libs/ValidationUtils';
import getInitialSubstepForBusinessInfo from '@pages/ReimbursementAccount/utils/getInitialSubstepForBusinessInfo';
@@ -48,7 +44,6 @@ const bodyContent: Array> = [
function BusinessInfo({onBackButtonPress}: BusinessInfoProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
@@ -107,29 +102,22 @@ function BusinessInfo({onBackButtonPress}: BusinessInfoProps) {
};
return (
-
-
-
-
-
-
+
);
}
diff --git a/src/pages/ReimbursementAccount/CompleteVerification/CompleteVerification.tsx b/src/pages/ReimbursementAccount/CompleteVerification/CompleteVerification.tsx
index 900ca7207f59..cbc9f8d7f403 100644
--- a/src/pages/ReimbursementAccount/CompleteVerification/CompleteVerification.tsx
+++ b/src/pages/ReimbursementAccount/CompleteVerification/CompleteVerification.tsx
@@ -1,33 +1,18 @@
import type {ComponentType} from 'react';
import React, {useCallback, useMemo} from 'react';
-import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import type {OnyxEntry} from 'react-native-onyx';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
-import ScreenWrapper from '@components/ScreenWrapper';
+import {useOnyx} from 'react-native-onyx';
+import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
import useLocalize from '@hooks/useLocalize';
import useSubStep from '@hooks/useSubStep';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues';
import * as BankAccounts from '@userActions/BankAccounts';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {ReimbursementAccountForm} from '@src/types/form';
import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
-import type {ReimbursementAccount} from '@src/types/onyx';
import ConfirmAgreements from './substeps/ConfirmAgreements';
-type CompleteVerificationOnyxProps = {
- /** Reimbursement account from ONYX */
- reimbursementAccount: OnyxEntry;
-
- /** The draft values of the bank account being setup */
- reimbursementAccountDraft: OnyxEntry;
-};
-
-type CompleteVerificationProps = CompleteVerificationOnyxProps & {
+type CompleteVerificationProps = {
/** Handles back button press */
onBackButtonPress: () => void;
};
@@ -35,9 +20,11 @@ type CompleteVerificationProps = CompleteVerificationOnyxProps & {
const COMPLETE_VERIFICATION_KEYS = INPUT_IDS.COMPLETE_VERIFICATION;
const bodyContent: Array> = [ConfirmAgreements];
-function CompleteVerification({reimbursementAccount, reimbursementAccountDraft, onBackButtonPress}: CompleteVerificationProps) {
+function CompleteVerification({onBackButtonPress}: CompleteVerificationProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
+
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
const values = useMemo(() => getSubstepValues(COMPLETE_VERIFICATION_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]);
const policyID = reimbursementAccount?.achData?.policyID ?? '-1';
@@ -70,39 +57,24 @@ function CompleteVerification({reimbursementAccount, reimbursementAccountDraft,
};
return (
-
-
-
-
-
-
+
);
}
CompleteVerification.displayName = 'CompleteVerification';
-export default withOnyx({
- // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM
- reimbursementAccount: {
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- },
- reimbursementAccountDraft: {
- key: ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT,
- },
-})(CompleteVerification);
+export default CompleteVerification;
diff --git a/src/pages/ReimbursementAccount/ContinueBankAccountSetup.tsx b/src/pages/ReimbursementAccount/ContinueBankAccountSetup.tsx
index 6469426cd36b..9e1897348dab 100644
--- a/src/pages/ReimbursementAccount/ContinueBankAccountSetup.tsx
+++ b/src/pages/ReimbursementAccount/ContinueBankAccountSetup.tsx
@@ -85,7 +85,7 @@ function ContinueBankAccountSetup({policyName = '', onBackButtonPress, reimburse
- {reimbursementAccount?.shouldShowResetModal && }
+ {!!reimbursementAccount?.shouldShowResetModal && }
);
}
diff --git a/src/pages/ReimbursementAccount/NonUSD/Country/substeps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/Country/substeps/Confirmation.tsx
index df4cee627c78..28a4485dacfb 100644
--- a/src/pages/ReimbursementAccount/NonUSD/Country/substeps/Confirmation.tsx
+++ b/src/pages/ReimbursementAccount/NonUSD/Country/substeps/Confirmation.tsx
@@ -1,5 +1,10 @@
-import React, {useState} from 'react';
+import React, {useCallback, useEffect, useState} from 'react';
+import {useOnyx} from 'react-native-onyx';
import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
+import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
+import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
import PushRowWithModal from '@components/PushRowWithModal';
import SafeAreaConsumer from '@components/SafeAreaConsumer';
import ScrollView from '@components/ScrollView';
@@ -7,19 +12,57 @@ import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import type {SubStepProps} from '@hooks/useSubStep/types';
import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
+import * as ValidationUtils from '@libs/ValidationUtils';
+import mapCurrencyToCountry from '@pages/ReimbursementAccount/utils/mapCurrencyToCountry';
+import * as FormActions from '@userActions/FormActions';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
+
+const {COUNTRY} = INPUT_IDS.ADDITIONAL_DATA;
function Confirmation({onNext}: SubStepProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
+
+ const policyID = reimbursementAccount?.achData?.policyID ?? '-1';
+ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
+ const currency = policy?.outputCurrency ?? '';
+
+ const shouldAllowChange = currency === CONST.CURRENCY.EUR;
+ const currencyMappedToCountry = mapCurrencyToCountry(currency);
- const [selectedCountry, setSelectedCountry] = useState('');
+ const countryDefaultValue = reimbursementAccount?.achData?.additionalData?.[COUNTRY] ?? reimbursementAccountDraft?.[COUNTRY] ?? '';
+ const [selectedCountry, setSelectedCountry] = useState(countryDefaultValue);
+
+ const disableSubmit = !(currency in CONST.CURRENCY);
+
+ const handleSettingsPress = () => {
+ Navigation.navigate(ROUTES.WORKSPACE_PROFILE.getRoute(policyID));
+ };
const handleSelectingCountry = (country: string) => {
+ FormActions.setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {[COUNTRY]: country});
setSelectedCountry(country);
};
+ const validate = useCallback((values: FormOnyxValues): FormInputErrors => {
+ return ValidationUtils.getFieldRequiredErrors(values, [COUNTRY]);
+ }, []);
+
+ useEffect(() => {
+ if (currency === CONST.CURRENCY.EUR) {
+ return;
+ }
+
+ FormActions.setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {[COUNTRY]: currencyMappedToCountry});
+ setSelectedCountry(currencyMappedToCountry);
+ }, [currency, currencyMappedToCountry]);
+
return (
{({safeAreaPaddingBottomStyle}) => (
@@ -27,22 +70,45 @@ function Confirmation({onNext}: SubStepProps) {
style={styles.pt0}
contentContainerStyle={[styles.flexGrow1, safeAreaPaddingBottomStyle]}
>
+ {translate('countryStep.confirmBusinessBank')}
+
+
+ {`${translate('countryStep.yourBusiness')} ${translate('countryStep.youCanChange')}`}
+
+ {translate('common.settings').toLowerCase()}
+
+ .
+
- {translate('countryStep.confirmBusinessBank')}
- {/* This is only to showcase usage of PushRowWithModal component. The actual implementation will come in next issue - https://github.com/Expensify/App/issues/50897 */}
-
diff --git a/src/pages/ReimbursementAccount/PersonalInfo/PersonalInfo.tsx b/src/pages/ReimbursementAccount/PersonalInfo/PersonalInfo.tsx
index 1aa7e519416e..1764bde198af 100644
--- a/src/pages/ReimbursementAccount/PersonalInfo/PersonalInfo.tsx
+++ b/src/pages/ReimbursementAccount/PersonalInfo/PersonalInfo.tsx
@@ -1,38 +1,23 @@
-import type {RefAttributes} from 'react';
import React, {forwardRef, useCallback, useMemo} from 'react';
-import {View} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
-import ScreenWrapper from '@components/ScreenWrapper';
+import type {View} from 'react-native';
+import {useOnyx} from 'react-native-onyx';
+import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
import useLocalize from '@hooks/useLocalize';
import useSubStep from '@hooks/useSubStep';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
import getInitialSubstepForPersonalInfo from '@pages/ReimbursementAccount/utils/getInitialSubstepForPersonalInfo';
import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues';
import * as BankAccounts from '@userActions/BankAccounts';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {ReimbursementAccountForm} from '@src/types/form';
import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
-import type {ReimbursementAccount} from '@src/types/onyx';
import Address from './substeps/Address';
import Confirmation from './substeps/Confirmation';
import DateOfBirth from './substeps/DateOfBirth';
import FullName from './substeps/FullName';
import SocialSecurityNumber from './substeps/SocialSecurityNumber';
-type PersonalInfoOnyxProps = {
- /** Reimbursement account from ONYX */
- reimbursementAccount: OnyxEntry;
-
- /** The draft values of the bank account being setup */
- reimbursementAccountDraft: OnyxEntry;
-};
-
-type PersonalInfoProps = PersonalInfoOnyxProps & {
+type PersonalInfoProps = {
/** Goes to the previous step */
onBackButtonPress: () => void;
};
@@ -40,9 +25,11 @@ type PersonalInfoProps = PersonalInfoOnyxProps & {
const PERSONAL_INFO_STEP_KEYS = INPUT_IDS.PERSONAL_INFO_STEP;
const bodyContent: Array> = [FullName, DateOfBirth, SocialSecurityNumber, Address, Confirmation];
-function PersonalInfo({reimbursementAccount, reimbursementAccountDraft, onBackButtonPress}: PersonalInfoProps, ref: React.ForwardedRef) {
+function PersonalInfo({onBackButtonPress}: PersonalInfoProps, ref: React.ForwardedRef) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
+
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
const policyID = reimbursementAccount?.achData?.policyID ?? '-1';
const values = useMemo(() => getSubstepValues(PERSONAL_INFO_STEP_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]);
@@ -79,40 +66,25 @@ function PersonalInfo({reimbursementAccount, reimbursementAccountDraft, onBackBu
};
return (
-
-
-
-
-
-
+
);
}
PersonalInfo.displayName = 'PersonalInfo';
-export default withOnyx & PersonalInfoProps, PersonalInfoOnyxProps>({
- // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM
- reimbursementAccount: {
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- },
- reimbursementAccountDraft: {
- key: ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT,
- },
-})(forwardRef(PersonalInfo));
+export default forwardRef(PersonalInfo);
diff --git a/src/pages/ReimbursementAccount/PersonalInfo/substeps/Address.tsx b/src/pages/ReimbursementAccount/PersonalInfo/substeps/Address.tsx
index b37dd207ea37..6477c57ac53a 100644
--- a/src/pages/ReimbursementAccount/PersonalInfo/substeps/Address.tsx
+++ b/src/pages/ReimbursementAccount/PersonalInfo/substeps/Address.tsx
@@ -1,27 +1,11 @@
-import React, {useCallback} from 'react';
-import {View} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
-import FormProvider from '@components/Form/FormProvider';
-import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
-import Text from '@components/Text';
+import React from 'react';
+import {useOnyx} from 'react-native-onyx';
+import AddressStep from '@components/SubStepForms/AddressStep';
import useLocalize from '@hooks/useLocalize';
import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
-import * as ValidationUtils from '@libs/ValidationUtils';
-import AddressFormFields from '@pages/ReimbursementAccount/AddressFormFields';
-import HelpLinks from '@pages/ReimbursementAccount/PersonalInfo/HelpLinks';
import ONYXKEYS from '@src/ONYXKEYS';
import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
-import type {ReimbursementAccount} from '@src/types/onyx';
-
-type AddressOnyxProps = {
- /** Reimbursement account from ONYX */
- reimbursementAccount: OnyxEntry;
-};
-
-type AddressProps = AddressOnyxProps & SubStepProps;
const PERSONAL_INFO_STEP_KEY = INPUT_IDS.PERSONAL_INFO_STEP;
@@ -34,9 +18,10 @@ const INPUT_KEYS = {
const STEP_FIELDS = [PERSONAL_INFO_STEP_KEY.STREET, PERSONAL_INFO_STEP_KEY.CITY, PERSONAL_INFO_STEP_KEY.STATE, PERSONAL_INFO_STEP_KEY.ZIP_CODE];
-function Address({reimbursementAccount, onNext, isEditing}: AddressProps) {
+function Address({onNext, onMove, isEditing}: SubStepProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
+
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
const defaultValues = {
street: reimbursementAccount?.achData?.[PERSONAL_INFO_STEP_KEY.STREET] ?? '',
@@ -45,23 +30,6 @@ function Address({reimbursementAccount, onNext, isEditing}: AddressProps) {
zipCode: reimbursementAccount?.achData?.[PERSONAL_INFO_STEP_KEY.ZIP_CODE] ?? '',
};
- const validate = useCallback(
- (values: FormOnyxValues): FormInputErrors => {
- const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS);
-
- if (values.requestorAddressStreet && !ValidationUtils.isValidAddress(values.requestorAddressStreet)) {
- errors.requestorAddressStreet = translate('bankAccount.error.addressStreet');
- }
-
- if (values.requestorAddressZipCode && !ValidationUtils.isValidZipCode(values.requestorAddressZipCode)) {
- errors.requestorAddressZipCode = translate('bankAccount.error.zipCode');
- }
-
- return errors;
- },
- [translate],
- );
-
const handleSubmit = useReimbursementAccountStepFormSubmit({
fieldIds: STEP_FIELDS,
onNext,
@@ -69,34 +37,21 @@ function Address({reimbursementAccount, onNext, isEditing}: AddressProps) {
});
return (
-
+ isEditing={isEditing}
+ onNext={onNext}
+ onMove={onMove}
formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM}
- submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')}
- validate={validate}
+ formTitle={translate('personalInfoStep.enterYourAddress')}
+ formPOBoxDisclaimer={translate('common.noPO')}
onSubmit={handleSubmit}
- submitButtonStyles={[styles.mb0]}
- style={[styles.mh5, styles.flexGrow1]}
- >
-
- {translate('personalInfoStep.enterYourAddress')}
- {translate('common.noPO')}
-
-
-
-
+ stepFields={STEP_FIELDS}
+ inputFieldsIDs={INPUT_KEYS}
+ defaultValues={defaultValues}
+ />
);
}
Address.displayName = 'Address';
-export default withOnyx({
- // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM
- reimbursementAccount: {
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- },
-})(Address);
+export default Address;
diff --git a/src/pages/ReimbursementAccount/PersonalInfo/substeps/Confirmation.tsx b/src/pages/ReimbursementAccount/PersonalInfo/substeps/Confirmation.tsx
index af1f081cc3da..d882adedd6fb 100644
--- a/src/pages/ReimbursementAccount/PersonalInfo/substeps/Confirmation.tsx
+++ b/src/pages/ReimbursementAccount/PersonalInfo/substeps/Confirmation.tsx
@@ -1,146 +1,77 @@
import React, {useMemo} from 'react';
-import {View} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
-import Button from '@components/Button';
-import DotIndicatorMessage from '@components/DotIndicatorMessage';
-import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
-import SafeAreaConsumer from '@components/SafeAreaConsumer';
-import ScrollView from '@components/ScrollView';
-import Text from '@components/Text';
-import TextLink from '@components/TextLink';
+import {useOnyx} from 'react-native-onyx';
+import ConfirmationStep from '@components/SubStepForms/ConfirmationStep';
import useLocalize from '@hooks/useLocalize';
-import useNetwork from '@hooks/useNetwork';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
import * as ErrorUtils from '@libs/ErrorUtils';
import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {ReimbursementAccountForm} from '@src/types/form';
import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
-import type {ReimbursementAccount} from '@src/types/onyx';
-
-type ConfirmationOnyxProps = {
- /** Reimbursement account from ONYX */
- reimbursementAccount: OnyxEntry;
-
- /** The draft values of the bank account being setup */
- reimbursementAccountDraft: OnyxEntry;
-};
-
-type ConfirmationProps = ConfirmationOnyxProps & SubStepProps;
const PERSONAL_INFO_STEP_KEYS = INPUT_IDS.PERSONAL_INFO_STEP;
const PERSONAL_INFO_STEP_INDEXES = CONST.REIMBURSEMENT_ACCOUNT.SUBSTEP_INDEX.PERSONAL_INFO;
-function Confirmation({reimbursementAccount, reimbursementAccountDraft, onNext, onMove}: ConfirmationProps) {
+function Confirmation({onNext, onMove, isEditing}: SubStepProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
- const {isOffline} = useNetwork();
+
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
const isLoading = reimbursementAccount?.isLoading ?? false;
const values = useMemo(() => getSubstepValues(PERSONAL_INFO_STEP_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]);
const error = ErrorUtils.getLatestErrorMessage(reimbursementAccount ?? {});
- return (
-
- {({safeAreaPaddingBottomStyle}) => (
-
- {translate('personalInfoStep.letsDoubleCheck')}
- {
- onMove(PERSONAL_INFO_STEP_INDEXES.LEGAL_NAME);
- }}
- />
- {
- onMove(PERSONAL_INFO_STEP_INDEXES.DATE_OF_BIRTH);
- }}
- />
- {
- onMove(PERSONAL_INFO_STEP_INDEXES.SSN);
- }}
- />
- {
- onMove(PERSONAL_INFO_STEP_INDEXES.ADDRESS);
- }}
- />
+ const summaryItems = [
+ {
+ description: translate('personalInfoStep.legalName'),
+ title: `${values[PERSONAL_INFO_STEP_KEYS.FIRST_NAME]} ${values[PERSONAL_INFO_STEP_KEYS.LAST_NAME]}`,
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(PERSONAL_INFO_STEP_INDEXES.LEGAL_NAME);
+ },
+ },
+ {
+ description: translate('common.dob'),
+ title: values[PERSONAL_INFO_STEP_KEYS.DOB],
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(PERSONAL_INFO_STEP_INDEXES.DATE_OF_BIRTH);
+ },
+ },
+ {
+ description: translate('personalInfoStep.last4SSN'),
+ title: values[PERSONAL_INFO_STEP_KEYS.SSN_LAST_4],
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(PERSONAL_INFO_STEP_INDEXES.SSN);
+ },
+ },
+ {
+ description: translate('personalInfoStep.address'),
+ title: `${values[PERSONAL_INFO_STEP_KEYS.STREET]}, ${values[PERSONAL_INFO_STEP_KEYS.CITY]}, ${values[PERSONAL_INFO_STEP_KEYS.STATE]} ${values[PERSONAL_INFO_STEP_KEYS.ZIP_CODE]}`,
+ shouldShowRightIcon: true,
+ onPress: () => {
+ onMove(PERSONAL_INFO_STEP_INDEXES.ADDRESS);
+ },
+ },
+ ];
-
- {`${translate('personalInfoStep.byAddingThisBankAccount')} `}
-
- {translate('onfidoStep.facialScan')}
-
- {', '}
-
- {translate('common.privacy')}
-
- {` ${translate('common.and')} `}
-
- {translate('common.termsOfService')}
-
-
-
- {error && error.length > 0 && (
-
- )}
-
-
-
- )}
-
+ return (
+
);
}
Confirmation.displayName = 'Confirmation';
-export default withOnyx({
- // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM
- reimbursementAccount: {
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- },
- reimbursementAccountDraft: {
- key: ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT,
- },
-})(Confirmation);
+export default Confirmation;
diff --git a/src/pages/ReimbursementAccount/PersonalInfo/substeps/DateOfBirth.tsx b/src/pages/ReimbursementAccount/PersonalInfo/substeps/DateOfBirth.tsx
index 8c68380d6e55..526181a6cb84 100644
--- a/src/pages/ReimbursementAccount/PersonalInfo/substeps/DateOfBirth.tsx
+++ b/src/pages/ReimbursementAccount/PersonalInfo/substeps/DateOfBirth.tsx
@@ -1,63 +1,23 @@
-import {subYears} from 'date-fns';
-import React, {useCallback} from 'react';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
-import DatePicker from '@components/DatePicker';
-import FormProvider from '@components/Form/FormProvider';
-import InputWrapper from '@components/Form/InputWrapper';
-import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
-import Text from '@components/Text';
+import React from 'react';
+import {useOnyx} from 'react-native-onyx';
+import DateOfBirthStep from '@components/SubStepForms/DateOfBirthStep';
import useLocalize from '@hooks/useLocalize';
import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
-import * as ValidationUtils from '@libs/ValidationUtils';
-import HelpLinks from '@pages/ReimbursementAccount/PersonalInfo/HelpLinks';
-import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {ReimbursementAccountForm} from '@src/types/form';
import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
-import type {ReimbursementAccount} from '@src/types/onyx';
-
-type DateOfBirthOnyxProps = {
- /** Reimbursement account from ONYX */
- reimbursementAccount: OnyxEntry;
-
- /** The draft values of the bank account being setup */
- reimbursementAccountDraft: OnyxEntry;
-};
-
-type DateOfBirthProps = DateOfBirthOnyxProps & SubStepProps;
const PERSONAL_INFO_DOB_KEY = INPUT_IDS.PERSONAL_INFO_STEP.DOB;
const STEP_FIELDS = [PERSONAL_INFO_DOB_KEY];
-function DateOfBirth({reimbursementAccount, reimbursementAccountDraft, onNext, isEditing}: DateOfBirthProps) {
+function DateOfBirth({onNext, onMove, isEditing}: SubStepProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
-
- const validate = useCallback(
- (values: FormOnyxValues): FormInputErrors => {
- const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS);
- if (values.dob) {
- if (!ValidationUtils.isValidPastDate(values.dob) || !ValidationUtils.meetsMaximumAgeRequirement(values.dob)) {
- errors.dob = translate('bankAccount.error.dob');
- } else if (!ValidationUtils.meetsMinimumAgeRequirement(values.dob)) {
- errors.dob = translate('bankAccount.error.age');
- }
- }
-
- return errors;
- },
- [translate],
- );
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+ const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT);
const dobDefaultValue = reimbursementAccount?.achData?.[PERSONAL_INFO_DOB_KEY] ?? reimbursementAccountDraft?.[PERSONAL_INFO_DOB_KEY] ?? '';
- const minDate = subYears(new Date(), CONST.DATE_BIRTH.MAX_AGE);
- const maxDate = subYears(new Date(), CONST.DATE_BIRTH.MIN_AGE_FOR_PAYMENT);
-
const handleSubmit = useReimbursementAccountStepFormSubmit({
fieldIds: STEP_FIELDS,
onNext,
@@ -65,38 +25,20 @@ function DateOfBirth({reimbursementAccount, reimbursementAccountDraft, onNext, i
});
return (
-
+ isEditing={isEditing}
+ onNext={onNext}
+ onMove={onMove}
formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM}
- submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')}
- validate={validate}
+ formTitle={translate('personalInfoStep.enterYourDateOfBirth')}
onSubmit={handleSubmit}
- style={[styles.mh5, styles.flexGrow2, styles.justifyContentBetween]}
- submitButtonStyles={[styles.mb0]}
- >
- {translate('personalInfoStep.enterYourDateOfBirth')}
-
-
-
+ stepFields={STEP_FIELDS}
+ dobInputID={PERSONAL_INFO_DOB_KEY}
+ dobDefaultValue={dobDefaultValue}
+ />
);
}
DateOfBirth.displayName = 'DateOfBirth';
-export default withOnyx({
- // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM
- reimbursementAccount: {
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- },
- reimbursementAccountDraft: {
- key: ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT,
- },
-})(DateOfBirth);
+export default DateOfBirth;
diff --git a/src/pages/ReimbursementAccount/PersonalInfo/substeps/FullName.tsx b/src/pages/ReimbursementAccount/PersonalInfo/substeps/FullName.tsx
index 15c4114432da..8d07b9af7994 100644
--- a/src/pages/ReimbursementAccount/PersonalInfo/substeps/FullName.tsx
+++ b/src/pages/ReimbursementAccount/PersonalInfo/substeps/FullName.tsx
@@ -1,56 +1,25 @@
-import React, {useCallback} from 'react';
-import {View} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
-import FormProvider from '@components/Form/FormProvider';
-import InputWrapper from '@components/Form/InputWrapper';
-import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
-import Text from '@components/Text';
-import TextInput from '@components/TextInput';
+import React from 'react';
+import {useOnyx} from 'react-native-onyx';
+import FullNameStep from '@components/SubStepForms/FullNameStep';
import useLocalize from '@hooks/useLocalize';
import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
-import * as ValidationUtils from '@libs/ValidationUtils';
-import HelpLinks from '@pages/ReimbursementAccount/PersonalInfo/HelpLinks';
-import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
-import type {ReimbursementAccount} from '@src/types/onyx';
-
-type FullNameOnyxProps = {
- /** Reimbursement account from ONYX */
- reimbursementAccount: OnyxEntry;
-};
-
-type FullNameProps = FullNameOnyxProps & SubStepProps;
const PERSONAL_INFO_STEP_KEY = INPUT_IDS.PERSONAL_INFO_STEP;
const STEP_FIELDS = [PERSONAL_INFO_STEP_KEY.FIRST_NAME, PERSONAL_INFO_STEP_KEY.LAST_NAME];
-function FullName({reimbursementAccount, onNext, isEditing}: FullNameProps) {
+
+function FullName({onNext, onMove, isEditing}: SubStepProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
+
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
const defaultValues = {
firstName: reimbursementAccount?.achData?.[PERSONAL_INFO_STEP_KEY.FIRST_NAME] ?? '',
lastName: reimbursementAccount?.achData?.[PERSONAL_INFO_STEP_KEY.LAST_NAME] ?? '',
};
- const validate = useCallback(
- (values: FormOnyxValues): FormInputErrors => {
- const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS);
- if (values.firstName && !ValidationUtils.isValidPersonName(values.firstName)) {
- errors.firstName = translate('bankAccount.error.firstName');
- }
-
- if (values.lastName && !ValidationUtils.isValidPersonName(values.lastName)) {
- errors.lastName = translate('bankAccount.error.lastName');
- }
- return errors;
- },
- [translate],
- );
-
const handleSubmit = useReimbursementAccountStepFormSubmit({
fieldIds: STEP_FIELDS,
onNext,
@@ -58,49 +27,21 @@ function FullName({reimbursementAccount, onNext, isEditing}: FullNameProps) {
});
return (
-
+ isEditing={isEditing}
+ onNext={onNext}
+ onMove={onMove}
formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM}
- submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')}
- validate={validate}
+ formTitle={translate('personalInfoStep.enterYourLegalFirstAndLast')}
onSubmit={handleSubmit}
- style={[styles.mh5, styles.flexGrow1]}
- submitButtonStyles={[styles.mb0]}
- >
-
- {translate('personalInfoStep.enterYourLegalFirstAndLast')}
-
-
-
-
-
-
-
-
-
+ stepFields={STEP_FIELDS}
+ firstNameInputID={PERSONAL_INFO_STEP_KEY.FIRST_NAME}
+ lastNameInputID={PERSONAL_INFO_STEP_KEY.LAST_NAME}
+ defaultValues={defaultValues}
+ />
);
}
FullName.displayName = 'FullName';
-export default withOnyx({
- // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM
- reimbursementAccount: {
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- },
-})(FullName);
+export default FullName;
diff --git a/src/pages/ReimbursementAccount/PersonalInfo/substeps/SocialSecurityNumber.tsx b/src/pages/ReimbursementAccount/PersonalInfo/substeps/SocialSecurityNumber.tsx
index 2f08980f2bd0..f94ceef07d2f 100644
--- a/src/pages/ReimbursementAccount/PersonalInfo/substeps/SocialSecurityNumber.tsx
+++ b/src/pages/ReimbursementAccount/PersonalInfo/substeps/SocialSecurityNumber.tsx
@@ -1,36 +1,22 @@
import React, {useCallback} from 'react';
-import {View} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
-import FormProvider from '@components/Form/FormProvider';
-import InputWrapper from '@components/Form/InputWrapper';
+import {useOnyx} from 'react-native-onyx';
import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
-import Text from '@components/Text';
-import TextInput from '@components/TextInput';
+import SingleFieldStep from '@components/SubStepForms/SingleFieldStep';
import useLocalize from '@hooks/useLocalize';
import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit';
import type {SubStepProps} from '@hooks/useSubStep/types';
-import useThemeStyles from '@hooks/useThemeStyles';
import * as ValidationUtils from '@libs/ValidationUtils';
-import HelpLinks from '@pages/ReimbursementAccount/PersonalInfo/HelpLinks';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
-import type {ReimbursementAccount} from '@src/types/onyx';
-
-type SocialSecurityNumberOnyxProps = {
- /** Reimbursement account from ONYX */
- reimbursementAccount: OnyxEntry;
-};
-
-type SocialSecurityNumberProps = SocialSecurityNumberOnyxProps & SubStepProps;
const PERSONAL_INFO_STEP_KEY = INPUT_IDS.PERSONAL_INFO_STEP;
const STEP_FIELDS = [PERSONAL_INFO_STEP_KEY.SSN_LAST_4];
-function SocialSecurityNumber({reimbursementAccount, onNext, isEditing}: SocialSecurityNumberProps) {
+function SocialSecurityNumber({onNext, onMove, isEditing}: SubStepProps) {
const {translate} = useLocalize();
- const styles = useThemeStyles();
+
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
const defaultSsnLast4 = reimbursementAccount?.achData?.[PERSONAL_INFO_STEP_KEY.SSN_LAST_4] ?? '';
@@ -54,42 +40,24 @@ function SocialSecurityNumber({reimbursementAccount, onNext, isEditing}: SocialS
});
return (
-
+ isEditing={isEditing}
+ onNext={onNext}
+ onMove={onMove}
formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM}
- submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')}
+ formTitle={translate('personalInfoStep.enterTheLast4')}
+ formDisclaimer={translate('personalInfoStep.dontWorry')}
validate={validate}
onSubmit={handleSubmit}
- style={[styles.mh5, styles.flexGrow1]}
- submitButtonStyles={[styles.mb0]}
- >
-
- {translate('personalInfoStep.enterTheLast4')}
- {translate('personalInfoStep.dontWorry')}
-
-
-
-
-
-
+ inputId={PERSONAL_INFO_STEP_KEY.SSN_LAST_4}
+ inputLabel={translate('personalInfoStep.last4SSN')}
+ inputMode={CONST.INPUT_MODE.NUMERIC}
+ defaultValue={defaultSsnLast4}
+ maxLength={CONST.BANK_ACCOUNT.MAX_LENGTH.SSN}
+ />
);
}
SocialSecurityNumber.displayName = 'SocialSecurityNumber';
-export default withOnyx({
- // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM
- reimbursementAccount: {
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- },
-})(SocialSecurityNumber);
+export default SocialSecurityNumber;
diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx
index a6b4d2f88a76..c2b3bd60cb99 100644
--- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx
+++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx
@@ -144,6 +144,8 @@ function ReimbursementAccountPage({route, policy}: ReimbursementAccountPageProps
const [onfidoToken = ''] = useOnyx(ONYXKEYS.ONFIDO_TOKEN);
const [isLoadingApp = false] = useOnyx(ONYXKEYS.IS_LOADING_APP);
const [account] = useOnyx(ONYXKEYS.ACCOUNT);
+ const [isDebugModeEnabled] = useOnyx(ONYXKEYS.USER, {selector: (user) => !!user?.isDebugModeEnabled});
+ const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(false);
const policyName = policy?.name ?? '';
const policyIDParam = route.params?.policyID ?? '-1';
@@ -178,7 +180,7 @@ function ReimbursementAccountPage({route, policy}: ReimbursementAccountPageProps
const [hasACHDataBeenLoaded, setHasACHDataBeenLoaded] = useState(reimbursementAccount !== CONST.REIMBURSEMENT_ACCOUNT.DEFAULT_DATA && isPreviousPolicy);
const [shouldShowContinueSetupButton, setShouldShowContinueSetupButton] = useState(getShouldShowContinueSetupButtonInitialValue());
- function getBankAccountFields(fieldNames: T[]): Pick {
+ function getBankAccountFields(fieldNames: InputID[]): Partial {
return {
...lodashPick(reimbursementAccount?.achData, ...fieldNames),
};
@@ -415,7 +417,12 @@ function ReimbursementAccountPage({route, policy}: ReimbursementAccountPageProps
// or when data is being loaded. Don't show the loading indicator if we're offline and restarted the bank account setup process
// On Android, when we open the app from the background, Onfido activity gets destroyed, so we need to reopen it.
// eslint-disable-next-line react-compiler/react-compiler
- if ((!hasACHDataBeenLoaded || isLoading) && shouldShowOfflineLoader && (shouldReopenOnfido || !requestorStepRef.current)) {
+ if (
+ (!hasACHDataBeenLoaded || isLoading) &&
+ shouldShowOfflineLoader &&
+ (shouldReopenOnfido || !requestorStepRef?.current) &&
+ !(currentStep === CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT && isValidateCodeActionModalVisible)
+ ) {
return ;
}
@@ -439,8 +446,8 @@ function ReimbursementAccountPage({route, policy}: ReimbursementAccountPageProps
const policyCurrency = policy?.outputCurrency ?? '';
// TODO once nonUSD flow is complete update the flag below to reflect all supported currencies, this will be updated in - https://github.com/Expensify/App/issues/50912
const hasUnsupportedCurrency = policyCurrency !== CONST.CURRENCY.USD;
- // TODO remove isDevelopment flag once nonUSD flow is complete, this will be updated in - https://github.com/Expensify/App/issues/50912
- const hasForeignCurrency = SUPPORTED_FOREIGN_CURRENCIES.includes(policyCurrency) && isDevelopment;
+ // TODO remove isDevelopment and isDebugModeEnabled flags once nonUSD flow is complete, this will be updated in - https://github.com/Expensify/App/issues/50912
+ const hasForeignCurrency = SUPPORTED_FOREIGN_CURRENCIES.includes(policyCurrency) && (isDevelopment || isDebugModeEnabled);
if (userHasPhonePrimaryEmail) {
errorText = translate('bankAccount.hasPhoneLoginError');
@@ -536,6 +543,8 @@ function ReimbursementAccountPage({route, policy}: ReimbursementAccountPageProps
plaidLinkOAuthToken={plaidLinkToken}
policyName={policyName}
policyID={policyIDParam}
+ isValidateCodeActionModalVisible={isValidateCodeActionModalVisible}
+ toggleValidateCodeActionModal={setIsValidateCodeActionModalVisible}
/>
);
case CONST.BANK_ACCOUNT.STEP.REQUESTOR:
diff --git a/src/pages/ReimbursementAccount/VerifyIdentity/VerifyIdentity.tsx b/src/pages/ReimbursementAccount/VerifyIdentity/VerifyIdentity.tsx
index cabaf543a756..943f04d66840 100644
--- a/src/pages/ReimbursementAccount/VerifyIdentity/VerifyIdentity.tsx
+++ b/src/pages/ReimbursementAccount/VerifyIdentity/VerifyIdentity.tsx
@@ -1,13 +1,9 @@
import React, {useCallback} from 'react';
-import {View} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
+import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
import Onfido from '@components/Onfido';
import type {OnfidoData} from '@components/Onfido/types';
-import ScreenWrapper from '@components/ScreenWrapper';
import ScrollView from '@components/ScrollView';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -15,30 +11,22 @@ import Growl from '@libs/Growl';
import * as BankAccounts from '@userActions/BankAccounts';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {ReimbursementAccount} from '@src/types/onyx';
-type VerifyIdentityOnyxProps = {
- /** Reimbursement account from ONYX */
- reimbursementAccount: OnyxEntry;
-
- /** Onfido applicant ID from ONYX */
- onfidoApplicantID: OnyxEntry;
-
- /** The token required to initialize the Onfido SDK */
- onfidoToken: OnyxEntry;
-};
-
-type VerifyIdentityProps = VerifyIdentityOnyxProps & {
+type VerifyIdentityProps = {
/** Goes to the previous step */
onBackButtonPress: () => void;
};
const ONFIDO_ERROR_DISPLAY_DURATION = 10000;
-function VerifyIdentity({reimbursementAccount, onBackButtonPress, onfidoApplicantID, onfidoToken}: VerifyIdentityProps) {
+function VerifyIdentity({onBackButtonPress}: VerifyIdentityProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+ const [onfidoApplicantID] = useOnyx(ONYXKEYS.ONFIDO_APPLICANT_ID);
+ const [onfidoToken] = useOnyx(ONYXKEYS.ONFIDO_TOKEN);
+
const policyID = reimbursementAccount?.achData?.policyID ?? '-1';
const handleOnfidoSuccess = useCallback(
(onfidoData: OnfidoData) => {
@@ -61,17 +49,13 @@ function VerifyIdentity({reimbursementAccount, onBackButtonPress, onfidoApplican
};
return (
-
-
-
-
-
+
-
+
);
}
VerifyIdentity.displayName = 'VerifyIdentity';
-export default withOnyx({
- // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM
- reimbursementAccount: {
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- },
- onfidoApplicantID: {
- key: ONYXKEYS.ONFIDO_APPLICANT_ID,
- },
- onfidoToken: {
- key: ONYXKEYS.ONFIDO_TOKEN,
- },
-})(VerifyIdentity);
+export default VerifyIdentity;
diff --git a/src/pages/ReimbursementAccount/utils/mapCurrencyToCountry.ts b/src/pages/ReimbursementAccount/utils/mapCurrencyToCountry.ts
new file mode 100644
index 000000000000..f4062674c183
--- /dev/null
+++ b/src/pages/ReimbursementAccount/utils/mapCurrencyToCountry.ts
@@ -0,0 +1,18 @@
+import CONST from '@src/CONST';
+
+function mapCurrencyToCountry(currency: string): string {
+ switch (currency) {
+ case CONST.CURRENCY.USD:
+ return CONST.COUNTRY.US;
+ case CONST.CURRENCY.AUD:
+ return CONST.COUNTRY.AU;
+ case CONST.CURRENCY.CAD:
+ return CONST.COUNTRY.CA;
+ case CONST.CURRENCY.GBP:
+ return CONST.COUNTRY.GB;
+ default:
+ return '';
+ }
+}
+
+export default mapCurrencyToCountry;
diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx
index 7de12eeda892..9ec3691f49a8 100644
--- a/src/pages/ReportDetailsPage.tsx
+++ b/src/pages/ReportDetailsPage.tsx
@@ -726,7 +726,7 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) {
/>
);
- const nameSectionTitleField = titleField && (
+ const nameSectionTitleField = !!titleField && (
() => {
- UserSearchPhraseActions.clearUserSearchPhrase();
- },
- [],
- );
-
- useEffect(() => {
- UserSearchPhraseActions.updateUserSearchPhrase(debouncedSearchValue);
- }, [debouncedSearchValue]);
+ const [searchValue, setSearchValue] = useState('');
useEffect(() => {
if (isFocused) {
@@ -424,9 +412,7 @@ function ReportParticipantsPage({report, route}: ReportParticipantsPageProps) {
shouldShowTextInput={shouldShowTextInput}
textInputLabel={translate('selectionList.findMember')}
textInputValue={searchValue}
- onChangeText={(value) => {
- setSearchValue(value);
- }}
+ onChangeText={setSearchValue}
headerMessage={headerMessage}
ListItem={TableListItem}
onSelectRow={openMemberDetails}
diff --git a/src/pages/RoomDescriptionPage.tsx b/src/pages/RoomDescriptionPage.tsx
index a70f17d1468c..fde0eb72e2dc 100644
--- a/src/pages/RoomDescriptionPage.tsx
+++ b/src/pages/RoomDescriptionPage.tsx
@@ -40,7 +40,7 @@ function RoomDescriptionPage({report, policies}: RoomDescriptionPageProps) {
const route = useRoute>();
const backTo = route.params.backTo;
const styles = useThemeStyles();
- const [description, setDescription] = useState(() => Parser.htmlToMarkdown(report?.description ?? ''));
+ const [description, setDescription] = useState(() => Parser.htmlToMarkdown(ReportUtils.getReportDescription(report)));
const reportDescriptionInputRef = useRef(null);
const focusTimeoutRef = useRef | null>(null);
const {translate} = useLocalize();
diff --git a/src/pages/RoomMembersPage.tsx b/src/pages/RoomMembersPage.tsx
index 1018b86083be..6a89eca6f778 100644
--- a/src/pages/RoomMembersPage.tsx
+++ b/src/pages/RoomMembersPage.tsx
@@ -19,7 +19,6 @@ import SelectionListWithModal from '@components/SelectionListWithModal';
import Text from '@components/Text';
import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails';
import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
-import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
@@ -55,7 +54,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
const [selectedMembers, setSelectedMembers] = useState([]);
const [removeMembersConfirmModalVisible, setRemoveMembersConfirmModalVisible] = useState(false);
const [userSearchPhrase] = useOnyx(ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE);
- const [searchValue, debouncedSearchTerm, setSearchValue] = useDebouncedState('');
+ const [searchValue, setSearchValue] = useState('');
const [didLoadRoomMembers, setDidLoadRoomMembers] = useState(false);
const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
const policy = useMemo(() => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? ''}`], [policies, report?.policyID]);
@@ -71,14 +70,6 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE);
const canSelectMultiple = isSmallScreenWidth ? selectionMode?.isEnabled : true;
- useEffect(() => {
- setSearchValue(userSearchPhrase ?? '');
- }, [isFocusedScreen, setSearchValue, userSearchPhrase]);
-
- useEffect(() => {
- UserSearchPhraseActions.updateUserSearchPhrase(debouncedSearchTerm);
- }, [debouncedSearchTerm]);
-
useEffect(() => {
if (isFocusedScreen) {
return;
@@ -195,6 +186,17 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
return activeParticipants.length >= CONST.SHOULD_SHOW_MEMBERS_SEARCH_INPUT_BREAKPOINT;
}, [participants, personalDetails, isOffline, report]);
+ useEffect(() => {
+ if (!isFocusedScreen || !shouldShowTextInput) {
+ return;
+ }
+ setSearchValue(userSearchPhrase ?? '');
+ }, [isFocusedScreen, shouldShowTextInput, userSearchPhrase]);
+
+ useEffect(() => {
+ UserSearchPhraseActions.updateUserSearchPhrase(searchValue);
+ }, [searchValue]);
+
useEffect(() => {
if (!isFocusedScreen) {
return;
@@ -385,9 +387,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) {
textInputLabel={translate('selectionList.findMember')}
disableKeyboardShortcuts={removeMembersConfirmModalVisible}
textInputValue={searchValue}
- onChangeText={(value) => {
- setSearchValue(value);
- }}
+ onChangeText={setSearchValue}
headerMessage={headerMessage}
turnOnSelectionModeOnLongPress
onTurnOnSelectionMode={(item) => item && toggleUser(item)}
diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx
index 286ec2917de6..ce4daabc983a 100644
--- a/src/pages/Search/AdvancedSearchFilters.tsx
+++ b/src/pages/Search/AdvancedSearchFilters.tsx
@@ -361,7 +361,7 @@ function AdvancedSearchFilters() {
})}
- {displaySearchButton && (
+ {!!displaySearchButton && (
- {ctaErrorMessage && (
+ {!!ctaErrorMessage && (
- {queryJSON && (
+ {!!queryJSON && (
<>
- {queryJSON && (
+ {!!queryJSON && (
);
}
+ const shouldShowSavedSearchesMenuItemTitle = Object.values(savedSearches ?? {}).filter((s) => s.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline).length > 0;
return (
<>
@@ -261,7 +264,7 @@ function SearchTypeMenu({queryJSON, searchName}: SearchTypeMenuProps) {
);
})}
- {savedSearches && Object.keys(savedSearches).length > 0 && (
+ {shouldShowSavedSearchesMenuItemTitle && (
<>
{translate('search.savedSearchesMenuItemTitle')}
{autoAuthStateWithDefault === CONST.AUTO_AUTH_STATE.FAILED && }
- {autoAuthStateWithDefault === CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN && is2FARequired && !isSignedIn && login && }
- {autoAuthStateWithDefault === CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN && isSignedIn && !exitTo && login && }
+ {autoAuthStateWithDefault === CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN && is2FARequired && !isSignedIn && !!login && }
+ {autoAuthStateWithDefault === CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN && isSignedIn && !exitTo && !!login && }
{/* If session.autoAuthState isn't available yet, we use shouldStartSignInWithValidateCode to conditionally render the component instead of local autoAuthState which contains a default value of NOT_STARTED */}
{(!autoAuthState ? !shouldStartSignInWithValidateCode : autoAuthStateWithDefault === CONST.AUTO_AUTH_STATE.NOT_STARTED) && !exitTo && (
);
+ const freeTrialButton = ;
+
const renderAdditionalText = () => {
if (shouldShowSubtitle() || isPersonalExpenseChat || !policyName || !isEmptyObject(parentNavigationSubtitleData) || isSelfDM) {
return null;
@@ -143,6 +146,7 @@ function HeaderView({report, parentReportAction, reportID, onNavigationMenuButto
const isReportInRHP = isReportOpenInRHP(navigationRef?.getRootState());
const shouldDisplaySearchRouter = !isReportInRHP;
+ const isChatUsedForOnboarding = ReportUtils.isChatUsedForOnboarding(report);
return (
- {ReportUtils.isChatUsedForOnboarding(report) && }
+ {!shouldUseNarrowLayout && isChatUsedForOnboarding && freeTrialButton}
{isTaskReport && !shouldUseNarrowLayout && ReportUtils.isOpenTaskReport(report, parentReportAction) && }
{canJoin && !shouldUseNarrowLayout && joinButton}
@@ -304,6 +308,7 @@ function HeaderView({report, parentReportAction, reportID, onNavigationMenuButto
)}
{!isLoading && canJoin && shouldUseNarrowLayout && {joinButton}}
+ {!isLoading && isChatUsedForOnboarding && shouldUseNarrowLayout && {freeTrialButton}}
);
}
diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx
index 8d9d494016a1..4c7c46f7f4af 100644
--- a/src/pages/home/ReportScreen.tsx
+++ b/src/pages/home/ReportScreen.tsx
@@ -4,7 +4,7 @@ import type {StackScreenProps} from '@react-navigation/stack';
import lodashIsEqual from 'lodash/isEqual';
import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import type {FlatList, ViewStyle} from 'react-native';
-import {InteractionManager, View} from 'react-native';
+import {DeviceEventEmitter, InteractionManager, View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {useOnyx} from 'react-native-onyx';
import Banner from '@components/Banner';
@@ -105,6 +105,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
const isFocused = useIsFocused();
const prevIsFocused = usePrevious(isFocused);
const firstRenderRef = useRef(true);
+ const isSkippingOpenReport = useRef(false);
const flatListRef = useRef(null);
const {canUseDefaultRooms} = usePermissions();
const reactionListRef = useRef(null);
@@ -220,6 +221,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
const prevReport = usePrevious(report);
const prevUserLeavingStatus = usePrevious(userLeavingStatus);
+ const lastReportIDFromRoute = usePrevious(reportIDFromRoute);
const [isLinkingToMessage, setIsLinkingToMessage] = useState(!!reportActionIDFromRoute);
const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: (value) => value?.accountID});
@@ -419,6 +421,19 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
Report.updateLastVisitTime(reportID);
}, [reportID, isFocused]);
+ useEffect(() => {
+ const skipOpenReportListener = DeviceEventEmitter.addListener(`switchToPreExistingReport_${reportID}`, ({preexistingReportID}: {preexistingReportID: string}) => {
+ if (!preexistingReportID) {
+ return;
+ }
+ isSkippingOpenReport.current = true;
+ });
+
+ return () => {
+ skipOpenReportListener.remove();
+ };
+ }, [reportID]);
+
const fetchReportIfNeeded = useCallback(() => {
// Report ID will be empty when the reports collection is empty.
// This could happen when we are loading the collection for the first time after logging in.
@@ -441,6 +456,12 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
if (!shouldFetchReport(report) && (isInitialPageReady || isLinkedMessagePageReady)) {
return;
}
+ // When creating an optimistic report that already exists, we need to skip openReport
+ // when replacing the optimistic report with the real one received from the server.
+ if (isSkippingOpenReport.current) {
+ isSkippingOpenReport.current = false;
+ return;
+ }
fetchReport();
}, [report, fetchReport, reportIDFromRoute, isLoadingApp, isInitialPageReady, isLinkedMessagePageReady]);
@@ -578,7 +599,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
// the ReportScreen never actually unmounts and the reportID in the route also doesn't change.
// Therefore, we need to compare if the existing reportID is the same as the one in the route
// before deciding that we shouldn't call OpenReport.
- if (onyxReportID === prevReport?.reportID && (!onyxReportID || onyxReportID === reportIDFromRoute)) {
+ if (reportIDFromRoute === lastReportIDFromRoute && (!onyxReportID || onyxReportID === reportIDFromRoute)) {
return;
}
@@ -598,6 +619,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
prevReport?.chatType,
prevReport,
reportIDFromRoute,
+ lastReportIDFromRoute,
isFocused,
isDeletedParentAction,
prevIsDeletedParentAction,
@@ -733,7 +755,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
needsOffscreenAlphaCompositing
>
{headerView}
- {report && ReportUtils.isTaskReport(report) && shouldUseNarrowLayout && ReportUtils.isOpenTaskReport(report, parentReportAction) && (
+ {!!report && ReportUtils.isTaskReport(report) && shouldUseNarrowLayout && ReportUtils.isOpenTaskReport(report, parentReportAction) && (
@@ -758,7 +780,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
style={[styles.flex1, styles.justifyContentEnd, styles.overflowHidden]}
testID="report-actions-view-wrapper"
>
- {!shouldShowSkeleton && report && (
+ {!shouldShowSkeleton && !!report && (
- {logo && (
+ {!!logo && (
)}
- {publisher && (
+ {!!publisher && (
)}
- {title && url && (
+ {!!title && !!url && (
)}
- {description && {description}}
- {image?.type && IMAGE_TYPES.includes(image.type) && image.width && image.height && (
+ {!!description && {description}}
+ {!!image?.type && IMAGE_TYPES.includes(image.type) && !!image.width && !!image.height && (
{
onTriggerAttachmentPicker();
openPicker({
- onPicked: displayFileInModal,
+ onPicked: (data) => displayFileInModal(data.at(0) ?? {}),
onCanceled: onCanceledAttachmentPicker,
});
};
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
index e63bd952b4ab..9ae2eaa2eaad 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
@@ -14,7 +14,7 @@ import type {
import {DeviceEventEmitter, findNodeHandle, InteractionManager, NativeModules, View} from 'react-native';
import {useFocusedInputHandler} from 'react-native-keyboard-controller';
import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx, withOnyx} from 'react-native-onyx';
import {useAnimatedRef, useSharedValue} from 'react-native-reanimated';
import type {Emoji} from '@assets/emojis/types';
import type {FileObject} from '@components/AttachmentModal';
@@ -298,12 +298,13 @@ function ComposerWithSuggestions(
const {shouldUseNarrowLayout} = useResponsiveLayout();
const maxComposerLines = shouldUseNarrowLayout ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES;
+ const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
const parentReportAction = parentReportActions?.[parentReportActionID ?? '-1'];
const shouldAutoFocus =
!modal?.isVisible &&
Modal.areAllModalsHidden() &&
isFocused &&
- (shouldFocusInputOnScreenFocus || (isEmptyChat && !ReportActionsUtils.isTransactionThread(parentReportAction))) &&
+ (shouldFocusInputOnScreenFocus || (isEmptyChat && !ReportActionsUtils.isTransactionThread(parentReportAction) && !ReportUtils.isTaskReport(report))) &&
shouldShowComposeInput;
const valueRef = useRef(value);
diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
index 7a7230fef333..6a62201058e8 100644
--- a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
+++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
@@ -157,9 +157,9 @@ function SuggestionMention(
useCallback(() => {
const foundSuggestionsCount = suggestionValues.suggestedMentions.length;
if (suggestionValues.prefixType === '#' && foundSuggestionsCount < 5 && isGroupPolicyReport) {
- ReportUserActions.searchInServer(value, policyID);
+ ReportUserActions.searchInServer(suggestionValues.mentionPrefix, policyID);
}
- }, [suggestionValues.suggestedMentions.length, suggestionValues.prefixType, policyID, value, isGroupPolicyReport]),
+ }, [suggestionValues.suggestedMentions.length, suggestionValues.prefixType, suggestionValues.mentionPrefix, policyID, isGroupPolicyReport]),
CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME,
);
diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx
index 819f4e9b3270..88edaf9fb8ef 100644
--- a/src/pages/home/report/ReportActionItem.tsx
+++ b/src/pages/home/report/ReportActionItem.tsx
@@ -965,6 +965,7 @@ function ReportActionItem({
{(hovered) => (
diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx
index 5e1f0a591532..fa8230640c8e 100644
--- a/src/pages/home/report/ReportActionItemSingle.tsx
+++ b/src/pages/home/report/ReportActionItemSingle.tsx
@@ -85,7 +85,9 @@ function ReportActionItemSingle({
const personalDetails = usePersonalDetails() ?? CONST.EMPTY_OBJECT;
const policy = usePolicy(report?.policyID);
const delegatePersonalDetails = personalDetails[action?.delegateAccountID ?? ''];
- const actorAccountID = ReportUtils.getReportActionActorAccountID(action, iouReport);
+ const ownerAccountID = iouReport?.ownerAccountID ?? action?.childOwnerAccountID;
+ const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW;
+ const actorAccountID = ReportUtils.getReportActionActorAccountID(action, iouReport, report);
const [invoiceReceiverPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? report.invoiceReceiver.policyID : -1}`);
let displayName = ReportUtils.getDisplayNameForParticipant(actorAccountID);
@@ -94,20 +96,13 @@ function ReportActionItemSingle({
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
let actorHint = (login || (displayName ?? '')).replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, '');
const isTripRoom = ReportUtils.isTripRoom(report);
- const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW;
const displayAllActors = isReportPreviewAction && !isTripRoom && !ReportUtils.isPolicyExpenseChat(report);
const isInvoiceReport = ReportUtils.isInvoiceReport(iouReport ?? null);
const isWorkspaceActor = isInvoiceReport || (ReportUtils.isPolicyExpenseChat(report) && (!actorAccountID || displayAllActors));
- const ownerAccountID = iouReport?.ownerAccountID ?? action?.childOwnerAccountID;
+
let avatarSource = avatar;
let avatarId: number | string | undefined = actorAccountID;
- if (isReportPreviewAction && ReportUtils.isPolicyExpenseChat(report)) {
- avatarId = ownerAccountID;
- avatarSource = personalDetails[ownerAccountID ?? -1]?.avatar;
- displayName = ReportUtils.getDisplayNameForParticipant(ownerAccountID);
- actorHint = displayName;
- }
if (isWorkspaceActor) {
displayName = ReportUtils.getPolicyName(report, undefined, policy);
actorHint = displayName;
@@ -220,7 +215,7 @@ function ReportActionItemSingle({
}
return (
@@ -271,7 +266,7 @@ function ReportActionItemSingle({
) : null}
- {action?.delegateAccountID && !isReportPreviewAction && (
+ {!!action?.delegateAccountID && !isReportPreviewAction && (
{translate('delegate.onBehalfOfMessage', {delegator: accountOwnerDetails?.displayName ?? ''})}
)}
{children}
diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx
index 0d1f90c0459c..b2a021e7a569 100644
--- a/src/pages/home/report/ReportActionsList.tsx
+++ b/src/pages/home/report/ReportActionsList.tsx
@@ -14,7 +14,8 @@ import {usePersonalDetails} from '@components/OnyxProvider';
import {ReportActionHighlightContext} from '@components/ReportActionHighlightProvider';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
-import useNetwork from '@hooks/useNetwork';
+import useNetworkWithOfflineStatus from '@hooks/useNetworkWithOfflineStatus';
+import usePrevious from '@hooks/usePrevious';
import useReportScrollManager from '@hooks/useReportScrollManager';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -124,14 +125,6 @@ function keyExtractor(item: OnyxTypes.ReportAction): string {
return item.reportActionID;
}
-function isMessageUnread(message: OnyxTypes.ReportAction, lastReadTime?: string): boolean {
- if (!lastReadTime) {
- return !ReportActionsUtils.isCreatedAction(message);
- }
-
- return !!(message && lastReadTime && message.created && lastReadTime < message.created);
-}
-
const onScrollToIndexFailed = () => {};
function ReportActionsList({
@@ -163,7 +156,8 @@ function ReportActionsList({
const {windowHeight} = useWindowDimensions();
const {isInNarrowPaneModal, shouldUseNarrowLayout} = useResponsiveLayout();
- const {isOffline} = useNetwork();
+ const {preferredLocale} = useLocalize();
+ const {isOffline, lastOfflineAt, lastOnlineAt} = useNetworkWithOfflineStatus();
const route = useRoute>();
const reportScrollManager = useReportScrollManager();
const userActiveSince = useRef(DateUtils.getDBTime());
@@ -172,6 +166,8 @@ function ReportActionsList({
const isFocused = useIsFocused();
const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`);
+ const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.accountID});
+
useEffect(() => {
const unsubscriber = Visibility.onVisibilityChange(() => {
setIsVisible(Visibility.isVisible());
@@ -198,6 +194,16 @@ function ReportActionsList({
),
[sortedReportActions, isOffline],
);
+ const lastAction = sortedVisibleReportActions.at(0);
+ const sortedVisibleReportActionsObjects: OnyxTypes.ReportActions = useMemo(
+ () =>
+ sortedVisibleReportActions.reduce((actions, action) => {
+ Object.assign(actions, {[action.reportActionID]: action});
+ return actions;
+ }, {}),
+ [sortedVisibleReportActions],
+ );
+ const prevSortedVisibleReportActionsObjects = usePrevious(sortedVisibleReportActionsObjects);
/**
* The timestamp for the unread marker.
@@ -214,21 +220,82 @@ function ReportActionsList({
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, [report.reportID]);
+ const prevUnreadMarkerReportActionID = useRef(null);
+ /**
+ * Whether a message is NOT from the active user and it was received while the user was offline.
+ */
+ const wasMessageReceivedWhileOffline = useCallback(
+ (message: OnyxTypes.ReportAction) =>
+ !ReportActionsUtils.wasActionTakenByCurrentUser(message) &&
+ ReportActionsUtils.wasActionCreatedWhileOffline(message, isOffline, lastOfflineAt.current, lastOnlineAt.current, preferredLocale),
+ [isOffline, lastOfflineAt, lastOnlineAt, preferredLocale],
+ );
+
+ /**
+ * The index of the earliest message that was received while offline
+ */
+ const earliestReceivedOfflineMessageIndex = useMemo(() => {
+ // Create a list of (sorted) indices of message that were received while offline
+ const receviedOfflineMessages = sortedReportActions.reduce((acc, message, index) => {
+ if (wasMessageReceivedWhileOffline(message)) {
+ acc[index] = index;
+ }
+
+ return acc;
+ }, []);
+
+ // The last index in the list is the earliest message that was received while offline
+ return receviedOfflineMessages.at(-1);
+ }, [sortedReportActions, wasMessageReceivedWhileOffline]);
+
/**
* The reportActionID the unread marker should display above
*/
const unreadMarkerReportActionID = useMemo(() => {
- const shouldDisplayNewMarker = (reportAction: OnyxTypes.ReportAction, index: number): boolean => {
+ const shouldDisplayNewMarker = (message: OnyxTypes.ReportAction, index: number): boolean => {
const nextMessage = sortedVisibleReportActions.at(index + 1);
- const isCurrentMessageUnread = isMessageUnread(reportAction, unreadMarkerTime);
- const isNextMessageRead = !nextMessage || !isMessageUnread(nextMessage, unreadMarkerTime);
- const shouldDisplay = isCurrentMessageUnread && isNextMessageRead && !ReportActionsUtils.shouldHideNewMarker(reportAction);
- const isWithinVisibleThreshold = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? reportAction.created < (userActiveSince.current ?? '') : true;
- return shouldDisplay && isWithinVisibleThreshold;
+ const isNextMessageUnread = !!nextMessage && ReportActionsUtils.isReportActionUnread(nextMessage, unreadMarkerTime);
+
+ // If the current message is the earliest message received while offline, we want to display the unread marker above this message.
+ const isEarliestReceivedOfflineMessage = index === earliestReceivedOfflineMessageIndex;
+ if (isEarliestReceivedOfflineMessage && !isNextMessageUnread) {
+ return true;
+ }
+
+ const isWithinVisibleThreshold = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? message.created < (userActiveSince.current ?? '') : true;
+
+ // If the unread marker should be hidden or is not within the visible area, don't show the unread marker.
+ if (ReportActionsUtils.shouldHideNewMarker(message) || !isWithinVisibleThreshold) {
+ return false;
+ }
+
+ const isCurrentMessageUnread = ReportActionsUtils.isReportActionUnread(message, unreadMarkerTime);
+
+ // If the current message is read or the next message is unread, don't show the unread marker.
+ if (!isCurrentMessageUnread || isNextMessageUnread) {
+ return false;
+ }
+
+ // If no unread marker exists, don't set an unread marker for newly added messages from the current user.
+ const isFromCurrentUser = accountID === (ReportActionsUtils.isReportPreviewAction(message) ? !message.childLastActorAccountID : message.actorAccountID);
+ const isNewMessage = !prevSortedVisibleReportActionsObjects[message.reportActionID];
+
+ // The unread marker will show if the action's `created` time is later than `unreadMarkerTime`.
+ // The `unreadMarkerTime` has already been updated to match the optimistic action created time,
+ // but once the new action is saved on the backend, the actual created time will be later than the optimistic one.
+ // Therefore, we also need to prevent the unread marker from appearing for previously optimistic actions.
+ const isPreviouslyOptimistic = !!prevSortedVisibleReportActionsObjects[message.reportActionID]?.isOptimisticAction && !message.isOptimisticAction;
+ const shouldIgnoreUnreadForCurrentUserMessage = !prevUnreadMarkerReportActionID.current && isFromCurrentUser && (isNewMessage || isPreviouslyOptimistic);
+
+ return !shouldIgnoreUnreadForCurrentUserMessage;
};
+ // If there are message that were recevied while offline,
+ // we can skip checking all messages later than the earliest recevied offline message.
+ const startIndex = earliestReceivedOfflineMessageIndex ?? 0;
+
// Scan through each visible report action until we find the appropriate action to show the unread marker
- for (let index = 0; index < sortedVisibleReportActions.length; index++) {
+ for (let index = startIndex; index < sortedVisibleReportActions.length; index++) {
const reportAction = sortedVisibleReportActions.at(index);
// eslint-disable-next-line react-compiler/react-compiler
@@ -238,7 +305,8 @@ function ReportActionsList({
}
return null;
- }, [sortedVisibleReportActions, unreadMarkerTime]);
+ }, [accountID, earliestReceivedOfflineMessageIndex, prevSortedVisibleReportActionsObjects, sortedVisibleReportActions, unreadMarkerTime]);
+ prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID;
/**
* Subscribe to read/unread events and update our unreadMarkerTime
@@ -269,7 +337,7 @@ function ReportActionsList({
return;
}
- const mostRecentReportActionCreated = sortedVisibleReportActions.at(0)?.created ?? '';
+ const mostRecentReportActionCreated = lastAction?.created ?? '';
if (mostRecentReportActionCreated <= unreadMarkerTime) {
return;
}
@@ -277,7 +345,7 @@ function ReportActionsList({
setUnreadMarkerTime(mostRecentReportActionCreated);
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
- }, [sortedVisibleReportActions]);
+ }, [lastAction?.created]);
const {removeHighlight} = useContext(ReportActionHighlightContext);
@@ -299,13 +367,13 @@ function ReportActionsList({
};
}, [removeHighlight]);
- const lastActionIndex = sortedVisibleReportActions.at(0)?.reportActionID;
+ const lastActionIndex = lastAction?.reportActionID;
const reportActionSize = useRef(sortedVisibleReportActions.length);
const lastVisibleActionCreated =
(transactionThreadReport?.lastVisibleActionCreated ?? '') > (report.lastVisibleActionCreated ?? '')
? transactionThreadReport?.lastVisibleActionCreated
: report.lastVisibleActionCreated;
- const hasNewestReportAction = sortedVisibleReportActions.at(0)?.created === lastVisibleActionCreated;
+ const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated;
const hasNewestReportActionRef = useRef(hasNewestReportAction);
// eslint-disable-next-line react-compiler/react-compiler
hasNewestReportActionRef.current = hasNewestReportAction;
@@ -499,7 +567,7 @@ function ReportActionsList({
if (!isVisible || !isFocused) {
if (!lastMessageTime.current) {
- lastMessageTime.current = sortedVisibleReportActions.at(0)?.created ?? '';
+ lastMessageTime.current = lastAction?.created ?? '';
}
return;
}
diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx
index 7c4ec786b633..c087510374be 100644
--- a/src/pages/home/report/ReportFooter.tsx
+++ b/src/pages/home/report/ReportFooter.tsx
@@ -185,7 +185,7 @@ function ReportFooter({
return (
<>
- {shouldHideComposer && (
+ {!!shouldHideComposer && (
)}
{isArchivedRoom && }
- {!isArchivedRoom && isBlockedFromChat && }
+ {!isArchivedRoom && !!isBlockedFromChat && }
{!isAnonymousUser && !canWriteInReport && isSystemChat && }
{isAdminsOnlyPostingRoom && !isUserPolicyAdmin && !isArchivedRoom && !isAnonymousUser && !isBlockedFromChat && (
{convertToLTR(message ?? '')}
- {fragment?.isEdited && (
+ {!!fragment?.isEdited && (
<>
- {isLoading && optionListItems?.length === 0 && (
+ {!!isLoading && optionListItems?.length === 0 && (
diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
index 942b472734a2..aa3a432a0e5a 100644
--- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
+++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
@@ -145,7 +145,7 @@ function IOURequestStepConfirmation({
const isPolicyExpenseChat = useMemo(() => participants?.some((participant) => participant.isPolicyExpenseChat), [participants]);
const formHasBeenSubmitted = useRef(false);
- useFetchRoute(transaction, transaction?.comment?.waypoints, action);
+ useFetchRoute(transaction, transaction?.comment?.waypoints, action, IOUUtils.shouldUseTransactionDraft(action) ? CONST.TRANSACTION.STATE.DRAFT : CONST.TRANSACTION.STATE.CURRENT);
useEffect(() => {
const policyExpenseChat = participants?.find((participant) => participant.isPolicyExpenseChat);
@@ -624,7 +624,7 @@ function IOURequestStepConfirmation({
]}
/>
{isLoading && }
- {gpsRequired && (
+ {!!gpsRequired && (
setStartLocationPermissionFlow(false)}
diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx
index 899f024de4be..03cffc1ec1dd 100644
--- a/src/pages/iou/request/step/IOURequestStepDistance.tsx
+++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx
@@ -20,6 +20,7 @@ import useNetwork from '@hooks/useNetwork';
import usePolicy from '@hooks/usePolicy';
import usePrevious from '@hooks/usePrevious';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as Report from '@libs/actions/Report';
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
import type {MileageRate} from '@libs/DistanceRequestUtils';
import * as ErrorUtils from '@libs/ErrorUtils';
@@ -78,7 +79,18 @@ function IOURequestStepDistance({
},
[optimisticWaypoints, transaction],
);
- const {shouldFetchRoute, validatedWaypoints} = useFetchRoute(transaction, waypoints, action);
+
+ const backupWaypoints = transactionBackup?.pendingFields?.waypoints ? transactionBackup?.comment?.waypoints : undefined;
+ // When online, fetch the backup route to ensure the map is populated even if the user does not save the transaction.
+ // Fetch the backup route first to ensure the backup transaction map is updated before the main transaction map.
+ // This prevents a scenario where the main map loads, the user dismisses the map editor, and the backup map has not yet loaded due to delay.
+ useFetchRoute(transactionBackup, backupWaypoints, action, CONST.TRANSACTION.STATE.BACKUP);
+ const {shouldFetchRoute, validatedWaypoints} = useFetchRoute(
+ transaction,
+ waypoints,
+ action,
+ IOUUtils.shouldUseTransactionDraft(action) ? CONST.TRANSACTION.STATE.DRAFT : CONST.TRANSACTION.STATE.CURRENT,
+ );
const waypointsList = Object.keys(waypoints);
const previousWaypoints = usePrevious(waypoints);
const numberOfWaypoints = Object.keys(waypoints).length;
@@ -213,6 +225,12 @@ function IOURequestStepDistance({
return;
}
TransactionEdit.restoreOriginalTransactionFromBackup(transaction?.transactionID ?? '-1', IOUUtils.shouldUseTransactionDraft(action));
+
+ // If the user opens IOURequestStepDistance in offline mode and then goes online, re-open the report to fill in missing fields from the transaction backup
+ if (!transaction?.reportID) {
+ return;
+ }
+ Report.openReport(transaction?.reportID);
};
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, []);
diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx
index c3fec439e042..34e77b19d078 100644
--- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx
+++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx
@@ -183,7 +183,7 @@ function IOURequestStepParticipants({
testID={IOURequestStepParticipants.displayName}
includeSafeAreaPaddingBottom={false}
>
- {skipConfirmation && (
+ {!!skipConfirmation && (
{isLoadingReceipt && }
- {pdfFile && (
+ {!!pdfFile && (
{
openPicker({
- onPicked: setReceiptAndNavigate,
+ onPicked: (data) => setReceiptAndNavigate(data.at(0) ?? {}),
});
}}
>
@@ -680,7 +680,7 @@ function IOURequestStepScan({
)}
- {startLocationPermissionFlow && fileResize && (
+ {startLocationPermissionFlow && !!fileResize && (
setStartLocationPermissionFlow(false)}
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx
index 1f71f174dd23..4ed956e5ce7e 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx
@@ -666,7 +666,7 @@ function IOURequestStepScan({
role={CONST.ROLE.BUTTON}
onPress={() => {
openPicker({
- onPicked: setReceiptAndNavigate,
+ onPicked: (data) => setReceiptAndNavigate(data.at(0) ?? {}),
});
}}
>
@@ -743,7 +743,7 @@ function IOURequestStepScan({
style={[styles.p9]}
onPress={() => {
openPicker({
- onPicked: setReceiptAndNavigate,
+ onPicked: (data) => setReceiptAndNavigate(data.at(0) ?? {}),
});
}}
/>
@@ -783,7 +783,7 @@ function IOURequestStepScan({
confirmText={translate('common.close')}
shouldShowCancelButton={false}
/>
- {startLocationPermissionFlow && fileResize && (
+ {startLocationPermissionFlow && !!fileResize && (
setStartLocationPermissionFlow(false)}
diff --git a/src/pages/iou/request/step/IOURequestStepTag.tsx b/src/pages/iou/request/step/IOURequestStepTag.tsx
index 90731e732d50..6c999d7a7f70 100644
--- a/src/pages/iou/request/step/IOURequestStepTag.tsx
+++ b/src/pages/iou/request/step/IOURequestStepTag.tsx
@@ -131,7 +131,7 @@ function IOURequestStepTag({
)}
)}
- {shouldShowTag && (
+ {!!shouldShowTag && (
<>
{translate('iou.tagSelection')} {
- if (!el) {
- return;
- }
- if (!inputRef.current) {
- updateMultilineInputRange(el);
- }
- inputCallbackRef(el);
- }}
+ ref={inputCallbackRef}
containerStyles={[baseResponseInputContainerStyle]}
shouldSaveDraft
shouldSubmitForm
diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx
index d4455b54ffcb..8c1d68e0a95b 100755
--- a/src/pages/settings/InitialSettingsPage.tsx
+++ b/src/pages/settings/InitialSettingsPage.tsx
@@ -36,6 +36,7 @@ import * as UserUtils from '@libs/UserUtils';
import {hasGlobalWorkspaceSettingsRBR} from '@libs/WorkspacesSettingsUtils';
import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu';
import variables from '@styles/variables';
+import * as App from '@userActions/App';
import * as Link from '@userActions/Link';
import * as PaymentMethods from '@userActions/PaymentMethods';
import * as Session from '@userActions/Session';
@@ -80,6 +81,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr
const [walletTerms] = useOnyx(ONYXKEYS.WALLET_TERMS);
const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST);
const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
+ const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS);
const network = useNetwork();
const theme = useTheme();
@@ -102,6 +104,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr
useEffect(() => {
Wallet.openInitialSettingsPage();
+ App.confirmReadyToOpenApp();
}, []);
const toggleSignoutConfirmModal = (value: boolean) => {
@@ -126,7 +129,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr
* @returns object with translationKey, style and items for the account section
*/
const accountMenuItemsData: Menu = useMemo(() => {
- const profileBrickRoadIndicator = UserUtils.getLoginListBrickRoadIndicator(loginList);
+ const profileBrickRoadIndicator = UserUtils.getProfilePageBrickRoadIndicator(loginList, privatePersonalDetails);
const paymentCardList = fundList;
const defaultMenu: Menu = {
sectionStyle: styles.accountSettingsSectionContainer,
@@ -161,7 +164,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr
};
return defaultMenu;
- }, [loginList, fundList, styles.accountSettingsSectionContainer, bankAccountList, userWallet?.errors, walletTerms?.errors]);
+ }, [loginList, fundList, styles.accountSettingsSectionContainer, bankAccountList, userWallet?.errors, walletTerms?.errors, privatePersonalDetails]);
/**
* Retuns a list of menu items data for workspace section
diff --git a/src/pages/settings/Preferences/PreferencesPage.tsx b/src/pages/settings/Preferences/PreferencesPage.tsx
index f2c5f0366640..5dee30518533 100755
--- a/src/pages/settings/Preferences/PreferencesPage.tsx
+++ b/src/pages/settings/Preferences/PreferencesPage.tsx
@@ -13,6 +13,8 @@ import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as Browser from '@libs/Browser';
+import getPlatform from '@libs/getPlatform';
import LocaleUtils from '@libs/LocaleUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as User from '@userActions/User';
@@ -22,6 +24,10 @@ import ROUTES from '@src/ROUTES';
function PreferencesPage() {
const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE);
+
+ const platform = Browser.isMobile() ? CONST.PLATFORM.MOBILEWEB : getPlatform();
+ const [mutedPlatforms = {}] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS);
+ const isPlatformMuted = mutedPlatforms[platform];
const [user] = useOnyx(ONYXKEYS.USER);
const [preferredTheme] = useOnyx(ONYXKEYS.PREFERRED_THEME);
@@ -79,8 +85,8 @@ function PreferencesPage() {
User.togglePlatformMute(platform, mutedPlatforms)}
/>
diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx
index bd0151cda4ea..dc21701f65fe 100644
--- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx
+++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx
@@ -135,14 +135,6 @@ function ContactMethodDetailsPage({route}: ContactMethodDetailsPageProps) {
User.deleteContactMethod(contactMethod, loginList ?? {}, backTo);
}, [contactMethod, loginList, toggleDeleteModal, backTo]);
- const sendValidateCode = () => {
- if (loginData?.validateCodeSent) {
- return;
- }
-
- User.requestContactMethodValidateCode(contactMethod);
- };
-
const prevValidatedDate = usePrevious(loginData?.validatedDate);
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
@@ -276,7 +268,7 @@ function ContactMethodDetailsPage({route}: ContactMethodDetailsPageProps) {
Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(backTo));
setIsValidateCodeActionModalVisible(false);
}}
- sendValidateCode={sendValidateCode}
+ sendValidateCode={() => User.requestContactMethodValidateCode(contactMethod)}
description={translate('contacts.enterMagicCode', {contactMethod})}
footer={() => getMenuItems()}
/>
diff --git a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx
index 6c6d4268eccd..92a246949c53 100644
--- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx
+++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx
@@ -41,9 +41,9 @@ function ContactMethodsPage({route}: ContactMethodsPageProps) {
// Sort the login names by placing the one corresponding to the default contact method as the first item before displaying the contact methods.
// The default contact method is determined by checking against the session email (the current login).
const sortedLoginNames = loginNames.sort((loginName) => (loginList?.[loginName].partnerUserID === session?.email ? -1 : 1));
-
const loginMenuItems = sortedLoginNames.map((loginName) => {
const login = loginList?.[loginName];
+ const isDefaultContactMethod = session?.email === login?.partnerUserID;
const pendingAction = login?.pendingFields?.deletedLogin ?? login?.pendingFields?.addedLogin ?? undefined;
if (!login?.partnerUserID && !pendingAction) {
return null;
@@ -60,7 +60,9 @@ function ContactMethodsPage({route}: ContactMethodsPageProps) {
let indicator;
if (Object.values(login?.errorFields ?? {}).some((errorField) => !isEmptyObject(errorField))) {
indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
- } else if (!login?.validatedDate) {
+ } else if (!login?.validatedDate && !isDefaultContactMethod) {
+ indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO;
+ } else if (!login?.validatedDate && isDefaultContactMethod && loginNames.length > 1) {
indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO;
}
diff --git a/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx
index 124d6525113b..c2a7e1b6712c 100644
--- a/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx
+++ b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx
@@ -109,14 +109,6 @@ function NewContactMethodPage({route}: NewContactMethodPageProps) {
Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(navigateBackTo));
}, [navigateBackTo]);
- const sendValidateCode = () => {
- if (loginData?.validateCodeSent) {
- return;
- }
-
- User.requestValidateCodeAction();
- };
-
return (
User.requestValidateCodeAction()}
description={translate('contacts.enterMagicCode', {contactMethod})}
/>
diff --git a/src/pages/settings/Profile/PersonalDetails/PhoneNumberPage.tsx b/src/pages/settings/Profile/PersonalDetails/PhoneNumberPage.tsx
new file mode 100644
index 000000000000..12c6f011c6a2
--- /dev/null
+++ b/src/pages/settings/Profile/PersonalDetails/PhoneNumberPage.tsx
@@ -0,0 +1,121 @@
+import {Str} from 'expensify-common';
+import React, {useCallback} from 'react';
+import {useOnyx} from 'react-native-onyx';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
+import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
+import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import OfflineWithFeedback from '@components/OfflineWithFeedback';
+import ScreenWrapper from '@components/ScreenWrapper';
+import TextInput from '@components/TextInput';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as ErrorUtils from '@libs/ErrorUtils';
+import * as LoginUtils from '@libs/LoginUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import * as PhoneNumberUtils from '@libs/PhoneNumber';
+import * as ValidationUtils from '@libs/ValidationUtils';
+import * as PersonalDetails from '@userActions/PersonalDetails';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import INPUT_IDS from '@src/types/form/PersonalDetailsForm';
+import type {PrivatePersonalDetails} from '@src/types/onyx';
+
+function PhoneNumberPage() {
+ const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS);
+ const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {initialValue: true});
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const phoneNumber = privatePersonalDetails?.phoneNumber ?? '';
+
+ const validateLoginError = ErrorUtils.getEarliestErrorField(privatePersonalDetails, 'phoneNumber');
+ const currenPhoneNumber = privatePersonalDetails?.phoneNumber ?? '';
+
+ const updatePhoneNumber = (values: PrivatePersonalDetails) => {
+ // Clear the error when the user tries to submit the form
+ if (validateLoginError) {
+ PersonalDetails.clearPhoneNumberError();
+ }
+
+ // Only call the API if the user has changed their phone number
+ if (phoneNumber !== values?.phoneNumber) {
+ PersonalDetails.updatePhoneNumber(values?.phoneNumber ?? '', currenPhoneNumber);
+ }
+
+ Navigation.goBack();
+ };
+
+ const validate = useCallback(
+ (values: FormOnyxValues): FormInputErrors => {
+ const errors: FormInputErrors = {};
+ if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.PHONE_NUMBER])) {
+ errors[INPUT_IDS.PHONE_NUMBER] = translate('common.error.fieldRequired');
+ }
+ const phoneNumberWithCountryCode = LoginUtils.appendCountryCode(values[INPUT_IDS.PHONE_NUMBER]);
+ const parsedPhoneNumber = PhoneNumberUtils.parsePhoneNumber(phoneNumberWithCountryCode);
+ if (!parsedPhoneNumber.possible || !Str.isValidE164Phone(phoneNumberWithCountryCode.slice(0))) {
+ errors[INPUT_IDS.PHONE_NUMBER] = translate('bankAccount.error.phoneNumber');
+ }
+
+ // Clear the error when the user tries to validate the form and there are errors
+ if (validateLoginError && !!errors) {
+ PersonalDetails.clearPhoneNumberError();
+ }
+ return errors;
+ },
+ [translate, validateLoginError],
+ );
+
+ return (
+
+ Navigation.goBack()}
+ />
+ {isLoadingApp ? (
+
+ ) : (
+
+ PersonalDetails.clearPhoneNumberError()}
+ >
+ {
+ if (!validateLoginError) {
+ return;
+ }
+ PersonalDetails.clearPhoneNumberError();
+ }}
+ />
+
+
+ )}
+
+ );
+}
+
+PhoneNumberPage.displayName = 'PhoneNumberPage';
+
+export default PhoneNumberPage;
diff --git a/src/pages/settings/Profile/ProfilePage.tsx b/src/pages/settings/Profile/ProfilePage.tsx
index f42be1385ecf..faa399598d05 100755
--- a/src/pages/settings/Profile/ProfilePage.tsx
+++ b/src/pages/settings/Profile/ProfilePage.tsx
@@ -96,6 +96,12 @@ function ProfilePage() {
title: privateDetails.dob ?? '',
pageRoute: ROUTES.SETTINGS_DATE_OF_BIRTH,
},
+ {
+ description: translate('common.phoneNumber'),
+ title: privateDetails.phoneNumber ?? '',
+ pageRoute: ROUTES.SETTINGS_PHONE_NUMBER,
+ brickRoadIndicator: privatePersonalDetails?.errorFields?.phoneNumber ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined,
+ },
{
description: translate('privatePersonalDetails.address'),
title: PersonalDetailsUtils.getFormattedAddress(privateDetails),
@@ -195,6 +201,7 @@ function ProfilePage() {
description={detail.description}
wrapperStyle={styles.sectionMenuItemTopDescription}
onPress={() => Navigation.navigate(detail.pageRoute)}
+ brickRoadIndicator={detail.brickRoadIndicator}
/>
))}
>
diff --git a/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx b/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx
index ea9a1da690e8..8dd3996a769a 100644
--- a/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx
+++ b/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx
@@ -73,20 +73,18 @@ function ConfirmDelegatePage({route}: ConfirmDelegatePageProps) {
onPress={() => Navigation.navigate(ROUTES.SETTINGS_DELEGATE_ROLE.getRoute(login, role))}
shouldShowRightIcon
/>
-
- {isValidateCodeActionModalVisible && (
- {
- if (!showValidateActionModal) {
- return;
- }
-
- Navigation.navigate(ROUTES.SETTINGS_SECURITY);
- }}
- />
- )}
+ {
+ if (!showValidateActionModal) {
+ setIsValidateCodeActionModalVisible(false);
+ return;
+ }
+ Navigation.navigate(ROUTES.SETTINGS_SECURITY);
+ }}
+ isValidateCodeActionModalVisible={isValidateCodeActionModalVisible}
+ />
);
}
diff --git a/src/pages/settings/Security/AddDelegate/DelegateMagicCodeModal.tsx b/src/pages/settings/Security/AddDelegate/DelegateMagicCodeModal.tsx
index dd54aa5b9404..4f6770bd98ff 100644
--- a/src/pages/settings/Security/AddDelegate/DelegateMagicCodeModal.tsx
+++ b/src/pages/settings/Security/AddDelegate/DelegateMagicCodeModal.tsx
@@ -1,4 +1,4 @@
-import React, {useEffect, useState} from 'react';
+import React, {useEffect} from 'react';
import {useOnyx} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import ValidateCodeActionModal from '@components/ValidateCodeActionModal';
@@ -14,13 +14,13 @@ import ROUTES from '@src/ROUTES';
type DelegateMagicCodeModalProps = {
login: string;
role: ValueOf;
+ isValidateCodeActionModalVisible: boolean;
onClose?: () => void;
};
-function DelegateMagicCodeModal({login, role, onClose}: DelegateMagicCodeModalProps) {
+function DelegateMagicCodeModal({login, role, onClose, isValidateCodeActionModalVisible}: DelegateMagicCodeModalProps) {
const {translate} = useLocalize();
const [account] = useOnyx(ONYXKEYS.ACCOUNT);
- const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(true);
const currentDelegate = account?.delegatedAccess?.delegates?.find((d) => d.email === login);
const validateLoginError = ErrorUtils.getLatestErrorField(currentDelegate, 'addDelegate');
@@ -36,7 +36,6 @@ function DelegateMagicCodeModal({login, role, onClose}: DelegateMagicCodeModalPr
const onBackButtonPress = () => {
onClose?.();
- setIsValidateCodeActionModalVisible(false);
};
const clearError = () => {
@@ -46,14 +45,6 @@ function DelegateMagicCodeModal({login, role, onClose}: DelegateMagicCodeModalPr
Delegate.clearAddDelegateErrors(currentDelegate?.email ?? '', 'addDelegate');
};
- const sendValidateCode = () => {
- if (currentDelegate?.validateCodeSent) {
- return;
- }
-
- User.requestValidateCodeAction();
- };
-
return (
User.requestValidateCodeAction()}
+ hasMagicCodeBeenSent={!!currentDelegate?.validateCodeSent}
handleSubmitForm={(validateCode) => Delegate.addDelegate(login, role, validateCode)}
description={translate('delegate.enterMagicCode', {contactMethod: account?.primaryLogin ?? ''})}
/>
diff --git a/src/pages/settings/Security/SecuritySettingsPage.tsx b/src/pages/settings/Security/SecuritySettingsPage.tsx
index 42352594db7a..ec82000d06c8 100644
--- a/src/pages/settings/Security/SecuritySettingsPage.tsx
+++ b/src/pages/settings/Security/SecuritySettingsPage.tsx
@@ -236,7 +236,7 @@ function SecuritySettingsPage() {
shouldUseSingleExecution
/>
- {canUseNewDotCopilot && (
+ {!!canUseNewDotCopilot && (
TwoFactorAuthActions.quitAndNavigateBack(backTo)}
>
- {isUserValidated && (
+ {!!isUserValidated && (
}
- {account?.isEligibleForRefund && (
+ {!!account?.isEligibleForRefund && (
;
+ pressable?: boolean;
};
-function FreeTrialBadge({badgeStyles}: FreeTrialBadgeProps) {
+function FreeTrial({badgeStyles, pressable = false}: FreeTrialProps) {
const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
const [firstDayFreeTrial] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL);
const [lastDayFreeTrial] = useOnyx(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL);
@@ -30,7 +35,14 @@ function FreeTrialBadge({badgeStyles}: FreeTrialBadgeProps) {
return null;
}
- return (
+ return pressable ? (
+
-
+
);
}
diff --git a/src/pages/workspace/expensifyCard/issueNew/LimitStep.tsx b/src/pages/workspace/expensifyCard/issueNew/LimitStep.tsx
index eb7c2e7d8e0f..6c59489ad89b 100644
--- a/src/pages/workspace/expensifyCard/issueNew/LimitStep.tsx
+++ b/src/pages/workspace/expensifyCard/issueNew/LimitStep.tsx
@@ -1,13 +1,10 @@
import React, {useCallback} from 'react';
-import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import AmountForm from '@components/AmountForm';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
-import ScreenWrapper from '@components/ScreenWrapper';
+import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
import Text from '@components/Text';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
@@ -51,8 +48,14 @@ function LimitStep() {
const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.LIMIT]);
// We only want integers to be sent as the limit
- if (!Number(values.limit) || !Number.isInteger(Number(values.limit))) {
+ if (!Number(values.limit)) {
errors.limit = translate('iou.error.invalidAmount');
+ } else if (!Number.isInteger(Number(values.limit))) {
+ errors.limit = translate('iou.error.invalidIntegerAmount');
+ }
+
+ if (Number(values.limit) > CONST.EXPENSIFY_CARD.LIMIT_VALUE) {
+ errors.limit = translate('workspace.card.issueNewCard.cardLimitError');
}
return errors;
},
@@ -60,22 +63,15 @@ function LimitStep() {
);
return (
-
-
-
-
- {translate('workspace.card.issueNewCard.setLimit')}
-
+
);
}
diff --git a/src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx
index 5dff1e9b5109..a593fb4c75c7 100644
--- a/src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx
+++ b/src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx
@@ -1,11 +1,8 @@
import React, {useCallback, useMemo, useState} from 'react';
-import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
-import ScreenWrapper from '@components/ScreenWrapper';
+import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
import SelectionList from '@components/SelectionList';
import RadioListItem from '@components/SelectionList/RadioListItem';
import Text from '@components/Text';
@@ -84,22 +81,15 @@ function LimitTypeStep({policy}: LimitTypeStepProps) {
}, [areApprovalsConfigured, translate, typeSelected]);
return (
-
-
-
-
- {translate('workspace.card.issueNewCard.chooseLimitType')}
-
+
);
}
diff --git a/src/pages/workspace/invoices/WorkspaceInvoicesPage.tsx b/src/pages/workspace/invoices/WorkspaceInvoicesPage.tsx
index 4f14950ec93d..0697ac0750cd 100644
--- a/src/pages/workspace/invoices/WorkspaceInvoicesPage.tsx
+++ b/src/pages/workspace/invoices/WorkspaceInvoicesPage.tsx
@@ -37,9 +37,9 @@ function WorkspaceInvoicesPage({route}: WorkspaceInvoicesPageProps) {
>
{(_hasVBA?: boolean, policyID?: string) => (
- {policyID && }
- {policyID && }
- {policyID && }
+ {!!policyID && }
+ {!!policyID && }
+ {!!policyID && }
)}
diff --git a/src/pages/workspace/invoices/WorkspaceInvoicingDetailsName.tsx b/src/pages/workspace/invoices/WorkspaceInvoicingDetailsName.tsx
index 80f323431baa..2b933a4ab695 100644
--- a/src/pages/workspace/invoices/WorkspaceInvoicingDetailsName.tsx
+++ b/src/pages/workspace/invoices/WorkspaceInvoicingDetailsName.tsx
@@ -14,6 +14,7 @@ import * as ValidationUtils from '@libs/ValidationUtils';
import Navigation from '@navigation/Navigation';
import type {SettingsNavigatorParamList} from '@navigation/types';
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
+import * as Policy from '@userActions/Policy/Policy';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
@@ -31,7 +32,7 @@ function WorkspaceInvoicingDetailsName({route}: WorkspaceInvoicingDetailsNamePro
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const submit = (values: FormOnyxValues) => {
- // TODO: implement UpdateInvoiceCompanyName API call when it's supported
+ Policy.updateInvoiceCompanyName(policyID, values[INPUT_IDS.COMPANY_NAME]);
Navigation.goBack();
};
diff --git a/src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx b/src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx
index 0427aef81db3..cd2f559da3fa 100644
--- a/src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx
+++ b/src/pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite.tsx
@@ -16,6 +16,7 @@ import * as ValidationUtils from '@libs/ValidationUtils';
import Navigation from '@navigation/Navigation';
import type {SettingsNavigatorParamList} from '@navigation/types';
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
+import * as Policy from '@userActions/Policy/Policy';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
@@ -33,7 +34,7 @@ function WorkspaceInvoicingDetailsWebsite({route}: WorkspaceInvoicingDetailsWebs
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const submit = (values: FormOnyxValues) => {
- // TODO: implement UpdateInvoiceCompanyWebsite API call when it's supported
+ Policy.updateInvoiceCompanyWebsite(policyID, values[INPUT_IDS.COMPANY_WEBSITE]);
Navigation.goBack();
};
diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx
index 8f5c9b2994cd..6d865df7280b 100644
--- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx
+++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx
@@ -61,7 +61,8 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM
const {translate} = useLocalize();
const StyleUtils = useStyleUtils();
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
- const [allCardsList] = useOnyx(`${ONYXKEYS.CARD_LIST}`);
+ const [cards] = useOnyx(`${ONYXKEYS.CARD_LIST}`);
+ const [expensifyCards] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`);
const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`);
const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}`);
@@ -89,11 +90,22 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM
}, [policyID, workspaceAccountID]);
const memberCards = useMemo(() => {
- if (!allCardsList) {
+ if (!cards && !expensifyCards) {
return [];
}
- return Object.values(allCardsList ?? {}).filter((card) => card.accountID === accountID && workspaceAccountID.toString() === card.fundID);
- }, [allCardsList, accountID, workspaceAccountID]);
+ // For admin Expensify Cards can also appear in the cards list, so we need to remove duplicates
+ const allCards = [...Object.values(cards ?? {}), ...Object.values(expensifyCards ?? {})];
+ const cardIDs = new Set();
+ const uniqueObjects = allCards.filter((obj) => {
+ if (cardIDs.has(obj.cardID)) {
+ return false;
+ }
+ cardIDs.add(obj.cardID);
+ return true;
+ });
+
+ return Object.values(uniqueObjects ?? {}).filter((card) => card.accountID === accountID && workspaceAccountID.toString() === card.fundID);
+ }, [accountID, workspaceAccountID, cards, expensifyCards]);
const confirmModalPrompt = useMemo(() => {
const isApprover = Member.isApprover(policy, accountID);
@@ -303,7 +315,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM
{translate('walletPage.assignedCards')}
- {memberCards.map((memberCard) => (
+ {(memberCards as MemberCard[]).map((memberCard) => (
{
if (!selectedFeed) {
@@ -78,7 +79,7 @@ function WorkspaceMemberNewCardPage({route, personalDetails}: WorkspaceMemberNew
setShouldShowError(false);
};
- const companyCardFeeds: CardFeedListItem[] = (Object.keys(cardFeeds?.settings?.companyCards ?? {}) as CompanyCardFeed[]).map((key) => ({
+ const companyCardFeeds: CardFeedListItem[] = (Object.keys(availableCompanyCards) as CompanyCardFeed[]).map((key) => ({
value: key,
text: cardFeeds?.settings?.companyCardNicknames?.[key] ?? CardUtils.getCardFeedName(key),
keyForList: key,
diff --git a/src/pages/workspace/members/WorkspaceOwnerChangeWrapperPage.tsx b/src/pages/workspace/members/WorkspaceOwnerChangeWrapperPage.tsx
index 325e1112ab4b..c6a37c668c1a 100644
--- a/src/pages/workspace/members/WorkspaceOwnerChangeWrapperPage.tsx
+++ b/src/pages/workspace/members/WorkspaceOwnerChangeWrapperPage.tsx
@@ -75,7 +75,7 @@ function WorkspaceOwnerChangeWrapperPage({route, policy}: WorkspaceOwnerChangeWr
}}
/>
- {policy?.isLoading && }
+ {!!policy?.isLoading && }
{shouldShowPaymentCardForm && }
{!policy?.isLoading && !shouldShowPaymentCardForm && (
{
- const header = (
-
- {translate('common.name')}
- {translate('statusPage.status')}
-
+ return (
+
);
- if (canSelectMultiple) {
- return header;
- }
- return {header};
};
const getHeaderButtons = () => {
diff --git a/src/pages/workspace/rules/PolicyRulesPage.tsx b/src/pages/workspace/rules/PolicyRulesPage.tsx
index 1fce2fb7bfcf..bda7870c2186 100644
--- a/src/pages/workspace/rules/PolicyRulesPage.tsx
+++ b/src/pages/workspace/rules/PolicyRulesPage.tsx
@@ -2,7 +2,6 @@ import type {StackScreenProps} from '@react-navigation/stack';
import React from 'react';
import {View} from 'react-native';
import useLocalize from '@hooks/useLocalize';
-import usePermissions from '@hooks/usePermissions';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import type {FullScreenNavigatorParamList} from '@libs/Navigation/types';
@@ -21,7 +20,6 @@ function PolicyRulesPage({route}: PolicyRulesPageProps) {
const {policyID} = route.params;
const styles = useThemeStyles();
const {shouldUseNarrowLayout} = useResponsiveLayout();
- const {canUseWorkspaceRules} = usePermissions();
return (
diff --git a/src/pages/workspace/tags/TagSettingsPage.tsx b/src/pages/workspace/tags/TagSettingsPage.tsx
index 0461024b3f13..8fcecfd48ef6 100644
--- a/src/pages/workspace/tags/TagSettingsPage.tsx
+++ b/src/pages/workspace/tags/TagSettingsPage.tsx
@@ -174,7 +174,7 @@ function TagSettingsPage({route, navigation}: TagSettingsPageProps) {
/>
- {policy?.areRulesEnabled && canUseCategoryAndTagApprovers && !isMultiLevelTags && (
+ {!!policy?.areRulesEnabled && !!canUseCategoryAndTagApprovers && !isMultiLevelTags && (
<>
{translate('workspace.tags.tagRules')}
diff --git a/src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx
index 3fadba088648..566d7d3910ff 100644
--- a/src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx
+++ b/src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx
@@ -12,7 +12,6 @@ import Switch from '@components/Switch';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
-import usePermissions from '@hooks/usePermissions';
import useThemeStyles from '@hooks/useThemeStyles';
import * as Tag from '@libs/actions/Policy/Tag';
import Navigation from '@libs/Navigation/Navigation';
@@ -59,7 +58,6 @@ function WorkspaceTagsSettingsPage({route}: WorkspaceTagsSettingsPageProps) {
const isLoading = !PolicyUtils.getTagLists(policyTags)?.at(0) || Object.keys(policyTags ?? {}).at(0) === 'undefined';
const {isOffline} = useNetwork();
const hasEnabledOptions = OptionsListUtils.hasEnabledOptions(Object.values(policyTags ?? {}).flatMap(({tags}) => Object.values(tags)));
- const {canUseWorkspaceRules} = usePermissions();
const updateWorkspaceRequiresTag = useCallback(
(value: boolean) => {
Tag.setPolicyRequiresTag(policyID, value);
@@ -106,7 +104,7 @@ function WorkspaceTagsSettingsPage({route}: WorkspaceTagsSettingsPageProps) {
/>
- {canUseWorkspaceRules && policy?.areRulesEnabled && (
+ {!!policy?.areRulesEnabled && (
{translate('workspace.tags.trackBillable')}
diff --git a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx
index cec800f2e562..4db8033c1c11 100644
--- a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx
+++ b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx
@@ -14,13 +14,12 @@ import ScreenWrapper from '@components/ScreenWrapper';
import ListItemRightCaretWithLabel from '@components/SelectionList/ListItemRightCaretWithLabel';
import TableListItem from '@components/SelectionList/TableListItem';
import SelectionListWithModal from '@components/SelectionListWithModal';
-import Text from '@components/Text';
+import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader';
import useLocalize from '@hooks/useLocalize';
import useMobileSelectionMode from '@hooks/useMobileSelectionMode';
import useNetwork from '@hooks/useNetwork';
import usePolicy from '@hooks/usePolicy';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
-import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
@@ -47,7 +46,6 @@ function WorkspaceViewTagsPage({route}: WorkspaceViewTagsProps) {
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout();
const styles = useThemeStyles();
- const StyleUtils = useStyleUtils();
const theme = useTheme();
const {translate} = useLocalize();
const [selectedTags, setSelectedTags] = useState>({});
@@ -128,20 +126,13 @@ function WorkspaceViewTagsPage({route}: WorkspaceViewTagsProps) {
};
const getCustomListHeader = () => {
- const header = (
-
-
- {translate('common.name')}
-
-
- {translate('statusPage.status')}
-
-
+ return (
+
);
- if (canSelectMultiple) {
- return header;
- }
- return {header};
};
const navigateToTagSettings = (tag: TagListItem) => {
@@ -273,7 +264,7 @@ function WorkspaceViewTagsPage({route}: WorkspaceViewTagsProps) {
turnOffMobileSelectionMode();
return;
}
- Navigation.goBack(isQuickSettingsFlow ? ROUTES.SETTINGS_TAGS_ROOT.getRoute(policyID) : ROUTES.WORKSPACE_TAGS.getRoute(policyID));
+ Navigation.goBack(isQuickSettingsFlow ? ROUTES.SETTINGS_TAGS_ROOT.getRoute(policyID) : undefined);
}}
>
{!shouldUseNarrowLayout && getHeaderButtons()}
diff --git a/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx b/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx
index 0f50ed8fe0b8..f10e0fba84a2 100644
--- a/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx
+++ b/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx
@@ -158,7 +158,7 @@ function WorkspaceEditTaxPage({
}}
/>
- {shouldShowDeleteMenuItem && (
+ {!!shouldShowDeleteMenuItem && (
{
@@ -97,7 +105,6 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) {
title={translate('common.upgrade')}
onBackButtonPress={() => {
if (isUpgraded) {
- Navigation.dismissModal();
goBack();
} else {
Navigation.goBack();
@@ -106,10 +113,7 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) {
/>
{isUpgraded && (
{
- Navigation.dismissModal();
- goBack();
- }}
+ onConfirmUpgrade={goBack}
policyName={policy.name}
/>
)}
diff --git a/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx b/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx
index 4ebd61929504..5241b6671e26 100644
--- a/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx
+++ b/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx
@@ -182,7 +182,7 @@ function ApprovalWorkflowEditor({approvalWorkflow, removeApprovalWorkflow, polic
brickRoadIndicator={approvalWorkflow?.errors?.additionalApprover ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
/>
- {removeApprovalWorkflow && !approvalWorkflow.isDefault && (
+ {!!removeApprovalWorkflow && !approvalWorkflow.isDefault && (
Navigation.goBack(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVER.getRoute(route.params.policyID, 0))}
/>
- {approvalWorkflow && (
+ {!!approvalWorkflow && (
<>
{
formRef.current?.scrollTo({y: 0, animated: true});
}}
- isLoading={approvalWorkflow?.isLoading}
buttonText={translate('workflowsCreateApprovalsPage.submitButton')}
containerStyles={[styles.mb5, styles.mh5]}
enabledWhenOffline
diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx
index c2da4e39739a..d67dd564057c 100644
--- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx
+++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx
@@ -107,7 +107,6 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true
availableMembers: [...currentApprovalWorkflow.members, ...defaultWorkflowMembers],
usedApproverEmails,
action: CONST.APPROVAL_WORKFLOW.ACTION.EDIT,
- isLoading: false,
errors: null,
});
setInitialApprovalWorkflow(currentApprovalWorkflow);
@@ -132,7 +131,7 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true
title={translate('workflowsEditApprovalsPage.title')}
onBackButtonPress={Navigation.goBack}
/>
- {approvalWorkflow && (
+ {!!approvalWorkflow && (
<>
{
formRef.current?.scrollTo({y: 0, animated: true});
}}
- isLoading={approvalWorkflow?.isLoading}
buttonText={translate('common.save')}
containerStyles={[styles.mb5, styles.mh5]}
enabledWhenOffline
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 5b04a7a8eace..a8e58b047658 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -3008,6 +3008,13 @@ const styles = (theme: ThemeColors) =>
...spacing.mv3,
},
+ sectionDividerLine: {
+ height: 1,
+ backgroundColor: theme.border,
+ ...spacing.mh5,
+ ...spacing.mv6,
+ },
+
unreadIndicatorText: {
color: theme.unreadIndicator,
...FontUtils.fontFamily.platform.EXP_NEUE_BOLD,
diff --git a/src/styles/utils/generators/ModalStyleUtils.ts b/src/styles/utils/generators/ModalStyleUtils.ts
index 7f51b7727d6a..4b5ce5b28988 100644
--- a/src/styles/utils/generators/ModalStyleUtils.ts
+++ b/src/styles/utils/generators/ModalStyleUtils.ts
@@ -116,6 +116,36 @@ const createModalStyleUtils: StyleUtilGenerator = ({the
shouldAddTopSafeAreaPadding = isSmallScreenWidth;
shouldAddBottomSafeAreaPadding = false;
break;
+ case CONST.MODAL.MODAL_TYPE.CENTERED_SWIPABLE_TO_RIGHT:
+ // A centered modal is one that has a visible backdrop
+ // and can be dismissed by clicking outside of the modal.
+ // This modal should take up the entire visible area when
+ // viewed on a smaller device (e.g. mobile or mobile web).
+ modalStyle = {
+ ...modalStyle,
+ ...{
+ alignItems: 'center',
+ },
+ };
+ modalContainerStyle = {
+ boxShadow: '0px 0px 5px 5px rgba(0, 0, 0, 0.1)',
+ flex: 1,
+ marginTop: isSmallScreenWidth ? 0 : 20,
+ marginBottom: isSmallScreenWidth ? 0 : 20,
+ borderRadius: isSmallScreenWidth ? 0 : variables.componentBorderRadiusLarge,
+ overflow: 'hidden',
+ ...getCenteredModalStyles(styles, windowWidth, isSmallScreenWidth),
+ };
+
+ // Allow this modal to be dismissed with a swipe to the right, required when we want to have a list in centered modal
+ swipeDirection = ['right'];
+ animationIn = isSmallScreenWidth ? 'slideInRight' : 'fadeIn';
+ animationOut = isSmallScreenWidth ? 'slideOutRight' : 'fadeOut';
+ shouldAddTopSafeAreaMargin = !isSmallScreenWidth;
+ shouldAddBottomSafeAreaMargin = !isSmallScreenWidth;
+ shouldAddTopSafeAreaPadding = isSmallScreenWidth;
+ shouldAddBottomSafeAreaPadding = false;
+ break;
case CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE:
// A centered modal that cannot be dismissed with a swipe.
modalStyle = {
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 4e9bf50e3c8e..d647f3433446 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -75,6 +75,7 @@ export default {
iconSizeXXSmall: 8,
iconSizeXSmall: 10,
iconSizeExtraSmall: 12,
+ iconSizeSemiSmall: 14,
iconSizeSmall: 16,
iconSizeMedium: 18,
iconSizeNormal: 20,
@@ -215,6 +216,7 @@ export default {
restrictedActionIllustrationHeight: 136,
photoUploadPopoverWidth: 335,
onboardingModalWidth: 500,
+ fontSizeToWidthRatio: getValueUsingPixelRatio(0.8, 1),
// The height of the empty list is 14px (2px for borders and 12px for vertical padding)
// This is calculated based on the values specified in the 'getGoogleListViewStyle' function of the 'StyleUtils' utility
diff --git a/src/types/form/NetSuiteCustomFieldForm.ts b/src/types/form/NetSuiteCustomFieldForm.ts
index efbf844bd89f..6f0685c5f027 100644
--- a/src/types/form/NetSuiteCustomFieldForm.ts
+++ b/src/types/form/NetSuiteCustomFieldForm.ts
@@ -6,6 +6,7 @@ const INPUT_IDS = {
INTERNAL_ID: 'internalID',
MAPPING: 'mapping',
LIST_NAME: 'listName',
+ SEGMENT_TYPE: 'segmentType',
SEGMENT_NAME: 'segmentName',
TRANSACTION_FIELD_ID: 'transactionFieldID',
SCRIPT_ID: 'scriptID',
@@ -19,6 +20,7 @@ type NetSuiteCustomFieldForm = Form<
[INPUT_IDS.INTERNAL_ID]: string;
[INPUT_IDS.MAPPING]: NetSuiteCustomFieldMapping;
[INPUT_IDS.LIST_NAME]: string;
+ [INPUT_IDS.SEGMENT_TYPE]: string;
[INPUT_IDS.SEGMENT_NAME]: string;
[INPUT_IDS.TRANSACTION_FIELD_ID]: string;
[INPUT_IDS.SCRIPT_ID]: string;
diff --git a/src/types/form/ReimbursementAccountForm.ts b/src/types/form/ReimbursementAccountForm.ts
index c422d3ea7ce4..1d480b993e6a 100644
--- a/src/types/form/ReimbursementAccountForm.ts
+++ b/src/types/form/ReimbursementAccountForm.ts
@@ -1,3 +1,4 @@
+import type {Country} from '@src/CONST';
import type DeepValueOf from '@src/types/utils/DeepValueOf';
import type Form from './Form';
@@ -50,6 +51,9 @@ const INPUT_IDS = {
AMOUNT1: 'amount1',
AMOUNT2: 'amount2',
AMOUNT3: 'amount3',
+ ADDITIONAL_DATA: {
+ COUNTRY: 'country',
+ },
} as const;
type InputID = DeepValueOf;
@@ -121,8 +125,23 @@ type ReimbursementAccountProps = {
[INPUT_IDS.AMOUNT3]: string;
};
+/** Additional props for non-USD reimbursement account */
+type NonUSDReimbursementAccountAdditionalProps = {
+ /** Country of the reimbursement account */
+ [INPUT_IDS.ADDITIONAL_DATA.COUNTRY]: Country | '';
+};
+
type ReimbursementAccountForm = ReimbursementAccountFormExtraProps &
- Form;
+ Form<
+ InputID,
+ BeneficialOwnersStepBaseProps &
+ BankAccountStepProps &
+ CompanyStepProps &
+ RequestorStepProps &
+ ACHContractStepProps &
+ ReimbursementAccountProps &
+ NonUSDReimbursementAccountAdditionalProps
+ >;
export type {
ReimbursementAccountForm,
@@ -133,6 +152,7 @@ export type {
BeneficialOwnersStepProps,
ACHContractStepProps,
ReimbursementAccountProps,
+ NonUSDReimbursementAccountAdditionalProps,
InputID,
};
export default INPUT_IDS;
diff --git a/src/types/onyx/ApprovalWorkflow.ts b/src/types/onyx/ApprovalWorkflow.ts
index bafb1c78a8de..8c3f7ed555f0 100644
--- a/src/types/onyx/ApprovalWorkflow.ts
+++ b/src/types/onyx/ApprovalWorkflow.ts
@@ -100,11 +100,6 @@ type ApprovalWorkflowOnyx = Omit & {
*/
action: ValueOf;
- /**
- * Whether we are waiting for the API action to complete
- */
- isLoading: boolean;
-
/**
* List of available members that can be selected in the workflow
*/
diff --git a/src/types/onyx/CardFeeds.ts b/src/types/onyx/CardFeeds.ts
index 10938f710b5c..ce06ed076a15 100644
--- a/src/types/onyx/CardFeeds.ts
+++ b/src/types/onyx/CardFeeds.ts
@@ -48,6 +48,21 @@ type CardFeeds = {
/** Company cards feeds */
companyCards: Record;
+
+ /** Account details */
+ oAuthAccountDetails: Record<
+ ValueOf,
+ {
+ /** List of accounts */
+ accountList: string[];
+
+ /** Credentials info */
+ credentials: string;
+
+ /** Expiration number */
+ expiration: number;
+ }
+ >;
};
/** Whether we are loading the data via the API */
diff --git a/src/types/onyx/CardOnWaitlist.ts b/src/types/onyx/CardOnWaitlist.ts
new file mode 100644
index 000000000000..ba09374996de
--- /dev/null
+++ b/src/types/onyx/CardOnWaitlist.ts
@@ -0,0 +1,25 @@
+/** Card on waitlist data model */
+type CardOnWaitlist = {
+ /** Whether the user uses the bank account on another domain */
+ bankAccountIsNotOn0therDomain: boolean;
+
+ /** Domain name in "+@expensify-policy.exfy" format */
+ domainName: string;
+
+ /** Whether the user has a balance checked */
+ hasBalanceBeenChecked: boolean;
+
+ /** Whether the user has a verified account */
+ hasVerifiedAccount: boolean;
+
+ /** Whether the user has a withdrawal account */
+ hasWithdrawalAccount: string;
+
+ /** Whether the user is a member of a private domain */
+ isMember0fPrivateDomain: boolean;
+
+ /** Whether the account passed the latest checks */
+ passedLatestChecks: boolean;
+};
+
+export default CardOnWaitlist;
diff --git a/src/types/onyx/ExpensifyCardSettings.ts b/src/types/onyx/ExpensifyCardSettings.ts
index 05c6667e7c05..2b91ddcfa525 100644
--- a/src/types/onyx/ExpensifyCardSettings.ts
+++ b/src/types/onyx/ExpensifyCardSettings.ts
@@ -22,6 +22,12 @@ type ExpensifyCardSettings = OnyxCommon.OnyxValueWithOfflineFeedback<{
/** Whether we are loading the data via the API */
isLoading?: boolean;
+
+ /** Error message */
+ errors?: OnyxCommon.Errors;
+
+ /** Whether the request was successful */
+ isSuccess?: boolean;
}>;
export default ExpensifyCardSettings;
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
index d6c49e9f8b36..ecc5bd1f6606 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -784,7 +784,7 @@ type NetSuiteExportDateOptions = 'SUBMITTED' | 'EXPORTED' | 'LAST_EXPENSE';
type NetSuiteJournalPostingPreferences = 'JOURNALS_POSTING_TOTAL_LINE' | 'JOURNALS_POSTING_INDIVIDUAL_LINE';
/** NetSuite custom segment/records and custom lists mapping values */
-type NetSuiteCustomFieldMapping = 'TAG' | 'REPORT_FIELD';
+type NetSuiteCustomFieldMapping = 'TAG' | 'REPORT_FIELD' | '';
/** The custom form selection options for transactions (any one will be used at most) */
type NetSuiteCustomFormIDOptions = {
diff --git a/src/types/onyx/PrivatePersonalDetails.ts b/src/types/onyx/PrivatePersonalDetails.ts
index 1635c610badf..1d88cd3af1ff 100644
--- a/src/types/onyx/PrivatePersonalDetails.ts
+++ b/src/types/onyx/PrivatePersonalDetails.ts
@@ -1,4 +1,5 @@
import type {Country} from '@src/CONST';
+import type * as OnyxCommon from './OnyxCommon';
/** User address data */
type Address = {
@@ -64,6 +65,9 @@ type PrivatePersonalDetails = {
/** User's home address history. The most recent address is the last item in the array */
addresses?: Address[];
+
+ /** Error objects keyed by field name containing errors keyed by microtime */
+ errorFields?: OnyxCommon.ErrorFields;
};
export default PrivatePersonalDetails;
diff --git a/src/types/onyx/ReimbursementAccount.ts b/src/types/onyx/ReimbursementAccount.ts
index 0d5c8a83b99b..ad348c5ad390 100644
--- a/src/types/onyx/ReimbursementAccount.ts
+++ b/src/types/onyx/ReimbursementAccount.ts
@@ -1,6 +1,8 @@
import type {ValueOf} from 'type-fest';
import type CONST from '@src/CONST';
+import type {Country} from '@src/CONST';
import type {ACHContractStepProps, BeneficialOwnersStepProps, CompanyStepProps, ReimbursementAccountProps, RequestorStepProps} from '@src/types/form/ReimbursementAccountForm';
+import type INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
import type {BankName} from './Bank';
import type * as OnyxCommon from './OnyxCommon';
@@ -10,6 +12,12 @@ type BankAccountStep = ValueOf;
/** Substeps to setup a reimbursement bank account */
type BankAccountSubStep = ValueOf;
+/** Additional data where details of the non-USD reimbursements account are stored */
+type AdditionalData = {
+ /** Country of the reimbursement account */
+ [INPUT_IDS.ADDITIONAL_DATA.COUNTRY]: Country | '';
+};
+
/** Model of ACH data */
type ACHData = Partial & {
/** Step of the setup flow that we are on. Determines which view is presented. */
@@ -50,6 +58,9 @@ type ACHData = Partial;
+
+export default TransactionStateType;
diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts
index c4327158b562..c279079b995b 100644
--- a/tests/actions/ReportTest.ts
+++ b/tests/actions/ReportTest.ts
@@ -1,9 +1,13 @@
/* eslint-disable @typescript-eslint/naming-convention */
import {afterEach, beforeAll, beforeEach, describe, expect, it} from '@jest/globals';
+import {addSeconds, format, subMinutes} from 'date-fns';
import {toZonedTime} from 'date-fns-tz';
+import type {Mock} from 'jest-mock';
import Onyx from 'react-native-onyx';
import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import {WRITE_COMMANDS} from '@libs/API/types';
+import * as EmojiUtils from '@libs/EmojiUtils';
+import HttpUtils from '@libs/HttpUtils';
import CONST from '@src/CONST';
import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager';
import * as PersistedRequests from '@src/libs/actions/PersistedRequests';
@@ -36,7 +40,7 @@ jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({
didScreenTransitionEnd: true,
}),
}));
-
+const originalXHR = HttpUtils.xhr;
OnyxUpdateManager();
describe('actions/Report', () => {
beforeAll(() => {
@@ -47,16 +51,21 @@ describe('actions/Report', () => {
});
beforeEach(() => {
+ HttpUtils.xhr = originalXHR;
const promise = Onyx.clear().then(jest.useRealTimers);
if (getIsUsingFakeTimers()) {
// flushing pending timers
// Onyx.clear() promise is resolved in batch which happends after the current microtasks cycle
setImmediate(jest.runOnlyPendingTimers);
}
+
return promise;
});
- afterEach(PusherHelper.teardown);
+ afterEach(() => {
+ jest.clearAllMocks();
+ PusherHelper.teardown();
+ });
it('should store a new report action in Onyx when onyxApiUpdate event is handled via Pusher', () => {
global.fetch = TestHelper.getGlobalFetchMock();
@@ -759,7 +768,7 @@ describe('actions/Report', () => {
});
});
- it.only('should send only one OpenReport, replacing any extra ones with same reportIDs', async () => {
+ it('should send only one OpenReport, replacing any extra ones with same reportIDs', async () => {
global.fetch = TestHelper.getGlobalFetchMock();
const REPORT_ID = '1';
@@ -782,7 +791,7 @@ describe('actions/Report', () => {
TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1);
});
- it.only('should replace duplicate OpenReport commands with the same reportID', async () => {
+ it('should replace duplicate OpenReport commands with the same reportID', async () => {
global.fetch = TestHelper.getGlobalFetchMock();
const REPORT_ID = '1';
@@ -809,6 +818,574 @@ describe('actions/Report', () => {
TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 4);
});
+ it('should remove AddComment and UpdateComment without sending any request when DeleteComment is set', async () => {
+ global.fetch = TestHelper.getGlobalFetchMock();
+
+ const TEST_USER_ACCOUNT_ID = 1;
+ const REPORT_ID = '1';
+ const TEN_MINUTES_AGO = subMinutes(new Date(), 10);
+ const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING);
+
+ Onyx.set(ONYXKEYS.NETWORK, {isOffline: true});
+
+ Report.addComment(REPORT_ID, 'Testing a comment');
+ // Need the reportActionID to delete the comments
+ const newComment = PersistedRequests.getAll().at(0);
+ const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1';
+ const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID);
+ Report.editReportComment(REPORT_ID, reportAction, 'Testing an edited comment');
+
+ await waitForBatchedUpdates();
+
+ await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: ONYXKEYS.PERSISTED_REQUESTS,
+ callback: (persistedRequests) => {
+ Onyx.disconnect(connection);
+
+ expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_COMMENT);
+ expect(persistedRequests?.at(1)?.command).toBeUndefined();
+
+ resolve();
+ },
+ });
+ });
+
+ // Checking the Report Action exists before deleting it
+ await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`,
+ callback: (reportActions) => {
+ Onyx.disconnect(connection);
+
+ expect(reportActions?.[reportActionID]).not.toBeNull();
+ expect(reportActions?.[reportActionID].reportActionID).toBe(reportActionID);
+ resolve();
+ },
+ });
+ });
+
+ Report.deleteReportComment(REPORT_ID, reportAction);
+
+ await waitForBatchedUpdates();
+ expect(PersistedRequests.getAll().length).toBe(0);
+
+ // Checking the Report Action doesn't exist after deleting it
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`,
+ callback: (reportActions) => {
+ Onyx.disconnect(connection);
+ expect(reportActions?.[reportActionID]).toBeUndefined();
+ },
+ });
+
+ Onyx.set(ONYXKEYS.NETWORK, {isOffline: false});
+ await waitForBatchedUpdates();
+
+ // Checking no requests were or will be made
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 0);
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.UPDATE_COMMENT, 0);
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 0);
+ });
+
+ it('should send DeleteComment request and remove UpdateComment accordingly', async () => {
+ global.fetch = TestHelper.getGlobalFetchMock();
+
+ const TEST_USER_ACCOUNT_ID = 1;
+ const REPORT_ID = '1';
+ const TEN_MINUTES_AGO = subMinutes(new Date(), 10);
+ const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING);
+
+ await Onyx.set(ONYXKEYS.NETWORK, {isOffline: false});
+
+ Report.addComment(REPORT_ID, 'Testing a comment');
+
+ // Need the reportActionID to delete the comments
+ const newComment = PersistedRequests.getAll().at(1);
+ const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1';
+ const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID);
+
+ await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true});
+
+ Report.editReportComment(REPORT_ID, reportAction, 'Testing an edited comment');
+
+ await waitForBatchedUpdates();
+
+ await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: ONYXKEYS.PERSISTED_REQUESTS,
+ callback: (persistedRequests) => {
+ Onyx.disconnect(connection);
+ expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.UPDATE_COMMENT);
+ resolve();
+ },
+ });
+ });
+
+ Report.deleteReportComment(REPORT_ID, reportAction);
+
+ await waitForBatchedUpdates();
+ expect(PersistedRequests.getAll().length).toBe(1);
+
+ Onyx.set(ONYXKEYS.NETWORK, {isOffline: false});
+ await waitForBatchedUpdates();
+
+ // Checking no requests were or will be made
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1);
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.UPDATE_COMMENT, 0);
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 1);
+ });
+
+ it('should send DeleteComment request after AddComment is rollbacked', async () => {
+ global.fetch = jest.fn().mockRejectedValue(new TypeError(CONST.ERROR.FAILED_TO_FETCH));
+
+ const mockedXhr = jest.fn();
+ mockedXhr
+ .mockImplementationOnce(originalXHR)
+ .mockImplementationOnce(() =>
+ Promise.resolve({
+ jsonCode: CONST.JSON_CODE.EXP_ERROR,
+ }),
+ )
+ .mockImplementation(() =>
+ Promise.resolve({
+ jsonCode: CONST.JSON_CODE.SUCCESS,
+ }),
+ );
+
+ HttpUtils.xhr = mockedXhr;
+ await waitForBatchedUpdates();
+ const TEST_USER_ACCOUNT_ID = 1;
+ const REPORT_ID = '1';
+ const TEN_MINUTES_AGO = subMinutes(new Date(), 10);
+ const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING);
+
+ Report.addComment(REPORT_ID, 'Testing a comment');
+ await waitForNetworkPromises();
+
+ const newComment = PersistedRequests.getAll().at(1);
+ const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1';
+ const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID);
+
+ await waitForBatchedUpdates();
+
+ expect(PersistedRequests.getAll().length).toBe(1);
+ expect(PersistedRequests.getAll().at(0)?.isRollbacked).toBeTruthy();
+ Report.deleteReportComment(REPORT_ID, reportAction);
+
+ jest.runOnlyPendingTimers();
+ await waitForBatchedUpdates();
+
+ const httpCalls = (HttpUtils.xhr as Mock).mock.calls;
+
+ const addCommentCalls = httpCalls.filter(([command]) => command === 'AddComment');
+ const deleteCommentCalls = httpCalls.filter(([command]) => command === 'DeleteComment');
+
+ if (httpCalls.length === 3) {
+ expect(addCommentCalls).toHaveLength(2);
+ expect(deleteCommentCalls).toHaveLength(1);
+ }
+ });
+
+ it('should send not DeleteComment request and remove AddAttachment accordingly', async () => {
+ global.fetch = TestHelper.getGlobalFetchMock();
+
+ const TEST_USER_ACCOUNT_ID = 1;
+ const REPORT_ID = '1';
+ const TEN_MINUTES_AGO = subMinutes(new Date(), 10);
+ const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING);
+
+ await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true});
+
+ const file = new File([''], 'test.txt', {type: 'text/plain'});
+ Report.addAttachment(REPORT_ID, file);
+
+ // Need the reportActionID to delete the comments
+ const newComment = PersistedRequests.getAll().at(0);
+ const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1';
+ const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID);
+
+ // wait for Onyx.connect execute the callback and start processing the queue
+ await waitForBatchedUpdates();
+
+ await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: ONYXKEYS.PERSISTED_REQUESTS,
+ callback: (persistedRequests) => {
+ Onyx.disconnect(connection);
+ expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_ATTACHMENT);
+ resolve();
+ },
+ });
+ });
+
+ // Checking the Report Action exists before deleting it
+ await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`,
+ callback: (reportActions) => {
+ Onyx.disconnect(connection);
+ expect(reportActions?.[reportActionID]).not.toBeNull();
+ expect(reportActions?.[reportActionID].reportActionID).toBe(reportActionID);
+ resolve();
+ },
+ });
+ });
+
+ Report.deleteReportComment(REPORT_ID, reportAction);
+
+ await waitForBatchedUpdates();
+ expect(PersistedRequests.getAll().length).toBe(0);
+
+ // Checking the Report Action doesn't exist after deleting it
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`,
+ callback: (reportActions) => {
+ Onyx.disconnect(connection);
+ expect(reportActions?.[reportActionID]).toBeUndefined();
+ },
+ });
+
+ Onyx.set(ONYXKEYS.NETWORK, {isOffline: false});
+ await waitForBatchedUpdates();
+
+ // Checking no requests were or will be made
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_ATTACHMENT, 0);
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 0);
+ });
+
+ it('should send not DeleteComment request and remove AddTextAndAttachment accordingly', async () => {
+ global.fetch = TestHelper.getGlobalFetchMock();
+
+ const TEST_USER_ACCOUNT_ID = 1;
+ const REPORT_ID = '1';
+ const TEN_MINUTES_AGO = subMinutes(new Date(), 10);
+ const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING);
+ const file = new File([''], 'test.txt', {type: 'text/plain'});
+
+ await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true});
+
+ Report.addAttachment(REPORT_ID, file, 'Attachment with comment');
+
+ // Need the reportActionID to delete the comments
+ const newComment = PersistedRequests.getAll().at(0);
+ const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1';
+ const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID);
+
+ // wait for Onyx.connect execute the callback and start processing the queue
+ await waitForBatchedUpdates();
+
+ await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: ONYXKEYS.PERSISTED_REQUESTS,
+ callback: (persistedRequests) => {
+ Onyx.disconnect(connection);
+ expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT);
+ resolve();
+ },
+ });
+ });
+
+ // Checking the Report Action exists before deleting it
+ await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`,
+ callback: (reportActions) => {
+ Onyx.disconnect(connection);
+ expect(reportActions?.[reportActionID]).not.toBeNull();
+ expect(reportActions?.[reportActionID].reportActionID).toBe(reportActionID);
+ resolve();
+ },
+ });
+ });
+
+ Report.deleteReportComment(REPORT_ID, reportAction);
+
+ await waitForBatchedUpdates();
+ expect(PersistedRequests.getAll().length).toBe(0);
+
+ // Checking the Report Action doesn't exist after deleting it
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`,
+ callback: (reportActions) => {
+ Onyx.disconnect(connection);
+ expect(reportActions?.[reportActionID]).toBeUndefined();
+ },
+ });
+
+ Onyx.set(ONYXKEYS.NETWORK, {isOffline: false});
+ await waitForBatchedUpdates();
+
+ // Checking no requests were or will be made
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT, 0);
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 0);
+ });
+
+ it('should not send DeleteComment request and remove any Reactions accordingly', async () => {
+ global.fetch = TestHelper.getGlobalFetchMock();
+ jest.spyOn(EmojiUtils, 'hasAccountIDEmojiReacted').mockImplementation(() => true);
+ const TEST_USER_ACCOUNT_ID = 1;
+ const REPORT_ID = '1';
+ const TEN_MINUTES_AGO = subMinutes(new Date(), 10);
+ const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING);
+
+ await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true});
+ await Promise.resolve();
+
+ Report.addComment(REPORT_ID, 'reactions with comment');
+ // Need the reportActionID to delete the comments
+ const newComment = PersistedRequests.getAll().at(0);
+ const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1';
+ const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID);
+
+ await waitForBatchedUpdates();
+
+ Report.toggleEmojiReaction(REPORT_ID, reportAction, {name: 'smile', code: '😄'}, {});
+ Report.toggleEmojiReaction(
+ REPORT_ID,
+ reportAction,
+ {name: 'smile', code: '😄'},
+ {
+ smile: {
+ createdAt: '2024-10-14 14:58:12',
+ oldestTimestamp: '2024-10-14 14:58:12',
+ users: {
+ [`${TEST_USER_ACCOUNT_ID}`]: {
+ id: `${TEST_USER_ACCOUNT_ID}`,
+ oldestTimestamp: '2024-10-14 14:58:12',
+ skinTones: {
+ '-1': '2024-10-14 14:58:12',
+ },
+ },
+ },
+ },
+ },
+ );
+
+ await waitForBatchedUpdates();
+
+ await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: ONYXKEYS.PERSISTED_REQUESTS,
+ callback: (persistedRequests) => {
+ Onyx.disconnect(connection);
+ expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_COMMENT);
+ expect(persistedRequests?.at(1)?.command).toBe(WRITE_COMMANDS.ADD_EMOJI_REACTION);
+ expect(persistedRequests?.at(2)?.command).toBe(WRITE_COMMANDS.REMOVE_EMOJI_REACTION);
+ resolve();
+ },
+ });
+ });
+
+ // Checking the Report Action exists before deleting it
+ await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`,
+ callback: (reportActions) => {
+ Onyx.disconnect(connection);
+ expect(reportActions?.[reportActionID]).not.toBeNull();
+ expect(reportActions?.[reportActionID].reportActionID).toBe(reportActionID);
+ resolve();
+ },
+ });
+ });
+
+ Report.deleteReportComment(REPORT_ID, reportAction);
+
+ await waitForBatchedUpdates();
+ expect(PersistedRequests.getAll().length).toBe(0);
+
+ // Checking the Report Action doesn't exist after deleting it
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`,
+ callback: (reportActions) => {
+ Onyx.disconnect(connection);
+ expect(reportActions?.[reportActionID]).toBeUndefined();
+ },
+ });
+
+ Onyx.set(ONYXKEYS.NETWORK, {isOffline: false});
+ await waitForBatchedUpdates();
+
+ // Checking no requests were or will be made
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 0);
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_EMOJI_REACTION, 0);
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.REMOVE_EMOJI_REACTION, 0);
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 0);
+ });
+
+ it('should send DeleteComment request and remove any Reactions accordingly', async () => {
+ global.fetch = TestHelper.getGlobalFetchMock();
+ jest.spyOn(EmojiUtils, 'hasAccountIDEmojiReacted').mockImplementation(() => true);
+ const TEST_USER_ACCOUNT_ID = 1;
+ const REPORT_ID = '1';
+ const TEN_MINUTES_AGO = subMinutes(new Date(), 10);
+ const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING);
+
+ Report.addComment(REPORT_ID, 'Attachment with comment');
+
+ // Need the reportActionID to delete the comments
+ const newComment = PersistedRequests.getAll().at(0);
+ const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1';
+ const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID);
+ await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true});
+
+ // wait for Onyx.connect execute the callback and start processing the queue
+ await Promise.resolve();
+
+ Report.toggleEmojiReaction(REPORT_ID, reportAction, {name: 'smile', code: '😄'}, {});
+ Report.toggleEmojiReaction(
+ REPORT_ID,
+ reportAction,
+ {name: 'smile', code: '😄'},
+ {
+ smile: {
+ createdAt: '2024-10-14 14:58:12',
+ oldestTimestamp: '2024-10-14 14:58:12',
+ users: {
+ [`${TEST_USER_ACCOUNT_ID}`]: {
+ id: `${TEST_USER_ACCOUNT_ID}`,
+ oldestTimestamp: '2024-10-14 14:58:12',
+ skinTones: {
+ '-1': '2024-10-14 14:58:12',
+ },
+ },
+ },
+ },
+ },
+ );
+
+ await waitForBatchedUpdates();
+ await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: ONYXKEYS.PERSISTED_REQUESTS,
+ callback: (persistedRequests) => {
+ Onyx.disconnect(connection);
+ expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_EMOJI_REACTION);
+ expect(persistedRequests?.at(1)?.command).toBe(WRITE_COMMANDS.REMOVE_EMOJI_REACTION);
+ resolve();
+ },
+ });
+ });
+
+ Report.deleteReportComment(REPORT_ID, reportAction);
+
+ await waitForBatchedUpdates();
+ expect(PersistedRequests.getAll().length).toBe(1);
+
+ Onyx.set(ONYXKEYS.NETWORK, {isOffline: false});
+ await waitForBatchedUpdates();
+
+ // Checking no requests were or will be made
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1);
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_EMOJI_REACTION, 0);
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.REMOVE_EMOJI_REACTION, 0);
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 1);
+ });
+
+ it('should create and delete thread processing all the requests', async () => {
+ global.fetch = TestHelper.getGlobalFetchMock();
+
+ const TEST_USER_ACCOUNT_ID = 1;
+ const REPORT_ID = '1';
+ const TEN_MINUTES_AGO = subMinutes(new Date(), 10);
+ const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING);
+
+ await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true});
+ await waitForBatchedUpdates();
+
+ Report.addComment(REPORT_ID, 'Testing a comment');
+
+ const newComment = PersistedRequests.getAll().at(0);
+ const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1';
+ const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID);
+
+ Report.openReport(
+ REPORT_ID,
+ undefined,
+ ['test@user.com'],
+ {
+ isOptimisticReport: true,
+ parentReportID: REPORT_ID,
+ parentReportActionID: reportActionID,
+ reportID: '2',
+ },
+ reportActionID,
+ );
+
+ Report.deleteReportComment(REPORT_ID, reportAction);
+
+ expect(PersistedRequests.getAll().length).toBe(3);
+
+ await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: ONYXKEYS.PERSISTED_REQUESTS,
+ callback: (persistedRequests) => {
+ if (persistedRequests?.length !== 3) {
+ return;
+ }
+ Onyx.disconnect(connection);
+
+ expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_COMMENT);
+ expect(persistedRequests?.at(1)?.command).toBe(WRITE_COMMANDS.OPEN_REPORT);
+ expect(persistedRequests?.at(2)?.command).toBe(WRITE_COMMANDS.DELETE_COMMENT);
+ resolve();
+ },
+ });
+ });
+
+ Onyx.set(ONYXKEYS.NETWORK, {isOffline: false});
+ await waitForBatchedUpdates();
+
+ // Checking no requests were or will be made
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1);
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1);
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 1);
+ });
+
+ it('should update AddComment text with the UpdateComment text, sending just an AddComment request', async () => {
+ global.fetch = TestHelper.getGlobalFetchMock();
+
+ const TEST_USER_ACCOUNT_ID = 1;
+ const REPORT_ID = '1';
+ const TEN_MINUTES_AGO = subMinutes(new Date(), 10);
+ const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING);
+
+ Onyx.set(ONYXKEYS.NETWORK, {isOffline: true});
+
+ Report.addComment(REPORT_ID, 'Testing a comment');
+ // Need the reportActionID to delete the comments
+ const newComment = PersistedRequests.getAll().at(0);
+ const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1';
+ const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID);
+ Report.editReportComment(REPORT_ID, reportAction, 'Testing an edited comment');
+
+ await waitForBatchedUpdates();
+
+ await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: ONYXKEYS.PERSISTED_REQUESTS,
+ callback: (persistedRequests) => {
+ Onyx.disconnect(connection);
+
+ expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_COMMENT);
+
+ resolve();
+ },
+ });
+ });
+
+ await waitForBatchedUpdates();
+ expect(PersistedRequests.getAll().length).toBe(1);
+
+ Onyx.set(ONYXKEYS.NETWORK, {isOffline: false});
+ await waitForBatchedUpdates();
+
+ // Checking no requests were or will be made
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1);
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.UPDATE_COMMENT, 0);
+ });
+
it('it should only send the last sequential UpdateComment request to BE', async () => {
global.fetch = TestHelper.getGlobalFetchMock();
const reportID = '123';
diff --git a/tests/e2e/compare/compare.ts b/tests/e2e/compare/compare.ts
index 40a728545668..ad38c249bff3 100644
--- a/tests/e2e/compare/compare.ts
+++ b/tests/e2e/compare/compare.ts
@@ -91,16 +91,23 @@ function compareResults(baselineEntries: Metric | string, compareEntries: Metric
};
}
-export default (main: Metric | string, delta: Metric | string, outputFile: string, outputFormat = 'all', metricForTest = {}) => {
+type Options = {
+ outputFile: string;
+ outputFormat: 'console' | 'markdown' | 'all';
+ metricForTest: Record;
+ skippedTests: string[];
+};
+
+export default (main: Metric | string, delta: Metric | string, {outputFile, outputFormat = 'all', metricForTest = {}, skippedTests}: Options) => {
// IMPORTANT NOTE: make sure you are passing the main/baseline results first, then the delta/compare results:
const outputData = compareResults(main, delta, metricForTest);
if (outputFormat === 'console' || outputFormat === 'all') {
- printToConsole(outputData);
+ printToConsole(outputData, skippedTests);
}
if (outputFormat === 'markdown' || outputFormat === 'all') {
- return writeToMarkdown(outputFile, outputData);
+ return writeToMarkdown(outputFile, outputData, skippedTests);
}
};
export {compareResults};
diff --git a/tests/e2e/compare/output/console.ts b/tests/e2e/compare/output/console.ts
index 41ae5a4e0ccf..2e303ffa1538 100644
--- a/tests/e2e/compare/output/console.ts
+++ b/tests/e2e/compare/output/console.ts
@@ -26,7 +26,7 @@ const printRegularLine = (entry: Entry) => {
/**
* Prints the result simply to console.
*/
-export default (data: Data) => {
+export default (data: Data, skippedTests: string[]) => {
// No need to log errors or warnings as these were be logged on the fly
console.debug('');
console.debug('❇️ Performance comparison results:');
@@ -38,6 +38,10 @@ export default (data: Data) => {
data.meaningless.forEach(printRegularLine);
console.debug('');
+
+ if (skippedTests.length > 0) {
+ console.debug(`⚠️ Some tests did not pass successfully, so some results are omitted from final report: ${skippedTests.join(', ')}`);
+ }
};
export type {Data, Entry};
diff --git a/tests/e2e/compare/output/markdown.ts b/tests/e2e/compare/output/markdown.ts
index 32af6c5e22ad..bd32d2d99ab2 100644
--- a/tests/e2e/compare/output/markdown.ts
+++ b/tests/e2e/compare/output/markdown.ts
@@ -67,7 +67,7 @@ const buildSummaryTable = (entries: Entry[], collapse = false) => {
return collapse ? collapsibleSection('Show entries', content) : content;
};
-const buildMarkdown = (data: Data) => {
+const buildMarkdown = (data: Data, skippedTests: string[]) => {
let result = '## Performance Comparison Report 📊';
if (data.errors?.length) {
@@ -92,6 +92,10 @@ const buildMarkdown = (data: Data) => {
result += `\n${buildDetailsTable(data.meaningless)}`;
result += '\n';
+ if (skippedTests.length > 0) {
+ result += `⚠️ Some tests did not pass successfully, so some results are omitted from final report: ${skippedTests.join(', ')}`;
+ }
+
return result;
};
@@ -109,8 +113,9 @@ const writeToFile = (filePath: string, content: string) =>
throw error;
});
-const writeToMarkdown = (filePath: string, data: Data) => {
- const markdown = buildMarkdown(data);
+const writeToMarkdown = (filePath: string, data: Data, skippedTests: string[]) => {
+ const markdown = buildMarkdown(data, skippedTests);
+ Logger.info('Markdown was built successfully, writing to file...', markdown);
return writeToFile(filePath, markdown).catch((error) => {
console.error(error);
throw error;
diff --git a/tests/e2e/testRunner.ts b/tests/e2e/testRunner.ts
index 58fb6b9cdae1..5485385ad8c9 100644
--- a/tests/e2e/testRunner.ts
+++ b/tests/e2e/testRunner.ts
@@ -123,6 +123,20 @@ const runTests = async (): Promise => {
}
};
+ const skippedTests: string[] = [];
+ const clearTestResults = (test: TestConfig) => {
+ skippedTests.push(test.name);
+
+ Object.keys(results).forEach((branch: string) => {
+ Object.keys(results[branch]).forEach((metric: string) => {
+ if (!metric.startsWith(test.name)) {
+ return;
+ }
+ delete results[branch][metric];
+ });
+ });
+ };
+
// Collect results while tests are being executed
server.addTestResultListener((testResult) => {
const {isCritical = true} = testResult;
@@ -151,7 +165,7 @@ const runTests = async (): Promise => {
await launchApp('android', appPackage, config.ACTIVITY_PATH, launchArgs);
const {promise, resetTimeout} = withFailTimeout(
- new Promise((resolve) => {
+ new Promise((resolve, reject) => {
const removeListener = server.addTestDoneListener(() => {
Logger.success(iterationText);
@@ -201,9 +215,14 @@ const runTests = async (): Promise => {
removeListener();
// something went wrong, let's wait a little bit and try again
await sleep(5000);
- // simply restart the test
- await runTestIteration(appPackage, iterationText, branch, launchArgs);
- resolve();
+ try {
+ // simply restart the test
+ await runTestIteration(appPackage, iterationText, branch, launchArgs);
+ resolve();
+ } catch (e) {
+ // okay, give up and throw the exception further
+ reject(e);
+ }
},
});
}),
@@ -244,88 +263,103 @@ const runTests = async (): Promise => {
server.setTestConfig(test as TestConfig);
server.setReadyToAcceptTestResults(false);
- const warmupText = `Warmup for test '${test?.name}' [${testIndex + 1}/${tests.length}]`;
-
- // For each warmup we allow the warmup to fail three times before we stop the warmup run:
- const errorCountWarmupRef = {
- errorCount: 0,
- allowedExceptions: 3,
- };
-
- // by default we do 2 warmups:
- // - first warmup to pass a login flow
- // - second warmup to pass an actual flow and cache network requests
- const iterations = 2;
- for (let i = 0; i < iterations; i++) {
- try {
- // Warmup the main app:
- await runTestIteration(config.MAIN_APP_PACKAGE, `[MAIN] ${warmupText}. Iteration ${i + 1}/${iterations}`, config.BRANCH_MAIN);
-
- // Warmup the delta app:
- await runTestIteration(config.DELTA_APP_PACKAGE, `[DELTA] ${warmupText}. Iteration ${i + 1}/${iterations}`, config.BRANCH_DELTA);
- } catch (e) {
- // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
- Logger.error(`Warmup failed with error: ${e}`);
-
- errorCountWarmupRef.errorCount++;
- i--; // repeat warmup again
-
- if (errorCountWarmupRef.errorCount === errorCountWarmupRef.allowedExceptions) {
- Logger.error("There was an error running the warmup and we've reached the maximum number of allowed exceptions. Stopping the test run.");
- throw e;
+ try {
+ const warmupText = `Warmup for test '${test?.name}' [${testIndex + 1}/${tests.length}]`;
+
+ // For each warmup we allow the warmup to fail three times before we stop the warmup run:
+ const errorCountWarmupRef = {
+ errorCount: 0,
+ allowedExceptions: 3,
+ };
+
+ // by default we do 2 warmups:
+ // - first warmup to pass a login flow
+ // - second warmup to pass an actual flow and cache network requests
+ const iterations = 2;
+ for (let i = 0; i < iterations; i++) {
+ try {
+ // Warmup the main app:
+ await runTestIteration(config.MAIN_APP_PACKAGE, `[MAIN] ${warmupText}. Iteration ${i + 1}/${iterations}`, config.BRANCH_MAIN);
+
+ // Warmup the delta app:
+ await runTestIteration(config.DELTA_APP_PACKAGE, `[DELTA] ${warmupText}. Iteration ${i + 1}/${iterations}`, config.BRANCH_DELTA);
+ } catch (e) {
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+ Logger.error(`Warmup failed with error: ${e}`);
+
+ MeasureUtils.stop('error-warmup');
+ server.clearAllTestDoneListeners();
+
+ errorCountWarmupRef.errorCount++;
+ i--; // repeat warmup again
+
+ if (errorCountWarmupRef.errorCount === errorCountWarmupRef.allowedExceptions) {
+ Logger.error("There was an error running the warmup and we've reached the maximum number of allowed exceptions. Stopping the test run.");
+ throw e;
+ }
}
}
- }
- server.setReadyToAcceptTestResults(true);
-
- // For each test case we allow the test to fail three times before we stop the test run:
- const errorCountRef = {
- errorCount: 0,
- allowedExceptions: 3,
- };
-
- // We run each test multiple time to average out the results
- for (let testIteration = 0; testIteration < config.RUNS; testIteration++) {
- const onError = (e: Error) => {
- // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
- Logger.error(`Unexpected error during test execution: ${e}. `);
- MeasureUtils.stop('error');
- server.clearAllTestDoneListeners();
- errorCountRef.errorCount += 1;
- if (testIteration === 0 || errorCountRef.errorCount === errorCountRef.allowedExceptions) {
- Logger.error("There was an error running the test and we've reached the maximum number of allowed exceptions. Stopping the test run.");
- // If the error happened on the first test run, the test is broken
- // and we should not continue running it. Or if we have reached the
- // maximum number of allowed exceptions, we should stop the test run.
- throw e;
- }
- // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
- Logger.warn(`There was an error running the test. Continuing the test run. Error: ${e}`);
- };
+ server.setReadyToAcceptTestResults(true);
- const launchArgs = {
- mockNetwork: true,
+ // For each test case we allow the test to fail three times before we stop the test run:
+ const errorCountRef = {
+ errorCount: 0,
+ allowedExceptions: 3,
};
- const iterationText = `Test '${test?.name}' [${testIndex + 1}/${tests.length}], iteration [${testIteration + 1}/${config.RUNS}]`;
- const mainIterationText = `[MAIN] ${iterationText}`;
- const deltaIterationText = `[DELTA] ${iterationText}`;
- try {
- // Run the test on the main app:
- await runTestIteration(config.MAIN_APP_PACKAGE, mainIterationText, config.BRANCH_MAIN, launchArgs);
-
- // Run the test on the delta app:
- await runTestIteration(config.DELTA_APP_PACKAGE, deltaIterationText, config.BRANCH_DELTA, launchArgs);
- } catch (e) {
- onError(e as Error);
+ // We run each test multiple time to average out the results
+ for (let testIteration = 0; testIteration < config.RUNS; testIteration++) {
+ const onError = (e: Error) => {
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+ Logger.error(`Unexpected error during test execution: ${e}. `);
+ MeasureUtils.stop('error');
+ server.clearAllTestDoneListeners();
+ errorCountRef.errorCount += 1;
+ if (testIteration === 0 || errorCountRef.errorCount === errorCountRef.allowedExceptions) {
+ Logger.error("There was an error running the test and we've reached the maximum number of allowed exceptions. Stopping the test run.");
+ // If the error happened on the first test run, the test is broken
+ // and we should not continue running it. Or if we have reached the
+ // maximum number of allowed exceptions, we should stop the test run.
+ throw e;
+ }
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+ Logger.warn(`There was an error running the test. Continuing the test run. Error: ${e}`);
+ };
+
+ const launchArgs = {
+ mockNetwork: true,
+ };
+
+ const iterationText = `Test '${test?.name}' [${testIndex + 1}/${tests.length}], iteration [${testIteration + 1}/${config.RUNS}]`;
+ const mainIterationText = `[MAIN] ${iterationText}`;
+ const deltaIterationText = `[DELTA] ${iterationText}`;
+ try {
+ // Run the test on the main app:
+ await runTestIteration(config.MAIN_APP_PACKAGE, mainIterationText, config.BRANCH_MAIN, launchArgs);
+
+ // Run the test on the delta app:
+ await runTestIteration(config.DELTA_APP_PACKAGE, deltaIterationText, config.BRANCH_DELTA, launchArgs);
+ } catch (e) {
+ onError(e as Error);
+ }
}
+ } catch (exception) {
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+ Logger.warn(`Test ${test?.name} can not be finished due to error: ${exception}`);
+ clearTestResults(test as TestConfig);
}
}
// Calculate statistics and write them to our work file
Logger.info('Calculating statics and writing results');
- compare(results.main, results.delta, `${config.OUTPUT_DIR}/output.md`, 'all', metricForTest);
+ await compare(results.main, results.delta, {
+ outputFile: `${config.OUTPUT_DIR}/output.md`,
+ outputFormat: 'all',
+ metricForTest,
+ skippedTests,
+ });
+ Logger.info('Finished calculating statics and writing results, stopping the test server');
await server.stop();
};
diff --git a/tests/perf-test/ReportActionCompose.perf-test.tsx b/tests/perf-test/ReportActionCompose.perf-test.tsx
index d1d1dd45abc2..845727c75c97 100644
--- a/tests/perf-test/ReportActionCompose.perf-test.tsx
+++ b/tests/perf-test/ReportActionCompose.perf-test.tsx
@@ -113,52 +113,13 @@ test('[ReportActionCompose] should render Composer with text input interactions'
await measureRenders(, {scenario});
});
-test('[ReportActionCompose] should scroll to hide suggestions', async () => {
+test('[ReportActionCompose] should press create button', async () => {
const scenario = async () => {
- // Query for the composer
- const composer = await screen.findByTestId('composer');
-
- // scroll to hide suggestions
- fireEvent.scroll(composer);
- };
-
- await waitForBatchedUpdates();
- await measureRenders(, {scenario});
-});
-
-test('[ReportActionCompose] should press to block suggestions', async () => {
- const scenario = async () => {
- // Query for the composer
- const composer = await screen.findByTestId('composer');
-
- // press to block suggestions
- fireEvent.press(composer);
- };
-
- await waitForBatchedUpdates();
- await measureRenders(, {scenario});
-});
-
-test('[ReportActionCompose] should press add attachemnt button', async () => {
- const scenario = async () => {
- // Query for the attachment button
+ // Query for the create button
const hintAttachmentButtonText = Localize.translateLocal('common.create');
- const attachmentButton = await screen.findByLabelText(hintAttachmentButtonText);
-
- fireEvent.press(attachmentButton, mockEvent);
- };
-
- await waitForBatchedUpdates();
- await measureRenders(, {scenario});
-});
-
-test('[ReportActionCompose] should press add emoji button', async () => {
- const scenario = async () => {
- // Query for the emoji button
- const hintEmojiButtonText = Localize.translateLocal('reportActionCompose.emoji');
- const emojiButton = await screen.findByLabelText(hintEmojiButtonText);
+ const createButton = await screen.findByLabelText(hintAttachmentButtonText);
- fireEvent.press(emojiButton);
+ fireEvent.press(createButton, mockEvent);
};
await waitForBatchedUpdates();
@@ -177,37 +138,3 @@ test('[ReportActionCompose] should press send message button', async () => {
await waitForBatchedUpdates();
await measureRenders(, {scenario});
});
-
-test('[ReportActionCompose] press add attachment button', async () => {
- const scenario = async () => {
- const hintAddAttachmentButtonText = Localize.translateLocal('reportActionCompose.addAttachment');
-
- const addAttachmentButton = await screen.findByLabelText(hintAddAttachmentButtonText);
- fireEvent.press(addAttachmentButton, mockEvent);
- };
-
- await waitForBatchedUpdates();
- await measureRenders(, {scenario});
-});
-
-test('[ReportActionCompose] should press split bill button', async () => {
- const scenario = async () => {
- const hintSplitBillButtonText = Localize.translateLocal('iou.splitExpense');
- const splitBillButton = await screen.findByLabelText(hintSplitBillButtonText);
- fireEvent.press(splitBillButton, mockEvent);
- };
-
- await waitForBatchedUpdates();
- await measureRenders(, {scenario});
-});
-
-test('[ReportActionCompose] should press assign task button', async () => {
- const scenario = async () => {
- const hintAssignTaskButtonText = Localize.translateLocal('newTaskPage.assignTask');
- const assignTaskButton = await screen.findByLabelText(hintAssignTaskButtonText);
- fireEvent.press(assignTaskButton, mockEvent);
- };
-
- await waitForBatchedUpdates();
- await measureRenders(, {scenario});
-});
diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx
index 4b2a114392c5..f7debc510832 100644
--- a/tests/perf-test/ReportActionsList.perf-test.tsx
+++ b/tests/perf-test/ReportActionsList.perf-test.tsx
@@ -1,4 +1,4 @@
-import {fireEvent, screen} from '@testing-library/react-native';
+import {screen} from '@testing-library/react-native';
import type {ComponentType} from 'react';
import Onyx from 'react-native-onyx';
import {measureRenders} from 'reassure';
@@ -7,12 +7,10 @@ import type Navigation from '@libs/Navigation/Navigation';
import ComposeProviders from '@src/components/ComposeProviders';
import {LocaleContextProvider} from '@src/components/LocaleContextProvider';
import OnyxProvider from '@src/components/OnyxProvider';
-import * as Localize from '@src/libs/Localize';
import ONYXKEYS from '@src/ONYXKEYS';
import ReportActionsList from '@src/pages/home/report/ReportActionsList';
import {ReportAttachmentsProvider} from '@src/pages/home/report/ReportAttachmentsContext';
import {ActionListContext, ReactionListContext} from '@src/pages/home/ReportScreenContext';
-import variables from '@src/styles/variables';
import type {PersonalDetailsList} from '@src/types/onyx';
import createRandomReportAction from '../utils/collections/reportActions';
import * as LHNTestUtilsModule from '../utils/LHNTestUtils';
@@ -117,50 +115,3 @@ test('[ReportActionsList] should render ReportActionsList with 500 reportActions
await measureRenders(, {scenario});
});
-
-test('[ReportActionsList] should render list items', async () => {
- const scenario = async () => {
- const hintText = Localize.translateLocal('accessibilityHints.chatMessage');
- await screen.findAllByLabelText(hintText);
- };
-
- await waitForBatchedUpdates();
-
- Onyx.multiSet({
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtilsModule.fakePersonalDetails,
- });
-
- await measureRenders(, {scenario});
-});
-
-test('[ReportActionsList] should scroll through list of items', async () => {
- const eventData = {
- nativeEvent: {
- contentOffset: {
- y: variables.optionRowHeight * 5,
- },
- contentSize: {
- // Dimensions of the scrollable content
- height: variables.optionRowHeight * 10,
- width: 100,
- },
- layoutMeasurement: {
- // Dimensions of the device
- height: variables.optionRowHeight * 5,
- width: 100,
- },
- },
- };
-
- const scenario = async () => {
- const reportActionsList = await screen.findByTestId('report-actions-list');
- fireEvent.scroll(reportActionsList, eventData);
- };
- await waitForBatchedUpdates();
-
- Onyx.multiSet({
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtilsModule.fakePersonalDetails,
- });
-
- await measureRenders(, {scenario});
-});
diff --git a/tests/perf-test/ReportScreen.perf-test.tsx b/tests/perf-test/ReportScreen.perf-test.tsx
deleted file mode 100644
index 550b6adabc36..000000000000
--- a/tests/perf-test/ReportScreen.perf-test.tsx
+++ /dev/null
@@ -1,322 +0,0 @@
-import type {StackNavigationProp, StackScreenProps} from '@react-navigation/stack';
-import {screen, waitFor} from '@testing-library/react-native';
-import type {ComponentType} from 'react';
-import React from 'react';
-import type ReactNative from 'react-native';
-import {Dimensions, InteractionManager} from 'react-native';
-import Onyx from 'react-native-onyx';
-import type Animated from 'react-native-reanimated';
-import {measureRenders} from 'reassure';
-import type {WithNavigationFocusProps} from '@components/withNavigationFocus';
-import type Navigation from '@libs/Navigation/Navigation';
-import type {AuthScreensParamList} from '@libs/Navigation/types';
-import ComposeProviders from '@src/components/ComposeProviders';
-import DragAndDropProvider from '@src/components/DragAndDrop/Provider';
-import {LocaleContextProvider} from '@src/components/LocaleContextProvider';
-import OnyxProvider from '@src/components/OnyxProvider';
-import {CurrentReportIDContextProvider} from '@src/components/withCurrentReportID';
-import {KeyboardStateProvider} from '@src/components/withKeyboardState';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import {ReportAttachmentsProvider} from '@src/pages/home/report/ReportAttachmentsContext';
-import ReportScreen from '@src/pages/home/ReportScreen';
-import type SCREENS from '@src/SCREENS';
-import type * as OnyxTypes from '@src/types/onyx';
-import type {ReportCollectionDataSet} from '@src/types/onyx/Report';
-import type {ReportActionsCollectionDataSet} from '@src/types/onyx/ReportAction';
-import createCollection from '../utils/collections/createCollection';
-import createPersonalDetails from '../utils/collections/personalDetails';
-import createRandomPolicy from '../utils/collections/policies';
-import createRandomReport from '../utils/collections/reports';
-import createAddListenerMock from '../utils/createAddListenerMock';
-import * as ReportTestUtils from '../utils/ReportTestUtils';
-import * as TestHelper from '../utils/TestHelper';
-import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
-import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates';
-
-type ReportScreenWrapperProps = StackScreenProps;
-
-jest.mock('@src/libs/API', () => ({
- write: jest.fn(),
- makeRequestWithSideEffects: jest.fn(),
- read: jest.fn(),
- paginate: jest.fn(),
-}));
-
-jest.mock('react-native/Libraries/Interaction/InteractionManager', () => ({
- runAfterInteractions: () => ({
- cancel: jest.fn(),
- }),
- createInteractionHandle: jest.fn(),
- clearInteractionHandle: jest.fn(),
-}));
-
-jest.mock('react-native', () => {
- const actualReactNative = jest.requireActual('react-native');
- // eslint-disable-next-line @typescript-eslint/no-unsafe-return
- return {
- ...actualReactNative,
- Dimensions: {
- ...actualReactNative.Dimensions,
- addEventListener: jest.fn(),
- },
- };
-});
-
-jest.mock('react-native-reanimated', () => {
- const actualNav = jest.requireActual('react-native-reanimated/mock');
- return {
- ...actualNav,
- useSharedValue: jest.fn,
- useAnimatedStyle: jest.fn,
- useAnimatedRef: jest.fn,
- };
-});
-
-jest.mock('@src/components/ConfirmedRoute.tsx');
-
-jest.mock('@src/components/withNavigationFocus', () => (Component: ComponentType) => {
- function WithNavigationFocus(props: Omit) {
- return (
-
- );
- }
-
- WithNavigationFocus.displayName = 'WithNavigationFocus';
-
- return WithNavigationFocus;
-});
-
-jest.mock('@src/hooks/useEnvironment', () =>
- jest.fn(() => ({
- environment: 'development',
- environmentURL: 'https://new.expensify.com',
- isProduction: false,
- isDevelopment: true,
- })),
-);
-
-jest.mock('@src/libs/Permissions', () => ({
- canUseLinkPreviews: jest.fn(() => true),
- canUseDefaultRooms: jest.fn(() => true),
- canUseNewSearchRouter: jest.fn(() => true),
-}));
-
-jest.mock('@src/libs/Navigation/Navigation', () => ({
- isNavigationReady: jest.fn(() => Promise.resolve()),
- isDisplayedInModal: jest.fn(() => false),
-}));
-
-jest.mock('@react-navigation/native', () => {
- const actualNav = jest.requireActual('@react-navigation/native');
- return {
- ...actualNav,
- useFocusEffect: jest.fn(),
- useIsFocused: () => true,
- useRoute: () => jest.fn(),
- useNavigation: () => ({
- navigate: jest.fn(),
- addListener: () => jest.fn(),
- }),
- useNavigationState: () => {},
- createNavigationContainerRef: jest.fn(),
- };
-});
-
-// mock PortalStateContext
-jest.mock('@gorhom/portal');
-
-beforeAll(() =>
- Onyx.init({
- keys: ONYXKEYS,
- safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS],
- }),
-);
-
-type MockListener = {
- remove: jest.Mock;
- callback?: () => void;
-};
-
-const mockListener: MockListener = {remove: jest.fn()};
-let mockCancel: jest.Mock;
-let mockRunAfterInteractions: jest.Mock;
-
-beforeEach(() => {
- global.fetch = TestHelper.getGlobalFetchMock();
- wrapOnyxWithWaitForBatchedUpdates(Onyx);
-
- // Reset mocks before each test
- (Dimensions.addEventListener as jest.Mock).mockClear();
- mockListener.remove.mockClear();
-
- // Mock the implementation of addEventListener to return the mockListener
- (Dimensions.addEventListener as jest.Mock).mockImplementation((event: string, callback: () => void) => {
- if (event === 'change') {
- mockListener.callback = callback;
- return mockListener;
- }
- return {remove: jest.fn()};
- });
-
- // Mock the implementation of InteractionManager.runAfterInteractions
- mockCancel = jest.fn();
- mockRunAfterInteractions = jest.fn().mockReturnValue({cancel: mockCancel});
-
- jest.spyOn(InteractionManager, 'runAfterInteractions').mockImplementation(mockRunAfterInteractions);
-
- // Initialize the network key for OfflineWithFeedback
- Onyx.merge(ONYXKEYS.NETWORK, {isOffline: false});
- Onyx.clear().then(waitForBatchedUpdates);
-});
-
-const policies = createCollection(
- (item: OnyxTypes.Policy) => `${ONYXKEYS.COLLECTION.POLICY}${item.id}`,
- (index: number) => createRandomPolicy(index),
- 10,
-);
-
-const personalDetails = createCollection(
- (item: OnyxTypes.PersonalDetails) => item.accountID,
- (index: number) => createPersonalDetails(index),
- 20,
-);
-
-function ReportScreenWrapper(props: ReportScreenWrapperProps) {
- return (
-
-
-
- );
-}
-
-const report = {...createRandomReport(1), policyID: '1'};
-const reportActions = ReportTestUtils.getMockedReportActionsMap(1000);
-const mockRoute = {params: {reportID: '1', reportActionID: ''}, key: 'Report', name: 'Report' as const};
-
-test('[ReportScreen] should render ReportScreen', async () => {
- const {addListener} = createAddListenerMock();
- const scenario = async () => {
- // wrapp the screen with waitFor to wait for the screen to be rendered
- await waitFor(async () => {
- await screen.findByTestId(`report-screen-${report.reportID}`);
- });
- };
-
- const navigation = {addListener} as unknown as StackNavigationProp;
-
- await waitForBatchedUpdates();
- const reportCollectionDataSet: ReportCollectionDataSet = {
- [`${ONYXKEYS.COLLECTION.REPORT}${mockRoute.params.reportID}`]: report,
- };
- const reportActionsCollectionDataSet: ReportActionsCollectionDataSet = {
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${mockRoute.params.reportID}`]: reportActions,
- };
-
- Onyx.multiSet({
- [ONYXKEYS.IS_SIDEBAR_LOADED]: true,
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: personalDetails,
- [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS],
- [`${ONYXKEYS.COLLECTION.POLICY}`]: policies,
- [ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT]: true,
- ...reportCollectionDataSet,
- ...reportActionsCollectionDataSet,
- });
-
- await measureRenders(
- ,
- {scenario},
- );
-});
-
-test('[ReportScreen] should render composer', async () => {
- const {addListener} = createAddListenerMock();
- const scenario = async () => {
- await waitFor(async () => {
- await screen.findByTestId('composer');
- });
- };
-
- const navigation = {addListener} as unknown as StackNavigationProp;
-
- await waitForBatchedUpdates();
-
- const reportCollectionDataSet: ReportCollectionDataSet = {
- [`${ONYXKEYS.COLLECTION.REPORT}${mockRoute.params.reportID}`]: report,
- };
-
- const reportActionsCollectionDataSet: ReportActionsCollectionDataSet = {
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${mockRoute.params.reportID}`]: reportActions,
- };
- Onyx.multiSet({
- [ONYXKEYS.IS_SIDEBAR_LOADED]: true,
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: personalDetails,
- [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS],
- [`${ONYXKEYS.COLLECTION.POLICY}`]: policies,
- [ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT]: true,
- ...reportCollectionDataSet,
- ...reportActionsCollectionDataSet,
- });
- await measureRenders(
- ,
- {scenario},
- );
-});
-
-test('[ReportScreen] should render report list', async () => {
- const {addListener} = createAddListenerMock();
- const scenario = async () => {
- await waitFor(async () => {
- await screen.findByTestId('report-actions-list');
- });
- };
-
- const navigation = {addListener} as unknown as StackNavigationProp;
-
- await waitForBatchedUpdates();
-
- const reportCollectionDataSet: ReportCollectionDataSet = {
- [`${ONYXKEYS.COLLECTION.REPORT}${mockRoute.params.reportID}`]: report,
- };
-
- const reportActionsCollectionDataSet: ReportActionsCollectionDataSet = {
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${mockRoute.params.reportID}`]: reportActions,
- };
-
- Onyx.multiSet({
- [ONYXKEYS.IS_SIDEBAR_LOADED]: true,
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: personalDetails,
- [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS],
- [`${ONYXKEYS.COLLECTION.POLICY}`]: policies,
- [ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT]: true,
- [ONYXKEYS.IS_LOADING_APP]: false,
- [`${ONYXKEYS.COLLECTION.REPORT_METADATA}${mockRoute.params.reportID}`]: {
- isLoadingInitialReportActions: false,
- },
- ...reportCollectionDataSet,
- ...reportActionsCollectionDataSet,
- });
-
- await measureRenders(
- ,
- {scenario},
- );
-});
diff --git a/tests/perf-test/SearchRouter.perf-test.tsx b/tests/perf-test/SearchRouter.perf-test.tsx
index e9154a36a9a1..e5a0b2e30533 100644
--- a/tests/perf-test/SearchRouter.perf-test.tsx
+++ b/tests/perf-test/SearchRouter.perf-test.tsx
@@ -5,9 +5,9 @@ import type {ComponentType} from 'react';
import Onyx from 'react-native-onyx';
import {measureRenders} from 'reassure';
import {LocaleContextProvider} from '@components/LocaleContextProvider';
-import OptionListContextProvider, {OptionsListContext} from '@components/OptionListContextProvider';
+import {OptionsListContext} from '@components/OptionListContextProvider';
import SearchRouter from '@components/Search/SearchRouter/SearchRouter';
-import {KeyboardStateProvider} from '@components/withKeyboardState';
+import SearchRouterInput from '@components/Search/SearchRouter/SearchRouterInput';
import type {WithNavigationFocusProps} from '@components/withNavigationFocus';
import {createOptionList} from '@libs/OptionsListUtils';
import ComposeProviders from '@src/components/ComposeProviders';
@@ -125,12 +125,15 @@ afterEach(() => {
const mockOnClose = jest.fn();
-function SearchRouterWrapper() {
+function SearchRouterInputWrapper() {
+ const [value, setValue] = React.useState('');
return (
-
-
-
-
+
+ setValue(searchTerm)}
+ isFullWidth={false}
+ />
);
}
@@ -145,7 +148,7 @@ function SearchRouterWrapperWithCachedOptions() {
);
}
-test('[SearchRouter] should render chat list with cached options', async () => {
+test('[SearchRouter] should render list with cached options', async () => {
const scenario = async () => {
await screen.findByTestId('SearchRouter');
};
@@ -164,9 +167,7 @@ test('[SearchRouter] should render chat list with cached options', async () => {
test('[SearchRouter] should react to text input changes', async () => {
const scenario = async () => {
- await screen.findByTestId('SearchRouter');
-
- const input = screen.getByTestId('search-router-text-input');
+ const input = await screen.findByTestId('search-router-text-input');
fireEvent.changeText(input, 'Email Four');
fireEvent.changeText(input, 'Report');
fireEvent.changeText(input, 'Email Five');
@@ -181,5 +182,5 @@ test('[SearchRouter] should react to text input changes', async () => {
[ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: true,
}),
)
- .then(() => measureRenders(, {scenario}));
+ .then(() => measureRenders(, {scenario}));
});
diff --git a/tests/perf-test/SidebarLinks.perf-test.tsx b/tests/perf-test/SidebarLinks.perf-test.tsx
index d1942522af38..ad19888b47a3 100644
--- a/tests/perf-test/SidebarLinks.perf-test.tsx
+++ b/tests/perf-test/SidebarLinks.perf-test.tsx
@@ -1,7 +1,6 @@
import {fireEvent, screen, waitFor} from '@testing-library/react-native';
import Onyx from 'react-native-onyx';
import {measureRenders} from 'reassure';
-import variables from '@styles/variables';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import * as LHNTestUtils from '../utils/LHNTestUtils';
@@ -88,70 +87,6 @@ describe('SidebarLinks', () => {
await measureRenders(, {scenario});
});
- test('[SidebarLinks] should render list itmes', async () => {
- const scenario = async () => {
- await waitFor(async () => {
- /**
- * Query for display names of participants [1, 2].
- * This will ensure that the sidebar renders a list of items.
- */
- await screen.findAllByText('Email Two');
- });
- };
-
- await waitForBatchedUpdates();
-
- Onyx.multiSet({
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
- [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS],
- [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
- [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
- ...mockedResponseMap,
- });
-
- await measureRenders(, {scenario});
- });
-
- test('[SidebarLinks] should scroll through the list of items ', async () => {
- const scenario = async () => {
- const eventData = {
- nativeEvent: {
- contentOffset: {
- y: variables.optionRowHeight * 5,
- },
- contentSize: {
- // Dimensions of the scrollable content
- height: variables.optionRowHeight * 10,
- width: 100,
- },
- layoutMeasurement: {
- // Dimensions of the device
- height: variables.optionRowHeight * 5,
- width: 100,
- },
- },
- };
-
- await wrapInAct(async () => {
- const lhnOptionsList = await screen.findByTestId('lhn-options-list');
-
- fireEvent.scroll(lhnOptionsList, eventData);
- });
- };
-
- await waitForBatchedUpdates();
-
- Onyx.multiSet({
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
- [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS],
- [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
- [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
- ...mockedResponseMap,
- });
-
- await measureRenders(, {scenario});
- });
-
test('[SidebarLinks] should click on list item', async () => {
const scenario = async () => {
await wrapInAct(async () => {
diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx
index 186a5f5d4483..8fcd6dbac1d6 100644
--- a/tests/ui/PaginationTest.tsx
+++ b/tests/ui/PaginationTest.tsx
@@ -19,7 +19,7 @@ import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct';
// We need a large timeout here as we are lazy loading React Navigation screens and this test is running against the entire mounted App
-jest.setTimeout(30000);
+jest.setTimeout(60000);
jest.mock('@react-navigation/native');
jest.mock('../../src/libs/Notification/LocalNotification');
diff --git a/tests/unit/E2EMarkdownTest.ts b/tests/unit/E2EMarkdownTest.ts
index 74c5659c9487..766ec708f31b 100644
--- a/tests/unit/E2EMarkdownTest.ts
+++ b/tests/unit/E2EMarkdownTest.ts
@@ -13,6 +13,6 @@ const results = {
describe('markdown formatter', () => {
it('should format significant changes properly', () => {
const data = compareResults(results.main, results.delta, {commentLinking: 'ms'});
- expect(buildMarkdown(data)).toMatchSnapshot();
+ expect(buildMarkdown(data, [])).toMatchSnapshot();
});
});
diff --git a/tests/unit/FastSearchTest.ts b/tests/unit/FastSearchTest.ts
deleted file mode 100644
index 029e05e15b1f..000000000000
--- a/tests/unit/FastSearchTest.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-import FastSearch from '../../src/libs/FastSearch';
-
-describe('FastSearch', () => {
- it('should insert, and find the word', () => {
- const {search} = FastSearch.createFastSearch([
- {
- data: ['banana'],
- toSearchableString: (data) => data,
- },
- ]);
- expect(search('an')).toEqual([['banana']]);
- });
-
- it('should work with multiple words', () => {
- const {search} = FastSearch.createFastSearch([
- {
- data: ['banana', 'test'],
- toSearchableString: (data) => data,
- },
- ]);
-
- expect(search('es')).toEqual([['test']]);
- });
-
- it('should work when providing two data sets', () => {
- const {search} = FastSearch.createFastSearch([
- {
- data: ['erica', 'banana'],
- toSearchableString: (data) => data,
- },
- {
- data: ['banana', 'test'],
- toSearchableString: (data) => data,
- },
- ]);
-
- expect(search('es')).toEqual([[], ['test']]);
- });
-
- it('should work with numbers', () => {
- const {search} = FastSearch.createFastSearch([
- {
- data: [1, 2, 3, 4, 5],
- toSearchableString: (data) => String(data),
- },
- ]);
-
- expect(search('2')).toEqual([[2]]);
- });
-
- it('should work with unicodes', () => {
- const {search} = FastSearch.createFastSearch([
- {
- data: ['banana', 'ñèşťǒř', 'test'],
- toSearchableString: (data) => data,
- },
- ]);
-
- expect(search('èşť')).toEqual([['ñèşťǒř']]);
- });
-
- it('should work with words containing "reserved special characters"', () => {
- const {search} = FastSearch.createFastSearch([
- {
- data: ['ba|nana', 'te{st', 'he}llo'],
- toSearchableString: (data) => data,
- },
- ]);
-
- expect(search('st')).toEqual([['te{st']]);
- expect(search('llo')).toEqual([['he}llo']]);
- expect(search('nana')).toEqual([['ba|nana']]);
- });
-
- it('should be case insensitive', () => {
- const {search} = FastSearch.createFastSearch([
- {
- data: ['banana', 'TeSt', 'TEST', 'X'],
- toSearchableString: (data) => data,
- },
- ]);
-
- expect(search('test')).toEqual([['TeSt', 'TEST']]);
- });
-
- it('should work with large random data sets', () => {
- const data = Array.from({length: 1000}, () => {
- return Array.from({length: Math.floor(Math.random() * 22 + 9)}, () => {
- const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789@-_.';
- return alphabet.charAt(Math.floor(Math.random() * alphabet.length));
- }).join('');
- });
-
- const {search} = FastSearch.createFastSearch([
- {
- data,
- toSearchableString: (x) => x,
- },
- ]);
-
- data.forEach((word) => {
- expect(search(word)).toEqual([expect.arrayContaining([word])]);
- });
- });
-
- it('should find email addresses without dots', () => {
- const {search} = FastSearch.createFastSearch([
- {
- data: ['test.user@example.com', 'unrelated'],
- toSearchableString: (data) => data,
- },
- ]);
-
- expect(search('testuser')).toEqual([['test.user@example.com']]);
- expect(search('test.user')).toEqual([['test.user@example.com']]);
- expect(search('examplecom')).toEqual([['test.user@example.com']]);
- });
-});
diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts
index 2ab1247ec5b0..7031045e3f05 100644
--- a/tests/unit/IOUUtilsTest.ts
+++ b/tests/unit/IOUUtilsTest.ts
@@ -146,3 +146,19 @@ describe('isValidMoneyRequestType', () => {
expect(IOUUtils.isValidMoneyRequestType('money')).toBe(false);
});
});
+
+describe('Check valid amount for IOU/Expense request', () => {
+ test('IOU amount should be positive', () => {
+ const iouReport = ReportUtils.buildOptimisticIOUReport(1, 2, 100, '1', 'USD');
+ const iouTransaction = TransactionUtils.buildOptimisticTransaction(100, 'USD', iouReport.reportID);
+ const iouAmount = TransactionUtils.getAmount(iouTransaction, false, false);
+ expect(iouAmount).toBeGreaterThan(0);
+ });
+
+ test('Expense amount should be negative', () => {
+ const expenseReport = ReportUtils.buildOptimisticExpenseReport('212', '123', 100, 122, 'USD');
+ const expenseTransaction = TransactionUtils.buildOptimisticTransaction(100, 'USD', expenseReport.reportID);
+ const expenseAmount = TransactionUtils.getAmount(expenseTransaction, true, false);
+ expect(expenseAmount).toBeLessThan(0);
+ });
+});
diff --git a/tests/unit/PersistedRequests.ts b/tests/unit/PersistedRequests.ts
index 7d3a7288ed90..c488b36013ad 100644
--- a/tests/unit/PersistedRequests.ts
+++ b/tests/unit/PersistedRequests.ts
@@ -36,7 +36,7 @@ describe('PersistedRequests', () => {
});
it('remove a request from the PersistedRequests array', () => {
- PersistedRequests.remove(request);
+ PersistedRequests.endRequestAndRemoveFromQueue(request);
expect(PersistedRequests.getAll().length).toBe(0);
});
@@ -84,7 +84,7 @@ describe('PersistedRequests', () => {
it('when removing a request should update the persistedRequests queue and clear the ongoing request', () => {
PersistedRequests.processNextRequest();
expect(PersistedRequests.getOngoingRequest()).toEqual(request);
- PersistedRequests.remove(request);
+ PersistedRequests.endRequestAndRemoveFromQueue(request);
expect(PersistedRequests.getOngoingRequest()).toBeNull();
expect(PersistedRequests.getAll().length).toBe(0);
});
diff --git a/tests/unit/RequestConflictUtilsTest.ts b/tests/unit/RequestConflictUtilsTest.ts
index 98ffe50e62ba..c290940289d8 100644
--- a/tests/unit/RequestConflictUtilsTest.ts
+++ b/tests/unit/RequestConflictUtilsTest.ts
@@ -1,4 +1,5 @@
-import {resolveDuplicationConflictAction} from '@libs/actions/RequestConflictUtils';
+import Onyx from 'react-native-onyx';
+import {resolveCommentDeletionConflicts, resolveDuplicationConflictAction, resolveEditCommentWithNewAddCommentRequest} from '@libs/actions/RequestConflictUtils';
import type {WriteCommand} from '@libs/API/types';
describe('RequestConflictUtils', () => {
@@ -32,4 +33,105 @@ describe('RequestConflictUtils', () => {
const result = resolveDuplicationConflictAction(persistedRequests, (request) => request.command === 'OpenReport' && request.data?.reportID === reportID);
expect(result).toEqual({conflictAction: {type: 'replace', index: 2}});
});
+
+ it('resolveCommentDeletionConflicts should return push when no special comments are found', () => {
+ const persistedRequests = [{command: 'OpenReport'}, {command: 'AddComment', data: {reportActionID: 2}}, {command: 'CloseAccount'}];
+ const reportActionID = '1';
+ const originalReportID = '1';
+ const result = resolveCommentDeletionConflicts(persistedRequests, reportActionID, originalReportID);
+ expect(result).toEqual({conflictAction: {type: 'push'}});
+ });
+
+ it('resolveCommentDeletionConflicts should return delete when special comments are found', () => {
+ const persistedRequests = [{command: 'AddComment', data: {reportActionID: '2'}}, {command: 'CloseAccount'}, {command: 'OpenReport'}];
+ const reportActionID = '2';
+ const originalReportID = '1';
+ const result = resolveCommentDeletionConflicts(persistedRequests, reportActionID, originalReportID);
+ expect(result).toEqual({conflictAction: {type: 'delete', indices: [0], pushNewRequest: false}});
+ });
+
+ it.each([['AddComment'], ['AddAttachment'], ['AddTextAndAttachment']])(
+ 'resolveCommentDeletionConflicts should return delete when special comments are found and %s is true',
+ (commandName) => {
+ const updateSpy = jest.spyOn(Onyx, 'update');
+ const persistedRequests = [
+ {command: commandName, data: {reportActionID: '2'}},
+ {command: 'UpdateComment', data: {reportActionID: '2'}},
+ {command: 'CloseAccount'},
+ {command: 'OpenReport'},
+ ];
+ const reportActionID = '2';
+ const originalReportID = '1';
+ const result = resolveCommentDeletionConflicts(persistedRequests, reportActionID, originalReportID);
+ expect(result).toEqual({conflictAction: {type: 'delete', indices: [0, 1], pushNewRequest: false}});
+ expect(updateSpy).toHaveBeenCalledTimes(1);
+ updateSpy.mockClear();
+ },
+ );
+
+ it.each([['UpdateComment'], ['AddEmojiReaction'], ['RemoveEmojiReaction']])(
+ 'resolveCommentDeletionConflicts should return delete when special comments are found and %s is false',
+ (commandName) => {
+ const persistedRequests = [{command: commandName, data: {reportActionID: '2'}}, {command: 'CloseAccount'}, {command: 'OpenReport'}];
+ const reportActionID = '2';
+ const originalReportID = '1';
+ const result = resolveCommentDeletionConflicts(persistedRequests, reportActionID, originalReportID);
+ expect(result).toEqual({conflictAction: {type: 'delete', indices: [0], pushNewRequest: true}});
+ },
+ );
+
+ it('resolveCommentDeletionConflicts should return push when an OpenReport as thread is found', () => {
+ const reportActionID = '2';
+ const persistedRequests = [
+ {command: 'CloseAccount'},
+ {command: 'AddComment', data: {reportActionID}},
+ {command: 'OpenReport', data: {parentReportActionID: reportActionID}},
+ {command: 'AddComment', data: {reportActionID: '3'}},
+ {command: 'OpenReport'},
+ ];
+ const originalReportID = '1';
+ const result = resolveCommentDeletionConflicts(persistedRequests, reportActionID, originalReportID);
+ expect(result).toEqual({conflictAction: {type: 'push'}});
+ });
+
+ it('resolveEditCommentWithNewAddCommentRequest should return delete and replace when update comment are found and new comment is added', () => {
+ const reportActionID = '2';
+ const persistedRequests = [
+ {command: 'AddComment', data: {reportActionID, reportComment: 'test'}},
+ {command: 'UpdateComment', data: {reportActionID, reportComment: 'test edit'}},
+ {command: 'UpdateComment', data: {reportActionID, reportComment: 'test edit edit'}},
+ {command: 'CloseAccount'},
+ {command: 'OpenReport'},
+ ];
+ const parameters = {reportID: '1', reportActionID, reportComment: 'new edit comment'};
+ const addCommentIndex = 0;
+ const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, addCommentIndex);
+ expect(result).toEqual({
+ conflictAction: {
+ type: 'delete',
+ indices: [1, 2],
+ pushNewRequest: false,
+ nextAction: {
+ type: 'replace',
+ index: addCommentIndex,
+ request: {command: 'AddComment', data: {reportID: '1', reportActionID, reportComment: 'new edit comment'}},
+ },
+ },
+ });
+ });
+
+ it('resolveEditCommentWithNewAddCommentRequest should only replace the add comment with the update comment text when no other update comments are found', () => {
+ const reportActionID = '2';
+ const persistedRequests = [{command: 'AddComment', data: {reportActionID, reportComment: 'test'}}, {command: 'CloseAccount'}, {command: 'OpenReport'}];
+ const parameters = {reportID: '1', reportActionID, reportComment: 'new edit comment'};
+ const addCommentIndex = 0;
+ const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, addCommentIndex);
+ expect(result).toEqual({
+ conflictAction: {
+ type: 'replace',
+ index: addCommentIndex,
+ request: {command: 'AddComment', data: {reportID: '1', reportActionID, reportComment: 'new edit comment'}},
+ },
+ });
+ });
});
diff --git a/tests/unit/SearchAutocompleteParserTest.ts b/tests/unit/SearchAutocompleteParserTest.ts
new file mode 100644
index 000000000000..2571b03089b1
--- /dev/null
+++ b/tests/unit/SearchAutocompleteParserTest.ts
@@ -0,0 +1,181 @@
+import type {SearchQueryJSON} from '@components/Search/types';
+import * as autocompleteParser from '@libs/SearchParser/autocompleteParser';
+
+const tests = [
+ {
+ query: 'date>2024-01-01 amount>100 merchant:"A B" description:A,B,C ,, reportID:123456789 word',
+ expected: {
+ autocomplete: null,
+ ranges: [],
+ },
+ },
+ {
+ query: ',',
+ expected: {
+ autocomplete: null,
+ ranges: [],
+ },
+ },
+ {
+ query: 'tag:,,',
+ expected: {
+ autocomplete: null,
+ ranges: [],
+ },
+ },
+ {
+ query: 'type:expense status:all',
+ expected: {
+ autocomplete: {
+ key: 'status',
+ value: 'all',
+ start: 20,
+ length: 3,
+ },
+ ranges: [
+ {key: 'type', value: 'expense', start: 5, length: 7},
+ {key: 'status', value: 'all', start: 20, length: 3},
+ ],
+ },
+ },
+ {
+ query: 'in:123456 currency:USD ',
+ expected: {
+ autocomplete: {
+ key: 'currency',
+ value: 'USD',
+ start: 19,
+ length: 3,
+ },
+ ranges: [
+ {key: 'in', value: '123456', start: 3, length: 6},
+ {key: 'currency', value: 'USD', start: 19, length: 3},
+ ],
+ },
+ },
+ {
+ query: 'tag:aa,bbb,cccc',
+ expected: {
+ autocomplete: {
+ key: 'tag',
+ value: 'cccc',
+ start: 11,
+ length: 4,
+ },
+ ranges: [
+ {key: 'tag', value: 'aa', start: 4, length: 2},
+ {key: 'tag', value: 'bbb', start: 7, length: 3},
+ {key: 'tag', value: 'cccc', start: 11, length: 4},
+ ],
+ },
+ },
+ {
+ query: 'category:',
+ expected: {
+ autocomplete: {
+ key: 'category',
+ value: '',
+ start: 9,
+ length: 0,
+ },
+ ranges: [],
+ },
+ },
+ {
+ query: 'category:Advertising,',
+ expected: {
+ autocomplete: {
+ key: 'category',
+ value: '',
+ start: 21,
+ length: 0,
+ },
+ ranges: [{key: 'category', value: 'Advertising', start: 9, length: 11}],
+ },
+ },
+ {
+ query: 'in:"Big Room","small room"',
+ expected: {
+ autocomplete: {
+ key: 'in',
+ value: 'small room',
+ start: 14,
+ length: 12,
+ },
+ ranges: [
+ {key: 'in', value: 'Big Room', start: 3, length: 10},
+ {key: 'in', value: 'small room', start: 14, length: 12},
+ ],
+ },
+ },
+ {
+ query: 'category: Car',
+ expected: {
+ autocomplete: {
+ key: 'category',
+ value: 'Car',
+ start: 12,
+ length: 3,
+ },
+ ranges: [{key: 'category', value: 'Car', start: 12, length: 3}],
+ },
+ },
+ {
+ query: 'type:expense status:all word',
+ expected: {
+ autocomplete: null,
+ ranges: [
+ {key: 'type', value: 'expense', start: 5, length: 7},
+ {key: 'status', value: 'all', start: 20, length: 3},
+ ],
+ },
+ },
+ {
+ query: 'in:"Big Room" from:Friend category:Car,"Cell Phone" status:all expenseType:card,cash',
+ expected: {
+ autocomplete: {
+ key: 'expenseType',
+ value: 'cash',
+ start: 80,
+ length: 4,
+ },
+ ranges: [
+ {key: 'in', value: 'Big Room', start: 3, length: 10},
+ {key: 'from', value: 'Friend', start: 19, length: 6},
+ {key: 'category', value: 'Car', start: 35, length: 3},
+ {key: 'category', value: 'Cell Phone', start: 39, length: 12},
+ {key: 'status', value: 'all', start: 59, length: 3},
+ {key: 'expenseType', value: 'card', start: 75, length: 4},
+ {key: 'expenseType', value: 'cash', start: 80, length: 4},
+ ],
+ },
+ },
+ {
+ query: 'currency:PLN,USD keyword taxRate:tax merchant:"Expensify, Inc." tag:"General Overhead",IT expenseType:card,distance',
+ expected: {
+ autocomplete: {
+ key: 'expenseType',
+ value: 'distance',
+ start: 108,
+ length: 8,
+ },
+ ranges: [
+ {key: 'currency', value: 'PLN', start: 9, length: 3},
+ {key: 'currency', value: 'USD', start: 13, length: 3},
+ {key: 'taxRate', value: 'tax', start: 33, length: 3},
+ {key: 'tag', value: 'General Overhead', start: 69, length: 18},
+ {key: 'tag', value: 'IT', start: 88, length: 2},
+ {key: 'expenseType', value: 'card', start: 103, length: 4},
+ {key: 'expenseType', value: 'distance', start: 108, length: 8},
+ ],
+ },
+ },
+];
+
+describe('autocomplete parser', () => {
+ test.each(tests)(`parsing: $query`, ({query, expected}) => {
+ const result = autocompleteParser.parse(query) as SearchQueryJSON;
+
+ expect(result).toEqual(expected);
+ });
+});
diff --git a/tests/unit/SearchParserTest.ts b/tests/unit/SearchParserTest.ts
index 1f2a97771bf3..2964e406b512 100644
--- a/tests/unit/SearchParserTest.ts
+++ b/tests/unit/SearchParserTest.ts
@@ -12,6 +12,62 @@ const tests = [
filters: null,
},
},
+ {
+ query: ',',
+ expected: {
+ type: 'expense',
+ status: 'all',
+ sortBy: 'date',
+ sortOrder: 'desc',
+ filters: {
+ operator: 'eq',
+ left: 'keyword',
+ right: [','],
+ },
+ },
+ },
+ {
+ query: 'currency:,',
+ expected: {
+ type: 'expense',
+ status: 'all',
+ sortBy: 'date',
+ sortOrder: 'desc',
+ filters: {
+ operator: 'eq',
+ left: 'keyword',
+ right: ['currency:,'],
+ },
+ },
+ },
+ {
+ query: 'tag:,,travel,',
+ expected: {
+ type: 'expense',
+ status: 'all',
+ sortBy: 'date',
+ sortOrder: 'desc',
+ filters: {
+ operator: 'eq',
+ left: 'tag',
+ right: 'travel',
+ },
+ },
+ },
+ {
+ query: 'category:',
+ expected: {
+ type: 'expense',
+ status: 'all',
+ sortBy: 'date',
+ sortOrder: 'desc',
+ filters: {
+ operator: 'eq',
+ left: 'keyword',
+ right: ['category:'],
+ },
+ },
+ },
{
query: 'in:123333 currency:USD merchant:marriott',
expected: {
diff --git a/tests/unit/SuffixUkkonenTreeTest.ts b/tests/unit/SuffixUkkonenTreeTest.ts
deleted file mode 100644
index c0c556c16e14..000000000000
--- a/tests/unit/SuffixUkkonenTreeTest.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import SuffixUkkonenTree from '@libs/SuffixUkkonenTree/index';
-
-describe('SuffixUkkonenTree', () => {
- // The suffix tree doesn't take strings, but expects an array buffer, where strings have been separated by a delimiter.
- function helperStringsToNumericForTree(strings: string[]) {
- const numericLists = strings.map((s) => SuffixUkkonenTree.stringToNumeric(s, {clamp: true}));
- const numericList = numericLists.reduce(
- (acc, {numeric}) => {
- acc.push(...numeric, SuffixUkkonenTree.DELIMITER_CHAR_CODE);
- return acc;
- },
- // The value we pass to makeTree needs to be offset by one
- [0],
- );
- numericList.push(SuffixUkkonenTree.END_CHAR_CODE);
- return Uint8Array.from(numericList);
- }
-
- it('should insert, build, and find all occurrences', () => {
- const strings = ['banana', 'pancake'];
- const numericIntArray = helperStringsToNumericForTree(strings);
-
- const tree = SuffixUkkonenTree.makeTree(numericIntArray);
- tree.build();
- const searchValue = SuffixUkkonenTree.stringToNumeric('an', {clamp: true}).numeric;
- expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([2, 4, 9]));
- });
-
- it('should find by first character', () => {
- const strings = ['pancake', 'banana'];
- const numericIntArray = helperStringsToNumericForTree(strings);
- const tree = SuffixUkkonenTree.makeTree(numericIntArray);
- tree.build();
- const searchValue = SuffixUkkonenTree.stringToNumeric('p', {clamp: true}).numeric;
- expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([1]));
- });
-
- it('should handle identical words', () => {
- const strings = ['banana', 'banana', 'x'];
- const numericIntArray = helperStringsToNumericForTree(strings);
- const tree = SuffixUkkonenTree.makeTree(numericIntArray);
- tree.build();
- const searchValue = SuffixUkkonenTree.stringToNumeric('an', {clamp: true}).numeric;
- expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([2, 4, 9, 11]));
- });
-
- it('should convert string to numeric with a list of chars to skip', () => {
- const {numeric} = SuffixUkkonenTree.stringToNumeric('abcabc', {
- charSetToSkip: new Set(['b']),
- clamp: true,
- });
- expect(Array.from(numeric)).toEqual([0, 2, 0, 2]);
- });
-
- it('should convert string outside of a-z to numeric with clamping', () => {
- const {numeric} = SuffixUkkonenTree.stringToNumeric('2', {
- clamp: true,
- });
-
- // "2" in ASCII is 50, so base26(50) = [0, 23]
- expect(Array.from(numeric)).toEqual([SuffixUkkonenTree.SPECIAL_CHAR_CODE, 0, 23]);
- });
-});
diff --git a/tests/unit/markPullRequestsAsDeployedTest.ts b/tests/unit/markPullRequestsAsDeployedTest.ts
index 45fa83a36734..0118f42f6554 100644
--- a/tests/unit/markPullRequestsAsDeployedTest.ts
+++ b/tests/unit/markPullRequestsAsDeployedTest.ts
@@ -80,7 +80,9 @@ function mockGetInputDefaultImplementation(key: string): boolean | string {
case 'DEPLOY_VERSION':
return version;
case 'IOS':
+ case 'IOS_HYBRID':
case 'ANDROID':
+ case 'ANDROID_HYBRID':
case 'DESKTOP':
case 'WEB':
return 'success';
@@ -88,7 +90,7 @@ function mockGetInputDefaultImplementation(key: string): boolean | string {
case 'NOTE':
return '';
default:
- throw new Error('Trying to access invalid input');
+ throw new Error(`Trying to access invalid input: ${key}`);
}
}
@@ -196,7 +198,9 @@ platform | result
🤖 android 🤖|success ✅
🖥 desktop 🖥|success ✅
🍎 iOS 🍎|success ✅
-🕸 web 🕸|success ✅`,
+🕸 web 🕸|success ✅
+🤖🔄 android HybridApp 🤖🔄|success ✅
+🍎🔄 iOS HybridApp 🍎🔄|success ✅`,
issue_number: PR.issue_number,
owner: 'Expensify',
repo: 'App',
@@ -226,7 +230,9 @@ platform | result
🤖 android 🤖|success ✅
🖥 desktop 🖥|success ✅
🍎 iOS 🍎|success ✅
-🕸 web 🕸|success ✅`,
+🕸 web 🕸|success ✅
+🤖🔄 android HybridApp 🤖🔄|success ✅
+🍎🔄 iOS HybridApp 🍎🔄|success ✅`,
issue_number: PRList[i + 1].issue_number,
owner: 'Expensify',
repo: 'App',
@@ -289,6 +295,8 @@ platform | result
🖥 desktop 🖥|success ✅
🍎 iOS 🍎|success ✅
🕸 web 🕸|success ✅
+🤖🔄 android HybridApp 🤖🔄|success ✅
+🍎🔄 iOS HybridApp 🍎🔄|success ✅
@Expensify/applauseleads please QA this PR and check it off on the [deploy checklist](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3AStagingDeployCash) if it passes.`,
issue_number: 3,
@@ -325,7 +333,9 @@ platform | result
🤖 android 🤖|skipped 🚫
🖥 desktop 🖥|cancelled 🔪
🍎 iOS 🍎|failed ❌
-🕸 web 🕸|success ✅`,
+🕸 web 🕸|success ✅
+🤖🔄 android HybridApp 🤖🔄|success ✅
+🍎🔄 iOS HybridApp 🍎🔄|success ✅`,
issue_number: PR.issue_number,
owner: 'Expensify',
repo: 'App',
diff --git a/tests/unit/splitLongWordTest.ts b/tests/unit/splitLongWordTest.ts
new file mode 100644
index 000000000000..19f8fc98e73f
--- /dev/null
+++ b/tests/unit/splitLongWordTest.ts
@@ -0,0 +1,47 @@
+import {splitLongWord} from '@components/InlineCodeBlock/WrappedText';
+
+describe('splitLongWord', () => {
+ const testCases = [
+ {
+ word: 'thissadasdasdsadsadasdadsadasdasdasdasdasdasdasdasdasdsadsadggggggggggggggggg',
+ maxLength: 4,
+ output: ['this', 'sada', 'sdas', 'dsad', 'sada', 'sdad', 'sada', 'sdas', 'dasd', 'asda', 'sdas', 'dasd', 'asda', 'sdsa', 'dsad', 'gggg', 'gggg', 'gggg', 'gggg', 'g'],
+ },
+ {
+ word: 'https://www.google.com/search?q=google&oq=goog&gs_lcrp=EgZjaHJvbWUqEAgAEAAYgwEY4wIYsQMYgAQyEAgAEAAYgwEY4wIYsQMYgAQyEwgBEC4YgwEYxwEYsQMY0QMYgAQyDQgCEAAYgwEYsQMYgAQyBggDEEUYOzIGCAQQRRg8MgYIBRBFGDwyBggGEEUYPDIGCAcQBRhA0gEHNzM1ajBqN6gCALACAA&sourceid=chrome&ie=UTF-8',
+ maxLength: 20,
+ output: [
+ 'https://www.google.c',
+ 'om/search?q=google&o',
+ 'q=goog&gs_lcrp=EgZja',
+ 'HJvbWUqEAgAEAAYgwEY4',
+ 'wIYsQMYgAQyEAgAEAAYg',
+ 'wEY4wIYsQMYgAQyEwgBE',
+ 'C4YgwEYxwEYsQMY0QMYg',
+ 'AQyDQgCEAAYgwEYsQMYg',
+ 'AQyBggDEEUYOzIGCAQQR',
+ 'Rg8MgYIBRBFGDwyBggGE',
+ 'EUYPDIGCAcQBRhA0gEHN',
+ 'zM1ajBqN6gCALACAA&so',
+ 'urceid=chrome&ie=UTF',
+ '-8',
+ ],
+ },
+ {
+ word: 'superkalifragilistischexpialigetisch',
+ maxLength: 5,
+ output: ['super', 'kalif', 'ragil', 'istis', 'chexp', 'ialig', 'etisc', 'h'],
+ },
+ {
+ word: 'Este es un ejemplo de texto en español para la prueba',
+ maxLength: 8,
+ output: ['Este es ', 'un ejemp', 'lo de te', 'xto en e', 'spañol p', 'ara la p', 'rueba'],
+ },
+ ];
+
+ testCases.forEach(({word, maxLength, output}) => {
+ test(`should split ${word} into ${output.join()} with maxLength of ${maxLength}`, () => {
+ expect(splitLongWord(word, maxLength)).toEqual(output);
+ });
+ });
+});
diff --git a/web/index.html b/web/index.html
index 25a650c4412d..c15f79b428a7 100644
--- a/web/index.html
+++ b/web/index.html
@@ -19,6 +19,14 @@
<% if (htmlWebpackPlugin.options.isStaging) { %>
<% } %>
+
+ <% if (htmlWebpackPlugin.options.isWeb && (htmlWebpackPlugin.options.isStaging || htmlWebpackPlugin.options.isProduction)) { %>
+
+
+
+ <% } %>
+