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/binoculars.svg b/assets/images/binoculars.svg
new file mode 100644
index 000000000000..64977dee38b5
--- /dev/null
+++ b/assets/images/binoculars.svg
@@ -0,0 +1,25 @@
+
+
\ 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/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/bank-accounts-and-payments/payments/Reimburse-Reports.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports.md
index afe366fb1dbe..41dc52a4239c 100644
--- a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports.md
+++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports.md
@@ -14,7 +14,7 @@ Before a report can be reimbursed via direct deposit:
To reimburse a report via direct deposit (USD):
1. Open the report.
-2. Click the **Reimburse** button and select **Via Direct Deposit (ACH)**.
+2. Click the **Reimburse** button and select **Via Direct Deposit**.
3. Confirm that the correct bank account is listed in the dropdown menu.
4. Click **Accept Terms & Pay**.
@@ -27,7 +27,7 @@ Before a report can be reimbursed via global reimbursement:
To reimburse a report via global reimbursement:
1. Open the report.
-2. Click the **Reimburse** button and select **Via Direct Deposit (ACH)**.
+2. Click the **Reimburse** button and select **Via Direct Deposit**.
3. Confirm that the correct bank account is listed in the dropdown menu.
4. Click **Accept Terms & Pay**.
diff --git a/docs/articles/expensify-classic/connections/quickbooks-desktop/Connect-To-QuickBooks-Desktop.md b/docs/articles/expensify-classic/connections/quickbooks-desktop/Connect-To-QuickBooks-Desktop.md
index bda84eb0a49f..30785330a9ad 100644
--- a/docs/articles/expensify-classic/connections/quickbooks-desktop/Connect-To-QuickBooks-Desktop.md
+++ b/docs/articles/expensify-classic/connections/quickbooks-desktop/Connect-To-QuickBooks-Desktop.md
@@ -52,6 +52,10 @@ For this step, it is key to ensure that the correct company file is open in Quic
![The Web Connector pop-up, where you will need to click "Yes"](https://help.expensify.com/assets/images/QBO_desktop_07.png){:width="100%"}
+{% include info.html %}
+Be sure to securely save this password in a trusted password manager. You'll need it for future configuration updates or troubleshooting. Having it easily accessible will help avoid delays and ensure a smoother workflow.
+{% include end-info.html %}
+
# FAQ
## What are the hardware and software requirements for the QuickBooks Desktop connector?
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 43c8da9bddd5..42f887a827d1 100644
--- a/fastlane/Appfile
+++ b/fastlane/Appfile
@@ -1,9 +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
-
-for_lane :build_hybrid, :build_unsigned_hybrid, :upload_testflight_hybrid do
- app_identifier("com.expensify.expensifylite")
-end
+# 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 3c85aa5ff3d1..e90fdbe50255 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -191,6 +191,25 @@ platform :android do
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"
@@ -369,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,
@@ -402,11 +422,13 @@ platform :ios do
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,
+ reject_build_waiting_for_review: true,
changelog: "Thank you for beta testing New Expensify, this version includes bug fixes and improvements.",
- groups: ["Beta"],
+ groups: ["Applause", "Beta Testers", "Expensify Employees"],
demo_account_required: true,
beta_app_review_info: {
contact_email: ENV["APPLE_CONTACT_EMAIL"],
@@ -433,9 +455,10 @@ platform :ios do
)
end
- desc "Submit app to App Store Review"
+ 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.55
+ 9.0.57CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 9.0.55.0
+ 9.0.57.3FullStoryOrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index eef74d4f127c..6d7ff5c4ac09 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 9.0.55
+ 9.0.57CFBundleSignature????CFBundleVersion
- 9.0.55.0
+ 9.0.57.3
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 33f6517f0329..605eb605529c 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName$(PRODUCT_NAME)CFBundleShortVersionString
- 9.0.55
+ 9.0.57CFBundleVersion
- 9.0.55.0
+ 9.0.57.3NSExtensionNSExtensionPointIdentifier
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 fbdba9487652..7d7c82747222 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,17 +1,17 @@
{
"name": "new.expensify",
- "version": "9.0.55-0",
+ "version": "9.0.57-3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.0.55-0",
+ "version": "9.0.57-3",
"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 9e24776bbf42..b3a47c387b6c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.0.55-0",
+ "version": "9.0.57-3",
"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+019+Android-onHostResume-resume-frame-callback.patch b/patches/react-native+0.75.2+019+Android-onHostResume-resume-frame-callback.patch
new file mode 100644
index 000000000000..3bea9012e9d5
--- /dev/null
+++ b/patches/react-native+0.75.2+019+Android-onHostResume-resume-frame-callback.patch
@@ -0,0 +1,23 @@
+diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/FabricEventDispatcher.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/FabricEventDispatcher.java
+index ce8520e..e2b22b0 100644
+--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/FabricEventDispatcher.java
++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/FabricEventDispatcher.java
+@@ -122,6 +122,7 @@ public class FabricEventDispatcher implements EventDispatcher, LifecycleEventLis
+ @Override
+ public void onHostResume() {
+ maybePostFrameCallbackFromNonUI();
++ mCurrentFrameCallback.resume();
+ }
+
+ @Override
+@@ -190,6 +191,10 @@ public class FabricEventDispatcher implements EventDispatcher, LifecycleEventLis
+ mShouldStop = true;
+ }
+
++ public void resume() {
++ mShouldStop = false;
++ }
++
+ public void maybePost() {
+ if (!mIsPosted) {
+ mIsPosted = true;
diff --git a/src/App.tsx b/src/App.tsx
index 177cc00c7dee..643e2146e501 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -50,6 +50,8 @@ LogBox.ignoreLogs([
// the timer is lost. Currently Expensify is using a 30 minutes interval to refresh personal details.
// More details here: https://git.io/JJYeb
'Setting a timer for a long period of time',
+ // We are not using expo-const, so ignore the warning.
+ 'No native ExponentConstants module found',
]);
const fill = {flex: 1};
diff --git a/src/CONFIG.ts b/src/CONFIG.ts
index d82a261c2ec6..8a30c8bf57c2 100644
--- a/src/CONFIG.ts
+++ b/src/CONFIG.ts
@@ -97,9 +97,9 @@ export default {
},
GCP_GEOLOCATION_API_KEY: googleGeolocationAPIKey,
FIREBASE_WEB_CONFIG: {
- apiKey: get(Config, 'FB_API_KEY', 'AIzaSyDxzigVLZl4G8MP7jACQ0qpmADMzmrrON0'),
- appId: get(Config, 'FB_APP_ID', '1:921154746561:web:7b8213357d07d6e4027c40'),
- projectId: get(Config, 'FB_PROJECT_ID', 'expensify-chat'),
+ apiKey: get(Config, 'FB_API_KEY', 'AIzaSyBrLKgCuo6Vem6Xi5RPokdumssW8HaWBow'),
+ appId: get(Config, 'FB_APP_ID', '1:1008697809946:web:ca25268d2645fc285445a3'),
+ projectId: get(Config, 'FB_PROJECT_ID', 'expensify-mobile-app'),
},
// to read more about StrictMode see: contributingGuides/STRICT_MODE.md
USE_REACT_STRICT_MODE_IN_DEV: false,
diff --git a/src/CONST.ts b/src/CONST.ts
index 8e240ac43ca1..fbe12d1fdfb2 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -77,6 +77,12 @@ const onboardingChoices = {
...backendOnboardingChoices,
} as const;
+const combinedTrackSubmitOnboardingChoices = {
+ PERSONAL_SPEND: selectableOnboardingChoices.PERSONAL_SPEND,
+ EMPLOYER: selectableOnboardingChoices.EMPLOYER,
+ SUBMIT: backendOnboardingChoices.SUBMIT,
+} as const;
+
const signupQualifiers = {
INDIVIDUAL: 'individual',
VSB: 'vsb',
@@ -127,6 +133,95 @@ const onboardingEmployerOrSubmitMessage: OnboardingMessageType = {
],
};
+const combinedTrackSubmitOnboardingEmployerOrSubmitMessage: OnboardingMessageType = {
+ ...onboardingEmployerOrSubmitMessage,
+ tasks: [
+ {
+ type: 'submitExpense',
+ autoCompleted: false,
+ title: 'Submit an expense',
+ description:
+ '*Submit an expense* by entering an amount or scanning a receipt.\n' +
+ '\n' +
+ 'Here’s how to submit 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. Add your reimburser to the request.\n' +
+ '5. Click *Submit*.\n' +
+ '\n' +
+ 'And you’re done! Now wait for that sweet “Cha-ching!” when it’s complete.',
+ },
+ {
+ type: 'addBankAccount',
+ autoCompleted: false,
+ title: 'Add personal bank account',
+ description:
+ 'You’ll need to add your personal bank account to get paid back. Don’t worry, it’s easy!\n' +
+ '\n' +
+ 'Here’s how to set up your bank account:\n' +
+ '\n' +
+ '1. Click your profile picture.\n' +
+ '2. Click *Wallet* > *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',
@@ -505,10 +620,9 @@ const CONST = {
COMPANY_CARD_FEEDS: 'companyCardFeeds',
DIRECT_FEEDS: 'directFeeds',
NETSUITE_USA_TAX: 'netsuiteUsaTax',
- NEW_DOT_COPILOT: 'newDotCopilot',
- WORKSPACE_RULES: 'workspaceRules',
COMBINED_TRACK_SUBMIT: 'combinedTrackSubmit',
CATEGORY_AND_TAG_APPROVERS: 'categoryAndTagApprovers',
+ PER_DIEM: 'newDotPerDiem',
},
BUTTON_STATES: {
DEFAULT: 'default',
@@ -541,6 +655,7 @@ const CONST = {
ANDROID: 'android',
WEB: 'web',
DESKTOP: 'desktop',
+ MOBILEWEB: 'mobileweb',
},
PLATFORM_SPECIFIC_KEYS: {
CTRL: {
@@ -763,8 +878,10 @@ const CONST = {
// Use Environment.getEnvironmentURL to get the complete URL with port number
DEV_NEW_EXPENSIFY_URL: 'https://dev.new.expensify.com:',
NAVATTIC: {
- ADMIN_TOUR: 'https://expensify.navattic.com/kh204a7',
- EMPLOYEE_TOUR: 'https://expensify.navattic.com/35609gb',
+ ADMIN_TOUR_PRODUCTION: 'https://expensify.navattic.com/kh204a7',
+ ADMIN_TOUR_STAGING: 'https://expensify.navattic.com/3i300k18',
+ EMPLOYEE_TOUR_PRODUCTION: 'https://expensify.navattic.com/35609gb',
+ EMPLOYEE_TOUR_STAGING: 'https://expensify.navattic.com/cf15002s',
},
OLDDOT_URLS: {
@@ -1098,6 +1215,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',
@@ -1178,7 +1296,13 @@ const CONST = {
PENDING: 'Pending',
POSTED: 'Posted',
},
+ STATE: {
+ CURRENT: 'current',
+ DRAFT: 'draft',
+ BACKUP: 'backup',
+ },
},
+
MCC_GROUPS: {
AIRLINES: 'Airlines',
COMMUTER: 'Commuter',
@@ -2576,6 +2700,11 @@ const CONST = {
INDIVIDUAL: 'individual',
NONE: 'none',
},
+ VERIFICATION_STATE: {
+ LOADING: 'loading',
+ VERIFIED: 'verified',
+ ON_WAITLIST: 'onWaitlist',
+ },
STATE: {
STATE_NOT_ISSUED: 2,
OPEN: 3,
@@ -2590,6 +2719,7 @@ const CONST = {
MONTHLY: 'monthly',
FIXED: 'fixed',
},
+ LIMIT_VALUE: 21474836,
STEP_NAMES: ['1', '2', '3', '4', '5', '6'],
STEP: {
ASSIGNEE: 'Assignee',
@@ -2607,7 +2737,6 @@ const CONST = {
DAILY: 'daily',
MONTHLY: 'monthly',
},
- CARD_TITLE_INPUT_LIMIT: 255,
MANAGE_EXPENSIFY_CARDS_ARTICLE_LINK: 'https://help.expensify.com/articles/new-expensify/expensify-card/Manage-Expensify-Cards',
},
COMPANY_CARDS: {
@@ -2837,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}`);
@@ -2925,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,
@@ -4239,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',
@@ -4672,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},
@@ -4715,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,
@@ -4827,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: {
@@ -4925,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,
@@ -5799,6 +5935,11 @@ const CONST = {
IN: 'in',
},
EMPTY_VALUE: 'none',
+ SEARCH_ROUTER_ITEM_TYPE: {
+ CONTEXTUAL_SUGGESTION: 'contextualSuggestion',
+ AUTOCOMPLETE_SUGGESTION: 'autocompleteSuggestion',
+ SEARCH: 'searchItem',
+ },
},
REFERRER: {
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 2e895537eaac..45501bf46374 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -951,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 feded7c81a47..dea0f028e1a0 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -532,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..9a90de17595d 100644
--- a/src/components/AccountSwitcher.tsx
+++ b/src/components/AccountSwitcher.tsx
@@ -5,7 +5,6 @@ import {useOnyx} from 'react-native-onyx';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
-import usePermissions from '@hooks/usePermissions';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -34,7 +33,6 @@ function AccountSwitcher() {
const theme = useTheme();
const {translate} = useLocalize();
const {isOffline} = useNetwork();
- const {canUseNewDotCopilot} = usePermissions();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const [account] = useOnyx(ONYXKEYS.ACCOUNT);
const [session] = useOnyx(ONYXKEYS.SESSION);
@@ -47,7 +45,7 @@ function AccountSwitcher() {
const delegators = account?.delegatedAccess?.delegators ?? [];
const isActingAsDelegate = !!account?.delegatedAccess?.delegate ?? false;
- const canSwitchAccounts = canUseNewDotCopilot && (delegators.length > 0 || isActingAsDelegate);
+ const canSwitchAccounts = delegators.length > 0 || isActingAsDelegate;
const createBaseMenuItem = (
personalDetails: PersonalDetails | undefined,
@@ -87,7 +85,7 @@ function AccountSwitcher() {
}
const delegatePersonalDetails = PersonalDetailsUtils.getPersonalDetailByEmail(delegateEmail);
- const error = ErrorUtils.getLatestErrorField(account?.delegatedAccess, 'connect');
+ const error = ErrorUtils.getLatestError(account?.delegatedAccess?.errorFields?.disconnect);
return [
createBaseMenuItem(delegatePersonalDetails, error, {
@@ -105,8 +103,9 @@ function AccountSwitcher() {
const delegatorMenuItems: PopoverMenuItem[] = delegators
.filter(({email}) => email !== currentUserPersonalDetails.login)
- .map(({email, role, errorFields}) => {
- const error = ErrorUtils.getLatestErrorField({errorFields}, 'connect');
+ .map(({email, role}) => {
+ const errorFields = account?.delegatedAccess?.errorFields ?? {};
+ const error = ErrorUtils.getLatestError(errorFields?.connect?.[email]);
const personalDetails = PersonalDetailsUtils.getPersonalDetailByEmail(email);
return createBaseMenuItem(personalDetails, error, {
badgeText: translate('delegate.role', {role}),
@@ -152,7 +151,7 @@ function AccountSwitcher() {
>
{currentUserPersonalDetails?.displayName}
- {canSwitchAccounts && (
+ {!!canSwitchAccounts && (
- {canSwitchAccounts && (
+ {!!canSwitchAccounts && (
{
diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx
index 4848577bdea0..a230dfa1af8d 100644
--- a/src/components/AmountForm.tsx
+++ b/src/components/AmountForm.tsx
@@ -1,6 +1,6 @@
import type {ForwardedRef} from 'react';
import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react';
-import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native';
+import type {NativeSyntheticEvent} from 'react-native';
import {View} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -16,7 +16,7 @@ import TextInput from './TextInput';
import isTextInputFocused from './TextInput/BaseTextInput/isTextInputFocused';
import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types';
import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol';
-import type TextInputWithCurrencySymbolProps from './TextInputWithCurrencySymbol/types';
+import type BaseTextInputWithCurrencySymbolProps from './TextInputWithCurrencySymbol/types';
type AmountFormProps = {
/** Amount supplied by the FormProvider */
@@ -51,7 +51,7 @@ type AmountFormProps = {
/** Number of decimals to display */
fixedDecimals?: number;
-} & Pick &
+} & Pick &
Pick;
/**
@@ -290,11 +290,11 @@ function AmountForm(
}}
selectedCurrencyCode={currency}
selection={selection}
- onSelectionChange={(e: NativeSyntheticEvent) => {
+ onSelectionChange={(start, end) => {
if (!shouldUpdateSelection) {
return;
}
- setSelection(e.nativeEvent.selection);
+ setSelection({start, end});
}}
onKeyPress={textInputKeyPress}
isCurrencyPressable={isCurrencyPressable}
diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx
index d3a51c7fc0f0..5305155ae495 100644
--- a/src/components/AttachmentPicker/index.native.tsx
+++ b/src/components/AttachmentPicker/index.native.tsx
@@ -16,6 +16,8 @@ import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import CONST from '@src/CONST';
@@ -33,46 +35,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 +113,16 @@ 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 StyleUtils = useStyleUtils();
+ const theme = useTheme();
- 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 +148,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 +205,7 @@ function AttachmentPicker({
}
});
}),
- [showGeneralAlert, type],
+ [fileLimit, showGeneralAlert, type],
);
/**
* Launch the DocumentPicker. Results are in the same format as ImagePicker
@@ -209,7 +214,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 +222,7 @@ function AttachmentPicker({
showGeneralAlert(error.message);
throw error;
}),
- [showGeneralAlert, type],
+ [fileLimit, showGeneralAlert, type],
);
const menuItemData: Item[] = useMemo(() => {
@@ -261,7 +266,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 +291,7 @@ function AttachmentPicker({
}
return getDataForUpload(fileData)
.then((result) => {
- completeAttachmentSelection.current(result);
+ completeAttachmentSelection.current([result]);
})
.catch((error: Error) => {
showGeneralAlert(error.message);
@@ -301,63 +306,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],
);
/**
@@ -428,6 +448,7 @@ function AttachmentPicker({
title={translate(item.textTranslationKey)}
onPress={() => selectItem(item)}
focused={focusedIndex === menuIndex}
+ wrapperStyle={StyleUtils.getItemBackgroundColorStyle(false, focusedIndex === menuIndex, theme.activeComponentBG, theme.hoverComponentBG)}
/>
))}
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 && (
[
- isFocused ? themeStyles.emojiItemKeyboardHighlighted : {},
- isHovered || isHighlighted ? themeStyles.emojiItemHighlighted : {},
+ isFocused || isHovered || isHighlighted ? themeStyles.emojiItemHighlighted : {},
Browser.isMobile() && StyleUtils.getButtonBackgroundColorStyle(getButtonState(false, pressed)),
themeStyles.emojiItem,
]}
diff --git a/src/components/FeatureList.tsx b/src/components/FeatureList.tsx
index fe209e90eb70..70c56ad5d963 100644
--- a/src/components/FeatureList.tsx
+++ b/src/components/FeatureList.tsx
@@ -131,7 +131,7 @@ function FeatureList({
large
/>
)}
- {ctaErrorMessage && (
+ {!!ctaErrorMessage && (
;
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx
index 3ab907dc767d..94a46d861dde 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx
@@ -10,10 +10,14 @@ import Text from '@components/Text';
import useCurrentReportID from '@hooks/useCurrentReportID';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
-import Navigation from '@navigation/Navigation';
+import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute';
+import type {RootStackParamList, State} from '@libs/Navigation/types';
+import Navigation, {navigationRef} from '@navigation/Navigation';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
+import type {Route} from '@src/ROUTES';
+import SCREENS from '@src/SCREENS';
import type {Report} from '@src/types/onyx';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import MentionReportContext from './MentionReportContext';
@@ -69,7 +73,12 @@ function MentionReportRenderer({style, tnode, TDefaultRenderer, ...defaultRender
}
const {reportID, mentionDisplayText} = mentionDetails;
- const navigationRoute = reportID ? ROUTES.REPORT_WITH_ID.getRoute(reportID) : undefined;
+ let navigationRoute: Route | undefined = reportID ? ROUTES.REPORT_WITH_ID.getRoute(reportID) : undefined;
+ const topmostCentralPaneRoute = getTopmostCentralPaneRoute(navigationRef.getRootState() as State);
+ const backTo = Navigation.getActiveRoute();
+ if (topmostCentralPaneRoute?.name === SCREENS.SEARCH.CENTRAL_PANE) {
+ navigationRoute = reportID ? ROUTES.SEARCH_REPORT.getRoute({reportID, backTo}) : undefined;
+ }
const isCurrentRoomMention = reportID === currentReportIDValue;
const flattenStyle = StyleSheet.flatten(style as TextStyle);
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/Expensicons.ts b/src/components/Icon/Expensicons.ts
index 90f0e0d8a151..fa531ce34adf 100644
--- a/src/components/Icon/Expensicons.ts
+++ b/src/components/Icon/Expensicons.ts
@@ -23,6 +23,7 @@ import Bed from '@assets/images/bed.svg';
import Bell from '@assets/images/bell.svg';
import BellSlash from '@assets/images/bellSlash.svg';
import Bill from '@assets/images/bill.svg';
+import Binoculars from '@assets/images/binoculars.svg';
import Bolt from '@assets/images/bolt.svg';
import Bookmark from '@assets/images/bookmark.svg';
import Box from '@assets/images/box.svg';
@@ -223,6 +224,7 @@ export {
Bill,
Bell,
BellSlash,
+ Binoculars,
Bolt,
Box,
Briefcase,
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,
+ headerSubtitle,
+ 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/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 04d5173e9c16..c2b3bd60cb99 100644
--- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx
+++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx
@@ -145,6 +145,7 @@ function ReimbursementAccountPage({route, policy}: ReimbursementAccountPageProps
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';
@@ -416,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 ;
}
@@ -537,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/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 && (
introSelected?.choice});
- const navatticLink = onboardingPurpose === CONST.SELECTABLE_ONBOARDING_CHOICES.MANAGE_TEAM ? CONST.NAVATTIC.ADMIN_TOUR : CONST.NAVATTIC.EMPLOYEE_TOUR;
+ const {environment} = useEnvironment();
+ const navatticURL = getNavatticURL(environment, onboardingPurpose);
const content = useMemo(() => {
switch (type) {
@@ -120,7 +123,7 @@ function EmptySearchView({type}: EmptySearchViewProps) {
title: translate('search.searchResults.emptyExpenseResults.title'),
subtitle: translate('search.searchResults.emptyExpenseResults.subtitle'),
buttons: [
- {buttonText: translate('emptySearchView.takeATour'), buttonAction: () => Link.openExternalLink(navatticLink)},
+ {buttonText: translate('emptySearchView.takeATour'), buttonAction: () => Link.openExternalLink(navatticURL)},
{
buttonText: translate('iou.createExpense'),
buttonAction: () => interceptAnonymousUser(() => IOU.startMoneyRequest(CONST.IOU.TYPE.CREATE, ReportUtils.generateReportID())),
@@ -140,7 +143,7 @@ function EmptySearchView({type}: EmptySearchViewProps) {
headerContentStyles: styles.emptyStateFolderWebStyles,
};
}
- }, [type, StyleUtils, translate, theme, styles, subtitleComponent, ctaErrorMessage, navatticLink]);
+ }, [type, StyleUtils, translate, theme, styles, subtitleComponent, ctaErrorMessage, navatticURL]);
return (
{
SearchActions.clearAdvancedFilters();
+ Navigation.dismissModal();
Navigation.navigate(
ROUTES.SEARCH_CENTRAL_PANE.getRoute({
query: q,
- name: newName,
+ name: newName?.trim(),
}),
);
};
@@ -38,7 +39,7 @@ function SavedSearchRenamePage({route}: {route: {params: {q: string; name: strin
SearchActions.saveSearch({
queryJSON,
- newName,
+ newName: newName?.trim() || q,
});
applyFiltersAndNavigate();
diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx
index 2289eb7b0a85..4c68d84a0520 100644
--- a/src/pages/Search/SearchPage.tsx
+++ b/src/pages/Search/SearchPage.tsx
@@ -41,7 +41,7 @@ function SearchPage({route}: SearchPageProps) {
onBackButtonPress={handleOnBackButtonPress}
shouldShowLink={false}
>
- {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 d141920ac99b..4c3ed5c705a5 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';
@@ -104,6 +104,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);
@@ -219,6 +220,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});
@@ -418,6 +420,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.
@@ -440,6 +455,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]);
@@ -577,7 +598,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;
}
@@ -597,6 +618,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
prevReport?.chatType,
prevReport,
reportIDFromRoute,
+ lastReportIDFromRoute,
isFocused,
isDeletedParentAction,
prevIsDeletedParentAction,
@@ -698,6 +720,9 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
const lastRoute = usePrevious(route);
const lastReportActionIDFromRoute = usePrevious(reportActionIDFromRoute);
+ const onComposerFocus = useCallback(() => setIsComposerFocus(true), []);
+ const onComposerBlur = useCallback(() => setIsComposerFocus(false), []);
+
// Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger.
// If we have cached reportActions, they will be shown immediately.
// We aim to display a loader first, then fetch relevant reportActions, and finally show them.
@@ -731,7 +756,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) && (
@@ -756,7 +781,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
style={[styles.flex1, styles.justifyContentEnd, styles.overflowHidden]}
testID="report-actions-view-wrapper"
>
- {!shouldShowSkeleton && report && (
+ {!shouldShowSkeleton && !!report && (
setIsComposerFocus(true)}
- onComposerBlur={() => setIsComposerFocus(false)}
+ onComposerFocus={onComposerFocus}
+ onComposerBlur={onComposerBlur}
report={report}
reportMetadata={reportMetadata}
policy={policy}
diff --git a/src/pages/home/report/LinkPreviewer.tsx b/src/pages/home/report/LinkPreviewer.tsx
index 209096d2fa15..aa55eab4c798 100644
--- a/src/pages/home/report/LinkPreviewer.tsx
+++ b/src/pages/home/report/LinkPreviewer.tsx
@@ -54,13 +54,13 @@ function LinkPreviewer({linkMetadata = [], maxAmountOfPreviews = -1}: LinkPrevie
key={url}
>
- {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 9ae2eaa2eaad..1cb70fe6c926 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
@@ -1,6 +1,6 @@
import {useIsFocused, useNavigation} from '@react-navigation/native';
import lodashDebounce from 'lodash/debounce';
-import type {ForwardedRef, MutableRefObject, RefAttributes, RefObject} from 'react';
+import type {ForwardedRef, MutableRefObject, RefObject} from 'react';
import React, {forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import type {
LayoutChangeEvent,
@@ -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 {useOnyx, withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import {useAnimatedRef, useSharedValue} from 'react-native-reanimated';
import type {Emoji} from '@assets/emojis/types';
import type {FileObject} from '@components/AttachmentModal';
@@ -65,113 +65,95 @@ type SyncSelection = {
type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string};
-type ComposerWithSuggestionsOnyxProps = {
- /** The parent report actions for the report */
- parentReportActions: OnyxEntry;
+type ComposerWithSuggestionsProps = Partial & {
+ /** Report ID */
+ reportID: string;
- /** The modal state */
- modal: OnyxEntry;
+ /** Callback to focus composer */
+ onFocus: () => void;
- /** The preferred skin tone of the user */
- preferredSkinTone: number;
+ /** Callback to blur composer */
+ onBlur: (event: NativeSyntheticEvent) => void;
- /** Whether the input is focused */
- editFocused: OnyxEntry;
-};
-
-type ComposerWithSuggestionsProps = ComposerWithSuggestionsOnyxProps &
- Partial & {
- /** Report ID */
- reportID: string;
-
- /** Callback to focus composer */
- onFocus: () => void;
-
- /** Callback to blur composer */
- onBlur: (event: NativeSyntheticEvent) => void;
-
- /** Callback when layout of composer changes */
- onLayout?: (event: LayoutChangeEvent) => void;
+ /** Callback when layout of composer changes */
+ onLayout?: (event: LayoutChangeEvent) => void;
- /** Callback to update the value of the composer */
- onValueChange: (value: string) => void;
+ /** Callback to update the value of the composer */
+ onValueChange: (value: string) => void;
- /** Callback when the composer got cleared on the UI thread */
- onCleared?: (text: string) => void;
+ /** Callback when the composer got cleared on the UI thread */
+ onCleared?: (text: string) => void;
- /** Whether the composer is full size */
- isComposerFullSize: boolean;
+ /** Whether the composer is full size */
+ isComposerFullSize: boolean;
- /** Whether the menu is visible */
- isMenuVisible: boolean;
+ /** Whether the menu is visible */
+ isMenuVisible: boolean;
- /** The placeholder for the input */
- inputPlaceholder: string;
+ /** The placeholder for the input */
+ inputPlaceholder: string;
- /** Function to display a file in a modal */
- displayFileInModal: (file: FileObject) => void;
+ /** Function to display a file in a modal */
+ displayFileInModal: (file: FileObject) => void;
- /** Whether the user is blocked from concierge */
- isBlockedFromConcierge: boolean;
+ /** Whether the user is blocked from concierge */
+ isBlockedFromConcierge: boolean;
- /** Whether the input is disabled */
- disabled: boolean;
+ /** Whether the input is disabled */
+ disabled: boolean;
- /** Whether the full composer is available */
- isFullComposerAvailable: boolean;
+ /** Whether the full composer is available */
+ isFullComposerAvailable: boolean;
- /** Function to set whether the full composer is available */
- setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void;
+ /** Function to set whether the full composer is available */
+ setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void;
- /** Function to set whether the comment is empty */
- setIsCommentEmpty: (isCommentEmpty: boolean) => void;
+ /** Function to set whether the comment is empty */
+ setIsCommentEmpty: (isCommentEmpty: boolean) => void;
- /** Function to handle sending a message */
- handleSendMessage: () => void;
+ /** Function to handle sending a message */
+ handleSendMessage: () => void;
- /** Whether the compose input should show */
- shouldShowComposeInput: OnyxEntry;
+ /** Whether the compose input should show */
+ shouldShowComposeInput: OnyxEntry;
- /** Function to measure the parent container */
- measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void;
+ /** Function to measure the parent container */
+ measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void;
- /** Whether the scroll is likely to trigger a layout */
- isScrollLikelyLayoutTriggered: RefObject;
+ /** Whether the scroll is likely to trigger a layout */
+ isScrollLikelyLayoutTriggered: RefObject;
- /** Function to raise the scroll is likely layout triggered */
- raiseIsScrollLikelyLayoutTriggered: () => void;
+ /** Function to raise the scroll is likely layout triggered */
+ raiseIsScrollLikelyLayoutTriggered: () => void;
- /** The ref to the suggestions */
- suggestionsRef: React.RefObject;
+ /** The ref to the suggestions */
+ suggestionsRef: React.RefObject;
- /** The ref to the next modal will open */
- isNextModalWillOpenRef: MutableRefObject;
+ /** The ref to the next modal will open */
+ isNextModalWillOpenRef: MutableRefObject;
- /** Whether the edit is focused */
- editFocused: boolean;
+ /** Wheater chat is empty */
+ isEmptyChat?: boolean;
- /** Wheater chat is empty */
- isEmptyChat?: boolean;
+ /** The last report action */
+ lastReportAction?: OnyxEntry;
- /** The last report action */
- lastReportAction?: OnyxEntry;
+ /** Whether to include chronos */
+ includeChronos?: boolean;
- /** Whether to include chronos */
- includeChronos?: boolean;
+ /** The parent report action ID */
+ parentReportActionID?: string;
- /** The parent report action ID */
- parentReportActionID?: string;
+ /** The parent report ID */
+ // eslint-disable-next-line react/no-unused-prop-types -- its used in the withOnyx HOC
+ parentReportID: string | undefined;
- /** The parent report ID */
- // eslint-disable-next-line react/no-unused-prop-types -- its used in the withOnyx HOC
- parentReportID: string | undefined;
+ /** Whether report is from group policy */
+ isGroupPolicyReport: boolean;
- /** Whether report is from group policy */
- isGroupPolicyReport: boolean;
-
- /** policy ID of the report */
- policyID: string;
- };
+ /** policy ID of the report */
+ policyID: string;
+};
type SwitchToCurrentReportProps = {
preexistingReportID: string;
@@ -223,13 +205,9 @@ const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
*/
function ComposerWithSuggestions(
{
- // Onyx
- modal,
- preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE,
- parentReportActions,
-
// Props: Report
reportID,
+ parentReportID,
includeChronos,
isEmptyChat,
lastReportAction,
@@ -263,7 +241,6 @@ function ComposerWithSuggestions(
// Refs
suggestionsRef,
isNextModalWillOpenRef,
- editFocused,
// For testing
children,
@@ -290,6 +267,13 @@ function ComposerWithSuggestions(
});
const commentRef = useRef(value);
+ const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
+ const [modal] = useOnyx(ONYXKEYS.MODAL);
+ const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, {selector: EmojiUtils.getPreferredSkinToneIndex});
+ const [editFocused] = useOnyx(ONYXKEYS.INPUT_FOCUSED);
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID || '-1'}`, {canEvict: false, initWithStoredValues: false});
+
const lastTextRef = useRef(value);
useEffect(() => {
lastTextRef.current = value;
@@ -298,8 +282,7 @@ 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 parentReportAction = useMemo(() => parentReportActions?.[parentReportActionID ?? '-1'], [parentReportActionID, parentReportActions]);
const shouldAutoFocus =
!modal?.isVisible &&
Modal.areAllModalsHidden() &&
@@ -653,6 +636,7 @@ function ComposerWithSuggestions(
const prevIsModalVisible = usePrevious(modal?.isVisible);
const prevIsFocused = usePrevious(isFocused);
+
useEffect(() => {
if (modal?.isVisible && !prevIsModalVisible) {
// eslint-disable-next-line react-compiler/react-compiler, no-param-reassign
@@ -683,6 +667,7 @@ function ComposerWithSuggestions(
updateMultilineInputRange(textInputRef.current, !!shouldAutoFocus);
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, []);
+
useImperativeHandle(
ref,
() => ({
@@ -836,24 +821,6 @@ function ComposerWithSuggestions(
ComposerWithSuggestions.displayName = 'ComposerWithSuggestions';
-const ComposerWithSuggestionsWithRef = forwardRef(ComposerWithSuggestions);
-
-export default withOnyx, ComposerWithSuggestionsOnyxProps>({
- modal: {
- key: ONYXKEYS.MODAL,
- },
- preferredSkinTone: {
- key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE,
- selector: EmojiUtils.getPreferredSkinToneIndex,
- },
- editFocused: {
- key: ONYXKEYS.INPUT_FOCUSED,
- },
- parentReportActions: {
- key: ({parentReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`,
- canEvict: false,
- initWithStoredValues: false,
- },
-})(memo(ComposerWithSuggestionsWithRef));
+export default memo(forwardRef(ComposerWithSuggestions));
export type {ComposerWithSuggestionsProps, ComposerRef};
diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
index 14908014ca03..23b059f2fda2 100644
--- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
+++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
@@ -393,6 +393,16 @@ function ReportActionCompose({
],
);
+ const onValueChange = useCallback(
+ (value: string) => {
+ if (value.length === 0 && isComposerFullSize) {
+ Report.setIsComposerFullSize(reportID, false);
+ }
+ validateCommentMaxLength(value, {reportID});
+ },
+ [isComposerFullSize, reportID, validateCommentMaxLength],
+ );
+
return (
@@ -490,12 +500,7 @@ function ReportActionCompose({
onFocus={onFocus}
onBlur={onBlur}
measureParentContainer={measureContainer}
- onValueChange={(value) => {
- if (value.length === 0 && isComposerFullSize) {
- Report.setIsComposerFullSize(reportID, false);
- }
- validateCommentMaxLength(value, {reportID});
- }}
+ onValueChange={onValueChange}
/>
{
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 0af9bd61120d..a0e2f65a89a0 100644
--- a/src/pages/home/report/ReportActionItem.tsx
+++ b/src/pages/home/report/ReportActionItem.tsx
@@ -961,6 +961,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 541c5d44c6b2..58e7fe319359 100644
--- a/src/pages/home/report/ReportActionsList.tsx
+++ b/src/pages/home/report/ReportActionsList.tsx
@@ -13,7 +13,7 @@ import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/InvertedFlatList/BaseInve
import {usePersonalDetails} from '@components/OnyxProvider';
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';
@@ -123,14 +123,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({
@@ -162,7 +154,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());
@@ -226,32 +219,81 @@ function ReportActionsList({
}, [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;
+ 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(reportAction) ? !reportAction.childLastActorAccountID : reportAction.actorAccountID);
- const isNewMessage = !prevSortedVisibleReportActionsObjects[reportAction.reportActionID];
+ 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[reportAction.reportActionID]?.isOptimisticAction && !reportAction.isOptimisticAction;
+ const isPreviouslyOptimistic = !!prevSortedVisibleReportActionsObjects[message.reportActionID]?.isOptimisticAction && !message.isOptimisticAction;
const shouldIgnoreUnreadForCurrentUserMessage = !prevUnreadMarkerReportActionID.current && isFromCurrentUser && (isNewMessage || isPreviouslyOptimistic);
- return shouldDisplay && isWithinVisibleThreshold && !shouldIgnoreUnreadForCurrentUserMessage;
+ 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
@@ -261,7 +303,7 @@ function ReportActionsList({
}
return null;
- }, [sortedVisibleReportActions, unreadMarkerTime, accountID, prevSortedVisibleReportActionsObjects]);
+ }, [accountID, earliestReceivedOfflineMessageIndex, prevSortedVisibleReportActionsObjects, sortedVisibleReportActions, unreadMarkerTime]);
prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID;
/**
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/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx
index a49b474b185e..5899429cf23f 100644
--- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx
+++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx
@@ -1,21 +1,23 @@
import {useIsFocused as useIsFocusedOriginal, useNavigationState} from '@react-navigation/native';
import type {ImageContentFit} from 'expo-image';
-import type {ForwardedRef, RefAttributes} from 'react';
+import type {ForwardedRef} from 'react';
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
-import {useOnyx, withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import type {SvgProps} from 'react-native-svg';
import FloatingActionButton from '@components/FloatingActionButton';
import * as Expensicons from '@components/Icon/Expensicons';
import type {PopoverMenuItem} from '@components/PopoverMenu';
import PopoverMenu from '@components/PopoverMenu';
import Text from '@components/Text';
+import useEnvironment from '@hooks/useEnvironment';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import usePermissions from '@hooks/usePermissions';
import usePrevious from '@hooks/usePrevious';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import getIconForAction from '@libs/getIconForAction';
@@ -23,14 +25,19 @@ import interceptAnonymousUser from '@libs/interceptAnonymousUser';
import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute';
import Navigation from '@libs/Navigation/Navigation';
import type {CentralPaneName, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types';
+import {hasSeenTourSelector} from '@libs/onboardingSelectors';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
import * as SubscriptionUtils from '@libs/SubscriptionUtils';
+import {getNavatticURL} from '@libs/TourUtils';
+import variables from '@styles/variables';
import * as App from '@userActions/App';
import * as IOU from '@userActions/IOU';
+import * as Link from '@userActions/Link';
import * as Policy from '@userActions/Policy/Policy';
import * as Report from '@userActions/Report';
import * as Task from '@userActions/Task';
+import * as Welcome from '@userActions/Welcome';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -39,6 +46,7 @@ import SCREENS from '@src/SCREENS';
import type * as OnyxTypes from '@src/types/onyx';
import type {QuickActionName} from '@src/types/onyx/QuickAction';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import mapOnyxCollectionItems from '@src/utils/mapOnyxCollectionItems';
// On small screen we hide the search page from central pane to show the search bottom tab page with bottom tab bar.
// We need to take this in consideration when checking if the screen is focused.
@@ -51,33 +59,7 @@ const useIsFocused = () => {
type PolicySelector = Pick;
-type FloatingActionButtonAndPopoverOnyxProps = {
- /** The list of policies the user has access to. */
- allPolicies: OnyxCollection;
-
- /** Whether app is in loading state */
- isLoading: OnyxEntry;
-
- /** Information on the last taken action to display as Quick Action */
- quickAction: OnyxEntry;
-
- /** The report data of the quick action */
- quickActionReport: OnyxEntry;
-
- /** The policy data of the quick action */
- quickActionPolicy: OnyxEntry;
-
- /** The current session */
- session: OnyxEntry;
-
- /** Personal details of all the users */
- personalDetails: OnyxEntry;
-
- /** Has user seen track expense training interstitial */
- hasSeenTrackTraining: OnyxEntry;
-};
-
-type FloatingActionButtonAndPopoverProps = FloatingActionButtonAndPopoverOnyxProps & {
+type FloatingActionButtonAndPopoverProps = {
/* Callback function when the menu is shown */
onShowCreateMenu?: () => void;
@@ -161,24 +143,20 @@ const getQuickActionTitle = (action: QuickActionName): TranslationPaths => {
* Responsible for rendering the {@link PopoverMenu}, and the accompanying
* FAB that can open or close the menu.
*/
-function FloatingActionButtonAndPopover(
- {
- onHideCreateMenu,
- onShowCreateMenu,
- isLoading = false,
- allPolicies,
- quickAction,
- quickActionReport,
- quickActionPolicy,
- session,
- personalDetails,
- hasSeenTrackTraining,
- }: FloatingActionButtonAndPopoverProps,
- ref: ForwardedRef,
-) {
+function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: FloatingActionButtonAndPopoverProps, ref: ForwardedRef) {
const styles = useThemeStyles();
+ const theme = useTheme();
const {translate} = useLocalize();
+ const [isLoading = false] = useOnyx(ONYXKEYS.IS_LOADING_APP);
+ const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
+ const [session] = useOnyx(ONYXKEYS.SESSION);
+ const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE);
+ const [quickActionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${quickAction?.chatReportID}`);
const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${quickActionReport?.reportID ?? -1}`);
+ const [quickActionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${quickActionReport?.policyID}`);
+ const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: (c) => mapOnyxCollectionItems(c, policySelector)});
+ const [hasSeenTrackTraining] = useOnyx(ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING);
+
const [isCreateMenuActive, setIsCreateMenuActive] = useState(false);
const fabRef = useRef(null);
const {windowHeight} = useWindowDimensions();
@@ -189,6 +167,12 @@ function FloatingActionButtonAndPopover(
const {canUseSpotnanaTravel, canUseCombinedTrackSubmit} = usePermissions();
const canSendInvoice = useMemo(() => PolicyUtils.canSendInvoice(allPolicies as OnyxCollection, session?.email), [allPolicies, session?.email]);
+ const {environment} = useEnvironment();
+ const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
+ const navatticURL = getNavatticURL(environment, introSelected?.choice);
+ const [hasSeenTour = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {
+ selector: hasSeenTourSelector,
+ });
const quickActionAvatars = useMemo(() => {
if (quickActionReport) {
@@ -454,14 +438,29 @@ function FloatingActionButtonAndPopover(
},
]
: []),
+ ...(!hasSeenTour
+ ? [
+ {
+ icon: Expensicons.Binoculars,
+ iconStyles: styles.popoverIconCircle,
+ iconFill: theme.icon,
+ text: translate('tour.takeATwoMinuteTour'),
+ description: translate('tour.exploreExpensify'),
+ onSelected: () => {
+ Welcome.setSelfTourViewed();
+ Link.openExternalLink(navatticURL);
+ },
+ },
+ ]
+ : []),
...(!isLoading && !Policy.hasActiveChatEnabledPolicies(allPolicies)
? [
{
displayInDefaultIconColor: true,
contentFit: 'contain' as ImageContentFit,
icon: Expensicons.NewWorkspace,
- iconWidth: 46,
- iconHeight: 40,
+ iconWidth: variables.w46,
+ iconHeight: variables.h40,
text: translate('workspace.new.newWorkspace'),
description: translate('workspace.new.getTheExpensifyCardAndMore'),
onSelected: () => interceptAnonymousUser(() => App.createWorkspaceWithPolicyDraftAndNavigateToIt()),
@@ -510,32 +509,6 @@ function FloatingActionButtonAndPopover(
FloatingActionButtonAndPopover.displayName = 'FloatingActionButtonAndPopover';
-export default withOnyx, FloatingActionButtonAndPopoverOnyxProps>({
- allPolicies: {
- key: ONYXKEYS.COLLECTION.POLICY,
- selector: policySelector,
- },
- isLoading: {
- key: ONYXKEYS.IS_LOADING_APP,
- },
- quickAction: {
- key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE,
- },
- quickActionReport: {
- key: ({quickAction}) => `${ONYXKEYS.COLLECTION.REPORT}${quickAction?.chatReportID}`,
- },
- quickActionPolicy: {
- key: ({quickActionReport}) => `${ONYXKEYS.COLLECTION.POLICY}${quickActionReport?.policyID}`,
- },
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
- session: {
- key: ONYXKEYS.SESSION,
- },
- hasSeenTrackTraining: {
- key: ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING,
- },
-})(forwardRef(FloatingActionButtonAndPopover));
+export default forwardRef(FloatingActionButtonAndPopover);
export type {PolicySelector};
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..65e041180408 100644
--- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx
+++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx
@@ -1,7 +1,6 @@
import {useIsFocused} from '@react-navigation/core';
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import FormHelpMessage from '@components/FormHelpMessage';
import useLocalize from '@hooks/useLocalize';
import usePermissions from '@hooks/usePermissions';
@@ -26,13 +25,7 @@ import withFullTransactionOrNotFound from './withFullTransactionOrNotFound';
import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound';
import withWritableReportOrNotFound from './withWritableReportOrNotFound';
-type IOURequestStepParticipantsOnyxProps = {
- /** Whether the confirmation step should be skipped */
- skipConfirmation: OnyxEntry;
-};
-
-type IOURequestStepParticipantsProps = IOURequestStepParticipantsOnyxProps &
- WithWritableReportOrNotFoundProps &
+type IOURequestStepParticipantsProps = WithWritableReportOrNotFoundProps &
WithFullTransactionOrNotFoundProps;
function IOURequestStepParticipants({
@@ -40,13 +33,13 @@ function IOURequestStepParticipants({
params: {iouType, reportID, transactionID, action},
},
transaction,
- skipConfirmation,
}: IOURequestStepParticipantsProps) {
const participants = transaction?.participants;
const {translate} = useLocalize();
const styles = useThemeStyles();
const isFocused = useIsFocused();
const {canUseP2PDistanceRequests} = usePermissions(iouType);
+ const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? -1}`);
// We need to set selectedReportID if user has navigated back from confirmation page and navigates to confirmation page with already selected participant
const selectedReportID = useRef(participants?.length === 1 ? participants.at(0)?.reportID ?? reportID : reportID);
@@ -161,6 +154,8 @@ function IOURequestStepParticipants({
return;
}
+ const rateID = DistanceRequestUtils.getCustomUnitRateID(selfDMReportID, !canUseP2PDistanceRequests);
+ IOU.setCustomUnitRateID(transactionID, rateID);
IOU.setMoneyRequestParticipantsFromReport(transactionID, ReportUtils.getReport(selfDMReportID));
const iouConfirmationPageRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, CONST.IOU.TYPE.TRACK, transactionID, selfDMReportID);
Navigation.navigate(iouConfirmationPageRoute);
@@ -183,7 +178,7 @@ function IOURequestStepParticipants({
testID={IOURequestStepParticipants.displayName}
includeSafeAreaPaddingBottom={false}
>
- {skipConfirmation && (
+ {!!skipConfirmation && (
({
- skipConfirmation: {
- key: ({route}) => {
- const transactionID = route.params.transactionID ?? -1;
- return `${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`;
- },
- },
-})(IOURequestStepParticipants);
-
-export default withWritableReportOrNotFound(withFullTransactionOrNotFound(IOURequestStepParticipantsWithOnyx));
+export default withWritableReportOrNotFound(withFullTransactionOrNotFound(IOURequestStepParticipants));
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
index d9f65c451f3c..d3f0c9cb496d 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
@@ -560,7 +560,7 @@ function IOURequestStepScan({
testID={IOURequestStepScan.displayName}
>
{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')} = RouteProp;
+type Route = RouteProp;
-type WithFullTransactionOrNotFoundProps = WithFullTransactionOrNotFoundOnyxProps & {route: Route};
+type WithFullTransactionOrNotFoundProps = WithFullTransactionOrNotFoundOnyxProps & {
+ route: Route;
+};
-export default function , TRef>(WrappedComponent: ComponentType>) {
+export default function , TRef>(
+ WrappedComponent: ComponentType>,
+): React.ComponentType & RefAttributes> {
// eslint-disable-next-line rulesdir/no-negated-variables
- function WithFullTransactionOrNotFound(props: TProps, ref: ForwardedRef) {
- const transactionID = props.transaction?.transactionID;
+ function WithFullTransactionOrNotFound(props: Omit, ref: ForwardedRef) {
+ const {route} = props;
+ const transactionID = route.params.transactionID ?? -1;
+ const userAction = 'action' in route.params && route.params.action ? route.params.action : CONST.IOU.ACTION.CREATE;
+
+ const shouldUseTransactionDraft = IOUUtils.shouldUseTransactionDraft(userAction);
+ const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`);
+ const [transactionDraft] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`);
const isFocused = useIsFocused();
@@ -55,31 +65,19 @@ export default function ;
}
-
return (
);
}
WithFullTransactionOrNotFound.displayName = `withFullTransactionOrNotFound(${getComponentDisplayName(WrappedComponent)})`;
- // eslint-disable-next-line deprecation/deprecation
- return withOnyx, WithFullTransactionOrNotFoundOnyxProps>({
- transaction: {
- key: ({route}) => {
- const transactionID = route.params.transactionID ?? -1;
- const userAction = 'action' in route.params && route.params.action ? route.params.action : CONST.IOU.ACTION.CREATE;
- if (IOUUtils.shouldUseTransactionDraft(userAction)) {
- return `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}` as `${typeof ONYXKEYS.COLLECTION.TRANSACTION}${string}`;
- }
- return `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`;
- },
- },
- })(forwardRef(WithFullTransactionOrNotFound));
+ return forwardRef(WithFullTransactionOrNotFound);
}
export type {WithFullTransactionOrNotFoundProps};
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/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..e466b862ae9a 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,44 +14,36 @@ 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');
+ const addDelegateErrors = account?.delegatedAccess?.errorFields?.addDelegate?.[login];
+ const validateLoginError = ErrorUtils.getLatestError(addDelegateErrors);
useEffect(() => {
- if (!currentDelegate || !!currentDelegate.pendingFields?.email || !!currentDelegate.errorFields?.addDelegate) {
+ if (!currentDelegate || !!currentDelegate.pendingFields?.email || !!addDelegateErrors) {
return;
}
// Dismiss modal on successful magic code verification
Navigation.navigate(ROUTES.SETTINGS_SECURITY);
- }, [login, currentDelegate, role]);
+ }, [login, currentDelegate, role, addDelegateErrors]);
const onBackButtonPress = () => {
onClose?.();
- setIsValidateCodeActionModalVisible(false);
};
const clearError = () => {
if (!validateLoginError) {
return;
}
- Delegate.clearAddDelegateErrors(currentDelegate?.email ?? '', 'addDelegate');
- };
-
- const sendValidateCode = () => {
- if (currentDelegate?.validateCodeSent) {
- return;
- }
-
- User.requestValidateCodeAction();
+ Delegate.clearDelegateErrorsByField(currentDelegate?.email ?? '', 'addDelegate');
};
return (
@@ -61,7 +53,8 @@ function DelegateMagicCodeModal({login, role, onClose}: DelegateMagicCodeModalPr
validateError={validateLoginError}
isVisible={isValidateCodeActionModalVisible}
title={translate('delegate.makeSureItIsYou')}
- sendValidateCode={sendValidateCode}
+ sendValidateCode={() => 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/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodePage.tsx b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodePage.tsx
index 2c1bc55e0e92..3bc82e8d7e65 100644
--- a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodePage.tsx
+++ b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodePage.tsx
@@ -28,15 +28,16 @@ function UpdateDelegateMagicCodePage({route}: UpdateDelegateMagicCodePageProps)
const validateCodeFormRef = useRef(null);
const currentDelegate = account?.delegatedAccess?.delegates?.find((d) => d.email === login);
+ const updateDelegateErrors = account?.delegatedAccess?.errorFields?.addDelegate?.[login];
useEffect(() => {
- if (!currentDelegate || !!currentDelegate.pendingFields?.role || !!currentDelegate.errorFields?.updateDelegateRole) {
+ if (!currentDelegate || !!currentDelegate.pendingFields?.role || !!updateDelegateErrors) {
return;
}
// Dismiss modal on successful magic code verification
Navigation.dismissModal();
- }, [login, currentDelegate, role]);
+ }, [login, currentDelegate, role, updateDelegateErrors]);
const onBackButtonPress = () => {
Navigation.goBack(ROUTES.SETTINGS_UPDATE_DELEGATE_ROLE.getRoute(login, role));
diff --git a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx
index 4c07803ef0e3..7c35d1478eb2 100644
--- a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx
+++ b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/ValidateCodeForm/BaseValidateCodeForm.tsx
@@ -66,7 +66,8 @@ function BaseValidateCodeForm({autoComplete = 'one-time-code', innerRef = () =>
const focusTimeoutRef = useRef(null);
const currentDelegate = account?.delegatedAccess?.delegates?.find((d) => d.email === delegate);
- const validateLoginError = ErrorUtils.getLatestErrorField(currentDelegate, 'updateDelegateRole');
+ const errorFields = account?.delegatedAccess?.errorFields ?? {};
+ const validateLoginError = ErrorUtils.getLatestError(errorFields.updateDelegateRole?.[currentDelegate?.email ?? '']);
const shouldDisableResendValidateCode = !!isOffline || currentDelegate?.isLoading;
@@ -127,7 +128,7 @@ function BaseValidateCodeForm({autoComplete = 'one-time-code', innerRef = () =>
setValidateCode(text);
setFormError({});
if (validateLoginError) {
- Delegate.clearAddDelegateErrors(currentDelegate?.email ?? '', 'updateDelegateRole');
+ Delegate.clearDelegateErrorsByField(currentDelegate?.email ?? '', 'updateDelegateRole');
}
},
[currentDelegate?.email, validateLoginError],
diff --git a/src/pages/settings/Security/SecuritySettingsPage.tsx b/src/pages/settings/Security/SecuritySettingsPage.tsx
index 42352594db7a..cd8e7c14d882 100644
--- a/src/pages/settings/Security/SecuritySettingsPage.tsx
+++ b/src/pages/settings/Security/SecuritySettingsPage.tsx
@@ -21,12 +21,11 @@ import Section from '@components/Section';
import Text from '@components/Text';
import TextLink from '@components/TextLink';
import useLocalize from '@hooks/useLocalize';
-import usePermissions from '@hooks/usePermissions';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import useWaitForNavigation from '@hooks/useWaitForNavigation';
import useWindowDimensions from '@hooks/useWindowDimensions';
-import {clearAddDelegateErrors, removeDelegate} from '@libs/actions/Delegate';
+import {clearDelegateErrorsByField, removeDelegate} from '@libs/actions/Delegate';
import * as ErrorUtils from '@libs/ErrorUtils';
import getClickedTargetLocation from '@libs/getClickedTargetLocation';
import {formatPhoneNumber} from '@libs/LocalePhoneNumber';
@@ -46,7 +45,6 @@ function SecuritySettingsPage() {
const {translate} = useLocalize();
const waitForNavigate = useWaitForNavigation();
const {shouldUseNarrowLayout} = useResponsiveLayout();
- const {canUseNewDotCopilot} = usePermissions();
const {windowWidth} = useWindowDimensions();
const personalDetails = usePersonalDetails();
@@ -56,6 +54,7 @@ function SecuritySettingsPage() {
const [shouldShowDelegatePopoverMenu, setShouldShowDelegatePopoverMenu] = useState(false);
const [shouldShowRemoveDelegateModal, setShouldShowRemoveDelegateModal] = useState(false);
const [selectedDelegate, setSelectedDelegate] = useState();
+ const errorFields = account?.delegatedAccess?.errorFields ?? {};
const [anchorPosition, setAnchorPosition] = useState({
anchorPositionHorizontal: 0,
@@ -136,9 +135,10 @@ function SecuritySettingsPage() {
() =>
delegates
.filter((d) => !d.optimisticAccountID)
- .map(({email, role, pendingAction, errorFields, pendingFields}) => {
+ .map(({email, role, pendingAction, pendingFields}) => {
const personalDetail = getPersonalDetailByEmail(email);
- const error = ErrorUtils.getLatestErrorField({errorFields}, 'addDelegate');
+ const addDelegateErrors = errorFields?.addDelegate?.[email];
+ const error = ErrorUtils.getLatestError(addDelegateErrors);
const onPress = (e: GestureResponderEvent | KeyboardEvent) => {
if (isEmptyObject(pendingAction)) {
@@ -171,14 +171,14 @@ function SecuritySettingsPage() {
shouldShowRightIcon: true,
pendingAction,
shouldForceOpacity: !!pendingAction,
- onPendingActionDismiss: () => clearAddDelegateErrors(email, 'addDelegate'),
+ onPendingActionDismiss: () => clearDelegateErrorsByField(email, 'addDelegate'),
error,
onPress,
};
}),
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
- [delegates, translate, styles, personalDetails],
+ [delegates, translate, styles, personalDetails, errorFields],
);
const delegatorMenuItems: MenuItemProps[] = useMemo(
@@ -236,50 +236,48 @@ function SecuritySettingsPage() {
shouldUseSingleExecution
/>
- {canUseNewDotCopilot && (
-
- (
-
- {translate('delegate.copilotDelegatedAccessDescription')}
-
- {translate('common.learnMore')}
-
-
- )}
- isCentralPane
- subtitleMuted
- titleStyles={styles.accountSettingsSectionTitle}
- childrenStyles={styles.pt5}
- >
- {hasDelegates && (
- <>
- {translate('delegate.membersCanAccessYourAccount')}
-
- >
- )}
- {!isActingAsDelegate && (
- Navigation.navigate(ROUTES.SETTINGS_ADD_DELEGATE)}
- shouldShowRightIcon
- wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mb6]}
- />
- )}
- {hasDelegators && (
- <>
- {translate('delegate.youCanAccessTheseAccounts')}
-
- >
- )}
-
-
- )}
+
+ (
+
+ {translate('delegate.copilotDelegatedAccessDescription')}
+
+ {translate('common.learnMore')}
+
+
+ )}
+ isCentralPane
+ subtitleMuted
+ titleStyles={styles.accountSettingsSectionTitle}
+ childrenStyles={styles.pt5}
+ >
+ {hasDelegates && (
+ <>
+ {translate('delegate.membersCanAccessYourAccount')}
+
+ >
+ )}
+ {!isActingAsDelegate && (
+ Navigation.navigate(ROUTES.SETTINGS_ADD_DELEGATE)}
+ shouldShowRightIcon
+ wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mb6]}
+ />
+ )}
+ {hasDelegators && (
+ <>
+ {translate('delegate.youCanAccessTheseAccounts')}
+
+ >
+ )}
+
+ }
diff --git a/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.tsx b/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.tsx
index 52b000e08699..6ae779fce480 100644
--- a/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.tsx
+++ b/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.tsx
@@ -69,7 +69,7 @@ function CodesStep({backTo}: CodesStepProps) {
onBackButtonPress={() => 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 87b8eb39edcb..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';
@@ -57,28 +54,24 @@ function LimitStep() {
errors.limit = translate('iou.error.invalidIntegerAmount');
}
+ if (Number(values.limit) > CONST.EXPENSIFY_CARD.LIMIT_VALUE) {
+ errors.limit = translate('workspace.card.issueNewCard.cardLimitError');
+ }
return errors;
},
[translate],
);
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..e079fdee90a0 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) => (
navigateToDetails(memberCard)}
shouldShowRightIcon
/>
diff --git a/src/pages/workspace/members/WorkspaceMemberNewCardPage.tsx b/src/pages/workspace/members/WorkspaceMemberNewCardPage.tsx
index 24a2b12cb4d0..9bee3108c7e5 100644
--- a/src/pages/workspace/members/WorkspaceMemberNewCardPage.tsx
+++ b/src/pages/workspace/members/WorkspaceMemberNewCardPage.tsx
@@ -46,6 +46,7 @@ function WorkspaceMemberNewCardPage({route, personalDetails}: WorkspaceMemberNew
const accountID = Number(route.params.accountID);
const memberLogin = personalDetails?.[accountID]?.login ?? '';
+ const availableCompanyCards = CardUtils.removeExpensifyCardFromCompanyCards(cardFeeds?.settings?.companyCards);
const handleSubmit = () => {
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 && (
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 651c18ff90e2..4db8033c1c11 100644
--- a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx
+++ b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx
@@ -264,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..b08987459a1a 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -1798,6 +1798,13 @@ const styles = (theme: ThemeColors) =>
alignItems: 'center',
},
+ popoverIconCircle: {
+ backgroundColor: theme.buttonDefaultBG,
+ borderRadius: variables.buttonBorderRadius,
+ height: variables.h40,
+ width: variables.w46,
+ },
+
rightLabelMenuItem: {
fontSize: variables.fontSizeLabel,
color: theme.textSupporting,
@@ -3008,6 +3015,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/utils/index.ts b/src/styles/utils/index.ts
index 65faa941866a..f517a19c5ebf 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -1119,6 +1119,19 @@ function getAmountWidth(amount: string): number {
return width;
}
+function getItemBackgroundColorStyle(isSelected: boolean, isFocused: boolean, selectedBG: string, focusedBG: string): ViewStyle {
+ let backgroundColor;
+ if (isSelected) {
+ backgroundColor = selectedBG;
+ } else if (isFocused) {
+ backgroundColor = focusedBG;
+ }
+
+ return {
+ backgroundColor,
+ };
+}
+
const staticStyleUtils = {
positioning,
combineStyles,
@@ -1193,6 +1206,7 @@ const staticStyleUtils = {
getAmountWidth,
getBorderRadiusStyle,
getHighResolutionInfoWrapperStyle,
+ getItemBackgroundColorStyle,
};
const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 4e9bf50e3c8e..164b161d8824 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
@@ -264,6 +266,7 @@ export default {
h20: 20,
h28: 28,
h36: 36,
+ h40: 40,
h112: 112,
h172: 172,
w20: 20,
@@ -271,6 +274,7 @@ export default {
w36: 36,
w40: 40,
w44: 44,
+ w46: 46,
w52: 52,
w80: 80,
w92: 92,
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/onyx/Account.ts b/src/types/onyx/Account.ts
index b856ed9010dd..3902d67882c4 100644
--- a/src/types/onyx/Account.ts
+++ b/src/types/onyx/Account.ts
@@ -20,9 +20,6 @@ type Delegate = OnyxCommon.OnyxValueWithOfflineFeedback<{
/** Whether the user validation code was sent */
validateCodeSent?: boolean;
- /** Field-specific server side errors keyed by microtime */
- errorFields?: OnyxCommon.ErrorFields;
-
/** Whether the user is loading */
isLoading?: boolean;
@@ -30,6 +27,24 @@ type Delegate = OnyxCommon.OnyxValueWithOfflineFeedback<{
optimisticAccountID?: number;
}>;
+/** Delegate errors */
+type DelegateErrors = {
+ /** Errors while adding a delegate keyed by email */
+ addDelegate?: Record;
+
+ /** Errors while updating a delegate's role keyed by email */
+ updateDelegateRole?: Record;
+
+ /** Errors while removing a delegate keyed by email */
+ removeDelegate?: Record;
+
+ /** Errors while connecting as a delegate keyed by email */
+ connect?: Record;
+
+ /** Errors while disconnecting as a delegate. No email needed here. */
+ disconnect?: OnyxCommon.Errors;
+};
+
/** Model of delegated access data */
type DelegatedAccess = {
/** The users that can access your account as a delegate */
@@ -41,8 +56,8 @@ type DelegatedAccess = {
/** The email of original user when they are acting as a delegate for another account */
delegate?: string;
- /** Authentication failure errors when disconnecting as a copilot */
- errorFields?: OnyxCommon.ErrorFields;
+ /** Field-specific server side errors keyed by microtime */
+ errorFields?: DelegateErrors;
};
/** Model of user account */
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/Onboarding.ts b/src/types/onyx/Onboarding.ts
index 4b6a52f25cb4..2cf8eccba1c2 100644
--- a/src/types/onyx/Onboarding.ts
+++ b/src/types/onyx/Onboarding.ts
@@ -11,6 +11,9 @@ type Onboarding = {
/** A string that informs which qualifier the user selected during sign up */
signupQualifier: ValueOf;
+
+ /** A Boolean that tells whether the user has seen navattic tour */
+ selfTourViewed?: boolean;
};
export default Onboarding;
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/Request.ts b/src/types/onyx/Request.ts
index 238e3a8c6a81..764696acbc8a 100644
--- a/src/types/onyx/Request.ts
+++ b/src/types/onyx/Request.ts
@@ -55,6 +55,11 @@ type RequestData = {
shouldSkipWebProxy?: boolean;
};
+/**
+ * Represents the possible actions to take in case of a conflict in the request queue.
+ */
+type ConflictData = ConflictRequestReplace | ConflictRequestDelete | ConflictRequestPush | ConflictRequestNoAction;
+
/**
* Model of a conflict request that has to be replaced in the request queue.
*/
@@ -68,6 +73,36 @@ type ConflictRequestReplace = {
* The index of the request in the queue to update.
*/
index: number;
+
+ /**
+ * The new request to replace the existing request in the queue.
+ */
+ request?: Request;
+};
+
+/**
+ * Model of a conflict request that needs to be deleted from the request queue.
+ */
+type ConflictRequestDelete = {
+ /**
+ * The action to take in case of a conflict.
+ */
+ type: 'delete';
+
+ /**
+ * The indices of the requests in the queue that are to be deleted.
+ */
+ indices: number[];
+
+ /**
+ * A flag to mark if the new request should be pushed into the queue after deleting the conflicting requests.
+ */
+ pushNewRequest: boolean;
+
+ /**
+ * The next action to execute after the current conflict is resolved.
+ */
+ nextAction?: ConflictData;
};
/**
@@ -97,7 +132,7 @@ type ConflictActionData = {
/**
* The action to take in case of a conflict.
*/
- conflictAction: ConflictRequestReplace | ConflictRequestPush | ConflictRequestNoAction;
+ conflictAction: ConflictData;
};
/**
@@ -115,6 +150,11 @@ type RequestConflictResolver = {
* the ongoing request, it will be removed from the persisted request queue.
*/
persistWhenOngoing?: boolean;
+
+ /**
+ * A boolean flag to mark a request as rollbacked, if set to true it means the request failed and was added back into the queue.
+ */
+ isRollbacked?: boolean;
};
/** Model of requests sent to the API */
@@ -147,4 +187,4 @@ type PaginatedRequest = Request &
};
export default Request;
-export type {OnyxData, RequestType, PaginationConfig, PaginatedRequest, RequestConflictResolver, ConflictActionData};
+export type {OnyxData, RequestType, PaginationConfig, PaginatedRequest, RequestConflictResolver, ConflictActionData, ConflictData};
diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts
index fe559aab3aa9..7702da678105 100644
--- a/src/types/onyx/index.ts
+++ b/src/types/onyx/index.ts
@@ -13,6 +13,7 @@ import type Card from './Card';
import type {CardList, IssueNewCard, WorkspaceCardsList} from './Card';
import type CardFeeds from './CardFeeds';
import type {AddNewCompanyCardFeed, CompanyCardFeed} from './CardFeeds';
+import type CardOnWaitlist from './CardOnWaitlist';
import type {CapturedLogs, Log} from './Console';
import type Credentials from './Credentials';
import type Currency from './Currency';
@@ -119,6 +120,7 @@ export type {
BlockedFromConcierge,
Card,
CardList,
+ CardOnWaitlist,
Credentials,
Currency,
CurrencyList,
diff --git a/src/types/utils/TransactionStateType.ts b/src/types/utils/TransactionStateType.ts
new file mode 100644
index 000000000000..5564de95c001
--- /dev/null
+++ b/src/types/utils/TransactionStateType.ts
@@ -0,0 +1,6 @@
+import type {ValueOf} from 'type-fest';
+import type CONST from '@src/CONST';
+
+type TransactionStateType = ValueOf;
+
+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/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
index 97db970bbb43..2571b03089b1 100644
--- a/tests/unit/SearchAutocompleteParserTest.ts
+++ b/tests/unit/SearchAutocompleteParserTest.ts
@@ -9,6 +9,20 @@ const tests = [
ranges: [],
},
},
+ {
+ query: ',',
+ expected: {
+ autocomplete: null,
+ ranges: [],
+ },
+ },
+ {
+ query: 'tag:,,',
+ expected: {
+ autocomplete: null,
+ ranges: [],
+ },
+ },
{
query: 'type:expense status:all',
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/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)) { %>
+
+
+
+ <% } %>
+