diff --git a/.gitignore b/.gitignore index d3b4daac04d7..335efdc5586a 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,6 @@ tsconfig.tsbuildinfo # Yalc .yalc yalc.lock + +# Local https certificate/key +config/webpack/*.pem diff --git a/.prettierignore b/.prettierignore index 5f6292b551c1..80888b18a317 100644 --- a/.prettierignore +++ b/.prettierignore @@ -15,3 +15,5 @@ package-lock.json *.css *.scss *.md +# We need to modify the import here specifically, hence we disable prettier to get rid of the sorted imports +src/libs/E2E/reactNativeLaunchingTest.js diff --git a/README.md b/README.md index 998f185939fa..b2d51b2a1709 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ #### Table of Contents * [Local Development](#local-development) +* [Testing on browsers on simulators and emulators](#testing-on-browsers-on-simulators-and-emulators) * [Running The Tests](#running-the-tests) * [Debugging](#debugging) * [App Structure and Conventions](#app-structure-and-conventions) @@ -34,12 +35,22 @@ These instructions should get you set up ready to work on New Expensify ๐Ÿ™Œ 1. Install `nvm` then `node` & `npm`: `brew install nvm && nvm install` 2. Install `watchman`: `brew install watchman` 3. Install dependencies: `npm install` +4. Install `mkcert`: `brew install mkcert` followed by `npm run setup-https`. If you are not using macOS, follow the instructions [here](https://github.com/FiloSottile/mkcert?tab=readme-ov-file#installation). +5. Create a host entry in your local hosts file, `/etc/hosts` for dev.new.expensify.com pointing to localhost: +``` +127.0.0.1 dev.new.expensify.com +``` You can use any IDE or code editing tool for developing on any platform. Use your favorite! ## Recommended `node` setup In order to have more consistent builds, we use a strict `node` and `npm` version as defined in the `package.json` `engines` field and `.nvmrc` file. `npm install` will fail if you do not use the version defined, so it is recommended to install `node` via `nvm` for easy node version management. Automatic `node` version switching can be installed for [`zsh`](https://github.com/nvm-sh/nvm#zsh) or [`bash`](https://github.com/nvm-sh/nvm#bash) using `nvm`. +## Configuring HTTPS +The webpack development server now uses https. If you're using a mac, you can simply run `npm run setup-https`. + +If you're using another operating system, you will need to ensure `mkcert` is installed, and then follow the instructions in the repository to generate certificates valid for `new.expesify.com.dev` and `localhost`. The certificate should be named `certificate.pem` and the key should be named `key.pem`. They should be placed in `config/webpack`. + ## Running the web app ๐Ÿ•ธ * To run the **development web app**: `npm run web` * Changes applied to Javascript will be applied automatically via WebPack as configured in `webpack.dev.js` @@ -103,6 +114,43 @@ variables referenced here get updated since your local `.env` file is ignored. ---- +# Testing on browsers in simulators and emulators + +The development server is reached through the HTTPS protocol, and any client that access the development server needs a certificate. + +You create this certificate by following the instructions in [`Configuring HTTPS`](#configuring-https) of this readme. When accessing the website served from the development server on browsers in iOS simulator or Android emulator, these virtual devices need to have the same certificate installed. Follow the steps below to install them. + +#### Pre-requisite for Android flow +1. Open any emulator using Android Studio +2. Use `adb push "$(mkcert -CAROOT)/rootCA.pem" /storage/emulated/0/Download/` to push certificate to install in Download folder. +3. Install the certificate as CA certificate from the settings. On the Android emulator, this option can be found in Settings > Security > Encryption & Credentials > Install a certificate > CA certificate. +4. Close the emulator. + +Note - If you want to run app on `https://127.0.0.1:8082`, then just install the certificate and use `adb reverse tcp:8082 tcp:8082` on every startup. + +#### Android Flow +1. Run `npm run setupNewDotWebForEmulators android` +2. Select the emulator you want to run if prompted. (If single emulator is available, then it will open automatically) +3. Let the script execute till the message `๐ŸŽ‰ Done!`. + +Note - If you want to run app on `https://dev.new.expensify.com:8082`, then just do the Android flow and use `npm run startAndroidEmulator` to start the Android Emulator every time (It will configure the emulator). + + +Possible Scenario: +The flow may fail to root with error `adbd cannot run as root in production builds`. In this case, please refer to https://stackoverflow.com/a/45668555. Or use `https://127.0.0.1:8082` for less hassle. + +#### iOS Flow +1. Run `npm run setupNewDotWebForEmulators ios` +2. Select the emulator you want to run if prompted. (If single emulator is available, then it will open automatically) +3. Let the script execute till the message `๐ŸŽ‰ Done!`. + +#### All Flow +1. Run `npm run setupNewDotWebForEmulators all` or `npm run setupNewDotWebForEmulators` +2. Check if the iOS flow runs first and then Android flow runs. +3. Let the script execute till the message `๐ŸŽ‰ Done!`. + +---- + # Running the tests ## Unit tests Unit tests are valuable when you want to test one component. They should be short, fast, and ideally only test one thing. diff --git a/babel.config.js b/babel.config.js index 189c3379aa6d..7de6926c850d 100644 --- a/babel.config.js +++ b/babel.config.js @@ -98,6 +98,7 @@ if (process.env.CAPTURE_METRICS === 'true') { 'scheduler/tracing': 'scheduler/tracing-profiling', }, }, + 'extra-alias', ]); } diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js index a22a9d55b2a3..8d423dbc4213 100644 --- a/config/webpack/webpack.common.js +++ b/config/webpack/webpack.common.js @@ -187,7 +187,6 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ 'react-native-config': 'react-web-config', 'react-native$': '@expensify/react-native-web', 'react-native-web': '@expensify/react-native-web', - 'react-content-loader/native': 'react-content-loader', 'lottie-react-native': 'react-native-web-lottie', // Module alias for web & desktop diff --git a/config/webpack/webpack.dev.js b/config/webpack/webpack.dev.js index 19999491395e..e28383eff557 100644 --- a/config/webpack/webpack.dev.js +++ b/config/webpack/webpack.dev.js @@ -44,6 +44,14 @@ module.exports = (env = {}) => ...proxySettings, historyApiFallback: true, port, + host: 'dev.new.expensify.com', + server: { + type: 'https', + options: { + key: path.join(__dirname, 'key.pem'), + cert: path.join(__dirname, 'certificate.pem'), + }, + }, }, plugins: [ new DefinePlugin({ diff --git a/contributingGuides/APPLE_GOOGLE_SIGNIN.md b/contributingGuides/APPLE_GOOGLE_SIGNIN.md index 9032a99dfbbd..3ade13554bd6 100644 --- a/contributingGuides/APPLE_GOOGLE_SIGNIN.md +++ b/contributingGuides/APPLE_GOOGLE_SIGNIN.md @@ -161,10 +161,10 @@ function beginAppleSignIn(idToken) { You can use any SSH tunneling service that allows you to configure custom subdomains so that we have a consistent address to use. We'll use ngrok in these examples, but ngrok requires a paid account for this. If you need a free option, try serveo.net. -After you've set ngrok up to be able to run on your machine (requires configuring a key with the command line tool, instructions provided by the ngrok website after you create an account), test hosting the web app on a custom subdomain. This example assumes the development web app is running at `localhost:8082`: +After you've set ngrok up to be able to run on your machine (requires configuring a key with the command line tool, instructions provided by the ngrok website after you create an account), test hosting the web app on a custom subdomain. This example assumes the development web app is running at `dev.new.expensify.com:8082`: ``` -ngrok http 8082 --host-header="localhost:8082" --subdomain=mysubdomain +ngrok http 8082 --host-header="dev.new.expensify.com:8082" --subdomain=mysubdomain ``` The `--host-header` flag is there to avoid webpack errors with header validation. In addition, add `allowedHosts: 'all'` to the dev server config in `webpack.dev.js`: diff --git a/desktop/main.js b/desktop/main.js index 782617ef78b5..2f09fd5721a4 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -90,7 +90,7 @@ _.assign(console, log.functions); // until it detects that it has been upgraded to the correct version. const EXPECTED_UPDATE_VERSION_FLAG = '--expected-update-version'; -const APP_DOMAIN = __DEV__ ? `http://localhost:${port}` : 'app://-'; +const APP_DOMAIN = __DEV__ ? `https://dev.new.expensify.com:${port}` : 'app://-'; let expectedUpdateVersion; for (let i = 0; i < process.argv.length; i++) { @@ -226,7 +226,7 @@ const mainWindow = () => { let deeplinkUrl; let browserWindow; - const loadURL = __DEV__ ? (win) => win.loadURL(`http://localhost:${port}`) : serve({directory: `${__dirname}/www`}); + const loadURL = __DEV__ ? (win) => win.loadURL(`https://dev.new.expensify.com:${port}`) : serve({directory: `${__dirname}/www`}); // Prod and staging set the icon in the electron-builder config, so only update it here for dev if (__DEV__) { diff --git a/desktop/start.js b/desktop/start.js index d9ec59b71c83..05a1b031350d 100644 --- a/desktop/start.js +++ b/desktop/start.js @@ -32,7 +32,7 @@ portfinder env, }, { - command: `wait-port localhost:${port} && npx electronmon ./desktop/dev.js`, + command: `wait-port dev.new.expensify.com:${port} && npx electronmon ./desktop/dev.js`, name: 'Electron', prefixColor: 'cyan.dim', env, diff --git a/docs/_includes/CONST.html b/docs/_includes/CONST.html index 4b87f87931d5..231423f10586 100644 --- a/docs/_includes/CONST.html +++ b/docs/_includes/CONST.html @@ -1,7 +1,7 @@ {% if jekyll.environment == "production" %} {% assign MAIN_SITE_URL = "https://new.expensify.com" %} {% else %} - {% assign MAIN_SITE_URL = "http://localhost:8082" %} + {% assign MAIN_SITE_URL = "https://dev.new.expensify.com:8082" %} {% endif %} {% capture CONCIERGE_CHAT_URL %}{{MAIN_SITE_URL}}/concierge{% endcapture %} diff --git a/docs/sitemap.xml b/docs/sitemap.xml index 1dc57b5364fa..8b911fa849cd 100644 --- a/docs/sitemap.xml +++ b/docs/sitemap.xml @@ -1,10 +1,12 @@ +--- +layout: +--- - - + + {% assign pages = site.html_pages | where_exp:'doc','doc.sitemap != false' | where_exp:'doc','doc.url != "/404.html"' %} + {% for page in pages %} - https://help.expensify.com - 2023-07-27 - monthly - 1.0 + {{ page.url | replace:'/index.html','/' | absolute_url | xml_escape }} - + {% endfor %} + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 690e658fa476..34d73c4c3fb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,7 +85,7 @@ "react-native-fast-image": "^8.6.3", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "2.12.0", - "react-native-google-places-autocomplete": "2.5.5", + "react-native-google-places-autocomplete": "2.5.6", "react-native-haptic-feedback": "^1.13.0", "react-native-image-pan-zoom": "^2.1.12", "react-native-image-picker": "^5.1.0", @@ -44682,9 +44682,9 @@ } }, "node_modules/react-native-google-places-autocomplete": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/react-native-google-places-autocomplete/-/react-native-google-places-autocomplete-2.5.5.tgz", - "integrity": "sha512-ypqaHYRifcY9q28HkZYExzHMF4Eul+mf3y4dlIlvBj3SgLI2FyrD1mdQoF8A7xwhOWctYs6PsVj3Mg71IVJTTw==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/react-native-google-places-autocomplete/-/react-native-google-places-autocomplete-2.5.6.tgz", + "integrity": "sha512-Dy7mFKyEoiNeWPLd7HUkrI/SzJYe7GST55FGtiXzXDoPs05LYHIOCPrT9qFE51COh5X8kgDKm+f7D5aMY/aMbg==", "dependencies": { "lodash.debounce": "^4.0.8", "prop-types": "^15.7.2", @@ -85364,9 +85364,9 @@ } }, "react-native-google-places-autocomplete": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/react-native-google-places-autocomplete/-/react-native-google-places-autocomplete-2.5.5.tgz", - "integrity": "sha512-ypqaHYRifcY9q28HkZYExzHMF4Eul+mf3y4dlIlvBj3SgLI2FyrD1mdQoF8A7xwhOWctYs6PsVj3Mg71IVJTTw==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/react-native-google-places-autocomplete/-/react-native-google-places-autocomplete-2.5.6.tgz", + "integrity": "sha512-Dy7mFKyEoiNeWPLd7HUkrI/SzJYe7GST55FGtiXzXDoPs05LYHIOCPrT9qFE51COh5X8kgDKm+f7D5aMY/aMbg==", "requires": { "lodash.debounce": "^4.0.8", "prop-types": "^15.7.2", diff --git a/package.json b/package.json index a8143fd2a809..29dce434468b 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,8 @@ "test:e2e:compare": "node tests/e2e/merge.js", "gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh", "workflow-test": "./workflow_tests/scripts/runWorkflowTests.sh", - "workflow-test:generate": "node workflow_tests/utils/preGenerateTest.js" + "workflow-test:generate": "node workflow_tests/utils/preGenerateTest.js", + "setup-https": "mkcert -install && mkcert -cert-file config/webpack/certificate.pem -key-file config/webpack/key.pem dev.new.expensify.com localhost 127.0.0.1" }, "dependencies": { "@expensify/react-native-web": "0.18.15", @@ -132,7 +133,7 @@ "react-native-fast-image": "^8.6.3", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "2.12.0", - "react-native-google-places-autocomplete": "2.5.5", + "react-native-google-places-autocomplete": "2.5.6", "react-native-haptic-feedback": "^1.13.0", "react-native-image-pan-zoom": "^2.1.12", "react-native-image-picker": "^5.1.0", diff --git a/src/CONST.ts b/src/CONST.ts index 3989d2b5cbff..8507072da5c8 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -468,7 +468,7 @@ const CONST = { ONFIDO_TERMS_OF_SERVICE_URL: 'https://onfido.com/terms-of-service/', // Use Environment.getEnvironmentURL to get the complete URL with port number - DEV_NEW_EXPENSIFY_URL: 'http://localhost:', + DEV_NEW_EXPENSIFY_URL: 'https://dev.new.expensify.com:', SIGN_IN_FORM_WIDTH: 300, diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.js index 3e122e029969..f8219c028853 100644 --- a/src/components/AddressSearch/index.js +++ b/src/components/AddressSearch/index.js @@ -1,6 +1,6 @@ import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {ActivityIndicator, Keyboard, LogBox, ScrollView, Text, View} from 'react-native'; import {GooglePlacesAutocomplete} from 'react-native-google-places-autocomplete'; import _ from 'underscore'; @@ -140,27 +140,46 @@ const defaultProps = { resultTypes: 'address', }; -// Do not convert to class component! It's been tried before and presents more challenges than it's worth. -// Relevant thread: https://expensify.slack.com/archives/C03TQ48KC/p1634088400387400 -// Reference: https://github.com/FaridSafi/react-native-google-places-autocomplete/issues/609#issuecomment-886133839 -function AddressSearch(props) { +function AddressSearch({ + canUseCurrentLocation, + containerStyles, + defaultValue, + errorText, + hint, + innerRef, + inputID, + isLimitedToUSA, + label, + maxInputLength, + network, + onBlur, + onInputChange, + onPress, + predefinedPlaces, + preferredLocale, + renamedInputKeys, + resultTypes, + shouldSaveDraft, + translate, + value, +}) { const [displayListViewBorder, setDisplayListViewBorder] = useState(false); const [isTyping, setIsTyping] = useState(false); const [isFocused, setIsFocused] = useState(false); - const [searchValue, setSearchValue] = useState(props.value || props.defaultValue || ''); + const [searchValue, setSearchValue] = useState(value || defaultValue || ''); const [locationErrorCode, setLocationErrorCode] = useState(null); const [isFetchingCurrentLocation, setIsFetchingCurrentLocation] = useState(false); const shouldTriggerGeolocationCallbacks = useRef(true); const containerRef = useRef(); const query = useMemo( () => ({ - language: props.preferredLocale, - types: props.resultTypes, - components: props.isLimitedToUSA ? 'country:us' : undefined, + language: preferredLocale, + types: resultTypes, + components: isLimitedToUSA ? 'country:us' : undefined, }), - [props.preferredLocale, props.resultTypes, props.isLimitedToUSA], + [preferredLocale, resultTypes, isLimitedToUSA], ); - const shouldShowCurrentLocationButton = props.canUseCurrentLocation && searchValue.trim().length === 0 && isFocused; + const shouldShowCurrentLocationButton = canUseCurrentLocation && searchValue.trim().length === 0 && isFocused; const saveLocationDetails = (autocompleteData, details) => { const addressComponents = details.address_components; @@ -169,7 +188,7 @@ function AddressSearch(props) { // to this component which don't match the usual properties coming from auto-complete. In that case, only a limited // amount of data massaging needs to happen for what the parent expects to get from this function. if (_.size(details)) { - props.onPress({ + onPress({ address: lodashGet(details, 'description'), lat: lodashGet(details, 'geometry.location.lat', 0), lng: lodashGet(details, 'geometry.location.lng', 0), @@ -256,7 +275,7 @@ function AddressSearch(props) { // Not all pages define the Address Line 2 field, so in that case we append any additional address details // (e.g. Apt #) to Address Line 1 - if (subpremise && typeof props.renamedInputKeys.street2 === 'undefined') { + if (subpremise && typeof renamedInputKeys.street2 === 'undefined') { values.street += `, ${subpremise}`; } @@ -265,19 +284,19 @@ function AddressSearch(props) { values.country = country; } - if (props.inputID) { - _.each(values, (value, key) => { - const inputKey = lodashGet(props.renamedInputKeys, key, key); + if (inputID) { + _.each(values, (inputValue, key) => { + const inputKey = lodashGet(renamedInputKeys, key, key); if (!inputKey) { return; } - props.onInputChange(value, inputKey); + onInputChange(inputValue, inputKey); }); } else { - props.onInputChange(values); + onInputChange(values); } - props.onPress(values); + onPress(values); }; /** Gets the user's current location and registers success/error callbacks */ @@ -307,7 +326,7 @@ function AddressSearch(props) { lng: successData.coords.longitude, address: CONST.YOUR_LOCATION_TEXT, }; - props.onPress(location); + onPress(location); }, (errorData) => { if (!shouldTriggerGeolocationCallbacks.current) { @@ -325,16 +344,16 @@ function AddressSearch(props) { }; const renderHeaderComponent = () => - props.predefinedPlaces.length > 0 && ( + predefinedPlaces.length > 0 && ( <> {/* This will show current location button in list if there are some recent destinations */} {shouldShowCurrentLocationButton && ( )} - {!props.value && {props.translate('common.recentDestinations')}} + {!value && {translate('common.recentDestinations')}} ); @@ -346,6 +365,26 @@ function AddressSearch(props) { }; }, []); + const listEmptyComponent = useCallback( + () => + network.isOffline || !isTyping ? null : ( + {translate('common.noResultsFound')} + ), + [isTyping, translate, network.isOffline], + ); + + const listLoader = useCallback( + () => ( + + + + ), + [], + ); + return ( /* * The GooglePlacesAutocomplete component uses a VirtualizedList internally, @@ -372,20 +411,10 @@ function AddressSearch(props) { fetchDetails suppressDefaultStyles enablePoweredByContainer={false} - predefinedPlaces={props.predefinedPlaces} - listEmptyComponent={ - props.network.isOffline || !isTyping ? null : ( - {props.translate('common.noResultsFound')} - ) - } - listLoaderComponent={ - - - - } + predefinedPlaces={predefinedPlaces} + listEmptyComponent={listEmptyComponent} + listLoaderComponent={listLoader} + renderHeaderComponent={renderHeaderComponent} renderRow={(data) => { const title = data.isPredefinedPlace ? data.name : data.structured_formatting.main_text; const subtitle = data.isPredefinedPlace ? data.description : data.structured_formatting.secondary_text; @@ -396,7 +425,6 @@ function AddressSearch(props) { ); }} - renderHeaderComponent={renderHeaderComponent} onPress={(data, details) => { saveLocationDetails(data, details); setIsTyping(false); @@ -411,34 +439,31 @@ function AddressSearch(props) { query={query} requestUrl={{ useOnPlatform: 'all', - url: props.network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}), + url: network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}), }} textInputProps={{ InputComp: TextInput, ref: (node) => { - if (!props.innerRef) { + if (!innerRef) { return; } - if (_.isFunction(props.innerRef)) { - props.innerRef(node); + if (_.isFunction(innerRef)) { + innerRef(node); return; } // eslint-disable-next-line no-param-reassign - props.innerRef.current = node; + innerRef.current = node; }, - label: props.label, - containerStyles: props.containerStyles, - errorText: props.errorText, - hint: - displayListViewBorder || (props.predefinedPlaces.length === 0 && shouldShowCurrentLocationButton) || (props.canUseCurrentLocation && isTyping) - ? undefined - : props.hint, - value: props.value, - defaultValue: props.defaultValue, - inputID: props.inputID, - shouldSaveDraft: props.shouldSaveDraft, + label, + containerStyles, + errorText, + hint: displayListViewBorder || (predefinedPlaces.length === 0 && shouldShowCurrentLocationButton) || (canUseCurrentLocation && isTyping) ? undefined : hint, + value, + defaultValue, + inputID, + shouldSaveDraft, onFocus: () => { setIsFocused(true); }, @@ -448,24 +473,24 @@ function AddressSearch(props) { setIsFocused(false); setIsTyping(false); } - props.onBlur(); + onBlur(); }, autoComplete: 'off', onInputChange: (text) => { setSearchValue(text); setIsTyping(true); - if (props.inputID) { - props.onInputChange(text); + if (inputID) { + onInputChange(text); } else { - props.onInputChange({street: text}); + onInputChange({street: text}); } // If the text is empty and we have no predefined places, we set displayListViewBorder to false to prevent UI flickering - if (_.isEmpty(text) && _.isEmpty(props.predefinedPlaces)) { + if (_.isEmpty(text) && _.isEmpty(predefinedPlaces)) { setDisplayListViewBorder(false); } }, - maxLength: props.maxInputLength, + maxLength: maxInputLength, spellCheck: false, }} styles={{ @@ -486,17 +511,18 @@ function AddressSearch(props) { }} inbetweenCompo={ // We want to show the current location button even if there are no recent destinations - props.predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? ( + predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? ( ) : ( <> ) } + placeholder="" /> setLocationErrorCode(null)} diff --git a/src/components/CheckboxWithLabel.js b/src/components/CheckboxWithLabel.js index 4bffadecb733..f18ec346dfa2 100644 --- a/src/components/CheckboxWithLabel.js +++ b/src/components/CheckboxWithLabel.js @@ -7,6 +7,7 @@ import variables from '@styles/variables'; import Checkbox from './Checkbox'; import FormHelpMessage from './FormHelpMessage'; import PressableWithFeedback from './Pressable/PressableWithFeedback'; +import refPropTypes from './refPropTypes'; import Text from './Text'; /** @@ -54,7 +55,7 @@ const propTypes = { defaultValue: PropTypes.bool, /** React ref being forwarded to the Checkbox input */ - forwardedRef: PropTypes.func, + forwardedRef: refPropTypes, /** The ID used to uniquely identify the input in a Form */ /* eslint-disable-next-line react/no-unused-prop-types */ diff --git a/src/components/CurrentUserPersonalDetailsSkeletonView/index.js b/src/components/CurrentUserPersonalDetailsSkeletonView/index.js index f2d7a8b71897..d56153cce1d3 100644 --- a/src/components/CurrentUserPersonalDetailsSkeletonView/index.js +++ b/src/components/CurrentUserPersonalDetailsSkeletonView/index.js @@ -1,9 +1,9 @@ import PropTypes from 'prop-types'; import React from 'react'; -import SkeletonViewContentLoader from 'react-content-loader/native'; import {View} from 'react-native'; import {Circle, Rect} from 'react-native-svg'; import _ from 'underscore'; +import SkeletonViewContentLoader from '@components/SkeletonViewContentLoader'; import styles from '@styles/styles'; import * as StyleUtils from '@styles/StyleUtils'; import themeColors from '@styles/themes/default'; diff --git a/src/components/DotIndicatorMessage.js b/src/components/DotIndicatorMessage.js index 26e42c0b9ec7..26e01f0eee8a 100644 --- a/src/components/DotIndicatorMessage.js +++ b/src/components/DotIndicatorMessage.js @@ -5,6 +5,7 @@ import _ from 'underscore'; import * as Localize from '@libs/Localize'; import stylePropTypes from '@styles/stylePropTypes'; import styles from '@styles/styles'; +import * as StyleUtils from '@styles/StyleUtils'; import themeColors from '@styles/themes/default'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; @@ -57,19 +58,21 @@ function DotIndicatorMessage(props) { .map((message) => Localize.translateIfPhraseKey(message)) .value(); + const isErrorMessage = props.type === 'error'; + return ( {_.map(sortedMessages, (message, i) => ( {message} diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js index 92baa9727832..85408323c9f2 100644 --- a/src/components/Form/FormProvider.js +++ b/src/components/Form/FormProvider.js @@ -71,6 +71,8 @@ const propTypes = { shouldValidateOnChange: PropTypes.bool, }; +const VALIDATE_DELAY = 200; + const defaultProps = { isSubmitButtonVisible: true, formState: { @@ -246,19 +248,28 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC // as this is already happening by the value prop. defaultValue: undefined, onTouched: (event) => { - setTouchedInput(inputID); + setTimeout(() => { + setTouchedInput(inputID); + }, VALIDATE_DELAY); if (_.isFunction(propsToParse.onTouched)) { propsToParse.onTouched(event); } }, onPress: (event) => { - setTouchedInput(inputID); + setTimeout(() => { + setTouchedInput(inputID); + }, VALIDATE_DELAY); if (_.isFunction(propsToParse.onPress)) { propsToParse.onPress(event); } }, - onPressIn: (event) => { - setTouchedInput(inputID); + onPressOut: (event) => { + // To prevent validating just pressed inputs, we need to set the touched input right after + // onValidate and to do so, we need to delays setTouchedInput of the same amount of time + // as the onValidate is delayed + setTimeout(() => { + setTouchedInput(inputID); + }, VALIDATE_DELAY); if (_.isFunction(propsToParse.onPressIn)) { propsToParse.onPressIn(event); } @@ -274,7 +285,7 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC if (shouldValidateOnBlur) { onValidate(inputValues, !hasServerError); } - }, 200); + }, VALIDATE_DELAY); } if (_.isFunction(propsToParse.onBlur)) { diff --git a/src/components/Form/InputWrapper.js b/src/components/Form/InputWrapper.js index 99237fd8db43..b2e6f4477e89 100644 --- a/src/components/Form/InputWrapper.js +++ b/src/components/Form/InputWrapper.js @@ -1,12 +1,13 @@ import PropTypes from 'prop-types'; import React, {forwardRef, useContext} from 'react'; +import refPropTypes from '@components/refPropTypes'; import FormContext from './FormContext'; const propTypes = { InputComponent: PropTypes.oneOfType([PropTypes.func, PropTypes.elementType]).isRequired, inputID: PropTypes.string.isRequired, valueType: PropTypes.string, - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), + forwardedRef: refPropTypes, }; const defaultProps = { diff --git a/src/components/LocationErrorMessage/BaseLocationErrorMessage.js b/src/components/LocationErrorMessage/BaseLocationErrorMessage.js index 786588993cd8..b5114acaa36b 100644 --- a/src/components/LocationErrorMessage/BaseLocationErrorMessage.js +++ b/src/components/LocationErrorMessage/BaseLocationErrorMessage.js @@ -10,6 +10,7 @@ import Tooltip from '@components/Tooltip'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import colors from '@styles/colors'; import styles from '@styles/styles'; +import * as StyleUtils from '@styles/StyleUtils'; import CONST from '@src/CONST'; import * as locationErrorMessagePropTypes from './locationErrorMessagePropTypes'; @@ -42,14 +43,14 @@ function BaseLocationErrorMessage({onClose, onAllowLocationLinkPress, locationEr {isPermissionDenied ? ( - {`${translate('location.permissionDenied')} ${translate('location.please')}`} + {`${translate('location.permissionDenied')} ${translate('location.please')}`} {` ${translate('location.allowPermission')} `} - {translate('location.tryAgain')} + {translate('location.tryAgain')} ) : ( {translate('location.notFound')} diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index b60e950b2bbf..7cd834c4cb5d 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -320,6 +320,9 @@ function MoneyRequestConfirmationList(props) { text = translate('iou.split'); } else if ((props.receiptPath && isTypeRequest) || isDistanceRequestWithoutRoute) { text = translate('iou.request'); + if (props.iouAmount !== 0) { + text = translate('iou.requestAmount', {amount: formattedAmount}); + } } else { const translationKey = isSplitBill ? 'iou.splitAmount' : 'iou.requestAmount'; text = translate(translationKey, {amount: formattedAmount}); diff --git a/src/components/MoneyRequestSkeletonView.js b/src/components/MoneyRequestSkeletonView.js index e03cb78972cf..32eb8fef222b 100644 --- a/src/components/MoneyRequestSkeletonView.js +++ b/src/components/MoneyRequestSkeletonView.js @@ -1,9 +1,9 @@ import React from 'react'; -import SkeletonViewContentLoader from 'react-content-loader/native'; import {Rect} from 'react-native-svg'; import styles from '@styles/styles'; import themeColors from '@styles/themes/default'; import variables from '@styles/variables'; +import SkeletonViewContentLoader from './SkeletonViewContentLoader'; function MoneyRequestSkeletonView() { return ( diff --git a/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js b/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js index 5489a9244f68..d784f439dfee 100644 --- a/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js +++ b/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js @@ -82,6 +82,7 @@ function YearPickerModal(props) { onSelectRow={(option) => props.onYearChange(option.value)} initiallyFocusedOptionKey={props.currentYear.toString()} showScrollIndicator + shouldStopPropagation /> diff --git a/src/components/OptionsListSkeletonView.js b/src/components/OptionsListSkeletonView.js index 76341276ba91..24783604e39a 100644 --- a/src/components/OptionsListSkeletonView.js +++ b/src/components/OptionsListSkeletonView.js @@ -1,11 +1,11 @@ import PropTypes from 'prop-types'; import React from 'react'; -import SkeletonViewContentLoader from 'react-content-loader/native'; import {View} from 'react-native'; import {Circle, Rect} from 'react-native-svg'; import styles from '@styles/styles'; import themeColors from '@styles/themes/default'; import CONST from '@src/CONST'; +import SkeletonViewContentLoader from './SkeletonViewContentLoader'; const propTypes = { /** Whether to animate the skeleton view */ diff --git a/src/components/ReportActionsSkeletonView/SkeletonViewLines.js b/src/components/ReportActionsSkeletonView/SkeletonViewLines.js index 1811c14e4695..51ae4c1034a5 100644 --- a/src/components/ReportActionsSkeletonView/SkeletonViewLines.js +++ b/src/components/ReportActionsSkeletonView/SkeletonViewLines.js @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import React from 'react'; -import SkeletonViewContentLoader from 'react-content-loader/native'; import {Circle, Rect} from 'react-native-svg'; +import SkeletonViewContentLoader from '@components/SkeletonViewContentLoader'; import styles from '@styles/styles'; import themeColors from '@styles/themes/default'; import CONST from '@src/CONST'; diff --git a/src/components/ReportHeaderSkeletonView.js b/src/components/ReportHeaderSkeletonView.js index f2001094f60a..6d2a8e343e3b 100644 --- a/src/components/ReportHeaderSkeletonView.js +++ b/src/components/ReportHeaderSkeletonView.js @@ -1,6 +1,5 @@ import PropTypes from 'prop-types'; import React from 'react'; -import SkeletonViewContentLoader from 'react-content-loader/native'; import {View} from 'react-native'; import {Circle, Rect} from 'react-native-svg'; import compose from '@libs/compose'; @@ -11,6 +10,7 @@ import CONST from '@src/CONST'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; import PressableWithFeedback from './Pressable/PressableWithFeedback'; +import SkeletonViewContentLoader from './SkeletonViewContentLoader'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions'; diff --git a/src/components/SelectionList/BaseSelectionList.js b/src/components/SelectionList/BaseSelectionList.js index 65f98828dca7..d4fd0bb524d4 100644 --- a/src/components/SelectionList/BaseSelectionList.js +++ b/src/components/SelectionList/BaseSelectionList.js @@ -58,6 +58,7 @@ function BaseSelectionList({ inputRef = null, disableKeyboardShortcuts = false, children, + shouldStopPropagation = false, }) { const {translate} = useLocalize(); const firstLayoutRef = useRef(true); @@ -342,6 +343,7 @@ function BaseSelectionList({ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, { captureOnInputs: true, shouldBubble: () => !flattenedSections.allOptions[focusedIndex], + shouldStopPropagation, isActive: !disableKeyboardShortcuts && !disableEnterShortcut && isFocused, }); diff --git a/src/components/SelectionList/selectionListPropTypes.js b/src/components/SelectionList/selectionListPropTypes.js index 2b53f555134e..c3bae89eaba2 100644 --- a/src/components/SelectionList/selectionListPropTypes.js +++ b/src/components/SelectionList/selectionListPropTypes.js @@ -168,6 +168,9 @@ const propTypes = { /** Whether to show the default confirm button */ showConfirmButton: PropTypes.bool, + /** Whether to stop automatic form submission on pressing enter key or not */ + shouldStopPropagation: PropTypes.bool, + /** Whether to prevent default focusing of options and focus the textinput when selecting an option */ shouldPreventDefaultFocusOnSelectRow: PropTypes.bool, diff --git a/src/components/SkeletonViewContentLoader/index.js b/src/components/SkeletonViewContentLoader/index.js new file mode 100644 index 000000000000..335611c6f5cb --- /dev/null +++ b/src/components/SkeletonViewContentLoader/index.js @@ -0,0 +1,3 @@ +import SkeletonViewContentLoader from 'react-content-loader'; + +export default SkeletonViewContentLoader; diff --git a/src/components/SkeletonViewContentLoader/index.native.js b/src/components/SkeletonViewContentLoader/index.native.js new file mode 100644 index 000000000000..bdcea964e52c --- /dev/null +++ b/src/components/SkeletonViewContentLoader/index.native.js @@ -0,0 +1,3 @@ +import SkeletonViewContentLoader from 'react-content-loader/native'; + +export default SkeletonViewContentLoader; diff --git a/src/components/StatePicker/StateSelectorModal.js b/src/components/StatePicker/StateSelectorModal.js index 8bda9d5303c8..6a753dbc660c 100644 --- a/src/components/StatePicker/StateSelectorModal.js +++ b/src/components/StatePicker/StateSelectorModal.js @@ -99,6 +99,7 @@ function StateSelectorModal({currentState, isVisible, onClose, onStateSelected, onSelectRow={onStateSelected} onChangeText={setSearchValue} initiallyFocusedOptionKey={currentState} + shouldStopPropagation /> diff --git a/src/components/ValuePicker/ValueSelectorModal.js b/src/components/ValuePicker/ValueSelectorModal.js index 0a524a324959..cd7923296eed 100644 --- a/src/components/ValuePicker/ValueSelectorModal.js +++ b/src/components/ValuePicker/ValueSelectorModal.js @@ -71,6 +71,7 @@ function ValueSelectorModal({currentValue, items, selectedItem, label, isVisible sections={[{data: sectionsData}]} onSelectRow={onItemSelected} initiallyFocusedOptionKey={currentValue} + shouldStopPropagation /> diff --git a/src/hooks/useArrowKeyFocusManager.js b/src/hooks/useArrowKeyFocusManager.ts similarity index 77% rename from src/hooks/useArrowKeyFocusManager.js rename to src/hooks/useArrowKeyFocusManager.ts index a633dba5ffc5..ecd494dfd9ea 100644 --- a/src/hooks/useArrowKeyFocusManager.js +++ b/src/hooks/useArrowKeyFocusManager.ts @@ -2,19 +2,28 @@ import {useCallback, useEffect, useMemo, useState} from 'react'; import CONST from '@src/CONST'; import useKeyboardShortcut from './useKeyboardShortcut'; +type Config = { + maxIndex: number; + onFocusedIndexChange?: (index: number) => void; + initialFocusedIndex?: number; + disabledIndexes?: readonly number[]; + shouldExcludeTextAreaNodes?: boolean; + isActive?: boolean; +}; + +type UseArrowKeyFocusManager = [number, (index: number) => void]; + /** * A hook that makes it easy to use the arrow keys to manage focus of items in a list * * Recommendation: To ensure stability, wrap the `onFocusedIndexChange` function with the useCallback hook before using it with this hook. * - * @param {Object} config - * @param {Number} config.maxIndex โ€“ typically the number of items in your list - * @param {Function} [config.onFocusedIndexChange] โ€“ optional callback to execute when focusedIndex changes - * @param {Number} [config.initialFocusedIndex] โ€“ where to start in the list - * @param {Array} [config.disabledIndexes] โ€“ An array of indexes to disable + skip over - * @param {Boolean} [config.shouldExcludeTextAreaNodes] โ€“ Whether arrow keys should have any effect when a TextArea node is focused - * @param {Boolean} [config.isActive] โ€“ Whether the component is ready and should subscribe to KeyboardShortcut - * @returns {Array} + * @param config.maxIndex โ€“ typically the number of items in your list + * @param [config.onFocusedIndexChange] โ€“ optional callback to execute when focusedIndex changes + * @param [config.initialFocusedIndex] โ€“ where to start in the list + * @param [config.disabledIndexes] โ€“ An array of indexes to disable + skip over + * @param [config.shouldExcludeTextAreaNodes] โ€“ Whether arrow keys should have any effect when a TextArea node is focused + * @param [config.isActive] โ€“ Whether the component is ready and should subscribe to KeyboardShortcut */ export default function useArrowKeyFocusManager({ maxIndex, @@ -26,7 +35,7 @@ export default function useArrowKeyFocusManager({ disabledIndexes = CONST.EMPTY_ARRAY, shouldExcludeTextAreaNodes = true, isActive, -}) { +}: Config): UseArrowKeyFocusManager { const [focusedIndex, setFocusedIndex] = useState(initialFocusedIndex); const arrowConfig = useMemo( () => ({ diff --git a/src/hooks/useKeyboardShortcut.js b/src/hooks/useKeyboardShortcut.js index 470d29243fe8..5427fc6a654e 100644 --- a/src/hooks/useKeyboardShortcut.js +++ b/src/hooks/useKeyboardShortcut.js @@ -21,6 +21,7 @@ export default function useKeyboardShortcut(shortcut, callback, config = {}) { // Hence the use of CONST.EMPTY_ARRAY. excludedNodes = CONST.EMPTY_ARRAY, isActive = true, + shouldStopPropagation = false, } = config; useEffect(() => { @@ -35,8 +36,21 @@ export default function useKeyboardShortcut(shortcut, callback, config = {}) { priority, shouldPreventDefault, excludedNodes, + shouldStopPropagation, ); } return () => {}; - }, [isActive, callback, captureOnInputs, excludedNodes, priority, shortcut.descriptionKey, shortcut.modifiers, shortcut.shortcutKey, shouldBubble, shouldPreventDefault]); + }, [ + isActive, + callback, + captureOnInputs, + excludedNodes, + priority, + shortcut.descriptionKey, + shortcut.modifiers, + shortcut.shortcutKey, + shouldBubble, + shouldPreventDefault, + shouldStopPropagation, + ]); } diff --git a/src/libs/ApiUtils.ts b/src/libs/ApiUtils.ts index a15c900b9387..3d7347136897 100644 --- a/src/libs/ApiUtils.ts +++ b/src/libs/ApiUtils.ts @@ -1,4 +1,5 @@ import Onyx from 'react-native-onyx'; +import {ValueOf} from 'type-fest'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -8,7 +9,7 @@ import * as Environment from './Environment/Environment'; // To avoid rebuilding native apps, native apps use production config for both staging and prod // We use the async environment check because it works on all platforms -let ENV_NAME = CONST.ENVIRONMENT.PRODUCTION; +let ENV_NAME: ValueOf = CONST.ENVIRONMENT.PRODUCTION; let shouldUseStagingServer = false; Environment.getEnvironment().then((envName) => { ENV_NAME = envName; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index e03e3dd55680..13853189ed26 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -8,13 +8,14 @@ import { isBefore, isSameDay, isSameYear, + isValid, setDefaultOptions, startOfWeek, subDays, subMilliseconds, subMinutes, } from 'date-fns'; -import {formatInTimeZone, utcToZonedTime, zonedTimeToUtc} from 'date-fns-tz'; +import {formatInTimeZone, format as tzFormat, utcToZonedTime, zonedTimeToUtc} from 'date-fns-tz'; import {enGB, es} from 'date-fns/locale'; import throttle from 'lodash/throttle'; import Onyx from 'react-native-onyx'; @@ -335,6 +336,22 @@ function getStatusUntilDate(inputDate: string): string { return translateLocal('statusPage.untilTime', {time: format(input, `${CONST.DATE.FNS_FORMAT_STRING} ${CONST.DATE.LOCAL_TIME_FORMAT}`)}); } +/** + * Get a date and format this date using the UTC timezone. + * @param datetime + * @param dateFormat + * @returns If the date is valid, returns the formatted date with the UTC timezone, otherwise returns an empty string. + */ +function formatWithUTCTimeZone(datetime: string, dateFormat: string = CONST.DATE.FNS_FORMAT_STRING) { + const date = new Date(datetime); + + if (isValid(date)) { + return tzFormat(utcToZonedTime(date, 'UTC'), dateFormat); + } + + return ''; +} + const DateUtils = { formatToDayOfWeek, formatToLongDateWithWeekday, @@ -356,6 +373,7 @@ const DateUtils = { isToday, isTomorrow, isYesterday, + formatWithUTCTimeZone, }; export default DateUtils; diff --git a/src/libs/E2E/reactNativeLaunchingTest.js b/src/libs/E2E/reactNativeLaunchingTest.js index 1e0d6a8afa3b..7621e462f8c5 100644 --- a/src/libs/E2E/reactNativeLaunchingTest.js +++ b/src/libs/E2E/reactNativeLaunchingTest.js @@ -7,7 +7,6 @@ */ import * as Metrics from '@libs/Metrics'; import Performance from '@libs/Performance'; -import '../../../index'; import E2EConfig from '../../../tests/e2e/config'; import E2EClient from './client'; @@ -65,5 +64,5 @@ E2EClient.getTestConfig() // start the usual app Performance.markStart('regularAppStart'); - +import '../../../index'; Performance.markEnd('regularAppStart'); diff --git a/src/libs/Environment/Environment.js b/src/libs/Environment/Environment.ts similarity index 72% rename from src/libs/Environment/Environment.js rename to src/libs/Environment/Environment.ts index e89c8d74a43a..14c880edc593 100644 --- a/src/libs/Environment/Environment.js +++ b/src/libs/Environment/Environment.ts @@ -1,4 +1,3 @@ -import lodashGet from 'lodash/get'; import Config from 'react-native-config'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; @@ -20,28 +19,22 @@ const OLDDOT_ENVIRONMENT_URLS = { /** * Are we running the app in development? - * - * @return {boolean} */ -function isDevelopment() { - return lodashGet(Config, 'ENVIRONMENT', CONST.ENVIRONMENT.DEV) === CONST.ENVIRONMENT.DEV; +function isDevelopment(): boolean { + return (Config?.ENVIRONMENT ?? CONST.ENVIRONMENT.DEV) === CONST.ENVIRONMENT.DEV; } /** * Are we running an internal test build? - * - * @return {boolean} */ -function isInternalTestBuild() { - return lodashGet(Config, 'ENVIRONMENT', CONST.ENVIRONMENT.DEV) === CONST.ENVIRONMENT.ADHOC && lodashGet(Config, 'PULL_REQUEST_NUMBER', ''); +function isInternalTestBuild(): boolean { + return !!((Config?.ENVIRONMENT ?? CONST.ENVIRONMENT.DEV) === CONST.ENVIRONMENT.ADHOC && (Config?.PULL_REQUEST_NUMBER ?? '')); } /** * Get the URL based on the environment we are in - * - * @returns {Promise} */ -function getEnvironmentURL() { +function getEnvironmentURL(): Promise { return new Promise((resolve) => { getEnvironment().then((environment) => resolve(ENVIRONMENT_URLS[environment])); }); @@ -49,10 +42,8 @@ function getEnvironmentURL() { /** * Get the corresponding oldDot URL based on the environment we are in - * - * @returns {Promise} */ -function getOldDotEnvironmentURL() { +function getOldDotEnvironmentURL(): Promise { return getEnvironment().then((environment) => OLDDOT_ENVIRONMENT_URLS[environment]); } diff --git a/src/libs/Environment/betaChecker/index.android.js b/src/libs/Environment/betaChecker/index.android.ts similarity index 89% rename from src/libs/Environment/betaChecker/index.android.js rename to src/libs/Environment/betaChecker/index.android.ts index 18a4290cb634..f230120ba0b1 100644 --- a/src/libs/Environment/betaChecker/index.android.js +++ b/src/libs/Environment/betaChecker/index.android.ts @@ -4,19 +4,20 @@ import * as AppUpdate from '@userActions/AppUpdate'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import pkg from '../../../../package.json'; +import IsBetaBuild from './types'; let isLastSavedBeta = false; Onyx.connect({ key: ONYXKEYS.IS_BETA, - callback: (value) => (isLastSavedBeta = value), + callback: (value) => { + isLastSavedBeta = Boolean(value); + }, }); /** * Check the GitHub releases to see if the current build is a beta build or production build - * - * @returns {Promise} */ -function isBetaBuild() { +function isBetaBuild(): IsBetaBuild { return new Promise((resolve) => { fetch(CONST.GITHUB_RELEASE_URL) .then((res) => res.json()) diff --git a/src/libs/Environment/betaChecker/index.ios.js b/src/libs/Environment/betaChecker/index.ios.ts similarity index 62% rename from src/libs/Environment/betaChecker/index.ios.js rename to src/libs/Environment/betaChecker/index.ios.ts index 65b3ea935b04..0d901fc4b003 100644 --- a/src/libs/Environment/betaChecker/index.ios.js +++ b/src/libs/Environment/betaChecker/index.ios.ts @@ -1,13 +1,12 @@ import {NativeModules} from 'react-native'; +import IsBetaBuild from './types'; /** * Check to see if the build is staging (TestFlight) or production - * - * @returns {Promise} */ -function isBetaBuild() { +function isBetaBuild(): IsBetaBuild { return new Promise((resolve) => { - NativeModules.EnvironmentChecker.isBeta().then((isBeta) => { + NativeModules.EnvironmentChecker.isBeta().then((isBeta: boolean) => { resolve(isBeta); }); }); diff --git a/src/libs/Environment/betaChecker/index.js b/src/libs/Environment/betaChecker/index.ts similarity index 62% rename from src/libs/Environment/betaChecker/index.js rename to src/libs/Environment/betaChecker/index.ts index 9d0d4af5741b..541a3120ccce 100644 --- a/src/libs/Environment/betaChecker/index.js +++ b/src/libs/Environment/betaChecker/index.ts @@ -1,9 +1,9 @@ +import IsBetaBuild from './types'; + /** * There's no beta build in non native - * - * @returns {Promise} */ -function isBetaBuild() { +function isBetaBuild(): IsBetaBuild { return Promise.resolve(false); } diff --git a/src/libs/Environment/betaChecker/types.ts b/src/libs/Environment/betaChecker/types.ts new file mode 100644 index 000000000000..61ce4bc9cec4 --- /dev/null +++ b/src/libs/Environment/betaChecker/types.ts @@ -0,0 +1,3 @@ +type IsBetaBuild = Promise; + +export default IsBetaBuild; diff --git a/src/libs/Environment/getEnvironment/index.js b/src/libs/Environment/getEnvironment/index.js deleted file mode 100644 index bc1c31cf5076..000000000000 --- a/src/libs/Environment/getEnvironment/index.js +++ /dev/null @@ -1,14 +0,0 @@ -import lodashGet from 'lodash/get'; -import Config from 'react-native-config'; -import CONST from '@src/CONST'; - -/** - * Returns a promise that resolves with the current environment string value - * - * @returns {Promise} - */ -function getEnvironment() { - return Promise.resolve(lodashGet(Config, 'ENVIRONMENT', CONST.ENVIRONMENT.DEV)); -} - -export default getEnvironment; diff --git a/src/libs/Environment/getEnvironment/index.native.js b/src/libs/Environment/getEnvironment/index.native.ts similarity index 75% rename from src/libs/Environment/getEnvironment/index.native.js rename to src/libs/Environment/getEnvironment/index.native.ts index ca660f9117cb..766f288376b5 100644 --- a/src/libs/Environment/getEnvironment/index.native.js +++ b/src/libs/Environment/getEnvironment/index.native.ts @@ -1,28 +1,26 @@ -import lodashGet from 'lodash/get'; import Config from 'react-native-config'; import betaChecker from '@libs/Environment/betaChecker'; import CONST from '@src/CONST'; +import Environment from './types'; -let environment = null; +let environment: Environment | null = null; /** * Returns a promise that resolves with the current environment string value - * - * @returns {Promise} */ -function getEnvironment() { +function getEnvironment(): Promise { return new Promise((resolve) => { // If we've already set the environment, use the current value if (environment) { return resolve(environment); } - if (lodashGet(Config, 'ENVIRONMENT', CONST.ENVIRONMENT.DEV) === CONST.ENVIRONMENT.DEV) { + if ((Config?.ENVIRONMENT ?? CONST.ENVIRONMENT.DEV) === CONST.ENVIRONMENT.DEV) { environment = CONST.ENVIRONMENT.DEV; return resolve(environment); } - if (lodashGet(Config, 'ENVIRONMENT', CONST.ENVIRONMENT.DEV) === CONST.ENVIRONMENT.ADHOC) { + if ((Config?.ENVIRONMENT ?? CONST.ENVIRONMENT.DEV) === CONST.ENVIRONMENT.ADHOC) { environment = CONST.ENVIRONMENT.ADHOC; return resolve(environment); } diff --git a/src/libs/Environment/getEnvironment/index.ts b/src/libs/Environment/getEnvironment/index.ts new file mode 100644 index 000000000000..84f64e91649b --- /dev/null +++ b/src/libs/Environment/getEnvironment/index.ts @@ -0,0 +1,9 @@ +import Config from 'react-native-config'; +import CONST from '@src/CONST'; +import Environment from './types'; + +function getEnvironment(): Promise { + return Promise.resolve((Config?.ENVIRONMENT as Environment) ?? CONST.ENVIRONMENT.DEV); +} + +export default getEnvironment; diff --git a/src/libs/Environment/getEnvironment/types.ts b/src/libs/Environment/getEnvironment/types.ts new file mode 100644 index 000000000000..9247ed17ffe2 --- /dev/null +++ b/src/libs/Environment/getEnvironment/types.ts @@ -0,0 +1,6 @@ +import {ValueOf} from 'type-fest'; +import CONST from '@src/CONST'; + +type Environment = ValueOf; + +export default Environment; diff --git a/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.native.ts b/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.native.ts index 72a4365b87e2..4deabb9aa5ef 100644 --- a/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.native.ts +++ b/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.native.ts @@ -8,6 +8,11 @@ const bindHandlerToKeydownEvent: BindHandlerToKeydownEvent = (getDisplayName, ev const eventModifiers = getKeyEventModifiers(keyCommandEvent); const displayName = getDisplayName(keyCommandEvent.input, eventModifiers); + // If we didn't register any event handlers for a key we ignore it + if (!eventHandlers[displayName]) { + return; + } + // Loop over all the callbacks Object.values(eventHandlers[displayName]).every((callback) => { // Determine if the event should bubble before executing the callback (which may have side-effects) diff --git a/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.ts b/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.ts index 8f2eaad25c5b..f8e18a11971d 100644 --- a/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.ts +++ b/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.ts @@ -13,6 +13,11 @@ const bindHandlerToKeydownEvent: BindHandlerToKeydownEvent = (getDisplayName, ev const eventModifiers = getKeyEventModifiers(keyCommandEvent); const displayName = getDisplayName(keyCommandEvent.input, eventModifiers); + // If we didn't register any event handlers for a key we ignore it + if (!eventHandlers[displayName]) { + return; + } + // Loop over all the callbacks Object.values(eventHandlers[displayName]).every((callback) => { const textArea = event.target as HTMLTextAreaElement; @@ -42,7 +47,9 @@ const bindHandlerToKeydownEvent: BindHandlerToKeydownEvent = (getDisplayName, ev if (callback.shouldPreventDefault) { event.preventDefault(); } - + if (callback.shouldStopPropagation) { + event.stopPropagation(); + } // If the event should not bubble, short-circuit the loop return shouldBubble; }); diff --git a/src/libs/KeyboardShortcut/index.ts b/src/libs/KeyboardShortcut/index.ts index 249311f92cd2..cfcf5d5ef535 100644 --- a/src/libs/KeyboardShortcut/index.ts +++ b/src/libs/KeyboardShortcut/index.ts @@ -13,6 +13,7 @@ type EventHandler = { shouldPreventDefault: boolean; shouldBubble: boolean | (() => void); excludedNodes: string[]; + shouldStopPropagation: boolean; }; // Handlers for the various keyboard listeners we set up @@ -135,6 +136,7 @@ function subscribe( priority = 0, shouldPreventDefault = true, excludedNodes = [], + shouldStopPropagation = false, ) { const platformAdjustedModifiers = getPlatformEquivalentForKeys(modifiers); const displayName = getDisplayName(key, platformAdjustedModifiers); @@ -150,6 +152,7 @@ function subscribe( shouldPreventDefault, shouldBubble, excludedNodes, + shouldStopPropagation, }); if (descriptionKey) { diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index fdc48aec2010..34837135d22d 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -192,7 +192,9 @@ function AuthScreens({isUsingMemoryOnlyKeys, lastUpdateIDAppliedToClient, sessio } else { App.reconnectApp(lastUpdateIDAppliedToClient); } - App.setUpPoliciesAndNavigate(session, !isSmallScreenWidth); + + App.setUpPoliciesAndNavigate(session); + App.redirectThirdPartyDesktopSignIn(); // Check if we should be running any demos immediately after signing in. diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js index fc75f3544346..20baf44b23f4 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js +++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js @@ -18,7 +18,7 @@ function CentralPaneNavigator() { '', -}; diff --git a/src/libs/SelectionScraper/index.native.ts b/src/libs/SelectionScraper/index.native.ts new file mode 100644 index 000000000000..7712906f05e6 --- /dev/null +++ b/src/libs/SelectionScraper/index.native.ts @@ -0,0 +1,8 @@ +import GetCurrentSelection from './types'; + +// This is a no-op function for native devices because they wouldn't be able to support Selection API like a website. +const getCurrentSelection: GetCurrentSelection = () => ''; + +export default { + getCurrentSelection, +}; diff --git a/src/libs/SelectionScraper/index.js b/src/libs/SelectionScraper/index.ts similarity index 65% rename from src/libs/SelectionScraper/index.js rename to src/libs/SelectionScraper/index.ts index 02b3ff8bf61b..1f62f83e1c91 100644 --- a/src/libs/SelectionScraper/index.js +++ b/src/libs/SelectionScraper/index.ts @@ -1,25 +1,29 @@ import render from 'dom-serializer'; +import {DataNode, Element, Node} from 'domhandler'; import Str from 'expensify-common/lib/str'; import {parseDocument} from 'htmlparser2'; -import _ from 'underscore'; import CONST from '@src/CONST'; +import GetCurrentSelection from './types'; const elementsWillBeSkipped = ['html', 'body']; const tagAttribute = 'data-testid'; /** * Reads html of selection. If browser doesn't support Selection API, returns empty string. - * @returns {String} HTML of selection as String + * @returns HTML of selection as String */ -const getHTMLOfSelection = () => { +const getHTMLOfSelection = (): string => { // If browser doesn't support Selection API, return an empty string. if (!window.getSelection) { return ''; } const selection = window.getSelection(); + if (!selection) { + return ''; + } if (selection.rangeCount <= 0) { - return window.getSelection().toString(); + return window.getSelection()?.toString() ?? ''; } const div = document.createElement('div'); @@ -44,7 +48,7 @@ const getHTMLOfSelection = () => { // If clonedSelection has no text content this data has no meaning to us. if (clonedSelection.textContent) { - let parent; + let parent: globalThis.Element | null = null; let child = clonedSelection; // If selection starts and ends within same text node we use its parentNode. This is because we can't @@ -65,16 +69,16 @@ const getHTMLOfSelection = () => { if (range.commonAncestorContainer instanceof HTMLElement) { parent = range.commonAncestorContainer.closest(`[${tagAttribute}]`); } else { - parent = range.commonAncestorContainer.parentNode.closest(`[${tagAttribute}]`); + parent = (range.commonAncestorContainer.parentNode as HTMLElement | null)?.closest(`[${tagAttribute}]`) ?? null; } // Keep traversing up to clone all parents with 'data-testid' attribute. while (parent) { const cloned = parent.cloneNode(); cloned.appendChild(child); - child = cloned; + child = cloned as DocumentFragment; - parent = parent.parentNode.closest(`[${tagAttribute}]`); + parent = (parent.parentNode as HTMLElement | null)?.closest(`[${tagAttribute}]`) ?? null; } div.appendChild(child); @@ -96,40 +100,41 @@ const getHTMLOfSelection = () => { /** * Clears all attributes from dom elements - * @param {Object} dom htmlparser2 dom representation - * @param {Boolean} isChildOfEditorElement - * @returns {Object} htmlparser2 dom representation + * @param dom - dom htmlparser2 dom representation */ -const replaceNodes = (dom, isChildOfEditorElement) => { - let domName = dom.name; - let domChildren; - const domAttribs = {}; - let data; +const replaceNodes = (dom: Node, isChildOfEditorElement: boolean): Node => { + let domName; + let domChildren: Node[] = []; + const domAttribs: Element['attribs'] = {}; + let data = ''; // Encoding HTML chars '< >' in the text, because any HTML will be removed in stripHTML method. - if (dom.type === 'text') { + if (dom.type.toString() === 'text' && dom instanceof DataNode) { data = Str.htmlEncode(dom.data); - } - - // We are skipping elements which has html and body in data-testid, since ExpensiMark can't parse it. Also this data - // has no meaning for us. - if (dom.attribs && dom.attribs[tagAttribute]) { - if (!elementsWillBeSkipped.includes(dom.attribs[tagAttribute])) { - domName = dom.attribs[tagAttribute]; + } else if (dom instanceof Element) { + domName = dom.name; + // We are skipping elements which has html and body in data-testid, since ExpensiMark can't parse it. Also this data + // has no meaning for us. + if (dom.attribs?.[tagAttribute]) { + if (!elementsWillBeSkipped.includes(dom.attribs[tagAttribute])) { + domName = dom.attribs[tagAttribute]; + } + } else if (dom.name === 'div' && dom.children.length === 1 && isChildOfEditorElement) { + // We are excluding divs that are children of our editor element and have only one child to prevent + // additional newlines from being added in the HTML to Markdown conversion process. + return replaceNodes(dom.children[0], isChildOfEditorElement); } - } else if (dom.name === 'div' && dom.children.length === 1 && isChildOfEditorElement) { - // We are excluding divs that are children of our editor element and have only one child to prevent - // additional newlines from being added in the HTML to Markdown conversion process. - return replaceNodes(dom.children[0], isChildOfEditorElement); - } - // We need to preserve href attribute in order to copy links. - if (dom.attribs && dom.attribs.href) { - domAttribs.href = dom.attribs.href; - } + // We need to preserve href attribute in order to copy links. + if (dom.attribs?.href) { + domAttribs.href = dom.attribs.href; + } - if (dom.children) { - domChildren = _.map(dom.children, (c) => replaceNodes(c, isChildOfEditorElement || !_.isEmpty(dom.attribs && dom.attribs[tagAttribute]))); + if (dom.children) { + domChildren = dom.children.map((c) => replaceNodes(c, isChildOfEditorElement || !!dom.attribs?.[tagAttribute])); + } + } else { + throw new Error(`Unknown dom type: ${dom.type}`); } return { @@ -138,16 +143,15 @@ const replaceNodes = (dom, isChildOfEditorElement) => { name: domName, attribs: domAttribs, children: domChildren, - }; + } as Element & DataNode; }; /** * Resolves the current selection to values and produces clean HTML. - * @returns {String} resolved selection in the HTML format */ -const getCurrentSelection = () => { +const getCurrentSelection: GetCurrentSelection = () => { const domRepresentation = parseDocument(getHTMLOfSelection()); - domRepresentation.children = _.map(domRepresentation.children, replaceNodes); + domRepresentation.children = domRepresentation.children.map((item) => replaceNodes(item, false)); // Newline characters need to be removed here because the HTML could contain both newlines and
tags, and when //
tags are converted later to markdown, it creates duplicate newline characters. This means that when the content diff --git a/src/libs/SelectionScraper/types.ts b/src/libs/SelectionScraper/types.ts new file mode 100644 index 000000000000..d33338883dd4 --- /dev/null +++ b/src/libs/SelectionScraper/types.ts @@ -0,0 +1,3 @@ +type GetCurrentSelection = () => string; + +export default GetCurrentSelection; diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 79d3280e859e..a0b676bdffff 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -385,7 +385,7 @@ function getOptionData( result.parentReportID = report.parentReportID ?? null; result.isWaitingOnBankAccount = report.isWaitingOnBankAccount; result.notificationPreference = report.notificationPreference ?? null; - result.isAllowedToComment = !ReportUtils.shouldDisableWriteActions(report); + result.isAllowedToComment = ReportUtils.canUserPerformWriteAction(report); result.chatType = report.chatType; const hasMultipleParticipants = participantPersonalDetailList.length > 1 || result.isChatRoom || result.isPolicyExpenseChat; diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index a30ba7fc2723..2cc63e63e753 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -1,4 +1,3 @@ -import {format, isValid} from 'date-fns'; import Onyx, {OnyxCollection} from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -201,7 +200,8 @@ function getTransaction(transactionID: string): Transaction | Record { - if (transitionFromOldDot) { - // We must call goBack() to remove the /transition route from history - Navigation.goBack(ROUTES.HOME); - } - - if (shouldNavigateToAdminChat) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(adminsChatReportID)); - } - - Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(policyID)); - }) - .then(endSignOnTransition); -} - /** * Create a new draft workspace and navigate to it * * @param {String} [policyOwnerEmail] Optional, the email of the account to make the owner of the policy * @param {String} [policyName] Optional, custom policy name we will use for created workspace * @param {Boolean} [transitionFromOldDot] Optional, if the user is transitioning from old dot + * @param {Boolean} [makeMeAdmin] Optional, leave the calling account as an admin on the policy */ -function createWorkspaceWithPolicyDraftAndNavigateToIt(policyOwnerEmail = '', policyName = '', transitionFromOldDot = false) { +function createWorkspaceWithPolicyDraftAndNavigateToIt(policyOwnerEmail = '', policyName = '', transitionFromOldDot = false, makeMeAdmin = false) { const policyID = Policy.generatePolicyID(); - Policy.createDraftInitialWorkspace(policyOwnerEmail, policyName, policyID); + Policy.createDraftInitialWorkspace(policyOwnerEmail, policyName, policyID, makeMeAdmin); Navigation.isNavigationReady() .then(() => { @@ -403,9 +376,8 @@ function savePolicyDraftByNewWorkspace(policyID, policyName, policyOwnerEmail = * pass it in as a parameter. withOnyx guarantees that the value has been read * from Onyx because it will not render the AuthScreens until that point. * @param {Object} session - * @param {Boolean} shouldNavigateToAdminChat Should we navigate to admin chat after creating workspace */ -function setUpPoliciesAndNavigate(session, shouldNavigateToAdminChat) { +function setUpPoliciesAndNavigate(session) { const currentUrl = getCurrentUrl(); if (!session || !currentUrl || !currentUrl.includes('exitTo')) { return; @@ -426,7 +398,7 @@ function setUpPoliciesAndNavigate(session, shouldNavigateToAdminChat) { const shouldCreateFreePolicy = !isLoggingInAsNewUser && isTransitioning && exitTo === ROUTES.WORKSPACE_NEW; if (shouldCreateFreePolicy) { - createWorkspaceAndNavigateToIt(policyOwnerEmail, makeMeAdmin, policyName, true, shouldNavigateToAdminChat); + createWorkspaceWithPolicyDraftAndNavigateToIt(policyOwnerEmail, policyName, true, makeMeAdmin); return; } if (!isLoggingInAsNewUser && exitTo) { @@ -555,7 +527,6 @@ export { handleRestrictedEvent, beginDeepLinkRedirect, beginDeepLinkRedirectAfterTransition, - createWorkspaceAndNavigateToIt, getMissingOnyxUpdates, finalReconnectAppAfterActivatingReliableUpdates, savePolicyDraftByNewWorkspace, diff --git a/src/libs/actions/Card.js b/src/libs/actions/Card.js index 126608cf1b47..b3fde73aef6f 100644 --- a/src/libs/actions/Card.js +++ b/src/libs/actions/Card.js @@ -92,13 +92,13 @@ function requestReplacementExpensifyCard(cardId, reason) { /** * Activates the physical Expensify card based on the last four digits of the card number * - * @param {Number} lastFourDigits + * @param {Number} cardLastFourDigits * @param {Number} cardID */ -function activatePhysicalExpensifyCard(lastFourDigits, cardID) { +function activatePhysicalExpensifyCard(cardLastFourDigits, cardID) { API.write( 'ActivatePhysicalExpensifyCard', - {lastFourDigits, cardID}, + {cardLastFourDigits, cardID}, { optimisticData: [ { diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 77ab246506ce..2d471a0ca26c 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -346,6 +346,7 @@ function buildOnyxDataForMoneyRequest( : { [reportPreviewAction.reportActionID]: { created: reportPreviewAction.created, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), }, }), }, diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts index dc5b7b83aa8e..bcc5d8142470 100644 --- a/src/libs/actions/PaymentMethods.ts +++ b/src/libs/actions/PaymentMethods.ts @@ -214,7 +214,7 @@ function clearDebitCardFormErrorAndSubmit() { Onyx.set(ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM, { isLoading: false, errors: undefined, - setupComplete: true, + setupComplete: false, }); } diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index 67352526baad..8060e737bd22 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -982,8 +982,9 @@ function buildOptimisticCustomUnits() { * @param {String} [policyOwnerEmail] Optional, the email of the account to make the owner of the policy * @param {String} [policyName] Optional, custom policy name we will use for created workspace * @param {String} [policyID] Optional, custom policy id we will use for created workspace + * @param {Boolean} [makeMeAdmin] Optional, leave the calling account as an admin on the policy */ -function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', policyID = generatePolicyID()) { +function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', policyID = generatePolicyID(), makeMeAdmin = false) { const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); const {customUnits} = buildOptimisticCustomUnits(); @@ -1001,6 +1002,7 @@ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', pol outputCurrency: lodashGet(allPersonalDetails, [sessionAccountID, 'localCurrencyCode'], CONST.CURRENCY.USD), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, customUnits, + makeMeAdmin, }, }, { diff --git a/src/libs/convertToLTRForComposer/index.ts b/src/libs/convertToLTRForComposer/index.ts index 37015a755869..dd6ee50d862e 100644 --- a/src/libs/convertToLTRForComposer/index.ts +++ b/src/libs/convertToLTRForComposer/index.ts @@ -9,13 +9,11 @@ function hasRTLCharacters(text: string): boolean { // Converts a given text to ensure it starts with the LTR (Left-to-Right) marker. const convertToLTRForComposer: ConvertToLTRForComposer = (text) => { - // Ensure that the text starts with RTL characters if not we return the same text to avoid concatination with special character at the start which leads to unexpected behaviour for Emoji/Mention suggestions. + // Ensure that the text starts with RTL characters if not we return the same text to avoid concatination with special + // character at the start which leads to unexpected behaviour for Emoji/Mention suggestions. if (!hasRTLCharacters(text)) { // If text is empty string return empty string to avoid an empty draft due to special character. - if (text === '' || CONST.UNICODE.LTR.match(text)) { - return ''; - } - return text; + return text.replace(CONST.UNICODE.LTR, ''); } // Check if the text contains only spaces. If it does, we do not concatenate it with CONST.UNICODE.LTR, diff --git a/src/pages/EditRequestDescriptionPage.js b/src/pages/EditRequestDescriptionPage.js index 6bbe1aa3a4e8..d83df3a07671 100644 --- a/src/pages/EditRequestDescriptionPage.js +++ b/src/pages/EditRequestDescriptionPage.js @@ -2,7 +2,8 @@ import {useFocusEffect} from '@react-navigation/native'; import PropTypes from 'prop-types'; import React, {useCallback, useRef} from 'react'; import {View} from 'react-native'; -import Form from '@components/Form'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapperWithRef from '@components/Form/InputWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; @@ -49,7 +50,7 @@ function EditRequestDescriptionPage({defaultDescription, onSubmit}) { testID={EditRequestDescriptionPage.displayName} > -
- -
+ ); } diff --git a/src/pages/ReportWelcomeMessagePage.js b/src/pages/ReportWelcomeMessagePage.js index c4d91088eff0..1329817dae5b 100644 --- a/src/pages/ReportWelcomeMessagePage.js +++ b/src/pages/ReportWelcomeMessagePage.js @@ -75,7 +75,11 @@ function ReportWelcomeMessagePage(props) { ); return ( - + { if (!shouldDisplayNewMarker(reportAction, index)) { return; } + markerFound = true; if (!currentUnreadMarker && currentUnreadMarker !== reportAction.reportActionID) { setCurrentUnreadMarker(reportAction.reportActionID); } }); + if (!markerFound) { + setCurrentUnreadMarker(null); + } }, [sortedReportActions, report.lastReadTime, messageManuallyMarkedUnread, shouldDisplayNewMarker, currentUnreadMarker]); const renderItem = useCallback( @@ -349,7 +354,7 @@ function ReportActionsList({ // Native mobile does not render updates flatlist the changes even though component did update called. // To notify there something changes we can use extraData prop to flatlist const extraData = [isSmallScreenWidth ? currentUnreadMarker : undefined, ReportUtils.isArchivedRoom(report)]; - const hideComposer = ReportUtils.shouldDisableWriteActions(report); + const hideComposer = !ReportUtils.canUserPerformWriteAction(report); const shouldShowReportRecipientLocalTime = ReportUtils.canShowReportRecipientLocalTime(personalDetailsList, report, currentUserPersonalDetails.accountID) && !isComposerFullSize; const contentContainerStyle = useMemo( diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js index 63c2cc2436d7..cca91a45e36f 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js @@ -228,7 +228,7 @@ function FloatingActionButtonAndPopover(props) { iconHeight: 40, text: props.translate('workspace.new.newWorkspace'), description: props.translate('workspace.new.getTheExpensifyCardAndMore'), - onSelected: () => interceptAnonymousUser(() => App.createWorkspaceAndNavigateToIt('', false, '', false, !props.isSmallScreenWidth)), + onSelected: () => interceptAnonymousUser(() => App.createWorkspaceWithPolicyDraftAndNavigateToIt()), }, ] : []), @@ -281,7 +281,7 @@ export default compose( key: ONYXKEYS.BETAS, }, isLoading: { - key: ONYXKEYS.IS_LOADING_REPORT_DATA, + key: ONYXKEYS.IS_LOADING_APP, }, demoInfo: { key: ONYXKEYS.DEMO_INFO, diff --git a/src/pages/iou/MoneyRequestDescriptionPage.js b/src/pages/iou/MoneyRequestDescriptionPage.js index c0ed2da2eb7d..425aa313a468 100644 --- a/src/pages/iou/MoneyRequestDescriptionPage.js +++ b/src/pages/iou/MoneyRequestDescriptionPage.js @@ -5,17 +5,18 @@ import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import Form from '@components/Form'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapperWithRef from '@components/Form/InputWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; import useLocalize from '@hooks/useLocalize'; +import * as IOU from '@libs/actions/IOU'; import * as Browser from '@libs/Browser'; import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import updateMultilineInputRange from '@libs/UpdateMultilineInputRange'; import styles from '@styles/styles'; -import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -115,7 +116,7 @@ function MoneyRequestDescriptionPage({iou, route, selectedTab}) { title={translate('common.description')} onBackButtonPress={() => navigateBack()} /> -
updateComment(value)} @@ -123,7 +124,8 @@ function MoneyRequestDescriptionPage({iou, route, selectedTab}) { enabledWhenOffline > - -
+
); diff --git a/src/pages/settings/Wallet/AddDebitCardPage.js b/src/pages/settings/Wallet/AddDebitCardPage.js index ff20d518ff5d..5b99bd49812c 100644 --- a/src/pages/settings/Wallet/AddDebitCardPage.js +++ b/src/pages/settings/Wallet/AddDebitCardPage.js @@ -4,7 +4,8 @@ import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import AddressSearch from '@components/AddressSearch'; import CheckboxWithLabel from '@components/CheckboxWithLabel'; -import Form from '@components/Form'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import StatePicker from '@components/StatePicker'; @@ -45,8 +46,15 @@ function DebitCardPage(props) { const prevFormDataSetupComplete = usePrevious(props.formData.setupComplete); const nameOnCardRef = useRef(null); + /** + * Reset the form values on the mount and unmount so that old errors don't show when this form is displayed again. + */ useEffect(() => { PaymentMethods.clearDebitCardFormErrorAndSubmit(); + + return () => { + PaymentMethods.clearDebitCardFormErrorAndSubmit(); + }; }, []); useEffect(() => { @@ -55,10 +63,6 @@ function DebitCardPage(props) { } PaymentMethods.continueSetup(); - - return () => { - PaymentMethods.clearDebitCardFormErrorAndSubmit(); - }; }, [prevFormDataSetupComplete, props.formData.setupComplete]); /** @@ -114,7 +118,7 @@ function DebitCardPage(props) { title={translate('addDebitCardPage.addADebitCard')} onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_WALLET)} /> -
- (nameOnCardRef.current = ref)} + ref={nameOnCardRef} spellCheck={false} /> - - - - - - + - ( {`${translate('common.iAcceptThe')}`} @@ -195,7 +210,7 @@ function DebitCardPage(props) { )} style={[styles.mt4]} /> - +
); } diff --git a/src/pages/tasks/NewTaskDescriptionPage.js b/src/pages/tasks/NewTaskDescriptionPage.js index c8bd4f8cafd1..0d6f03006263 100644 --- a/src/pages/tasks/NewTaskDescriptionPage.js +++ b/src/pages/tasks/NewTaskDescriptionPage.js @@ -2,7 +2,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import Form from '@components/Form'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapperWithRef from '@components/Form/InputWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; @@ -63,7 +64,7 @@ function NewTaskDescriptionPage(props) { onCloseButtonPress={() => Task.dismissModalAndClearOutTaskInfo()} onBackButtonPress={() => Navigation.goBack(ROUTES.NEW_TASK)} /> -
- -
+ ); diff --git a/src/pages/tasks/NewTaskTitlePage.js b/src/pages/tasks/NewTaskTitlePage.js index 8508024b45ef..e7be9a239e5d 100644 --- a/src/pages/tasks/NewTaskTitlePage.js +++ b/src/pages/tasks/NewTaskTitlePage.js @@ -2,7 +2,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import Form from '@components/Form'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapperWithRef from '@components/Form/InputWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; @@ -79,7 +80,7 @@ function NewTaskTitlePage(props) { shouldShowBackButton onBackButtonPress={() => Navigation.goBack(ROUTES.NEW_TASK)} /> -
- -
+ ); } diff --git a/src/pages/tasks/TaskShareDestinationSelectorModal.js b/src/pages/tasks/TaskShareDestinationSelectorModal.js index fde02c2a4108..085d9a5a96b7 100644 --- a/src/pages/tasks/TaskShareDestinationSelectorModal.js +++ b/src/pages/tasks/TaskShareDestinationSelectorModal.js @@ -52,7 +52,7 @@ function TaskShareDestinationSelectorModal(props) { const reports = {}; _.keys(props.reports).forEach((reportKey) => { if ( - ReportUtils.shouldDisableWriteActions(props.reports[reportKey]) || + !ReportUtils.canUserPerformWriteAction(props.reports[reportKey]) || ReportUtils.isExpensifyOnlyParticipantInReport(props.reports[reportKey]) || ReportUtils.isCanceledTaskReport(props.reports[reportKey]) ) { diff --git a/src/pages/workspace/WorkspaceInitialPage.js b/src/pages/workspace/WorkspaceInitialPage.js index 69c6ee7589e6..207db2620f9f 100644 --- a/src/pages/workspace/WorkspaceInitialPage.js +++ b/src/pages/workspace/WorkspaceInitialPage.js @@ -92,7 +92,7 @@ function WorkspaceInitialPage(props) { return; } - App.savePolicyDraftByNewWorkspace(props.policyDraft.id, props.policyDraft.name, '', false); + App.savePolicyDraftByNewWorkspace(props.policyDraft.id, props.policyDraft.name, '', props.policyDraft.makeMeAdmin); // We only care when the component renders the first time // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/pages/workspace/WorkspaceNewRoomPage.js b/src/pages/workspace/WorkspaceNewRoomPage.js index 05156c89ea61..4ac16188a652 100644 --- a/src/pages/workspace/WorkspaceNewRoomPage.js +++ b/src/pages/workspace/WorkspaceNewRoomPage.js @@ -166,7 +166,7 @@ function WorkspaceNewRoomPage(props) { shouldShow={!Permissions.canUsePolicyRooms(props.betas) || !workspaceOptions.length} shouldShowBackButton={false} linkKey="workspace.emptyWorkspace.title" - onLinkPress={() => App.createWorkspaceAndNavigateToIt('', false, '', false, false)} + onLinkPress={() => App.createWorkspaceWithPolicyDraftAndNavigateToIt()} > fs.readFile(path, 'utf8').then((data) => { diff --git a/tests/e2e/config.js b/tests/e2e/config.js index 3b1856ab8ad8..dae35321fda3 100644 --- a/tests/e2e/config.js +++ b/tests/e2e/config.js @@ -31,7 +31,7 @@ module.exports = { SERVER_PORT: 4723, // The amount of times a test should be executed for average performance metrics - RUNS: 60, + RUNS: 80, DEFAULT_BASELINE_BRANCH: 'main', @@ -46,6 +46,9 @@ module.exports = { // The time in milliseconds after which an operation fails due to timeout INTERACTION_TIMEOUT: 300000, + // Period we wait between each test runs, to let the device cool down + COOL_DOWN: 90 * 1000, + TEST_NAMES, /** diff --git a/tests/e2e/testRunner.js b/tests/e2e/testRunner.js index 5c6c33bdf7e9..9bdbdfe8efe8 100644 --- a/tests/e2e/testRunner.js +++ b/tests/e2e/testRunner.js @@ -250,6 +250,15 @@ const runTests = async () => { } } testLog.done(); + + // If we still have tests add a cool down period + if (testIndex < numOfTests - 1) { + const coolDownLogs = Logger.progressInfo(`Cooling down for ${config.COOL_DOWN / 1000}s`); + coolDownLogs.updateText(`Cooling down for ${config.COOL_DOWN / 1000}s`); + // eslint-disable-next-line no-loop-func + await new Promise((resolve) => setTimeout(resolve, config.COOL_DOWN)); + coolDownLogs.done(); + } } // Calculate statistics and write them to our work file diff --git a/tests/unit/ConvertToLTRForComposerTest.js b/tests/unit/ConvertToLTRForComposerTest.js new file mode 100644 index 000000000000..86246a9f1302 --- /dev/null +++ b/tests/unit/ConvertToLTRForComposerTest.js @@ -0,0 +1,62 @@ +import convertToLTRForComposer from '@libs/convertToLTRForComposer'; +import CONST from '@src/CONST'; + +describe('convertToLTRForComposer', () => { + test('Input without RTL characters remains unchanged', () => { + // Test when input contains no RTL characters + const inputText = 'Hello, world!'; + const result = convertToLTRForComposer(inputText); + expect(result).toBe(inputText); + }); + + test('Input with RTL characters is prefixed with LTR marker', () => { + // Test when input contains RTL characters + const inputText = 'ู…ุซุงู„'; + const result = convertToLTRForComposer(inputText); + expect(result).toBe(`${CONST.UNICODE.LTR}${inputText}`); + }); + + test('Input with mixed RTL and LTR characters is prefixed with LTR marker', () => { + // Test when input contains mix of RTL and LTR characters + const inputText = 'ู…ุซุงู„ test '; + const result = convertToLTRForComposer(inputText); + expect(result).toBe(`${CONST.UNICODE.LTR}${inputText}`); + }); + + test('Input with only space remains unchanged', () => { + // Test when input contains only spaces + const inputText = ' '; + const result = convertToLTRForComposer(inputText); + expect(result).toBe(inputText); + }); + + test('Input with existing LTR marker remains unchanged', () => { + // Test when input already starts with the LTR marker + const inputText = `${CONST.UNICODE.LTR}ู…ุซุงู„`; + const result = convertToLTRForComposer(inputText); + expect(result).toBe(inputText); + }); + + test('Input starting with native emojis remains unchanged', () => { + // Test when input starts with the native emojis + const inputText = '๐Ÿงถ'; + const result = convertToLTRForComposer(inputText); + expect(result).toBe(inputText); + }); + + test('Input is empty', () => { + // Test when input is empty to check for draft comments + const inputText = ''; + const result = convertToLTRForComposer(inputText); + expect(result.length).toBe(0); + }); + + test('input with special characters remains unchanged', () => { + // Test when input contains special characters + const specialCharacters = ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', '-', '=', '{', '}', '[', ']', '"', ':', ';', '<', '>', '?', '`', '~']; + specialCharacters.forEach((character) => { + const result = convertToLTRForComposer(character); + expect(result).toBe(character); + }); + }); +}); diff --git a/tests/unit/DateUtilsTest.js b/tests/unit/DateUtilsTest.js index 4b76e0f496c8..0303085f1172 100644 --- a/tests/unit/DateUtilsTest.js +++ b/tests/unit/DateUtilsTest.js @@ -182,4 +182,30 @@ describe('DateUtils', () => { expect(getDBTime).toBe('2022-11-22 03:14:10.792'); }); }); + + describe('formatWithUTCTimeZone', () => { + describe('when the date is invalid', () => { + it('returns an empty string', () => { + const invalidDateStr = ''; + + const formattedDate = DateUtils.formatWithUTCTimeZone(invalidDateStr); + + expect(formattedDate).toEqual(''); + }); + }); + + describe('when the date is valid', () => { + const scenarios = [ + {dateFormat: CONST.DATE.FNS_FORMAT_STRING, expectedResult: '2022-11-07'}, + {dateFormat: CONST.DATE.FNS_TIMEZONE_FORMAT_STRING, expectedResult: '2022-11-07T00:00:00Z'}, + {dateFormat: CONST.DATE.FNS_DB_FORMAT_STRING, expectedResult: '2022-11-07 00:00:00.000'}, + ]; + + test.each(scenarios)('returns the date as string with the format "$dateFormat"', ({dateFormat, expectedResult}) => { + const formattedDate = DateUtils.formatWithUTCTimeZone(datetime, dateFormat); + + expect(formattedDate).toEqual(expectedResult); + }); + }); + }); }); diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.js index 5d18608a3de4..77e639ad1106 100644 --- a/tests/unit/OptionsListUtilsTest.js +++ b/tests/unit/OptionsListUtilsTest.js @@ -590,7 +590,7 @@ describe('OptionsListUtils', () => { // Filter current REPORTS as we do in the component, before getting share destination options const filteredReports = {}; _.keys(REPORTS).forEach((reportKey) => { - if (ReportUtils.shouldDisableWriteActions(REPORTS[reportKey]) || ReportUtils.isExpensifyOnlyParticipantInReport(REPORTS[reportKey])) { + if (!ReportUtils.canUserPerformWriteAction(REPORTS[reportKey]) || ReportUtils.isExpensifyOnlyParticipantInReport(REPORTS[reportKey])) { return; } filteredReports[reportKey] = REPORTS[reportKey]; @@ -617,7 +617,7 @@ describe('OptionsListUtils', () => { // Filter current REPORTS_WITH_WORKSPACE_ROOMS as we do in the component, before getting share destination options const filteredReportsWithWorkspaceRooms = {}; _.keys(REPORTS_WITH_WORKSPACE_ROOMS).forEach((reportKey) => { - if (ReportUtils.shouldDisableWriteActions(REPORTS_WITH_WORKSPACE_ROOMS[reportKey]) || ReportUtils.isExpensifyOnlyParticipantInReport(REPORTS_WITH_WORKSPACE_ROOMS[reportKey])) { + if (!ReportUtils.canUserPerformWriteAction(REPORTS_WITH_WORKSPACE_ROOMS[reportKey]) || ReportUtils.isExpensifyOnlyParticipantInReport(REPORTS_WITH_WORKSPACE_ROOMS[reportKey])) { return; } filteredReportsWithWorkspaceRooms[reportKey] = REPORTS_WITH_WORKSPACE_ROOMS[reportKey]; diff --git a/tests/unit/ReportUtilsTest.js b/tests/unit/ReportUtilsTest.js index 2b836f8eb0bf..72de874a631e 100644 --- a/tests/unit/ReportUtilsTest.js +++ b/tests/unit/ReportUtilsTest.js @@ -519,8 +519,7 @@ describe('ReportUtils', () => { expect(ReportUtils.getReportIDFromLink('new-expensify://r/75431276')).toBe('75431276'); expect(ReportUtils.getReportIDFromLink('https://www.expensify.cash/r/75431276')).toBe('75431276'); expect(ReportUtils.getReportIDFromLink('https://staging.new.expensify.com/r/75431276')).toBe('75431276'); - expect(ReportUtils.getReportIDFromLink('http://localhost/r/75431276')).toBe('75431276'); - expect(ReportUtils.getReportIDFromLink('http://localhost:8080/r/75431276')).toBe('75431276'); + expect(ReportUtils.getReportIDFromLink('https://dev.new.expensify.com/r/75431276')).toBe('75431276'); expect(ReportUtils.getReportIDFromLink('https://staging.expensify.cash/r/75431276')).toBe('75431276'); expect(ReportUtils.getReportIDFromLink('https://new.expensify.com/r/75431276')).toBe('75431276'); }); diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts new file mode 100644 index 000000000000..e51ffede633b --- /dev/null +++ b/tests/unit/TransactionUtilsTest.ts @@ -0,0 +1,64 @@ +import * as TransactionUtils from '../../src/libs/TransactionUtils'; +import type {Transaction} from '../../src/types/onyx'; + +function generateTransaction(values: Partial = {}): Transaction { + const reportID = '1'; + const amount = 100; + const currency = 'USD'; + const comment = ''; + const created = '2023-10-01'; + const baseValues = TransactionUtils.buildOptimisticTransaction(amount, currency, reportID, comment, created); + + return {...baseValues, ...values}; +} + +describe('TransactionUtils', () => { + describe('getCreated', () => { + describe('when the transaction property "modifiedCreated" has value', () => { + const transaction = generateTransaction({ + created: '2023-10-01', + modifiedCreated: '2023-10-25', + }); + + it('returns the "modifiedCreated" date with the correct format', () => { + const expectedResult = '2023-10-25'; + + const result = TransactionUtils.getCreated(transaction); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when the transaction property "modifiedCreated" does not have value', () => { + describe('and the transaction property "created" has value', () => { + const transaction = generateTransaction({ + created: '2023-10-01', + modifiedCreated: undefined, + }); + + it('returns the "created" date with the correct format', () => { + const expectedResult = '2023-10-01'; + + const result = TransactionUtils.getCreated(transaction); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('and the transaction property "created" does not have value', () => { + const transaction = generateTransaction({ + created: undefined, + modifiedCreated: undefined, + }); + + it('returns an empty string', () => { + const expectedResult = ''; + + const result = TransactionUtils.getCreated(transaction); + + expect(result).toEqual(expectedResult); + }); + }); + }); + }); +});