diff --git a/.github/actions/composite/announceFailedWorkflowInSlack/action.yml b/.github/actions/composite/announceFailedWorkflowInSlack/action.yml index 12895597b947..4069db93e1dd 100644 --- a/.github/actions/composite/announceFailedWorkflowInSlack/action.yml +++ b/.github/actions/composite/announceFailedWorkflowInSlack/action.yml @@ -9,7 +9,7 @@ inputs: runs: using: composite steps: - - uses: 8398a7/action-slack@7dc038dcfbfff230b5f4d0e756653f822038fef5 + - uses: 8398a7/action-slack@v3 name: Job failed Slack notification with: status: custom @@ -20,7 +20,7 @@ runs: attachments: [{ color: "#DB4545", pretext: ``, - text: `💥 ${process.env.AS_REPO} failed on ${process.env.AS_WORKFLOW_RUN} workflow 💥`, + text: `💥 ${process.env.AS_REPO} failed on workflow 💥`, }] } env: diff --git a/android/app/build.gradle b/android/app/build.gradle index f41e0e7f8fec..5046aac66445 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -156,8 +156,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001026301 - versionName "1.2.63-1" + versionCode 1001026706 + versionName "1.2.67-6" buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() if (isNewArchitectureEnabled()) { diff --git a/assets/images/simple-illustrations/simple-illustration__shield.svg b/assets/images/simple-illustrations/simple-illustration__shield.svg new file mode 100644 index 000000000000..5d56b9c3acb2 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__shield.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js index 8334cfd33a46..84f0989328ad 100644 --- a/config/webpack/webpack.common.js +++ b/config/webpack/webpack.common.js @@ -8,6 +8,7 @@ const CopyPlugin = require('copy-webpack-plugin'); const dotenv = require('dotenv'); const {BundleAnalyzerPlugin} = require('webpack-bundle-analyzer'); const HtmlInlineScriptPlugin = require('html-inline-script-webpack-plugin'); +const FontPreloadPlugin = require('webpack-font-preload-plugin'); const CustomVersionFilePlugin = require('./CustomVersionFilePlugin'); const includeModules = [ @@ -79,6 +80,9 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ new HtmlInlineScriptPlugin({ scriptMatchPattern: [/splash.+[.]js$/], }), + new FontPreloadPlugin({ + extensions: ['woff2'], + }), new ProvidePlugin({ process: 'process/browser', }), @@ -152,22 +156,16 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ // Rule for react-native-web-webview { test: /postMock.html$/, - use: { - loader: 'file-loader', - options: { - name: '[name].[ext]', - }, + type: 'asset', + generator: { + filename: '[name].[ext]', }, }, // Gives the ability to load local images { test: /\.(png|jpe?g|gif)$/i, - use: [ - { - loader: 'file-loader', - }, - ], + type: 'asset', }, // Load svg images diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md index dc531040812e..8c305e159ef9 100644 --- a/contributingGuides/CONTRIBUTING.md +++ b/contributingGuides/CONTRIBUTING.md @@ -111,11 +111,18 @@ Additionally if you want to discuss an idea with the open source community witho 3. If you cannot reproduce the problem, pause on this step and add a comment to the issue explaining where you are stuck or that you don't think the issue can be reproduced. #### Propose a solution for the job -4. After you reproduce the issue, make a proposal for your solution and post it as a comment in the corresponding GitHub issue (linked in the Upwork job). Your solution proposal should include a brief written technical explanation of the changes you will make. Include "Proposal" as the first word in your comment. +4. After you reproduce the issue, complete the [proposal template here](./PROPOSAL_TEMPLATE.md) and post it as a comment in the corresponding GitHub issue (linked in the Upwork job). - Note: Before submitting a proposal on an issue, be sure to read any other existing proposals. Any new proposal should be substantively different from existing proposals. -5. Pause at this step until someone from the Contributor-Plus team and / or someone from Expensify provides feedback on your proposal (do not create a pull request yet). -6. If your solution proposal is accepted by the Expensify engineer assigned to the issue, Expensify will hire you on Upwork and assign the GitHub issue to you. -7. Once hired, post a comment in the Github issue stating when you expect to have your PR ready for review +5. Refrain from leaving additional comments until someone from the Contributor-Plus team and / or someone from Expensify provides feedback on your proposal (do not create a pull request yet). + - Do not leave more than one proposal. + - Do not make extensive changes to your current proposal until after it has been reviewed. + - If you want to make an entirely new proposal or update an existing proposal, please go back and edit your original proposal, then post a new comment to the issue in this format to alert everyone that it has been updated: + ``` + ## Proposal + [Updated](link to proposal) + ``` +6. If your proposal is accepted by the Expensify engineer assigned to the issue, Expensify will hire you on Upwork and assign the GitHub issue to you. +7. Once hired, post a comment in the Github issue stating when you expect to have your PR ready for review. #### Begin coding your solution in a pull request 7. When you are ready to start, fork the repository and create a new branch. diff --git a/contributingGuides/FORMS.md b/contributingGuides/FORMS.md index cfcb76743312..53f2d87603a2 100644 --- a/contributingGuides/FORMS.md +++ b/contributingGuides/FORMS.md @@ -250,6 +250,15 @@ Form.js will automatically provide the following props to any input with the inp - onBlur: An onBlur handler that calls validate. - onInputChange: An onChange handler that saves draft values and calls validate for that input (inputA). Passing an inputID as a second param allows inputA to manipulate the input value of the provided inputID (inputB). +## Dynamic Form Inputs + +It's possible to conditionally render inputs (or more complex components with multiple inputs) inside a form. For example, an IdentityForm might be nested as input for a Form component. +In order for Form to track the nested values properly, each field must have a unique identifier. It's not safe to use an index because adding or removing fields from the child Form component will not update these internal keys. Therefore, we will need to define keys and dynamically access the correlating child form data for validation/submission. + +To generate these unique keys, use `Str.guid()`. + +An example of this can be seen in the [ACHContractStep](https://github.com/Expensify/App/blob/f2973f88cfc0d36c0dbe285201d3ed5e12f29d87/src/pages/ReimbursementAccount/ACHContractStep.js), where each key is stored in an array in state, and IdentityForms are dynamically rendered based on which keys are present in the array. + ### Safe Area Padding Any `Form.js` that has a button will also add safe area padding by default. If the `
` is inside a `` we will want to disable the default safe area padding applied there e.g. diff --git a/contributingGuides/PROPOSAL_TEMPLATE.md b/contributingGuides/PROPOSAL_TEMPLATE.md new file mode 100644 index 000000000000..53330dfe96c9 --- /dev/null +++ b/contributingGuides/PROPOSAL_TEMPLATE.md @@ -0,0 +1,33 @@ +## Proposal + +### Please re-state the problem that we are trying to solve in this issue. + +### What is the root cause of that problem? + +### What changes do you think we should make in order to solve the problem? + + +### What alternative solutions did you explore? (Optional) + +**Reminder:** Please use plain English, be brief and avoid jargon. Feel free to use images, charts or pseudo-code if necessary. Do not post large multi-line diffs or write walls of text. Do not create PRs unless you have been hired for this job. + + diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 6d5e5b26c6f3..d06f8954c050 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.2.63 + 1.2.67 CFBundleSignature ???? CFBundleURLTypes @@ -30,7 +30,7 @@ CFBundleVersion - 1.2.63.1 + 1.2.67.6 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 4a1d8af8ac9e..0f0ef17989c2 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.2.63 + 1.2.67 CFBundleSignature ???? CFBundleVersion - 1.2.63.1 + 1.2.67.6 diff --git a/package-lock.json b/package-lock.json index 7e91c3818c1b..4ffdcd338468 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "new.expensify", - "version": "1.2.63-1", + "version": "1.2.67-6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.2.63-1", + "version": "1.2.67-6", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@expensify/react-native-web": "0.18.10", + "@expensify/react-native-web": "0.18.12", "@formatjs/intl-getcanonicallocales": "^1.5.8", "@formatjs/intl-locale": "^2.4.21", "@formatjs/intl-numberformat": "^6.2.5", @@ -39,7 +39,6 @@ "domhandler": "^4.3.0", "expensify-common": "git+https://github.com/Expensify/expensify-common.git#ca07936e09ff4ebbaf4b423f43adfb35e17c799d", "fbjs": "^3.0.2", - "file-loader": "^6.0.0", "html-entities": "^1.3.1", "htmlparser2": "^7.2.0", "jest-when": "^3.5.2", @@ -63,7 +62,7 @@ "react-native-document-picker": "^8.0.0", "react-native-fast-image": "^8.6.3", "react-native-gesture-handler": "2.6.0", - "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#3bbd17d63e6c38d38d857b50f6037c1c0376ff06", + "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#e12768f1542e7982d90f6449798f0d6b7f18f192", "react-native-haptic-feedback": "^1.13.0", "react-native-image-pan-zoom": "^2.1.12", "react-native-image-picker": "^5.0.1", @@ -130,10 +129,10 @@ "clean-webpack-plugin": "^3.0.0", "concurrently": "^5.3.0", "copy-webpack-plugin": "^6.4.1", - "css-loader": "^5.2.4", + "css-loader": "^6.7.2", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", - "electron": "^21.4.0", + "electron": "^21.4.1", "electron-builder": "23.5.0", "electron-notarize": "^1.2.1", "eslint": "^7.6.0", @@ -164,6 +163,7 @@ "webpack-bundle-analyzer": "^4.5.0", "webpack-cli": "^4.10.0", "webpack-dev-server": "^4.9.3", + "webpack-font-preload-plugin": "^1.5.0", "webpack-merge": "^5.8.0" }, "engines": { @@ -2464,9 +2464,9 @@ "dev": true }, "node_modules/@expensify/react-native-web": { - "version": "0.18.10", - "resolved": "https://registry.npmjs.org/@expensify/react-native-web/-/react-native-web-0.18.10.tgz", - "integrity": "sha512-T810S0yEVCi9SGDBp6yZvkCKEmoLAL88T2ctz7wADwAcNo5T0dmrkIKrxb1WTpJxNDJ4PpGRLqvQE2uzi+B+tA==", + "version": "0.18.12", + "resolved": "https://registry.npmjs.org/@expensify/react-native-web/-/react-native-web-0.18.12.tgz", + "integrity": "sha512-0+4XdDTNM2/XsURL++qJyBrrmpsm/mgZ+z1QgPahBkS85DxHvUkj4fwFidN8kkONz5yn2URfr6PxVqAQ0dpETg==", "dependencies": { "@babel/runtime": "^7.18.6", "create-react-class": "^15.7.0", @@ -9383,6 +9383,34 @@ "integrity": "sha512-uMVxJ111wpHzkx/vshZFb6Qni3BOMnlWLq7q9jrwej7Yw/KvjsEbpxCCxw+hLKxexFMc8YmpG8J9tnEe/rKsIg==", "dev": true }, + "node_modules/@storybook/builder-webpack5/node_modules/css-loader": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz", + "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==", + "dev": true, + "dependencies": { + "icss-utils": "^5.1.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.15", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.1.0", + "schema-utils": "^3.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.27.0 || ^5.0.0" + } + }, "node_modules/@storybook/builder-webpack5/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -9392,6 +9420,18 @@ "node": ">=8" } }, + "node_modules/@storybook/builder-webpack5/node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/@storybook/builder-webpack5/node_modules/jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -9412,6 +9452,95 @@ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", "dev": true }, + "node_modules/@storybook/builder-webpack5/node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/@storybook/builder-webpack5/node_modules/postcss": { + "version": "8.4.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", + "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + } + ], + "dependencies": { + "nanoid": "^3.3.4", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/@storybook/builder-webpack5/node_modules/postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/@storybook/builder-webpack5/node_modules/postcss-modules-local-by-default": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz", + "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==", + "dev": true, + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/@storybook/builder-webpack5/node_modules/postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/@storybook/builder-webpack5/node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/@storybook/builder-webpack5/node_modules/serialize-javascript": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", @@ -12714,6 +12843,34 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/@storybook/manager-webpack5/node_modules/css-loader": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz", + "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==", + "dev": true, + "dependencies": { + "icss-utils": "^5.1.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.15", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.1.0", + "schema-utils": "^3.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.27.0 || ^5.0.0" + } + }, "node_modules/@storybook/manager-webpack5/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -12723,6 +12880,18 @@ "node": ">=8" } }, + "node_modules/@storybook/manager-webpack5/node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/@storybook/manager-webpack5/node_modules/jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -12752,6 +12921,95 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/@storybook/manager-webpack5/node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/@storybook/manager-webpack5/node_modules/postcss": { + "version": "8.4.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", + "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + } + ], + "dependencies": { + "nanoid": "^3.3.4", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/@storybook/manager-webpack5/node_modules/postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/@storybook/manager-webpack5/node_modules/postcss-modules-local-by-default": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz", + "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==", + "dev": true, + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/@storybook/manager-webpack5/node_modules/postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/@storybook/manager-webpack5/node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/@storybook/manager-webpack5/node_modules/serialize-javascript": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", @@ -20448,31 +20706,29 @@ } }, "node_modules/css-loader": { - "version": "5.2.7", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz", - "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==", + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.7.3.tgz", + "integrity": "sha512-qhOH1KlBMnZP8FzRO6YCH9UHXQhVMcEGLyNdb7Hv2cpcmJbW0YrddO+tG1ab5nT41KpHIYGsbeHqxB9xPu1pKQ==", "dev": true, "dependencies": { "icss-utils": "^5.1.0", - "loader-utils": "^2.0.0", - "postcss": "^8.2.15", + "postcss": "^8.4.19", "postcss-modules-extract-imports": "^3.0.0", "postcss-modules-local-by-default": "^4.0.0", "postcss-modules-scope": "^3.0.0", "postcss-modules-values": "^4.0.0", - "postcss-value-parser": "^4.1.0", - "schema-utils": "^3.0.0", - "semver": "^7.3.5" + "postcss-value-parser": "^4.2.0", + "semver": "^7.3.8" }, "engines": { - "node": ">= 10.13.0" + "node": ">= 12.13.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "webpack": "^4.27.0 || ^5.0.0" + "webpack": "^5.0.0" } }, "node_modules/css-loader/node_modules/icss-utils": { @@ -20494,9 +20750,9 @@ "dev": true }, "node_modules/css-loader/node_modules/postcss": { - "version": "8.4.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.16.tgz", - "integrity": "sha512-ipHE1XBvKzm5xI7hiHCZJCSugxvsdq2mPnsq5+UF+VHCjiBvtDrlxJfMBToWaP9D5XlgNmcFGqoHmUn0EYEaRQ==", + "version": "8.4.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", + "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", "dev": true, "funding": [ { @@ -21553,9 +21809,9 @@ } }, "node_modules/electron": { - "version": "21.4.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-21.4.0.tgz", - "integrity": "sha512-eko9hBBgJujF6er/LYhft1Os4NKaw/lcitF2HWjDsjw2B2aCxWLkw+gPslJVhW6OfusC/vzpvqi26XEgnVnnOg==", + "version": "21.4.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-21.4.1.tgz", + "integrity": "sha512-uhFf3vpE6th6X2E1NSIy1+dWVeS9gb7W8EWd/cn5MacEiv4aVY3gtypaglTaVhYPfnJfcD+v3Ql6gGvx4Efh6A==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -35555,8 +35811,8 @@ }, "node_modules/react-native-google-places-autocomplete": { "version": "2.4.1", - "resolved": "git+ssh://git@github.com/Expensify/react-native-google-places-autocomplete.git#3bbd17d63e6c38d38d857b50f6037c1c0376ff06", - "integrity": "sha512-Qs9TCeuH8+WkSLEBc/LhGANVL4fNL8RmzIcO/DWmajzgJixmZX3HVQtV2D9F0EiVPFI3jaPYjyo6xJtgvXNy5w==", + "resolved": "git+ssh://git@github.com/Expensify/react-native-google-places-autocomplete.git#e12768f1542e7982d90f6449798f0d6b7f18f192", + "integrity": "sha512-Lk5/8qqzPoqx9ygfHSYtqXFvXiWIY+1tnyoWHVbpyTlPrJOcfkFrELCXaDlokJJqg1luvTLoMtEA1pkDfHYVUg==", "license": "MIT", "dependencies": { "lodash.debounce": "^4.0.8", @@ -42089,6 +42345,236 @@ "webpack": "^4.0.0 || ^5.0.0" } }, + "node_modules/webpack-font-preload-plugin": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/webpack-font-preload-plugin/-/webpack-font-preload-plugin-1.5.0.tgz", + "integrity": "sha512-/Nh6MNa7/rbu3ZcqSR1SxB+G5XaITu7U2yZO5INTsVRpVlMLQmHQZCoDt4PP+iFyBdvBCDbA0CImRXHarQ0wpQ==", + "dev": true, + "dependencies": { + "jsdom": "^19.0.0", + "webpack-sources": "^3.2.2" + }, + "engines": { + "node": ">= 10.17.0" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/acorn": { + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", + "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true + }, + "node_modules/webpack-font-preload-plugin/node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "dev": true, + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/data-urls/node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dev": true, + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "dev": true, + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/jsdom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-19.0.0.tgz", + "integrity": "sha512-RYAyjCbxy/vri/CfnjUWJQQtZ3LKlLnDqj+9XLNnJPgEGeirZs3hllKR20re8LUZ6o1b1X4Jat+Qd26zmP41+A==", + "dev": true, + "dependencies": { + "abab": "^2.0.5", + "acorn": "^8.5.0", + "acorn-globals": "^6.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.1", + "decimal.js": "^10.3.1", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.0", + "parse5": "6.0.1", + "saxes": "^5.0.1", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.0.0", + "w3c-hr-time": "^1.0.2", + "w3c-xmlserializer": "^3.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^10.0.0", + "ws": "^8.2.3", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dev": true, + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/w3c-xmlserializer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-3.0.0.tgz", + "integrity": "sha512-3WFqGEgSXIyGhOmAFtlicJNMjEps8b1MG31NCA0/vOF9+nKMUW1ckhi9cnNHmf88Rzw5V+dwIwsm2C7X8k9aQg==", + "dev": true, + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/whatwg-url": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-10.0.0.tgz", + "integrity": "sha512-CLxxCmdUby142H5FZzn4D8ikO1cmypvXVQktsgosNy4a4BHrDHeciBBGZhb0bNoR5/MltoCatso+vFjjGx8t0w==", + "dev": true, + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/webpack-font-preload-plugin/node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/webpack-hot-middleware": { "version": "2.25.2", "resolved": "https://registry.npmjs.org/webpack-hot-middleware/-/webpack-hot-middleware-2.25.2.tgz", @@ -44492,9 +44978,9 @@ } }, "@expensify/react-native-web": { - "version": "0.18.10", - "resolved": "https://registry.npmjs.org/@expensify/react-native-web/-/react-native-web-0.18.10.tgz", - "integrity": "sha512-T810S0yEVCi9SGDBp6yZvkCKEmoLAL88T2ctz7wADwAcNo5T0dmrkIKrxb1WTpJxNDJ4PpGRLqvQE2uzi+B+tA==", + "version": "0.18.12", + "resolved": "https://registry.npmjs.org/@expensify/react-native-web/-/react-native-web-0.18.12.tgz", + "integrity": "sha512-0+4XdDTNM2/XsURL++qJyBrrmpsm/mgZ+z1QgPahBkS85DxHvUkj4fwFidN8kkONz5yn2URfr6PxVqAQ0dpETg==", "requires": { "@babel/runtime": "^7.18.6", "create-react-class": "^15.7.0", @@ -49712,12 +50198,37 @@ "integrity": "sha512-uMVxJ111wpHzkx/vshZFb6Qni3BOMnlWLq7q9jrwej7Yw/KvjsEbpxCCxw+hLKxexFMc8YmpG8J9tnEe/rKsIg==", "dev": true }, + "css-loader": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz", + "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==", + "dev": true, + "requires": { + "icss-utils": "^5.1.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.15", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.1.0", + "schema-utils": "^3.0.0", + "semver": "^7.3.5" + } + }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, + "icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "requires": {} + }, "jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -49735,6 +50246,59 @@ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", "dev": true }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "postcss": { + "version": "8.4.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", + "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "dev": true, + "requires": { + "nanoid": "^3.3.4", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + } + }, + "postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "dev": true, + "requires": {} + }, + "postcss-modules-local-by-default": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz", + "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==", + "dev": true, + "requires": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + } + }, + "postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.4" + } + }, + "postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "requires": { + "icss-utils": "^5.0.0" + } + }, "serialize-javascript": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", @@ -52341,12 +52905,37 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "css-loader": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz", + "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==", + "dev": true, + "requires": { + "icss-utils": "^5.1.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.15", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.1.0", + "schema-utils": "^3.0.0", + "semver": "^7.3.5" + } + }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, + "icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "requires": {} + }, "jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -52369,6 +52958,59 @@ } } }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "postcss": { + "version": "8.4.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", + "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "dev": true, + "requires": { + "nanoid": "^3.3.4", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + } + }, + "postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "dev": true, + "requires": {} + }, + "postcss-modules-local-by-default": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz", + "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==", + "dev": true, + "requires": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + } + }, + "postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.4" + } + }, + "postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "requires": { + "icss-utils": "^5.0.0" + } + }, "serialize-javascript": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", @@ -58359,21 +59001,19 @@ } }, "css-loader": { - "version": "5.2.7", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz", - "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==", + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.7.3.tgz", + "integrity": "sha512-qhOH1KlBMnZP8FzRO6YCH9UHXQhVMcEGLyNdb7Hv2cpcmJbW0YrddO+tG1ab5nT41KpHIYGsbeHqxB9xPu1pKQ==", "dev": true, "requires": { "icss-utils": "^5.1.0", - "loader-utils": "^2.0.0", - "postcss": "^8.2.15", + "postcss": "^8.4.19", "postcss-modules-extract-imports": "^3.0.0", "postcss-modules-local-by-default": "^4.0.0", "postcss-modules-scope": "^3.0.0", "postcss-modules-values": "^4.0.0", - "postcss-value-parser": "^4.1.0", - "schema-utils": "^3.0.0", - "semver": "^7.3.5" + "postcss-value-parser": "^4.2.0", + "semver": "^7.3.8" }, "dependencies": { "icss-utils": { @@ -58390,9 +59030,9 @@ "dev": true }, "postcss": { - "version": "8.4.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.16.tgz", - "integrity": "sha512-ipHE1XBvKzm5xI7hiHCZJCSugxvsdq2mPnsq5+UF+VHCjiBvtDrlxJfMBToWaP9D5XlgNmcFGqoHmUn0EYEaRQ==", + "version": "8.4.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", + "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", "dev": true, "requires": { "nanoid": "^3.3.4", @@ -59206,9 +59846,9 @@ } }, "electron": { - "version": "21.4.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-21.4.0.tgz", - "integrity": "sha512-eko9hBBgJujF6er/LYhft1Os4NKaw/lcitF2HWjDsjw2B2aCxWLkw+gPslJVhW6OfusC/vzpvqi26XEgnVnnOg==", + "version": "21.4.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-21.4.1.tgz", + "integrity": "sha512-uhFf3vpE6th6X2E1NSIy1+dWVeS9gb7W8EWd/cn5MacEiv4aVY3gtypaglTaVhYPfnJfcD+v3Ql6gGvx4Efh6A==", "dev": true, "requires": { "@electron/get": "^1.14.1", @@ -69990,9 +70630,9 @@ } }, "react-native-google-places-autocomplete": { - "version": "git+ssh://git@github.com/Expensify/react-native-google-places-autocomplete.git#3bbd17d63e6c38d38d857b50f6037c1c0376ff06", - "integrity": "sha512-Qs9TCeuH8+WkSLEBc/LhGANVL4fNL8RmzIcO/DWmajzgJixmZX3HVQtV2D9F0EiVPFI3jaPYjyo6xJtgvXNy5w==", - "from": "react-native-google-places-autocomplete@git+https://github.com/Expensify/react-native-google-places-autocomplete.git#3bbd17d63e6c38d38d857b50f6037c1c0376ff06", + "version": "git+ssh://git@github.com/Expensify/react-native-google-places-autocomplete.git#e12768f1542e7982d90f6449798f0d6b7f18f192", + "integrity": "sha512-Lk5/8qqzPoqx9ygfHSYtqXFvXiWIY+1tnyoWHVbpyTlPrJOcfkFrELCXaDlokJJqg1luvTLoMtEA1pkDfHYVUg==", + "from": "react-native-google-places-autocomplete@git+https://github.com/Expensify/react-native-google-places-autocomplete.git#e12768f1542e7982d90f6449798f0d6b7f18f192", "requires": { "lodash.debounce": "^4.0.8", "prop-types": "^15.7.2", @@ -75020,6 +75660,178 @@ } } }, + "webpack-font-preload-plugin": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/webpack-font-preload-plugin/-/webpack-font-preload-plugin-1.5.0.tgz", + "integrity": "sha512-/Nh6MNa7/rbu3ZcqSR1SxB+G5XaITu7U2yZO5INTsVRpVlMLQmHQZCoDt4PP+iFyBdvBCDbA0CImRXHarQ0wpQ==", + "dev": true, + "requires": { + "jsdom": "^19.0.0", + "webpack-sources": "^3.2.2" + }, + "dependencies": { + "acorn": { + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", + "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "dev": true + }, + "cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true + }, + "data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "dev": true, + "requires": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "dependencies": { + "whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dev": true, + "requires": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + } + } + } + }, + "domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "dev": true, + "requires": { + "webidl-conversions": "^7.0.0" + } + }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "requires": { + "whatwg-encoding": "^2.0.0" + } + }, + "jsdom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-19.0.0.tgz", + "integrity": "sha512-RYAyjCbxy/vri/CfnjUWJQQtZ3LKlLnDqj+9XLNnJPgEGeirZs3hllKR20re8LUZ6o1b1X4Jat+Qd26zmP41+A==", + "dev": true, + "requires": { + "abab": "^2.0.5", + "acorn": "^8.5.0", + "acorn-globals": "^6.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.1", + "decimal.js": "^10.3.1", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.0", + "parse5": "6.0.1", + "saxes": "^5.0.1", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.0.0", + "w3c-hr-time": "^1.0.2", + "w3c-xmlserializer": "^3.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^10.0.0", + "ws": "^8.2.3", + "xml-name-validator": "^4.0.0" + } + }, + "tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dev": true, + "requires": { + "punycode": "^2.1.1" + } + }, + "w3c-xmlserializer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-3.0.0.tgz", + "integrity": "sha512-3WFqGEgSXIyGhOmAFtlicJNMjEps8b1MG31NCA0/vOF9+nKMUW1ckhi9cnNHmf88Rzw5V+dwIwsm2C7X8k9aQg==", + "dev": true, + "requires": { + "xml-name-validator": "^4.0.0" + } + }, + "webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true + }, + "webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true + }, + "whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "requires": { + "iconv-lite": "0.6.3" + } + }, + "whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true + }, + "whatwg-url": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-10.0.0.tgz", + "integrity": "sha512-CLxxCmdUby142H5FZzn4D8ikO1cmypvXVQktsgosNy4a4BHrDHeciBBGZhb0bNoR5/MltoCatso+vFjjGx8t0w==", + "dev": true, + "requires": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + } + }, + "xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true + } + } + }, "webpack-hot-middleware": { "version": "2.25.2", "resolved": "https://registry.npmjs.org/webpack-hot-middleware/-/webpack-hot-middleware-2.25.2.tgz", diff --git a/package.json b/package.json index db52903f6d17..d8b5462c1098 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.2.63-1", + "version": "1.2.67-6", "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.", @@ -41,7 +41,7 @@ "test:e2e": "node tests/e2e/testRunner.js --development" }, "dependencies": { - "@expensify/react-native-web": "0.18.10", + "@expensify/react-native-web": "0.18.12", "@formatjs/intl-getcanonicallocales": "^1.5.8", "@formatjs/intl-locale": "^2.4.21", "@formatjs/intl-numberformat": "^6.2.5", @@ -70,7 +70,6 @@ "domhandler": "^4.3.0", "expensify-common": "git+https://github.com/Expensify/expensify-common.git#ca07936e09ff4ebbaf4b423f43adfb35e17c799d", "fbjs": "^3.0.2", - "file-loader": "^6.0.0", "html-entities": "^1.3.1", "htmlparser2": "^7.2.0", "jest-when": "^3.5.2", @@ -94,7 +93,7 @@ "react-native-document-picker": "^8.0.0", "react-native-fast-image": "^8.6.3", "react-native-gesture-handler": "2.6.0", - "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#3bbd17d63e6c38d38d857b50f6037c1c0376ff06", + "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#e12768f1542e7982d90f6449798f0d6b7f18f192", "react-native-haptic-feedback": "^1.13.0", "react-native-image-pan-zoom": "^2.1.12", "react-native-image-picker": "^5.0.1", @@ -161,10 +160,10 @@ "clean-webpack-plugin": "^3.0.0", "concurrently": "^5.3.0", "copy-webpack-plugin": "^6.4.1", - "css-loader": "^5.2.4", + "css-loader": "^6.7.2", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", - "electron": "^21.4.0", + "electron": "^21.4.1", "electron-builder": "23.5.0", "electron-notarize": "^1.2.1", "eslint": "^7.6.0", @@ -195,6 +194,7 @@ "webpack-bundle-analyzer": "^4.5.0", "webpack-cli": "^4.10.0", "webpack-dev-server": "^4.9.3", + "webpack-font-preload-plugin": "^1.5.0", "webpack-merge": "^5.8.0" }, "overrides": { diff --git a/src/CONST.js b/src/CONST.js index ae3ffca7f025..48206b8f9755 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -7,6 +7,7 @@ const ACTIVE_EXPENSIFY_URL = Url.addTrailingForwardSlash(lodashGet(Config, 'NEW_ const USE_EXPENSIFY_URL = 'https://use.expensify.com'; const PLATFORM_OS_MACOS = 'Mac OS'; const ANDROID_PACKAGE_NAME = 'com.expensify.chat'; +const USA_COUNTRY_NAME = 'United States'; const CONST = { ANDROID_PACKAGE_NAME, @@ -840,6 +841,7 @@ const CONST = { MAX_COMMENT_LENGTH: 15000, FORM_CHARACTER_LIMIT: 50, + LEGAL_NAMES_CHARACTER_LIMIT: 150, AVATAR_CROP_MODAL: { // The next two constants control what is min and max value of the image crop scale. // Values define in how many times the image can be bigger than its container. @@ -907,6 +909,259 @@ const CONST = { TFA_CODE_LENGTH: 6, CHAT_ATTACHMENT_TOKEN_KEY: 'X-Chat-Attachment-Token', + + USA_COUNTRY_NAME, + ALL_COUNTRIES: [ + 'Afghanistan', + 'Aland Islands', + 'Albania', + 'Algeria', + 'American Samoa', + 'Andorra', + 'Angola', + 'Anguilla', + 'Antarctica', + 'Antigua and Barbuda', + 'Argentina', + 'Armenia', + 'Aruba', + 'Australia', + 'Austria', + 'Azerbaijan', + 'Bahamas', + 'Bahrain', + 'Bangladesh', + 'Barbados', + 'Belarus', + 'Belgium', + 'Belize', + 'Benin', + 'Bermuda', + 'Bhutan', + 'Bolivia', + 'Bonaire, Saint Eustatius and Saba ', + 'Bosnia and Herzegovina', + 'Botswana', + 'Bouvet Island', + 'Brazil', + 'British Indian Ocean Territory', + 'British Virgin Islands', + 'Brunei', + 'Bulgaria', + 'Burkina Faso', + 'Burundi', + 'Cambodia', + 'Cameroon', + 'Canada', + 'Cape Verde', + 'Cayman Islands', + 'Central African Republic', + 'Chad', + 'Chile', + 'China', + 'Christmas Island', + 'Cocos Islands', + 'Colombia', + 'Comoros', + 'Cook Islands', + 'Costa Rica', + 'Croatia', + 'Cuba', + 'Curacao', + 'Cyprus', + 'Czech Republic', + 'Democratic Republic of the Congo', + 'Denmark', + 'Djibouti', + 'Dominica', + 'Dominican Republic', + 'East Timor', + 'Ecuador', + 'Egypt', + 'El Salvador', + 'Equatorial Guinea', + 'Eritrea', + 'Estonia', + 'Ethiopia', + 'Falkland Islands', + 'Faroe Islands', + 'Fiji', + 'Finland', + 'France', + 'French Guiana', + 'French Polynesia', + 'French Southern Territories', + 'Gabon', + 'Gambia', + 'Georgia', + 'Germany', + 'Ghana', + 'Gibraltar', + 'Greece', + 'Greenland', + 'Grenada', + 'Guadeloupe', + 'Guam', + 'Guatemala', + 'Guernsey', + 'Guinea', + 'Guinea-Bissau', + 'Guyana', + 'Haiti', + 'Heard Island and McDonald Islands', + 'Honduras', + 'Hong Kong', + 'Hungary', + 'Iceland', + 'India', + 'Indonesia', + 'Iran', + 'Iraq', + 'Ireland', + 'Isle of Man', + 'Israel', + 'Italy', + 'Ivory Coast', + 'Jamaica', + 'Japan', + 'Jersey', + 'Jordan', + 'Kazakhstan', + 'Kenya', + 'Kiribati', + 'Kosovo', + 'Kuwait', + 'Kyrgyzstan', + 'Laos', + 'Latvia', + 'Lebanon', + 'Lesotho', + 'Liberia', + 'Libya', + 'Liechtenstein', + 'Lithuania', + 'Luxembourg', + 'Macao', + 'Macedonia', + 'Madagascar', + 'Malawi', + 'Malaysia', + 'Maldives', + 'Mali', + 'Malta', + 'Marshall Islands', + 'Martinique', + 'Mauritania', + 'Mauritius', + 'Mayotte', + 'Mexico', + 'Micronesia', + 'Moldova', + 'Monaco', + 'Mongolia', + 'Montenegro', + 'Montserrat', + 'Morocco', + 'Mozambique', + 'Myanmar', + 'Namibia', + 'Nauru', + 'Nepal', + 'Netherlands', + 'New Caledonia', + 'New Zealand', + 'Nicaragua', + 'Niger', + 'Nigeria', + 'Niue', + 'Norfolk Island', + 'North Korea', + 'Northern Mariana Islands', + 'Norway', + 'Oman', + 'Pakistan', + 'Palau', + 'Palestinian Territory', + 'Panama', + 'Papua New Guinea', + 'Paraguay', + 'Peru', + 'Philippines', + 'Pitcairn', + 'Poland', + 'Portugal', + 'Puerto Rico', + 'Qatar', + 'Republic of the Congo', + 'Reunion', + 'Romania', + 'Russia', + 'Rwanda', + 'Saint Barthelemy', + 'Saint Helena', + 'Saint Kitts and Nevis', + 'Saint Lucia', + 'Saint Martin', + 'Saint Pierre and Miquelon', + 'Saint Vincent and the Grenadines', + 'Samoa', + 'San Marino', + 'Sao Tome and Principe', + 'Saudi Arabia', + 'Senegal', + 'Serbia', + 'Seychelles', + 'Sierra Leone', + 'Singapore', + 'Sint Maarten', + 'Slovakia', + 'Slovenia', + 'Solomon Islands', + 'Somalia', + 'South Africa', + 'South Georgia and the South Sandwich Islands', + 'South Korea', + 'South Sudan', + 'Spain', + 'Sri Lanka', + 'Sudan', + 'Suriname', + 'Svalbard and Jan Mayen', + 'Swaziland', + 'Sweden', + 'Switzerland', + 'Syria', + 'Taiwan', + 'Tajikistan', + 'Tanzania', + 'Thailand', + 'Togo', + 'Tokelau', + 'Tonga', + 'Trinidad and Tobago', + 'Tunisia', + 'Turkey', + 'Turkmenistan', + 'Turks and Caicos Islands', + 'Tuvalu', + 'U.S. Virgin Islands', + 'Uganda', + 'Ukraine', + 'United Arab Emirates', + 'United Kingdom', + USA_COUNTRY_NAME, + 'Uruguay', + 'Uzbekistan', + 'Vanuatu', + 'Vatican', + 'Venezuela', + 'Vietnam', + 'Wallis and Futuna', + 'Western Sahara', + 'Yemen', + 'Zambia', + 'Zimbabwe', + ], }; export default CONST; diff --git a/src/Expensify.js b/src/Expensify.js index 6750c365c164..1a48abdc57a9 100644 --- a/src/Expensify.js +++ b/src/Expensify.js @@ -2,9 +2,10 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {PureComponent} from 'react'; -import {AppState} from 'react-native'; +import {AppState, Linking} from 'react-native'; import Onyx, {withOnyx} from 'react-native-onyx'; +import * as ReportUtils from './libs/ReportUtils'; import BootSplash from './libs/BootSplash'; import * as ActiveClientManager from './libs/ActiveClientManager'; import ONYXKEYS from './ONYXKEYS'; @@ -120,6 +121,9 @@ class Expensify extends PureComponent { }); this.appStateChangeListener = AppState.addEventListener('change', this.initializeClient); + + // Open chat report from a deep link (only mobile native) + Linking.addEventListener('url', state => ReportUtils.openReportFromDeepLink(state.url)); } componentDidUpdate() { @@ -134,6 +138,9 @@ class Expensify extends PureComponent { // eslint-disable-next-line react/no-did-update-set-state this.setState({isSplashShown: false}); + + // If the app is opened from a deep link, get the reportID (if exists) from the deep link and navigate to the chat report + Linking.getInitialURL().then(url => ReportUtils.openReportFromDeepLink(url)); } } diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index f38f281b8939..c6d709a83059 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -39,6 +39,9 @@ export default { // Contains all the personalDetails the user has access to PERSONAL_DETAILS: 'personalDetails', + // Contains all the private personal details of the user + PRIVATE_PERSONAL_DETAILS: 'private_personalDetails', + // Contains a list of all currencies available to the user - user can // select a currency based on the list CURRENCY_LIST: 'currencyList', @@ -173,6 +176,9 @@ export default { CLOSE_ACCOUNT_FORM: 'closeAccount', PROFILE_SETTINGS_FORM: 'profileSettingsForm', DISPLAY_NAME_FORM: 'displayNameForm', + LEGAL_NAME_FORM: 'legalNameForm', + DATE_OF_BIRTH_FORM: 'dateOfBirthForm', + HOME_ADDRESS_FORM: 'homeAddressForm', NEW_ROOM_FORM: 'newRoomForm', ROOM_SETTINGS_FORM: 'roomSettingsForm', }, diff --git a/src/ROUTES.js b/src/ROUTES.js index 470ea9d99eb7..469b654b3255 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -13,6 +13,7 @@ const IOU_DETAILS = 'iou/details'; const IOU_REQUEST_CURRENCY = `${IOU_REQUEST}/currency`; const IOU_BILL_CURRENCY = `${IOU_BILL}/currency`; const IOU_SEND_CURRENCY = `${IOU_SEND}/currency`; +const SETTINGS_PERSONAL_DETAILS = 'settings/profile/personal-details'; export default { BANK_ACCOUNT: 'bank-account', @@ -27,6 +28,8 @@ export default { SETTINGS_TIMEZONE_SELECT: 'settings/profile/timezone/select', SETTINGS_PRONOUNS: 'settings/profile/pronouns', SETTINGS_PREFERENCES: 'settings/preferences', + SETTINGS_PRIORITY_MODE: 'settings/preferences/priority-mode', + SETTINGS_LANGUAGE: 'settings/preferences/language', SETTINGS_WORKSPACES: 'settings/workspaces', SETTINGS_SECURITY: 'settings/security', SETTINGS_CLOSE: 'settings/security/closeAccount', @@ -42,6 +45,10 @@ export default { getSettingsAddLoginRoute: type => `settings/addlogin/${type}`, SETTINGS_PAYMENTS_TRANSFER_BALANCE: 'settings/payments/transfer-balance', SETTINGS_PAYMENTS_CHOOSE_TRANSFER_ACCOUNT: 'settings/payments/choose-transfer-account', + SETTINGS_PERSONAL_DETAILS, + SETTINGS_PERSONAL_DETAILS_LEGAL_NAME: `${SETTINGS_PERSONAL_DETAILS}/legal-name`, + SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH: `${SETTINGS_PERSONAL_DETAILS}/date-of-birth`, + SETTINGS_PERSONAL_DETAILS_ADDRESS: `${SETTINGS_PERSONAL_DETAILS}/address`, NEW_GROUP: 'new/group', NEW_CHAT: 'new/chat', REPORT, diff --git a/src/components/AddressSearch.js b/src/components/AddressSearch.js index 77aeb76c7624..0c5c6179a2f2 100644 --- a/src/components/AddressSearch.js +++ b/src/components/AddressSearch.js @@ -7,9 +7,11 @@ import lodashGet from 'lodash/get'; import CONFIG from '../CONFIG'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; import styles from '../styles/styles'; +import themeColors from '../styles/themes/default'; import TextInput from './TextInput'; import Log from '../libs/Log'; import * as GooglePlacesUtils from '../libs/GooglePlacesUtils'; +import CONST from '../CONST'; // The error that's being thrown below will be ignored until we fork the // react-native-google-places-autocomplete repo and replace the @@ -48,6 +50,9 @@ const propTypes = { // eslint-disable-next-line react/forbid-prop-types containerStyles: PropTypes.arrayOf(PropTypes.object), + /** Should address search be limited to results in the USA */ + isLimitedToUSA: PropTypes.bool, + /** A map of inputID key names */ renamedInputKeys: PropTypes.shape({ street: PropTypes.string, @@ -56,6 +61,9 @@ const propTypes = { zipCode: PropTypes.string, }), + /** Maximum number of characters allowed in search input */ + maxInputLength: PropTypes.number, + ...withLocalizePropTypes, }; @@ -68,12 +76,14 @@ const defaultProps = { value: undefined, defaultValue: undefined, containerStyles: [], + isLimitedToUSA: true, renamedInputKeys: { street: 'addressStreet', city: 'addressCity', state: 'addressState', zipCode: 'addressZipCode', }, + maxInputLength: undefined, }; // Do not convert to class component! It's been tried before and presents more challenges than it's worth. @@ -81,6 +91,10 @@ const defaultProps = { // Reference: https://github.com/FaridSafi/react-native-google-places-autocomplete/issues/609#issuecomment-886133839 const AddressSearch = (props) => { const [displayListViewBorder, setDisplayListViewBorder] = useState(false); + const query = {language: props.preferredLocale, types: 'address'}; + if (props.isLimitedToUSA) { + query.components = 'country:us'; + } const saveLocationDetails = (details) => { const addressComponents = details.address_components; @@ -99,6 +113,7 @@ const AddressSearch = (props) => { } const zipCode = GooglePlacesUtils.getAddressComponent(addressComponents, 'postal_code', 'long_name'); const state = GooglePlacesUtils.getAddressComponent(addressComponents, 'administrative_area_level_1', 'short_name'); + const country = GooglePlacesUtils.getAddressComponent(addressComponents, 'country', 'long_name'); const values = { street: props.value ? props.value.trim() : '', @@ -119,6 +134,15 @@ const AddressSearch = (props) => { if (state) { values.state = state; } + if (country) { + // If country doesn't match our list of countries (maybe spelling issue), + // default the country to empty string so the country picker value doesn't + // hold a value but show '-' + values.country = ''; + if (_.includes(CONST.ALL_COUNTRIES, country)) { + values.country = country; + } + } if (_.size(values) === 0) { return; } @@ -162,11 +186,7 @@ const AddressSearch = (props) => { // After we select an option, we set displayListViewBorder to false to prevent UI flickering setDisplayListViewBorder(false); }} - query={{ - language: props.preferredLocale, - types: 'address', - components: 'country:us', - }} + query={query} requestUrl={{ useOnPlatform: 'all', url: `${CONFIG.EXPENSIFY.URL_API_ROOT}api?command=Proxy_GooglePlaces&proxyUrl=`, @@ -208,6 +228,7 @@ const AddressSearch = (props) => { setDisplayListViewBorder(false); } }, + maxLength: props.maxInputLength, }} styles={{ textInputContainer: [styles.flexColumn], @@ -228,6 +249,8 @@ const AddressSearch = (props) => { description: [styles.googleSearchText], separator: [styles.googleSearchSeparator], }} + listHoverColor={themeColors.border} + listUnderlayColor={themeColors.buttonPressedBG} onLayout={(event) => { // We use the height of the element to determine if we should hide the border of the listView dropdown // to prevent a lingering border when there are no address suggestions. diff --git a/src/components/AttachmentPicker/index.js b/src/components/AttachmentPicker/index.js index dddceb7cf7df..dc6573e02c20 100644 --- a/src/components/AttachmentPicker/index.js +++ b/src/components/AttachmentPicker/index.js @@ -36,10 +36,10 @@ class AttachmentPicker extends React.Component { if (file) { const cleanName = FileUtils.cleanFileName(file.name); - file.uri = URL.createObjectURL(file); if (file.name !== cleanName) { file = new File([file], cleanName); } + file.uri = URL.createObjectURL(file); this.onPicked(file); } diff --git a/src/components/BlockingViews/BlockingView.js b/src/components/BlockingViews/BlockingView.js index 2cf6ff8262c4..f4b0d36e4726 100644 --- a/src/components/BlockingViews/BlockingView.js +++ b/src/components/BlockingViews/BlockingView.js @@ -27,7 +27,7 @@ const defaultProps = { const BlockingView = props => ( ( height={variables.iconSizeSuperLarge} /> {props.title} - {props.subtitle} + {props.subtitle} ); diff --git a/src/components/ConfirmationPage.js b/src/components/ConfirmationPage.js index 7c27313720af..3ee077577e5b 100644 --- a/src/components/ConfirmationPage.js +++ b/src/components/ConfirmationPage.js @@ -43,7 +43,7 @@ const ConfirmationPage = props => ( source={{uri: props.illustration}} style={styles.confirmationAnimation} /> - + {props.heading} diff --git a/src/components/CountryPicker.js b/src/components/CountryPicker.js new file mode 100644 index 000000000000..d673e3719079 --- /dev/null +++ b/src/components/CountryPicker.js @@ -0,0 +1,66 @@ +import _ from 'underscore'; +import React, {forwardRef} from 'react'; +import PropTypes from 'prop-types'; +import CONST from '../CONST'; +import Picker from './Picker'; +import withLocalize, {withLocalizePropTypes} from './withLocalize'; + +const COUNTRIES = _.map(CONST.ALL_COUNTRIES, countryName => ({ + value: countryName, + label: countryName, +})); + +const propTypes = { + /** The label for the field */ + label: PropTypes.string, + + /** A callback method that is called when the value changes and it receives the selected value as an argument. */ + onInputChange: PropTypes.func.isRequired, + + /** The value that needs to be selected */ + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + + /** The ID used to uniquely identify the input in a form */ + inputID: PropTypes.string, + + /** Saves a draft of the input value when used in a form */ + shouldSaveDraft: PropTypes.bool, + + /** Callback that is called when the text input is blurred */ + onBlur: PropTypes.func, + + /** Error text to display */ + errorText: PropTypes.string, + + ...withLocalizePropTypes, +}; + +const defaultProps = { + label: '', + value: undefined, + errorText: '', + shouldSaveDraft: false, + inputID: undefined, + onBlur: () => {}, +}; + +const CountryPicker = forwardRef((props, ref) => ( + +)); + +CountryPicker.propTypes = propTypes; +CountryPicker.defaultProps = defaultProps; +CountryPicker.displayName = 'CountryPicker'; + +export default withLocalize(CountryPicker); diff --git a/src/components/Form.js b/src/components/Form.js index 99270029f565..5a45d599661b 100644 --- a/src/components/Form.js +++ b/src/components/Form.js @@ -31,7 +31,11 @@ const propTypes = { /** Callback to submit the form */ onSubmit: PropTypes.func.isRequired, - children: PropTypes.node.isRequired, + /** Children to render. */ + children: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.node, + ]).isRequired, /* Onyx Props */ @@ -95,7 +99,9 @@ class Form extends React.Component { this.state = { errors: {}, - inputValues: {}, + inputValues: { + ...props.draftValues, + }, }; this.formRef = React.createRef(null); @@ -186,7 +192,7 @@ class Form extends React.Component { /** * Loops over Form's children and automatically supplies Form props to them * - * @param {Array} children - An array containing all Form children + * @param {Array | Function | Node} children - An array containing all Form children * @returns {React.Component} */ childrenWrapperWithProps(children) { @@ -280,7 +286,7 @@ class Form extends React.Component { render() { const scrollViewContent = safeAreaPaddingBottomStyle => ( - {this.childrenWrapperWithProps(this.props.children)} + {this.childrenWrapperWithProps(_.isFunction(this.props.children) ? this.props.children({inputValues: this.state.inputValues}) : this.props.children)} {this.props.isSubmitButtonVisible && ( { return ( { if (locale === props.preferredLocale) { return; diff --git a/src/components/Modal/BaseModal.js b/src/components/Modal/BaseModal.js index d3e5b27c8570..9c41516f58ce 100644 --- a/src/components/Modal/BaseModal.js +++ b/src/components/Modal/BaseModal.js @@ -1,5 +1,5 @@ import React, {PureComponent} from 'react'; -import {View} from 'react-native'; +import {StatusBar, View} from 'react-native'; import PropTypes from 'prop-types'; import ReactNativeModal from 'react-native-modal'; import {SafeAreaInsetsContext} from 'react-native-safe-area-context'; @@ -108,7 +108,10 @@ class BaseModal extends PureComponent { hasBackdrop={this.props.fullscreen} coverScreen={this.props.fullscreen} style={modalStyle} - deviceHeight={this.props.windowHeight} + + // When `statusBarTranslucent` is true on Android, the modal fully covers the status bar. + // Since `windowHeight` doesn't include status bar height, it should be added in the `deviceHeight` calculation. + deviceHeight={this.props.windowHeight + ((this.props.statusBarTranslucent && StatusBar.currentHeight) || 0)} deviceWidth={this.props.windowWidth} animationIn={this.props.animationIn || animationIn} animationOut={this.props.animationOut || animationOut} diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js index 0925077c33b2..59faa18eb45c 100644 --- a/src/components/OptionRow.js +++ b/src/components/OptionRow.js @@ -58,6 +58,9 @@ const propTypes = { /** Whether to show a line separating options in list */ shouldHaveOptionSeparator: PropTypes.bool, + /** Whether to remove the lateral padding and align the content with the margins */ + shouldDisableRowInnerPadding: PropTypes.bool, + style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), ...withLocalizePropTypes, @@ -74,6 +77,7 @@ const defaultProps = { optionIsFocused: false, style: null, shouldHaveOptionSeparator: false, + shouldDisableRowInnerPadding: false, }; class OptionRow extends Component { @@ -85,17 +89,18 @@ class OptionRow extends Component { } // It is very important to use shouldComponentUpdate here so SectionList items will not unnecessarily re-render - shouldComponentUpdate(prevProps, nextProps) { - return prevProps.optionIsFocused === nextProps.optionIsFocused - && prevProps.isSelected === nextProps.isSelected - && prevProps.option.alternateText === nextProps.option.alternateText - && prevProps.option.descriptiveText === nextProps.option.descriptiveText - && _.isEqual(prevProps.option.icons, nextProps.option.icons) - && prevProps.option.text === nextProps.option.text - && prevProps.showSelectedState === nextProps.showSelectedState - && prevProps.isDisabled === nextProps.isDisabled - && prevProps.showTitleTooltip === nextProps.showTitleTooltip - && prevProps.option.brickRoadIndicator === nextProps.option.brickRoadIndicator; + shouldComponentUpdate(nextProps, nextState) { + return this.state.isDisabled !== nextState.isDisabled + || this.props.isDisabled !== nextProps.isDisabled + || this.props.isSelected !== nextProps.isSelected + || this.props.showSelectedState !== nextProps.showSelectedState + || this.props.showTitleTooltip !== nextProps.showTitleTooltip + || !_.isEqual(this.props.option.icons, nextProps.option.icons) + || this.props.optionIsFocused !== nextProps.optionIsFocused + || this.props.option.text !== nextProps.option.text + || this.props.option.alternateText !== nextProps.option.alternateText + || this.props.option.descriptiveText !== nextProps.option.descriptiveText + || this.props.option.brickRoadIndicator !== nextProps.option.brickRoadIndicator; } componentDidUpdate(prevProps) { @@ -172,7 +177,7 @@ class OptionRow extends Component { styles.alignItemsCenter, styles.justifyContentBetween, styles.sidebarLink, - styles.sidebarLinkInner, + this.props.shouldDisableRowInnerPadding ? null : styles.sidebarLinkInner, this.props.optionIsFocused ? styles.sidebarLinkActive : null, hovered && !this.props.optionIsFocused ? this.props.hoverStyle : null, this.props.isDisabled && styles.cursorDisabled, diff --git a/src/components/OptionsList/BaseOptionsList.js b/src/components/OptionsList/BaseOptionsList.js index 949fd0b13316..d3662abf0e05 100644 --- a/src/components/OptionsList/BaseOptionsList.js +++ b/src/components/OptionsList/BaseOptionsList.js @@ -104,6 +104,7 @@ class BaseOptionsList extends Component { * @returns {Array} */ buildFlatSectionArray() { + const optionHeight = variables.optionRowHeight; let offset = 0; // Start with just an empty list header @@ -120,12 +121,8 @@ class BaseOptionsList extends Component { // Add section items for (let i = 0; i < section.data.length; i++) { - let fullOptionHeight = variables.optionRowHeight; - if (i > 0 && this.props.shouldHaveOptionSeparator) { - fullOptionHeight += variables.borderTopWidth; - } - flatArray.push({length: fullOptionHeight, offset}); - offset += fullOptionHeight; + flatArray.push({length: optionHeight, offset}); + offset += optionHeight; } // Add the section footer @@ -157,19 +154,22 @@ class BaseOptionsList extends Component { * @return {Component} */ renderItem({item, index, section}) { + const isDisabled = this.props.isDisabled || section.isDisabled; return ( option.login === item.login))} showSelectedState={this.props.canSelectMultipleOptions} boldStyle={this.props.boldStyle} - isDisabled={this.props.isDisabled || section.isDisabled} + isDisabled={isDisabled} shouldHaveOptionSeparator={index > 0 && this.props.shouldHaveOptionSeparator} + shouldDisableRowInnerPadding={this.props.shouldDisableRowInnerPadding} /> ); } diff --git a/src/components/OptionsList/optionsListPropTypes.js b/src/components/OptionsList/optionsListPropTypes.js index 0ca8b7a70901..91aaa5988d57 100644 --- a/src/components/OptionsList/optionsListPropTypes.js +++ b/src/components/OptionsList/optionsListPropTypes.js @@ -70,6 +70,9 @@ const propTypes = { /** Whether to show a line separating options in list */ shouldHaveOptionSeparator: PropTypes.bool, + + /** Whether to disable the inner padding in rows */ + shouldDisableRowInnerPadding: PropTypes.bool, }; const defaultProps = { @@ -90,6 +93,7 @@ const defaultProps = { isDisabled: false, onLayout: undefined, shouldHaveOptionSeparator: false, + shouldDisableRowInnerPadding: false, }; export {propTypes, defaultProps}; diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 903a767eeedc..78afe0f379b2 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -39,11 +39,10 @@ class BaseOptionsSelector extends Component { this.relatedTarget = null; const allOptions = this.flattenSections(); - const focusedIndex = this.getInitiallyFocusedIndex(allOptions); this.state = { allOptions, - focusedIndex, + focusedIndex: this.props.shouldTextInputAppearBelowOptions ? allOptions.length : 0, }; } @@ -86,8 +85,6 @@ class BaseOptionsSelector extends Component { true, ); - this.scrollToIndex(this.state.focusedIndex, false); - if (!this.props.autoFocus) { return; } @@ -139,25 +136,6 @@ class BaseOptionsSelector extends Component { } } - /** - * @param {Array} allOptions - * @returns {Number} - */ - getInitiallyFocusedIndex(allOptions) { - const defaultIndex = this.props.shouldTextInputAppearBelowOptions ? allOptions.length : 0; - if (_.isUndefined(this.props.initiallyFocusedOptionKey)) { - return defaultIndex; - } - - const indexOfInitiallyFocusedOption = _.findIndex(allOptions, option => option.keyForList === this.props.initiallyFocusedOptionKey); - - if (indexOfInitiallyFocusedOption >= 0) { - return indexOfInitiallyFocusedOption; - } - - return defaultIndex; - } - /** * Flattens the sections into a single array of options. * Each object in this array is enhanced to have: @@ -198,9 +176,8 @@ class BaseOptionsSelector extends Component { * Scrolls to the focused index within the SectionList * * @param {Number} index - * @param {Boolean} animated */ - scrollToIndex(index, animated = true) { + scrollToIndex(index) { const option = this.state.allOptions[index]; if (!this.list || !option) { return; @@ -219,7 +196,7 @@ class BaseOptionsSelector extends Component { } } - this.list.scrollToLocation({sectionIndex: adjustedSectionIndex, itemIndex, animated}); + this.list.scrollToLocation({sectionIndex: adjustedSectionIndex, itemIndex}); } /** @@ -293,13 +270,7 @@ class BaseOptionsSelector extends Component { showTitleTooltip={this.props.showTitleTooltip} isDisabled={this.props.isDisabled} shouldHaveOptionSeparator={this.props.shouldHaveOptionSeparator} - onLayout={() => { - this.scrollToIndex(this.state.focusedIndex, false); - - if (this.props.onLayout) { - this.props.onLayout(); - } - }} + onLayout={this.props.onLayout} /> ) : ; return ( diff --git a/src/components/OptionsSelector/optionsSelectorPropTypes.js b/src/components/OptionsSelector/optionsSelectorPropTypes.js index 8527afd16a03..306b89a26e8e 100644 --- a/src/components/OptionsSelector/optionsSelectorPropTypes.js +++ b/src/components/OptionsSelector/optionsSelectorPropTypes.js @@ -89,9 +89,6 @@ const propTypes = { /** Whether to show a line separating options in list */ shouldHaveOptionSeparator: PropTypes.bool, - - /** Key of the option that we should focus on when first opening the options list */ - initiallyFocusedOptionKey: PropTypes.string, }; const defaultProps = { @@ -116,7 +113,6 @@ const defaultProps = { disableArrowKeysActions: false, isDisabled: false, shouldHaveOptionSeparator: false, - initiallyFocusedOptionKey: undefined, }; export {propTypes, defaultProps}; diff --git a/src/components/PDFView/index.js b/src/components/PDFView/index.js index f8a6d961ca7e..f3ef801f668d 100644 --- a/src/components/PDFView/index.js +++ b/src/components/PDFView/index.js @@ -148,6 +148,9 @@ class PDFView extends Component { width={pageWidth} key={`page_${index + 1}`} pageNumber={index + 1} + + // This needs to be empty to avoid multiple loading texts which show per page and look ugly + // See https://github.com/Expensify/App/issues/14358 for more details loading="" /> ))} diff --git a/src/components/TextInput/BaseTextInput.js b/src/components/TextInput/BaseTextInput.js index 87f02ba52837..63a2e41e780e 100644 --- a/src/components/TextInput/BaseTextInput.js +++ b/src/components/TextInput/BaseTextInput.js @@ -301,6 +301,7 @@ class BaseTextInput extends Component { keyboardType={getSecureEntryKeyboardType(this.props.keyboardType, this.props.secureTextEntry, this.state.passwordHidden)} value={this.state.value} selection={this.state.selection} + editable={_.isUndefined(this.props.editable) ? !this.props.disabled : this.props.editable} // FormSubmit Enter key handler does not have access to direct props. // `dataset.submitOnEnter` is used to indicate that pressing Enter on this input should call the submit callback. diff --git a/src/languages/en.js b/src/languages/en.js index 69ae592a7b3b..daead6a598d8 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -53,16 +53,20 @@ export default { members: 'Members', invite: 'Invite', here: 'here', + date: 'Date', dob: 'Date of birth', ssnLast4: 'Last 4 digits of SSN', ssnFull9: 'Full 9 digits of SSN', + addressLine: ({lineNumber}) => `Address line ${lineNumber}`, personalAddress: 'Personal address', companyAddress: 'Company address', noPO: 'PO boxes and mail drop addresses are not allowed', city: 'City', state: 'State', + stateOrProvince: 'State / Province', + country: 'Country', zip: 'Zip code', - isRequiredField: 'is a required field', + zipPostCode: 'Zip / Postcode', whatThis: 'What\'s this?', iAcceptThe: 'I accept the ', remove: 'Remove', @@ -83,7 +87,12 @@ export default { invalidAmount: 'Invalid amount', acceptedTerms: 'You must accept the Terms of Service to continue', phoneNumber: `Please enter a valid phone number, with the country code (e.g. ${CONST.EXAMPLE_PHONE_NUMBER})`, + fieldRequired: 'This field is required.', + characterLimit: ({limit}) => `Exceeds the maximum length of ${limit} characters`, + dateInvalid: 'Please enter a valid date', }, + comma: 'comma', + semicolon: 'semicolon', please: 'Please', contactUs: 'contact us', pleaseEnterEmailOrPhoneNumber: 'Please enter an email or phone number', @@ -113,8 +122,8 @@ export default { websiteExample: 'e.g. https://www.expensify.com', }, attachmentPicker: { - cameraPermissionRequired: 'Camera permission required', - expensifyDoesntHaveAccessToCamera: 'This app does not have access to your camera, please enable the permission and try again.', + cameraPermissionRequired: 'Camera access', + expensifyDoesntHaveAccessToCamera: 'Expensify can\'t take photos without access to your camera. Tap Settings to update permissions.', attachmentError: 'Attachment error', errorWhileSelectingAttachment: 'An error occurred while selecting an attachment, please try again', errorWhileSelectingCorruptedImage: 'An error occurred while selecting a corrupted attachment, please try another file', @@ -493,16 +502,31 @@ export default { defaultPaymentMethod: 'Default', }, preferencesPage: { - mostRecent: 'Most recent', - mostRecentModeDescription: 'This will display all chats by default, sorted by most recent, with pinned items at the top.', - focus: '#focus', - focusModeDescription: '#focus – This will only display unread and pinned chats, all sorted alphabetically.', receiveRelevantFeatureUpdatesAndExpensifyNews: 'Receive relevant feature updates and Expensify news', + }, + priorityModePage: { priorityMode: 'Priority mode', + explainerText: 'Choose whether to show all chats by default sorted with most recent with pinned items at the top, or #focus on unread pinned items, sorted alphabetically.', + priorityModes: { + default: { + label: 'Most recent', + description: 'Show all chats sorted by most recent', + }, + gsd: { + label: '#focus', + description: 'Only show unread sorted alphabetically', + }, + }, + }, + languagePage: { language: 'Language', languages: { - english: 'English', - spanish: 'Spanish', + en: { + label: 'English', + }, + es: { + label: 'Spanish', + }, }, }, signInPage: { @@ -568,10 +592,20 @@ export default { error: { firstNameLength: 'First name shouldn\'t be longer than 50 characters', lastNameLength: 'Last name shouldn\'t be longer than 50 characters', - characterLimit: ({limit}) => `Exceeds the max length of ${limit} characters`, hasInvalidCharacter: ({invalidCharacter}) => `Please remove the ${invalidCharacter} from the name field.`, - comma: 'comma', - semicolon: 'semicolon', + }, + }, + privatePersonalDetails: { + personalDetails: 'Personal details', + privateDataMessage: 'These details are used for travel and payments. They are never shown on your public profile.', + legalName: 'Legal name', + legalFirstName: 'Legal first name', + legalLastName: 'Legal last name', + homeAddress: 'Home address', + error: { + hasInvalidCharacter: ({invalidCharacter}) => `Please remove the ${invalidCharacter} from the field above.`, + dateShouldBeBefore: ({dateString}) => `Date should be before ${dateString}.`, + dateShouldBeAfter: ({dateString}) => `Date should be after ${dateString}.`, }, }, resendValidationForm: { @@ -631,7 +665,6 @@ export default { addressCity: 'Please enter a valid city', addressStreet: 'Please enter a valid street address that is not a PO Box', addressState: 'Please select a valid state', - incorporationDate: 'Please enter a valid date', incorporationDateFuture: 'Incorporation date cannot be in the future', incorporationState: 'Please enter a valid state', industryCode: 'Please enter a valid industry classification code. Must be 6 digits.', @@ -827,6 +860,9 @@ export default { letsChatCTA: 'Yes, let\'s chat', letsChatText: 'Thanks for doing that. We need your help verifying a few pieces of information, but we can work this out quickly over chat. Ready?', letsChatTitle: 'Let\'s chat!', + enable2FATitle: 'Prevent fraud, enable two-factor authentication!', + enable2FAText: 'We take your security seriously, so please set up two-factor authentication for your account now. That will allow us to dispute Expensify Card digital transactions, and will reduce your risk for fraud.', + secureYourAccount: 'Secure your account', }, beneficialOwnersStep: { additionalInformation: 'Additional information', @@ -1103,8 +1139,8 @@ export default { message: 'Attachment cannot be downloaded', }, permissionError: { - title: 'Access needed', - message: 'Expensify does not have access to save attachments. To enable access, go to Settings and allow access', + title: 'Storage access', + message: 'Expensify can\'t save attachments without storage access. Tap Settings to update permissions.', }, }, desktopApplicationMenu: { @@ -1133,4 +1169,7 @@ export default { message: 'We couldn\'t look for an update. Please check again in a bit!', }, }, + report: { + genericAddCommentFailureMessage: 'Unexpected error while posting the comment, please try again later', + }, }; diff --git a/src/languages/es.js b/src/languages/es.js index 8b1ac18ef8e4..096e1730f833 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -53,16 +53,20 @@ export default { members: 'Miembros', invite: 'Invitar', here: 'aquí', + date: 'Fecha', dob: 'Fecha de Nacimiento', ssnLast4: 'Últimos 4 dígitos de su SSN', ssnFull9: 'Los 9 dígitos del SSN', + addressLine: ({lineNumber}) => `Dirección línea ${lineNumber}`, personalAddress: 'Dirección física personal', companyAddress: 'Dirección física de la empresa', noPO: 'No se aceptan apartados ni direcciones postales', city: 'Ciudad', state: 'Estado', + stateOrProvince: 'Estado / Provincia', + country: 'País', zip: 'Código postal', - isRequiredField: 'es un campo obligatorio', + zipPostCode: 'Código Postal', whatThis: '¿Qué es esto?', iAcceptThe: 'Acepto los ', remove: 'Eliminar', @@ -83,7 +87,12 @@ export default { invalidAmount: 'Monto no válido', acceptedTerms: 'Debes aceptar los Términos de servicio para continuar', phoneNumber: `Ingresa un teléfono válido, incluyendo el código de país (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER})`, + fieldRequired: 'Este campo es obligatorio.', + characterLimit: ({limit}) => `Supera el límite de ${limit} caracteres`, + dateInvalid: 'Ingresa una fecha válida', }, + comma: 'la coma', + semicolon: 'el punto y coma', please: 'Por favor', contactUs: 'contáctenos', pleaseEnterEmailOrPhoneNumber: 'Por favor escribe un email o número de teléfono', @@ -113,8 +122,8 @@ export default { websiteExample: 'p. ej. https://www.expensify.com', }, attachmentPicker: { - cameraPermissionRequired: 'Se necesita permiso para usar la cámara', - expensifyDoesntHaveAccessToCamera: 'Esta aplicación no tiene acceso a tu cámara, por favor activa el permiso y vuelve a intentarlo.', + cameraPermissionRequired: 'Permiso para acceder a la cámara', + expensifyDoesntHaveAccessToCamera: 'Expensify no puede tomar fotos sin acceso a tu cámara. Haz click en Configuración para actualizar los permisos.', attachmentError: 'Error al adjuntar archivo', errorWhileSelectingAttachment: 'Ha ocurrido un error al seleccionar un adjunto, por favor inténtalo de nuevo', errorWhileSelectingCorruptedImage: 'Ha ocurrido un error al seleccionar un adjunto corrupto, por favor inténtalo con otro archivo', @@ -493,16 +502,31 @@ export default { defaultPaymentMethod: 'Predeterminado', }, preferencesPage: { - mostRecent: 'Más recientes', - mostRecentModeDescription: 'Esta opción muestra por defecto todos los chats, ordenados a partir del más reciente, con los chats destacados arriba de todo.', - focus: '#concentración', - focusModeDescription: '#concentración – Muestra sólo los chats no leídos y destacados ordenados alfabéticamente.', receiveRelevantFeatureUpdatesAndExpensifyNews: 'Recibir noticias sobre Expensify y actualizaciones del producto', + }, + priorityModePage: { priorityMode: 'Modo prioridad', + explainerText: 'Elija si desea mostrar por defecto todos los chats ordenados desde el más reciente y con los elementos anclados en la parte superior, o elija el modo #concentración, con los elementos no leídos anclados en la parte superior y ordenados alfabéticamente.', + priorityModes: { + default: { + label: 'Más recientes', + description: 'Mostrar todos los chats ordenados desde el más reciente', + }, + gsd: { + label: '#concentración', + description: 'Mostrar sólo los no leídos ordenados alfabéticamente', + }, + }, + }, + languagePage: { language: 'Idioma', languages: { - english: 'Inglés', - spanish: 'Español', + en: { + label: 'Inglés', + }, + es: { + label: 'Español', + }, }, }, signInPage: { @@ -568,10 +592,20 @@ export default { error: { firstNameLength: 'El nombre no debe tener más de 50 caracteres', lastNameLength: 'El apellido no debe tener más de 50 caracteres', - characterLimit: ({limit}) => `Supera el límite de ${limit} caracteres`, hasInvalidCharacter: ({invalidCharacter}) => `Por favor elimina ${invalidCharacter} del campo nombre.`, - comma: 'la coma', - semicolon: 'el punto y coma', + }, + }, + privatePersonalDetails: { + personalDetails: 'Datos personales', + privateDataMessage: 'Estos detalles se utilizan para viajes y pagos. Nunca se mostrarán en su perfil público.', + legalName: 'Nombre completo', + legalFirstName: 'Nombre', + legalLastName: 'Apellidos', + homeAddress: 'Domicilio', + error: { + hasInvalidCharacter: ({invalidCharacter}) => `Por favor elimina ${invalidCharacter}`, + dateShouldBeBefore: ({dateString}) => `La fecha debe ser anterior a ${dateString}.`, + dateShouldBeAfter: ({dateString}) => `La fecha debe ser posterior a ${dateString}.`, }, }, resendValidationForm: { @@ -631,7 +665,6 @@ export default { addressCity: 'Ingresa una ciudad válida', addressStreet: 'Ingresa una calle de dirección válida que no sea un apartado postal', addressState: 'Por favor, selecciona un estado', - incorporationDate: 'Ingresa una fecha válida', incorporationDateFuture: 'La fecha de incorporación no puede ser futura', incorporationState: 'Ingresa un estado válido', industryCode: 'Ingresa un código de clasificación de industria válido', @@ -829,6 +862,9 @@ export default { letsChatCTA: 'Sí, vamos a chatear', letsChatText: 'Gracias. Necesitamos tu ayuda para verificar la información, pero podemos hacerlo rápidamente a través del chat. ¿Estás listo?', letsChatTitle: '¡Vamos a chatear!', + enable2FATitle: 'Evita fraudes, activa la autenticación de dos factores!', + enable2FAText: 'Tu seguridad es importante para nosotros, por favor configura ahora la autenticación de dos factores. Eso nos permitirá disputar las transacciones de la Tarjeta Expensify y reducirá tu riesgo de fraude.', + secureYourAccount: 'Asegura tu cuenta', }, beneficialOwnersStep: { additionalInformation: 'Información adicional', @@ -1105,8 +1141,8 @@ export default { message: 'No se puede descargar el archivo adjunto', }, permissionError: { - title: 'Se necesita acceso', - message: 'Expensify no tiene acceso para guardar archivos. Para habilitar la descarga de archivos, entra en Preferencias y habilita el acceso', + title: 'Permiso para acceder al almacenamiento', + message: 'Expensify no puede guardar los archivos adjuntos sin permiso para acceder al almacenamiento. Haz click en Configuración para actualizar los permisos.', }, }, desktopApplicationMenu: { @@ -1135,4 +1171,7 @@ export default { message: 'No hemos podido comprobar si existe una actualización. Inténtalo de nuevo más tarde!', }, }, + report: { + genericAddCommentFailureMessage: 'Error inesperado al agregar el comentario, por favor inténtalo más tarde', + }, }; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index 58fae5f8b4c5..c3230a6d353e 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -22,6 +22,7 @@ import * as Modal from '../../actions/Modal'; import modalCardStyleInterpolator from './modalCardStyleInterpolator'; import createCustomModalStackNavigator from './createCustomModalStackNavigator'; import NotFoundPage from '../../../pages/ErrorPage/NotFoundPage'; +import getCurrentUrl from '../currentUrl'; // Modal Stack Navigators import * as ModalStackNavigators from './ModalStackNavigators'; @@ -156,6 +157,8 @@ class AuthScreens extends React.Component { // when displaying a modal. This allows us to dismiss by clicking outside on web / large screens. isModal: true, }; + const url = getCurrentUrl(); + const openOnAdminRoom = url ? new URL(url).searchParams.get('openOnAdminRoom') : ''; return ( { - const last = ReportUtils.findLastAccessedReport(reports, ignoreDefaultRooms, policies); +const getInitialReportScreenParams = (reports, ignoreDefaultRooms, policies, openOnAdminRoom) => { + const last = ReportUtils.findLastAccessedReport(reports, ignoreDefaultRooms, policies, openOnAdminRoom); // Fallback to empty if for some reason reportID cannot be derived - prevents the app from crashing const reportID = lodashGet(last, 'reportID', ''); @@ -61,7 +68,7 @@ class MainDrawerNavigator extends Component { constructor(props) { super(props); this.trackAppStartTiming = this.trackAppStartTiming.bind(this); - this.initialParams = getInitialReportScreenParams(props.reports, !Permissions.canUseDefaultRooms(props.betas), props.policies); + this.initialParams = getInitialReportScreenParams(props.reports, !Permissions.canUseDefaultRooms(props.betas), props.policies, props.route.params.openOnAdminRoom); // When we have chat reports the moment this component got created // we know that the data was served from storage/cache @@ -69,7 +76,12 @@ class MainDrawerNavigator extends Component { } shouldComponentUpdate(nextProps) { - const initialNextParams = getInitialReportScreenParams(nextProps.reports, !Permissions.canUseDefaultRooms(nextProps.betas), nextProps.policies); + const initialNextParams = getInitialReportScreenParams( + nextProps.reports, + !Permissions.canUseDefaultRooms(nextProps.betas), + nextProps.policies, + nextProps.route.params.openOnAdminRoom, + ); if (this.initialParams.reportID === initialNextParams.reportID) { return false; } diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index e95581674fab..607ee566c6cd 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -245,6 +245,34 @@ const SettingsModalStackNavigator = createModalStackNavigator([ }, name: 'Settings_Timezone_Select', }, + { + getComponent: () => { + const SettingsPersonalDetailsInitialPage = require('../../../pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage').default; + return SettingsPersonalDetailsInitialPage; + }, + name: 'Settings_PersonalDetails_Initial', + }, + { + getComponent: () => { + const SettingsLegalNamePage = require('../../../pages/settings/Profile/PersonalDetails/LegalNamePage').default; + return SettingsLegalNamePage; + }, + name: 'Settings_PersonalDetails_LegalName', + }, + { + getComponent: () => { + const SettingsDateOfBirthPage = require('../../../pages/settings/Profile/PersonalDetails/DateOfBirthPage').default; + return SettingsDateOfBirthPage; + }, + name: 'Settings_PersonalDetails_DateOfBirth', + }, + { + getComponent: () => { + const SettingsAddressPage = require('../../../pages/settings/Profile/PersonalDetails/AddressPage').default; + return SettingsAddressPage; + }, + name: 'Settings_PersonalDetails_Address', + }, { getComponent: () => { const SettingsAddSecondaryLoginPage = require('../../../pages/settings/AddSecondaryLoginPage').default; @@ -254,11 +282,25 @@ const SettingsModalStackNavigator = createModalStackNavigator([ }, { getComponent: () => { - const SettingsPreferencesPage = require('../../../pages/settings/PreferencesPage').default; + const SettingsPreferencesPage = require('../../../pages/settings/Preferences/PreferencesPage').default; return SettingsPreferencesPage; }, name: 'Settings_Preferences', }, + { + getComponent: () => { + const SettingsPreferencesPriorityModePage = require('../../../pages/settings/Preferences/PriorityModePage').default; + return SettingsPreferencesPriorityModePage; + }, + name: 'Settings_Preferences_PriorityMode', + }, + { + getComponent: () => { + const SettingsPreferencesLanguagePage = require('../../../pages/settings/Preferences/LanguagePage').default; + return SettingsPreferencesLanguagePage; + }, + name: 'Settings_Preferences_Language', + }, { getComponent: () => { const SettingsPasswordPage = require('../../../pages/settings/PasswordPage').default; diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js index ea730bb45757..447fd481efab 100644 --- a/src/libs/Navigation/Navigation.js +++ b/src/libs/Navigation/Navigation.js @@ -21,6 +21,11 @@ const drawerIsReadyPromise = new Promise((resolve) => { resolveDrawerIsReadyPromise = resolve; }); +let resolveReportScreenIsReadyPromise; +const reportScreenIsReadyPromise = new Promise((resolve) => { + resolveReportScreenIsReadyPromise = resolve; +}); + let isLoggedIn = false; let pendingRoute = null; let isNavigating = false; @@ -267,6 +272,14 @@ function setIsDrawerReady() { resolveDrawerIsReadyPromise(); } +function isReportScreenReady() { + return reportScreenIsReadyPromise; +} + +function setIsReportScreenIsReady() { + resolveReportScreenIsReadyPromise(); +} + export default { canNavigate, navigate, @@ -284,6 +297,8 @@ export default { setIsDrawerReady, isDrawerRoute, setIsNavigating, + isReportScreenReady, + setIsReportScreenIsReady, }; export { diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index e9bbeb987d25..12b31245caf0 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -44,6 +44,14 @@ export default { path: ROUTES.SETTINGS_PREFERENCES, exact: true, }, + Settings_Preferences_PriorityMode: { + path: ROUTES.SETTINGS_PRIORITY_MODE, + exact: true, + }, + Settings_Preferences_Language: { + path: ROUTES.SETTINGS_LANGUAGE, + exact: true, + }, Settings_Close: { path: ROUTES.SETTINGS_CLOSE, exact: true, @@ -115,6 +123,22 @@ export default { Settings_Add_Secondary_Login: { path: ROUTES.SETTINGS_ADD_LOGIN, }, + Settings_PersonalDetails_Initial: { + path: ROUTES.SETTINGS_PERSONAL_DETAILS, + exact: true, + }, + Settings_PersonalDetails_LegalName: { + path: ROUTES.SETTINGS_PERSONAL_DETAILS_LEGAL_NAME, + exact: true, + }, + Settings_PersonalDetails_DateOfBirth: { + path: ROUTES.SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH, + exact: true, + }, + Settings_PersonalDetails_Address: { + path: ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS, + exact: true, + }, Workspace_Initial: { path: ROUTES.WORKSPACE_INITIAL, }, diff --git a/src/libs/ReimbursementAccountUtils.js b/src/libs/ReimbursementAccountUtils.js index 6c11b72fb5c4..59f3cd4810d2 100644 --- a/src/libs/ReimbursementAccountUtils.js +++ b/src/libs/ReimbursementAccountUtils.js @@ -1,15 +1,4 @@ import lodashGet from 'lodash/get'; -import * as BankAccounts from './actions/BankAccounts'; -import FormHelper from './FormHelper'; - -const formHelper = new FormHelper({ - errorPath: 'reimbursementAccount.errorFields', - setErrors: BankAccounts.setBankAccountFormValidationErrors, -}); - -const getErrors = props => formHelper.getErrors(props); -const clearError = (props, path) => formHelper.clearError(props, path); -const clearErrors = (props, paths) => formHelper.clearErrors(props, paths); /** * Get the default state for input fields in the VBA flow @@ -26,21 +15,7 @@ function getDefaultStateForField(reimbursementAccountDraft, reimbursementAccount || lodashGet(reimbursementAccount, ['achData', fieldName], defaultValue); } -/** - * @param {Object} props - * @param {Object} errorTranslationKeys - * @param {String} inputKey - * @returns {String} - */ -function getErrorText(props, errorTranslationKeys, inputKey) { - const errors = getErrors(props) || {}; - return errors[inputKey] ? props.translate(errorTranslationKeys[inputKey]) : ''; -} - export { + // eslint-disable-next-line import/prefer-default-export getDefaultStateForField, - getErrors, - clearError, - clearErrors, - getErrorText, }; diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 3328d2a9d1ec..9784d192cf4d 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -3,6 +3,7 @@ import Str from 'expensify-common/lib/str'; import lodashGet from 'lodash/get'; import Onyx from 'react-native-onyx'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; +import {InteractionManager} from 'react-native'; import ONYXKEYS from '../ONYXKEYS'; import CONST from '../CONST'; import * as Localize from './Localize'; @@ -15,6 +16,7 @@ import * as NumberUtils from './NumberUtils'; import * as NumberFormatUtils from './NumberFormatUtils'; import Permissions from './Permissions'; import DateUtils from './DateUtils'; +import linkingConfig from './Navigation/linkingConfig'; import * as defaultAvatars from '../components/Icon/DefaultAvatars'; let sessionEmail; @@ -66,6 +68,13 @@ Onyx.connect({ callback: val => allReports = val, }); +let doesDomainHaveApprovedAccountant; +Onyx.connect({ + key: ONYXKEYS.ACCOUNT, + waitForCollectionCallback: true, + callback: val => doesDomainHaveApprovedAccountant = val.doesDomainHaveApprovedAccountant, +}); + function getChatType(report) { return report ? report.chatType : ''; } @@ -241,9 +250,10 @@ function hasExpensifyGuidesEmails(emails) { * @param {Record|Array<{lastReadTime, reportID}>} reports * @param {Boolean} [ignoreDefaultRooms] * @param {Object} policies + * @param {Boolean} openOnAdminRoom * @returns {Object} */ -function findLastAccessedReport(reports, ignoreDefaultRooms, policies) { +function findLastAccessedReport(reports, ignoreDefaultRooms, policies, openOnAdminRoom = false) { let sortedReports = sortReportsByLastRead(reports); if (ignoreDefaultRooms) { @@ -252,7 +262,15 @@ function findLastAccessedReport(reports, ignoreDefaultRooms, policies) { || hasExpensifyGuidesEmails(lodashGet(report, ['participants'], []))); } - return _.last(sortedReports); + let adminReport; + if (!ignoreDefaultRooms && openOnAdminRoom) { + adminReport = _.find(sortedReports, (report) => { + const chatType = getChatType(report); + return chatType === CONST.REPORT.CHAT_TYPE.POLICY_ADMINS; + }); + } + + return adminReport || _.last(sortedReports); } /** @@ -370,10 +388,20 @@ function chatIncludesConcierge(report) { * @param {Array} emails * @returns {Boolean} */ -function hasExpensifyEmails(emails) { +function hasAutomatedExpensifyEmails(emails) { return _.intersection(emails, CONST.EXPENSIFY_EMAILS).length > 0; } +/** + * Returns true if there are any Expensify accounts (i.e. with domain 'expensify.com') in the set of emails. + * + * @param {Array} emails + * @return {Boolean} + */ +function hasExpensifyEmails(emails) { + return _.some(emails, email => Str.extractEmailDomain(email) === CONST.EXPENSIFY_PARTNER_NAME); +} + /** * Whether the time row should be shown for a report. * @param {Array} personalDetails @@ -842,7 +870,7 @@ function getIOUReportActionMessage(type, total, participants, comment, currency, break; case CONST.IOU.REPORT_ACTION_TYPE.PAY: iouMessage = isSettlingUp - ? `Settled up ${paymentMethodMessage}` + ? `Settled up${paymentMethodMessage}` : `Sent ${amount}${comment && ` for ${comment}`}${paymentMethodMessage}`; break; default: @@ -957,7 +985,7 @@ function buildOptimisticChatReport( lastActionCreated: currentTime, notificationPreference, oldPolicyName, - ownerEmail, + ownerEmail: ownerEmail || CONST.REPORT.OWNER_EMAIL_FAKE, participants: participantList, policyID, reportID: generateReportID(), @@ -1194,6 +1222,40 @@ function isIOUOwnedByCurrentUser(report, currentUserLogin, iouReports = {}) { return false; } +/** + * Assuming the passed in report is a default room, lets us know whether we can see it or not, based on permissions and + * the various subsets of users we've allowed to use default rooms. + * + * @param {Object} report + * @param {Array} policies + * @param {Array} betas + * @return {Boolean} + */ +function canSeeDefaultRoom(report, policies, betas) { + // Include archived rooms + if (isArchivedRoom(report)) { + return true; + } + + // Include default rooms for free plan policies (domain rooms aren't included in here because they do not belong to a policy) + if (getPolicyType(report, policies) === CONST.POLICY.TYPE.FREE) { + return true; + } + + // Include domain rooms with Partner Managers (Expensify accounts) in them for accounts that are on a domain with an Approved Accountant + if (isDomainRoom(report) && doesDomainHaveApprovedAccountant && hasExpensifyEmails(lodashGet(report, ['participants'], []))) { + return true; + } + + // If the room has an assigned guide, it can be seen. + if (hasExpensifyGuidesEmails(lodashGet(report, ['participants'], []))) { + return true; + } + + // For all other cases, just check that the user belongs to the default rooms beta + return Permissions.canUseDefaultRooms(betas); +} + /** * Takes several pieces of data from Onyx and evaluates if a report should be shown in the option list (either when searching * for reports or the reports shown in the LHN). @@ -1248,13 +1310,7 @@ function shouldReportBeInOptionList(report, reportIDFromRoute, isInGSDMode, curr return true; } - // Include default rooms for free plan policies - if (isDefaultRoom(report) && getPolicyType(report, policies) === CONST.POLICY.TYPE.FREE) { - return true; - } - - // Include default rooms unless you're on the default room beta, unless you have an assigned guide - if (isDefaultRoom(report) && !Permissions.canUseDefaultRooms(betas) && !hasExpensifyGuidesEmails(lodashGet(report, ['participants'], []))) { + if (isDefaultRoom(report) && !canSeeDefaultRoom(report, policies, betas)) { return false; } @@ -1326,6 +1382,66 @@ function getNewMarkerReportActionID(report, sortedAndFilteredReportActions) { : ''; } +/** + * Replace code points > 127 with C escape sequences, and return the resulting string's overall length + * Used for compatibility with the backend auth validator for AddComment + * @param {String} textComment + * @returns {Number} + */ +function getCommentLength(textComment) { + return textComment.replace(/[^ -~]/g, '\\u????').length; +} + +/** + * @param {String|null} url + * @returns {String} + */ +function getReportIDFromDeepLink(url) { + if (!url) { + return ''; + } + + // Get the reportID from URL + let route = url; + _.each(linkingConfig.prefixes, (prefix) => { + const localWebAndroidRegEx = /^(http:\/\/([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3}))/; + if (route.startsWith(prefix)) { + route = route.replace(prefix, ''); + } else if (localWebAndroidRegEx.test(route)) { + route = route.replace(localWebAndroidRegEx, ''); + } else { + return; + } + + // Remove the port if it's a localhost URL + if (/^:\d+/.test(route)) { + route = route.replace(/:\d+/, ''); + } + + // Remove the leading slash if exists + if (route.startsWith('/')) { + route = route.replace('/', ''); + } + }); + const {reportID} = ROUTES.parseReportRouteParams(route); + return reportID; +} + +/** + * @param {String|null} url + */ +function openReportFromDeepLink(url) { + const reportID = getReportIDFromDeepLink(url); + if (!reportID) { + return; + } + InteractionManager.runAfterInteractions(() => { + Navigation.isReportScreenReady().then(() => { + Navigation.navigate(ROUTES.getReportRoute(reportID)); + }); + }); +} + export { getReportParticipantsTitle, isReportMessageAttachment, @@ -1343,7 +1459,7 @@ export { getPolicyType, isArchivedRoom, isConciergeChatReport, - hasExpensifyEmails, + hasAutomatedExpensifyEmails, hasExpensifyGuidesEmails, hasOutstandingIOU, isIOUOwnedByCurrentUser, @@ -1357,6 +1473,7 @@ export { getRoomWelcomeMessage, getDisplayNamesWithTooltips, getReportName, + getReportIDFromDeepLink, navigateToDetailsPage, generateReportID, hasReportNameError, @@ -1379,4 +1496,7 @@ export { isDefaultAvatar, getOldDotDefaultAvatar, getNewMarkerReportActionID, + canSeeDefaultRoom, + getCommentLength, + openReportFromDeepLink, }; diff --git a/src/libs/ValidationUtils.js b/src/libs/ValidationUtils.js index f287c821f73b..f44af56a945e 100644 --- a/src/libs/ValidationUtils.js +++ b/src/libs/ValidationUtils.js @@ -206,6 +206,30 @@ function meetsAgeRequirements(date) { return testDate.isValid() && testDate.isBetween(oneHundredFiftyYearsAgo, eighteenYearsAgo); } +/** + * Validate that given date is in a specified range of years before now. + * + * @param {String} date + * @param {Number} minimumAge + * @param {Number} maximumAge + * @returns {String} + */ +function getAgeRequirementError(date, minimumAge, maximumAge) { + const recentDate = moment().subtract(minimumAge, 'years'); + const longAgoDate = moment().subtract(maximumAge, 'years'); + const testDate = moment(date); + if (!testDate.isValid()) { + return Localize.translateLocal('common.error.dateInvalid'); + } + if (testDate.isBetween(longAgoDate, recentDate)) { + return ''; + } + if (testDate.isAfter(recentDate)) { + return Localize.translateLocal('privatePersonalDetails.error.dateShouldBeBefore', {dateString: recentDate.format(CONST.DATE.MOMENT_FORMAT_STRING)}); + } + return Localize.translateLocal('privatePersonalDetails.error.dateShouldBeAfter', {dateString: longAgoDate.format(CONST.DATE.MOMENT_FORMAT_STRING)}); +} + /** * Similar to backend, checks whether a website has a valid URL or not. * http/https/ftp URL scheme required. @@ -367,9 +391,9 @@ function findInvalidSymbols(valuesToBeValidated) { if (!value) { return ''; } - let inValidSymbol = value.replace(/[,]+/g, '') !== value ? Localize.translateLocal('personalDetails.error.comma') : ''; + let inValidSymbol = value.replace(/[,]+/g, '') !== value ? Localize.translateLocal('common.comma') : ''; if (_.isEmpty(inValidSymbol)) { - inValidSymbol = value.replace(/[;]+/g, '') !== value ? Localize.translateLocal('personalDetails.error.semicolon') : ''; + inValidSymbol = value.replace(/[;]+/g, '') !== value ? Localize.translateLocal('common.semicolon') : ''; } return inValidSymbol; }); @@ -427,6 +451,7 @@ function isValidTaxID(taxID) { export { meetsAgeRequirements, + getAgeRequirementError, isValidAddress, isValidDate, isValidCardName, diff --git a/src/libs/actions/PersonalDetails.js b/src/libs/actions/PersonalDetails.js index 57443eb54e9d..9220b851cc42 100644 --- a/src/libs/actions/PersonalDetails.js +++ b/src/libs/actions/PersonalDetails.js @@ -173,6 +173,74 @@ function updateDisplayName(firstName, lastName) { Navigation.navigate(ROUTES.SETTINGS_PROFILE); } +/** + * @param {String} legalFirstName + * @param {String} legalLastName + */ +function updateLegalName(legalFirstName, legalLastName) { + API.write('UpdateLegalName', {legalFirstName, legalLastName}, { + optimisticData: [{ + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + value: { + legalFirstName, + legalLastName, + }, + }], + }); + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS); +} + +/** + * @param {String} dob - date of birth + */ +function updateDateOfBirth(dob) { + API.write('UpdateDateOfBirth', {dob}, { + optimisticData: [{ + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + value: { + dob, + }, + }], + }); + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS); +} + +/** + * @param {String} street + * @param {String} street2 + * @param {String} city + * @param {String} state + * @param {String} zip + * @param {String} country + */ +function updateAddress(street, street2, city, state, zip, country) { + API.write('UpdateHomeAddress', { + addressStreet: street, + addressStreet2: street2, + addressCity: city, + addressState: state, + addressZipCode: zip, + addressCountry: country, + }, { + optimisticData: [{ + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + value: { + address: { + street: `${street}\n${street2}`, + city, + state, + zip, + country, + }, + }, + }], + }); + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS); +} + /** * Updates timezone's 'automatic' setting, and updates * selected timezone if set to automatically update. @@ -230,6 +298,13 @@ function openIOUModalPage() { API.read('OpenIOUModalPage'); } +/** + * Fetches additional personal data like legal name, date of birth, address + */ +function openPersonalDetailsPage() { + API.read('OpenPersonalDetailsPage'); +} + /** * Updates the user's avatar image * @@ -331,8 +406,12 @@ export { updateAvatar, deleteAvatar, openIOUModalPage, + openPersonalDetailsPage, extractFirstAndLastNameFromAvailableDetails, updateDisplayName, + updateLegalName, + updateDateOfBirth, + updateAddress, updatePronouns, clearAvatarErrors, updateAutomaticTimezone, diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 7abb23497604..952920708e80 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -19,6 +19,7 @@ import * as ReportUtils from '../ReportUtils'; import DateUtils from '../DateUtils'; import * as ReportActionsUtils from '../ReportActionsUtils'; import * as OptionsListUtils from '../OptionsListUtils'; +import * as Localize from '../Localize'; let currentUserEmail; let currentUserAccountID; @@ -268,7 +269,7 @@ function addActions(reportID, text = '', file) { { onyxMethod: CONST.ONYX.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, - value: _.mapObject(optimisticReportActions, () => null), + value: _.mapObject(optimisticReportActions, action => ({...action, errors: {[DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericAddCommentFailureMessage')}})), }, ]; diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js index dd23484176ec..a74a7350e06a 100644 --- a/src/libs/actions/User.js +++ b/src/libs/actions/User.js @@ -402,6 +402,7 @@ function updateChatPriorityMode(mode) { API.write('UpdateChatPriorityMode', { value: mode, }, {optimisticData}); + Navigation.navigate(ROUTES.SETTINGS_PREFERENCES); } /** diff --git a/src/libs/actions/Welcome.js b/src/libs/actions/Welcome.js index 58a760a50f63..d3795ad825a3 100644 --- a/src/libs/actions/Welcome.js +++ b/src/libs/actions/Welcome.js @@ -111,10 +111,11 @@ function show({routes, showCreateMenu}) { // If we are rendering the SidebarScreen at the same time as a workspace route that means we've already created a workspace via workspace/new and should not open the global // create menu right now. We should also stay on the workspace page if that is our destination. - const topRouteName = lodashGet(_.last(routes), 'name', ''); + const topRoute = _.last(routes); + const isWorkspaceRoute = topRoute.name === 'Settings' && topRoute.params.path.includes('workspace'); const transitionRoute = _.find(routes, route => route.name === SCREENS.TRANSITION_FROM_OLD_DOT); const exitingToWorkspaceRoute = lodashGet(transitionRoute, 'params.exitTo', '') === 'workspace/new'; - const isDisplayingWorkspaceRoute = topRouteName.toLowerCase().includes('workspace') || exitingToWorkspaceRoute; + const isDisplayingWorkspaceRoute = isWorkspaceRoute || exitingToWorkspaceRoute; // We want to display the Workspace chat first since that means a user is already in a Workspace and doesn't need to create another one const workspaceChatReport = _.find(allReports, report => ReportUtils.isPolicyExpenseChat(report) && report.ownerEmail === email); diff --git a/src/libs/deprecatedAPI.js b/src/libs/deprecatedAPI.js index c5e7979d5c2d..d8a9224dd643 100644 --- a/src/libs/deprecatedAPI.js +++ b/src/libs/deprecatedAPI.js @@ -23,46 +23,6 @@ Request.use(Middleware.Retry); // SaveResponseInOnyx - Merges either the successData or failureData into Onyx depending on if the call was successful or not Request.use(Middleware.SaveResponseInOnyx); -/** - * @param {Object} parameters - * @param {String} parameters.authToken - * @param {String} parameters.partnerName - * @param {String} parameters.partnerPassword - * @param {String} parameters.partnerUserID - * @param {String} parameters.partnerUserSecret - * @param {Boolean} [parameters.shouldRetry] - * @param {String} [parameters.email] - * @returns {Promise} - */ -function CreateLogin(parameters) { - const commandName = 'CreateLogin'; - requireParameters([ - 'authToken', - 'partnerName', - 'partnerPassword', - 'partnerUserID', - 'partnerUserSecret', - ], parameters, commandName); - return Network.post(commandName, parameters); -} - -/** - * @param {Object} parameters - * @param {String} parameters.partnerUserID - * @param {String} parameters.partnerName - * @param {String} parameters.partnerPassword - * @param {Boolean} parameters.shouldRetry - * @returns {Promise} - */ -function DeleteLogin(parameters) { - const commandName = 'DeleteLogin'; - requireParameters(['partnerUserID', 'partnerName', 'partnerPassword', 'shouldRetry'], - parameters, commandName); - - // Non-cancellable request: during logout, when requests are cancelled, we don't want to cancel the actual logout request - return Network.post(commandName, {...parameters, canCancel: false}); -} - /** * @param {Object} parameters * @param {String} parameters.returnValueList @@ -123,8 +83,6 @@ function User_SecondaryLogin_Send(parameters) { } export { - CreateLogin, - DeleteLogin, Get, PersonalDetails_Update, ResendValidateCode, diff --git a/src/pages/DetailsPage.js b/src/pages/DetailsPage.js index 19918f915a32..447cb7a244b9 100755 --- a/src/pages/DetailsPage.js +++ b/src/pages/DetailsPage.js @@ -98,7 +98,7 @@ class DetailsPage extends React.PureComponent { // If we have a reportID param this means that we // arrived here via the ParticipantsPage and should be allowed to navigate back to it const shouldShowBackButton = Boolean(this.props.route.params.reportID); - const shouldShowLocalTime = !ReportUtils.hasExpensifyEmails([details.login]) && details.timezone; + const shouldShowLocalTime = !ReportUtils.hasAutomatedExpensifyEmails([details.login]) && details.timezone; let pronouns = details.pronouns; if (pronouns && pronouns.startsWith(CONST.PRONOUNS.PREFIX)) { diff --git a/src/pages/GetAssistancePage.js b/src/pages/GetAssistancePage.js index f21416668442..b29c042f6ab2 100644 --- a/src/pages/GetAssistancePage.js +++ b/src/pages/GetAssistancePage.js @@ -43,6 +43,7 @@ const GetAssistancePage = (props) => { onPress: () => Link.openExternalLink(CONST.NEWHELP_URL), icon: Expensicons.QuestionMark, shouldShowRightIcon: true, + iconRight: Expensicons.NewWindow, wrapperStyle: [styles.cardMenuItem], }]; @@ -54,6 +55,7 @@ const GetAssistancePage = (props) => { onPress: () => Linking.openURL(guideCalendarLink), icon: Expensicons.Phone, shouldShowRightIcon: true, + iconRight: Expensicons.NewWindow, wrapperStyle: [styles.cardMenuItem], }); } diff --git a/src/pages/LogOutPreviousUserPage.js b/src/pages/LogOutPreviousUserPage.js index d9d36602721d..843930b14e8a 100644 --- a/src/pages/LogOutPreviousUserPage.js +++ b/src/pages/LogOutPreviousUserPage.js @@ -22,9 +22,21 @@ class LogOutPreviousUserPage extends Component { .then((transitionURL) => { const sessionEmail = lodashGet(this.props.session, 'email', ''); const isLoggingInAsNewUser = SessionUtils.isLoggingInAsNewUser(transitionURL, sessionEmail); + if (isLoggingInAsNewUser) { Session.signOutAndRedirectToSignIn(); } + + // We need to signin and fetch a new authToken, if a user was already authenticated in NewDot, and was redirected to OldDot + // and their authToken stored in Onyx becomes invalid. + // This workflow is triggered while setting up VBBA. User is redirected from NewDot to OldDot to set up 2FA, and then redirected back to NewDot + // On Enabling 2FA, authToken stored in Onyx becomes expired and hence we need to fetch new authToken + const shouldForceLogin = lodashGet(this.props, 'route.params.shouldForceLogin', '') === 'true'; + if (shouldForceLogin) { + const email = lodashGet(this.props, 'route.params.email', ''); + const shortLivedAuthToken = lodashGet(this.props, 'route.params.shortLivedAuthToken', ''); + Session.signInWithShortLivedAuthToken(email, shortLivedAuthToken); + } }); } diff --git a/src/pages/ReimbursementAccount/ACHContractStep.js b/src/pages/ReimbursementAccount/ACHContractStep.js index d86115e0a386..cda27a84ed04 100644 --- a/src/pages/ReimbursementAccount/ACHContractStep.js +++ b/src/pages/ReimbursementAccount/ACHContractStep.js @@ -3,6 +3,7 @@ import lodashGet from 'lodash/get'; import React from 'react'; import PropTypes from 'prop-types'; import {View} from 'react-native'; +import Str from 'expensify-common/lib/str'; import Text from '../../components/Text'; import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; import styles from '../../styles/styles'; @@ -14,8 +15,9 @@ import * as BankAccounts from '../../libs/actions/BankAccounts'; import Navigation from '../../libs/Navigation/Navigation'; import CONST from '../../CONST'; import * as ValidationUtils from '../../libs/ValidationUtils'; -import * as ReimbursementAccountUtils from '../../libs/ReimbursementAccountUtils'; -import ReimbursementAccountForm from './ReimbursementAccountForm'; +import ONYXKEYS from '../../ONYXKEYS'; +import Form from '../../components/Form'; +import * as FormActions from '../../libs/actions/FormActions'; import ScreenWrapper from '../../components/ScreenWrapper'; import StepPropTypes from './StepPropTypes'; @@ -29,135 +31,129 @@ const propTypes = { class ACHContractStep extends React.Component { constructor(props) { super(props); + this.validate = this.validate.bind(this); this.addBeneficialOwner = this.addBeneficialOwner.bind(this); this.submit = this.submit.bind(this); this.state = { - ownsMoreThan25Percent: props.getDefaultStateForField('ownsMoreThan25Percent', false), - hasOtherBeneficialOwners: props.getDefaultStateForField('hasOtherBeneficialOwners', false), - acceptTermsAndConditions: props.getDefaultStateForField('acceptTermsAndConditions', false), - certifyTrueInformation: props.getDefaultStateForField('certifyTrueInformation', false), + + // Array of strings containing the keys to render associated Identity Forms beneficialOwners: props.getDefaultStateForField('beneficialOwners', []), }; + } - // These fields need to be filled out in order to submit the form (doesn't include IdentityForm fields) - this.requiredFields = [ - 'acceptTermsAndConditions', - 'certifyTrueInformation', - ]; + /** + * @param {Object} values - input values passed by the Form component + * @returns {Object} + */ + validate(values) { + const errors = {}; - // Map a field to the key of the error's translation - this.errorTranslationKeys = { - acceptTermsAndConditions: 'common.error.acceptedTerms', - certifyTrueInformation: 'beneficialOwnersStep.error.certify', + const errorKeys = { + street: 'address', + city: 'addressCity', + state: 'addressState', }; + const requiredFields = ['firstName', 'lastName', 'dob', 'ssnLast4', 'street', 'city', 'zipCode', 'state']; + if (values.hasOtherBeneficialOwners) { + _.each(this.state.beneficialOwners, (ownerKey) => { + // eslint-disable-next-line rulesdir/prefer-early-return + _.each(requiredFields, (inputKey) => { + if (!ValidationUtils.isRequiredFulfilled(values[`beneficialOwner_${ownerKey}_${inputKey}`])) { + const errorKey = errorKeys[inputKey] || inputKey; + errors[`beneficialOwner_${ownerKey}_${inputKey}`] = this.props.translate(`bankAccount.error.${errorKey}`); + } + }); - this.getErrors = () => ReimbursementAccountUtils.getErrors(this.props); - this.clearError = inputKey => ReimbursementAccountUtils.clearError(this.props, inputKey); - this.clearErrors = inputKeys => ReimbursementAccountUtils.clearErrors(this.props, inputKeys); - this.getErrorText = inputKey => ReimbursementAccountUtils.getErrorText(this.props, this.errorTranslationKeys, inputKey); - } + if (values[`beneficialOwner_${ownerKey}_dob`] && !ValidationUtils.meetsAgeRequirements(values[`beneficialOwner_${ownerKey}_dob`])) { + errors[`beneficialOwner_${ownerKey}_dob`] = this.props.translate('bankAccount.error.age'); + } - /** - * @returns {Boolean} - */ - validate() { - let beneficialOwnersErrors = []; - if (this.state.hasOtherBeneficialOwners) { - beneficialOwnersErrors = _.map(this.state.beneficialOwners, ValidationUtils.validateIdentity); + if (values[`beneficialOwner_${ownerKey}_ssnLast4`] && !ValidationUtils.isValidSSNLastFour(values[`beneficialOwner_${ownerKey}_ssnLast4`])) { + errors[`beneficialOwner_${ownerKey}_ssnLast4`] = this.props.translate('bankAccount.error.ssnLast4'); + } + + if (values[`beneficialOwner_${ownerKey}_street`] && !ValidationUtils.isValidAddress(values[`beneficialOwner_${ownerKey}_street`])) { + errors[`beneficialOwner_${ownerKey}_street`] = this.props.translate('bankAccount.error.addressStreet'); + } + + if (values[`beneficialOwner_${ownerKey}_zipCode`] && !ValidationUtils.isValidZipCode(values[`beneficialOwner_${ownerKey}_zipCode`])) { + errors[`beneficialOwner_${ownerKey}_zipCode`] = this.props.translate('bankAccount.error.zipCode'); + } + }); } - const errors = {}; - _.each(this.requiredFields, (inputKey) => { - if (ValidationUtils.isRequiredFulfilled(this.state[inputKey])) { - return; - } + if (!ValidationUtils.isRequiredFulfilled(values.acceptTermsAndConditions)) { + errors.acceptTermsAndConditions = this.props.translate('common.error.acceptedTerms'); + } - errors[inputKey] = true; - }); - BankAccounts.setBankAccountFormValidationErrors({...errors, beneficialOwnersErrors}); - return _.every(beneficialOwnersErrors, _.isEmpty) && _.isEmpty(errors); + if (!ValidationUtils.isRequiredFulfilled(values.certifyTrueInformation)) { + errors.certifyTrueInformation = this.props.translate('beneficialOwnersStep.error.certify'); + } + + return errors; } - removeBeneficialOwner(beneficialOwner) { + /** + * @param {Number} ownerKey - ID connected to the beneficial owner identity form + */ + removeBeneficialOwner(ownerKey) { this.setState((prevState) => { - const beneficialOwners = _.without(prevState.beneficialOwners, beneficialOwner); + const beneficialOwners = _.without(prevState.beneficialOwners, ownerKey); - // We set 'beneficialOwners' to null first because we don't have a way yet to replace a specific property without merging it. - // We don't use the debounced function because we want to make both function calls. - BankAccounts.updateReimbursementAccountDraft({beneficialOwners: null}); - BankAccounts.updateReimbursementAccountDraft({beneficialOwners}); + FormActions.setDraftValues(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {beneficialOwners}); - // Clear errors - BankAccounts.setBankAccountFormValidationErrors({}); return {beneficialOwners}; }); } addBeneficialOwner() { - this.setState(prevState => ({beneficialOwners: [...prevState.beneficialOwners, {}]})); + this.setState((prevState) => { + // Each beneficial owner is assigned a unique key that will connect it to an Identity Form. + // That way we can dynamically render each Identity Form based on which keys are present in the beneficial owners array. + const beneficialOwners = [...prevState.beneficialOwners, Str.guid()]; + + FormActions.setDraftValues(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {beneficialOwners}); + return {beneficialOwners}; + }); } /** + * @param {Boolean} ownsMoreThan25Percent * @returns {Boolean} */ - canAddMoreBeneficialOwners() { + canAddMoreBeneficialOwners(ownsMoreThan25Percent) { return _.size(this.state.beneficialOwners) < 3 - || (_.size(this.state.beneficialOwners) === 3 && !this.state.ownsMoreThan25Percent); + || (_.size(this.state.beneficialOwners) === 3 && !ownsMoreThan25Percent); } /** - * Clear the error associated to inputKey if found and store the inputKey new value in the state. - * - * @param {Integer} ownerIndex - * @param {Object} values + * @param {Object} values - object containing form input values */ - clearErrorAndSetBeneficialOwnerValues(ownerIndex, values) { - this.setState((prevState) => { - const beneficialOwners = [...prevState.beneficialOwners]; - beneficialOwners[ownerIndex] = {...beneficialOwners[ownerIndex], ...values}; - BankAccounts.updateReimbursementAccountDraft({beneficialOwners}); - return {beneficialOwners}; - }); - - // Prepare inputKeys for clearing errors - const inputKeys = _.keys(values); - - // dob field has multiple validations/errors, we are handling it temporarily like this. - if (_.contains(inputKeys, 'dob')) { - inputKeys.push('dobAge'); - } - this.clearErrors(_.map(inputKeys, inputKey => `beneficialOwnersErrors.${ownerIndex}.${inputKey}`)); - } - - submit() { - if (!this.validate()) { - return; - } - + submit(values) { const bankAccountID = lodashGet(this.props.reimbursementAccount, 'achData.bankAccountID') || 0; - // If they did not select that there are other beneficial owners, then we need to clear out the array here. The - // reason we do it here is that if they filled out several beneficial owners, but then toggled the checkbox, we - // want the data to remain in the form so we don't lose the user input until they submit the form. This will - // prevent the data from being sent to the API - this.setState(prevState => ({ - beneficialOwners: !prevState.hasOtherBeneficialOwners ? [] : prevState.beneficialOwners, - }), - () => BankAccounts.updateBeneficialOwnersForBankAccount({...this.state, beneficialOwners: JSON.stringify(this.state.beneficialOwners), bankAccountID})); - } + const beneficialOwners = !values.hasOtherBeneficialOwners ? [] + : _.map(this.state.beneficialOwners, ownerKey => ({ + firstName: lodashGet(values, `beneficialOwner_${ownerKey}_firstName`), + lastName: lodashGet(values, `beneficialOwner_${ownerKey}_lastName`), + dob: lodashGet(values, `beneficialOwner_${ownerKey}_dob`), + ssnLast4: lodashGet(values, `beneficialOwner_${ownerKey}_ssnLast4`), + street: lodashGet(values, `beneficialOwner_${ownerKey}_street`), + city: lodashGet(values, `beneficialOwner_${ownerKey}_city`), + state: lodashGet(values, `beneficialOwner_${ownerKey}_state`), + zipCode: lodashGet(values, `beneficialOwner_${ownerKey}_zipCode`), + })); - /** - * @param {Object} fieldName - */ - toggleCheckbox(fieldName) { - this.setState((prevState) => { - const newState = {[fieldName]: !prevState[fieldName]}; - BankAccounts.updateReimbursementAccountDraft(newState); - return newState; + BankAccounts.updateBeneficialOwnersForBankAccount({ + ownsMoreThan25Percent: values.ownsMoreThan25Percent, + hasOtherBeneficialOwners: values.hasOtherBeneficialOwners, + acceptTermsAndConditions: values.acceptTermsAndConditions, + certifyTrueInformation: values.certifyTrueInformation, + beneficialOwners: JSON.stringify(beneficialOwners), + bankAccountID, }); - this.clearError(fieldName); } render() { @@ -172,113 +168,132 @@ class ACHContractStep extends React.Component { guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_BANK_ACCOUNT} shouldShowBackButton /> - - - {this.props.translate('beneficialOwnersStep.checkAllThatApply')} - - this.toggleCheckbox('ownsMoreThan25Percent')} - LabelComponent={() => ( - - {this.props.translate('beneficialOwnersStep.iOwnMoreThan25Percent')} - {this.props.companyName} - - )} - /> - { - this.setState((prevState) => { - const hasOtherBeneficialOwners = !prevState.hasOtherBeneficialOwners; - const newState = { - hasOtherBeneficialOwners, - beneficialOwners: hasOtherBeneficialOwners && _.isEmpty(prevState.beneficialOwners) - ? [{}] - : prevState.beneficialOwners, - }; - BankAccounts.updateReimbursementAccountDraft(newState); - return newState; - }); - }} - LabelComponent={() => ( - - {this.props.translate('beneficialOwnersStep.someoneOwnsMoreThan25Percent')} - {this.props.companyName} + {({inputValues}) => ( + <> + + {this.props.translate('beneficialOwnersStep.checkAllThatApply')} - )} - /> - {this.state.hasOtherBeneficialOwners && ( - - {_.map(this.state.beneficialOwners, (owner, index) => ( - - - {this.props.translate('beneficialOwnersStep.additionalOwner')} + ( + + {this.props.translate('beneficialOwnersStep.iOwnMoreThan25Percent')} + {this.props.companyName} - this.clearErrorAndSetBeneficialOwnerValues(index, values)} - values={{ - firstName: owner.firstName || '', - lastName: owner.lastName || '', - street: owner.street || '', - city: owner.city || '', - state: owner.state || '', - zipCode: owner.zipCode || '', - dob: owner.dob || '', - ssnLast4: owner.ssnLast4 || '', - }} - errors={lodashGet(this.getErrors(), `beneficialOwnersErrors[${index}]`, {})} - /> - {this.state.beneficialOwners.length > 1 && ( - this.removeBeneficialOwner(owner)}> - {this.props.translate('beneficialOwnersStep.removeOwner')} + )} + // eslint-disable-next-line rulesdir/prefer-early-return + onValueChange={(ownsMoreThan25Percent) => { + if (ownsMoreThan25Percent && this.state.beneficialOwners.length > 3) { + // If the user owns more than 25% of the company, then there can only be a maximum of 3 other beneficial owners who owns more than 25%. + // We have to remove the 4th beneficial owner if the checkbox is checked. + this.setState(prevState => ({beneficialOwners: prevState.beneficialOwners.slice(0, -1)})); + } + }} + defaultValue={this.props.getDefaultStateForField('ownsMoreThan25Percent', false)} + shouldSaveDraft + /> + ( + + {this.props.translate('beneficialOwnersStep.someoneOwnsMoreThan25Percent')} + {this.props.companyName} + + )} + // eslint-disable-next-line rulesdir/prefer-early-return + onValueChange={(hasOtherBeneficialOwners) => { + if (hasOtherBeneficialOwners && this.state.beneficialOwners.length === 0) { + this.addBeneficialOwner(); + } + }} + defaultValue={this.props.getDefaultStateForField('hasOtherBeneficialOwners', false)} + shouldSaveDraft + /> + {inputValues.hasOtherBeneficialOwners && ( + + {_.map(this.state.beneficialOwners, (ownerKey, index) => ( + + + {this.props.translate('beneficialOwnersStep.additionalOwner')} + + + {this.state.beneficialOwners.length > 1 && ( + this.removeBeneficialOwner(ownerKey)}> + {this.props.translate('beneficialOwnersStep.removeOwner')} + + )} + + ))} + {this.canAddMoreBeneficialOwners(inputValues.ownsMoreThan25Percent) && ( + + {this.props.translate('beneficialOwnersStep.addAnotherIndividual')} + {this.props.companyName} )} - ))} - {this.canAddMoreBeneficialOwners() && ( - - {this.props.translate('beneficialOwnersStep.addAnotherIndividual')} - {this.props.companyName} - )} - - )} - - {this.props.translate('beneficialOwnersStep.agreement')} - - this.toggleCheckbox('acceptTermsAndConditions')} - LabelComponent={() => ( - - {this.props.translate('common.iAcceptThe')} - - {`${this.props.translate('beneficialOwnersStep.termsAndConditions')}`} - + + {this.props.translate('beneficialOwnersStep.agreement')} - )} - errorText={this.getErrorText('acceptTermsAndConditions')} - hasError={this.getErrors().acceptTermsAndConditions} - /> - this.toggleCheckbox('certifyTrueInformation')} - LabelComponent={() => ( - {this.props.translate('beneficialOwnersStep.certifyTrueAndAccurate')} - )} - errorText={this.getErrorText('certifyTrueInformation')} - /> - + ( + + {this.props.translate('common.iAcceptThe')} + + {`${this.props.translate('beneficialOwnersStep.termsAndConditions')}`} + + + )} + defaultValue={this.props.getDefaultStateForField('acceptTermsAndConditions', false)} + shouldSaveDraft + /> + ( + {this.props.translate('beneficialOwnersStep.certifyTrueAndAccurate')} + )} + defaultValue={this.props.getDefaultStateForField('certifyTrueInformation', false)} + shouldSaveDraft + /> + + )} + ); } diff --git a/src/pages/ReimbursementAccount/CompanyStep.js b/src/pages/ReimbursementAccount/CompanyStep.js index ad64a09e54f9..21233f86193f 100644 --- a/src/pages/ReimbursementAccount/CompanyStep.js +++ b/src/pages/ReimbursementAccount/CompanyStep.js @@ -103,7 +103,7 @@ class CompanyStep extends React.Component { } if (!values.incorporationDate || !ValidationUtils.isValidDate(values.incorporationDate)) { - errors.incorporationDate = this.props.translate('bankAccount.error.incorporationDate'); + errors.incorporationDate = this.props.translate('common.error.dateInvalid'); } else if (!values.incorporationDate || !ValidationUtils.isValidPastDate(values.incorporationDate)) { errors.incorporationDate = this.props.translate('bankAccount.error.incorporationDateFuture'); } @@ -137,9 +137,9 @@ class CompanyStep extends React.Component { } render() { - const bankAccountID = lodashGet(this.props.reimbursementAccount, 'achData.bankAccountID') || 0; - const shouldDisableCompanyName = bankAccountID && this.props.getDefaultStateForField('companyName'); - const shouldDisableCompanyTaxID = bankAccountID && this.props.getDefaultStateForField('companyTaxID'); + const bankAccountID = lodashGet(this.props.reimbursementAccount, 'achData.bankAccountID', 0); + const shouldDisableCompanyName = Boolean(bankAccountID && this.props.getDefaultStateForField('companyName')); + const shouldDisableCompanyTaxID = Boolean(bankAccountID && this.props.getDefaultStateForField('companyTaxID')); return ( diff --git a/src/pages/ReimbursementAccount/Enable2FAPrompt.js b/src/pages/ReimbursementAccount/Enable2FAPrompt.js new file mode 100644 index 000000000000..51c62b8957c1 --- /dev/null +++ b/src/pages/ReimbursementAccount/Enable2FAPrompt.js @@ -0,0 +1,45 @@ +import React from 'react'; +import {View} from 'react-native'; +import Text from '../../components/Text'; +import styles from '../../styles/styles'; +import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; +import * as Expensicons from '../../components/Icon/Expensicons'; +import * as Illustrations from '../../components/Icon/Illustrations'; +import Section from '../../components/Section'; +import * as Link from '../../libs/actions/Link'; +import ROUTES from '../../ROUTES'; +import themeColors from '../../styles/themes/default'; + +const propTypes = { + ...withLocalizePropTypes, +}; +const Enable2FAPrompt = props => ( +
{ + Link.openOldDotLink(encodeURI(`settings?param={"section":"account","action":"enableTwoFactorAuth","exitTo":"${ROUTES.getBankAccountRoute()}","isFromNewDot":"true"}`)); + }, + icon: Expensicons.Shield, + shouldShowRightIcon: true, + iconRight: Expensicons.NewWindow, + iconFill: themeColors.success, + wrapperStyle: [styles.cardMenuItem], + }, + ]} + > + + + {props.translate('validationStep.enable2FAText')} + + +
+); + +Enable2FAPrompt.propTypes = propTypes; +Enable2FAPrompt.displayName = 'Enable2FAPrompt'; + +export default withLocalize(Enable2FAPrompt); diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountDraftPropTypes.js b/src/pages/ReimbursementAccount/ReimbursementAccountDraftPropTypes.js index b37186e8c136..04cf3b3b761e 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountDraftPropTypes.js +++ b/src/pages/ReimbursementAccount/ReimbursementAccountDraftPropTypes.js @@ -41,16 +41,5 @@ export default PropTypes.shape({ hasOtherBeneficialOwners: PropTypes.bool, acceptTermsAndConditions: PropTypes.bool, certifyTrueInformation: PropTypes.bool, - beneficialOwners: PropTypes.arrayOf( - PropTypes.shape({ - firstName: PropTypes.string, - lastName: PropTypes.string, - street: PropTypes.string, - city: PropTypes.string, - state: PropTypes.string, - zipCode: PropTypes.string, - dob: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]), - ssnLast4: PropTypes.string, - }), - ), + beneficialOwners: PropTypes.arrayOf(PropTypes.string), }); diff --git a/src/pages/ReimbursementAccount/ValidationStep.js b/src/pages/ReimbursementAccount/ValidationStep.js index 282c1c0fa974..f90a53e082e6 100644 --- a/src/pages/ReimbursementAccount/ValidationStep.js +++ b/src/pages/ReimbursementAccount/ValidationStep.js @@ -1,11 +1,13 @@ import lodashGet from 'lodash/get'; import React from 'react'; -import {View} from 'react-native'; +import {ScrollView, View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; import Str from 'expensify-common/lib/str'; import _ from 'underscore'; import PropTypes from 'prop-types'; import styles from '../../styles/styles'; import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; +import compose from '../../libs/compose'; import * as BankAccounts from '../../libs/actions/BankAccounts'; import * as Report from '../../libs/actions/Report'; import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; @@ -25,6 +27,7 @@ import Section from '../../components/Section'; import CONST from '../../CONST'; import Button from '../../components/Button'; import MenuItem from '../../components/MenuItem'; +import Enable2FAPrompt from './Enable2FAPrompt'; const propTypes = { ...withLocalizePropTypes, @@ -33,6 +36,19 @@ const propTypes = { reimbursementAccount: ReimbursementAccountProps.reimbursementAccountPropTypes.isRequired, onBackButtonPress: PropTypes.func.isRequired, + + /** User's account who is setting up bank account */ + account: PropTypes.shape({ + + /** If user has Two factor authentication enabled */ + requiresTwoFactorAuth: PropTypes.bool, + }), +}; + +const defaultProps = { + account: { + requiresTwoFactorAuth: false, + }, }; class ValidationStep extends React.Component { @@ -86,7 +102,7 @@ class ValidationStep extends React.Component { * @returns {String} */ filterInput(amount) { - let value = amount ? amount.trim() : ''; + let value = amount ? amount.toString().trim() : ''; if (value === '' || !Math.abs(Str.fromUSDToNumber(value)) || _.isNaN(Number(value))) { return ''; } @@ -109,6 +125,7 @@ class ValidationStep extends React.Component { const maxAttemptsReached = lodashGet(this.props.reimbursementAccount, 'maxAttemptsReached'); const isVerifying = !maxAttemptsReached && state === BankAccount.STATE.VERIFYING; + const requiresTwoFactorAuth = lodashGet(this.props, 'account.requiresTwoFactorAuth'); return ( @@ -152,7 +169,7 @@ class ValidationStep extends React.Component { {this.props.translate('validationStep.descriptionCTA')} - + + {!requiresTwoFactorAuth && ( + + + + )} )} {isVerifying && ( - +
-
+ {!requiresTwoFactorAuth && ( + + )} + )}
); @@ -212,5 +237,13 @@ class ValidationStep extends React.Component { } ValidationStep.propTypes = propTypes; - -export default withLocalize(ValidationStep); +ValidationStep.defaultProps = defaultProps; + +export default compose( + withLocalize, + withOnyx({ + account: { + key: ONYXKEYS.ACCOUNT, + }, + }), +)(ValidationStep); diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js index b97b31e7e5de..688b691fb62c 100644 --- a/src/pages/ReportDetailsPage.js +++ b/src/pages/ReportDetailsPage.js @@ -23,6 +23,7 @@ import Text from '../components/Text'; import CONST from '../CONST'; import reportPropTypes from './reportPropTypes'; import withReportOrNavigateHome from './home/report/withReportOrNavigateHome'; +import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView'; const propTypes = { ...withLocalizePropTypes, @@ -109,66 +110,68 @@ class ReportDetailsPage extends Component { const menuItems = this.getMenuItems(); return ( - Navigation.goBack()} - onCloseButtonPress={() => Navigation.dismissModal()} - /> - - - - - - - - - + Navigation.goBack()} + onCloseButtonPress={() => Navigation.dismissModal()} + /> + + + + + - - {chatRoomSubtitle} - + + + + + + {chatRoomSubtitle} + + - - {_.map(menuItems, (item) => { - const brickRoadIndicator = ( - ReportUtils.hasReportNameError(this.props.report) - && item.key === CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS - ) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : ''; - return ( - - ); - })} - + {_.map(menuItems, (item) => { + const brickRoadIndicator = ( + ReportUtils.hasReportNameError(this.props.report) + && item.key === CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS + ) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : ''; + return ( + + ); + })} + + ); } diff --git a/src/pages/ReportParticipantsPage.js b/src/pages/ReportParticipantsPage.js index e91667d4e908..673191386b20 100755 --- a/src/pages/ReportParticipantsPage.js +++ b/src/pages/ReportParticipantsPage.js @@ -20,6 +20,7 @@ import compose from '../libs/compose'; import * as ReportUtils from '../libs/ReportUtils'; import reportPropTypes from './reportPropTypes'; import withReportOrNavigateHome from './home/report/withReportOrNavigateHome'; +import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView'; const propTypes = { /* Onyx Props */ @@ -73,37 +74,39 @@ const ReportParticipantsPage = (props) => { return ( - - - {Boolean(participants.length) - && ( - { - Navigation.navigate(ROUTES.getReportParticipantRoute( - props.route.params.reportID, option.login, - )); - }} - hideSectionHeaders - showTitleTooltip - disableFocusOptions - boldStyle - optionHoveredStyle={styles.hoveredComponentBG} - /> - )} - + + + + {Boolean(participants.length) + && ( + { + Navigation.navigate(ROUTES.getReportParticipantRoute( + props.route.params.reportID, option.login, + )); + }} + hideSectionHeaders + showTitleTooltip + disableFocusOptions + boldStyle + optionHoveredStyle={styles.hoveredComponentBG} + /> + )} + + ); }; diff --git a/src/pages/ReportSettingsPage.js b/src/pages/ReportSettingsPage.js index 1ff79c3b98a2..35c7e0c7cd34 100644 --- a/src/pages/ReportSettingsPage.js +++ b/src/pages/ReportSettingsPage.js @@ -22,6 +22,7 @@ import OfflineWithFeedback from '../components/OfflineWithFeedback'; import reportPropTypes from './reportPropTypes'; import withReportOrNavigateHome from './home/report/withReportOrNavigateHome'; import Form from '../components/Form'; +import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView'; const propTypes = { /** Route params */ @@ -116,100 +117,102 @@ class ReportSettingsPage extends Component { return ( - -
- - - { - if (this.props.report.notificationPreference === notificationPreference) { - return; - } - - Report.updateNotificationPreference( - this.props.report.reportID, - this.props.report.notificationPreference, - notificationPreference, - ); - }} - items={this.getNotificationPreferenceOptions()} - value={this.props.report.notificationPreference} - /> + + + + + + { + if (this.props.report.notificationPreference === notificationPreference) { + return; + } + + Report.updateNotificationPreference( + this.props.report.reportID, + this.props.report.notificationPreference, + notificationPreference, + ); + }} + items={this.getNotificationPreferenceOptions()} + value={this.props.report.notificationPreference} + /> + - - {shouldShowRoomName && ( - - Report.clearPolicyRoomNameErrors(this.props.report.reportID)} - > - - - {shouldDisableRename ? ( - - - {this.props.translate('newRoomPage.roomName')} - - - {this.props.report.reportName} - - - ) - : ( - - )} + {shouldShowRoomName && ( + + Report.clearPolicyRoomNameErrors(this.props.report.reportID)} + > + + + {shouldDisableRename ? ( + + + {this.props.translate('newRoomPage.roomName')} + + + {this.props.report.reportName} + + + ) + : ( + + )} + - - - - )} - {linkedWorkspace && ( - - - {this.props.translate('workspace.common.workspace')} - - - {linkedWorkspace.name} - - - )} - {this.props.report.visibility && ( - - - {this.props.translate('newRoomPage.visibility')} - - - {this.props.translate(`newRoomPage.visibilityOptions.${this.props.report.visibility}`)} - - - { - this.props.report.visibility === CONST.REPORT.VISIBILITY.RESTRICTED - ? this.props.translate('newRoomPage.restrictedDescription') - : this.props.translate('newRoomPage.privateDescription') - } - - - )} - + +
+ )} + {linkedWorkspace && ( + + + {this.props.translate('workspace.common.workspace')} + + + {linkedWorkspace.name} + + + )} + {this.props.report.visibility && ( + + + {this.props.translate('newRoomPage.visibility')} + + + {this.props.translate(`newRoomPage.visibilityOptions.${this.props.report.visibility}`)} + + + { + this.props.report.visibility === CONST.REPORT.VISIBILITY.RESTRICTED + ? this.props.translate('newRoomPage.restrictedDescription') + : this.props.translate('newRoomPage.privateDescription') + } + + + )} + +
); } diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index c99b60c83770..e1bfd267d8f2 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -67,7 +67,7 @@ const HeaderView = (props) => { const subtitle = ReportUtils.getChatRoomSubtitle(props.report, props.policies); const isConcierge = participants.length === 1 && _.contains(participants, CONST.EMAIL.CONCIERGE); - const isAutomatedExpensifyAccount = (participants.length === 1 && ReportUtils.hasExpensifyEmails(participants)); + const isAutomatedExpensifyAccount = (participants.length === 1 && ReportUtils.hasAutomatedExpensifyEmails(participants)); const guideCalendarLink = lodashGet(props.account, 'guideCalendarLink'); // We hide the button when we are chatting with an automated Expensify account since it's not possible to contact diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index ca6b60695c70..467ce7b70a70 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -120,6 +120,7 @@ class ReportScreen extends React.Component { componentDidMount() { this.fetchReportIfNeeded(); toggleReportActionComposeView(true); + Navigation.setIsReportScreenIsReady(); } componentDidUpdate(prevProps) { @@ -177,16 +178,7 @@ class ReportScreen extends React.Component { return null; } - // We create policy rooms for all policies, however we don't show them unless - // - It's a free plan workspace - // - The report includes guides participants (@team.expensify.com) for 1:1 Assigned - // - It's an archived room - if (!Permissions.canUseDefaultRooms(this.props.betas) - && ReportUtils.isDefaultRoom(this.props.report) - && ReportUtils.getPolicyType(this.props.report, this.props.policies) !== CONST.POLICY.TYPE.FREE - && !ReportUtils.hasExpensifyGuidesEmails(lodashGet(this.props.report, ['participants'], [])) - && !ReportUtils.isArchivedRoom(this.props.report) - ) { + if (ReportUtils.isDefaultRoom(this.props.report) && !ReportUtils.canSeeDefaultRoom(this.props.report, this.props.policies, this.props.betas)) { return null; } diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index 7ef7fd1d7a83..9c5ec671cb74 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -471,7 +471,7 @@ class ReportActionCompose extends React.Component { const trimmedComment = this.comment.trim(); // Don't submit empty comments or comments that exceed the character limit - if (this.state.isCommentEmpty || trimmedComment.length > CONST.MAX_COMMENT_LENGTH) { + if (this.state.isCommentEmpty || ReportUtils.getCommentLength(trimmedComment) > CONST.MAX_COMMENT_LENGTH) { return ''; } @@ -534,7 +534,8 @@ class ReportActionCompose extends React.Component { const isComposeDisabled = this.props.isDrawerOpen && this.props.isSmallScreenWidth; const isBlockedFromConcierge = ReportUtils.chatIncludesConcierge(this.props.report) && User.isBlockedFromConcierge(this.props.blockedFromConcierge); const inputPlaceholder = this.getInputPlaceholder(); - const hasExceededMaxCommentLength = this.comment.length > CONST.MAX_COMMENT_LENGTH; + const encodedCommentLength = ReportUtils.getCommentLength(this.comment); + const hasExceededMaxCommentLength = encodedCommentLength > CONST.MAX_COMMENT_LENGTH; return ( {!this.props.isSmallScreenWidth && } - + {this.state.isDraggingOver && } diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js index 97fffebebcd3..5f6b5e36632e 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.js +++ b/src/pages/home/report/ReportActionItemMessageEdit.js @@ -15,6 +15,7 @@ import ReportActionComposeFocusManager from '../../../libs/ReportActionComposeFo import compose from '../../../libs/compose'; import EmojiPickerButton from '../../../components/EmojiPicker/EmojiPickerButton'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; +import * as ReportUtils from '../../../libs/ReportUtils'; import * as EmojiUtils from '../../../libs/EmojiUtils'; import reportPropTypes from '../../reportPropTypes'; import ExceededCommentLength from '../../../components/ExceededCommentLength'; @@ -154,7 +155,7 @@ class ReportActionItemMessageEdit extends React.Component { */ publishDraft() { // Do nothing if draft exceed the character limit - if (this.state.draft.length > CONST.MAX_COMMENT_LENGTH) { + if (ReportUtils.getCommentLength(this.state.draft) > CONST.MAX_COMMENT_LENGTH) { return; } @@ -214,7 +215,8 @@ class ReportActionItemMessageEdit extends React.Component { } render() { - const hasExceededMaxCommentLength = this.state.draft.length > CONST.MAX_COMMENT_LENGTH; + const draftLength = ReportUtils.getCommentLength(this.state.draft); + const hasExceededMaxCommentLength = draftLength > CONST.MAX_COMMENT_LENGTH; return ( - + ); diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js index 2cb226c94f94..0c55f189bf1a 100755 --- a/src/pages/settings/InitialSettingsPage.js +++ b/src/pages/settings/InitialSettingsPage.js @@ -176,6 +176,8 @@ class InitialSettingsPage extends React.Component { translationKey: 'initialSettingsPage.help', icon: Expensicons.QuestionMark, action: () => { Link.openExternalLink(CONST.NEWHELP_URL); }, + shouldShowRightIcon: true, + iconRight: Expensicons.NewWindow, }, { translationKey: 'initialSettingsPage.about', @@ -204,6 +206,7 @@ class InitialSettingsPage extends React.Component { iconStyles={item.iconStyles} iconFill={item.iconFill} shouldShowRightIcon + iconRight={item.iconRight} badgeText={this.getWalletBalance(isPaymentItem)} fallbackIcon={item.fallbackIcon} brickRoadIndicator={item.brickRoadIndicator} diff --git a/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js b/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js index df20970bf278..9a480454eaf3 100644 --- a/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js +++ b/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js @@ -324,7 +324,7 @@ class BasePaymentsPage extends React.Component { )} {this.props.translate('paymentsPage.paymentMethodsTitle')} diff --git a/src/pages/settings/Preferences/LanguagePage.js b/src/pages/settings/Preferences/LanguagePage.js new file mode 100644 index 000000000000..92d16b2f9190 --- /dev/null +++ b/src/pages/settings/Preferences/LanguagePage.js @@ -0,0 +1,77 @@ +import _ from 'underscore'; +import React from 'react'; +import PropTypes from 'prop-types'; +import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton'; +import ScreenWrapper from '../../../components/ScreenWrapper'; +import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; +import Navigation from '../../../libs/Navigation/Navigation'; +import ROUTES from '../../../ROUTES'; +import OptionsList from '../../../components/OptionsList'; +import styles from '../../../styles/styles'; +import themeColors from '../../../styles/themes/default'; +import * as Expensicons from '../../../components/Icon/Expensicons'; +import * as App from '../../../libs/actions/App'; + +const greenCheckmark = {src: Expensicons.Checkmark, color: themeColors.success}; + +const propTypes = { + ...withLocalizePropTypes, + + /** The preferred language of the App */ + preferredLocale: PropTypes.string.isRequired, +}; + +const LanguagePage = (props) => { + const localesToLanguages = _.map(props.translate('languagePage.languages'), + (language, key) => ( + { + value: key, + text: language.label, + keyForList: key, + + // Include the green checkmark icon to indicate the currently selected value + customIcon: props.preferredLocale === key ? greenCheckmark : undefined, + + // This property will make the currently selected value have bold text + boldStyle: props.preferredLocale === key, + } + )); + + return ( + + Navigation.navigate(ROUTES.SETTINGS_PREFERENCES)} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> + { + if (language.value !== props.preferredLocale) { + App.setLocale(language.value); + } + Navigation.navigate(ROUTES.SETTINGS_PREFERENCES); + } + } + hideSectionHeaders + optionHoveredStyle={ + { + ...styles.hoveredComponentBG, + ...styles.mhn5, + ...styles.ph5, + } + } + shouldHaveOptionSeparator + shouldDisableRowInnerPadding + contentContainerStyles={[styles.ph5]} + /> + + ); +}; + +LanguagePage.displayName = 'LanguagePage'; +LanguagePage.propTypes = propTypes; + +export default withLocalize(LanguagePage); diff --git a/src/pages/settings/Preferences/PreferencesPage.js b/src/pages/settings/Preferences/PreferencesPage.js new file mode 100755 index 000000000000..3260fda5e5ca --- /dev/null +++ b/src/pages/settings/Preferences/PreferencesPage.js @@ -0,0 +1,118 @@ +import _ from 'underscore'; +import lodashGet from 'lodash/get'; +import React from 'react'; +import {View, ScrollView} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton'; +import Navigation from '../../../libs/Navigation/Navigation'; +import ROUTES from '../../../ROUTES'; +import ONYXKEYS from '../../../ONYXKEYS'; +import styles from '../../../styles/styles'; +import Text from '../../../components/Text'; +import CONST from '../../../CONST'; +import * as User from '../../../libs/actions/User'; +import ScreenWrapper from '../../../components/ScreenWrapper'; +import Switch from '../../../components/Switch'; +import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; +import compose from '../../../libs/compose'; +import withEnvironment, {environmentPropTypes} from '../../../components/withEnvironment'; +import TestToolMenu from '../../../components/TestToolMenu'; +import MenuItemWithTopDescription from '../../../components/MenuItemWithTopDescription'; + +const propTypes = { + /** The chat priority mode */ + priorityMode: PropTypes.string, + + /** The details about the user that is signed in */ + user: PropTypes.shape({ + /** Whether or not the user is subscribed to news updates */ + isSubscribedToNewsletter: PropTypes.bool, + }), + + /** The preferred language of the App */ + preferredLocale: PropTypes.string.isRequired, + + ...withLocalizePropTypes, + ...environmentPropTypes, +}; + +const defaultProps = { + priorityMode: CONST.PRIORITY_MODE.DEFAULT, + user: {}, +}; + +const PreferencesPage = (props) => { + const priorityModes = props.translate('priorityModePage.priorityModes'); + const languages = props.translate('languagePage.languages'); + + return ( + + Navigation.navigate(ROUTES.SETTINGS)} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> + + + + {props.translate('common.notifications')} + + + + + {props.translate('preferencesPage.receiveRelevantFeatureUpdatesAndExpensifyNews')} + + + + + + + Navigation.navigate(ROUTES.SETTINGS_PRIORITY_MODE)} + /> + Navigation.navigate(ROUTES.SETTINGS_LANGUAGE)} + /> + + {/* If we are in the staging environment then we enable additional test features */} + { + _.contains([CONST.ENVIRONMENT.STAGING, CONST.ENVIRONMENT.DEV], props.environment) + && ( + + + + ) + } + + + + ); +}; + +PreferencesPage.propTypes = propTypes; +PreferencesPage.defaultProps = defaultProps; +PreferencesPage.displayName = 'PreferencesPage'; + +export default compose( + withEnvironment, + withLocalize, + withOnyx({ + priorityMode: { + key: ONYXKEYS.NVP_PRIORITY_MODE, + }, + user: { + key: ONYXKEYS.USER, + }, + }), +)(PreferencesPage); diff --git a/src/pages/settings/Preferences/PriorityModePage.js b/src/pages/settings/Preferences/PriorityModePage.js new file mode 100644 index 000000000000..16af2569b824 --- /dev/null +++ b/src/pages/settings/Preferences/PriorityModePage.js @@ -0,0 +1,80 @@ +import _, {compose} from 'underscore'; +import React from 'react'; +import {withOnyx} from 'react-native-onyx'; +import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton'; +import ScreenWrapper from '../../../components/ScreenWrapper'; +import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; +import Navigation from '../../../libs/Navigation/Navigation'; +import ROUTES from '../../../ROUTES'; +import OptionsList from '../../../components/OptionsList'; +import styles from '../../../styles/styles'; +import Text from '../../../components/Text'; +import themeColors from '../../../styles/themes/default'; +import * as Expensicons from '../../../components/Icon/Expensicons'; +import ONYXKEYS from '../../../ONYXKEYS'; +import * as User from '../../../libs/actions/User'; + +const greenCheckmark = {src: Expensicons.Checkmark, color: themeColors.success}; + +const propTypes = { + ...withLocalizePropTypes, +}; + +const PriorityModePage = (props) => { + const priorityModes = _.map(props.translate('priorityModePage.priorityModes'), + (mode, key) => ( + { + value: key, + text: mode.label, + alternateText: mode.description, + keyForList: key, + + // Include the green checkmark icon to indicate the currently selected value + customIcon: props.priorityMode === key ? greenCheckmark : undefined, + + // This property will make the currently selected value have bold text + boldStyle: props.priorityMode === key, + } + )); + + return ( + + Navigation.navigate(ROUTES.SETTINGS_PREFERENCES)} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> + + {props.translate('priorityModePage.explainerText')} + + User.updateChatPriorityMode(mode.value)} + hideSectionHeaders + optionHoveredStyle={ + { + ...styles.hoveredComponentBG, + ...styles.mhn5, + ...styles.ph5, + } + } + shouldHaveOptionSeparator + shouldDisableRowInnerPadding + contentContainerStyles={[styles.ph5]} + /> + + ); +}; + +PriorityModePage.displayName = 'PriorityModePage'; +PriorityModePage.propTypes = propTypes; + +export default compose( + withLocalize, + withOnyx({ + priorityMode: { + key: ONYXKEYS.NVP_PRIORITY_MODE, + }, + }), +)(PriorityModePage); diff --git a/src/pages/settings/PreferencesPage.js b/src/pages/settings/PreferencesPage.js deleted file mode 100755 index ee4a39ddc404..000000000000 --- a/src/pages/settings/PreferencesPage.js +++ /dev/null @@ -1,125 +0,0 @@ -import _ from 'underscore'; -import lodashGet from 'lodash/get'; -import React from 'react'; -import {View, ScrollView} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import PropTypes from 'prop-types'; - -import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; -import LocalePicker from '../../components/LocalePicker'; -import Navigation from '../../libs/Navigation/Navigation'; -import ROUTES from '../../ROUTES'; -import ONYXKEYS from '../../ONYXKEYS'; -import styles from '../../styles/styles'; -import Text from '../../components/Text'; -import CONST from '../../CONST'; -import * as User from '../../libs/actions/User'; -import ScreenWrapper from '../../components/ScreenWrapper'; -import Switch from '../../components/Switch'; -import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; -import compose from '../../libs/compose'; -import Picker from '../../components/Picker'; -import withEnvironment, {environmentPropTypes} from '../../components/withEnvironment'; -import TestToolMenu from '../../components/TestToolMenu'; - -const propTypes = { - /** The chat priority mode */ - priorityMode: PropTypes.string, - - /** The details about the user that is signed in */ - user: PropTypes.shape({ - /** Whether or not the user is subscribed to news updates */ - isSubscribedToNewsletter: PropTypes.bool, - shouldUseStagingServer: PropTypes.bool, - }), - - ...withLocalizePropTypes, - ...environmentPropTypes, -}; - -const defaultProps = { - priorityMode: CONST.PRIORITY_MODE.DEFAULT, - user: {}, -}; - -const PreferencesPage = (props) => { - const priorityModes = { - default: { - value: CONST.PRIORITY_MODE.DEFAULT, - label: props.translate('preferencesPage.mostRecent'), - description: props.translate('preferencesPage.mostRecentModeDescription'), - }, - gsd: { - value: CONST.PRIORITY_MODE.GSD, - label: props.translate('preferencesPage.focus'), - description: props.translate('preferencesPage.focusModeDescription'), - }, - }; - - return ( - - Navigation.navigate(ROUTES.SETTINGS)} - onCloseButtonPress={() => Navigation.dismissModal(true)} - /> - - - - {props.translate('common.notifications')} - - - - - {props.translate('preferencesPage.receiveRelevantFeatureUpdatesAndExpensifyNews')} - - - - - - - - User.updateChatPriorityMode(mode) - } - items={_.values(priorityModes)} - value={props.priorityMode} - /> - - - {priorityModes[props.priorityMode].description} - - - - - - {/* If we are in the staging environment then we enable additional test features */} - {_.contains([CONST.ENVIRONMENT.STAGING, CONST.ENVIRONMENT.DEV], props.environment) && } - - - - ); -}; - -PreferencesPage.propTypes = propTypes; -PreferencesPage.defaultProps = defaultProps; -PreferencesPage.displayName = 'PreferencesPage'; - -export default compose( - withEnvironment, - withLocalize, - withOnyx({ - priorityMode: { - key: ONYXKEYS.NVP_PRIORITY_MODE, - }, - user: { - key: ONYXKEYS.USER, - }, - }), -)(PreferencesPage); diff --git a/src/pages/settings/Profile/DisplayNamePage.js b/src/pages/settings/Profile/DisplayNamePage.js index 3d0336b45358..44dacca89c90 100644 --- a/src/pages/settings/Profile/DisplayNamePage.js +++ b/src/pages/settings/Profile/DisplayNamePage.js @@ -85,7 +85,7 @@ class DisplayNamePage extends Component { } // Check the character limit for first and last name - const characterLimitError = Localize.translateLocal('personalDetails.error.characterLimit', {limit: CONST.FORM_CHARACTER_LIMIT}); + const characterLimitError = Localize.translateLocal('common.error.characterLimit', {limit: CONST.FORM_CHARACTER_LIMIT}); const [hasFirstNameError, hasLastNameError] = ValidationUtils.doesFailCharacterLimitAfterTrim( CONST.FORM_CHARACTER_LIMIT, [values.firstName, values.lastName], diff --git a/src/pages/settings/Profile/PersonalDetails/AddressPage.js b/src/pages/settings/Profile/PersonalDetails/AddressPage.js new file mode 100644 index 000000000000..848373fe2cc9 --- /dev/null +++ b/src/pages/settings/Profile/PersonalDetails/AddressPage.js @@ -0,0 +1,226 @@ +import lodashGet from 'lodash/get'; +import _ from 'underscore'; +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; +import {CONST as COMMON_CONST} from 'expensify-common/lib/CONST'; +import {withOnyx} from 'react-native-onyx'; +import ScreenWrapper from '../../../../components/ScreenWrapper'; +import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton'; +import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; +import ROUTES from '../../../../ROUTES'; +import Form from '../../../../components/Form'; +import ONYXKEYS from '../../../../ONYXKEYS'; +import CONST from '../../../../CONST'; +import TextInput from '../../../../components/TextInput'; +import styles from '../../../../styles/styles'; +import Navigation from '../../../../libs/Navigation/Navigation'; +import * as PersonalDetails from '../../../../libs/actions/PersonalDetails'; +import compose from '../../../../libs/compose'; +import AddressSearch from '../../../../components/AddressSearch'; +import CountryPicker from '../../../../components/CountryPicker'; +import StatePicker from '../../../../components/StatePicker'; + +const propTypes = { + /* Onyx Props */ + + /** User's private personal details */ + privatePersonalDetails: PropTypes.shape({ + /** User's home address */ + address: PropTypes.shape({ + street: PropTypes.string, + city: PropTypes.string, + state: PropTypes.string, + zip: PropTypes.string, + country: PropTypes.string, + }), + }), + + ...withLocalizePropTypes, +}; + +const defaultProps = { + privatePersonalDetails: { + address: { + street: '', + city: '', + state: '', + zip: '', + country: '', + }, + }, +}; + +class AddressPage extends Component { + constructor(props) { + super(props); + + this.validate = this.validate.bind(this); + this.updateAddress = this.updateAddress.bind(this); + this.onCountryUpdate = this.onCountryUpdate.bind(this); + + const currentCountry = lodashGet(props.privatePersonalDetails, 'address.country') || ''; + this.state = { + isUsaForm: currentCountry === CONST.USA_COUNTRY_NAME, + }; + } + + /** + * @param {String} newCountry - new country selected in form + */ + onCountryUpdate(newCountry) { + if (newCountry === CONST.USA_COUNTRY_NAME) { + this.setState({isUsaForm: true}); + } else { + this.setState({isUsaForm: false}); + } + } + + /** + * Submit form to update user's first and last legal name + * @param {Object} values - form input values + */ + updateAddress(values) { + PersonalDetails.updateAddress( + values.addressLine1.trim(), + values.addressLine2.trim(), + values.city.trim(), + values.state.trim(), + values.zipPostCode, + values.country, + ); + } + + /** + * @param {Object} values - form input values + * @returns {Object} - An object containing the errors for each inputID + */ + validate(values) { + const errors = {}; + + const requiredFields = [ + 'addressLine1', + 'city', + 'zipPostCode', + 'country', + 'state', + ]; + + // Check "State" dropdown is a valid state if selected Country is USA. + if (this.state.isUsaForm && !COMMON_CONST.STATES[values.state]) { + errors.state = this.props.translate('common.error.fieldRequired'); + } + + // Add "Field required" errors if any required field is empty + _.each(requiredFields, (fieldKey) => { + if (!_.isEmpty(values[fieldKey])) { + return; + } + errors[fieldKey] = this.props.translate('common.error.fieldRequired'); + }); + + return errors; + } + + render() { + const address = lodashGet(this.props.privatePersonalDetails, 'address') || {}; + const [street1, street2] = (address.street || '').split('\n'); + + return ( + + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS)} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> +
+ + + + + + + + + + + + {this.state.isUsaForm ? ( + + ) : ( + + )} + + + + + + + + +
+
+ ); + } +} + +AddressPage.propTypes = propTypes; +AddressPage.defaultProps = defaultProps; + +export default compose( + withLocalize, + withOnyx({ + privatePersonalDetails: { + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + }, + }), +)(AddressPage); diff --git a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js new file mode 100644 index 000000000000..75d56b68aca2 --- /dev/null +++ b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js @@ -0,0 +1,118 @@ +import _ from 'underscore'; +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import ScreenWrapper from '../../../../components/ScreenWrapper'; +import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton'; +import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; +import ROUTES from '../../../../ROUTES'; +import Form from '../../../../components/Form'; +import ONYXKEYS from '../../../../ONYXKEYS'; +import * as ValidationUtils from '../../../../libs/ValidationUtils'; +import styles from '../../../../styles/styles'; +import Navigation from '../../../../libs/Navigation/Navigation'; +import * as PersonalDetails from '../../../../libs/actions/PersonalDetails'; +import compose from '../../../../libs/compose'; +import DatePicker from '../../../../components/DatePicker'; + +const propTypes = { + /* Onyx Props */ + + /** User's private personal details */ + privatePersonalDetails: PropTypes.shape({ + dob: PropTypes.string, + }), + + ...withLocalizePropTypes, +}; + +const defaultProps = { + privatePersonalDetails: { + dob: '', + }, +}; + +class DateOfBirthPage extends Component { + constructor(props) { + super(props); + + this.validate = this.validate.bind(this); + this.updateDateOfBirth = this.updateDateOfBirth.bind(this); + } + + /** + * Submit form to update user's first and last legal name + * @param {Object} values + * @param {String} values.dob - date of birth + */ + updateDateOfBirth(values) { + PersonalDetails.updateDateOfBirth( + values.dob.trim(), + ); + } + + /** + * @param {Object} values + * @param {String} values.dob - date of birth + * @returns {Object} - An object containing the errors for each inputID + */ + validate(values) { + const errors = {}; + const minimumAge = 5; + const maximumAge = 150; + + if (_.isEmpty(values.dob)) { + errors.dob = this.props.translate('common.error.fieldRequired'); + } + const dateError = ValidationUtils.getAgeRequirementError(values.dob, minimumAge, maximumAge); + if (dateError) { + errors.dob = dateError; + } + + return errors; + } + + render() { + const privateDetails = this.props.privatePersonalDetails || {}; + + return ( + + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS)} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> +
+ + + +
+
+ ); + } +} + +DateOfBirthPage.propTypes = propTypes; +DateOfBirthPage.defaultProps = defaultProps; + +export default compose( + withLocalize, + withOnyx({ + privatePersonalDetails: { + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + }, + }), +)(DateOfBirthPage); diff --git a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js new file mode 100644 index 000000000000..be2353768492 --- /dev/null +++ b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js @@ -0,0 +1,155 @@ +import _ from 'underscore'; +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import ScreenWrapper from '../../../../components/ScreenWrapper'; +import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton'; +import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; +import * as Localize from '../../../../libs/Localize'; +import ROUTES from '../../../../ROUTES'; +import Form from '../../../../components/Form'; +import ONYXKEYS from '../../../../ONYXKEYS'; +import CONST from '../../../../CONST'; +import * as ValidationUtils from '../../../../libs/ValidationUtils'; +import TextInput from '../../../../components/TextInput'; +import styles from '../../../../styles/styles'; +import Navigation from '../../../../libs/Navigation/Navigation'; +import * as PersonalDetails from '../../../../libs/actions/PersonalDetails'; +import compose from '../../../../libs/compose'; + +const propTypes = { + /* Onyx Props */ + + /** User's private personal details */ + privatePersonalDetails: PropTypes.shape({ + legalFirstName: PropTypes.string, + legalLastName: PropTypes.string, + }), + + ...withLocalizePropTypes, +}; + +const defaultProps = { + privatePersonalDetails: { + legalFirstName: '', + legalLastName: '', + }, +}; + +class LegalNamePage extends Component { + constructor(props) { + super(props); + + this.validate = this.validate.bind(this); + this.updateLegalName = this.updateLegalName.bind(this); + } + + /** + * Submit form to update user's legal first and last name + * @param {Object} values + * @param {String} values.legalFirstName + * @param {String} values.legalLastName + */ + updateLegalName(values) { + PersonalDetails.updateLegalName( + values.legalFirstName.trim(), + values.legalLastName.trim(), + ); + } + + /** + * @param {Object} values + * @param {String} values.legalFirstName + * @param {String} values.legalLastName + * @returns {Object} - An object containing the errors for each inputID + */ + validate(values) { + const errors = {}; + + // Check for invalid characters in legal first and last name + const [invalidLegalFirstNameCharacter, invalidLegalLastNameCharacter] = ValidationUtils.findInvalidSymbols( + [values.legalFirstName, values.legalLastName], + ); + const [hasLegalFirstNameLengthError, hasLegalLastNameLengthError] = ValidationUtils.doesFailCharacterLimitAfterTrim( + CONST.LEGAL_NAMES_CHARACTER_LIMIT, + [values.legalFirstName, values.legalLastName], + ); + + if (!_.isEmpty(invalidLegalFirstNameCharacter)) { + errors.legalFirstName = Localize.translateLocal( + 'privatePersonalDetails.error.hasInvalidCharacter', + {invalidCharacter: invalidLegalFirstNameCharacter}, + ); + } else if (_.isEmpty(values.legalFirstName)) { + errors.legalFirstName = Localize.translateLocal('common.error.fieldRequired'); + } else if (hasLegalFirstNameLengthError) { + errors.legalFirstName = Localize.translateLocal('common.error.characterLimit', {limit: CONST.LEGAL_NAMES_CHARACTER_LIMIT}); + } + + if (!_.isEmpty(invalidLegalLastNameCharacter)) { + errors.legalLastName = Localize.translateLocal( + 'privatePersonalDetails.error.hasInvalidCharacter', + {invalidCharacter: invalidLegalLastNameCharacter}, + ); + } else if (_.isEmpty(values.legalLastName)) { + errors.legalLastName = Localize.translateLocal('common.error.fieldRequired'); + } else if (hasLegalLastNameLengthError) { + errors.legalLastName = Localize.translateLocal('common.error.characterLimit', {limit: CONST.LEGAL_NAMES_CHARACTER_LIMIT}); + } + + return errors; + } + + render() { + const privateDetails = this.props.privatePersonalDetails || {}; + + return ( + + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS)} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> +
+ + + + + + +
+
+ ); + } +} + +LegalNamePage.propTypes = propTypes; +LegalNamePage.defaultProps = defaultProps; + +export default compose( + withLocalize, + withOnyx({ + privatePersonalDetails: { + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + }, + }), +)(LegalNamePage); diff --git a/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js b/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js new file mode 100644 index 000000000000..4da6ec6134e5 --- /dev/null +++ b/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js @@ -0,0 +1,141 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {ScrollView, View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import ScreenWrapper from '../../../../components/ScreenWrapper'; +import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton'; +import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; +import ROUTES from '../../../../ROUTES'; +import Text from '../../../../components/Text'; +import styles from '../../../../styles/styles'; +import Navigation from '../../../../libs/Navigation/Navigation'; +import compose from '../../../../libs/compose'; +import MenuItemWithTopDescription from '../../../../components/MenuItemWithTopDescription'; +import * as PersonalDetails from '../../../../libs/actions/PersonalDetails'; +import ONYXKEYS from '../../../../ONYXKEYS'; + +const propTypes = { + /* Onyx Props */ + + /** User's private personal details */ + privatePersonalDetails: PropTypes.shape({ + legalFirstName: PropTypes.string, + legalLastName: PropTypes.string, + dob: PropTypes.string, + + /** User's home address */ + address: PropTypes.shape({ + street: PropTypes.string, + city: PropTypes.string, + state: PropTypes.string, + zip: PropTypes.string, + country: PropTypes.string, + }), + }), + + ...withLocalizePropTypes, +}; + +const defaultProps = { + privatePersonalDetails: { + legalFirstName: '', + legalLastName: '', + dob: '', + address: { + street: '', + street2: '', + city: '', + state: '', + zip: '', + country: '', + }, + }, +}; + +const PersonalDetailsInitialPage = (props) => { + PersonalDetails.openPersonalDetailsPage(); + + const privateDetails = props.privatePersonalDetails || {}; + const address = privateDetails.address || {}; + const legalName = `${privateDetails.legalFirstName || ''} ${privateDetails.legalLastName || ''}`.trim(); + + /** + * Applies common formatting to each piece of an address + * + * @param {String} piece + * @returns {String} + */ + const formatPiece = piece => (piece ? `${piece}, ` : ''); + + /** + * Formats an address object into an easily readable string + * + * @returns {String} + */ + const getFormattedAddress = () => { + const [street1, street2] = (address.street || '').split('\n'); + const formattedAddress = formatPiece(street1) + + formatPiece(street2) + + formatPiece(address.city) + + formatPiece(address.state) + + formatPiece(address.zip) + + formatPiece(address.country); + + // Remove the last comma of the address + return formattedAddress.trim().replace(/,$/, ''); + }; + + return ( + + Navigation.navigate(ROUTES.SETTINGS_PROFILE)} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> + + + + + {props.translate('privatePersonalDetails.privateDataMessage')} + + + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_LEGAL_NAME)} + /> + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH)} + /> + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS)} + /> + + + + ); +}; + +PersonalDetailsInitialPage.propTypes = propTypes; +PersonalDetailsInitialPage.defaultProps = defaultProps; +PersonalDetailsInitialPage.displayName = 'PersonalDetailsInitialPage'; + +export default compose( + withLocalize, + withOnyx({ + privatePersonalDetails: { + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + }, + }), +)(PersonalDetailsInitialPage); diff --git a/src/pages/settings/Profile/ProfilePage.js b/src/pages/settings/Profile/ProfilePage.js index 7057bf514190..443f5113b8e2 100755 --- a/src/pages/settings/Profile/ProfilePage.js +++ b/src/pages/settings/Profile/ProfilePage.js @@ -8,6 +8,7 @@ import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import AvatarWithImagePicker from '../../../components/AvatarWithImagePicker'; import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton'; +import MenuItem from '../../../components/MenuItem'; import MenuItemWithTopDescription from '../../../components/MenuItemWithTopDescription'; import OfflineWithFeedback from '../../../components/OfflineWithFeedback'; import ScreenWrapper from '../../../components/ScreenWrapper'; @@ -22,6 +23,7 @@ import ONYXKEYS from '../../../ONYXKEYS'; import ROUTES from '../../../ROUTES'; import styles from '../../../styles/styles'; import LoginField from './LoginField'; +import * as Expensicons from '../../../components/Icon/Expensicons'; const propTypes = { /* Onyx Props */ @@ -179,6 +181,12 @@ class ProfilePage extends Component { login={this.state.logins.phone} defaultValue={this.state.logins.phone} /> + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS)} + shouldShowRightIcon + />
); diff --git a/src/pages/settings/Profile/TimezoneSelectPage.js b/src/pages/settings/Profile/TimezoneSelectPage.js index f1e89caff3b1..7a89ac8ce73d 100644 --- a/src/pages/settings/Profile/TimezoneSelectPage.js +++ b/src/pages/settings/Profile/TimezoneSelectPage.js @@ -88,9 +88,8 @@ class TimezoneSelectPage extends Component { onChangeText={this.filterShownTimezones} onSelectRow={this.saveSelectedTimezone} optionHoveredStyle={styles.hoveredComponentBG} - sections={[{data: this.state.timezoneOptions, indexOffset: 0}]} + sections={[{data: this.state.timezoneOptions}]} shouldHaveOptionSeparator - initiallyFocusedOptionKey={this.currentSelectedTimezone} /> ); diff --git a/src/pages/signin/LoginForm.js b/src/pages/signin/LoginForm.js index e64705de8239..07867403d050 100755 --- a/src/pages/signin/LoginForm.js +++ b/src/pages/signin/LoginForm.js @@ -165,8 +165,8 @@ class LoginForm extends React.Component { } render() { - const formErrorTranslated = this.state.formError && this.props.translate(this.state.formError); - const error = formErrorTranslated || ErrorUtils.getLatestErrorMessage(this.props.account); + const formErrorText = this.state.formError ? this.props.translate(this.state.formError) : ''; + const serverErrorText = ErrorUtils.getLatestErrorMessage(this.props.account); return ( <> @@ -183,6 +183,7 @@ class LoginForm extends React.Component { autoCapitalize="none" autoCorrect={false} keyboardType={CONST.KEYBOARD_TYPE.EMAIL_ADDRESS} + errorText={formErrorText} /> {!_.isEmpty(this.props.account.success) && ( @@ -203,8 +204,8 @@ class LoginForm extends React.Component { buttonText={this.props.translate('common.continue')} isLoading={this.props.account.isLoading} onSubmit={this.validateAndSubmitForm} - message={error} - isAlertVisible={!_.isEmpty(error)} + message={serverErrorText} + isAlertVisible={!_.isEmpty(serverErrorText)} containerStyles={[styles.mh0]} /> diff --git a/src/pages/signin/PasswordForm.js b/src/pages/signin/PasswordForm.js index 30ec93d71ec5..7d8813313089 100755 --- a/src/pages/signin/PasswordForm.js +++ b/src/pages/signin/PasswordForm.js @@ -24,6 +24,7 @@ import * as ErrorUtils from '../../libs/ErrorUtils'; import {withNetwork} from '../../components/OnyxProvider'; import networkPropTypes from '../../components/networkPropTypes'; import OfflineIndicator from '../../components/OfflineIndicator'; +import FormHelpMessage from '../../components/FormHelpMessage'; const propTypes = { /* Onyx Props */ @@ -56,7 +57,7 @@ class PasswordForm extends React.Component { this.clearSignInData = this.clearSignInData.bind(this); this.state = { - formError: false, + formError: {}, password: '', twoFactorAuthCode: '', }; @@ -84,6 +85,23 @@ class PasswordForm extends React.Component { } } + /** + * Handle text input and clear formError upon text change + * + * @param {String} text + * @param {String} key + */ + onTextInput(text, key) { + this.setState({ + [key]: text, + formError: {[key]: ''}, + }); + + if (this.props.account.errors) { + Session.clearAccountMessages(); + } + } + /** * Clear Password from the state */ @@ -98,7 +116,7 @@ class PasswordForm extends React.Component { if (this.input2FA) { this.setState({twoFactorAuthCode: ''}, this.input2FA.clear); } - this.setState({formError: false}); + this.setState({formError: {}}); Session.resetPassword(); } @@ -106,7 +124,7 @@ class PasswordForm extends React.Component { * Clears local and Onyx sign in states */ clearSignInData() { - this.setState({twoFactorAuthCode: '', formError: false}); + this.setState({twoFactorAuthCode: '', formError: {}}); Session.clearSignInData(); } @@ -118,33 +136,28 @@ class PasswordForm extends React.Component { const twoFactorCode = this.state.twoFactorAuthCode.trim(); const requiresTwoFactorAuth = this.props.account.requiresTwoFactorAuth; - if (!password && requiresTwoFactorAuth && !twoFactorCode) { - this.setState({formError: 'passwordForm.pleaseFillOutAllFields'}); - return; - } - if (!password) { - this.setState({formError: 'passwordForm.pleaseFillPassword'}); + this.setState({formError: {password: 'passwordForm.pleaseFillPassword'}}); return; } if (!ValidationUtils.isValidPassword(password)) { - this.setState({formError: 'passwordForm.error.incorrectPassword'}); + this.setState({formError: {password: 'passwordForm.error.incorrectPassword'}}); return; } if (requiresTwoFactorAuth && !twoFactorCode) { - this.setState({formError: 'passwordForm.pleaseFillTwoFactorAuth'}); + this.setState({formError: {twoFactorAuthCode: 'passwordForm.pleaseFillTwoFactorAuth'}}); return; } if (requiresTwoFactorAuth && !ValidationUtils.isValidTwoFactorCode(twoFactorCode)) { - this.setState({formError: 'passwordForm.error.incorrect2fa'}); + this.setState({formError: {twoFactorAuthCode: 'passwordForm.error.incorrect2fa'}}); return; } this.setState({ - formError: null, + formError: {}, }); Session.signIn(password, '', twoFactorCode); @@ -163,9 +176,10 @@ class PasswordForm extends React.Component { nativeID="password" name="password" value={this.state.password} - onChangeText={text => this.setState({password: text})} + onChangeText={text => this.onTextInput(text, 'password')} onSubmitEditing={this.validateAndSubmitForm} blurOnSubmit={false} + errorText={this.state.formError.password ? this.props.translate(this.state.formError.password) : ''} /> this.setState({twoFactorAuthCode: text})} + onChangeText={text => this.onTextInput(text, 'twoFactorAuthCode')} onSubmitEditing={this.validateAndSubmitForm} keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} blurOnSubmit={false} maxLength={CONST.TFA_CODE_LENGTH} + errorText={this.state.formError.twoFactorAuthCode ? this.props.translate(this.state.formError.twoFactorAuthCode) : ''} /> )} - {!this.state.formError && this.props.account && !_.isEmpty(this.props.account.errors) && ( - - {ErrorUtils.getLatestErrorMessage(this.props.account)} - - )} - - {this.state.formError && ( - - {this.props.translate(this.state.formError)} - + {this.props.account && !_.isEmpty(this.props.account.errors) && ( + )}