diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml index 24f3b6aa9aeb..4a8a3fd732c0 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -264,11 +264,11 @@ jobs: - name: Deploy production to S3 if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: aws s3 cp --recursive --acl public-read "$GITHUB_WORKSPACE"/dist s3://expensify-cash/ && aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE s3://expensify-cash/.well-known/apple-app-site-association s3://expensify-cash/.well-known/apple-app-site-association + run: aws s3 cp --recursive --acl public-read "$GITHUB_WORKSPACE"/dist s3://expensify-cash/ && aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE s3://expensify-cash/.well-known/apple-app-site-association s3://expensify-cash/.well-known/apple-app-site-association && aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE s3://expensify-cash/.well-known/apple-app-site-association s3://expensify-cash/apple-app-site-association - name: Deploy staging to S3 if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: aws s3 cp --recursive --acl public-read "$GITHUB_WORKSPACE"/dist s3://staging-expensify-cash/ && aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE s3://staging-expensify-cash/.well-known/apple-app-site-association s3://staging-expensify-cash/.well-known/apple-app-site-association + run: aws s3 cp --recursive --acl public-read "$GITHUB_WORKSPACE"/dist s3://staging-expensify-cash/ && aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE s3://staging-expensify-cash/.well-known/apple-app-site-association s3://staging-expensify-cash/.well-known/apple-app-site-association && aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE s3://staging-expensify-cash/.well-known/apple-app-site-association s3://staging-expensify-cash/apple-app-site-association - name: Purge production Cloudflare cache if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} diff --git a/android/app/build.gradle b/android/app/build.gradle index b08a0b069915..80742df30297 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 1001028302 - versionName "1.2.83-2" + versionCode 1001028801 + versionName "1.2.88-1" buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() if (isNewArchitectureEnabled()) { diff --git a/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java b/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java index ef110747a6a4..eff3420ee96d 100644 --- a/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java +++ b/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java @@ -8,23 +8,25 @@ import android.app.NotificationManager; import android.content.Context; import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; import android.graphics.Canvas; import android.graphics.Paint; -import android.graphics.Rect; -import android.graphics.Bitmap.Config; -import android.graphics.PorterDuffXfermode; import android.graphics.PorterDuff.Mode; +import android.graphics.PorterDuffXfermode; +import android.graphics.Rect; import android.os.Build; import android.util.DisplayMetrics; import android.util.Log; import android.util.TypedValue; import android.view.WindowManager; + import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; import androidx.core.app.Person; import androidx.core.graphics.drawable.IconCompat; + import com.urbanairship.AirshipConfigOptions; import com.urbanairship.json.JsonMap; import com.urbanairship.json.JsonValue; @@ -32,12 +34,14 @@ import com.urbanairship.push.notifications.NotificationArguments; import com.urbanairship.reactnative.ReactNotificationProvider; import com.urbanairship.util.ImageUtils; + import java.net.MalformedURLException; import java.net.URL; -import java.sql.Timestamp; -import java.time.Instant; +import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Calendar; import java.util.HashMap; +import java.util.Locale; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -63,8 +67,6 @@ public class CustomNotificationProvider extends ReactNotificationProvider { // Conversation JSON keys private static final String PAYLOAD_KEY = "payload"; - private static final String TYPE_KEY = "type"; - private static final String REPORT_COMMENT_TYPE = "reportComment"; private final ExecutorService executorService = Executors.newCachedThreadPool(); public final HashMap cache = new HashMap<>(); @@ -92,16 +94,17 @@ protected NotificationCompat.Builder onExtendBuilder(@NonNull Context context, @ builder.setPriority(PRIORITY_MAX); } + // Attempt to parse data and apply custom notification styling if (message.containsKey(PAYLOAD_KEY)) { try { JsonMap payload = JsonValue.parseString(message.getExtra(PAYLOAD_KEY)).optMap(); - // Apply message style only for report comments - if (REPORT_COMMENT_TYPE.equals(payload.get(TYPE_KEY).getString())) { + // Apply message style using onyxData from the notification payload + if (payload.get("onyxData").getList().size() > 0) { applyMessageStyle(context, builder, payload, arguments.getNotificationId()); } } catch (Exception e) { - Log.e(TAG, "Failed to parse conversation. SendID=" + message.getSendId(), e); + Log.e(TAG, "Failed to parse conversation, falling back to default notification style. SendID=" + message.getSendId(), e); } } @@ -150,7 +153,7 @@ public Bitmap getCroppedBitmap(Bitmap bitmap) { /** * Applies the message style to the notification builder. It also takes advantage of the - * notification cache to build conversations. + * notification cache to build conversations style notifications. * * @param builder Notification builder that will receive the message style * @param payload Notification payload, which contains all the data we need to build the notifications. @@ -163,59 +166,82 @@ private void applyMessageStyle(@NonNull Context context, NotificationCompat.Buil } NotificationCache notificationCache = findOrCreateNotificationCache(reportID); - JsonMap reportAction = payload.get("reportAction").getMap(); - String name = reportAction.get("person").getList().get(0).getMap().get("text").getString(); - String avatar = reportAction.get("avatar").getString(); - String accountID = Integer.toString(reportAction.get("actorAccountID").getInt(-1)); - String message = reportAction.get("message").getList().get(0).getMap().get("text").getString(); - long time = Timestamp.valueOf(reportAction.get("created").getString(Instant.now().toString())).getTime(); - String roomName = payload.get("roomName") == null ? "" : payload.get("roomName").getString(""); - String conversationTitle = roomName.isEmpty() ? "Chat with " + name : roomName; - - // Retrieve or create the Person object who sent the latest report comment - Person person = notificationCache.people.get(accountID); - if (person == null) { - IconCompat iconCompat = fetchIcon(context, avatar); - person = new Person.Builder() - .setIcon(iconCompat) - .setKey(accountID) - .setName(name) - .build(); - - notificationCache.people.put(accountID, person); - } - // Store the latest report comment in the local conversation history - notificationCache.messages.add(new NotificationCache.Message(person, message, time)); - - // Create the messaging style notification builder for this notification. - // Associate the notification with the person who sent the report comment. - // If this conversation has 2 participants or more and there's no room name, we should mark - // it as a group conversation. - // Also set the conversation title. - NotificationCompat.MessagingStyle messagingStyle = new NotificationCompat.MessagingStyle(person) - .setGroupConversation(notificationCache.people.size() > 2 || !roomName.isEmpty()) - .setConversationTitle(conversationTitle); - - // Add all conversation messages to the notification, including the last one we just received. - for (NotificationCache.Message cachedMessage : notificationCache.messages) { - messagingStyle.addMessage(cachedMessage.text, cachedMessage.time, cachedMessage.person); - } + try { + JsonMap reportMap = payload.get("onyxData").getList().get(1).getMap().get("value").getMap(); + String reportId = reportMap.keySet().iterator().next(); + JsonMap messageData = reportMap.get(reportId).getMap(); + + String name = messageData.get("person").getList().get(0).getMap().get("text").getString(); + String avatar = messageData.get("avatar").getString(); + String accountID = Integer.toString(messageData.get("actorAccountID").getInt(-1)); + String message = messageData.get("message").getList().get(0).getMap().get("text").getString(); + + String roomName = payload.get("roomName") == null ? "" : payload.get("roomName").getString(""); + String conversationTitle = roomName.isEmpty() ? "Chat with " + name : roomName; + + // Retrieve or create the Person object who sent the latest report comment + Person person = notificationCache.people.get(accountID); + if (person == null) { + IconCompat iconCompat = fetchIcon(context, avatar); + person = new Person.Builder() + .setIcon(iconCompat) + .setKey(accountID) + .setName(name) + .build(); + + notificationCache.people.put(accountID, person); + } - // Clear the previous notification associated to this conversation so it looks like we are - // replacing them with this new one we just built. - if (notificationCache.prevNotificationID != -1) { - NotificationManagerCompat.from(context).cancel(notificationCache.prevNotificationID); - } + // Store the latest report comment in the local conversation history + long createdTimeInMillis = getMessageTimeInMillis(messageData.get("created").getString("")); + notificationCache.messages.add(new NotificationCache.Message(person, message, createdTimeInMillis)); + + // Create the messaging style notification builder for this notification, associating the + // notification with the person who sent the report comment. + NotificationCompat.MessagingStyle messagingStyle = new NotificationCompat.MessagingStyle(person) + .setGroupConversation(notificationCache.people.size() > 2 || !roomName.isEmpty()) + .setConversationTitle(conversationTitle); - // Apply the messaging style to the notification builder - builder.setStyle(messagingStyle); + // Add all conversation messages to the notification, including the last one we just received. + for (NotificationCache.Message cachedMessage : notificationCache.messages) { + messagingStyle.addMessage(cachedMessage.text, cachedMessage.time, cachedMessage.person); + } + + // Clear the previous notification associated to this conversation so it looks like we are + // replacing them with this new one we just built. + if (notificationCache.prevNotificationID != -1) { + NotificationManagerCompat.from(context).cancel(notificationCache.prevNotificationID); + } + + // Apply the messaging style to the notification builder + builder.setStyle(messagingStyle); + + } catch (Exception e) { + e.printStackTrace(); + } // Store the new notification ID so we can replace the notification if this conversation // receives more messages notificationCache.prevNotificationID = notificationID; } + /** + * Safely retrieve the message time in milliseconds + */ + private long getMessageTimeInMillis(String createdTime) { + if (!createdTime.isEmpty()) { + try { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()); + return sdf.parse(createdTime).getTime(); + } catch (Exception e) { + Log.e(TAG, "error parsing createdTime: " + createdTime); + e.printStackTrace(); + } + } + return Calendar.getInstance().getTimeInMillis(); + } + /** * Check if we are showing a notification related to a reportID. * If not, create a new NotificationCache so we can build a conversation notification diff --git a/android/app/src/main/res/drawable/alert_background.xml b/android/app/src/main/res/drawable/alert_background.xml new file mode 100644 index 000000000000..7c4ce0e0f563 --- /dev/null +++ b/android/app/src/main/res/drawable/alert_background.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/datepicker_background.xml b/android/app/src/main/res/drawable/datepicker_background.xml new file mode 100644 index 000000000000..4d32bd3e5020 --- /dev/null +++ b/android/app/src/main/res/drawable/datepicker_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/picker_background.xml b/android/app/src/main/res/drawable/picker_background.xml new file mode 100644 index 000000000000..c3a502870b20 --- /dev/null +++ b/android/app/src/main/res/drawable/picker_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/font/expneuebold.otf b/android/app/src/main/res/font/expneuebold.otf new file mode 100755 index 000000000000..7534aecda322 Binary files /dev/null and b/android/app/src/main/res/font/expneuebold.otf differ diff --git a/android/app/src/main/res/font/expneueregular.otf b/android/app/src/main/res/font/expneueregular.otf new file mode 100755 index 000000000000..d4d8cbe63b44 Binary files /dev/null and b/android/app/src/main/res/font/expneueregular.otf differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 55a3b29e3695..b483943f0350 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -4,4 +4,6 @@ #03D47C #0b1b34 #7D8B8F + #07271F + #1A3D32 diff --git a/android/app/src/main/res/values/dimen.xml b/android/app/src/main/res/values/dimen.xml new file mode 100644 index 000000000000..1b86b49ee890 --- /dev/null +++ b/android/app/src/main/res/values/dimen.xml @@ -0,0 +1,5 @@ + + + 16dp + 16dp + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index dbaeb878951e..ff3640c82925 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -9,6 +9,10 @@ #061B09 @color/accent @drawable/rn_edit_text_material + @style/AppTheme.Popup + @style/TextViewSpinnerDropDownItem + @style/DatePickerDialogTheme + @style/AlertDialogTheme @@ -18,4 +22,49 @@ true + + + + + + + + + + + + + + + + + + diff --git a/assets/images/calendar.svg b/assets/images/calendar.svg new file mode 100644 index 000000000000..18885029a7c8 --- /dev/null +++ b/assets/images/calendar.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/assets/images/expensify-wordmark.svg b/assets/images/expensify-wordmark.svg index 73018497030b..69fbcbae6743 100644 --- a/assets/images/expensify-wordmark.svg +++ b/assets/images/expensify-wordmark.svg @@ -1,26 +1,23 @@ - + + viewBox="0 0 78 19" style="enable-background:new 0 0 78 19;" xml:space="preserve"> - - - - - - - - - + + + + + + + + + diff --git a/assets/images/image-crop-circle-mask.svg b/assets/images/image-crop-circle-mask.svg new file mode 100644 index 000000000000..8edded23218d --- /dev/null +++ b/assets/images/image-crop-circle-mask.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/images/image-crop-mask.svg b/assets/images/image-crop-mask.svg deleted file mode 100644 index 89b63474dcb9..000000000000 --- a/assets/images/image-crop-mask.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - diff --git a/assets/images/image-crop-square-mask.svg b/assets/images/image-crop-square-mask.svg new file mode 100644 index 000000000000..050998d576f8 --- /dev/null +++ b/assets/images/image-crop-square-mask.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/images/product-illustrations/safe.svg b/assets/images/product-illustrations/safe.svg new file mode 100644 index 000000000000..db2ac0707f7f --- /dev/null +++ b/assets/images/product-illustrations/safe.svg @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/product-illustrations/todd-behind-cloud.svg b/assets/images/product-illustrations/todd-behind-cloud.svg new file mode 100644 index 000000000000..6281ce0ef727 --- /dev/null +++ b/assets/images/product-illustrations/todd-behind-cloud.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md index fa8a9d46d1da..035f186bc5dc 100644 --- a/contributingGuides/CONTRIBUTING.md +++ b/contributingGuides/CONTRIBUTING.md @@ -68,7 +68,7 @@ A job could be fixing a bug or working on a new feature. There are two ways you This is the most common scenario for contributors. The Expensify team posts new jobs to the Upwork job list [here](https://www.upwork.com/ab/jobs/search/?q=Expensify%20React%20Native&sort=recency&user_location_match=2) (you must be signed in to Upwork to view jobs). Each job in Upwork has a corresponding GitHub issue, which will include instructions to follow. You can also view all open jobs in the Expensify/App GH repository by searching for GH issues with the [`Help Wanted` label](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22). Lastly, you can follow the [@ExpensifyOSS](https://twitter.com/ExpensifyOSS) Twitter account to see a live feed of jobs that are posted. #### Proposing a job that Expensify hasn't posted -It’s possible that you found a new bug that we haven’t posted as a job to the [GitHub repository](https://github.com/Expensify/App/issues?q=is%3Aissue). This is an opportunity to propose a job. If it's a valid job proposal that we choose to implement by deploying it to production — either internally or via an external contributor — then we will compensate you $250 for identifying and proposing the bug (we do not compensate for reporting new feature requests). If the bug is fixed by a PR that is not associated with your bug report, then you will not be eligible for the corresponding compensation unless you can find the PR that fixed it and prove your proposal came first. +It’s possible that you found a new bug that we haven’t posted as a job to the [GitHub repository](https://github.com/Expensify/App/issues?q=is%3Aissue). This is an opportunity to propose a job. If it's a valid job proposal that we choose to implement by deploying it to production — either internally or via an external contributor — then we will compensate you $250 for identifying and proposing the bug (we do not compensate for reporting new feature requests). If the bug is fixed by a PR that is not associated with your bug report, then you will not be eligible for the corresponding compensation unless you can find the PR that fixed it and prove your bug report came first. - Note: If you get assigned the job you proposed **and** you complete the job, this $250 for identifying the improvement is *in addition to* the reward you will be paid for completing the job. - Note about proposed bugs: Expensify has the right not to pay the $250 reward if the suggested bug has already been reported. Following, if more than one contributor proposes the same bug, the contributor who posted it first in the [#expensify-bugs](https://expensify.slack.com/archives/C049HHMV9SM) Slack channel is the one who is eligible for the bonus. - Note: whilst you may optionally propose a solution for that job on Slack, solutions are ultimately reviewed in GitHub. The onus is on you to propose the solution on GitHub, and/or ensure the issue creator will include a link to your proposal. @@ -111,7 +111,7 @@ 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. Do not propose solutions to jobs until the `External` or `Help Wanted` label has been applied. Any proposals submitted before these labels are added will not be reviewed. +4. Do not propose solutions to jobs until the `Help Wanted` label has been applied. Any proposals submitted before these labels are added will not be reviewed. 5. 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. ALL NEW PROPOSALS MUST BE DIFFERENT FROM EXISTING PROPOSALS. The *difference* should be important, meaningful or considerable. 6. 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). diff --git a/contributingGuides/FORMS.md b/contributingGuides/FORMS.md index 53f2d87603a2..3ecfefefed90 100644 --- a/contributingGuides/FORMS.md +++ b/contributingGuides/FORMS.md @@ -106,7 +106,9 @@ Once a user has “touched” an input, i.e. blurred the input, we will also sta All form fields will additionally be validated when the form is submitted. Although we are validating on blur this additional step is necessary to cover edge cases where forms are auto-filled or when a form is submitted by pressing enter (i.e. there will be only a ‘submit’ event and no ‘blur’ event to hook into). -The Form component takes care of validation internally and the only requirement is that we pass a validate callback prop. The validate callback takes in the input values as argument and should return an object with shape `{[inputID]: errorMessage}`. Here's an example for a form that has two inputs, `routingNumber` and `accountNumber`: +The Form component takes care of validation internally and the only requirement is that we pass a validate callback prop. The validate callback takes in the input values as argument and should return an object with shape `{[inputID]: errorMessage}`. + +Here's an example for a form that has two inputs, `routingNumber` and `accountNumber`: ```js function validate(values) { @@ -121,6 +123,28 @@ function validate(values) { } ``` +When more than one method is used to validate the value, the `addErrorMessage` function from `ErrorUtils` should be used. Here's an example for a form with a field with multiple validators for `firstName` input: + +```js +function validate(values) { + let errors = {}; + + if (!ValidationUtils.isValidDisplayName(values.firstName)) { + errors = ErrorUtils.addErrorMessage(errors, 'firstName', props.translate('personalDetails.error.hasInvalidCharacter')); + } + + if (ValidationUtils.doesContainReservedWord(values.firstName, CONST.DISPLAY_NAME.RESERVED_FIRST_NAMES)) { + errors = ErrorUtils.addErrorMessage(errors, 'firstName', props.translate('personalDetails.error.containsReservedWord')); + } + + if (!ValidationUtils.isValidDisplayName(values.lastName)) { + errors.lastName = props.translate('personalDetails.error.hasInvalidCharacter'); + } + + return errors; + } +``` + For a working example, check [Form story](https://github.com/Expensify/App/blob/aa1f0f34eeba5d761657168255a1ae9aebdbd95e/src/stories/Form.stories.js#L63-L72) ### Highlight Fields and Inline Errors diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 2a2262f27a0b..7487f1fd4585 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.2.83 + 1.2.88 CFBundleSignature ???? CFBundleURLTypes @@ -30,7 +30,7 @@ CFBundleVersion - 1.2.83.2 + 1.2.88.1 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 4d65d1540b34..194f69ecee8a 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.2.83 + 1.2.88 CFBundleSignature ???? CFBundleVersion - 1.2.83.2 + 1.2.88.1 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index f494e566c73e..5011d9d325da 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -234,7 +234,7 @@ PODS: - RNPermissions - Permission-LocationWhenInUse (3.6.1): - RNPermissions - - Plaid (2.5.1) + - Plaid (4.1.0) - PromisesObjC (2.2.0) - RCT-Folly (2021.07.22.00): - boost @@ -484,8 +484,8 @@ PODS: - React-Core - react-native-performance (4.0.0): - React-Core - - react-native-plaid-link-sdk (7.4.0): - - Plaid (~> 2.5.1) + - react-native-plaid-link-sdk (10.0.0): + - Plaid (~> 4.1.0) - React-Core - react-native-progress-bar-android (1.0.4): - React @@ -984,7 +984,7 @@ SPEC CHECKSUMS: Permission-LocationAccuracy: 76df17de5c6b8bc2eee34e61ee92cdd7a864c73d Permission-LocationAlways: 8d99b025c9f73c696e0cdb367e42525f2e9a26f2 Permission-LocationWhenInUse: 3ba99e45c852763f730eabecec2870c2382b7bd4 - Plaid: 6beadc0828cfd5396c5905931b9503493bbc139a + Plaid: 7d340abeadb46c7aa1a91f896c5b22395a31fcf2 PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef RCT-Folly: 0080d0a6ebf2577475bda044aa59e2ca1f909cda RCTRequired: 329ead02b8edd20fb186d17745a9cadd5ce2922d @@ -1011,7 +1011,7 @@ SPEC CHECKSUMS: react-native-netinfo: 1a6035d3b9780221d407c277ebfb5722ace00658 react-native-pdf: 33c622cbdf776a649929e8b9d1ce2d313347c4fa react-native-performance: 224bd53e6a835fda4353302cf891d088a0af7406 - react-native-plaid-link-sdk: 77052f329310ff5a36ddda276793f40d27c02bc4 + react-native-plaid-link-sdk: 9eb0f71dad94b3bdde649c7a384cba93024af46c react-native-progress-bar-android: be43138ab7da30d51fc038bafa98e9ed594d0c40 react-native-progress-view: 4d3bbe6a099ba027b1fedb1548c2c87f74249b70 react-native-quick-sqlite: a7bd4139fb07194ef8534d1cc14c1aec6daa4d84 diff --git a/package-lock.json b/package-lock.json index d66966cfd253..16cf6ac8aadc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.2.83-2", + "version": "1.2.88-1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.2.83-2", + "version": "1.2.88-1", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -74,8 +74,8 @@ "react-native-pdf": "^6.6.2", "react-native-performance": "^4.0.0", "react-native-permissions": "^3.0.1", - "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#77cc9d42c474a693755941b10ee4c2d6f50e5346", - "react-native-plaid-link-sdk": "^7.2.0", + "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#84ee97dec11c2e65609511eb5a757d61bbeeab79", + "react-native-plaid-link-sdk": "^10.0.0", "react-native-quick-sqlite": "^5.0.3", "react-native-reanimated": "3.0.0-rc.10", "react-native-render-html": "6.3.1", @@ -135,11 +135,11 @@ "css-loader": "^6.7.2", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", - "electron": "22.2.0", + "electron": "22.3.3", "electron-builder": "23.5.0", "electron-notarize": "^1.2.1", "eslint": "^7.6.0", - "eslint-config-expensify": "2.0.30", + "eslint-config-expensify": "^2.0.31", "eslint-plugin-jest": "^24.1.0", "eslint-plugin-jsx-a11y": "^6.6.1", "eslint-plugin-storybook": "^0.5.13", @@ -22787,9 +22787,9 @@ } }, "node_modules/electron": { - "version": "22.2.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-22.2.0.tgz", - "integrity": "sha512-puRZSF2vWJ4pz3oetL5Td8LcuivTWz3MoAk/gjImHSN1B/2VJNEQlw1jGdkte+ppid2craOswE2lmCOZ7SwF1g==", + "version": "22.3.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-22.3.3.tgz", + "integrity": "sha512-+ZJDVfyhw7J2A46/kGKscktIhzOisTeJKrUBJLXa7PTB+U+cwyoxCBIaIOnDsdicBCX4nAc1mo6YMQjQQdAmgw==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -23582,9 +23582,9 @@ } }, "node_modules/eslint-config-expensify": { - "version": "2.0.30", - "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.30.tgz", - "integrity": "sha512-SmZTvgUXTRmE4PRlSVDP1LgSZzrxZCgRaKJYiGso+oWl7GcB0HADmrS4nARQswfd4SWqRDy8zsY+j7+GuG8f1A==", + "version": "2.0.31", + "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.31.tgz", + "integrity": "sha512-ekIF8oDR3UD6wghyzP7M6Kf0QSVtkW9UH90vXdXsm67kkTOX9ej47KvI7yGemxLvk/vOmj2mzb+Kg3TihwnghA==", "dev": true, "dependencies": { "@lwc/eslint-plugin-lwc": "^0.11.0", @@ -37641,8 +37641,8 @@ }, "node_modules/react-native-picker-select": { "version": "8.0.4", - "resolved": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#77cc9d42c474a693755941b10ee4c2d6f50e5346", - "integrity": "sha512-KhadZYEWeoTQv/dj2tXpCRQvoY3L9tMGcVnopiYNSzlPdbnDzJUdvdDwf2bVdR3zQXrmHjzsYUVUJx3FFu6LAA==", + "resolved": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#84ee97dec11c2e65609511eb5a757d61bbeeab79", + "integrity": "sha512-AvrO+pSH5mjJCosqPuFKB9kBmyTFXS+OWXEYBXVFBDzAYpq18U2DDYlpZoA54xhPbamDCz3xT3UDx0iFoG2GTg==", "license": "MIT", "dependencies": { "lodash.isequal": "^4.5.0" @@ -37652,12 +37652,12 @@ } }, "node_modules/react-native-plaid-link-sdk": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/react-native-plaid-link-sdk/-/react-native-plaid-link-sdk-7.4.0.tgz", - "integrity": "sha512-JF2K3r6h+Hd8008spc6dAYr9Zcr1IHMuUQ8dDnBPP7k4HvdmZ3Uw9hrBjCcyxDbIU1V6KbOVHsPx0MrkTZe63w==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/react-native-plaid-link-sdk/-/react-native-plaid-link-sdk-10.0.0.tgz", + "integrity": "sha512-WqU44tYzQoR/cuufD6GI7vOWTLcL9RXuEqfGaCynHdh2rmj3SC+mSEmXpg/LG0Q4E1XivkjfgF9tAOdlbnLMHQ==", "peerDependencies": { "react": "*", - "react-native": ">=0.61" + "react-native": ">=0.66.0" } }, "node_modules/react-native-quick-sqlite": { @@ -62143,9 +62143,9 @@ } }, "electron": { - "version": "22.2.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-22.2.0.tgz", - "integrity": "sha512-puRZSF2vWJ4pz3oetL5Td8LcuivTWz3MoAk/gjImHSN1B/2VJNEQlw1jGdkte+ppid2craOswE2lmCOZ7SwF1g==", + "version": "22.3.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-22.3.3.tgz", + "integrity": "sha512-+ZJDVfyhw7J2A46/kGKscktIhzOisTeJKrUBJLXa7PTB+U+cwyoxCBIaIOnDsdicBCX4nAc1mo6YMQjQQdAmgw==", "dev": true, "requires": { "@electron/get": "^2.0.0", @@ -62890,9 +62890,9 @@ } }, "eslint-config-expensify": { - "version": "2.0.30", - "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.30.tgz", - "integrity": "sha512-SmZTvgUXTRmE4PRlSVDP1LgSZzrxZCgRaKJYiGso+oWl7GcB0HADmrS4nARQswfd4SWqRDy8zsY+j7+GuG8f1A==", + "version": "2.0.31", + "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.31.tgz", + "integrity": "sha512-ekIF8oDR3UD6wghyzP7M6Kf0QSVtkW9UH90vXdXsm67kkTOX9ej47KvI7yGemxLvk/vOmj2mzb+Kg3TihwnghA==", "dev": true, "requires": { "@lwc/eslint-plugin-lwc": "^0.11.0", @@ -73560,17 +73560,17 @@ "requires": {} }, "react-native-picker-select": { - "version": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#77cc9d42c474a693755941b10ee4c2d6f50e5346", - "integrity": "sha512-KhadZYEWeoTQv/dj2tXpCRQvoY3L9tMGcVnopiYNSzlPdbnDzJUdvdDwf2bVdR3zQXrmHjzsYUVUJx3FFu6LAA==", - "from": "react-native-picker-select@git+https://github.com/Expensify/react-native-picker-select.git#77cc9d42c474a693755941b10ee4c2d6f50e5346", + "version": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#84ee97dec11c2e65609511eb5a757d61bbeeab79", + "integrity": "sha512-AvrO+pSH5mjJCosqPuFKB9kBmyTFXS+OWXEYBXVFBDzAYpq18U2DDYlpZoA54xhPbamDCz3xT3UDx0iFoG2GTg==", + "from": "react-native-picker-select@git+https://github.com/Expensify/react-native-picker-select.git#84ee97dec11c2e65609511eb5a757d61bbeeab79", "requires": { "lodash.isequal": "^4.5.0" } }, "react-native-plaid-link-sdk": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/react-native-plaid-link-sdk/-/react-native-plaid-link-sdk-7.4.0.tgz", - "integrity": "sha512-JF2K3r6h+Hd8008spc6dAYr9Zcr1IHMuUQ8dDnBPP7k4HvdmZ3Uw9hrBjCcyxDbIU1V6KbOVHsPx0MrkTZe63w==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/react-native-plaid-link-sdk/-/react-native-plaid-link-sdk-10.0.0.tgz", + "integrity": "sha512-WqU44tYzQoR/cuufD6GI7vOWTLcL9RXuEqfGaCynHdh2rmj3SC+mSEmXpg/LG0Q4E1XivkjfgF9tAOdlbnLMHQ==", "requires": {} }, "react-native-quick-sqlite": { diff --git a/package.json b/package.json index 7b665a89892a..7da8ae2c3b56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.2.83-2", + "version": "1.2.88-1", "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.", @@ -26,7 +26,7 @@ "ios-build": "fastlane ios build", "android-build": "fastlane android build", "android-build-e2e": "bundle exec fastlane android build_e2e", - "test": "jest", + "test": "TZ=utc jest", "lint": "eslint . --max-warnings=0", "lint-watch": "npx eslint-watch --watch --changed", "shellcheck": "./scripts/shellCheck.sh", @@ -105,8 +105,8 @@ "react-native-pdf": "^6.6.2", "react-native-performance": "^4.0.0", "react-native-permissions": "^3.0.1", - "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#77cc9d42c474a693755941b10ee4c2d6f50e5346", - "react-native-plaid-link-sdk": "^7.2.0", + "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#84ee97dec11c2e65609511eb5a757d61bbeeab79", + "react-native-plaid-link-sdk": "^10.0.0", "react-native-quick-sqlite": "^5.0.3", "react-native-reanimated": "3.0.0-rc.10", "react-native-render-html": "6.3.1", @@ -166,11 +166,11 @@ "css-loader": "^6.7.2", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", - "electron": "22.2.0", + "electron": "22.3.3", "electron-builder": "23.5.0", "electron-notarize": "^1.2.1", "eslint": "^7.6.0", - "eslint-config-expensify": "2.0.30", + "eslint-config-expensify": "^2.0.31", "eslint-plugin-jest": "^24.1.0", "eslint-plugin-jsx-a11y": "^6.6.1", "eslint-plugin-storybook": "^0.5.13", diff --git a/src/CONFIG.js b/src/CONFIG.js index 2dd63730b84c..6b8d191b514e 100644 --- a/src/CONFIG.js +++ b/src/CONFIG.js @@ -49,11 +49,17 @@ export default { EXPENSIFY: { // Note: This will be EXACTLY what is set for EXPENSIFY_URL whether the proxy is enabled or not. EXPENSIFY_URL: expensifyURL, - SECURE_EXPENSIFY_URL: secureURLRoot, NEW_EXPENSIFY_URL: newExpensifyURL, - URL_API_ROOT: expensifyURLRoot, - STAGING_EXPENSIFY_URL: stagingExpensifyURL, - STAGING_SECURE_EXPENSIFY_URL: stagingSecureExpensifyUrl, + + // The DEFAULT API is the API used by most environments, except staging, where we use STAGING (defined below) + // The "staging toggle" in settings toggles between DEFAULT and STAGING APIs + // On both STAGING and PROD this (DEFAULT) address points to production + // On DEV it can be configured through ENV settings and can be a proxy or ngrok address (defaults to PROD) + // Usually you don't need to use this URL directly - prefer `ApiUtils.getApiRoot()` + DEFAULT_API_ROOT: expensifyURLRoot, + DEFAULT_SECURE_API_ROOT: secureURLRoot, + STAGING_API_ROOT: stagingExpensifyURL, + STAGING_SECURE_API_ROOT: stagingSecureExpensifyUrl, PARTNER_NAME: lodashGet(Config, 'EXPENSIFY_PARTNER_NAME', 'chat-expensify-com'), PARTNER_PASSWORD: lodashGet(Config, 'EXPENSIFY_PARTNER_PASSWORD', 'e21965746fd75f82bb66'), EXPENSIFY_CASH_REFERER: 'ecash', diff --git a/src/CONST.js b/src/CONST.js index fda62a4f45e8..cc6654714354 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -8,6 +8,7 @@ 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 CURRENT_YEAR = new Date().getFullYear(); const CONST = { ANDROID_PACKAGE_NAME, @@ -26,6 +27,13 @@ const CONST = { MIN_SIZE: 240, }, + AUTO_AUTH_STATE: { + NOT_STARTED: 'not-started', + SIGNING_IN: 'signing-in', + JUST_SIGNED_IN: 'just-signed-in', + FAILED: 'failed', + }, + AVATAR_MAX_ATTACHMENT_SIZE: 6291456, AVATAR_ALLOWED_EXTENSIONS: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg'], @@ -46,6 +54,23 @@ const CONST = { RESERVED_FIRST_NAMES: ['Expensify', 'Concierge'], }, + CALENDAR_PICKER: { + // Numbers were arbitrarily picked. + MIN_YEAR: CURRENT_YEAR - 100, + MAX_YEAR: CURRENT_YEAR + 100, + }, + + DATE_BIRTH: { + MIN_AGE: 5, + MAX_AGE: 150, + }, + + // This is used to enable a rotation/transform style to any component. + DIRECTION: { + LEFT: 'left', + RIGHT: 'right', + }, + // Sizes needed for report empty state background image handling EMPTY_STATE_BACKGROUND: { SMALL_SCREEN: { @@ -469,6 +494,9 @@ const CONST = { MAX_RETRY_WAIT_TIME_MS: 10 * 1000, PROCESS_REQUEST_DELAY_MS: 1000, MAX_PENDING_TIME_MS: 10 * 1000, + COMMAND: { + RECONNECT_APP: 'ReconnectApp', + }, }, NVP: { IS_FIRST_TIME_NEW_EXPENSIFY_USER: 'isFirstTimeNewExpensifyUser', @@ -528,6 +556,8 @@ const CONST = { EMOJI_FREQUENT_ROW_COUNT: 3, + EMOJI_DEFAULT_SKIN_TONE: -1, + INVISIBLE_CODEPOINTS: ['fe0f', '200d', '2066'], TOOLTIP_MAX_LINES: 3, @@ -595,6 +625,7 @@ const CONST = { 3: 100, }, }, + LHN_SKELETON_VIEW_ITEM_HEIGHT: 64, EXPENSIFY_PARTNER_NAME: 'expensify.com', EMAIL: { CONCIERGE: 'concierge@expensify.com', @@ -823,6 +854,7 @@ const CONST = { PHONE_E164_PLUS: /^\+?[1-9]\d{1,14}$/, PHONE_WITH_SPECIAL_CHARS: /^\s*(?:\+?(\d{1,3}))?[-. (]*(\d{3})[-. )]*(\d{3})[-. ]*(\d{4})(?: *x(\d+))?\s*$/, ALPHABETIC_CHARS: /[a-zA-Z]+/, + ALPHABETIC_CHARS_WITH_NUMBER: /^[a-zA-Z0-9 ]*$/, POSITIVE_INTEGER: /^\d+$/, NON_ALPHA_NUMERIC: /[^A-Za-z0-9+]/g, PO_BOX: /\b[P|p]?(OST|ost)?\.?\s*[O|o|0]?(ffice|FFICE)?\.?\s*[B|b][O|o|0]?[X|x]?\.?\s+[#]?(\d+)\b/, diff --git a/src/Expensify.js b/src/Expensify.js index b4a4f341194e..b73954be6c77 100644 --- a/src/Expensify.js +++ b/src/Expensify.js @@ -5,7 +5,7 @@ import React, {PureComponent} from 'react'; import {AppState, Linking} from 'react-native'; import Onyx, {withOnyx} from 'react-native-onyx'; -import * as ReportUtils from './libs/ReportUtils'; +import * as Report from './libs/actions/Report'; import BootSplash from './libs/BootSplash'; import * as ActiveClientManager from './libs/ActiveClientManager'; import ONYXKEYS from './ONYXKEYS'; @@ -124,7 +124,7 @@ 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)); + Linking.addEventListener('url', state => Report.openReportFromDeepLink(state.url)); } componentDidUpdate() { @@ -141,7 +141,7 @@ class Expensify extends PureComponent { 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)); + Linking.getInitialURL().then(url => Report.openReportFromDeepLink(url)); } } diff --git a/src/ROUTES.js b/src/ROUTES.js index 0b70b92c1d6f..2cbe87a4361e 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -20,7 +20,7 @@ export default { BANK_ACCOUNT: 'bank-account', BANK_ACCOUNT_WITH_STEP_TO_OPEN: 'bank-account/:stepToOpen?', BANK_ACCOUNT_PERSONAL: 'bank-account/personal', - getBankAccountRoute: (stepToOpen = '') => `bank-account/${stepToOpen}`, + getBankAccountRoute: (stepToOpen = '', policyID = '') => `bank-account/${stepToOpen}?policyID=${policyID}`, HOME: '', SETTINGS: 'settings', SETTINGS_PROFILE: 'settings/profile', @@ -56,6 +56,8 @@ export default { REPORT, REPORT_WITH_ID: 'r/:reportID', getReportRoute: reportID => `r/${reportID}`, + SELECT_YEAR: 'select-year', + getYearSelectionRoute: (minYear, maxYear, currYear, backTo) => `select-year?min=${minYear}&max=${maxYear}&year=${currYear}&backTo=${backTo}`, /** This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated */ CONCIERGE: 'concierge', @@ -136,7 +138,7 @@ export default { */ parseReportRouteParams: (route) => { if (!route.startsWith(Url.addTrailingForwardSlash(REPORT))) { - return {}; + return {reportID: '', isSubReportPageRoute: false}; } const pathSegments = route.split('/'); diff --git a/src/components/AddressSearch.js b/src/components/AddressSearch.js index dd88073e228e..7e22542c0484 100644 --- a/src/components/AddressSearch.js +++ b/src/components/AddressSearch.js @@ -4,11 +4,11 @@ import PropTypes from 'prop-types'; import {LogBox, ScrollView, View} from 'react-native'; import {GooglePlacesAutocomplete} from 'react-native-google-places-autocomplete'; 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 * as ApiUtils from '../libs/ApiUtils'; import * as GooglePlacesUtils from '../libs/GooglePlacesUtils'; import CONST from '../CONST'; @@ -199,7 +199,7 @@ const AddressSearch = (props) => { query={query} requestUrl={{ useOnPlatform: 'all', - url: `${CONFIG.EXPENSIFY.URL_API_ROOT}api?command=Proxy_GooglePlaces&proxyUrl=`, + url: ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}), }} textInputProps={{ InputComp: TextInput, diff --git a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.js b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.js index f29447f9f781..86e9e9690a96 100644 --- a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.js +++ b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.js @@ -21,12 +21,16 @@ const propTypes = { /** Press out handler for the link */ onPressOut: PropTypes.func, + /** If a file download is happening */ + download: PropTypes.bool, + ...anchorForAttachmentsOnlyPropTypes, }; const defaultProps = { onPressIn: undefined, onPressOut: undefined, + download: false, ...anchorForAttachmentsOnlyDefaultProps, }; diff --git a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js index 18d3fbf25960..cb7625bc7eaa 100644 --- a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js +++ b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js @@ -54,7 +54,7 @@ const BaseAnchorForCommentsOnly = (props) => { onSecondaryInteraction={ (event) => { ReportActionContextMenu.showContextMenu( - Str.isValidEmail(props.displayName) ? ContextMenuActions.CONTEXT_MENU_TYPES.EMAIL : ContextMenuActions.CONTEXT_MENU_TYPES.LINK, + Str.isValidEmailMarkdown(props.displayName) ? ContextMenuActions.CONTEXT_MENU_TYPES.EMAIL : ContextMenuActions.CONTEXT_MENU_TYPES.LINK, event, props.href, lodashGet(linkRef, 'current'), @@ -65,7 +65,7 @@ const BaseAnchorForCommentsOnly = (props) => { onPressIn={props.onPressIn} onPressOut={props.onPressOut} > - + linkRef = el} style={StyleSheet.flatten([props.style, defaultTextStyle])} diff --git a/src/components/ArchivedReportFooter.js b/src/components/ArchivedReportFooter.js index 44dd223e8bbc..963702b38aa6 100644 --- a/src/components/ArchivedReportFooter.js +++ b/src/components/ArchivedReportFooter.js @@ -33,13 +33,13 @@ const propTypes = { report: reportPropTypes.isRequired, /** Personal details of all users */ - personalDetails: PropTypes.objectOf(personalDetailsPropType).isRequired, + personalDetails: PropTypes.objectOf(personalDetailsPropType), /** The list of policies the user has access to. */ policies: PropTypes.objectOf(PropTypes.shape({ /** The name of the policy */ name: PropTypes.string, - })).isRequired, + })), ...withLocalizePropTypes, }; @@ -50,6 +50,8 @@ const defaultProps = { reason: CONST.REPORT.ARCHIVE_REASON.DEFAULT, }, }, + personalDetails: {}, + policies: {}, }; const ArchivedReportFooter = (props) => { diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index 034821a8af56..e0c7f6e6bc95 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -6,7 +6,6 @@ import lodashGet from 'lodash/get'; import lodashExtend from 'lodash/extend'; import _ from 'underscore'; import CONST from '../CONST'; -import Navigation from '../libs/Navigation/Navigation'; import Modal from './Modal'; import AttachmentView from './AttachmentView'; import AttachmentCarousel from './AttachmentCarousel'; @@ -54,6 +53,9 @@ const propTypes = { /** Title shown in the header of the modal */ headerTitle: PropTypes.string, + /** The ID of the report that has this attachment */ + reportID: PropTypes.string, + ...withLocalizePropTypes, ...windowDimensionsPropTypes, @@ -66,6 +68,7 @@ const defaultProps = { isAuthTokenRequired: false, allowDownload: false, headerTitle: null, + reportID: '', onModalHide: () => {}, }; @@ -75,7 +78,6 @@ class AttachmentModal extends PureComponent { this.state = { isModalOpen: false, - reportID: null, shouldLoadAttachment: false, isAttachmentInvalid: false, attachmentInvalidReasonTitle: null, @@ -246,7 +248,6 @@ class AttachmentModal extends PureComponent { return ( <> this.setState({isModalOpen: false})} @@ -268,9 +269,9 @@ class AttachmentModal extends PureComponent { onCloseButtonPress={() => this.setState({isModalOpen: false})} /> - {this.state.reportID ? ( + {this.props.reportID ? ( { - const route = Navigation.getActiveRoute(); - let reportID = null; - if (route.includes('/r/')) { - reportID = route.replace('/r/', ''); - } - this.setState({isModalOpen: true, reportID}); + this.setState({isModalOpen: true}); }, })} diff --git a/src/components/AvatarCropModal/AvatarCropModal.js b/src/components/AvatarCropModal/AvatarCropModal.js index b0b568d986c8..b9b276520ff3 100644 --- a/src/components/AvatarCropModal/AvatarCropModal.js +++ b/src/components/AvatarCropModal/AvatarCropModal.js @@ -50,6 +50,9 @@ const propTypes = { /** Modal visibility */ isVisible: PropTypes.bool.isRequired, + /** Image crop vector mask */ + maskImage: PropTypes.func, + ...withLocalizePropTypes, ...windowDimensionsPropTypes, }; @@ -60,6 +63,7 @@ const defaultProps = { imageType: '', onClose: () => {}, onSave: () => {}, + maskImage: undefined, }; // This component can't be written using class since reanimated API uses hooks. @@ -73,6 +77,11 @@ const AvatarCropModal = (props) => { const translateSlider = useSharedValue(0); const isPressableEnabled = useSharedValue(true); + // The previous offset values are maintained to recalculate the offset value in proportion + // to the container size, especially when the window size is first decreased and then increased + const prevMaxOffsetX = useSharedValue(0); + const prevMaxOffsetY = useSharedValue(0); + const [imageContainerSize, setImageContainerSize] = useState(CONST.AVATAR_CROP_MODAL.INITIAL_SIZE); const [sliderContainerSize, setSliderContainerSize] = useState(CONST.AVATAR_CROP_MODAL.INITIAL_SIZE); const [isImageContainerInitialized, setIsImageContainerInitialized] = useState(false); @@ -82,7 +91,10 @@ const AvatarCropModal = (props) => { const initializeImageContainer = useCallback((event) => { setIsImageContainerInitialized(true); const {height, width} = event.nativeEvent.layout; - setImageContainerSize(Math.floor(Math.min(height - styles.imageCropRotateButton.height, width))); + + // Even if the browser height is reduced too much, the relative height should not be negative + const relativeHeight = Math.max(height - styles.imageCropRotateButton.height, CONST.AVATAR_CROP_MODAL.INITIAL_SIZE); + setImageContainerSize(Math.floor(Math.min(relativeHeight, width))); }, [props.isSmallScreenWidth]); // An onLayout callback, that initializes the slider container size, for proper render of a slider @@ -99,6 +111,8 @@ const AvatarCropModal = (props) => { scale.value = CONST.AVATAR_CROP_MODAL.MIN_SCALE; rotation.value = 0; translateSlider.value = 0; + prevMaxOffsetX.value = 0; + prevMaxOffsetY.value = 0; setImageContainerSize(CONST.AVATAR_CROP_MODAL.INITIAL_SIZE); setSliderContainerSize(CONST.AVATAR_CROP_MODAL.INITIAL_SIZE); setIsImageContainerInitialized(false); @@ -166,6 +180,8 @@ const AvatarCropModal = (props) => { const maxOffsetY = (height - imageContainerSize) / 2; translateX.value = clamp(offsetX, [maxOffsetX * -1, maxOffsetX]); translateY.value = clamp(offsetY, [maxOffsetY * -1, maxOffsetY]); + prevMaxOffsetX.value = maxOffsetX; + prevMaxOffsetY.value = maxOffsetY; }, [imageContainerSize, scale, clamp]); /** @@ -199,6 +215,38 @@ const AvatarCropModal = (props) => { }, }, [imageContainerSize, updateImageOffset, translateX, translateY]); + // This effect is needed to recalculate the maximum offset values + // when the browser window is resized. + useEffect(() => { + // If no panning has happened and the value is 0, do an early return. + if (!prevMaxOffsetX.value && !prevMaxOffsetY.value) { + return; + } + const {height, width} = getDisplayedImageSize(); + const maxOffsetX = (width - imageContainerSize) / 2; + const maxOffsetY = (height - imageContainerSize) / 2; + + // Since interpolation is expensive, we only want to do it if + // image has been panned across X or Y axis by the user. + if (prevMaxOffsetX) { + translateX.value = interpolate( + translateX.value, + [prevMaxOffsetX.value * -1, prevMaxOffsetX.value], + [maxOffsetX * -1, maxOffsetX], + ); + } + + if (prevMaxOffsetY) { + translateY.value = interpolate( + translateY.value, + [prevMaxOffsetY.value * -1, prevMaxOffsetY.value], + [maxOffsetY * -1, maxOffsetY], + ); + } + prevMaxOffsetX.value = maxOffsetX; + prevMaxOffsetY.value = maxOffsetY; + }, [imageContainerSize]); + /** * Calculates new scale value and updates images offset to ensure * that image stays in the center of the container after changing scale. @@ -315,7 +363,6 @@ const AvatarCropModal = (props) => { isVisible={props.isVisible} type={CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED} onModalHide={resetState} - statusBarTranslucent={false} > {props.isSmallScreenWidth && } { translateY={translateY} translateX={translateX} rotation={rotation} + maskImage={props.maskImage} /> diff --git a/src/components/AvatarCropModal/ImageCropView.js b/src/components/AvatarCropModal/ImageCropView.js index 1b9ca36d16e9..bf0762b960a7 100644 --- a/src/components/AvatarCropModal/ImageCropView.js +++ b/src/components/AvatarCropModal/ImageCropView.js @@ -37,12 +37,16 @@ const propTypes = { /** React-native-reanimated lib handler which executes when the user is panning image */ panGestureEventHandler: gestureHandlerPropTypes, + + /** Image crop vector mask */ + maskImage: PropTypes.func, }; const defaultProps = { imageUri: '', containerSize: 0, panGestureEventHandler: () => {}, + maskImage: Expensicons.ImageCropCircleMask, }; const ImageCropView = (props) => { @@ -71,7 +75,7 @@ const ImageCropView = (props) => { - + diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js index 6f33445ba146..f802fd6f70b4 100644 --- a/src/components/AvatarWithImagePicker.js +++ b/src/components/AvatarWithImagePicker.js @@ -60,6 +60,9 @@ const propTypes = { /** Denotes whether it is an avatar or a workspace avatar */ type: PropTypes.oneOf([CONST.ICON_TYPE_AVATAR, CONST.ICON_TYPE_WORKSPACE]), + /** Image crop vector mask */ + editorMaskImage: PropTypes.func, + ...withLocalizePropTypes, }; @@ -74,6 +77,7 @@ const defaultProps = { size: CONST.AVATAR_SIZE.DEFAULT, fallbackIcon: Expensicons.FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, + editorMaskImage: undefined, }; class AvatarWithImagePicker extends React.Component { @@ -309,6 +313,7 @@ class AvatarWithImagePicker extends React.Component { imageUri={this.state.imageUri} imageName={this.state.imageName} imageType={this.state.imageType} + maskImage={this.props.editorMaskImage} /> ); diff --git a/src/components/AvatarWithIndicator.js b/src/components/AvatarWithIndicator.js index 6430d463a712..4a04231c805e 100644 --- a/src/components/AvatarWithIndicator.js +++ b/src/components/AvatarWithIndicator.js @@ -15,6 +15,7 @@ import {policyPropTypes} from '../pages/workspace/withPolicy'; import walletTermsPropTypes from '../pages/EnablePayments/walletTermsPropTypes'; import * as PolicyUtils from '../libs/PolicyUtils'; import * as PaymentMethods from '../libs/actions/PaymentMethods'; +import * as ReportUtils from '../libs/ReportUtils'; const propTypes = { /** URL for the avatar */ @@ -89,7 +90,7 @@ const AvatarWithIndicator = (props) => { {shouldShowIndicator && ( diff --git a/src/components/CalendarPicker/ArrowIcon.js b/src/components/CalendarPicker/ArrowIcon.js new file mode 100644 index 000000000000..239e55d2e904 --- /dev/null +++ b/src/components/CalendarPicker/ArrowIcon.js @@ -0,0 +1,38 @@ +import React from 'react'; +import {View} from 'react-native'; +import PropTypes from 'prop-types'; +import styles from '../../styles/styles'; +import * as Expensicons from '../Icon/Expensicons'; +import * as StyleUtils from '../../styles/StyleUtils'; +import Icon from '../Icon'; +import CONST from '../../CONST'; + +const propTypes = { + /** Specifies if the arrow icon should be disabled or not. */ + disabled: PropTypes.bool, + + /** Specifies direction of icon */ + direction: PropTypes.oneOf([CONST.DIRECTION.LEFT, CONST.DIRECTION.RIGHT]), +}; + +const defaultProps = { + disabled: false, + direction: CONST.DIRECTION.RIGHT, +}; + +const ArrowIcon = props => ( + + + +); + +ArrowIcon.displayName = 'ArrowIcon'; +ArrowIcon.propTypes = propTypes; +ArrowIcon.defaultProps = defaultProps; + +export default ArrowIcon; diff --git a/src/components/CalendarPicker/calendarPickerPropTypes.js b/src/components/CalendarPicker/calendarPickerPropTypes.js new file mode 100644 index 000000000000..9efb999ebdb8 --- /dev/null +++ b/src/components/CalendarPicker/calendarPickerPropTypes.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import moment from 'moment'; +import CONST from '../../CONST'; + +const propTypes = { + /** An initial value of date */ + value: PropTypes.objectOf(Date), + + /** A minimum date (oldest) allowed to select */ + minDate: PropTypes.objectOf(Date), + + /** A maximum date (earliest) allowed to select */ + maxDate: PropTypes.objectOf(Date), + + /** Default year to be set in the calendar picker. Used with navigation to set the correct year after going back to the view with calendar */ + selectedYear: PropTypes.string, + + /** A function that is called when the date changed inside the calendar component */ + onChanged: PropTypes.func, + + /** A function called when the date is selected */ + onSelected: PropTypes.func, +}; + +const defaultProps = { + value: new Date(), + minDate: moment().year(CONST.CALENDAR_PICKER.MIN_YEAR).toDate(), + maxDate: moment().year(CONST.CALENDAR_PICKER.MAX_YEAR).toDate(), + selectedYear: null, + onChanged: () => {}, + onSelected: () => {}, +}; + +export {propTypes, defaultProps}; diff --git a/src/components/CalendarPicker/generateMonthMatrix.js b/src/components/CalendarPicker/generateMonthMatrix.js new file mode 100644 index 000000000000..c32316a1c881 --- /dev/null +++ b/src/components/CalendarPicker/generateMonthMatrix.js @@ -0,0 +1,60 @@ +import moment from 'moment'; + +/** + * Generates a matrix representation of a month's calendar given the year and month. + * + * @param {Number} year - The year for which to generate the month matrix. + * @param {Number} month - The month (0-indexed) for which to generate the month matrix. + * @returns {Array>} - A 2D array of the month's calendar days, with null values representing days outside the current month. + */ +export default function generateMonthMatrix(year, month) { + if (typeof year !== 'number') { + throw new TypeError('Year must be a number'); + } + if (year < 0) { + throw new Error('Year cannot be less than 0'); + } + if (typeof month !== 'number') { + throw new TypeError('Month must be a number'); + } + if (month < 0) { + throw new Error('Month cannot be less than 0'); + } + if (month > 11) { + throw new Error('Month cannot be greater than 11'); + } + + // Get the number of days in the month and the first day of the month + const daysInMonth = moment([year, month]).daysInMonth(); + const firstDay = moment([year, month, 1]).locale('en'); + + // Create a matrix to hold the calendar days + const matrix = []; + let currentWeek = []; + + // Add null values for days before the first day of the month + for (let i = 0; i < firstDay.weekday(); i++) { + currentWeek.push(null); + } + + // Add calendar days to the matrix + for (let i = 1; i <= daysInMonth; i++) { + const day = moment([year, month, i]).locale('en'); + currentWeek.push(day.date()); + + // Start a new row when the current week is full + if (day.weekday() === 6) { + matrix.push(currentWeek); + currentWeek = []; + } + } + + // Add null values for days after the last day of the month + if (currentWeek.length > 0) { + for (let i = currentWeek.length; i < 7; i++) { + currentWeek.push(null); + } + matrix.push(currentWeek); + } + return matrix; +} diff --git a/src/components/CalendarPicker/index.js b/src/components/CalendarPicker/index.js new file mode 100644 index 000000000000..a0f3e174ffa3 --- /dev/null +++ b/src/components/CalendarPicker/index.js @@ -0,0 +1,171 @@ +import _ from 'underscore'; +import React from 'react'; +import {View, TouchableOpacity, Pressable} from 'react-native'; +import moment from 'moment'; +import Text from '../Text'; +import ArrowIcon from './ArrowIcon'; +import styles from '../../styles/styles'; +import {propTypes as calendarPickerPropType, defaultProps as defaultCalendarPickerPropType} from './calendarPickerPropTypes'; +import generateMonthMatrix from './generateMonthMatrix'; +import withLocalize from '../withLocalize'; +import Navigation from '../../libs/Navigation/Navigation'; +import ROUTES from '../../ROUTES'; +import CONST from '../../CONST'; +import getButtonState from '../../libs/getButtonState'; +import * as StyleUtils from '../../styles/StyleUtils'; + +class CalendarPicker extends React.PureComponent { + constructor(props) { + super(props); + + this.monthNames = moment.localeData(props.preferredLocale).months(); + this.daysOfWeek = moment.localeData(props.preferredLocale).weekdays(); + + let currentDateView = props.value; + if (props.selectedYear) { + currentDateView = moment(currentDateView).set('year', props.selectedYear).toDate(); + } + if (props.maxDate < currentDateView) { + currentDateView = props.maxDate; + } else if (props.minDate > currentDateView) { + currentDateView = props.minDate; + } + + this.state = { + currentDateView, + }; + + this.moveToPrevMonth = this.moveToPrevMonth.bind(this); + this.moveToNextMonth = this.moveToNextMonth.bind(this); + this.onYearPickerPressed = this.onYearPickerPressed.bind(this); + this.onDayPressed = this.onDayPressed.bind(this); + } + + componentDidMount() { + if (this.props.minDate <= this.props.maxDate) { + return; + } + throw new Error('Minimum date cannot be greater than the maximum date.'); + } + + componentDidUpdate(prevProps) { + // Check if selectedYear has changed + if (this.props.selectedYear === prevProps.selectedYear) { + return; + } + + // If the selectedYear prop has changed, update the currentDateView state with the new year value + this.setState(prev => ({currentDateView: moment(prev.currentDateView).set('year', this.props.selectedYear).toDate()})); + } + + /** + * Handles the user pressing the year picker button. + * Opens the year selection screen with the minimum and maximum year range + * based on the props, the current year based on the state, and the active route. + */ + onYearPickerPressed() { + const minYear = moment(this.props.minDate).year(); + const maxYear = moment(this.props.maxDate).year(); + const currentYear = this.state.currentDateView.getFullYear(); + Navigation.navigate(ROUTES.getYearSelectionRoute(minYear, maxYear, currentYear, Navigation.getActiveRoute())); + } + + /** + * Calls the onSelected function with the selected date. + * @param {Number} day - The day of the month that was selected. + */ + onDayPressed(day) { + const selectedDate = new Date(this.state.currentDateView.getFullYear(), this.state.currentDateView.getMonth(), day); + this.props.onSelected(selectedDate); + } + + moveToPrevMonth() { + this.setState(prev => ({currentDateView: moment(prev.currentDateView).subtract(1, 'M').toDate()})); + } + + moveToNextMonth() { + this.setState(prev => ({currentDateView: moment(prev.currentDateView).add(1, 'M').toDate()})); + } + + render() { + const currentMonthView = this.state.currentDateView.getMonth(); + const currentYearView = this.state.currentDateView.getFullYear(); + const calendarDaysMatrix = generateMonthMatrix(currentYearView, currentMonthView); + const hasAvailableDatesNextMonth = moment(this.props.maxDate).endOf('month').startOf('day') > moment(this.state.currentDateView).add(1, 'M'); + const hasAvailableDatesPrevMonth = moment(this.props.minDate).startOf('day') < moment(this.state.currentDateView).subtract(1, 'M').endOf('month'); + + return ( + + + + {currentYearView} + + + + + {this.monthNames[currentMonthView]} + + + + + + + + + + + {_.map(this.daysOfWeek, (dayOfWeek => ( + + {dayOfWeek[0]} + + )))} + + {_.map(calendarDaysMatrix, week => ( + + {_.map(week, (day, index) => { + const currentDate = moment([currentYearView, currentMonthView, day]); + const isBeforeMinDate = currentDate < moment(this.props.minDate).startOf('day'); + const isAfterMaxDate = currentDate > moment(this.props.maxDate).startOf('day'); + const isDisabled = !day || isBeforeMinDate || isAfterMaxDate; + const isSelected = moment(this.props.value).isSame(moment([currentYearView, currentMonthView, day]), 'day'); + + return ( + this.onDayPressed(day)} + style={styles.calendarDayRoot} + accessibilityLabel={day ? day.toString() : undefined} + > + {({hovered, pressed}) => ( + + {day} + + )} + + ); + })} + + ))} + + ); + } +} + +CalendarPicker.propTypes = calendarPickerPropType; +CalendarPicker.defaultProps = defaultCalendarPickerPropType; + +export default withLocalize(CalendarPicker); diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.js index f0cdd578f8db..af53ebe3b501 100644 --- a/src/components/DatePicker/index.js +++ b/src/components/DatePicker/index.js @@ -19,12 +19,7 @@ class DatePicker extends React.Component { this.setDate = this.setDate.bind(this); this.showDatepicker = this.showDatepicker.bind(this); - /* We're using uncontrolled input otherwise it wont be possible to - * raise change events with a date value - each change will produce a date - * and make us reset the text input */ - this.defaultValue = props.defaultValue - ? moment(props.defaultValue).format(CONST.DATE.MOMENT_FORMAT_STRING) - : ''; + this.defaultValue = props.defaultValue ? moment(props.defaultValue).format(CONST.DATE.MOMENT_FORMAT_STRING) : ''; } componentDidMount() { diff --git a/src/components/DeeplinkWrapper/index.website.js b/src/components/DeeplinkWrapper/index.website.js index 5179ffa99da0..510087808a2c 100644 --- a/src/components/DeeplinkWrapper/index.website.js +++ b/src/components/DeeplinkWrapper/index.website.js @@ -1,21 +1,12 @@ import _ from 'underscore'; -import {View} from 'react-native'; import PropTypes from 'prop-types'; import React, {PureComponent} from 'react'; import {withOnyx} from 'react-native-onyx'; import deeplinkRoutes from './deeplinkRoutes'; import FullScreenLoadingIndicator from '../FullscreenLoadingIndicator'; -import TextLink from '../TextLink'; -import * as Illustrations from '../Icon/Illustrations'; -import withLocalize, {withLocalizePropTypes} from '../withLocalize'; -import Text from '../Text'; import styles from '../../styles/styles'; -import compose from '../../libs/compose'; import CONST from '../../CONST'; import CONFIG from '../../CONFIG'; -import Icon from '../Icon'; -import * as Expensicons from '../Icon/Expensicons'; -import colors from '../../styles/colors'; import * as Browser from '../../libs/Browser'; import ONYXKEYS from '../../ONYXKEYS'; @@ -23,7 +14,12 @@ const propTypes = { /** Children to render. */ children: PropTypes.node.isRequired, - ...withLocalizePropTypes, + /** List of betas available to current user */ + betas: PropTypes.arrayOf(PropTypes.string), +}; + +const defaultProps = { + betas: [], }; class DeeplinkWrapper extends PureComponent { @@ -65,10 +61,7 @@ class DeeplinkWrapper extends PureComponent { }); if (matchedRoute) { - this.setState({deeplinkMatch: true}); this.openRouteInDesktopApp(); - } else { - this.setState({deeplinkMatch: false}); } } @@ -115,56 +108,12 @@ class DeeplinkWrapper extends PureComponent { return ; } - if ( - this.state.deeplinkMatch - && this.state.appInstallationCheckStatus === CONST.DESKTOP_DEEPLINK_APP_STATE.INSTALLED - ) { - return ( - - - - - - - {this.props.translate('deeplinkWrapper.launching')} - - - - {this.props.translate('deeplinkWrapper.redirectedToDesktopApp')} - {'\n'} - {this.props.translate('deeplinkWrapper.youCanAlso')} - {' '} - this.setState({deeplinkMatch: false})}> - {this.props.translate('deeplinkWrapper.openLinkInBrowser')} - - . - - - - - - - - ); - } - return this.props.children; } } DeeplinkWrapper.propTypes = propTypes; -export default compose( - withLocalize, - withOnyx({ - betas: {key: ONYXKEYS.BETAS}, - }), -)(DeeplinkWrapper); +DeeplinkWrapper.defaultProps = defaultProps; +export default withOnyx({ + betas: {key: ONYXKEYS.BETAS}, +})(DeeplinkWrapper); diff --git a/src/components/DisplayNames/index.js b/src/components/DisplayNames/index.js index fd23a011bf69..ca7ade556a6f 100644 --- a/src/components/DisplayNames/index.js +++ b/src/components/DisplayNames/index.js @@ -71,7 +71,7 @@ class DisplayNames extends PureComponent { // No need for any complex text-splitting, just return a simple Text component return ( {this.props.fullTitle} @@ -100,7 +100,7 @@ class DisplayNames extends PureComponent { > {/* // We need to get the refs to all the names which will be used to correct the horizontal position of the tooltip */} - this.childRefs[index] = el} style={this.props.textStyles}> + this.childRefs[index] = el} style={[...this.props.textStyles, styles.pre]}> {displayName} diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js index ff24bf692627..33ea1370bc44 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js @@ -29,13 +29,13 @@ const propTypes = { forwardedRef: PropTypes.func, /** Stores user's preferred skin tone */ - preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), /** User's frequently used emojis */ frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.shape({ code: PropTypes.string.isRequired, keywords: PropTypes.arrayOf(PropTypes.string), - })).isRequired, + })), /** Props related to the dimensions of the window */ ...windowDimensionsPropTypes, @@ -45,6 +45,8 @@ const propTypes = { const defaultProps = { forwardedRef: () => {}, + preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, + frequentlyUsedEmojis: [], }; class EmojiPickerMenu extends Component { diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js index 57235ab8090a..702be109e5e1 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js @@ -29,7 +29,7 @@ const propTypes = { frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.shape({ code: PropTypes.string.isRequired, keywords: PropTypes.arrayOf(PropTypes.string), - })).isRequired, + })), /** Props related to the dimensions of the window */ ...windowDimensionsPropTypes, @@ -39,7 +39,8 @@ const propTypes = { }; const defaultProps = { - preferredSkinTone: undefined, + preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, + frequentlyUsedEmojis: [], }; class EmojiPickerMenu extends Component { diff --git a/src/components/Form.js b/src/components/Form.js index 672c0b660fb6..116179b46d4a 100644 --- a/src/components/Form.js +++ b/src/components/Form.js @@ -269,7 +269,15 @@ class Form extends React.Component { .value() || ''; return React.cloneElement(child, { - ref: node => this.inputRefs[inputID] = node, + ref: (node) => { + this.inputRefs[inputID] = node; + + // Call the original ref, if any + const {ref} = child; + if (_.isFunction(ref)) { + ref(node); + } + }, value: this.state.inputValues[inputID], errorText: this.state.errors[inputID] || fieldErrorMessage, onBlur: () => { diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js index 4f4f18d53da5..fbda8b4941dd 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js @@ -44,6 +44,10 @@ const customHTMLElementModels = { tagName: 'email-comment', mixedUAStyles: {whiteSpace: 'normal'}, }), + strong: defaultHTMLElementModels.span.extend({ + tagName: 'strong', + mixedUAStyles: {whiteSpace: 'pre'}, + }), }; const defaultViewProps = {style: [styles.alignItemsStart, styles.userSelectText]}; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js index cac01ca0b238..8c27cba1f7a8 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js @@ -14,7 +14,6 @@ import styles from '../../../styles/styles'; import Navigation from '../../../libs/Navigation/Navigation'; import AnchorForCommentsOnly from '../../AnchorForCommentsOnly'; import AnchorForAttachmentsOnly from '../../AnchorForAttachmentsOnly'; -import * as Report from '../../../libs/actions/Report'; import * as Url from '../../../libs/Url'; import ROUTES from '../../../ROUTES'; @@ -27,7 +26,7 @@ const AnchorRenderer = (props) => { const parentStyle = lodashGet(props.tnode, 'parent.styles.nativeTextRet', {}); const attrHref = htmlAttribs.href || ''; const attrPath = lodashGet(Url.getURLObject(attrHref), 'path', '').replace('/', ''); - const hasExpensifyOrigin = Url.hasSameExpensifyOrigin(attrHref, CONFIG.EXPENSIFY.EXPENSIFY_URL) || Url.hasSameExpensifyOrigin(attrHref, CONFIG.EXPENSIFY.STAGING_EXPENSIFY_URL); + const hasExpensifyOrigin = Url.hasSameExpensifyOrigin(attrHref, CONFIG.EXPENSIFY.EXPENSIFY_URL) || Url.hasSameExpensifyOrigin(attrHref, CONFIG.EXPENSIFY.STAGING_API_ROOT); const internalNewExpensifyPath = (Url.hasSameExpensifyOrigin(attrHref, CONST.NEW_EXPENSIFY_URL) || Url.hasSameExpensifyOrigin(attrHref, CONST.STAGING_NEW_EXPENSIFY_URL)) && attrPath; const internalExpensifyPath = hasExpensifyOrigin && !attrPath.startsWith(CONFIG.EXPENSIFY.CONCIERGE_URL_PATHNAME) @@ -48,11 +47,6 @@ const AnchorRenderer = (props) => { // If we are handling a New Expensify link then we will assume this should be opened by the app internally. This ensures that the links are opened internally via react-navigation // instead of in a new tab or with a page refresh (which is the default behavior of an anchor tag) if (internalNewExpensifyPath) { - if (attrPath.indexOf('r/') === 0) { - const reportID = attrPath.split('/')[1]; - Report.openReport(reportID); - } - Navigation.navigate(internalNewExpensifyPath); return; } diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js index c7e6d3a39908..750f316030bf 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js @@ -59,6 +59,7 @@ const ImageRenderer = (props) => { }) => ( ( {/* If there's no subtitle then display a fragment to avoid an empty space which moves the main title */} {_.isString(props.subtitle) - ? Boolean(props.subtitle) && {props.subtitle} + ? Boolean(props.subtitle) && {props.subtitle} : props.subtitle} {props.shouldShowEnvironmentBadge && ( diff --git a/src/components/IOUBadge.js b/src/components/IOUBadge.js index 1256436c36c3..15b1bf76a8fa 100644 --- a/src/components/IOUBadge.js +++ b/src/components/IOUBadge.js @@ -17,7 +17,7 @@ const propTypes = { /** Session of currently logged in user */ session: PropTypes.shape({ email: PropTypes.string.isRequired, - }).isRequired, + }), ...withLocalizePropTypes, }; @@ -30,6 +30,9 @@ const defaultProps = { ownerEmail: null, currency: CONST.CURRENCY.USD, }, + session: { + email: null, + }, }; const IOUBadge = (props) => { diff --git a/src/components/IOUConfirmationList.js b/src/components/IOUConfirmationList.js index b6b834828e67..eae3f48d84ca 100755 --- a/src/components/IOUConfirmationList.js +++ b/src/components/IOUConfirmationList.js @@ -80,7 +80,7 @@ const propTypes = { /** Current user session */ session: PropTypes.shape({ email: PropTypes.string.isRequired, - }).isRequired, + }), }; const defaultProps = { @@ -91,6 +91,9 @@ const defaultProps = { comment: '', iouType: CONST.IOU.IOU_TYPE.REQUEST, canModifyParticipants: false, + session: { + email: null, + }, ...withCurrentUserPersonalDetailsDefaultProps, }; @@ -336,8 +339,5 @@ export default compose( session: { key: ONYXKEYS.SESSION, }, - betas: { - key: ONYXKEYS.BETAS, - }, }), )(IOUConfirmationList); diff --git a/src/components/Icon/Expensicons.js b/src/components/Icon/Expensicons.js index 36a01a83f279..85560a76c577 100644 --- a/src/components/Icon/Expensicons.js +++ b/src/components/Icon/Expensicons.js @@ -13,6 +13,7 @@ import Bolt from '../../../assets/images/bolt.svg'; import Briefcase from '../../../assets/images/briefcase.svg'; import Bug from '../../../assets/images/bug.svg'; import Building from '../../../assets/images/building.svg'; +import Calendar from '../../../assets/images/calendar.svg'; import Camera from '../../../assets/images/camera.svg'; import Cash from '../../../assets/images/cash.svg'; import ChatBubble from '../../../assets/images/chatbubble.svg'; @@ -46,7 +47,8 @@ import Globe from '../../../assets/images/globe.svg'; import Hashtag from '../../../assets/images/hashtag.svg'; import History from '../../../assets/images/history.svg'; import Hourglass from '../../../assets/images/hourglass.svg'; -import ImageCropMask from '../../../assets/images/image-crop-mask.svg'; +import ImageCropCircleMask from '../../../assets/images/image-crop-circle-mask.svg'; +import ImageCropSquareMask from '../../../assets/images/image-crop-square-mask.svg'; import Info from '../../../assets/images/info.svg'; import Invoice from '../../../assets/images/invoice.svg'; import Key from '../../../assets/images/key.svg'; @@ -125,6 +127,7 @@ export { Briefcase, Bug, Building, + Calendar, Camera, Cash, ChatBubble, @@ -163,7 +166,8 @@ export { Hashtag, History, Hourglass, - ImageCropMask, + ImageCropCircleMask, + ImageCropSquareMask, Info, Invoice, Key, diff --git a/src/components/Icon/Illustrations.js b/src/components/Icon/Illustrations.js index 3a038d66ca72..167d8e90f29f 100644 --- a/src/components/Icon/Illustrations.js +++ b/src/components/Icon/Illustrations.js @@ -17,8 +17,10 @@ import ReceiptsSearchYellow from '../../../assets/images/product-illustrations/r import ReceiptYellow from '../../../assets/images/product-illustrations/receipt--yellow.svg'; import RocketBlue from '../../../assets/images/product-illustrations/rocket--blue.svg'; import RocketOrange from '../../../assets/images/product-illustrations/rocket--orange.svg'; +import SafeBlue from '../../../assets/images/product-illustrations/safe.svg'; import TadaYellow from '../../../assets/images/product-illustrations/tada--yellow.svg'; import TadaBlue from '../../../assets/images/product-illustrations/tada--blue.svg'; +import ToddBehindCloud from '../../../assets/images/product-illustrations/todd-behind-cloud.svg'; import GpsTrackOrange from '../../../assets/images/product-illustrations/gps-track--orange.svg'; import ShieldYellow from '../../../assets/images/simple-illustrations/simple-illustration__shield.svg'; import MoneyReceipts from '../../../assets/images/simple-illustrations/simple-illustration__money-receipts.svg'; @@ -58,8 +60,10 @@ export { ReceiptYellow, RocketBlue, RocketOrange, + SafeBlue, TadaYellow, TadaBlue, + ToddBehindCloud, GpsTrackOrange, ShieldYellow, MoneyReceipts, diff --git a/src/components/KYCWall/BaseKYCWall.js b/src/components/KYCWall/BaseKYCWall.js index 2b1685b102df..e1579fd38e73 100644 --- a/src/components/KYCWall/BaseKYCWall.js +++ b/src/components/KYCWall/BaseKYCWall.js @@ -159,8 +159,4 @@ export default withOnyx({ bankAccountList: { key: ONYXKEYS.BANK_ACCOUNT_LIST, }, - isLoadingPaymentMethods: { - key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS, - initWithStoredValues: false, - }, })(KYCWall); diff --git a/src/components/KYCWall/kycWallPropTypes.js b/src/components/KYCWall/kycWallPropTypes.js index 73e2b550b69f..4558d56ceb25 100644 --- a/src/components/KYCWall/kycWallPropTypes.js +++ b/src/components/KYCWall/kycWallPropTypes.js @@ -1,5 +1,7 @@ import PropTypes from 'prop-types'; import userWalletPropTypes from '../../pages/EnablePayments/userWalletPropTypes'; +import bankAccountPropTypes from '../bankAccountPropTypes'; +import cardPropTypes from '../cardPropTypes'; const propTypes = { /** Route for the Add Bank Account screen for a given navigation stack */ @@ -25,6 +27,12 @@ const propTypes = { /** When the button is opened via an IOU, ID for the chatReport that the IOU is linked to */ chatReportID: PropTypes.string, + + /** List of cards */ + cardList: PropTypes.objectOf(cardPropTypes), + + /** List of bank accounts */ + bankAccountList: PropTypes.objectOf(bankAccountPropTypes), }; const defaultProps = { @@ -33,6 +41,8 @@ const defaultProps = { shouldListenForResize: false, isDisabled: false, chatReportID: '', + bankAccountList: {}, + cardList: {}, }; export {propTypes, defaultProps}; diff --git a/src/components/KeyboardDismissingFlatList/index.js b/src/components/KeyboardDismissingFlatList/index.js new file mode 100644 index 000000000000..fca1a49ebf8d --- /dev/null +++ b/src/components/KeyboardDismissingFlatList/index.js @@ -0,0 +1,58 @@ +import React, {Component} from 'react'; +import {FlatList, Keyboard} from 'react-native'; +import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; + +class KeyboardDismissingFlatList extends Component { + constructor(props) { + super(props); + + this.touchStart = this.touchStart.bind(this); + this.touchEnd = this.touchEnd.bind(this); + } + + componentDidMount() { + if (!DeviceCapabilities.canUseTouchScreen()) { + return; + } + + // We're setting `isScreenTouched` in this listener only for web platforms with touchscreen (mWeb) where + // we want to dismiss the keyboard only when the list is scrolled by the user and not when it's scrolled programmatically. + document.addEventListener('touchstart', this.touchStart); + document.addEventListener('touchend', this.touchEnd); + } + + componentWillUnmount() { + if (!DeviceCapabilities.canUseTouchScreen()) { + return; + } + + document.removeEventListener('touchstart', this.touchStart); + document.removeEventListener('touchend', this.touchEnd); + } + + touchStart() { + this.isScreenTouched = true; + } + + touchEnd() { + this.isScreenTouched = false; + } + + render() { + return ( + { + // Only dismiss the keyboard whenever the user scrolls the screen + if (!this.isScreenTouched) { + return; + } + Keyboard.dismiss(); + }} + /> + ); + } +} + +export default KeyboardDismissingFlatList; diff --git a/src/components/KeyboardDismissingFlatList/index.native.js b/src/components/KeyboardDismissingFlatList/index.native.js new file mode 100644 index 000000000000..7f4735c93216 --- /dev/null +++ b/src/components/KeyboardDismissingFlatList/index.native.js @@ -0,0 +1,14 @@ +import React from 'react'; +import {FlatList, Keyboard} from 'react-native'; + +const KeyboardDismissingFlatList = props => ( + Keyboard.dismiss()} + /> +); + +KeyboardDismissingFlatList.displayName = 'KeyboardDismissingFlatList'; + +export default KeyboardDismissingFlatList; diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js index c57d51d29eb2..a07b95a3675f 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.js @@ -6,6 +6,7 @@ import { View, StyleSheet, } from 'react-native'; +import * as optionRowStyles from '../../styles/optionRowStyles'; import styles from '../../styles/styles'; import * as StyleUtils from '../../styles/StyleUtils'; import Icon from '../Icon'; @@ -67,15 +68,15 @@ const OptionRowLHN = (props) => { : styles.sidebarLinkText; const textUnreadStyle = optionItem.isUnread ? [textStyle, styles.sidebarLinkTextBold] : [textStyle]; - const displayNameStyle = StyleUtils.combineStyles([styles.optionDisplayName, styles.optionDisplayNameCompact, ...textUnreadStyle], props.style); + const displayNameStyle = StyleUtils.combineStyles([styles.optionDisplayName, styles.optionDisplayNameCompact, styles.pre, ...textUnreadStyle], props.style); const textPillStyle = props.isFocused ? [styles.ml1, StyleUtils.getBackgroundColorWithOpacityStyle(themeColors.icon, 0.5)] : [styles.ml1]; const alternateTextStyle = StyleUtils.combineStyles(props.viewMode === CONST.OPTION_MODE.COMPACT - ? [textStyle, styles.optionAlternateText, styles.textLabelSupporting, styles.optionAlternateTextCompact, styles.ml2] - : [textStyle, styles.optionAlternateText, styles.textLabelSupporting], props.style); + ? [textStyle, styles.optionAlternateText, styles.pre, styles.textLabelSupporting, styles.optionAlternateTextCompact, styles.ml2] + : [textStyle, styles.optionAlternateText, styles.pre, styles.textLabelSupporting], props.style); const contentContainerStyles = props.viewMode === CONST.OPTION_MODE.COMPACT - ? [styles.flex1, styles.flexRow, styles.overflowHidden, styles.alignItemsCenter] + ? [styles.flex1, styles.flexRow, styles.overflowHidden, optionRowStyles.compactContentContainerStyles] : [styles.flex1]; const sidebarInnerRowStyle = StyleSheet.flatten(props.viewMode === CONST.OPTION_MODE.COMPACT ? [ styles.chatLinkRowPressable, diff --git a/src/components/LHNSkeletonView.js b/src/components/LHNSkeletonView.js new file mode 100644 index 000000000000..efa7a629fe82 --- /dev/null +++ b/src/components/LHNSkeletonView.js @@ -0,0 +1,97 @@ +import React from 'react'; +import {View} from 'react-native'; +import PropTypes from 'prop-types'; +import {Rect, Circle} from 'react-native-svg'; +import SkeletonViewContentLoader from 'react-content-loader/native'; +import CONST from '../CONST'; +import themeColors from '../styles/themes/default'; +import styles from '../styles/styles'; + +const propTypes = { + /** Whether to animate the skeleton view */ + shouldAnimate: PropTypes.bool, +}; + +const defaultTypes = { + shouldAnimate: true, +}; + +class LHNSkeletonView extends React.Component { + constructor(props) { + super(props); + this.state = { + skeletonViewItems: [], + }; + } + + /** + * Generate the skeleton view items. + * + * @param {Number} numItems + */ + generateSkeletonViewItems(numItems) { + if (this.state.skeletonViewItems.length === numItems) { + return; + } + + if (this.state.skeletonViewItems.length > numItems) { + this.setState(prevState => ({ + skeletonViewItems: prevState.skeletonViewItems.slice(0, numItems), + })); + return; + } + + const skeletonViewItems = []; + for (let i = this.state.skeletonViewItems.length; i < numItems; i++) { + const step = i % 3; + let lineWidth; + switch (step) { + case 0: + lineWidth = '100%'; + break; + case 1: + lineWidth = '50%'; + break; + default: + lineWidth = '25%'; + } + skeletonViewItems.push( + + + + + , + ); + } + + this.setState(prevState => ({ + skeletonViewItems: [...prevState.skeletonViewItems, ...skeletonViewItems], + })); + } + + render() { + return ( + { + const numItems = Math.ceil(event.nativeEvent.layout.height / CONST.LHN_SKELETON_VIEW_ITEM_HEIGHT); + this.generateSkeletonViewItems(numItems); + }} + > + {this.state.skeletonViewItems} + + ); + } +} + +LHNSkeletonView.propTypes = propTypes; +LHNSkeletonView.defaultProps = defaultTypes; + +export default LHNSkeletonView; diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js index 33563bb029be..2942f250ce83 100644 --- a/src/components/MenuItem.js +++ b/src/components/MenuItem.js @@ -58,6 +58,7 @@ const MenuItem = (props) => { (props.icon ? styles.ml3 : undefined), (props.shouldShowBasicTitle ? undefined : styles.textStrong), (props.interactive && props.disabled ? {...styles.disabledText, ...styles.userSelectNone} : undefined), + styles.pre, ], props.style); const descriptionTextStyle = StyleUtils.combineStyles([ styles.textLabelSupporting, diff --git a/src/components/Modal/BaseModal.js b/src/components/Modal/BaseModal.js index a4b7ed90a8fe..8c14ee72884a 100644 --- a/src/components/Modal/BaseModal.js +++ b/src/components/Modal/BaseModal.js @@ -139,7 +139,7 @@ class BaseModal extends PureComponent { paddingBottom: safeAreaPaddingBottom, paddingLeft: safeAreaPaddingLeft, paddingRight: safeAreaPaddingRight, - } = StyleUtils.getSafeAreaPadding(insets); + } = StyleUtils.getSafeAreaPadding(insets, this.props.statusBarTranslucent); const modalPaddingStyles = StyleUtils.getModalPaddingStyles({ safeAreaPaddingTop, diff --git a/src/components/NewDatePicker/datePickerPropTypes.js b/src/components/NewDatePicker/datePickerPropTypes.js new file mode 100644 index 000000000000..0c3906e3c6ff --- /dev/null +++ b/src/components/NewDatePicker/datePickerPropTypes.js @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import moment from 'moment'; +import { + propTypes as baseTextInputPropTypes, + defaultProps as defaultBaseTextInputPropTypes, +} from '../TextInput/baseTextInputPropTypes'; +import CONST from '../../CONST'; + +const propTypes = { + ...baseTextInputPropTypes, + + /** + * The datepicker supports any value that `moment` can parse. + * `onInputChange` would always be called with a Date (or null) + */ + value: PropTypes.oneOfType([PropTypes.instanceOf(Date), PropTypes.string]), + + /** + * The datepicker supports any defaultValue that `moment` can parse. + * `onInputChange` would always be called with a Date (or null) + */ + defaultValue: PropTypes.oneOfType([PropTypes.instanceOf(Date), PropTypes.string]), + + /** A minimum date of calendar to select */ + minDate: PropTypes.objectOf(Date), + + /** A maximum date of calendar to select */ + maxDate: PropTypes.objectOf(Date), + + /** Default year to be set in the calendar picker */ + selectedYear: PropTypes.string, + + /** A function called when picked is closed */ + onHidePicker: PropTypes.func, +}; + +const defaultProps = { + ...defaultBaseTextInputPropTypes, + minDate: moment().year(CONST.CALENDAR_PICKER.MIN_YEAR).toDate(), + maxDate: moment().year(CONST.CALENDAR_PICKER.MAX_YEAR).toDate(), + value: undefined, + onHidePicker: () => {}, +}; + +export {propTypes, defaultProps}; diff --git a/src/components/NewDatePicker/index.js b/src/components/NewDatePicker/index.js new file mode 100644 index 000000000000..c5df338f4206 --- /dev/null +++ b/src/components/NewDatePicker/index.js @@ -0,0 +1,168 @@ +import React from 'react'; +import {View, Animated} from 'react-native'; +import moment from 'moment'; +import _ from 'underscore'; +import TextInput from '../TextInput'; +import CalendarPicker from '../CalendarPicker'; +import CONST from '../../CONST'; +import styles from '../../styles/styles'; +import * as Expensicons from '../Icon/Expensicons'; +import {propTypes as datePickerPropTypes, defaultProps as defaultDatePickerProps} from './datePickerPropTypes'; +import KeyboardShortcut from '../../libs/KeyboardShortcut'; + +const propTypes = { + ...datePickerPropTypes, +}; + +const datePickerDefaultProps = { + ...defaultDatePickerProps, +}; + +class NewDatePicker extends React.Component { + constructor(props) { + super(props); + + this.state = { + isPickerVisible: false, + selectedDate: moment(props.value || props.defaultValue || undefined).toDate(), + }; + + this.setDate = this.setDate.bind(this); + this.showPicker = this.showPicker.bind(this); + this.hidePicker = this.hidePicker.bind(this); + + this.opacity = new Animated.Value(0); + + // We're using uncontrolled input otherwise it wont be possible to + // raise change events with a date value - each change will produce a date + // and make us reset the text input + this.defaultValue = props.defaultValue + ? moment(props.defaultValue).format(CONST.DATE.MOMENT_FORMAT_STRING) + : ''; + } + + componentDidMount() { + const shortcutConfig = CONST.KEYBOARD_SHORTCUTS.ESCAPE; + this.unsubscribeEscapeKey = KeyboardShortcut.subscribe(shortcutConfig.shortcutKey, () => { + if (!this.state.isPickerVisible) { + return; + } + this.hidePicker(); + this.textInputRef.blur(); + }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true, () => !this.state.isPickerVisible); + } + + componentWillUnmount() { + if (!this.unsubscribeEscapeKey) { + return; + } + this.unsubscribeEscapeKey(); + } + + /** + * Trigger the `onInputChange` handler when the user input has a complete date or is cleared + * @param {Date} selectedDate + */ + setDate(selectedDate) { + this.setState({selectedDate}, () => { + this.props.onInputChange(moment(selectedDate).format(CONST.DATE.MOMENT_FORMAT_STRING)); + this.hidePicker(); + this.textInputRef.blur(); + }); + } + + /** + * Function to animate showing the picker. + */ + showPicker() { + this.setState({isPickerVisible: true}, () => { + Animated.timing(this.opacity, { + toValue: 1, + duration: 100, + useNativeDriver: true, + }).start(); + }); + } + + /** + * Function to animate and hide the picker. + */ + hidePicker() { + Animated.timing(this.opacity, { + toValue: 0, + duration: 100, + useNativeDriver: true, + }).start((animationResult) => { + if (!animationResult.finished) { + return; + } + this.setState({isPickerVisible: false}, this.props.onHidePicker); + }); + } + + render() { + return ( + this.wrapperRef = ref} + onBlur={(event) => { + if (this.wrapperRef && event.relatedTarget && this.wrapperRef.contains(event.relatedTarget)) { + return; + } + this.hidePicker(); + }} + style={styles.datePickerRoot} + > + + { + this.textInputRef = el; + if (!_.isFunction(this.props.innerRef)) { + return; + } + this.props.innerRef(el); + }} + icon={Expensicons.Calendar} + onPress={this.showPicker} + label={this.props.label} + value={this.props.value || ''} + defaultValue={this.defaultValue} + placeholder={this.props.placeholder || CONST.DATE.MOMENT_FORMAT_STRING} + errorText={this.props.errorText} + containerStyles={this.props.containerStyles} + textInputContainerStyles={this.state.isPickerVisible ? [styles.borderColorFocus] : []} + disabled={this.props.disabled} + editable={false} + /> + + { + this.state.isPickerVisible && ( + { + // To prevent focus stealing + e.preventDefault(); + }} + style={[styles.datePickerPopover, styles.border, {opacity: this.opacity}]} + > + + + ) + } + + ); + } +} + +NewDatePicker.propTypes = propTypes; +NewDatePicker.defaultProps = datePickerDefaultProps; + +export default React.forwardRef((props, ref) => ( + /* eslint-disable-next-line react/jsx-props-no-spreading */ + +)); diff --git a/src/components/OfflineWithFeedback.js b/src/components/OfflineWithFeedback.js index 64ce8b62170e..a4919f2cb406 100644 --- a/src/components/OfflineWithFeedback.js +++ b/src/components/OfflineWithFeedback.js @@ -13,6 +13,7 @@ import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; import * as StyleUtils from '../styles/StyleUtils'; import DotIndicatorMessage from './DotIndicatorMessage'; +import shouldRenderOffscreen from '../libs/shouldRenderOffscreen'; /** * This component should be used when we are using the offline pattern B (offline with feedback). @@ -97,7 +98,14 @@ const OfflineWithFeedback = (props) => { return ( {!hideChildren && ( - + {children} )} diff --git a/src/components/Onfido/BaseOnfidoWeb.js b/src/components/Onfido/BaseOnfidoWeb.js index 5901fa04a5cc..12eea80fa87c 100644 --- a/src/components/Onfido/BaseOnfidoWeb.js +++ b/src/components/Onfido/BaseOnfidoWeb.js @@ -22,6 +22,7 @@ class Onfido extends React.Component { this.onfidoOut = OnfidoSDK.init({ token: this.props.sdkToken, containerId: CONST.ONFIDO.CONTAINER_ID, + useMemoryHistory: true, customUI: { fontFamilyTitle: `${fontFamily.EXP_NEUE}, -apple-system, serif`, fontFamilySubtitle: `${fontFamily.EXP_NEUE}, -apple-system, serif`, diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js index 532ae783bdd6..d64569840f8d 100644 --- a/src/components/OptionRow.js +++ b/src/components/OptionRow.js @@ -118,8 +118,8 @@ class OptionRow extends Component { : styles.sidebarLinkText; const textUnreadStyle = (this.props.boldStyle || this.props.option.boldStyle) ? [textStyle, styles.sidebarLinkTextBold] : [textStyle]; - const displayNameStyle = StyleUtils.combineStyles(styles.optionDisplayName, textUnreadStyle, this.props.style); - const alternateTextStyle = StyleUtils.combineStyles(textStyle, styles.optionAlternateText, styles.textLabelSupporting, this.props.style); + const displayNameStyle = StyleUtils.combineStyles(styles.optionDisplayName, textUnreadStyle, this.props.style, styles.pre); + const alternateTextStyle = StyleUtils.combineStyles(textStyle, styles.optionAlternateText, styles.textLabelSupporting, this.props.style, styles.pre); const contentContainerStyles = [styles.flex1]; const sidebarInnerRowStyle = StyleSheet.flatten([ styles.chatLinkRowPressable, diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 075749f847e2..66b613a1ce33 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -275,6 +275,8 @@ class BaseOptionsSelector extends Component { label={this.props.textInputLabel} onChangeText={this.props.onChangeText} placeholder={this.props.placeholderText} + maxLength={this.props.maxLength} + keyboardType={this.props.keyboardType} onBlur={(e) => { if (!this.props.shouldFocusOnSelectRow) { return; diff --git a/src/components/OptionsSelector/optionsSelectorPropTypes.js b/src/components/OptionsSelector/optionsSelectorPropTypes.js index 8527afd16a03..851b95f05c6e 100644 --- a/src/components/OptionsSelector/optionsSelectorPropTypes.js +++ b/src/components/OptionsSelector/optionsSelectorPropTypes.js @@ -30,9 +30,15 @@ const propTypes = { /** Callback fired when text changes */ onChangeText: PropTypes.func.isRequired, + /** Limits the maximum number of characters that can be entered in input field */ + maxLength: PropTypes.number, + /** Label to display for the text input */ textInputLabel: PropTypes.string, + /** Optional keyboard type for the input */ + keyboardType: PropTypes.string, + /** Optional placeholder text for the selector */ placeholderText: PropTypes.string, @@ -98,6 +104,7 @@ const defaultProps = { onSelectRow: () => {}, textInputLabel: '', placeholderText: '', + keyboardType: 'default', selectedOptions: [], headerMessage: '', canSelectMultipleOptions: false, @@ -117,6 +124,7 @@ const defaultProps = { isDisabled: false, shouldHaveOptionSeparator: false, initiallyFocusedOptionKey: undefined, + maxLength: undefined, }; export {propTypes, defaultProps}; diff --git a/src/components/Picker.js b/src/components/Picker/Picker.js similarity index 82% rename from src/components/Picker.js rename to src/components/Picker/Picker.js index 797f361cdf02..98aba4e630c3 100644 --- a/src/components/Picker.js +++ b/src/components/Picker/Picker.js @@ -3,13 +3,13 @@ import React, {PureComponent} from 'react'; import {View} from 'react-native'; import PropTypes from 'prop-types'; import RNPickerSelect from 'react-native-picker-select'; -import Icon from './Icon'; -import * as Expensicons from './Icon/Expensicons'; -import FormHelpMessage from './FormHelpMessage'; -import Text from './Text'; -import styles from '../styles/styles'; -import themeColors from '../styles/themes/default'; -import {ScrollContext} from './ScrollViewWithContext'; +import Icon from '../Icon'; +import * as Expensicons from '../Icon/Expensicons'; +import FormHelpMessage from '../FormHelpMessage'; +import Text from '../Text'; +import styles from '../../styles/styles'; +import themeColors from '../../styles/themes/default'; +import {ScrollContext} from '../ScrollViewWithContext'; const propTypes = { /** Picker label */ @@ -75,6 +75,9 @@ const propTypes = { current: PropTypes.element, }), ]), + + /** Additional events passed to the core Picker for specific platforms such as web */ + additionalPickerEvents: PropTypes.func, }; const defaultProps = { @@ -97,16 +100,19 @@ const defaultProps = { ), onBlur: () => {}, innerRef: () => {}, + additionalPickerEvents: () => {}, }; class Picker extends PureComponent { constructor(props) { super(props); this.state = { - isOpen: false, + isHighlighted: false, }; this.onInputChange = this.onInputChange.bind(this); + this.enableHighlight = this.enableHighlight.bind(this); + this.disableHighlight = this.disableHighlight.bind(this); // Windows will reuse the text color of the select for each one of the options // so we might need to color accordingly so it doesn't blend with the background. @@ -151,6 +157,18 @@ class Picker extends PureComponent { this.props.onInputChange(this.props.items[0].value, 0); } + enableHighlight() { + this.setState({ + isHighlighted: true, + }); + } + + disableHighlight() { + this.setState({ + isHighlighted: false, + }); + } + render() { const hasError = !_.isEmpty(this.props.errorText); @@ -161,7 +179,7 @@ class Picker extends PureComponent { styles.pickerContainer, this.props.isDisabled && styles.inputDisabled, ...this.props.containerStyles, - this.state.isOpen && styles.borderColorFocus, + this.state.isHighlighted && styles.borderColorFocus, hasError && styles.borderColorDanger, ]} > @@ -184,15 +202,22 @@ class Picker extends PureComponent { Icon={() => this.props.icon(this.props.size)} disabled={this.props.isDisabled} fixAndroidTouchableBug - onOpen={() => this.setState({isOpen: true})} - onClose={() => this.setState({isOpen: false})} + onOpen={this.enableHighlight} + onClose={this.disableHighlight} textInputProps={{allowFontScaling: false}} pickerProps={{ - onFocus: () => this.setState({isOpen: true}), + onFocus: this.enableHighlight, onBlur: () => { - this.setState({isOpen: false}); + this.disableHighlight(); this.props.onBlur(); }, + ...this.props.additionalPickerEvents( + this.enableHighlight, + (value, index) => { + this.onInputChange(value, index); + this.disableHighlight(); + }, + ), }} ref={(el) => { if (!_.isFunction(this.props.innerRef)) { diff --git a/src/components/Picker/index.js b/src/components/Picker/index.js new file mode 100644 index 000000000000..ffa7f4868c45 --- /dev/null +++ b/src/components/Picker/index.js @@ -0,0 +1,19 @@ +import React, {forwardRef} from 'react'; +import BasePicker from './Picker'; + +const additionalPickerEvents = (onMouseDown, onChange) => ({ + onMouseDown, + onChange: (e) => { + if (e.target.selectedIndex === undefined) { + return; + } + const index = e.target.selectedIndex; + const value = e.target.options[index].value; + onChange(value, index); + }, +}); + +export default forwardRef((props, ref) => ( + // eslint-disable-next-line react/jsx-props-no-spreading + +)); diff --git a/src/components/Picker/index.native.js b/src/components/Picker/index.native.js new file mode 100644 index 000000000000..107150e60d2e --- /dev/null +++ b/src/components/Picker/index.native.js @@ -0,0 +1,7 @@ +import React, {forwardRef} from 'react'; +import BasePicker from './Picker'; + +export default forwardRef((props, ref) => ( + // eslint-disable-next-line react/jsx-props-no-spreading + +)); diff --git a/src/components/Reactions/EmojiReactionBubble.js b/src/components/Reactions/EmojiReactionBubble.js index d9a77e5f74f2..fe7f90ed225d 100644 --- a/src/components/Reactions/EmojiReactionBubble.js +++ b/src/components/Reactions/EmojiReactionBubble.js @@ -35,7 +35,7 @@ const propTypes = { /** * The account ids of the users who reacted. */ - reactionUsers: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])), + reactionUsers: PropTypes.arrayOf(PropTypes.string), /** * The default size of the reaction bubble is defined @@ -60,9 +60,9 @@ const EmojiReactionBubble = (props) => { const hasUserReacted = Report.hasAccountIDReacted(props.currentUserPersonalDetails.accountID, props.reactionUsers); return ( [ + style={({hovered, pressed}) => [ styles.emojiReactionBubble, - StyleUtils.getEmojiReactionBubbleStyle(hovered, hasUserReacted, props.sizeScale), + StyleUtils.getEmojiReactionBubbleStyle(hovered || pressed, hasUserReacted, props.sizeScale), ]} onPress={props.onPress} onLongPress={props.onReactionListOpen} @@ -75,13 +75,13 @@ const EmojiReactionBubble = (props) => { {props.emojiCodes.join('')} {props.count > 0 && ( - - {props.count} - + + {props.count} + )} ); diff --git a/src/components/Reactions/MiniQuickEmojiReactions.js b/src/components/Reactions/MiniQuickEmojiReactions.js index 7c7f29f70a1d..b84974f3f86f 100644 --- a/src/components/Reactions/MiniQuickEmojiReactions.js +++ b/src/components/Reactions/MiniQuickEmojiReactions.js @@ -32,12 +32,13 @@ const propTypes = { onEmojiPickerClosed: PropTypes.func, ...withLocalizePropTypes, - preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), }; const defaultProps = { onEmojiPickerClosed: () => {}, + preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, }; /** diff --git a/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.js b/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.js index 6e561d4a5201..310ddc26affb 100644 --- a/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.js +++ b/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.js @@ -32,9 +32,19 @@ const baseQuickEmojiReactionsPropTypes = { onPressOpenPicker: PropTypes.func, }; +const baseQuickEmojiReactionsDefaultProps = { + onWillShowPicker: () => {}, + onPressOpenPicker: () => {}, +}; + const propTypes = { ...baseQuickEmojiReactionsPropTypes, - preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), +}; + +const defaultProps = { + ...baseQuickEmojiReactionsDefaultProps, + preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, }; const BaseQuickEmojiReactions = props => ( @@ -44,7 +54,6 @@ const BaseQuickEmojiReactions = props => ( // Note: focus is handled by the Pressable component in EmojiReactionBubble { @@ -65,6 +74,7 @@ const BaseQuickEmojiReactions = props => ( BaseQuickEmojiReactions.displayName = 'BaseQuickEmojiReactions'; BaseQuickEmojiReactions.propTypes = propTypes; +BaseQuickEmojiReactions.defaultProps = defaultProps; export default withOnyx({ preferredSkinTone: { key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, diff --git a/src/components/Reactions/ReactionTooltipContent.js b/src/components/Reactions/ReactionTooltipContent.js new file mode 100644 index 000000000000..5a190eee863b --- /dev/null +++ b/src/components/Reactions/ReactionTooltipContent.js @@ -0,0 +1,77 @@ +import React from 'react'; +import {View} from 'react-native'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import styles from '../../styles/styles'; +import {withPersonalDetails} from '../OnyxProvider'; +import * as PersonalDetailsUtils from '../../libs/PersonalDetailsUtils'; +import Text from '../Text'; +import withCurrentUserPersonalDetails, { + withCurrentUserPersonalDetailsPropTypes, +} from '../withCurrentUserPersonalDetails'; +import compose from '../../libs/compose'; +import withLocalize from '../withLocalize'; + +const propTypes = { + /** + * A list of emoji codes to display in the tooltip. + */ + emojiCodes: PropTypes.arrayOf(PropTypes.string).isRequired, + + /** + * The name of the emoji to display in the tooltip. + */ + emojiName: PropTypes.string.isRequired, + + /** + * A list of account IDs to display in the tooltip. + */ + accountIDs: PropTypes.arrayOf(PropTypes.string).isRequired, + + ...withCurrentUserPersonalDetailsPropTypes, +}; + +const ReactionTooltipContent = (props) => { + const users = PersonalDetailsUtils.getPersonalDetailsByIDs(props.accountIDs, true); + const namesString = _.filter(_.map(users, user => user && user.displayName), n => n).join(', '); + + return ( + + + {_.map(props.emojiCodes, emojiCode => ( + + {emojiCode} + + ))} + + + + {namesString} + + + + {`reacted with :${props.emojiName}:`} + + + ); +}; + +ReactionTooltipContent.propTypes = propTypes; +ReactionTooltipContent.defaultProps = withCurrentUserPersonalDetails; +ReactionTooltipContent.displayName = 'ReactionTooltipContent'; +export default React.memo(compose( + withPersonalDetails(), + withLocalize, +)(ReactionTooltipContent)); diff --git a/src/components/Reactions/ReportActionItemReactions.js b/src/components/Reactions/ReportActionItemReactions.js index bfc67e95c1df..4ab90bee33ad 100644 --- a/src/components/Reactions/ReportActionItemReactions.js +++ b/src/components/Reactions/ReportActionItemReactions.js @@ -7,6 +7,8 @@ import EmojiReactionBubble from './EmojiReactionBubble'; import emojis from '../../../assets/emojis'; import AddReactionBubble from './AddReactionBubble'; import getPreferredEmojiCode from './getPreferredEmojiCode'; +import Tooltip from '../Tooltip'; +import ReactionTooltipContent from './ReactionTooltipContent'; /** * Given an emoji object and a list of senders it will return an @@ -59,11 +61,7 @@ const ReportActionItemReactions = (props) => { {_.map(reactionsWithCount, (reaction) => { const reactionCount = reaction.users.length; - if (reactionCount === 0) { - return null; - } - - const reactionUsers = _.map(reaction.users, sender => sender.accountID); + const reactionUsers = _.map(reaction.users, sender => sender.accountID.toString()); const emoji = _.find(emojis, e => e.name === reaction.emoji); const emojiCodes = getUniqueEmojiCodes(emoji, reaction.users); @@ -72,14 +70,23 @@ const ReportActionItemReactions = (props) => { }; return ( - ( + + )} key={reaction.emoji} - count={reactionCount} - emojiName={reaction.emoji} - emojiCodes={emojiCodes} - onPress={onPress} - reactionUsers={reactionUsers} - /> + > + + ); })} {reactionsWithCount.length > 0 && } diff --git a/src/components/ReportActionItem/IOUPreview.js b/src/components/ReportActionItem/IOUPreview.js index 5bced4225915..2afb7e7fc1df 100644 --- a/src/components/ReportActionItem/IOUPreview.js +++ b/src/components/ReportActionItem/IOUPreview.js @@ -87,13 +87,13 @@ const propTypes = { /** This is either the user's full name, or their login if full name is an empty string */ displayName: PropTypes.string.isRequired, - })).isRequired, + })), /** Session info for the currently logged in user. */ session: PropTypes.shape({ /** Currently logged in user email */ email: PropTypes.string, - }).isRequired, + }), /** Information about the user accepting the terms for payments */ walletTerms: walletTermsPropTypes, @@ -116,6 +116,10 @@ const defaultProps = { walletTerms: {}, pendingAction: null, isHovered: false, + personalDetails: {}, + session: { + email: null, + }, }; const IOUPreview = (props) => { @@ -176,7 +180,7 @@ const IOUPreview = (props) => { onPressOut={() => ControlSelection.unblock()} onLongPress={showContextMenu} > - + { Report.clearIOUError(props.chatReportID); }} errorRowStyles={[styles.mbn1]} + needsOffscreenAlphaCompositing > - + diff --git a/src/components/ReportActionsSkeletonView/SkeletonViewLines.js b/src/components/ReportActionsSkeletonView/SkeletonViewLines.js index 30e96d5784dd..464bdffa6f53 100644 --- a/src/components/ReportActionsSkeletonView/SkeletonViewLines.js +++ b/src/components/ReportActionsSkeletonView/SkeletonViewLines.js @@ -9,16 +9,16 @@ import styles from '../../styles/styles'; const propTypes = { /** Number of rows to show in Skeleton UI block */ numberOfRows: PropTypes.number.isRequired, - animate: PropTypes.bool, + shouldAnimate: PropTypes.bool, }; const defaultTypes = { - animate: true, + shouldAnimate: true, }; const SkeletonViewLines = props => ( { @@ -23,13 +23,13 @@ const ReportActionsSkeletonView = (props) => { const iconIndex = (index + 1) % 4; switch (iconIndex) { case 2: - skeletonViewLines.push(); + skeletonViewLines.push(); break; case 0: - skeletonViewLines.push(); + skeletonViewLines.push(); break; default: - skeletonViewLines.push(); + skeletonViewLines.push(); } } return <>{skeletonViewLines}; diff --git a/src/components/ReportHeaderSkeletonView.js b/src/components/ReportHeaderSkeletonView.js index 6196fca054fd..040d883ca0b5 100644 --- a/src/components/ReportHeaderSkeletonView.js +++ b/src/components/ReportHeaderSkeletonView.js @@ -12,24 +12,26 @@ import themeColors from '../styles/themes/default'; const propTypes = { ...windowDimensionsPropTypes, - animate: PropTypes.bool, + shouldAnimate: PropTypes.bool, }; const defaultProps = { - animate: true, + shouldAnimate: true, }; const ReportHeaderSkeletonView = props => ( - {}} - style={[styles.LHNToggle]} - > - - + {props.isSmallScreenWidth && ( + {}} + style={[styles.LHNToggle]} + > + + + )} { diff --git a/src/components/SettlementButton.js b/src/components/SettlementButton.js index 5fdfb437802f..d9e0db3b2e61 100644 --- a/src/components/SettlementButton.js +++ b/src/components/SettlementButton.js @@ -30,6 +30,9 @@ const propTypes = { /** When the button is opened via an IOU, ID for the chatReport that the IOU is linked to */ chatReportID: PropTypes.string, + /** List of betas available to current user */ + betas: PropTypes.arrayOf(PropTypes.string), + ...withLocalizePropTypes, }; @@ -37,6 +40,7 @@ const defaultProps = { currency: CONST.CURRENCY.USD, shouldShowPaypal: false, chatReportID: '', + betas: [], }; class SettlementButton extends React.Component { diff --git a/src/components/TestToolMenu.js b/src/components/TestToolMenu.js index a5e61942f240..c1512a805bf6 100644 --- a/src/components/TestToolMenu.js +++ b/src/components/TestToolMenu.js @@ -1,6 +1,5 @@ import React from 'react'; import PropTypes from 'prop-types'; -import _ from 'underscore'; import {withOnyx} from 'react-native-onyx'; import lodashGet from 'lodash/get'; import styles from '../styles/styles'; @@ -15,8 +14,7 @@ import TestToolRow from './TestToolRow'; import networkPropTypes from './networkPropTypes'; import compose from '../libs/compose'; import {withNetwork} from './OnyxProvider'; -import getPlatform from '../libs/getPlatform'; -import CONST from '../CONST'; +import * as ApiUtils from '../libs/ApiUtils'; const propTypes = { /** User object in Onyx */ @@ -31,7 +29,9 @@ const propTypes = { const defaultProps = { user: { - shouldUseStagingServer: false, + // The default value is environment specific and can't be set with `defaultProps` (ENV is not resolved yet) + // When undefined (during render) STAGING defaults to `true`, other envs default to `false` + shouldUseStagingServer: undefined, }, }; @@ -41,12 +41,14 @@ const TestToolMenu = props => ( Test Preferences - {/* Option to switch from using the staging secure endpoint or the production secure endpoint. + {/* Option to switch between staging and default api endpoints. This enables QA and internal testers to take advantage of sandbox environments for 3rd party services like Plaid and Onfido. */} User.setShouldUseStagingServer(!lodashGet(props, 'user.shouldUseStagingServer', true))} + isOn={lodashGet(props, 'user.shouldUseStagingServer', ApiUtils.isUsingStagingApi())} + onToggle={() => User.setShouldUseStagingServer( + !lodashGet(props, 'user.shouldUseStagingServer', ApiUtils.isUsingStagingApi()), + )} /> diff --git a/src/components/TextInput/BaseTextInput.js b/src/components/TextInput/BaseTextInput.js index 74f7700ec7a6..d85aea8a71a0 100644 --- a/src/components/TextInput/BaseTextInput.js +++ b/src/components/TextInput/BaseTextInput.js @@ -318,7 +318,7 @@ class BaseTextInput extends Component { /> {this.props.secureTextEntry && ( e.preventDefault()} > @@ -328,6 +328,14 @@ class BaseTextInput extends Component { /> )} + {!this.props.secureTextEntry && this.props.icon && ( + + + + )} diff --git a/src/components/TextInput/baseTextInputPropTypes.js b/src/components/TextInput/baseTextInputPropTypes.js index 5c52d1e17b8f..3fe09cfc87e4 100644 --- a/src/components/TextInput/baseTextInputPropTypes.js +++ b/src/components/TextInput/baseTextInputPropTypes.js @@ -19,6 +19,9 @@ const propTypes = { /** Error text to display */ errorText: PropTypes.string, + /** Icon to display in right side of text input */ + icon: PropTypes.func, + /** Customize the TextInput container */ textInputContainerStyles: PropTypes.arrayOf(PropTypes.object), @@ -105,6 +108,7 @@ const defaultProps = { onInputChange: () => {}, shouldDelayFocus: false, submitOnEnter: false, + icon: null, }; export {propTypes, defaultProps}; diff --git a/src/components/ValidateCode/AbracadabraModal.js b/src/components/ValidateCode/AbracadabraModal.js new file mode 100644 index 000000000000..fd54fdd7db7b --- /dev/null +++ b/src/components/ValidateCode/AbracadabraModal.js @@ -0,0 +1,51 @@ +import React, {PureComponent} from 'react'; +import {View} from 'react-native'; +import colors from '../../styles/colors'; +import styles from '../../styles/styles'; +import Icon from '../Icon'; +import withLocalize, {withLocalizePropTypes} from '../withLocalize'; +import Text from '../Text'; +import * as Expensicons from '../Icon/Expensicons'; +import * as Illustrations from '../Icon/Illustrations'; +import variables from '../../styles/variables'; + +const propTypes = { + ...withLocalizePropTypes, +}; + +class AbracadabraModal extends PureComponent { + render() { + return ( + + + + + + + {this.props.translate('validateCodeModal.successfulSignInTitle')} + + + + {this.props.translate('validateCodeModal.successfulSignInDescription')} + + + + + + + + ); + } +} + +AbracadabraModal.propTypes = propTypes; +export default withLocalize(AbracadabraModal); diff --git a/src/components/ValidateCode/ExpiredValidateCodeModal.js b/src/components/ValidateCode/ExpiredValidateCodeModal.js new file mode 100644 index 000000000000..d1d9f4fa530a --- /dev/null +++ b/src/components/ValidateCode/ExpiredValidateCodeModal.js @@ -0,0 +1,134 @@ +import React, {PureComponent} from 'react'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import _, {compose} from 'underscore'; +import lodashGet from 'lodash/get'; +import {View} from 'react-native'; +import colors from '../../styles/colors'; +import styles from '../../styles/styles'; +import Icon from '../Icon'; +import withLocalize, {withLocalizePropTypes} from '../withLocalize'; +import Text from '../Text'; +import * as Expensicons from '../Icon/Expensicons'; +import * as Illustrations from '../Icon/Illustrations'; +import variables from '../../styles/variables'; +import TextLink from '../TextLink'; +import ONYXKEYS from '../../ONYXKEYS'; +import * as ErrorUtils from '../../libs/ErrorUtils'; +import * as Session from '../../libs/actions/Session'; + +const propTypes = { + /** The details about the account that the user is signing in with */ + account: PropTypes.shape({ + /** An error message to display to the user */ + errors: PropTypes.objectOf(PropTypes.string), + + /** The message to be displayed when code requested */ + message: PropTypes.string, + }), + + /** The credentials of the logged in person */ + credentials: PropTypes.shape({ + /** The email the user logged in with */ + login: PropTypes.string, + }), + + ...withLocalizePropTypes, +}; + +const defaultProps = { + account: {}, + credentials: {}, +}; + +class ExpiredValidateCodeModal extends PureComponent { + constructor(props) { + super(props); + + this.requestNewCode = this.requestNewCode.bind(this); + } + + shouldShowRequestCodeLink() { + return Boolean(lodashGet(this.props, 'credentials.login', null)); + } + + requestNewCode() { + Session.resendValidateCode(); + } + + render() { + const codeRequestedMessage = lodashGet(this.props, 'account.message', null); + const accountErrors = lodashGet(this.props, 'account.errors', {}); + let codeRequestedErrors; + if (_.keys(accountErrors).length > 1) { + codeRequestedErrors = ErrorUtils.getLatestErrorMessage(this.props.account); + } + return ( + + + + + + + {this.props.translate('validateCodeModal.expiredCodeTitle')} + + + + {this.props.translate('validateCodeModal.expiredCodeDescription')} + {this.shouldShowRequestCodeLink() && !codeRequestedMessage + && ( + <> +
+ {this.props.translate('validateCodeModal.requestNewCode')} + {' '} + + {this.props.translate('validateCodeModal.requestNewCodeLink')} + + ! + + )} +
+ {this.shouldShowRequestCodeLink() && codeRequestedErrors + && ( + +
+
+ {codeRequestedErrors} +
+ )} + {this.shouldShowRequestCodeLink() && codeRequestedMessage + && ( + +
+
+ {codeRequestedMessage} +
+ )} +
+
+ + + +
+ ); + } +} + +ExpiredValidateCodeModal.propTypes = propTypes; +ExpiredValidateCodeModal.defaultProps = defaultProps; +export default compose( + withLocalize, + withOnyx({ + account: {key: ONYXKEYS.ACCOUNT}, + credentials: {key: ONYXKEYS.CREDENTIALS}, + }), +)(ExpiredValidateCodeModal); diff --git a/src/components/ValidateCode/TfaRequiredModal.js b/src/components/ValidateCode/TfaRequiredModal.js new file mode 100644 index 000000000000..5ac7802edc7a --- /dev/null +++ b/src/components/ValidateCode/TfaRequiredModal.js @@ -0,0 +1,51 @@ +import React, {PureComponent} from 'react'; +import {View} from 'react-native'; +import colors from '../../styles/colors'; +import styles from '../../styles/styles'; +import Icon from '../Icon'; +import withLocalize, {withLocalizePropTypes} from '../withLocalize'; +import Text from '../Text'; +import * as Expensicons from '../Icon/Expensicons'; +import * as Illustrations from '../Icon/Illustrations'; +import variables from '../../styles/variables'; + +const propTypes = { + ...withLocalizePropTypes, +}; + +class TfaRequiredModal extends PureComponent { + render() { + return ( + + + + + + + {this.props.translate('validateCodeModal.tfaRequiredTitle')} + + + + {this.props.translate('validateCodeModal.tfaRequiredDescription')} + + + + + + + + ); + } +} + +TfaRequiredModal.propTypes = propTypes; +export default withLocalize(TfaRequiredModal); diff --git a/src/components/ValidateCodeModal.js b/src/components/ValidateCode/ValidateCodeModal.js similarity index 52% rename from src/components/ValidateCodeModal.js rename to src/components/ValidateCode/ValidateCodeModal.js index 9bf52d2c4795..f7ca07c7c543 100644 --- a/src/components/ValidateCodeModal.js +++ b/src/components/ValidateCode/ValidateCodeModal.js @@ -1,40 +1,55 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; +import {compose} from 'underscore'; +import {withOnyx} from 'react-native-onyx'; +import lodashGet from 'lodash/get'; import {View} from 'react-native'; -import colors from '../styles/colors'; -import styles from '../styles/styles'; -import Icon from './Icon'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; -import Text from './Text'; -import * as Expensicons from './Icon/Expensicons'; -import * as Illustrations from './Icon/Illustrations'; -import variables from '../styles/variables'; -import TextLink from './TextLink'; +import colors from '../../styles/colors'; +import styles from '../../styles/styles'; +import Icon from '../Icon'; +import withLocalize, {withLocalizePropTypes} from '../withLocalize'; +import Text from '../Text'; +import * as Expensicons from '../Icon/Expensicons'; +import * as Illustrations from '../Icon/Illustrations'; +import variables from '../../styles/variables'; +import TextLink from '../TextLink'; +import ONYXKEYS from '../../ONYXKEYS'; +import * as Session from '../../libs/actions/Session'; const propTypes = { - /** Whether the user has been signed in with the link. */ - isSuccessfullySignedIn: PropTypes.bool, - /** Code to display. */ code: PropTypes.string.isRequired, - /** Whether the user can get signed straight in the App from the current page */ - shouldShowSignInHere: PropTypes.bool, + /** The ID of the account to which the code belongs. */ + accountID: PropTypes.string.isRequired, - /** Callback to be called when user clicks the Sign in here link */ - onSignInHereClick: PropTypes.func, + /** Session of currently logged in user */ + session: PropTypes.shape({ + /** Currently logged in user authToken */ + authToken: PropTypes.string, + }), ...withLocalizePropTypes, }; const defaultProps = { - isSuccessfullySignedIn: false, - shouldShowSignInHere: false, - onSignInHereClick: () => {}, + session: { + authToken: null, + }, }; class ValidateCodeModal extends PureComponent { + constructor(props) { + super(props); + + this.signInHere = this.signInHere.bind(this); + } + + signInHere() { + Session.signInWithValidateCode(this.props.accountID, this.props.code); + } + render() { return ( @@ -42,22 +57,22 @@ class ValidateCodeModal extends PureComponent { - {this.props.translate(this.props.isSuccessfullySignedIn ? 'validateCodeModal.successfulSignInTitle' : 'validateCodeModal.title')} + {this.props.translate('validateCodeModal.title')} - {this.props.translate(this.props.isSuccessfullySignedIn ? 'validateCodeModal.successfulSignInDescription' : 'validateCodeModal.description')} - {this.props.shouldShowSignInHere + {this.props.translate('validateCodeModal.description')} + {!lodashGet(this.props, 'session.authToken', null) && ( <> {this.props.translate('validateCodeModal.or')} {' '} - + {this.props.translate('validateCodeModal.signInHere')} @@ -65,13 +80,11 @@ class ValidateCodeModal extends PureComponent { {this.props.shouldShowSignInHere ? '!' : '.'} - {!this.props.isSuccessfullySignedIn && ( - - - {this.props.code} - - - )} + + + {this.props.code} + + { Provider.propTypes = propTypes; Provider.displayName = `${Str.UCFirst(onyxKeyName)}Provider`; + // eslint-disable-next-line rulesdir/onyx-props-must-have-default const ProviderWithOnyx = withOnyx({ [onyxKeyName]: { key: onyxKeyName, diff --git a/src/components/withCurrentUserPersonalDetails.js b/src/components/withCurrentUserPersonalDetails.js index 65b695fc06ce..bbe398a7c683 100644 --- a/src/components/withCurrentUserPersonalDetails.js +++ b/src/components/withCurrentUserPersonalDetails.js @@ -14,21 +14,7 @@ const withCurrentUserPersonalDetailsDefaultProps = { }; export default function (WrappedComponent) { - const WithCurrentUserPersonalDetails = (props) => { - const currentUserEmail = props.session.email; - - return ( - - ); - }; - - WithCurrentUserPersonalDetails.displayName = `WithCurrentUserPersonalDetails(${getComponentDisplayName(WrappedComponent)})`; - WithCurrentUserPersonalDetails.propTypes = { + const propTypes = { forwardedRef: PropTypes.oneOfType([ PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)}), @@ -42,8 +28,7 @@ export default function (WrappedComponent) { email: PropTypes.string, }), }; - - WithCurrentUserPersonalDetails.defaultProps = { + const defaultProps = { forwardedRef: undefined, personalDetails: {}, session: { @@ -51,6 +36,24 @@ export default function (WrappedComponent) { }, }; + const WithCurrentUserPersonalDetails = (props) => { + const currentUserEmail = props.session.email; + + return ( + + ); + }; + + WithCurrentUserPersonalDetails.displayName = `WithCurrentUserPersonalDetails(${getComponentDisplayName(WrappedComponent)})`; + WithCurrentUserPersonalDetails.propTypes = propTypes; + + WithCurrentUserPersonalDetails.defaultProps = defaultProps; + const withCurrentUserPersonalDetails = React.forwardRef((props, ref) => ( // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/src/languages/en.js b/src/languages/en.js index 83de58b70b24..765be98ed4dc 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -57,6 +57,8 @@ export default { here: 'here', date: 'Date', dob: 'Date of birth', + currentYear: 'Current year', + currentMonth: 'Current month', ssnLast4: 'Last 4 digits of SSN', ssnFull9: 'Full 9 digits of SSN', addressLine: ({lineNumber}) => `Address line ${lineNumber}`, @@ -115,6 +117,7 @@ export default { enterManually: 'Enter it manually', message: 'Message ', leaveRoom: 'Leave room', + you: 'You', your: 'your', conciergeHelp: 'Please reach out to Concierge for help.', maxParticipantsReached: ({count}) => `You've selected the maximum number (${count}) of participants.`, @@ -152,12 +155,6 @@ export default { updateApp: 'Update app', updatePrompt: 'A new version of this app is available.\nUpdate now or restart the app at a later time to download the latest changes.', }, - deeplinkWrapper: { - launching: 'Launching Expensify', - redirectedToDesktopApp: 'We\'ve redirected you to the desktop app.', - youCanAlso: 'You can also', - openLinkInBrowser: 'open this link in your browser', - }, validateCodeModal: { successfulSignInTitle: 'Abracadabra,\nyou are signed in!', successfulSignInDescription: 'Head back to your original tab to continue.', @@ -165,6 +162,13 @@ export default { description: 'Please enter the code using the device\nwhere it was originally requested', or: ', or', signInHere: 'just sign in here', + expiredCodeTitle: 'Magic code expired', + expiredCodeDescription: 'Go back to the original device and request a new code.', + requestNewCode: 'You can also', + requestNewCodeLink: 'request a new code here', + successfulNewCodeRequest: 'Code requested. Please check your device.', + tfaRequiredTitle: 'Two factor authentication\nrequired', + tfaRequiredDescription: 'Please enter the two-factor authentication code\nwhere you are trying to sign in.', }, iOUConfirmationList: { whoPaid: 'Who paid?', @@ -227,7 +231,7 @@ export default { editComment: 'Edit comment', deleteComment: 'Delete comment', deleteConfirmation: 'Are you sure you want to delete this comment?', - addReactionTooltip: 'Add Reaction', + addReactionTooltip: 'Add reaction', }, reportActionsView: { beginningOfArchivedRoomPartOne: 'You missed the party in ', @@ -621,6 +625,7 @@ export default { error: { dateShouldBeBefore: ({dateString}) => `Date should be before ${dateString}.`, dateShouldBeAfter: ({dateString}) => `Date should be after ${dateString}.`, + hasInvalidCharacter: 'Name can only include letters and numbers.', }, }, resendValidationForm: { @@ -635,6 +640,10 @@ export default { newChatPage: { createGroup: 'Create group', }, + yearPickerPage: { + year: 'Year', + selectYear: 'Please select a year', + }, notFound: { chatYouLookingForCannotBeFound: 'The chat you are looking for cannot be found.', getMeOutOfHere: 'Get me out of here', @@ -1074,7 +1083,7 @@ export default { publicDescription: 'Anyone can find this room', createRoom: 'Create room', roomAlreadyExistsError: 'A room with this name already exists', - roomNameReservedError: 'A room on this workspace already uses this name', + roomNameReservedError: ({reservedName}) => `${reservedName} is a default room on all workspaces. Please choose another name.`, roomNameInvalidError: 'Room names can only include lowercase letters, numbers and hyphens', pleaseEnterRoomName: 'Please enter a room name', pleaseSelectWorkspace: 'Please select a workspace', diff --git a/src/languages/es.js b/src/languages/es.js index d2305eb4ca2a..f7012c3d6654 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -56,6 +56,8 @@ export default { here: 'aquí', date: 'Fecha', dob: 'Fecha de Nacimiento', + currentYear: 'Año actual', + currentMonth: 'Mes actual', ssnLast4: 'Últimos 4 dígitos de su SSN', ssnFull9: 'Los 9 dígitos del SSN', addressLine: ({lineNumber}) => `Dirección línea ${lineNumber}`, @@ -114,6 +116,7 @@ export default { enterManually: 'Ingresar manualmente', message: 'Chatear con ', leaveRoom: 'Salir de la sala de chat', + you: 'Tú', your: 'tu', conciergeHelp: 'Por favor contacta con Concierge para obtener ayuda.', maxParticipantsReached: ({count}) => `Has seleccionado el número máximo (${count}) de participantes.`, @@ -151,19 +154,20 @@ export default { updateApp: 'Actualizar app', updatePrompt: 'Existe una nueva versión de esta aplicación.\nActualiza ahora or reinicia la aplicación más tarde para recibir la última versión.', }, - deeplinkWrapper: { - launching: 'Cargando Expensify', - redirectedToDesktopApp: 'Te hemos redirigido a la aplicación de escritorio.', - youCanAlso: 'También puedes', - openLinkInBrowser: 'abrir este enlace en tu navegador', - }, validateCodeModal: { successfulSignInTitle: 'Abracadabra,\n¡sesión iniciada!', successfulSignInDescription: 'Vuelve a la pestaña original para continuar.', title: 'Aquí está tu código mágico', or: ', ¡o', - description: 'Por favor, introduzca el código utilizando el dispositivo\nen el que se solicitó originalmente', + description: 'Por favor, introduce el código utilizando el dispositivo\nen el que se solicitó originalmente', signInHere: 'simplemente inicia sesión aquí', + expiredCodeTitle: 'Código mágico caducado', + expiredCodeDescription: 'Vuelve al dispositivo original y solicita un nuevo código.', + requestNewCode: '¡También puedes', + requestNewCodeLink: 'solicitar un nuevo código aquí', + successfulNewCodeRequest: 'Código solicitado. Por favor, comprueba su dispositivo.', + tfaRequiredTitle: 'Se requiere autenticación\nde dos factores', + tfaRequiredDescription: 'Por favor, introduce el código de autenticación de dos factores\ndonde estás intentando iniciar sesión.', }, iOUConfirmationList: { whoPaid: '¿Quién pago?', @@ -185,7 +189,7 @@ export default { hello: 'Hola', phoneCountryCode: '34', welcomeText: { - welcome: 'Con el Nuevo Expensify, chat y pagos son lo mismo.', + welcome: '¡Bienvenido al Nuevo Expensify! Por favor, introduce tu número de teléfono o email para continuar.', welcomeEnterMagicCode: ({login}) => `¡Siempre es genial ver una cara nueva por aquí! Por favor ingresa el código mágico enviado a ${login}`, phrase2: 'El dinero habla. Y ahora que chat y pagos están en un mismo lugar, es también fácil.', phrase3: 'Tus pagos llegan tan rápido como tus mensajes.', @@ -223,7 +227,7 @@ export default { copyURLToClipboard: 'Copiar URL al portapapeles', copyEmailToClipboard: 'Copiar email al portapapeles', markAsUnread: 'Marcar como no leído', - editComment: 'Editar commentario', + editComment: 'Editar comentario', deleteComment: 'Eliminar comentario', deleteConfirmation: '¿Estás seguro de que quieres eliminar este comentario?', addReactionTooltip: 'Añadir una reacción', @@ -467,7 +471,7 @@ export default { invalidName: 'Por favor ingresa un nombre válido', addressZipCode: 'Por favor ingresa un código postal válido', debitCardNumber: 'Ingresa un número de tarjeta de débito válido', - expirationDate: 'Por favor introduzca una fecha de vencimiento válida', + expirationDate: 'Por favor introduce una fecha de vencimiento válida', securityCode: 'Ingresa un código de seguridad válido', addressStreet: 'Ingresa una dirección de facturación válida que no sea un apartado postal', addressState: 'Por favor seleccione un estado', @@ -620,6 +624,7 @@ export default { error: { dateShouldBeBefore: ({dateString}) => `La fecha debe ser anterior a ${dateString}.`, dateShouldBeAfter: ({dateString}) => `La fecha debe ser posterior a ${dateString}.`, + hasInvalidCharacter: 'El nombre solo puede contener letras y números.', }, }, resendValidationForm: { @@ -634,6 +639,10 @@ export default { newChatPage: { createGroup: 'Crear grupo', }, + yearPickerPage: { + year: 'Año', + selectYear: 'Por favor seleccione un año', + }, notFound: { chatYouLookingForCannotBeFound: 'El chat que estás buscando no se pudo encontrar.', getMeOutOfHere: 'Sácame de aquí', @@ -687,7 +696,7 @@ export default { routingNumber: 'Ingresa un número de ruta válido', accountNumber: 'Ingresa un número de cuenta válido', companyType: 'Ingresa un tipo de compañía válido', - tooManyAttempts: 'Debido a la gran cantidad de intentos de inicio de sesión, esta opción se ha desactivado temporalmente durante 24 horas. Vuelve a intentarlo más tarde o introduzca los detalles manualmente.', + tooManyAttempts: 'Debido a la gran cantidad de intentos de inicio de sesión, esta opción se ha desactivado temporalmente durante 24 horas. Vuelve a intentarlo más tarde o introduce los detalles manualmente.', address: 'Ingresa una dirección válida', dob: 'Ingresa una fecha de nacimiento válida', age: 'Debe ser mayor de 18 años', @@ -1075,7 +1084,7 @@ export default { publicDescription: 'Cualquier persona puede unirse a esta sala', createRoom: 'Crea una sala de chat', roomAlreadyExistsError: 'Ya existe una sala con este nombre', - roomNameReservedError: 'Una sala en este espacio de trabajo ya usa este nombre', + roomNameReservedError: ({reservedName}) => `${reservedName} es el nombre una sala por defecto de todos los espacios de trabajo. Por favor elige otro nombre.`, roomNameInvalidError: 'Los nombres de las salas solo pueden contener minúsculas, números y guiones', pleaseEnterRoomName: 'Por favor, escribe el nombre de una sala', pleaseSelectWorkspace: 'Por favor, selecciona un espacio de trabajo', diff --git a/src/libs/ApiUtils.js b/src/libs/ApiUtils.js new file mode 100644 index 000000000000..7d7792480901 --- /dev/null +++ b/src/libs/ApiUtils.js @@ -0,0 +1,80 @@ +import lodashGet from 'lodash/get'; +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../ONYXKEYS'; +import CONFIG from '../CONFIG'; +import CONST from '../CONST'; +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 shouldUseStagingServer = false; +Environment.getEnvironment() + .then((envName) => { + ENV_NAME = envName; + + // We connect here, so we have the updated ENV_NAME when Onyx callback runs + Onyx.connect({ + key: ONYXKEYS.USER, + callback: (val) => { + // Toggling between APIs is not allowed on production + if (ENV_NAME === CONST.ENVIRONMENT.PRODUCTION) { + shouldUseStagingServer = false; + return; + } + + const defaultToggleState = ENV_NAME === CONST.ENVIRONMENT.STAGING; + shouldUseStagingServer = lodashGet(val, 'shouldUseStagingServer', defaultToggleState); + }, + }); + }); + +/** + * Get the currently used API endpoint + * (Non-production environments allow for dynamically switching the API) + * + * @param {Object} [request] + * @param {Boolean} [request.shouldUseSecure] + * @returns {String} + */ +function getApiRoot(request) { + const shouldUseSecure = lodashGet(request, 'shouldUseSecure', false); + + if (shouldUseStagingServer) { + return shouldUseSecure + ? CONFIG.EXPENSIFY.STAGING_SECURE_API_ROOT + : CONFIG.EXPENSIFY.STAGING_API_ROOT; + } + + return shouldUseSecure + ? CONFIG.EXPENSIFY.DEFAULT_SECURE_API_ROOT + : CONFIG.EXPENSIFY.DEFAULT_API_ROOT; +} + +/** + * Get the command url for the given request + * + * @param {Object} request + * @param {String} request.command - the name of the API command + * @param {Boolean} [request.shouldUseSecure] + * @returns {String} + */ +function getCommandURL(request) { + return `${getApiRoot(request)}api?command=${request.command}`; +} + +/** + * Check if we're currently using the staging API root + * + * @returns {Boolean} + */ +function isUsingStagingApi() { + return shouldUseStagingServer; +} + +export { + getApiRoot, + getCommandURL, + isUsingStagingApi, +}; + diff --git a/src/libs/ErrorUtils.js b/src/libs/ErrorUtils.js index e1ca7233ba8a..89d2029c7457 100644 --- a/src/libs/ErrorUtils.js +++ b/src/libs/ErrorUtils.js @@ -54,8 +54,28 @@ function getLatestErrorMessage(onyxData) { .value(); } +/** + * Method used to generate error message for given inputID + * @param {Object} errors - An object containing current errors in the form + * @param {String} inputID + * @param {String} message - Message to assign to the inputID errors + * + */ +function addErrorMessage(errors, inputID, message) { + const errorList = errors; + if (!message || !inputID) { + return; + } + if (_.isEmpty(errorList[inputID])) { + errorList[inputID] = message; + } else { + errorList[inputID] = `${errorList[inputID]}\n${message}`; + } +} + export { // eslint-disable-next-line import/prefer-default-export getAuthenticateErrorMessage, getLatestErrorMessage, + addErrorMessage, }; diff --git a/src/libs/GetStyledTextArray.js b/src/libs/GetStyledTextArray.js index 463bed6aba26..795c8dfc41d2 100644 --- a/src/libs/GetStyledTextArray.js +++ b/src/libs/GetStyledTextArray.js @@ -8,10 +8,11 @@ import Str from 'expensify-common/lib/str'; */ const getStyledTextArray = (name, prefix) => { const texts = []; - const prefixLocation = name.search(Str.escapeForRegExp(prefix)); + const prefixLowercase = prefix.toLowerCase(); + const prefixLocation = name.search(Str.escapeForRegExp(prefixLowercase)); if (prefixLocation === 0 && prefix.length === name.length) { - texts.push({text: prefix, isColored: true}); + texts.push({text: prefixLowercase, isColored: true}); } else if (prefixLocation === 0 && prefix.length !== name.length) { texts.push( {text: name.slice(0, prefix.length), isColored: true}, diff --git a/src/libs/HttpUtils.js b/src/libs/HttpUtils.js index a1cd16bcc4f6..d3322733e253 100644 --- a/src/libs/HttpUtils.js +++ b/src/libs/HttpUtils.js @@ -1,20 +1,9 @@ import Onyx from 'react-native-onyx'; -import lodashGet from 'lodash/get'; import _ from 'underscore'; -import CONFIG from '../CONFIG'; import CONST from '../CONST'; import ONYXKEYS from '../ONYXKEYS'; import HttpsError from './Errors/HttpsError'; -import shouldUseStagingServer from './shouldUseStagingServer'; -import getPlatform from './getPlatform'; - -// Desktop and web use staging config too so we we should default to staging API endpoint if on those platforms -const shouldDefaultToStaging = _.contains([CONST.PLATFORM.WEB, CONST.PLATFORM.DESKTOP], getPlatform()); -let stagingServerToggleState = false; -Onyx.connect({ - key: ONYXKEYS.USER, - callback: val => stagingServerToggleState = lodashGet(val, 'shouldUseStagingServer', shouldDefaultToStaging), -}); +import * as ApiUtils from './ApiUtils'; let shouldFailAllRequests = false; let shouldForceOffline = false; @@ -32,6 +21,9 @@ Onyx.connect({ // We use the AbortController API to terminate pending request in `cancelPendingRequests` let cancellationController = new AbortController(); +// To terminate pending ReconnectApp requests https://github.com/Expensify/App/issues/15627 +let reconnectAppCancellationController = new AbortController(); + /** * Send an HTTP request, and attempt to resolve the json response. * If there is a network error, we'll set the application offline. @@ -40,12 +32,18 @@ let cancellationController = new AbortController(); * @param {String} [method] * @param {Object} [body] * @param {Boolean} [canCancel] + * @param {String} [command] * @returns {Promise} */ -function processHTTPRequest(url, method = 'get', body = null, canCancel = true) { +function processHTTPRequest(url, method = 'get', body = null, canCancel = true, command = '') { + let signal; + if (canCancel) { + signal = command === CONST.NETWORK.COMMAND.RECONNECT_APP ? reconnectAppCancellationController.signal : cancellationController.signal; + } + return fetch(url, { // We hook requests to the same Controller signal, so we can cancel them all at once - signal: canCancel ? cancellationController.signal : undefined, + signal, method, body, }) @@ -119,13 +117,13 @@ function xhr(command, data, type = CONST.NETWORK.METHOD.POST, shouldUseSecure = formData.append(key, val); }); - let apiRoot = shouldUseSecure ? CONFIG.EXPENSIFY.SECURE_EXPENSIFY_URL : CONFIG.EXPENSIFY.URL_API_ROOT; - - if (shouldUseStagingServer(stagingServerToggleState)) { - apiRoot = shouldUseSecure ? CONFIG.EXPENSIFY.STAGING_SECURE_EXPENSIFY_URL : CONFIG.EXPENSIFY.STAGING_EXPENSIFY_URL; - } + const url = ApiUtils.getCommandURL({shouldUseSecure, command}); + return processHTTPRequest(url, type, formData, data.canCancel, command); +} - return processHTTPRequest(`${apiRoot}api?command=${command}`, type, formData, data.canCancel); +function cancelPendingReconnectAppRequest() { + reconnectAppCancellationController.abort(); + reconnectAppCancellationController = new AbortController(); } function cancelPendingRequests() { @@ -134,9 +132,11 @@ function cancelPendingRequests() { // We create a new instance because once `abort()` is called any future requests using the same controller would // automatically get rejected: https://dom.spec.whatwg.org/#abortcontroller-api-integration cancellationController = new AbortController(); + cancelPendingReconnectAppRequest(); } export default { xhr, cancelPendingRequests, + cancelPendingReconnectAppRequest, }; diff --git a/src/libs/LoginUtils.js b/src/libs/LoginUtils.js index 4c7e8a7d3d48..c4f160f8cf40 100644 --- a/src/libs/LoginUtils.js +++ b/src/libs/LoginUtils.js @@ -1,4 +1,13 @@ +import Str from 'expensify-common/lib/str'; +import Onyx from 'react-native-onyx'; import CONST from '../CONST'; +import ONYXKEYS from '../ONYXKEYS'; + +let countryCodeByIP; +Onyx.connect({ + key: ONYXKEYS.COUNTRY_CODE, + callback: val => countryCodeByIP = val || 1, +}); /** * Remove the special chars from the phone number @@ -20,7 +29,20 @@ function getPhoneNumberWithoutUSCountryCodeAndSpecialChars(phone) { return getPhoneNumberWithoutSpecialChars(phone.replace(/^\+1/, '')); } +/** + * Append user country code to the phone number + * + * @param {String} phone + * @return {String} + */ +function appendCountryCode(phone) { + return (Str.isValidPhone(phone) && !phone.includes('+')) + ? `+${countryCodeByIP}${phone}` + : phone; +} + export { getPhoneNumberWithoutSpecialChars, getPhoneNumberWithoutUSCountryCodeAndSpecialChars, + appendCountryCode, }; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index ec8b31b5880c..dac8228588e3 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -1,5 +1,6 @@ import React from 'react'; import Onyx, {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; import moment from 'moment'; import _ from 'underscore'; import lodashGet from 'lodash/get'; @@ -81,9 +82,19 @@ const modalScreenListeners = { }; const propTypes = { + /** Session of currently logged in user */ + session: PropTypes.shape({ + email: PropTypes.string.isRequired, + }), ...windowDimensionsPropTypes, }; +const defaultProps = { + session: { + email: null, + }, +}; + class AuthScreens extends React.Component { constructor(props) { super(props); @@ -98,7 +109,7 @@ class AuthScreens extends React.Component { Pusher.init({ appKey: CONFIG.PUSHER.APP_KEY, cluster: CONFIG.PUSHER.CLUSTER, - authEndpoint: `${CONFIG.EXPENSIFY.URL_API_ROOT}api?command=AuthenticatePusher`, + authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api?command=AuthenticatePusher`, }).then(() => { User.subscribeToUserEvents(); }); @@ -315,6 +326,12 @@ class AuthScreens extends React.Component { component={ModalStackNavigators.WalletStatementStackNavigator} listeners={modalScreenListeners} /> + ; - } - - // After the app initializes and reports are available the home navigation is mounted - // This way routing information is updated (if needed) based on the initial report ID resolved. - // This is usually needed after login/create account and re-launches return ( { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index fecb903eafd1..405ea6c6c332 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -511,6 +511,14 @@ const WalletStatementStackNavigator = createModalStackNavigator([{ name: 'WalletStatement_Root', }]); +const YearPickerStackNavigator = createModalStackNavigator([{ + getComponent: () => { + const YearPickerPage = require('../../../pages/YearPickerPage').default; + return YearPickerPage; + }, + name: 'YearPicker_Root', +}]); + export { IOUBillStackNavigator, IOURequestModalStackNavigator, @@ -528,4 +536,5 @@ export { AddPersonalBankAccountModalStackNavigator, ReimbursementAccountModalStackNavigator, WalletStatementStackNavigator, + YearPickerStackNavigator, }; diff --git a/src/libs/Navigation/AppNavigator/PublicScreens.js b/src/libs/Navigation/AppNavigator/PublicScreens.js index ea209b043307..855efccad709 100644 --- a/src/libs/Navigation/AppNavigator/PublicScreens.js +++ b/src/libs/Navigation/AppNavigator/PublicScreens.js @@ -4,7 +4,6 @@ import SignInPage from '../../../pages/signin/SignInPage'; import SetPasswordPage from '../../../pages/SetPasswordPage'; import ValidateLoginPage from '../../../pages/ValidateLoginPage'; import LogInWithShortLivedAuthTokenPage from '../../../pages/LogInWithShortLivedAuthTokenPage'; -import ConciergePage from '../../../pages/ConciergePage'; import SCREENS from '../../../SCREENS'; import defaultScreenOptions from './defaultScreenOptions'; @@ -32,11 +31,6 @@ const PublicScreens = () => ( options={defaultScreenOptions} component={SetPasswordPage} /> - ); diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js index 6c3dcfa618b4..7d1cacc07089 100644 --- a/src/libs/Navigation/Navigation.js +++ b/src/libs/Navigation/Navigation.js @@ -1,7 +1,7 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; import {Keyboard} from 'react-native'; -import {DrawerActions, getPathFromState, StackActions} from '@react-navigation/native'; +import {CommonActions, DrawerActions, getPathFromState} from '@react-navigation/native'; import Onyx from 'react-native-onyx'; import Log from '../Log'; import DomUtils from '../DomUtils'; @@ -11,6 +11,7 @@ import DeprecatedCustomActions from './DeprecatedCustomActions'; import ONYXKEYS from '../../ONYXKEYS'; import linkingConfig from './linkingConfig'; import navigationRef from './navigationRef'; +import SCREENS from '../../SCREENS'; let resolveNavigationIsReadyPromise; const navigationIsReadyPromise = new Promise((resolve) => { @@ -23,7 +24,7 @@ let drawerIsReadyPromise = new Promise((resolve) => { }); let resolveReportScreenIsReadyPromise; -const reportScreenIsReadyPromise = new Promise((resolve) => { +let reportScreenIsReadyPromise = new Promise((resolve) => { resolveReportScreenIsReadyPromise = resolve; }); @@ -173,7 +174,7 @@ function navigate(route = ROUTES.HOME) { // If we're navigating to the signIn page while logged out, pop whatever screen is on top // since it's guaranteed that the sign in page will be underneath (since it's the initial route). // Also, if we're coming from a link to validate login (pendingRoute is not null), we want to pop the loading screen. - navigationRef.current.dispatch(StackActions.pop()); + navigationRef.current.dispatch(CommonActions.reset({index: 0, routes: [{name: SCREENS.HOME}]})); return; } @@ -185,6 +186,19 @@ function navigate(route = ROUTES.HOME) { linkTo(navigationRef.current, route); } +/** + * Update route params for the specified route. + * + * @param {Object} params + * @param {String} routeKey + */ +function setParams(params, routeKey) { + navigationRef.current.dispatch({ + ...CommonActions.setParams(params), + source: routeKey, + }); +} + /** * Dismisses a screen presented modally and returns us back to the previous view. * @@ -267,6 +281,12 @@ function setIsNavigationReady() { resolveNavigationIsReadyPromise(); } +function resetIsReportScreenReadyPromise() { + reportScreenIsReadyPromise = new Promise((resolve) => { + resolveReportScreenIsReadyPromise = resolve; + }); +} + /** * @returns {Promise} */ @@ -295,6 +315,7 @@ function setIsReportScreenIsReady() { export default { canNavigate, navigate, + setParams, dismissModal, isActiveRoute, getActiveRoute, @@ -308,6 +329,7 @@ export default { isDrawerReady, setIsDrawerReady, resetDrawerIsReadyPromise, + resetIsReportScreenReadyPromise, isDrawerRoute, setIsNavigating, isReportScreenReady, diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index 3af0f479ae17..080b8d148cff 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -262,6 +262,11 @@ export default { WalletStatement_Root: ROUTES.WALLET_STATEMENT_WITH_DATE, }, }, + Select_Year: { + screens: { + YearPicker_Root: ROUTES.SELECT_YEAR, + }, + }, [SCREENS.NOT_FOUND]: '*', }, }, diff --git a/src/libs/NetworkConnection.js b/src/libs/NetworkConnection.js index 7dd0df70eb57..66f62a862b0e 100644 --- a/src/libs/NetworkConnection.js +++ b/src/libs/NetworkConnection.js @@ -78,7 +78,7 @@ function subscribeToNetInfo() { // By default, NetInfo uses `/` for `reachabilityUrl` // When App is served locally (or from Electron) this address is always reachable - even offline // Using the API url ensures reachability is tested over internet - reachabilityUrl: `${CONFIG.EXPENSIFY.URL_API_ROOT}api`, + reachabilityUrl: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api`, reachabilityTest: response => Promise.resolve(response.status === 200), // If a check is taking longer than this time we're considered offline diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index bf88721ae0a0..2b91b20462a4 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -10,6 +10,7 @@ import * as Localize from './Localize'; import Permissions from './Permissions'; import * as CollectionUtils from './CollectionUtils'; import Navigation from './Navigation/Navigation'; +import * as LoginUtils from './LoginUtils'; /** * OptionsListUtils is used to build a list options passed to the OptionsList component. Several different UI views can @@ -580,9 +581,8 @@ function getOptions(reports, personalDetails, { // If the phone number doesn't have an international code then let's prefix it with the // current user's international code based on their IP address. - const login = (Str.isValidPhone(searchValue) && !searchValue.includes('+')) - ? `+${countryCodeByIP}${searchValue}` - : searchValue; + const login = LoginUtils.appendCountryCode(searchValue); + if (login && (noOptions || noOptionsMatchExactly) && !isCurrentUser({login}) && _.every(selectedOptions, option => option.login !== login) @@ -764,14 +764,16 @@ function getHeaderMessage(hasSelectableOptions, hasUserToInvite, searchValue, ma return Localize.translate(preferredLocale, 'common.maxParticipantsReached', {count: CONST.REPORT.MAXIMUM_PARTICIPANTS}); } - if (searchValue && CONST.REGEX.DIGITS_AND_PLUS.test(searchValue) && !Str.isValidPhone(searchValue)) { + const isValidPhone = Str.isValidPhone(LoginUtils.appendCountryCode(searchValue)); + + if (searchValue && CONST.REGEX.DIGITS_AND_PLUS.test(searchValue) && !isValidPhone) { return Localize.translate(preferredLocale, 'messages.errorMessageInvalidPhone'); } // Without a search value, it would be very confusing to see a search validation message. // Therefore, this skips the validation when there is no search value. if (searchValue && !hasSelectableOptions && !hasUserToInvite) { - if (/^\d+$/.test(searchValue) && !Str.isValidPhone(searchValue)) { + if (/^\d+$/.test(searchValue) && !isValidPhone) { return Localize.translate(preferredLocale, 'messages.errorMessageInvalidPhone'); } diff --git a/src/libs/PersonalDetailsUtils.js b/src/libs/PersonalDetailsUtils.js new file mode 100644 index 000000000000..8307e3587005 --- /dev/null +++ b/src/libs/PersonalDetailsUtils.js @@ -0,0 +1,43 @@ +import Onyx from 'react-native-onyx'; +import _ from 'underscore'; +import ONYXKEYS from '../ONYXKEYS'; +import * as Report from './actions/Report'; +import * as Localize from './Localize'; + +let personalDetails = []; +Onyx.connect({ + key: ONYXKEYS.PERSONAL_DETAILS, + callback: val => personalDetails = _.values(val), +}); + +/** + * Given a list of account IDs (as string) it will return an array of personal details objects. + * @param {Array} accountIDs - Array of accountIDs + * @param {boolean} shouldChangeUserDisplayName - It will replace the current user's personal detail object's displayName with 'You'. + * @returns {Array} - Array of personal detail objects + */ +function getPersonalDetailsByIDs(accountIDs, shouldChangeUserDisplayName = false) { + const result = []; + const currentAccountID = Report.getCurrentUserAccountID(); + _.each(personalDetails, (detail) => { + for (let i = 0; i < accountIDs.length; i++) { + if (detail.accountID === accountIDs[i]) { + if (shouldChangeUserDisplayName && currentAccountID.toString() === detail.accountID) { + result[i] = { + ...detail, + displayName: Localize.translateLocal('common.you'), + }; + } else { + result[i] = detail; + } + break; + } + } + }); + return result; +} + +export { + // eslint-disable-next-line import/prefer-default-export + getPersonalDetailsByIDs, +}; diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 372f8e003787..15000bbb87e8 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -4,7 +4,6 @@ import lodashGet from 'lodash/get'; import lodashIntersection from 'lodash/intersection'; 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'; @@ -558,6 +557,28 @@ function getFullSizeAvatar(avatarURL, login) { return source.replace('_128', ''); } +/** + * Small sized avatars end with _128.. This adds the _128 at the end of the + * source URL (before the file type) if it doesn't exist there already. + * + * @param {String} avatarURL + * @param {String} login + * @returns {String|Function} + */ +function getSmallSizeAvatar(avatarURL, login) { + const source = getAvatar(avatarURL, login); + if (!_.isString(source)) { + return source; + } + + // If image source already has _128 at the end, the given avatar URL is already what we want to use here. + const lastPeriodIndex = source.lastIndexOf('.'); + if (source.substring(lastPeriodIndex - 4, lastPeriodIndex) === '_128') { + return source; + } + return `${source.substring(0, lastPeriodIndex)}_128${source.substring(lastPeriodIndex)}`; +} + /** * Returns the appropriate icons for the given chat report using the stored personalDetails. * The Avatar sources can be URLs or Icon components according to the chat type. @@ -847,12 +868,15 @@ function hasReportNameError(report) { } /** + * For comments shorter than 10k chars, convert the comment from MD into HTML because that's how it is stored in the database + * For longer comments, skip parsing, but still escape the text, and display plaintext for performance reasons. It takes over 40s to parse a 100k long string!! + * * @param {String} text * @returns {String} */ function getParsedComment(text) { const parser = new ExpensiMark(); - return text.length < CONST.MAX_MARKUP_LENGTH ? parser.replace(text) : text; + return text.length < CONST.MAX_MARKUP_LENGTH ? parser.replace(text) : _.escape(text); } /** @@ -861,8 +885,6 @@ function getParsedComment(text) { * @returns {Object} */ function buildOptimisticAddCommentReportAction(text, file) { - // For comments shorter than 10k chars, convert the comment from MD into HTML because that's how it is stored in the database - // For longer comments, skip parsing and display plaintext for performance reasons. It takes over 40s to parse a 100k long string!! const parser = new ExpensiMark(); const commentText = getParsedComment(text); const isAttachment = _.isEmpty(text) && file !== undefined; @@ -1517,7 +1539,7 @@ function getCommentLength(textComment) { * @param {String|null} url * @returns {String} */ -function getReportIDFromDeepLink(url) { +function getRouteFromLink(url) { if (!url) { return ''; } @@ -1544,27 +1566,21 @@ function getReportIDFromDeepLink(url) { route = route.replace('/', ''); } }); - const {reportID, isSubReportPageRoute} = ROUTES.parseReportRouteParams(route); - if (isSubReportPageRoute) { - // We allow the Sub-Report deep link routes (settings, details, etc.) to be handled by their respective component pages - return ''; - } - return reportID; + return route; } /** * @param {String|null} url + * @returns {String} */ -function openReportFromDeepLink(url) { - const reportID = getReportIDFromDeepLink(url); - if (!reportID) { - return; +function getReportIDFromLink(url) { + const route = getRouteFromLink(url); + const {reportID, isSubReportPageRoute} = ROUTES.parseReportRouteParams(route); + if (isSubReportPageRoute) { + // We allow the Sub-Report deep link routes (settings, details, etc.) to be handled by their respective component pages + return ''; } - InteractionManager.runAfterInteractions(() => { - Navigation.isReportScreenReady().then(() => { - Navigation.navigate(ROUTES.getReportRoute(reportID)); - }); - }); + return reportID; } /** @@ -1661,7 +1677,8 @@ export { getRoomWelcomeMessage, getDisplayNamesWithTooltips, getReportName, - getReportIDFromDeepLink, + getReportIDFromLink, + getRouteFromLink, navigateToDetailsPage, generateReportID, hasReportNameError, @@ -1688,7 +1705,7 @@ export { hashLogin, getDefaultWorkspaceAvatar, getCommentLength, - openReportFromDeepLink, getFullSizeAvatar, + getSmallSizeAvatar, getIOUOptions, }; diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js index 5cf2514efcd8..3e6fd500c98e 100644 --- a/src/libs/SidebarUtils.js +++ b/src/libs/SidebarUtils.js @@ -1,5 +1,6 @@ import Onyx from 'react-native-onyx'; import _ from 'underscore'; +import lodashGet from 'lodash/get'; import lodashOrderBy from 'lodash/orderBy'; import Str from 'expensify-common/lib/str'; import ONYXKEYS from '../ONYXKEYS'; @@ -249,7 +250,16 @@ function getOptionData(reportID) { lastMessageTextFromReport = Str.htmlDecode(report ? report.lastMessageText : ''); } - const lastActorDetails = personalDetails[report.lastActorEmail] || null; + // If the last actor's details are not currently saved in Onyx Collection, + // then try to get that from the last report action. + let lastActorDetails = personalDetails[report.lastActorEmail] || null; + if (!lastActorDetails && lastReportActions[report.reportID] && lastReportActions[report.reportID].actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) { + const lastActorDisplayName = lodashGet(lastReportActions[report.reportID], 'person[0].text'); + lastActorDetails = lastActorDisplayName ? { + displayName: lastActorDisplayName, + login: report.lastActorEmail, + } : null; + } let lastMessageText = hasMultipleParticipants && lastActorDetails && (lastActorDetails.login !== currentUserLogin.email) ? `${lastActorDetails.displayName}: ` : ''; diff --git a/src/libs/ValidationUtils.js b/src/libs/ValidationUtils.js index f2e35941edc4..a6bfa519c911 100644 --- a/src/libs/ValidationUtils.js +++ b/src/libs/ValidationUtils.js @@ -221,7 +221,7 @@ function getAgeRequirementError(date, minimumAge, maximumAge) { if (!testDate.isValid()) { return Localize.translateLocal('common.error.dateInvalid'); } - if (testDate.isBetween(longAgoDate, recentDate)) { + if (testDate.isBetween(longAgoDate, recentDate, undefined, [])) { return ''; } if (testDate.isSameOrAfter(recentDate)) { @@ -358,6 +358,16 @@ function isValidDisplayName(name) { return !name.includes(',') && !name.includes(';'); } +/** + * Checks that the provided legal name doesn't contain special characters + * + * @param {String} name + * @returns {Boolean} + */ +function isValidLegalName(name) { + return CONST.REGEX.ALPHABETIC_CHARS_WITH_NUMBER.test(name); +} + /** * Checks if the provided string includes any of the provided reserved words * @@ -449,5 +459,6 @@ export { isValidTaxID, isValidValidateCode, isValidDisplayName, + isValidLegalName, doesContainReservedWord, }; diff --git a/src/libs/actions/App.js b/src/libs/actions/App.js index b3d05bed7f6a..30c825f40f90 100644 --- a/src/libs/actions/App.js +++ b/src/libs/actions/App.js @@ -165,7 +165,7 @@ function openApp() { * Refreshes data when the app reconnects */ function reconnectApp() { - API.write('ReconnectApp', {policyIDList: getNonOptimisticPolicyIDs(allPolicies)}, { + API.write(CONST.NETWORK.COMMAND.RECONNECT_APP, {policyIDList: getNonOptimisticPolicyIDs(allPolicies)}, { optimisticData: [{ onyxMethod: CONST.ONYX.METHOD.MERGE, key: ONYXKEYS.IS_LOADING_REPORT_DATA, diff --git a/src/libs/actions/CloseAccount.js b/src/libs/actions/CloseAccount.js index ea45ae86c76f..8a8f395277e1 100644 --- a/src/libs/actions/CloseAccount.js +++ b/src/libs/actions/CloseAccount.js @@ -6,7 +6,7 @@ import CONST from '../../CONST'; * Clear CloseAccount error message to hide modal */ function clearError() { - Onyx.merge(ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM, {error: '', errors: null}); + Onyx.merge(ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM, {errors: null}); } /** diff --git a/src/libs/actions/Link.js b/src/libs/actions/Link.js index 53984be930a5..c38f901bb391 100644 --- a/src/libs/actions/Link.js +++ b/src/libs/actions/Link.js @@ -60,7 +60,7 @@ function openOldDotLink(url) { } if (isNetworkOffline) { - Linking.openURL(buildOldDotURL()); + buildOldDotURL().then(oldDotURL => Linking.openURL(oldDotURL)); return; } diff --git a/src/libs/actions/PaymentMethods.js b/src/libs/actions/PaymentMethods.js index 3461b26d768a..627d2898166c 100644 --- a/src/libs/actions/PaymentMethods.js +++ b/src/libs/actions/PaymentMethods.js @@ -207,7 +207,6 @@ function transferWalletBalance(paymentMethod) { key: ONYXKEYS.WALLET_TRANSFER, value: { loading: true, - error: null, errors: null, }, }, diff --git a/src/libs/actions/PersistedRequests.js b/src/libs/actions/PersistedRequests.js index 79845f05612c..41798efc69dc 100644 --- a/src/libs/actions/PersistedRequests.js +++ b/src/libs/actions/PersistedRequests.js @@ -1,6 +1,8 @@ import Onyx from 'react-native-onyx'; import _ from 'underscore'; +import CONST from '../../CONST'; import ONYXKEYS from '../../ONYXKEYS'; +import HttpUtils from '../HttpUtils'; let persistedRequests = []; @@ -17,7 +19,12 @@ function clear() { * @param {Array} requestsToPersist */ function save(requestsToPersist) { - persistedRequests = persistedRequests.concat(requestsToPersist); + HttpUtils.cancelPendingReconnectAppRequest(); + persistedRequests = _.chain(persistedRequests) + .concat(requestsToPersist) + .partition(request => request.command !== CONST.NETWORK.COMMAND.RECONNECT_APP) + .flatten() + .value(); Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, persistedRequests); } diff --git a/src/libs/actions/Plaid.js b/src/libs/actions/Plaid.js index 0785a1fd9b3e..819192ca5f9d 100644 --- a/src/libs/actions/Plaid.js +++ b/src/libs/actions/Plaid.js @@ -48,7 +48,6 @@ function openPlaidBankAccountSelector(publicToken, bankName, allowDebit) { key: ONYXKEYS.PLAID_DATA, value: { isLoading: true, - error: '', errors: null, bankName, }, @@ -58,7 +57,6 @@ function openPlaidBankAccountSelector(publicToken, bankName, allowDebit) { key: ONYXKEYS.PLAID_DATA, value: { isLoading: false, - error: '', errors: null, }, }], diff --git a/src/libs/actions/ReimbursementAccount/navigation.js b/src/libs/actions/ReimbursementAccount/navigation.js index 312479838c9f..b3c9ba62879a 100644 --- a/src/libs/actions/ReimbursementAccount/navigation.js +++ b/src/libs/actions/ReimbursementAccount/navigation.js @@ -15,9 +15,11 @@ function goToWithdrawalAccountSetupStep(stepID, newAchData) { /** * Navigate to the correct bank account route based on the bank account state and type + * + * @param {String} policyId */ -function navigateToBankAccountRoute() { - Navigation.navigate(ROUTES.getBankAccountRoute()); +function navigateToBankAccountRoute(policyId) { + Navigation.navigate(ROUTES.getBankAccountRoute('', policyId)); } export { diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index c4549b6cc121..5db7bd5b48bd 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -1,4 +1,4 @@ -import {Linking} from 'react-native'; +import {Linking, InteractionManager} from 'react-native'; import _ from 'underscore'; import lodashGet from 'lodash/get'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; @@ -449,6 +449,7 @@ function reconnect(reportID) { value: { isLoadingReportActions: true, isLoadingMoreReportActions: false, + reportName: lodashGet(allReports, [reportID, 'reportName'], CONST.REPORT.DEFAULT_REPORT_NAME), }, }], successData: [{ @@ -671,10 +672,10 @@ function handleReportChanged(report) { } } - // A report can be missing a name if a comment is received via pusher event - // and the report does not yet exist in Onyx (eg. a new DM created with the logged in person) + // A report can be missing a name if a comment is received via pusher event and the report does not yet exist in Onyx (eg. a new DM created with the logged in person) + // In this case, we call reconnect so that we can fetch the report data without marking it as read if (report.reportID && report.reportName === undefined) { - openReport(report.reportID); + reconnect(report.reportID); } } @@ -1366,6 +1367,28 @@ function toggleEmojiReaction(reportID, reportAction, emoji, paramSkinTone = pref return addEmojiReaction(reportID, reportAction, emoji, skinTone); } +/** + * @param {String|null} url + */ +function openReportFromDeepLink(url) { + InteractionManager.runAfterInteractions(() => { + Navigation.isReportScreenReady().then(() => { + const route = ReportUtils.getRouteFromLink(url); + const reportID = ReportUtils.getReportIDFromLink(url); + if (reportID) { + Navigation.navigate(ROUTES.getReportRoute(reportID)); + } + if (route === ROUTES.CONCIERGE) { + navigateToConciergeChat(); + } + }); + }); +} + +function getCurrentUserAccountID() { + return currentUserAccountID; +} + export { addComment, addAttachment, @@ -1390,6 +1413,7 @@ export { readNewestAction, readOldestAction, openReport, + openReportFromDeepLink, navigateToAndOpenReport, openPaymentDetailsPage, updatePolicyRoomName, @@ -1401,4 +1425,5 @@ export { removeEmojiReaction, toggleEmojiReaction, hasAccountIDReacted, + getCurrentUserAccountID, }; diff --git a/src/libs/actions/Session/index.js b/src/libs/actions/Session/index.js index edf29b069400..42492432b230 100644 --- a/src/libs/actions/Session/index.js +++ b/src/libs/actions/Session/index.js @@ -17,8 +17,6 @@ import * as API from '../../API'; import * as NetworkStore from '../../Network/NetworkStore'; import * as Report from '../Report'; import DateUtils from '../../DateUtils'; -import Navigation from '../../Navigation/Navigation'; -import ROUTES from '../../../ROUTES'; let credentials = {}; Onyx.connect({ @@ -141,6 +139,39 @@ function resendValidateCode(login = credentials.login) { API.write('RequestNewValidateCode', {email: login}, {optimisticData, successData, failureData}); } +/** + * Request a new validate / magic code for user to sign in automatically with the link + * + * @param {String} [login] + */ +function resendLinkWithValidateCode(login = credentials.login) { + const optimisticData = [{ + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + isLoading: true, + message: null, + }, + }]; + const successData = [{ + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + isLoading: false, + message: Localize.translateLocal('validateCodeModal.successfulNewCodeRequest'), + }, + }]; + const failureData = [{ + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + isLoading: false, + message: null, + }, + }]; + API.write('RequestNewValidateCode', {email: login}, {optimisticData, successData, failureData}); +} + /** * Checks the API to see if an account exists for the given login * @@ -299,15 +330,18 @@ function signInWithValidateCode(accountID, validateCode, twoFactorAuthCode) { isLoading: true, }, }, + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.SESSION, + value: {autoAuthState: CONST.AUTO_AUTH_STATE.SIGNING_IN}, + }, ]; const successData = [ { onyxMethod: CONST.ONYX.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, - value: { - isLoading: false, - }, + value: {isLoading: false}, }, { onyxMethod: CONST.ONYX.METHOD.MERGE, @@ -317,15 +351,23 @@ function signInWithValidateCode(accountID, validateCode, twoFactorAuthCode) { validateCode, }, }, + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.SESSION, + value: {autoAuthState: CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN}, + }, ]; const failureData = [ { onyxMethod: CONST.ONYX.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, - value: { - isLoading: false, - }, + value: {isLoading: false}, + }, + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.SESSION, + value: {autoAuthState: CONST.AUTO_AUTH_STATE.FAILED}, }, ]; @@ -336,9 +378,24 @@ function signInWithValidateCode(accountID, validateCode, twoFactorAuthCode) { }, {optimisticData, successData, failureData}); } -function signInWithValidateCodeAndNavigate(accountID, validateCode) { - signInWithValidateCode(accountID, validateCode); - Navigation.navigate(ROUTES.HOME); +/** + * Initializes the state of the automatic authentication when the user clicks on a magic link. + * + * This method is called in componentDidMount event of the lifecycle. + * When the user gets authenticated, the component is unmounted and then remounted + * when AppNavigator switches from PublicScreens to AuthScreens. + * That's the reason why autoAuthState initialization is skipped while the last state is SIGNING_IN. + * + * @param {string} cachedAutoAuthState + */ +function initAutoAuthState(cachedAutoAuthState) { + Onyx.merge( + ONYXKEYS.SESSION, + { + autoAuthState: cachedAutoAuthState === CONST.AUTO_AUTH_STATE.SIGNING_IN + ? CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN : CONST.AUTO_AUTH_STATE.NOT_STARTED, + }, + ); } /** @@ -560,13 +617,14 @@ export { updatePasswordAndSignin, signIn, signInWithValidateCode, - signInWithValidateCodeAndNavigate, + initAutoAuthState, signInWithShortLivedAuthToken, cleanupSession, signOut, signOutAndRedirectToSignIn, resendValidationLink, resendValidateCode, + resendLinkWithValidateCode, resetPassword, resendResetPassword, clearSignInData, diff --git a/src/libs/getSafeAreaPaddingTop/index.android.js b/src/libs/getSafeAreaPaddingTop/index.android.js new file mode 100644 index 000000000000..db66c1739ffb --- /dev/null +++ b/src/libs/getSafeAreaPaddingTop/index.android.js @@ -0,0 +1,12 @@ +import {StatusBar} from 'react-native'; + +/** + * Returns safe area padding top to use for a View + * + * @param {Object} insets + * @param {Boolean} statusBarTranslucent + * @returns {Number} + */ +export default function getSafeAreaPaddingTop(insets, statusBarTranslucent) { + return (statusBarTranslucent && StatusBar.currentHeight) || 0; +} diff --git a/src/libs/getSafeAreaPaddingTop/index.js b/src/libs/getSafeAreaPaddingTop/index.js new file mode 100644 index 000000000000..89b3579587e7 --- /dev/null +++ b/src/libs/getSafeAreaPaddingTop/index.js @@ -0,0 +1,9 @@ +/** + * Takes safe area insets and returns padding top to use for a View + * + * @param {Object} insets + * @returns {Number} + */ +export default function getSafeAreaPaddingTop(insets) { + return insets.top; +} diff --git a/src/libs/shouldRenderOffscreen/index.android.js b/src/libs/shouldRenderOffscreen/index.android.js new file mode 100644 index 000000000000..c91ffa15894d --- /dev/null +++ b/src/libs/shouldRenderOffscreen/index.android.js @@ -0,0 +1,2 @@ +// Rendering offscreen on Android allows it to apply opacity to stacked components correctly. +export default true; diff --git a/src/libs/shouldRenderOffscreen/index.js b/src/libs/shouldRenderOffscreen/index.js new file mode 100644 index 000000000000..33136544dba2 --- /dev/null +++ b/src/libs/shouldRenderOffscreen/index.js @@ -0,0 +1 @@ +export default false; diff --git a/src/libs/shouldUseStagingServer/index.js b/src/libs/shouldUseStagingServer/index.js deleted file mode 100644 index 745dd03b4489..000000000000 --- a/src/libs/shouldUseStagingServer/index.js +++ /dev/null @@ -1,13 +0,0 @@ -import CONFIG from '../../CONFIG'; - -/** - * Helper method used to decide which API endpoint to call - * - * @param {Boolean} stagingServerToggleState - * @returns {Boolean} - */ -function shouldUseStagingServer(stagingServerToggleState) { - return CONFIG.IS_IN_STAGING && stagingServerToggleState; -} - -export default shouldUseStagingServer; diff --git a/src/libs/shouldUseStagingServer/index.native.js b/src/libs/shouldUseStagingServer/index.native.js deleted file mode 100644 index 92baff89c835..000000000000 --- a/src/libs/shouldUseStagingServer/index.native.js +++ /dev/null @@ -1,13 +0,0 @@ -import * as Environment from '../Environment/Environment'; - -/** - * Helper method used to decide which API endpoint to call in the Native apps. - * We build the staging native apps with production env config so we cannot rely on those values, - * hence we will decide solely on the value of the shouldUseStagingServer value (always false in production). - * - * @param {Boolean} stagingServerToggleState - * @returns {Boolean} - */ -export default function shouldUseStagingServer(stagingServerToggleState) { - return !Environment.isDevelopment() && stagingServerToggleState; -} diff --git a/src/libs/tryResolveUrlFromApiRoot.js b/src/libs/tryResolveUrlFromApiRoot.js index 7797d3446459..b5e5bf2239f3 100644 --- a/src/libs/tryResolveUrlFromApiRoot.js +++ b/src/libs/tryResolveUrlFromApiRoot.js @@ -1,24 +1,30 @@ import Config from '../CONFIG'; +import * as ApiUtils from './ApiUtils'; // Absolute URLs (`/` or `//`) should be resolved from API ROOT // Legacy attachments can come from either staging or prod, depending on the env they were uploaded by // Both should be replaced and loaded from API ROOT of the current environment -const ORIGINS_TO_REPLACE = ['/+', Config.EXPENSIFY.EXPENSIFY_URL, Config.EXPENSIFY.STAGING_EXPENSIFY_URL]; +const ORIGINS_TO_REPLACE = [ + '/+', + Config.EXPENSIFY.EXPENSIFY_URL, + Config.EXPENSIFY.STAGING_API_ROOT, + Config.EXPENSIFY.DEFAULT_API_ROOT, +]; // Anything starting with a match from ORIGINS_TO_REPLACE const ORIGIN_PATTERN = new RegExp(`^(${ORIGINS_TO_REPLACE.join('|')})`); /** - * When possible resolve sources relative to API ROOT - * Updates applicable URLs, so they are accessed relative to URL_API_ROOT + * When possible this function resolve URLs relative to API ROOT * - Absolute URLs like `/{path}`, become: `https://{API_ROOT}/{path}` * - Similarly for prod or staging URLs we replace the `https://www.expensify` * or `https://staging.expensify` part, with `https://{API_ROOT}` - * - Unmatched URLs are returned with no modifications + * - Unmatched URLs (non expensify) are returned with no modifications * * @param {String} url * @returns {String} */ export default function tryResolveUrlFromApiRoot(url) { - return url.replace(ORIGIN_PATTERN, Config.EXPENSIFY.URL_API_ROOT); + const apiRoot = ApiUtils.getApiRoot({shouldUseSecure: false}); + return url.replace(ORIGIN_PATTERN, apiRoot); } diff --git a/src/pages/AddPersonalBankAccountPage.js b/src/pages/AddPersonalBankAccountPage.js index b140d4657801..0901f31dae74 100644 --- a/src/pages/AddPersonalBankAccountPage.js +++ b/src/pages/AddPersonalBankAccountPage.js @@ -62,7 +62,7 @@ class AddPersonalBankAccountPage extends React.Component { }; } - componentDidMount() { + componentWillUnmount() { BankAccounts.clearPersonalBankAccount(); } @@ -99,7 +99,6 @@ class AddPersonalBankAccountPage extends React.Component { shouldShowButton buttonText={this.props.translate('common.continue')} onButtonPress={() => { - BankAccounts.clearPersonalBankAccount(); Navigation.navigate(ROUTES.SETTINGS_PAYMENTS); }} /> diff --git a/src/pages/ConciergePage.js b/src/pages/ConciergePage.js index 4a9812caaee3..00461573d043 100644 --- a/src/pages/ConciergePage.js +++ b/src/pages/ConciergePage.js @@ -12,10 +12,13 @@ const propTypes = { session: PropTypes.shape({ /** Currently logged in user authToken */ authToken: PropTypes.string, + }), +}; - /** Currently logged in user email */ - email: PropTypes.string, - }).isRequired, +const defaultProps = { + session: { + authToken: null, + }, }; /* @@ -36,6 +39,7 @@ const ConciergePage = (props) => { }; ConciergePage.propTypes = propTypes; +ConciergePage.defaultProps = defaultProps; ConciergePage.displayName = 'ConciergePage'; export default withOnyx({ diff --git a/src/pages/DetailsPage.js b/src/pages/DetailsPage.js index dcc6d74270b0..cf829d3d898b 100755 --- a/src/pages/DetailsPage.js +++ b/src/pages/DetailsPage.js @@ -45,12 +45,20 @@ const propTypes = { /** Route params */ route: matchType.isRequired, + /** Session of currently logged in user */ + session: PropTypes.shape({ + email: PropTypes.string.isRequired, + }), + ...withLocalizePropTypes, }; const defaultProps = { // When opening someone else's profile (via deep link) before login, this is empty personalDetails: {}, + session: { + email: null, + }, }; /** @@ -147,7 +155,7 @@ class DetailsPage extends React.PureComponent { )} {details.displayName && ( - + {isSMSLogin ? this.props.toLocalPhone(details.displayName) : details.displayName} )} diff --git a/src/pages/EnablePayments/AdditionalDetailsStep.js b/src/pages/EnablePayments/AdditionalDetailsStep.js index 1091b849c630..80a781e6badd 100644 --- a/src/pages/EnablePayments/AdditionalDetailsStep.js +++ b/src/pages/EnablePayments/AdditionalDetailsStep.js @@ -18,6 +18,7 @@ import TextInput from '../../components/TextInput'; import * as Wallet from '../../libs/actions/Wallet'; import * as ValidationUtils from '../../libs/ValidationUtils'; import * as LoginUtils from '../../libs/LoginUtils'; +import * as ErrorUtils from '../../libs/ErrorUtils'; import AddressForm from '../ReimbursementAccount/AddressForm'; import DatePicker from '../../components/DatePicker'; import Form from '../../components/Form'; @@ -125,11 +126,9 @@ class AdditionalDetailsStep extends React.Component { } if (!ValidationUtils.isValidPastDate(values[INPUT_IDS.DOB])) { - errors[INPUT_IDS.DOB] = this.props.translate(this.errorTranslationKeys.dob); - } - - if (!ValidationUtils.meetsAgeRequirements(values[INPUT_IDS.DOB])) { - errors[INPUT_IDS.DOB] = this.props.translate(this.errorTranslationKeys.age); + ErrorUtils.addErrorMessage(errors, INPUT_IDS.DOB, this.props.translate(this.errorTranslationKeys.dob)); + } else if (!ValidationUtils.meetsAgeRequirements(values[INPUT_IDS.DOB])) { + ErrorUtils.addErrorMessage(errors, INPUT_IDS.DOB, this.props.translate(this.errorTranslationKeys.age)); } if (!ValidationUtils.isValidAddress(values[INPUT_IDS.ADDRESS.street]) || _.isEmpty(values[INPUT_IDS.ADDRESS.street])) { diff --git a/src/pages/EnablePayments/OnfidoPrivacy.js b/src/pages/EnablePayments/OnfidoPrivacy.js index 6e99fd606b22..9d4ffc7d2405 100644 --- a/src/pages/EnablePayments/OnfidoPrivacy.js +++ b/src/pages/EnablePayments/OnfidoPrivacy.js @@ -113,8 +113,5 @@ export default compose( // Let's get a new onfido token each time the user hits this flow (as it should only be once) initWithStoredValues: false, }, - userWallet: { - key: ONYXKEYS.USER_WALLET, - }, }), )(OnfidoPrivacy); diff --git a/src/pages/EnablePayments/TermsStep.js b/src/pages/EnablePayments/TermsStep.js index 58dd5a93ae5a..7ad3933128ac 100644 --- a/src/pages/EnablePayments/TermsStep.js +++ b/src/pages/EnablePayments/TermsStep.js @@ -136,8 +136,5 @@ export default compose( walletTerms: { key: ONYXKEYS.WALLET_TERMS, }, - userWallet: { - key: ONYXKEYS.USER_WALLET, - }, }), )(TermsStep); diff --git a/src/pages/GetAssistancePage.js b/src/pages/GetAssistancePage.js index b29c042f6ab2..e2115c6d7814 100644 --- a/src/pages/GetAssistancePage.js +++ b/src/pages/GetAssistancePage.js @@ -27,9 +27,21 @@ const propTypes = { }), }).isRequired, + /** The details about the account that the user is signing in with */ + account: PropTypes.shape({ + /** URL to the assigned guide's appointment booking calendar */ + guideCalendarLink: PropTypes.string, + }), + ...withLocalizePropTypes, }; +const defaultProps = { + account: { + guideCalendarLink: null, + }, +}; + const GetAssistancePage = (props) => { const menuItems = [{ title: props.translate('getAssistancePage.chatWithConcierge'), @@ -84,6 +96,7 @@ const GetAssistancePage = (props) => { }; GetAssistancePage.propTypes = propTypes; +GetAssistancePage.defaultProps = defaultProps; GetAssistancePage.displayName = 'GetAssistancePage'; export default compose( diff --git a/src/pages/LogOutPreviousUserPage.js b/src/pages/LogOutPreviousUserPage.js index 843930b14e8a..0b8fea537028 100644 --- a/src/pages/LogOutPreviousUserPage.js +++ b/src/pages/LogOutPreviousUserPage.js @@ -13,14 +13,20 @@ const propTypes = { session: PropTypes.shape({ /** The user's email for the current session */ email: PropTypes.string, - }).isRequired, + }), +}; + +const defaultProps = { + session: { + email: null, + }, }; class LogOutPreviousUserPage extends Component { componentDidMount() { Linking.getInitialURL() .then((transitionURL) => { - const sessionEmail = lodashGet(this.props.session, 'email', ''); + const sessionEmail = this.props.session.email; const isLoggingInAsNewUser = SessionUtils.isLoggingInAsNewUser(transitionURL, sessionEmail); if (isLoggingInAsNewUser) { @@ -46,7 +52,7 @@ class LogOutPreviousUserPage extends Component { } LogOutPreviousUserPage.propTypes = propTypes; - +LogOutPreviousUserPage.defaultProps = defaultProps; export default withOnyx({ session: { key: ONYXKEYS.SESSION, diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js index 1ede56c65ca7..0ca8d13fbcc8 100755 --- a/src/pages/NewChatPage.js +++ b/src/pages/NewChatPage.js @@ -24,18 +24,13 @@ const propTypes = { isGroupChat: PropTypes.bool, /** Beta features list */ - betas: PropTypes.arrayOf(PropTypes.string).isRequired, + betas: PropTypes.arrayOf(PropTypes.string), /** All of the personal details for everyone */ - personalDetails: personalDetailsPropType.isRequired, + personalDetails: personalDetailsPropType, /** All reports shared with the user */ - reports: PropTypes.objectOf(reportPropTypes).isRequired, - - /** Session of currently logged in user */ - session: PropTypes.shape({ - email: PropTypes.string.isRequired, - }).isRequired, + reports: PropTypes.objectOf(reportPropTypes), ...windowDimensionsPropTypes, @@ -44,6 +39,9 @@ const propTypes = { const defaultProps = { isGroupChat: false, + betas: [], + personalDetails: {}, + reports: {}, }; class NewChatPage extends Component { @@ -286,9 +284,6 @@ export default compose( personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS, }, - session: { - key: ONYXKEYS.SESSION, - }, betas: { key: ONYXKEYS.BETAS, }, diff --git a/src/pages/ReimbursementAccount/BankAccountStep.js b/src/pages/ReimbursementAccount/BankAccountStep.js index b92b322dc0e2..a6ad01b8a4f9 100644 --- a/src/pages/ReimbursementAccount/BankAccountStep.js +++ b/src/pages/ReimbursementAccount/BankAccountStep.js @@ -44,12 +44,20 @@ const propTypes = { /** Is the user account validated? */ validated: PropTypes.bool, }), + + /** If the plaid button has been disabled */ + isPlaidDisabled: PropTypes.bool, + + /* The workspace name */ + policyName: PropTypes.string, }; const defaultProps = { receivedRedirectURI: null, plaidLinkOAuthToken: '', user: {}, + isPlaidDisabled: false, + policyName: '', }; const BankAccountStep = (props) => { @@ -88,6 +96,7 @@ const BankAccountStep = (props) => { ( ( ); ContinueBankAccountSetup.propTypes = propTypes; +ContinueBankAccountSetup.defaultProps = defaultProps; ContinueBankAccountSetup.displayName = 'ContinueBankAccountSetup'; export default compose( diff --git a/src/pages/ReimbursementAccount/EnableStep.js b/src/pages/ReimbursementAccount/EnableStep.js index b8b7dd1a029f..fa5f89bdd3ab 100644 --- a/src/pages/ReimbursementAccount/EnableStep.js +++ b/src/pages/ReimbursementAccount/EnableStep.js @@ -1,6 +1,7 @@ import React from 'react'; import {ScrollView} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; import lodashGet from 'lodash/get'; import styles from '../../styles/styles'; import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; @@ -29,11 +30,19 @@ const propTypes = { reimbursementAccount: ReimbursementAccountProps.reimbursementAccountPropTypes.isRequired, /* Onyx Props */ - user: userPropTypes.isRequired, + user: userPropTypes, + + /* The workspace name */ + policyName: PropTypes.string, ...withLocalizePropTypes, }; +const defaultProps = { + user: {}, + policyName: '', +}; + const EnableStep = (props) => { const isUsingExpensifyCard = props.user.isUsingExpensifyCard; const achData = lodashGet(props.reimbursementAccount, 'achData') || {}; @@ -49,6 +58,7 @@ const EnableStep = (props) => { { EnableStep.displayName = 'EnableStep'; EnableStep.propTypes = propTypes; +EnableStep.defaultProps = defaultProps; export default compose( withLocalize, diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js index 58511050b29e..1e61ceeeaabd 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js +++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js @@ -31,6 +31,7 @@ import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; import * as ReimbursementAccountProps from './reimbursementAccountPropTypes'; import reimbursementAccountDraftPropTypes from './ReimbursementAccountDraftPropTypes'; import * as ReimbursementAccountUtils from '../../libs/ReimbursementAccountUtils'; +import withPolicy from '../workspace/withPolicy'; const propTypes = { /** Plaid SDK token to use to initialize the widget */ @@ -52,7 +53,7 @@ const propTypes = { session: PropTypes.shape({ /** User login */ email: PropTypes.string, - }).isRequired, + }), /** Route object from navigation */ route: PropTypes.shape({ @@ -60,6 +61,7 @@ const propTypes = { params: PropTypes.shape({ /** A step to navigate to if we need to drop the user into a specific point in the flow */ stepToOpen: PropTypes.string, + policyID: PropTypes.string, }), }), @@ -71,9 +73,13 @@ const defaultProps = { reimbursementAccountDraft: {}, onfidoToken: '', plaidLinkToken: '', + session: { + email: null, + }, route: { params: { stepToOpen: '', + policyID: '', }, }, }; @@ -110,7 +116,9 @@ class ReimbursementAccountPage extends React.Component { // the route params when the component first mounts to jump to a specific route instead of picking up where the // user left off in the flow. BankAccounts.hideBankAccountErrors(); - Navigation.navigate(ROUTES.getBankAccountRoute(this.getRouteForCurrentStep(currentStep))); + Navigation.navigate(ROUTES.getBankAccountRoute( + this.getRouteForCurrentStep(currentStep), lodashGet(this.props.route.params, 'policyID'), + )); } /** @@ -247,6 +255,7 @@ class ReimbursementAccountPage extends React.Component { // next step. const achData = lodashGet(this.props.reimbursementAccount, 'achData', {}); const currentStep = achData.currentStep || CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT; + const policyName = lodashGet(this.props.policy, 'name'); // Don't show the loading indicator if we're offline and restarted the bank account setup process if (this.props.reimbursementAccount.isLoading && !(this.props.network.isOffline && currentStep === CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT)) { @@ -278,6 +287,7 @@ class ReimbursementAccountPage extends React.Component { reimbursementAccount={this.props.reimbursementAccount} continue={this.continue} startOver={() => this.setState({shouldHideContinueSetupButton: true})} + policyName={policyName} /> ); } @@ -310,6 +320,7 @@ class ReimbursementAccountPage extends React.Component { {errorComponent} @@ -325,6 +336,7 @@ class ReimbursementAccountPage extends React.Component { receivedRedirectURI={getPlaidOAuthReceivedRedirectURI()} plaidLinkOAuthToken={this.props.plaidLinkToken} getDefaultStateForField={this.getDefaultStateForField} + policyName={policyName} /> ); } @@ -376,7 +388,7 @@ class ReimbursementAccountPage extends React.Component { if (currentStep === CONST.BANK_ACCOUNT.STEP.ENABLE) { return ( - + ); } } @@ -405,4 +417,5 @@ export default compose( }, }), withLocalize, + withPolicy, )(ReimbursementAccountPage); diff --git a/src/pages/ReimbursementAccount/RequestorOnfidoStep.js b/src/pages/ReimbursementAccount/RequestorOnfidoStep.js index f03a3a2ad0a2..97411c0652b0 100644 --- a/src/pages/ReimbursementAccount/RequestorOnfidoStep.js +++ b/src/pages/ReimbursementAccount/RequestorOnfidoStep.js @@ -21,10 +21,12 @@ const propTypes = { ...StepPropTypes, /** The token required to initialize the Onfido SDK */ - onfidoToken: PropTypes.string.isRequired, + onfidoToken: PropTypes.string, }; -const defaultProps = {}; +const defaultProps = { + onfidoToken: null, +}; class RequestorOnfidoStep extends React.Component { constructor(props) { diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js index 5089b87c2742..ffb3e5b9cdfd 100644 --- a/src/pages/ReportDetailsPage.js +++ b/src/pages/ReportDetailsPage.js @@ -29,11 +29,6 @@ import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundVi const propTypes = { ...withLocalizePropTypes, - /** Whether or not to show the Compose Input */ - session: PropTypes.shape({ - accountID: PropTypes.number, - }).isRequired, - /** The report currently being looked at */ report: reportPropTypes.isRequired, @@ -41,7 +36,7 @@ const propTypes = { policies: PropTypes.shape({ /** Name of the policy */ name: PropTypes.string, - }).isRequired, + }), /** Route params */ route: PropTypes.shape({ @@ -52,7 +47,12 @@ const propTypes = { }).isRequired, /** Personal details of all the users */ - personalDetails: PropTypes.objectOf(participantPropTypes).isRequired, + personalDetails: PropTypes.objectOf(participantPropTypes), +}; + +const defaultProps = { + policies: {}, + personalDetails: {}, }; class ReportDetailsPage extends Component { @@ -139,7 +139,7 @@ class ReportDetailsPage extends Component { displayNamesWithTooltips={displayNamesWithTooltips} tooltipEnabled numberOfLines={1} - textStyles={[styles.textHeadline, styles.mb2, styles.textAlignCenter]} + textStyles={[styles.textHeadline, styles.mb2, styles.textAlignCenter, styles.pre]} shouldUseFullTitle={isChatRoom || isPolicyExpenseChat} /> @@ -149,6 +149,7 @@ class ReportDetailsPage extends Component { styles.optionAlternateText, styles.textLabelSupporting, styles.mb2, + styles.pre, ]} numberOfLines={1} > @@ -184,7 +185,7 @@ class ReportDetailsPage extends Component { } ReportDetailsPage.propTypes = propTypes; - +ReportDetailsPage.defaultProps = defaultProps; export default compose( withLocalize, withReportOrNotFound, @@ -195,8 +196,5 @@ export default compose( policies: { key: ONYXKEYS.COLLECTION.POLICY, }, - session: { - key: ONYXKEYS.SESSION, - }, }), )(ReportDetailsPage); diff --git a/src/pages/ReportParticipantsPage.js b/src/pages/ReportParticipantsPage.js index fb158c445a24..343b82b906e2 100755 --- a/src/pages/ReportParticipantsPage.js +++ b/src/pages/ReportParticipantsPage.js @@ -27,7 +27,7 @@ const propTypes = { /* Onyx Props */ /** The personal details of the person who is logged in */ - personalDetails: personalDetailsPropType.isRequired, + personalDetails: personalDetailsPropType, /** The active report */ report: reportPropTypes.isRequired, @@ -43,6 +43,10 @@ const propTypes = { ...withLocalizePropTypes, }; +const defaultProps = { + personalDetails: {}, +}; + /** * Returns all the participants in the active report * @@ -54,8 +58,8 @@ const getAllParticipants = (report, personalDetails) => { const {participants} = report; return _.map(participants, (login) => { - const userPersonalDetail = lodashGet(personalDetails, login, {displayName: login, avatar: ''}); const userLogin = Str.removeSMSDomain(login); + const userPersonalDetail = lodashGet(personalDetails, login, {displayName: userLogin, avatar: ''}); return ({ alternateText: userLogin, @@ -119,6 +123,7 @@ const ReportParticipantsPage = (props) => { }; ReportParticipantsPage.propTypes = propTypes; +ReportParticipantsPage.defaultProps = defaultProps; ReportParticipantsPage.displayName = 'ReportParticipantsPage'; export default compose( diff --git a/src/pages/ReportSettingsPage.js b/src/pages/ReportSettingsPage.js index 73469a441c1e..f3af110f6860 100644 --- a/src/pages/ReportSettingsPage.js +++ b/src/pages/ReportSettingsPage.js @@ -19,6 +19,7 @@ import Text from '../components/Text'; import RoomNameInput from '../components/RoomNameInput'; import Picker from '../components/Picker'; import * as ValidationUtils from '../libs/ValidationUtils'; +import * as ErrorUtils from '../libs/ErrorUtils'; import OfflineWithFeedback from '../components/OfflineWithFeedback'; import reportPropTypes from './reportPropTypes'; import withReportOrNotFound from './home/report/withReportOrNotFound'; @@ -48,7 +49,15 @@ const propTypes = { /** ID of the policy */ id: PropTypes.string, - }).isRequired, + }), + + /** All reports shared with the user */ + reports: PropTypes.objectOf(reportPropTypes), +}; + +const defaultProps = { + policies: {}, + reports: {}, }; class ReportSettingsPage extends Component { @@ -116,19 +125,18 @@ class ReportSettingsPage extends Component { return errors; } - // The following validations are ordered by precedence. - // First priority: We error if the user doesn't enter a room name or left blank if (!values.newRoomName || values.newRoomName === CONST.POLICY.ROOM_PREFIX) { - errors.newRoomName = this.props.translate('newRoomPage.pleaseEnterRoomName'); + // We error if the user doesn't enter a room name or left blank + ErrorUtils.addErrorMessage(errors, 'newRoomName', this.props.translate('newRoomPage.pleaseEnterRoomName')); + } else if (values.newRoomName !== CONST.POLICY.ROOM_PREFIX && !ValidationUtils.isValidRoomName(values.newRoomName)) { + // We error if the room name has invalid characters + ErrorUtils.addErrorMessage(errors, 'newRoomName', this.props.translate('newRoomPage.roomNameInvalidError')); } else if (ValidationUtils.isReservedRoomName(values.newRoomName)) { - // Second priority: Certain names are reserved for default rooms and should not be used for policy rooms. - errors.newRoomName = this.props.translate('newRoomPage.roomNameReservedError'); + // Certain names are reserved for default rooms and should not be used for policy rooms. + ErrorUtils.addErrorMessage(errors, 'newRoomName', this.props.translate('newRoomPage.roomNameReservedError', {reservedName: values.newRoomName})); } else if (ValidationUtils.isExistingRoomName(values.newRoomName, this.props.reports, this.props.report.policyID)) { - // Third priority: Show error if the room name already exists - errors.newRoomName = this.props.translate('newRoomPage.roomAlreadyExistsError'); - } else if (!ValidationUtils.isValidRoomName(values.newRoomName)) { - // Fourth priority: We error if the room name has invalid characters - errors.newRoomName = this.props.translate('newRoomPage.roomNameInvalidError'); + // Certain names are reserved for default rooms and should not be used for policy rooms. + ErrorUtils.addErrorMessage(errors, 'newRoomName', this.props.translate('newRoomPage.roomAlreadyExistsError')); } return errors; @@ -192,7 +200,7 @@ class ReportSettingsPage extends Component { {this.props.translate('newRoomPage.roomName')} - + {this.props.report.reportName} @@ -213,7 +221,7 @@ class ReportSettingsPage extends Component { {this.props.translate('workspace.common.workspace')} - + {linkedWorkspace.name}
@@ -243,7 +251,7 @@ class ReportSettingsPage extends Component { } ReportSettingsPage.propTypes = propTypes; - +ReportSettingsPage.defaultProps = defaultProps; export default compose( withLocalize, withReportOrNotFound, diff --git a/src/pages/SearchPage.js b/src/pages/SearchPage.js index 2e29a9395750..560ec388ef99 100755 --- a/src/pages/SearchPage.js +++ b/src/pages/SearchPage.js @@ -25,18 +25,13 @@ const propTypes = { /* Onyx Props */ /** Beta features list */ - betas: PropTypes.arrayOf(PropTypes.string).isRequired, + betas: PropTypes.arrayOf(PropTypes.string), /** All of the personal details for everyone */ - personalDetails: personalDetailsPropType.isRequired, + personalDetails: personalDetailsPropType, /** All reports shared with the user */ - reports: PropTypes.objectOf(reportPropTypes).isRequired, - - /** Session of currently logged in user */ - session: PropTypes.shape({ - email: PropTypes.string.isRequired, - }).isRequired, + reports: PropTypes.objectOf(reportPropTypes), /** Window Dimensions Props */ ...windowDimensionsPropTypes, @@ -44,6 +39,12 @@ const propTypes = { ...withLocalizePropTypes, }; +const defaultProps = { + betas: [], + personalDetails: {}, + reports: {}, +}; + class SearchPage extends Component { constructor(props) { super(props); @@ -199,6 +200,7 @@ class SearchPage extends Component { } SearchPage.propTypes = propTypes; +SearchPage.defaultProps = defaultProps; export default compose( withLocalize, @@ -210,9 +212,6 @@ export default compose( personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS, }, - session: { - key: ONYXKEYS.SESSION, - }, betas: { key: ONYXKEYS.BETAS, }, diff --git a/src/pages/ValidateLoginPage/index.js b/src/pages/ValidateLoginPage/index.js index 61c97ad4cd3d..df4e03e32aee 100644 --- a/src/pages/ValidateLoginPage/index.js +++ b/src/pages/ValidateLoginPage/index.js @@ -19,11 +19,20 @@ const propTypes = { /** List of betas available to current user */ betas: PropTypes.arrayOf(PropTypes.string), + + /** Session of currently logged in user */ + session: PropTypes.shape({ + /** Currently logged in user authToken */ + authToken: PropTypes.string, + }), }; const defaultProps = { route: validateLinkDefaultProps, betas: [], + session: { + authToken: null, + }, }; class ValidateLoginPage extends Component { diff --git a/src/pages/ValidateLoginPage/index.website.js b/src/pages/ValidateLoginPage/index.website.js index a15e13f232a9..47be8ee81835 100644 --- a/src/pages/ValidateLoginPage/index.website.js +++ b/src/pages/ValidateLoginPage/index.website.js @@ -9,11 +9,17 @@ import { import * as User from '../../libs/actions/User'; import compose from '../../libs/compose'; import FullScreenLoadingIndicator from '../../components/FullscreenLoadingIndicator'; -import ValidateCodeModal from '../../components/ValidateCodeModal'; +import ValidateCodeModal from '../../components/ValidateCode/ValidateCodeModal'; import ONYXKEYS from '../../ONYXKEYS'; import * as Session from '../../libs/actions/Session'; import Permissions from '../../libs/Permissions'; import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; +import AbracadabraModal from '../../components/ValidateCode/AbracadabraModal'; +import ExpiredValidateCodeModal from '../../components/ValidateCode/ExpiredValidateCodeModal'; +import Navigation from '../../libs/Navigation/Navigation'; +import ROUTES from '../../ROUTES'; +import CONST from '../../CONST'; +import TfaRequiredModal from '../../components/ValidateCode/TfaRequiredModal'; const propTypes = { /** The accountID and validateCode are passed via the URL */ @@ -22,83 +28,112 @@ const propTypes = { /** List of betas available to current user */ betas: PropTypes.arrayOf(PropTypes.string), + /** Session of currently logged in user */ + session: PropTypes.shape({ + /** Currently logged in user authToken */ + authToken: PropTypes.string, + }), + + /** The credentials of the logged in person */ + credentials: PropTypes.shape({ + /** The email the user logged in with */ + login: PropTypes.string, + + /** The validate code */ + validateCode: PropTypes.string, + }), + + /** The details about the account that the user is signing in with */ + account: PropTypes.shape({ + /** Whether a sign on form is loading (being submitted) */ + isLoading: PropTypes.bool, + }), + ...withLocalizePropTypes, }; const defaultProps = { route: validateLinkDefaultProps, betas: [], + session: { + authToken: null, + }, + credentials: {}, + account: {}, }; class ValidateLoginPage extends Component { - constructor(props) { - super(props); - - this.state = {justSignedIn: false}; - } - componentDidMount() { // Validate login if // - The user is not on passwordless beta - if (!this.isOnPasswordlessBeta()) { - User.validateLogin(this.accountID(), this.validateCode()); + if (!Permissions.canUsePasswordlessLogins(this.props.betas)) { + User.validateLogin(this.getAccountID(), this.getValidateCode()); return; } - // Sign in if - // - The user is on the passwordless beta - // - AND the user is not authenticated - // - AND the user has initiated the sign in process in another tab - if (this.isOnPasswordlessBeta() && !this.isAuthenticated() && this.isSignInInitiated()) { - Session.signInWithValidateCode(this.accountID(), this.validateCode()); + const isSignedIn = Boolean(lodashGet(this.props, 'session.authToken', null)); + const cachedAutoAuthState = lodashGet(this.props, 'session.autoAuthState', null); + const login = lodashGet(this.props, 'credentials.login', null); + if (!login && isSignedIn && cachedAutoAuthState === CONST.AUTO_AUTH_STATE.SIGNING_IN) { + // The user clicked the option to sign in the current tab + Navigation.navigate(ROUTES.REPORT); + return; } - } + Session.initAutoAuthState(cachedAutoAuthState); - componentDidUpdate(prevProps) { - if (!(prevProps.credentials && !prevProps.credentials.validateCode && this.props.credentials.validateCode)) { + if (isSignedIn || !login) { return; } - this.setState({justSignedIn: true}); + + // The user has initiated the sign in process on the same browser, in another tab. + Session.signInWithValidateCode(this.getAccountID(), this.getValidateCode()); } - /** - * @returns {Boolean} - */ - isOnPasswordlessBeta = () => Permissions.canUsePasswordlessLogins(this.props.betas); + componentDidUpdate() { + if ( + lodashGet(this.props, 'credentials.login', null) + || !lodashGet(this.props, 'credentials.accountID', null) + || !lodashGet(this.props, 'account.requiresTwoFactorAuth', false) + ) { + return; + } - /** - * @returns {String} - */ - accountID = () => lodashGet(this.props.route.params, 'accountID', ''); + // The user clicked the option to sign in the current tab + Navigation.navigate(ROUTES.REPORT); + } /** * @returns {String} */ - validateCode = () => lodashGet(this.props.route.params, 'validateCode', ''); + getAutoAuthState() { + return lodashGet(this.props, 'session.autoAuthState', CONST.AUTO_AUTH_STATE.NOT_STARTED); + } /** - * @returns {Boolean} + * @returns {String} */ - isAuthenticated = () => Boolean(lodashGet(this.props, 'session.authToken', null)); + getAccountID() { + return lodashGet(this.props.route.params, 'accountID', ''); + } /** - * Whether SignIn was initiated on the current browser. - * @returns {Boolean} + * @returns {String} */ - isSignInInitiated = () => !this.isAuthenticated() && lodashGet(this.props, 'credentials.login', null); + getValidateCode() { + return lodashGet(this.props.route.params, 'validateCode', ''); + } render() { + const isTfaRequired = lodashGet(this.props, 'account.requiresTwoFactorAuth', false); + const isSignedIn = Boolean(lodashGet(this.props, 'session.authToken', null)); return ( - this.isOnPasswordlessBeta() && !this.isSignInInitiated() && !lodashGet(this.props, 'account.isLoading', true) - ? ( - Session.signInWithValidateCodeAndNavigate(this.accountID(), this.validateCode())} - /> - ) - : + <> + {this.getAutoAuthState() === CONST.AUTO_AUTH_STATE.FAILED && } + {this.getAutoAuthState() === CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN && (!isTfaRequired || isSignedIn) && } + {this.getAutoAuthState() === CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN && isTfaRequired && !isSignedIn && } + {this.getAutoAuthState() === CONST.AUTO_AUTH_STATE.NOT_STARTED && } + {this.getAutoAuthState() === CONST.AUTO_AUTH_STATE.SIGNING_IN && } + ); } } diff --git a/src/pages/YearPickerPage.js b/src/pages/YearPickerPage.js new file mode 100644 index 000000000000..35ea89382355 --- /dev/null +++ b/src/pages/YearPickerPage.js @@ -0,0 +1,110 @@ +import _ from 'underscore'; +import React from 'react'; +import {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../components/withCurrentUserPersonalDetails'; +import ScreenWrapper from '../components/ScreenWrapper'; +import HeaderWithCloseButton from '../components/HeaderWithCloseButton'; +import withLocalize, {withLocalizePropTypes} from '../components/withLocalize'; +import ROUTES from '../ROUTES'; +import styles from '../styles/styles'; +import Navigation from '../libs/Navigation/Navigation'; +import OptionsSelector from '../components/OptionsSelector'; +import themeColors from '../styles/themes/default'; +import * as Expensicons from '../components/Icon/Expensicons'; +import CONST from '../CONST'; + +const greenCheckmark = {src: Expensicons.Checkmark, color: themeColors.success}; + +const propTypes = { + ...withLocalizePropTypes, + ...withCurrentUserPersonalDetailsPropTypes, +}; + +const defaultProps = { + ...withCurrentUserPersonalDetailsDefaultProps, +}; + +class YearPickerPage extends React.Component { + constructor(props) { + super(props); + + const {params} = props.route; + const minYear = Number(params.min); + const maxYear = Number(params.max); + const currentYear = Number(params.year); + + this.currentYear = currentYear; + this.yearList = _.map(Array.from({length: (maxYear - minYear) + 1}, (v, i) => i + minYear), value => ({ + text: value.toString(), + value, + keyForList: value.toString(), + + // Include the green checkmark icon to indicate the currently selected value + customIcon: value === currentYear ? greenCheckmark : undefined, + + // This property will make the currently selected value have bold text + boldStyle: value === currentYear, + })); + + this.updateYearOfBirth = this.updateSelectedYear.bind(this); + this.filterYearList = this.filterYearList.bind(this); + + this.state = { + inputText: '', + yearOptions: this.yearList, + }; + } + + /** + * Function called on selection of the year, to take user back to the previous screen + * + * @param {String} selectedYear + */ + updateSelectedYear(selectedYear) { + // We have to navigate using concatenation here as it is not possible to pass a function as a route param + Navigation.navigate(`${this.props.route.params.backTo}?year=${selectedYear}`); + } + + /** + * Function filtering the list of the items when using search input + * + * @param {String} text + */ + filterYearList(text) { + this.setState({ + inputText: text, + yearOptions: _.filter(this.yearList, year => year.text.includes(text.trim())), + }); + } + + render() { + return ( + + Navigation.navigate(`${this.props.route.params.backTo}?year=${this.currentYear}` || ROUTES.HOME)} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> + this.updateSelectedYear(option.value)} + initiallyFocusedOptionKey={this.currentYear.toString()} + hideSectionHeaders + optionHoveredStyle={styles.hoveredComponentBG} + shouldHaveOptionSeparator + contentContainerStyles={[styles.ph5]} + /> + + ); + } +} + +YearPickerPage.propTypes = propTypes; +YearPickerPage.defaultProps = defaultProps; + +export default withLocalize(YearPickerPage); diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index e1bfd267d8f2..0c5066e652c5 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -46,6 +46,12 @@ const propTypes = { /** Personal details of all the users */ personalDetails: PropTypes.objectOf(participantPropTypes), + /** The details about the account that the user is signing in with */ + account: PropTypes.shape({ + /** URL to the assigned guide's appointment booking calendar */ + guideCalendarLink: PropTypes.string, + }), + ...windowDimensionsPropTypes, ...withLocalizePropTypes, }; @@ -54,6 +60,9 @@ const defaultProps = { personalDetails: {}, policies: {}, report: null, + account: { + guideCalendarLink: null, + }, }; const HeaderView = (props) => { @@ -123,7 +132,7 @@ const HeaderView = (props) => { displayNamesWithTooltips={displayNamesWithTooltips} tooltipEnabled numberOfLines={1} - textStyles={[styles.headerText, styles.textNoWrap]} + textStyles={[styles.headerText, styles.pre]} shouldUseFullTitle={isChatRoom || isPolicyExpenseChat} /> {(isChatRoom || isPolicyExpenseChat) && ( @@ -132,6 +141,7 @@ const HeaderView = (props) => { styles.sidebarLinkText, styles.optionAlternateText, styles.textLabelSupporting, + styles.pre, ]} numberOfLines={1} > diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 5ef7531cbcf7..ec0cebb3833b 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -34,6 +34,7 @@ import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoun import ReportHeaderSkeletonView from '../../components/ReportHeaderSkeletonView'; import withViewportOffsetTop, {viewportOffsetTopPropTypes} from '../../components/withViewportOffsetTop'; import * as ReportActionsUtils from '../../libs/ReportActionsUtils'; +import personalDetailsPropType from '../personalDetailsPropType'; const propTypes = { /** Navigation route context info provided by react navigation */ @@ -72,6 +73,12 @@ const propTypes = { /** Information about the network */ network: networkPropTypes.isRequired, + /** The account manager report ID */ + accountManagerReportID: PropTypes.string, + + /** All of the personal details for everyone */ + personalDetails: PropTypes.objectOf(personalDetailsPropType), + ...windowDimensionsPropTypes, ...withDrawerPropTypes, ...viewportOffsetTopPropTypes, @@ -87,6 +94,8 @@ const defaultProps = { isComposerFullSize: false, betas: [], policies: {}, + accountManagerReportID: null, + personalDetails: {}, }; /** @@ -125,7 +134,13 @@ class ReportScreen extends React.Component { } componentDidUpdate(prevProps) { - if (this.props.route.params.reportID === prevProps.route.params.reportID) { + // If you already have a report open and are deeplinking to a new report on native, + // the ReportScreen never actually unmounts and the reportID in the route also doesn't change. + // Therefore, we need to compare if the existing reportID is the same as the one in the route + // before deciding that we shouldn't call OpenReport. + const onyxReportID = this.props.report.reportID; + const routeReportID = getReportID(this.props.route); + if (onyxReportID === prevProps.report.reportID && (!onyxReportID || onyxReportID === routeReportID)) { return; } @@ -133,6 +148,10 @@ class ReportScreen extends React.Component { toggleReportActionComposeView(true); } + componentWillUnmount() { + Navigation.resetIsReportScreenReadyPromise(); + } + /** * @param {String} text */ @@ -150,16 +169,22 @@ class ReportScreen extends React.Component { // This is necessary so that when we are retrieving the next report data from Onyx the ReportActionsView will remount completely const isTransitioning = this.props.report && this.props.report.reportID !== reportIDFromPath; - return reportIDFromPath && this.props.report.reportID && !isTransitioning; + return reportIDFromPath !== '' && this.props.report.reportID && !isTransitioning; } fetchReportIfNeeded() { const reportIDFromPath = getReportID(this.props.route); + // Report ID will be empty when the reports collection is empty. + // This could happen when we are loading the collection for the first time after logging in. + if (!reportIDFromPath) { + return; + } + // It possible that we may not have the report object yet in Onyx yet e.g. we navigated to a URL for an accessible report that // is not stored locally yet. If props.report.reportID exists, then the report has been stored locally and nothing more needs to be done. // If it doesn't exist, then we fetch the report from the API. - if (this.props.report.reportID) { + if (this.props.report.reportID && this.props.report.reportID === getReportID(this.props.route)) { return; } @@ -175,18 +200,6 @@ class ReportScreen extends React.Component { } render() { - if (!this.props.isSidebarLoaded || _.isEmpty(this.props.personalDetails)) { - return null; - } - - if (ReportUtils.isDefaultRoom(this.props.report) && !ReportUtils.canSeeDefaultRoom(this.props.report, this.props.policies, this.props.betas)) { - return null; - } - - if (!Permissions.canUsePolicyRooms(this.props.betas) && ReportUtils.isUserCreatedPolicyRoom(this.props.report)) { - return null; - } - // We are either adding a workspace room, or we're creating a chat, it isn't possible for both of these to be pending, or to have errors for the same report at the same time, so // simply looking up the first truthy value for each case will get the relevant property if it's set. const reportID = getReportID(this.props.route); @@ -197,31 +210,38 @@ class ReportScreen extends React.Component { // There are no reportActions at all to display and we are still in the process of loading the next set of actions. const isLoadingInitialReportActions = _.isEmpty(this.props.reportActions) && this.props.report.isLoadingReportActions; + // Users not in the Default Room or Policy Room Betas can't view the report + const shouldHideReport = ( + ReportUtils.isDefaultRoom(this.props.report) && !ReportUtils.canSeeDefaultRoom(this.props.report, this.props.policies, this.props.betas)) + || (ReportUtils.isUserCreatedPolicyRoom(this.props.report) && !Permissions.canUsePolicyRooms(this.props.betas)); + // When the ReportScreen is not open/in the viewport, we want to "freeze" it for performance reasons - const freeze = this.props.isSmallScreenWidth && this.props.isDrawerOpen; + const shouldFreeze = this.props.isSmallScreenWidth && this.props.isDrawerOpen; + + const isLoading = !reportID || !this.props.isSidebarLoaded || _.isEmpty(this.props.personalDetails); // the moment the ReportScreen becomes unfrozen we want to start the animation of the placeholder skeleton content // (which is shown, until all the actual views of the ReportScreen have been rendered) - const animatePlaceholder = !freeze; + const shouldAnimate = !shouldFreeze; return ( - + - + )} > - - Navigation.navigate(ROUTES.HOME)} - personalDetails={this.props.personalDetails} - report={this.props.report} - policies={this.props.policies} - /> - - {this.props.accountManagerReportID && ReportUtils.isConciergeChatReport(this.props.report) && this.state.isBannerVisible && ( - + {isLoading ? : ( + <> + + Navigation.navigate(ROUTES.HOME)} + personalDetails={this.props.personalDetails} + report={this.props.report} + policies={this.props.policies} + /> + + {this.props.accountManagerReportID && ReportUtils.isConciergeChatReport(this.props.report) && this.state.isBannerVisible && ( + + )} + )} - {(this.isReportReadyForDisplay() && !isLoadingInitialReportActions) && ( + {(this.isReportReadyForDisplay() && !isLoadingInitialReportActions && !isLoading) && ( <> @@ -319,9 +342,6 @@ export default compose( isSidebarLoaded: { key: ONYXKEYS.IS_SIDEBAR_LOADED, }, - session: { - key: ONYXKEYS.SESSION, - }, reportActions: { key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getReportID(route)}`, canEvict: false, diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js index 0075df0cdd31..a16a28d08c97 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js @@ -146,11 +146,9 @@ class PopoverReportActionContextMenu extends React.Component { // But it is possible that every new request registers new callbacks thus instanceID is used to corelate those callbacks this.instanceID = Math.random().toString(36).substr(2, 5); - // Register the onHide callback only when Popover is shown to remove the race conditions when there are mutltiple popover open requests - this.onPopoverShow = () => { - onShow(); - this.onPopoverHide = onHide; - }; + this.onPopoverShow = onShow; + this.onPopoverHide = onHide; + this.getContextMenuMeasuredLocation().then(({x, y}) => { this.setState({ cursorRelativePosition: { diff --git a/src/pages/home/report/ParticipantLocalTime.js b/src/pages/home/report/ParticipantLocalTime.js index a93d2e57adcc..ab1b3dcc4b5d 100644 --- a/src/pages/home/report/ParticipantLocalTime.js +++ b/src/pages/home/report/ParticipantLocalTime.js @@ -65,6 +65,7 @@ class ParticipantLocalTime extends PureComponent { style={[ styles.chatItemComposeSecondaryRowSubText, styles.chatItemComposeSecondaryRowOffset, + styles.pre, ]} numberOfLines={1} > diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index 3102ec9c5708..31876d1d6c68 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -98,6 +98,15 @@ const propTypes = { expiresAt: PropTypes.string, }), + /** Stores user's preferred skin tone */ + preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + + /** User's frequently used emojis */ + frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.shape({ + code: PropTypes.string.isRequired, + keywords: PropTypes.arrayOf(PropTypes.string), + })), + ...windowDimensionsPropTypes, ...withLocalizePropTypes, ...withCurrentUserPersonalDetailsPropTypes, @@ -112,6 +121,8 @@ const defaultProps = { reportActions: [], blockedFromConcierge: {}, personalDetails: {}, + preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, + frequentlyUsedEmojis: [], ...withCurrentUserPersonalDetailsDefaultProps, }; @@ -200,7 +211,7 @@ class ReportActionCompose extends React.Component { return; } - this.updateComment(''); + this.updateComment('', true); }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true); this.setMaxLines(); @@ -649,11 +660,6 @@ class ReportActionCompose extends React.Component { } render() { - // Waiting until ONYX variables are loaded before displaying the component - if (_.isEmpty(this.props.personalDetails)) { - return null; - } - const reportParticipants = _.without(lodashGet(this.props.report, 'participants', []), this.props.currentUserPersonalDetails.login); const participantsWithoutExpensifyEmails = _.difference(reportParticipants, CONST.EXPENSIFY_EMAILS); const reportRecipient = this.props.personalDetails[participantsWithoutExpensifyEmails[0]]; diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 78c6c142f4f8..d06e75de566f 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -263,6 +263,7 @@ class ReportActionItem extends Component { pendingAction={this.props.draftMessage ? null : this.props.action.pendingAction} errors={this.props.action.errors} errorRowStyles={[styles.ml10, styles.mr2]} + needsOffscreenAlphaCompositing={this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU} > {!this.props.displayAsGroup ? ( diff --git a/src/pages/home/report/ReportActionItemFragment.js b/src/pages/home/report/ReportActionItemFragment.js index c4f593517df5..04a57a1178a2 100644 --- a/src/pages/home/report/ReportActionItemFragment.js +++ b/src/pages/home/report/ReportActionItemFragment.js @@ -140,7 +140,7 @@ const ReportActionItemFragment = (props) => { {Str.htmlDecode(props.fragment.text)} diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index caa49da60196..544606abf3ee 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -31,12 +31,6 @@ const propTypes = { /** Array of report actions for this report */ reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), - /** The session of the logged in person */ - session: PropTypes.shape({ - /** Email of the logged in person */ - email: PropTypes.string, - }), - /** Whether the composer is full size */ isComposerFullSize: PropTypes.bool.isRequired, @@ -50,7 +44,6 @@ const propTypes = { const defaultProps = { reportActions: [], - session: {}, }; class ReportActionsView extends React.Component { diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index 4f1641498da8..20e67cd4d56c 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -1,3 +1,4 @@ +/* eslint-disable rulesdir/onyx-props-must-have-default */ import lodashGet from 'lodash/get'; import React from 'react'; import {View, TouchableOpacity} from 'react-native'; @@ -29,6 +30,7 @@ import LHNOptionsList from '../../../components/LHNOptionsList/LHNOptionsList'; import SidebarUtils from '../../../libs/SidebarUtils'; import reportPropTypes from '../../reportPropTypes'; import OfflineWithFeedback from '../../../components/OfflineWithFeedback'; +import LHNSkeletonView from '../../../components/LHNSkeletonView'; const propTypes = { /** Toggles the navigation menu open and closed */ @@ -129,12 +131,12 @@ class SidebarLinks extends React.Component { } render() { - // Wait until the personalDetails are actually loaded before displaying the LHN - if (_.isEmpty(this.props.personalDetails)) { - return null; - } + const isLoading = _.isEmpty(this.props.personalDetails) || _.isEmpty(this.props.chatReports); + const shouldFreeze = this.props.isSmallScreenWidth && !this.props.isDrawerOpen && this.isSidebarLoaded; const optionListItems = SidebarUtils.getOrderedReportIDs(this.props.reportIDFromRoute); + const skeletonPlaceholder = ; + return ( - - option.toString() === this.props.reportIDFromRoute - ))} - onSelectRow={this.showReportPage} - shouldDisableFocusOptions={this.props.isSmallScreenWidth} - optionMode={this.props.priorityMode === CONST.PRIORITY_MODE.GSD ? 'compact' : 'default'} - onLayout={() => { - this.props.onLayout(); - App.setSidebarLoaded(); - this.isSidebarLoaded = true; - }} - /> + + {isLoading ? skeletonPlaceholder : ( + option.toString() === this.props.reportIDFromRoute + ))} + onSelectRow={this.showReportPage} + shouldDisableFocusOptions={this.props.isSmallScreenWidth} + optionMode={this.props.priorityMode === CONST.PRIORITY_MODE.GSD ? CONST.OPTION_MODE.COMPACT : CONST.OPTION_MODE.DEFAULT} + onLayout={() => { + this.props.onLayout(); + App.setSidebarLoaded(); + this.isSidebarLoaded = true; + }} + /> + )} ); diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js index 6fe0447ef495..426a50e68285 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js @@ -47,6 +47,9 @@ const propTypes = { /* Beta features list */ betas: PropTypes.arrayOf(PropTypes.string), + /** Indicated whether the report data is loading */ + isLoading: PropTypes.bool, + ...withLocalizePropTypes, }; const defaultProps = { @@ -54,6 +57,7 @@ const defaultProps = { onShowCreateMenu: () => {}, allPolicies: {}, betas: [], + isLoading: false, }; /** @@ -207,7 +211,7 @@ class FloatingActionButtonAndPopover extends React.Component { onSelected: () => Navigation.navigate(ROUTES.IOU_BILL), }, ] : []), - ...(!Policy.hasActiveFreePolicy(this.props.allPolicies) ? [ + ...(!this.props.isLoading && !Policy.hasActiveFreePolicy(this.props.allPolicies) ? [ { icon: Expensicons.NewWorkspace, iconWidth: 46, @@ -247,5 +251,8 @@ export default compose( betas: { key: ONYXKEYS.BETAS, }, + isLoading: { + key: ONYXKEYS.IS_LOADING_REPORT_DATA, + }, }), )(FloatingActionButtonAndPopover); diff --git a/src/pages/iou/IOUDetailsModal.js b/src/pages/iou/IOUDetailsModal.js index e3380122db7a..e118cbf44d2e 100644 --- a/src/pages/iou/IOUDetailsModal.js +++ b/src/pages/iou/IOUDetailsModal.js @@ -67,7 +67,7 @@ const propTypes = { session: PropTypes.shape({ /** Currently logged in user email */ email: PropTypes.string, - }).isRequired, + }), /** Actions from the ChatReport */ reportActions: PropTypes.shape(reportActionPropTypes), @@ -75,6 +75,18 @@ const propTypes = { /** Information about the network */ network: networkPropTypes.isRequired, + /** chatReport associated with iouReport */ + chatReport: PropTypes.shape({ + /** Report ID associated with the transaction */ + reportID: PropTypes.string, + + /** The participants of this report */ + participants: PropTypes.arrayOf(PropTypes.string), + + /** Whether the chat report has an outstanding IOU */ + hasOutstandingIOU: PropTypes.bool.isRequired, + }), + ...withLocalizePropTypes, }; @@ -82,6 +94,12 @@ const defaultProps = { iou: {}, reportActions: {}, iouReport: undefined, + session: { + email: null, + }, + chatReport: { + participants: [], + }, }; class IOUDetailsModal extends Component { @@ -144,9 +162,10 @@ class IOUDetailsModal extends Component { // Finds if there is a reportAction pending for this IOU findPendingAction() { - return _.find(this.props.reportActions, reportAction => reportAction.originalMessage + const reportActionWithPendingAction = _.find(this.props.reportActions, reportAction => reportAction.originalMessage && Number(reportAction.originalMessage.IOUReportID) === Number(this.props.route.params.iouReportID) && !_.isEmpty(reportAction.pendingAction)); + return reportActionWithPendingAction ? reportActionWithPendingAction.pendingAction : undefined; } render() { diff --git a/src/pages/iou/IOUModal.js b/src/pages/iou/IOUModal.js index e658dec5ccf1..069ff51c7e57 100755 --- a/src/pages/iou/IOUModal.js +++ b/src/pages/iou/IOUModal.js @@ -56,12 +56,9 @@ const propTypes = { /** Whether or not transaction creation has resulted to error */ error: PropTypes.bool, - /** Flag to show a loading indicator and avoid showing a previously selected currency */ - isRetrievingCurrency: PropTypes.bool, - // Selected Currency Code of the current IOU selectedCurrencyCode: PropTypes.string, - }).isRequired, + }), /** Personal details of all the users */ personalDetails: PropTypes.shape({ @@ -73,7 +70,7 @@ const propTypes = { /** Avatar url of participant */ avatar: PropTypes.string, - }).isRequired, + }), /** Personal details of the current user */ currentUserPersonalDetails: PropTypes.shape({ @@ -93,6 +90,12 @@ const defaultProps = { currentUserPersonalDetails: { localCurrencyCode: CONST.CURRENCY.USD, }, + personalDetails: {}, + iou: { + creatingIOUTransaction: false, + error: false, + selectedCurrencyCode: null, + }, }; // Determines type of step to display within Modal, value provides the title for that page. diff --git a/src/pages/iou/steps/IOUAmountPage.js b/src/pages/iou/steps/IOUAmountPage.js index cd1d0ccac967..c95d5e8aedbd 100755 --- a/src/pages/iou/steps/IOUAmountPage.js +++ b/src/pages/iou/steps/IOUAmountPage.js @@ -36,17 +36,18 @@ const propTypes = { /** Holds data related to IOU view state, rather than the underlying IOU data. */ iou: PropTypes.shape({ - - /** Whether or not the IOU step is loading (retrieving users preferred currency) */ - loading: PropTypes.bool, - /** Selected Currency Code of the current IOU */ selectedCurrencyCode: PropTypes.string, - }).isRequired, + }), ...withLocalizePropTypes, }; +const defaultProps = { + iou: { + selectedCurrencyCode: CONST.CURRENCY.USD, + }, +}; class IOUAmountPage extends React.Component { constructor(props) { super(props); @@ -179,6 +180,8 @@ class IOUAmountPage extends React.Component { * @param {String} key */ updateAmountNumberPad(key) { + this.focusTextInput(); + // Backspace button is pressed if (key === '<' || key === 'Backspace') { if (this.state.amount.length > 0) { @@ -203,7 +206,7 @@ class IOUAmountPage extends React.Component { * @param {Boolean} value - Changed text from user input */ updateLongPressHandlerState(value) { - this.setState({shouldUpdateSelection: value}); + this.setState({shouldUpdateSelection: !value}); } /** @@ -270,7 +273,7 @@ class IOUAmountPage extends React.Component { placeholder={this.props.numberFormat(0)} preferredLocale={this.props.preferredLocale} ref={el => this.textInput = el} - selectedCurrencyCode={this.props.iou.selectedCurrencyCode || CONST.CURRENCY.USD} + selectedCurrencyCode={this.props.iou.selectedCurrencyCode} selection={this.state.selection} onSelectionChange={(e) => { if (!this.state.shouldUpdateSelection) { @@ -304,6 +307,7 @@ class IOUAmountPage extends React.Component { } IOUAmountPage.propTypes = propTypes; +IOUAmountPage.defaultProps = defaultProps; export default compose( withLocalize, diff --git a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsRequest.js b/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsRequest.js index c7f86ace9b4d..8d094f3cf466 100755 --- a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsRequest.js +++ b/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsRequest.js @@ -13,7 +13,7 @@ import reportPropTypes from '../../../reportPropTypes'; const propTypes = { /** Beta features list */ - betas: PropTypes.arrayOf(PropTypes.string).isRequired, + betas: PropTypes.arrayOf(PropTypes.string), /** Callback to inform parent modal of success */ onStepComplete: PropTypes.func.isRequired, @@ -22,10 +22,10 @@ const propTypes = { onAddParticipants: PropTypes.func.isRequired, /** All of the personal details for everyone */ - personalDetails: PropTypes.objectOf(personalDetailsPropType).isRequired, + personalDetails: PropTypes.objectOf(personalDetailsPropType), /** All reports shared with the user */ - reports: PropTypes.objectOf(reportPropTypes).isRequired, + reports: PropTypes.objectOf(reportPropTypes), /** padding bottom style of safe area */ safeAreaPaddingBottomStyle: PropTypes.oneOfType([ @@ -38,6 +38,9 @@ const propTypes = { const defaultProps = { safeAreaPaddingBottomStyle: {}, + personalDetails: {}, + reports: {}, + betas: [], }; class IOUParticipantsRequest extends Component { diff --git a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsSplit.js b/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsSplit.js index 716aae99900c..fed5ed23c072 100755 --- a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsSplit.js +++ b/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsSplit.js @@ -17,7 +17,7 @@ import avatarPropTypes from '../../../../components/avatarPropTypes'; const propTypes = { /** Beta features list */ - betas: PropTypes.arrayOf(PropTypes.string).isRequired, + betas: PropTypes.arrayOf(PropTypes.string), /** Callback to inform parent modal of success */ onStepComplete: PropTypes.func.isRequired, @@ -38,10 +38,10 @@ const propTypes = { })), /** All of the personal details for everyone */ - personalDetails: PropTypes.objectOf(personalDetailsPropType).isRequired, + personalDetails: PropTypes.objectOf(personalDetailsPropType), /** All reports shared with the user */ - reports: PropTypes.objectOf(reportPropTypes).isRequired, + reports: PropTypes.objectOf(reportPropTypes), /** padding bottom style of safe area */ safeAreaPaddingBottomStyle: PropTypes.oneOfType([ @@ -54,6 +54,9 @@ const propTypes = { const defaultProps = { participants: [], + betas: [], + personalDetails: {}, + reports: {}, safeAreaPaddingBottomStyle: {}, }; diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js index 6d6db5a498f3..1fe9a18a8260 100755 --- a/src/pages/settings/InitialSettingsPage.js +++ b/src/pages/settings/InitialSettingsPage.js @@ -34,6 +34,7 @@ import ConfirmModal from '../../components/ConfirmModal'; import * as ReportUtils from '../../libs/ReportUtils'; import * as Link from '../../libs/actions/Link'; import OfflineWithFeedback from '../../components/OfflineWithFeedback'; +import policyMemberPropType from '../policyMemberPropType'; const propTypes = { /* Onyx Props */ @@ -80,6 +81,9 @@ const propTypes = { /** Information about the user accepting the terms for payments */ walletTerms: walletTermsPropTypes, + /** List of policy members */ + policyMembers: PropTypes.objectOf(policyMemberPropType), + ...withLocalizePropTypes, ...withCurrentUserPersonalDetailsPropTypes, }; @@ -92,6 +96,9 @@ const defaultProps = { }, betas: [], walletTerms: {}, + bankAccountList: {}, + cardList: {}, + policyMembers: {}, ...withCurrentUserPersonalDetailsDefaultProps, }; @@ -274,7 +281,7 @@ class InitialSettingsPage extends React.Component { - + {this.props.currentUserPersonalDetails.displayName ? this.props.currentUserPersonalDetails.displayName : Str.removeSMSDomain(this.props.session.email)} diff --git a/src/pages/settings/Payments/PaymentsPage/paymentsPagePropTypes.js b/src/pages/settings/Payments/PaymentsPage/paymentsPagePropTypes.js index eb60b010d151..20e090fd5a0f 100644 --- a/src/pages/settings/Payments/PaymentsPage/paymentsPagePropTypes.js +++ b/src/pages/settings/Payments/PaymentsPage/paymentsPagePropTypes.js @@ -7,6 +7,7 @@ import bankAccountPropTypes from '../../../../components/bankAccountPropTypes'; import cardPropTypes from '../../../../components/cardPropTypes'; import userWalletPropTypes from '../../../EnablePayments/userWalletPropTypes'; import walletTermsPropTypes from '../../../EnablePayments/walletTermsPropTypes'; +import paypalMeDataPropTypes from '../../../../components/paypalMeDataPropTypes'; const propTypes = { /** Wallet balance transfer props */ @@ -36,6 +37,9 @@ const propTypes = { /** Information about the user accepting the terms for payments */ walletTerms: walletTermsPropTypes, + /** Account details for PayPal.Me */ + payPalMeData: paypalMeDataPropTypes, + ...withLocalizePropTypes, ...windowDimensionsPropTypes, @@ -52,6 +56,7 @@ const defaultProps = { bankAccountList: {}, cardList: {}, walletTerms: {}, + payPalMeData: {}, }; export {propTypes, defaultProps}; diff --git a/src/pages/settings/Payments/TransferBalancePage.js b/src/pages/settings/Payments/TransferBalancePage.js index c438b9e3e781..d9bad8d84f77 100644 --- a/src/pages/settings/Payments/TransferBalancePage.js +++ b/src/pages/settings/Payments/TransferBalancePage.js @@ -176,7 +176,7 @@ class TransferBalancePage extends React.Component { const transferAmount = this.props.userWallet.currentBalance - calculatedFee; const isTransferable = transferAmount > 0; const isButtonDisabled = !isTransferable || !selectedAccount; - const errorMessage = !_.isEmpty(this.props.walletTransfer.errors) ? _.chain(this.props.walletTransfer.errors).values().first().value() : this.props.walletTransfer.error; + const errorMessage = !_.isEmpty(this.props.walletTransfer.errors) ? _.chain(this.props.walletTransfer.errors).values().first().value() : ''; return ( diff --git a/src/pages/settings/Preferences/PriorityModePage.js b/src/pages/settings/Preferences/PriorityModePage.js index 2cd810d4f447..dc26cea4f4c4 100644 --- a/src/pages/settings/Preferences/PriorityModePage.js +++ b/src/pages/settings/Preferences/PriorityModePage.js @@ -1,6 +1,7 @@ import _, {compose} from 'underscore'; import React from 'react'; import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton'; import ScreenWrapper from '../../../components/ScreenWrapper'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; @@ -13,13 +14,21 @@ import themeColors from '../../../styles/themes/default'; import * as Expensicons from '../../../components/Icon/Expensicons'; import ONYXKEYS from '../../../ONYXKEYS'; import * as User from '../../../libs/actions/User'; +import CONST from '../../../CONST'; const greenCheckmark = {src: Expensicons.Checkmark, color: themeColors.success}; const propTypes = { + /** The chat priority mode */ + priorityMode: PropTypes.string, + ...withLocalizePropTypes, }; +const defaultProps = { + priorityMode: CONST.PRIORITY_MODE.DEFAULT, +}; + const PriorityModePage = (props) => { const priorityModes = _.map(props.translate('priorityModePage.priorityModes'), (mode, key) => ( @@ -72,6 +81,7 @@ const PriorityModePage = (props) => { PriorityModePage.displayName = 'PriorityModePage'; PriorityModePage.propTypes = propTypes; +PriorityModePage.defaultProps = defaultProps; export default compose( withLocalize, diff --git a/src/pages/settings/Profile/DisplayNamePage.js b/src/pages/settings/Profile/DisplayNamePage.js index 9e4e948237f4..6e4ec48cbc98 100644 --- a/src/pages/settings/Profile/DisplayNamePage.js +++ b/src/pages/settings/Profile/DisplayNamePage.js @@ -16,6 +16,7 @@ import styles from '../../../styles/styles'; import Navigation from '../../../libs/Navigation/Navigation'; import * as PersonalDetails from '../../../libs/actions/PersonalDetails'; import compose from '../../../libs/compose'; +import * as ErrorUtils from '../../../libs/ErrorUtils'; const propTypes = { ...withLocalizePropTypes, @@ -58,9 +59,10 @@ class DisplayNamePage extends Component { // First we validate the first name field if (!ValidationUtils.isValidDisplayName(values.firstName)) { - errors.firstName = this.props.translate('personalDetails.error.hasInvalidCharacter'); - } else if (ValidationUtils.doesContainReservedWord(values.firstName, CONST.DISPLAY_NAME.RESERVED_FIRST_NAMES)) { - errors.firstName = this.props.translate('personalDetails.error.containsReservedWord'); + ErrorUtils.addErrorMessage(errors, 'firstName', this.props.translate('personalDetails.error.hasInvalidCharacter')); + } + if (ValidationUtils.doesContainReservedWord(values.firstName, CONST.DISPLAY_NAME.RESERVED_FIRST_NAMES)) { + ErrorUtils.addErrorMessage(errors, 'firstName', this.props.translate('personalDetails.error.containsReservedWord')); } // Then we validate the last name field diff --git a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js index 260152188ff5..68ac9250aa98 100644 --- a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js +++ b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js @@ -1,7 +1,8 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; -import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import moment from 'moment'; +import _ from 'underscore'; import ScreenWrapper from '../../../../components/ScreenWrapper'; import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton'; import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; @@ -13,7 +14,8 @@ 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'; +import NewDatePicker from '../../../../components/NewDatePicker'; +import CONST from '../../../../CONST'; const propTypes = { /* Onyx Props */ @@ -38,6 +40,39 @@ class DateOfBirthPage extends Component { this.validate = this.validate.bind(this); this.updateDateOfBirth = this.updateDateOfBirth.bind(this); + this.clearSelectedYear = this.clearSelectedYear.bind(this); + this.getYearFromRouteParams = this.getYearFromRouteParams.bind(this); + this.minDate = moment().subtract(CONST.DATE_BIRTH.MAX_AGE, 'Y').toDate(); + this.maxDate = moment().subtract(CONST.DATE_BIRTH.MIN_AGE, 'Y').toDate(); + + this.state = { + selectedYear: '', + }; + } + + componentDidMount() { + this.props.navigation.addListener('focus', this.getYearFromRouteParams); + } + + componentWillUnmount() { + this.props.navigation.removeListener('focus', this.getYearFromRouteParams); + } + + /** + * Function to be called to read year from params - necessary to read passed year from the Year picker which is a separate screen + * It allows to display selected year in the calendar picker without overwriting this value in Onyx + */ + getYearFromRouteParams() { + const {params} = this.props.route; + if (params && params.year) { + this.setState({selectedYear: params.year}); + if (this.datePicker) { + this.datePicker.focus(); + if (_.isFunction(this.datePicker.click)) { + this.datePicker.click(); + } + } + } } /** @@ -51,6 +86,13 @@ class DateOfBirthPage extends Component { ); } + /** + * A function to clear selected year + */ + clearSelectedYear() { + this.setState({selectedYear: ''}); + } + /** * @param {Object} values * @param {String} values.dob - date of birth @@ -58,8 +100,8 @@ class DateOfBirthPage extends Component { */ validate(values) { const errors = {}; - const minimumAge = 5; - const maximumAge = 150; + const minimumAge = CONST.DATE_BIRTH.MIN_AGE; + const maximumAge = CONST.DATE_BIRTH.MAX_AGE; if (!values.dob || !ValidationUtils.isValidDate(values.dob)) { errors.dob = this.props.translate('common.error.fieldRequired'); @@ -91,15 +133,16 @@ class DateOfBirthPage extends Component { submitButtonText={this.props.translate('common.save')} enabledWhenOffline > - - - + this.datePicker = ref} + inputID="dob" + label={this.props.translate('common.date')} + defaultValue={privateDetails.dob || ''} + minDate={this.minDate} + maxDate={this.maxDate} + selectedYear={this.state.selectedYear} + onHidePicker={this.clearSelectedYear} + /> ); diff --git a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js index dee460123f1e..703be9de06ae 100644 --- a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js +++ b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js @@ -66,14 +66,14 @@ class LegalNamePage extends Component { validate(values) { const errors = {}; - if (!ValidationUtils.isValidDisplayName(values.legalFirstName)) { - errors.legalFirstName = this.props.translate('personalDetails.error.hasInvalidCharacter'); + if (!ValidationUtils.isValidLegalName(values.legalFirstName)) { + errors.legalFirstName = this.props.translate('privatePersonalDetails.error.hasInvalidCharacter'); } else if (_.isEmpty(values.legalFirstName)) { errors.legalFirstName = this.props.translate('common.error.fieldRequired'); } - if (!ValidationUtils.isValidDisplayName(values.legalLastName)) { - errors.legalLastName = this.props.translate('personalDetails.error.hasInvalidCharacter'); + if (!ValidationUtils.isValidLegalName(values.legalLastName)) { + errors.legalLastName = this.props.translate('privatePersonalDetails.error.hasInvalidCharacter'); } else if (_.isEmpty(values.legalLastName)) { errors.legalLastName = this.props.translate('common.error.fieldRequired'); } diff --git a/src/pages/settings/Security/CloseAccountPage.js b/src/pages/settings/Security/CloseAccountPage.js index 676f645242ba..56a5c27e12a0 100644 --- a/src/pages/settings/Security/CloseAccountPage.js +++ b/src/pages/settings/Security/CloseAccountPage.js @@ -27,11 +27,18 @@ const propTypes = { session: PropTypes.shape({ /** Email address */ email: PropTypes.string.isRequired, - }).isRequired, + }), ...windowDimensionsPropTypes, ...withLocalizePropTypes, }; + +const defaultProps = { + session: { + email: null, + }, +}; + class CloseAccountPage extends Component { constructor(props) { super(props); @@ -139,6 +146,7 @@ class CloseAccountPage extends Component { } CloseAccountPage.propTypes = propTypes; +CloseAccountPage.defaultProps = defaultProps; export default compose( withLocalize, diff --git a/src/pages/signin/ResendValidationForm.js b/src/pages/signin/ResendValidationForm.js index c6230e951be6..01f5cf7ea37e 100755 --- a/src/pages/signin/ResendValidationForm.js +++ b/src/pages/signin/ResendValidationForm.js @@ -25,7 +25,7 @@ const propTypes = { credentials: PropTypes.shape({ /** The email/phone the user logged in with */ login: PropTypes.string, - }).isRequired, + }), /** The details about the account that the user is signing in with */ account: PropTypes.shape({ @@ -43,6 +43,7 @@ const propTypes = { }; const defaultProps = { + credentials: {}, account: {}, }; diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js index 323c166b3ec4..f8c1ccaffa0b 100644 --- a/src/pages/signin/SignInPage.js +++ b/src/pages/signin/SignInPage.js @@ -1,6 +1,7 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; +import lodashGet from 'lodash/get'; import Str from 'expensify-common/lib/str'; import {SafeAreaView} from 'react-native-safe-area-context'; import ONYXKEYS from '../../ONYXKEYS'; @@ -90,7 +91,7 @@ class SignInPage extends Component { // We will only know this after a user signs in successfully, without their 2FA code welcomeText = this.props.translate('validateCodeForm.enterAuthenticatorCode'); } else { - const userLogin = Str.removeSMSDomain(this.props.credentials.login); + const userLogin = Str.removeSMSDomain(lodashGet(this.props, 'credentials.login', '')); welcomeText = this.props.account.validated ? this.props.translate('welcomeText.welcomeBackEnterMagicCode', {login: userLogin}) : this.props.translate('welcomeText.welcomeEnterMagicCode', {login: userLogin}); diff --git a/src/pages/signin/SignInPageLayout/Footer.js b/src/pages/signin/SignInPageLayout/Footer.js index 0f6e099bc8c0..99ace5d47176 100644 --- a/src/pages/signin/SignInPageLayout/Footer.js +++ b/src/pages/signin/SignInPageLayout/Footer.js @@ -13,12 +13,21 @@ import Licenses from '../Licenses'; import Socials from '../Socials'; import Hoverable from '../../../components/Hoverable'; import CONST from '../../../CONST'; +import Navigation from '../../../libs/Navigation/Navigation'; +import * as Session from '../../../libs/actions/Session'; const propTypes = { ...windowDimensionsPropTypes, ...withLocalizePropTypes, }; +const navigateHome = () => { + Navigation.navigate(); + + // We need to clear sign in data in case the user is already in the ValidateCodeForm or PasswordForm pages + Session.clearSignInData(); +}; + const columns = [ { translationPath: 'footer.features', @@ -123,11 +132,11 @@ const columns = [ translationPath: 'footer.getStarted', rows: [ { - link: CONST.NEW_EXPENSIFY_URL, + onPress: navigateHome, translationPath: 'footer.createAccount', }, { - link: CONST.NEW_EXPENSIFY_URL, + onPress: navigateHome, translationPath: 'footer.logIn', }, ], @@ -165,6 +174,7 @@ const Footer = (props) => { {props.translate(row.translationPath)} diff --git a/src/pages/signin/Socials.js b/src/pages/signin/Socials.js index a8f81e9e251e..a8dad2ca4821 100644 --- a/src/pages/signin/Socials.js +++ b/src/pages/signin/Socials.js @@ -1,6 +1,5 @@ import React from 'react'; import _ from 'underscore'; -import {Pressable, Linking} from 'react-native'; import Icon from '../../components/Icon'; import Text from '../../components/Text'; import * as Expensicons from '../../components/Icon/Expensicons'; @@ -8,6 +7,8 @@ import themeColors from '../../styles/themes/default'; import styles from '../../styles/styles'; import variables from '../../styles/variables'; import CONST from '../../CONST'; +import Hoverable from '../../components/Hoverable'; +import TextLink from '../../components/TextLink'; const socialsList = [ { @@ -35,22 +36,23 @@ const socialsList = [ const Socials = () => ( {_.map(socialsList, social => ( - { - Linking.openURL(social.link); - }} - style={styles.pr1} + - {({hovered}) => ( - + {hovered => ( + + + )} - + ))} ); diff --git a/src/pages/wallet/WalletStatementPage.js b/src/pages/wallet/WalletStatementPage.js index faf7c863b288..7c09d14b6ffe 100644 --- a/src/pages/wallet/WalletStatementPage.js +++ b/src/pages/wallet/WalletStatementPage.js @@ -40,6 +40,9 @@ const propTypes = { /** Information about the network */ network: networkPropTypes.isRequired, + /** Indicates which locale the user currently has selected */ + preferredLocale: PropTypes.string, + ...withLocalizePropTypes, }; @@ -47,6 +50,7 @@ const defaultProps = { walletStatement: { isGenerating: false, }, + preferredLocale: CONST.DEFAULT_LOCALE, }; class WalletStatementPage extends React.Component { @@ -83,7 +87,7 @@ class WalletStatementPage extends React.Component { } render() { - moment.locale(lodashGet(this.props, 'preferredLocale', 'en')); + moment.locale(this.props.preferredLocale); const year = this.yearMonth.substring(0, 4) || moment().year(); const month = this.yearMonth.substring(4) || moment().month(); const monthName = moment(month, 'M').format('MMMM'); diff --git a/src/pages/workspace/WorkspaceInitialPage.js b/src/pages/workspace/WorkspaceInitialPage.js index fea68a5af262..35239a2abf0b 100644 --- a/src/pages/workspace/WorkspaceInitialPage.js +++ b/src/pages/workspace/WorkspaceInitialPage.js @@ -38,6 +38,7 @@ const propTypes = { }; const defaultProps = { + reports: {}, ...policyDefaultProps, }; @@ -220,6 +221,7 @@ class WorkspaceInitialPage extends React.Component { style={[ styles.textHeadline, styles.alignSelfCenter, + styles.pre, ]} > {this.props.policy.name} diff --git a/src/pages/workspace/WorkspaceInvitePage.js b/src/pages/workspace/WorkspaceInvitePage.js index 5eee3282ef09..e1fbdbeda817 100644 --- a/src/pages/workspace/WorkspaceInvitePage.js +++ b/src/pages/workspace/WorkspaceInvitePage.js @@ -42,10 +42,10 @@ const personalDetailsPropTypes = PropTypes.shape({ const propTypes = { /** Beta features list */ - betas: PropTypes.arrayOf(PropTypes.string).isRequired, + betas: PropTypes.arrayOf(PropTypes.string), /** All of the personal details for everyone */ - personalDetails: PropTypes.objectOf(personalDetailsPropTypes).isRequired, + personalDetails: PropTypes.objectOf(personalDetailsPropTypes), /** URL Route params */ route: PropTypes.shape({ @@ -61,7 +61,11 @@ const propTypes = { network: networkPropTypes.isRequired, }; -const defaultProps = policyDefaultProps; +const defaultProps = { + personalDetails: {}, + betas: [], + ...policyDefaultProps, +}; class WorkspaceInvitePage extends React.Component { constructor(props) { @@ -268,7 +272,7 @@ class WorkspaceInvitePage extends React.Component { this.setState({shouldDisableButton: true}, () => { const logins = _.map(this.state.selectedOptions, option => option.login); const filteredLogins = _.uniq(_.compact(_.map(logins, login => login.toLowerCase().trim()))); - Policy.addMembersToWorkspace(filteredLogins, this.state.welcomeNote || this.getWelcomeNote(), this.props.route.params.policyID); + Policy.addMembersToWorkspace(filteredLogins, this.state.welcomeNote, this.props.route.params.policyID); Navigation.goBack(); }); } diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js index 0e0804c013d7..812a817dc503 100644 --- a/src/pages/workspace/WorkspaceMembersPage.js +++ b/src/pages/workspace/WorkspaceMembersPage.js @@ -2,7 +2,7 @@ import React from 'react'; import _ from 'underscore'; import lodashGet from 'lodash/get'; import { - View, FlatList, TouchableOpacity, + View, TouchableOpacity, } from 'react-native'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; @@ -32,6 +32,7 @@ import networkPropTypes from '../../components/networkPropTypes'; import * as ReportUtils from '../../libs/ReportUtils'; import FormHelpMessage from '../../components/FormHelpMessage'; import TextInput from '../../components/TextInput'; +import KeyboardDismissingFlatList from '../../components/KeyboardDismissingFlatList'; const propTypes = { /** The personal details of the person who is logged in */ @@ -46,13 +47,25 @@ const propTypes = { }), }).isRequired, + /** Session info for the currently logged in user. */ + session: PropTypes.shape({ + /** Currently logged in user email */ + email: PropTypes.string, + }), + ...policyPropTypes, ...withLocalizePropTypes, ...windowDimensionsPropTypes, network: networkPropTypes.isRequired, }; -const defaultProps = policyDefaultProps; +const defaultProps = { + personalDetails: {}, + session: { + email: null, + }, + ...policyDefaultProps, +}; class WorkspaceMembersPage extends React.Component { constructor(props) { @@ -153,15 +166,14 @@ class WorkspaceMembersPage extends React.Component { } /** - * Add or remove all users from the selectedEmployees list + * Add or remove all users passed from the selectedEmployees list + * @param {Object} memberList */ - toggleAllUsers() { - let policyMemberList = lodashGet(this.props, 'policyMemberList', {}); - policyMemberList = _.filter(_.keys(policyMemberList), policyMember => policyMemberList[policyMember].pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); - const removableMembers = _.without(policyMemberList, this.props.session.email, this.props.policy.owner); + toggleAllUsers(memberList) { + const emailList = _.keys(memberList); this.setState(prevState => ({ - selectedEmployees: !_.every(removableMembers, member => _.contains(prevState.selectedEmployees, member)) - ? removableMembers + selectedEmployees: !_.every(emailList, memberEmail => _.contains(prevState.selectedEmployees, memberEmail)) + ? emailList : [], }), () => this.validate()); } @@ -306,12 +318,9 @@ class WorkspaceMembersPage extends React.Component { render() { const policyMemberList = lodashGet(this.props, 'policyMemberList', {}); - const removableMembers = []; + const removableMembers = {}; let data = []; _.each(policyMemberList, (policyMember, email) => { - if (email !== this.props.session.email && email !== this.props.policy.owner && policyMember.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - removableMembers.push(email); - } const details = lodashGet(this.props.personalDetails, email, {displayName: email, login: email}); data.push({ ...policyMember, @@ -325,6 +334,13 @@ class WorkspaceMembersPage extends React.Component { || this.isKeywordMatch(member.phoneNumber, searchValue) || this.isKeywordMatch(member.firstName, searchValue) || this.isKeywordMatch(member.lastName, searchValue)); + + _.each(data, (member) => { + if (member.login === this.props.session.email || member.login === this.props.policy.owner || member.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return; + } + removableMembers[member.login] = member; + }); const policyID = lodashGet(this.props.route, 'params.policyID'); const policyName = lodashGet(this.props.policy, 'name'); @@ -388,8 +404,9 @@ class WorkspaceMembersPage extends React.Component { _.contains(this.state.selectedEmployees, member))} - onPress={() => this.toggleAllUsers()} + isChecked={!_.isEmpty(removableMembers) + && _.every(_.keys(removableMembers), memberEmail => _.contains(this.state.selectedEmployees, memberEmail))} + onPress={() => this.toggleAllUsers(removableMembers)} /> @@ -398,7 +415,7 @@ class WorkspaceMembersPage extends React.Component {
- item.login} diff --git a/src/pages/workspace/WorkspaceNewRoomPage.js b/src/pages/workspace/WorkspaceNewRoomPage.js index 4db4670ac63e..61ebf4fc23b3 100644 --- a/src/pages/workspace/WorkspaceNewRoomPage.js +++ b/src/pages/workspace/WorkspaceNewRoomPage.js @@ -17,6 +17,7 @@ import CONST from '../../CONST'; import Text from '../../components/Text'; import Permissions from '../../libs/Permissions'; import Log from '../../libs/Log'; +import * as ErrorUtils from '../../libs/ErrorUtils'; import * as ValidationUtils from '../../libs/ValidationUtils'; import Form from '../../components/Form'; import shouldDelayFocus from '../../libs/shouldDelayFocus'; @@ -32,15 +33,29 @@ const propTypes = { /** ID of the policy */ policyID: PropTypes.string, - }).isRequired, + }), /** List of betas available to current user */ betas: PropTypes.arrayOf(PropTypes.string), + /** The list of policies the user has access to. */ + policies: PropTypes.objectOf(PropTypes.shape({ + /** The policy type */ + type: PropTypes.oneOf(_.values(CONST.POLICY.TYPE)), + + /** The name of the policy */ + name: PropTypes.string, + + /** The ID of the policy */ + id: PropTypes.string, + })), + ...withLocalizePropTypes, }; const defaultProps = { betas: [], + reports: {}, + policies: {}, }; class WorkspaceNewRoomPage extends React.Component { @@ -82,19 +97,18 @@ class WorkspaceNewRoomPage extends React.Component { validate(values) { const errors = {}; - // The following validations are ordered by precedence. - // First priority: We error if the user doesn't enter a room name or left blank if (!values.roomName || values.roomName === CONST.POLICY.ROOM_PREFIX) { - errors.roomName = this.props.translate('newRoomPage.pleaseEnterRoomName'); + // We error if the user doesn't enter a room name or left blank + ErrorUtils.addErrorMessage(errors, 'roomName', this.props.translate('newRoomPage.pleaseEnterRoomName')); + } else if (values.roomName !== CONST.POLICY.ROOM_PREFIX && !ValidationUtils.isValidRoomName(values.roomName)) { + // We error if the room name has invalid characters + ErrorUtils.addErrorMessage(errors, 'roomName', this.props.translate('newRoomPage.roomNameInvalidError')); } else if (ValidationUtils.isReservedRoomName(values.roomName)) { - // Second priority: Certain names are reserved for default rooms and should not be used for policy rooms. - errors.roomName = this.props.translate('newRoomPage.roomNameReservedError'); + // Certain names are reserved for default rooms and should not be used for policy rooms. + ErrorUtils.addErrorMessage(errors, 'roomName', this.props.translate('newRoomPage.roomNameReservedError', {reservedName: values.roomName})); } else if (ValidationUtils.isExistingRoomName(values.roomName, this.props.reports, values.policyID)) { - // Third priority: We error if the room name already exists. - errors.roomName = this.props.translate('newRoomPage.roomAlreadyExistsError'); - } else if (!ValidationUtils.isValidRoomName(values.roomName)) { - // Fourth priority: We error if the room name has invalid characters - errors.roomName = this.props.translate('newRoomPage.roomNameInvalidError'); + // Certain names are reserved for default rooms and should not be used for policy rooms. + ErrorUtils.addErrorMessage(errors, 'roomName', this.props.translate('newRoomPage.roomAlreadyExistsError')); } if (!values.policyID) { diff --git a/src/pages/workspace/WorkspaceSettingsPage.js b/src/pages/workspace/WorkspaceSettingsPage.js index 37bbe886d310..dd31c56d2884 100644 --- a/src/pages/workspace/WorkspaceSettingsPage.js +++ b/src/pages/workspace/WorkspaceSettingsPage.js @@ -1,6 +1,7 @@ import React from 'react'; import {Keyboard, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; import _ from 'underscore'; import lodashGet from 'lodash/get'; import ONYXKEYS from '../../ONYXKEYS'; @@ -23,11 +24,17 @@ import * as ReportUtils from '../../libs/ReportUtils'; import Avatar from '../../components/Avatar'; const propTypes = { + // The currency list constant object from Onyx + currencyList: PropTypes.objectOf(PropTypes.shape({ + // Symbol for the currency + symbol: PropTypes.string, + })), ...policyPropTypes, ...withLocalizePropTypes, }; const defaultProps = { + currencyList: {}, ...policyDefaultProps, }; @@ -116,6 +123,7 @@ class WorkspaceSettingsPage extends React.Component { isUsingDefaultAvatar={!lodashGet(this.props.policy, 'avatar', null)} onImageSelected={file => Policy.updateWorkspaceAvatar(lodashGet(this.props.policy, 'id', ''), file)} onImageRemoved={() => Policy.deleteWorkspaceAvatar(lodashGet(this.props.policy, 'id', ''))} + editorMaskImage={Expensicons.ImageCropSquareMask} /> Navigation.navigate(ROUTES.getWorkspaceInitialRoute(policy.id)), - iconStyles: policy.avatar ? [] : [styles.popoverMenuIconEmphasized], iconFill: themeColors.textLight, fallbackIcon: Expensicons.FallbackWorkspaceAvatar, brickRoadIndicator: PolicyUtils.getPolicyBrickRoadIndicatorStatus(policy, this.props.policyMembers), diff --git a/src/pages/workspace/bills/WorkspaceBillsFirstSection.js b/src/pages/workspace/bills/WorkspaceBillsFirstSection.js index ad7f4fc36ca3..f97b6ba946ae 100644 --- a/src/pages/workspace/bills/WorkspaceBillsFirstSection.js +++ b/src/pages/workspace/bills/WorkspaceBillsFirstSection.js @@ -27,10 +27,17 @@ const propTypes = { session: PropTypes.shape({ /** Email address */ email: PropTypes.string.isRequired, - }).isRequired, + }), /** Information about the logged in user's account */ - user: userPropTypes.isRequired, + user: userPropTypes, +}; + +const defaultProps = { + session: { + email: null, + }, + user: {}, }; const WorkspaceBillsFirstSection = (props) => { @@ -76,6 +83,7 @@ const WorkspaceBillsFirstSection = (props) => { }; WorkspaceBillsFirstSection.propTypes = propTypes; +WorkspaceBillsFirstSection.defaultProps = defaultProps; WorkspaceBillsFirstSection.displayName = 'WorkspaceBillsFirstSection'; export default compose( diff --git a/src/pages/workspace/card/WorkspaceCardVBANoECardView.js b/src/pages/workspace/card/WorkspaceCardVBANoECardView.js index bfbd68f9e2c1..63148155888f 100644 --- a/src/pages/workspace/card/WorkspaceCardVBANoECardView.js +++ b/src/pages/workspace/card/WorkspaceCardVBANoECardView.js @@ -14,11 +14,19 @@ import * as User from '../../../libs/actions/User'; import ONYXKEYS from '../../../ONYXKEYS'; import compose from '../../../libs/compose'; import CONST from '../../../CONST'; +import userPropTypes from '../../settings/userPropTypes'; const propTypes = { + /** Information about the logged in user's account */ + user: userPropTypes, + ...withLocalizePropTypes, }; +const defaultProps = { + user: {}, +}; + const WorkspaceCardVBANoECardView = props => ( <>
( ); WorkspaceCardVBANoECardView.propTypes = propTypes; +WorkspaceCardVBANoECardView.defaultProps = defaultProps; WorkspaceCardVBANoECardView.displayName = 'WorkspaceCardVBANoECardView'; export default compose( diff --git a/src/setup/index.js b/src/setup/index.js index b2fea9a98ad8..352417242ade 100644 --- a/src/setup/index.js +++ b/src/setup/index.js @@ -35,7 +35,7 @@ export default function () { [ONYXKEYS.ACCOUNT]: CONST.DEFAULT_ACCOUNT_DATA, [ONYXKEYS.NETWORK]: {isOffline: false}, [ONYXKEYS.IOU]: { - loading: false, error: false, creatingIOUTransaction: false, isRetrievingCurrency: false, + loading: false, error: false, creatingIOUTransaction: false, }, [ONYXKEYS.IS_SIDEBAR_LOADED]: false, [ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT]: true, diff --git a/src/styles/StyleUtils.js b/src/styles/StyleUtils.js index 516e6c8d6de4..17893cb94c85 100644 --- a/src/styles/StyleUtils.js +++ b/src/styles/StyleUtils.js @@ -7,6 +7,7 @@ import colors from './colors'; import positioning from './utilities/positioning'; import styles from './styles'; import * as ReportUtils from '../libs/ReportUtils'; +import getSafeAreaPaddingTop from '../libs/getSafeAreaPaddingTop'; const workspaceColorOptions = [ {backgroundColor: colors.blue200, fill: colors.blue700}, @@ -125,11 +126,12 @@ function getDefaultWorspaceAvatarColor(workspaceName) { * Takes safe area insets and returns padding to use for a View * * @param {Object} insets + * @param {Boolean} statusBarTranslucent * @returns {Object} */ -function getSafeAreaPadding(insets) { +function getSafeAreaPadding(insets, statusBarTranslucent) { return { - paddingTop: insets.top, + paddingTop: getSafeAreaPaddingTop(insets, statusBarTranslucent), paddingBottom: insets.bottom * variables.safeInsertPercentage, paddingLeft: insets.left * variables.safeInsertPercentage, paddingRight: insets.right * variables.safeInsertPercentage, @@ -824,16 +826,22 @@ function getEmojiSuggestionItemStyle( hovered, currentEmojiIndex, ) { + let backgroundColor; + + if (currentEmojiIndex === highlightedEmojiIndex) { + backgroundColor = themeColors.activeComponentBG; + } else if (hovered) { + backgroundColor = themeColors.hoverComponentBG; + } + return [ { height: rowHeight, justifyContent: 'center', }, - (currentEmojiIndex === highlightedEmojiIndex && !hovered) || hovered - ? { - backgroundColor: themeColors.highlightBG, - } - : {}, + backgroundColor ? { + backgroundColor, + } : {}, ]; } @@ -904,6 +912,20 @@ function getEmojiReactionCounterTextStyle(hasUserReacted, sizeScale = 1) { return sizeStyles; } +/** + * Returns a style object with a rotation transformation applied based on the provided direction prop. + * + * @param {string} direction - The direction of the rotation (CONST.DIRECTION.LEFT or CONST.DIRECTION.RIGHT). + * @returns {Object} + */ +function getDirectionStyle(direction) { + if (direction === CONST.DIRECTION.LEFT) { + return {transform: [{rotate: '180deg'}]}; + } + + return {}; +} + export { getAvatarSize, getAvatarStyle, @@ -953,4 +975,5 @@ export { getEmojiReactionBubbleStyle, getEmojiReactionTextStyle, getEmojiReactionCounterTextStyle, + getDirectionStyle, }; diff --git a/src/styles/colors.js b/src/styles/colors.js index 6997d0cc3dc0..a4c203194f57 100644 --- a/src/styles/colors.js +++ b/src/styles/colors.js @@ -24,6 +24,7 @@ export default { greenAppBackground: '#061B09', greenHighlightBackground: '#07271F', greenBorders: '#1A3D32', + greenBordersLighter: '#2B5548', greenIcons: '#8B9C8F', greenSupportingText: '#AFBBB0', white: '#E7ECE9', diff --git a/src/styles/getModalStyles/index.android.js b/src/styles/getModalStyles/index.android.js deleted file mode 100644 index 69606478cca8..000000000000 --- a/src/styles/getModalStyles/index.android.js +++ /dev/null @@ -1,8 +0,0 @@ -import getBaseModalStyles from './getBaseModalStyles'; - -// Only apply top padding on iOS since it's the only platform using SafeAreaView -export default (type, windowDimensions, popoverAnchorPosition = {}, innerContainerStyle = {}) => ({ - ...getBaseModalStyles(type, windowDimensions, popoverAnchorPosition, innerContainerStyle), - shouldAddTopSafeAreaMargin: false, - shouldAddTopSafeAreaPadding: false, -}); diff --git a/src/styles/getTooltipStyles.js b/src/styles/getTooltipStyles.js index 6087ca485c16..7b7deb707f84 100644 --- a/src/styles/getTooltipStyles.js +++ b/src/styles/getTooltipStyles.js @@ -49,6 +49,33 @@ function computeHorizontalShift(windowWidth, xOffset, componentWidth, tooltipWid return 0; } +/** + * Determines if there is an overlapping element at the top of a given coordinate. + * + * @param {Number} xOffset - The distance between the left edge of the window + * and the left edge of the wrapped component. + * @param {Number} yOffset - The distance between the top edge of the window + * and the top edge of the wrapped component. + * @returns {Boolean} + */ +function isOverlappingAtTop(xOffset, yOffset) { + if (typeof document.elementFromPoint !== 'function') { + return false; + } + + const element = document.elementFromPoint(xOffset, yOffset); + + if (!element) { + return false; + } + + const rect = element.getBoundingClientRect(); + + // Ensure it's not itself + overlapping with another element by checking if the yOffset is greater than the top of the element + // and less than the bottom of the element + return yOffset > rect.top && yOffset < rect.bottom; +} + /** * Generate styles for the tooltip component. * @@ -86,9 +113,10 @@ export default function getTooltipStyles( manualShiftVertical = 0, ) { // Determine if the tooltip should display below the wrapped component. - // If a tooltip will try to render within GUTTER_WIDTH logical pixels of the top of the screen, + // If either a tooltip will try to render within GUTTER_WIDTH logical pixels of the top of the screen, + // Or the wrapped component is overlapping at top-left with another element // we'll display it beneath its wrapped component rather than above it as usual. - const shouldShowBelow = (yOffset - tooltipHeight) < GUTTER_WIDTH; + const shouldShowBelow = (yOffset - tooltipHeight) < GUTTER_WIDTH || isOverlappingAtTop(xOffset, yOffset); // Determine if we need to shift the tooltip horizontally to prevent it // from displaying too near to the edge of the screen. @@ -125,6 +153,9 @@ export default function getTooltipStyles( zIndex: variables.tooltipzIndex, width: wrapperWidth, + // We are adding this to prevent the tooltip text from being selected and copied on CTRL + A. + ...styles.userSelectNone, + // Because it uses fixed positioning, the top-left corner of the tooltip is aligned // with the top-left corner of the window by default. // we will use yOffset to position the tooltip relative to the Wrapped Component diff --git a/src/styles/optionRowStyles/index.js b/src/styles/optionRowStyles/index.js new file mode 100644 index 000000000000..2bef2a0cd094 --- /dev/null +++ b/src/styles/optionRowStyles/index.js @@ -0,0 +1,8 @@ +import styles from '../styles'; + +const compactContentContainerStyles = styles.alignItemsBaseline; + +export { + // eslint-disable-next-line import/prefer-default-export + compactContentContainerStyles, +}; diff --git a/src/styles/optionRowStyles/index.native.js b/src/styles/optionRowStyles/index.native.js new file mode 100644 index 000000000000..2ffeca3c419d --- /dev/null +++ b/src/styles/optionRowStyles/index.native.js @@ -0,0 +1,15 @@ +import styles from '../styles'; + +/** + * On native platforms, alignItemsBaseline does not work correctly + * in lining the items together. As such, on native platform, we're + * keeping compactContentContainerStyles as it is. + * https://github.com/Expensify/App/issues/14148 +*/ + +const compactContentContainerStyles = styles.alignItemsCenter; + +export { + // eslint-disable-next-line import/prefer-default-export + compactContentContainerStyles, +}; diff --git a/src/styles/styles.js b/src/styles/styles.js index 64245fcde07a..bcab5775a15b 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -323,6 +323,7 @@ const styles = { textHeadline: { ...headlineFont, + ...whiteSpace.preWrap, color: themeColors.heading, fontSize: variables.fontSizeXLarge, }, @@ -752,6 +753,38 @@ const styles = { height: variables.inputHeight, }, + calendarHeader: { + height: 50, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 15, + paddingRight: 5, + }, + + calendarDayRoot: { + flex: 1, + height: 45, + justifyContent: 'center', + alignItems: 'center', + }, + + calendarDayContainer: { + width: 30, + height: 30, + justifyContent: 'center', + alignItems: 'center', + borderRadius: 15, + }, + + calendarDayContainerSelected: { + backgroundColor: themeColors.buttonDefaultBG, + }, + + calendarButtonDisabled: { + opacity: 0.5, + }, + textInputContainer: { flex: 1, justifyContent: 'center', @@ -820,7 +853,7 @@ const styles = { textInputDesktop: addOutlineWidth({}, 0), - secureInputShowPasswordButton: { + textInputIconContainer: { paddingHorizontal: 11, justifyContent: 'center', margin: 1, @@ -1171,11 +1204,6 @@ const styles = { alignItems: 'center', }, - popoverMenuIconEmphasized: { - backgroundColor: themeColors.iconSuccessFill, - borderRadius: variables.componentSizeLarge / 2, - }, - popoverMenuText: { fontSize: variables.fontSizeNormal, color: themeColors.heading, @@ -2813,7 +2841,6 @@ const styles = { }, imageCropContainer: { - borderRadius: variables.componentBorderRadiusCard, overflow: 'hidden', alignItems: 'center', justifyContent: 'center', @@ -2888,6 +2915,7 @@ const styles = { flexShrink: 0, maxWidth: variables.badgeMaxWidth, fontSize: variables.fontSizeSmall, + ...whiteSpace.pre, ...spacing.ph2, }, @@ -2996,6 +3024,11 @@ const styles = { lineHeight: variables.iconSizeXLarge, }, + textReactionSenders: { + color: themeColors.dark, + ...wordBreak.breakWord, + }, + quickReactionsContainer: { gap: 12, flexDirection: 'row', @@ -3004,13 +3037,12 @@ const styles = { justifyContent: 'space-between', }, - magicCodeDigits: { + validateCodeDigits: { color: themeColors.text, fontFamily: fontFamily.EXP_NEUE, fontSize: variables.fontSizeXXLarge, letterSpacing: 4, }, - footer: { backgroundColor: themeColors.midtone, }, @@ -3047,6 +3079,29 @@ const styles = { width: '100%', }, + listPickerSeparator: { + height: 1, + backgroundColor: themeColors.buttonDefaultBG, + }, + + datePickerRoot: { + position: 'relative', + zIndex: 99, + }, + + datePickerPopover: { + position: 'absolute', + backgroundColor: themeColors.appBG, + width: '100%', + alignSelf: 'center', + top: 60, + zIndex: 100, + }, + + validateCodeMessage: { + width: variables.modalContentMaxWidth, + textAlign: 'center', + }, }; export default styles; diff --git a/src/styles/themes/default.js b/src/styles/themes/default.js index 97a3da5d500d..c263bddd4ddf 100644 --- a/src/styles/themes/default.js +++ b/src/styles/themes/default.js @@ -6,6 +6,7 @@ const darkTheme = { appBG: colors.greenAppBackground, highlightBG: colors.greenHighlightBackground, border: colors.greenBorders, + borderLighter: colors.greenBordersLighter, borderFocus: colors.green, icon: colors.greenIcons, iconMenu: colors.green, @@ -29,6 +30,7 @@ const darkTheme = { successPressed: colors.greenPressed, transparent: colors.transparent, midtone: colors.green700, + dark: colors.midnight, // Additional keys overlay: colors.greenHighlightBackground, diff --git a/src/styles/utilities/whiteSpace/index.js b/src/styles/utilities/whiteSpace/index.js index 7a7ac524d2cc..a7051cda6c21 100644 --- a/src/styles/utilities/whiteSpace/index.js +++ b/src/styles/utilities/whiteSpace/index.js @@ -5,4 +5,7 @@ export default { preWrap: { whiteSpace: 'pre-wrap', }, + pre: { + whiteSpace: 'pre', + }, }; diff --git a/src/styles/variables.js b/src/styles/variables.js index 375ee77a594a..c82d8286446a 100644 --- a/src/styles/variables.js +++ b/src/styles/variables.js @@ -99,8 +99,9 @@ export default { modalTopIconHeight: 164, modalTopBigIconHeight: 244, modalWordmarkWidth: 154, - modalWordmarkHeight: 34, + modalWordmarkHeight: 37, verticalLogoHeight: 634, verticalLogoWidth: 111, badgeMaxWidth: 180, + modalContentMaxWidth: 360, }; diff --git a/tests/actions/ReportTest.js b/tests/actions/ReportTest.js index 10fde6b007b1..34c7c665b7d6 100644 --- a/tests/actions/ReportTest.js +++ b/tests/actions/ReportTest.js @@ -42,7 +42,7 @@ describe('actions/Report', () => { Pusher.init({ appKey: CONFIG.PUSHER.APP_KEY, cluster: CONFIG.PUSHER.CLUSTER, - authEndpoint: `${CONFIG.EXPENSIFY.URL_API_ROOT}api?command=AuthenticatePusher`, + authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api?command=AuthenticatePusher`, }); Onyx.init({ diff --git a/tests/ui/UnreadIndicatorsTest.js b/tests/ui/UnreadIndicatorsTest.js index 3fd3a5946e33..5f65d8b20b50 100644 --- a/tests/ui/UnreadIndicatorsTest.js +++ b/tests/ui/UnreadIndicatorsTest.js @@ -46,7 +46,7 @@ beforeAll(() => { Pusher.init({ appKey: CONFIG.PUSHER.APP_KEY, cluster: CONFIG.PUSHER.CLUSTER, - authEndpoint: `${CONFIG.EXPENSIFY.URL_API_ROOT}api?command=AuthenticatePusher`, + authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api?command=AuthenticatePusher`, }); }); diff --git a/tests/unit/CalendarPickerTest.js b/tests/unit/CalendarPickerTest.js new file mode 100644 index 000000000000..3667cba0a7e6 --- /dev/null +++ b/tests/unit/CalendarPickerTest.js @@ -0,0 +1,153 @@ +import {render, fireEvent, within} from '@testing-library/react-native'; +import moment from 'moment'; +import CalendarPicker from '../../src/components/CalendarPicker'; + +moment.locale('en'); +const monthNames = moment.localeData().months(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({navigate: jest.fn()}), + createNavigationContainerRef: jest.fn(), +})); + +// eslint-disable-next-line arrow-body-style +const MockedCalendarPicker = (props) => { + // eslint-disable-next-line react/jsx-props-no-spreading + return ''} preferredLocale="en" />; +}; + +describe('CalendarPicker', () => { + test('renders calendar component', () => { + render(); + }); + + test('displays the current month and year', () => { + const currentDate = new Date(); + const maxDate = moment(currentDate).add(1, 'Y').toDate(); + const minDate = moment(currentDate).subtract(1, 'Y').toDate(); + const {getByText} = render(); + + expect(getByText(monthNames[currentDate.getMonth()])).toBeTruthy(); + expect(getByText(currentDate.getFullYear().toString())).toBeTruthy(); + }); + + test('clicking next month arrow updates the displayed month', () => { + const minDate = new Date('2022-01-01'); + const maxDate = new Date('2030-01-01'); + const {getByTestId, getByText} = render(); + + fireEvent.press(getByTestId('next-month-arrow')); + + const nextMonth = (new Date()).getMonth() + 1; + expect(getByText(monthNames[nextMonth])).toBeTruthy(); + }); + + test('clicking previous month arrow updates the displayed month', () => { + const {getByTestId, getByText} = render(); + + fireEvent.press(getByTestId('prev-month-arrow')); + + const prevMonth = (new Date()).getMonth() - 1; + expect(getByText(monthNames[prevMonth])).toBeTruthy(); + }); + + test('clicking a day updates the selected date', () => { + const onSelectedMock = jest.fn(); + const minDate = new Date('2022-01-01'); + const maxDate = new Date('2030-01-01'); + const value = new Date('2023-01-01'); + const {getByText} = render(); + + fireEvent.press(getByText('15')); + + expect(onSelectedMock).toHaveBeenCalledWith(new Date('2023-01-15')); + expect(onSelectedMock).toHaveBeenCalledTimes(1); + }); + + test('clicking previous month arrow and selecting day updates the selected date', () => { + const onSelectedMock = jest.fn(); + const value = new Date('2022-01-01'); + const minDate = new Date('2022-01-01'); + const maxDate = new Date('2030-01-01'); + const {getByText, getByTestId} = render(); + + fireEvent.press(getByTestId('next-month-arrow')); + fireEvent.press(getByText('15')); + + expect(onSelectedMock).toHaveBeenCalledWith(new Date('2022-02-15')); + }); + + test('should block the back arrow when there is no available dates in the previous month', () => { + const minDate = new Date('2003-02-01'); + const value = new Date('2003-02-17'); + const {getByTestId} = render(); + + expect(getByTestId('prev-month-arrow')).toBeDisabled(); + }); + + test('should block the next arrow when there is no available dates in the next month', () => { + const maxDate = new Date('2003-02-24'); + const value = new Date('2003-02-17'); + const {getByTestId} = render(); + + expect(getByTestId('next-month-arrow')).toBeDisabled(); + }); + + test('should open the calendar on a month from max date if it is earlier than current month', () => { + const onSelectedMock = jest.fn(); + const maxDate = new Date('2011-03-01'); + const {getByText} = render(); + + fireEvent.press(getByText('1')); + + expect(onSelectedMock).toHaveBeenCalledWith(new Date('2011-03-01')); + }); + + test('should open the calendar on a year from max date if it is earlier than current year', () => { + const maxDate = new Date('2011-03-01'); + const {getByTestId} = render(); + + expect(within(getByTestId('currentYearText')).getByText('2011')).toBeTruthy(); + }); + + test('should open the calendar on a month from min date if it is later than current month', () => { + const minDate = new Date('2035-02-16'); + const maxDate = new Date('2040-02-16'); + const {getByTestId} = render(); + + expect(within(getByTestId('currentYearText')).getByText(minDate.getFullYear().toString())).toBeTruthy(); + }); + + test('should not allow to press earlier day than minDate', () => { + const date = new Date('2003-02-17'); + const minDate = new Date('2003-02-16'); + const {getByLabelText} = render(); + + expect(getByLabelText('15')).toBeDisabled(); + }); + + test('should not allow to press later day than max', () => { + const date = new Date('2003-02-17'); + const maxDate = new Date('2003-02-24'); + const {getByLabelText} = render(); + + expect(getByLabelText('25')).toBeDisabled(); + }); + + test('should allow to press min date', () => { + const date = new Date('2003-02-17'); + const minDate = new Date('2003-02-16'); + const {getByLabelText} = render(); + + expect(getByLabelText('16')).not.toBeDisabled(); + }); + + test('should not allow to press max date', () => { + const date = new Date('2003-02-17'); + const maxDate = new Date('2003-02-24'); + const {getByLabelText} = render(); + + expect(getByLabelText('24')).not.toBeDisabled(); + }); +}); + diff --git a/tests/unit/ErrorUtilsTest.js b/tests/unit/ErrorUtilsTest.js new file mode 100644 index 000000000000..4657b8b96d80 --- /dev/null +++ b/tests/unit/ErrorUtilsTest.js @@ -0,0 +1,64 @@ +import * as ErrorUtils from '../../src/libs/ErrorUtils'; + +describe('ErrorUtils', () => { + test('should add a new error message for a given inputID', () => { + const errors = {}; + ErrorUtils.addErrorMessage(errors, 'username', 'Username cannot be empty'); + + expect(errors).toEqual({username: 'Username cannot be empty'}); + }); + + test('should append an error message to an existing error message for a given inputID', () => { + const errors = {username: 'Username cannot be empty'}; + ErrorUtils.addErrorMessage(errors, 'username', 'Username must be at least 6 characters long'); + + expect(errors).toEqual({username: 'Username cannot be empty\nUsername must be at least 6 characters long'}); + }); + + test('should add an error to input which does not contain any errors yet', () => { + const errors = {username: 'Username cannot be empty'}; + ErrorUtils.addErrorMessage(errors, 'password', 'Password cannot be empty'); + + expect(errors).toEqual({username: 'Username cannot be empty', password: 'Password cannot be empty'}); + }); + + test('should not mutate the errors object when message is empty', () => { + const errors = {username: 'Username cannot be empty'}; + ErrorUtils.addErrorMessage(errors, 'username', ''); + + expect(errors).toEqual({username: 'Username cannot be empty'}); + }); + + test('should not mutate the errors object when inputID is null', () => { + const errors = {username: 'Username cannot be empty'}; + ErrorUtils.addErrorMessage(errors, null, 'InputID cannot be null'); + + expect(errors).toEqual({username: 'Username cannot be empty'}); + }); + + test('should not mutate the errors object when message is null', () => { + const errors = {username: 'Username cannot be empty'}; + ErrorUtils.addErrorMessage(errors, 'username', null); + + expect(errors).toEqual({username: 'Username cannot be empty'}); + }); + + test('should add multiple error messages for the same inputID', () => { + const errors = {}; + ErrorUtils.addErrorMessage(errors, 'username', 'Username cannot be empty'); + ErrorUtils.addErrorMessage(errors, 'username', 'Username must be at least 6 characters long'); + ErrorUtils.addErrorMessage(errors, 'username', 'Username must contain at least one letter'); + + expect(errors).toEqual({username: 'Username cannot be empty\nUsername must be at least 6 characters long\nUsername must contain at least one letter'}); + }); + + test('should append multiple error messages to an existing error message for the same inputID', () => { + const errors = {username: 'Username cannot be empty\nUsername must be at least 6 characters long'}; + ErrorUtils.addErrorMessage(errors, 'username', 'Username must contain at least one letter'); + ErrorUtils.addErrorMessage(errors, 'username', 'Username must not contain special characters'); + + expect(errors).toEqual( + {username: 'Username cannot be empty\nUsername must be at least 6 characters long\nUsername must contain at least one letter\nUsername must not contain special characters'}, + ); + }); +}); diff --git a/tests/unit/ReportUtilsTest.js b/tests/unit/ReportUtilsTest.js index 87a7415ed02b..b4383b3c0fd4 100644 --- a/tests/unit/ReportUtilsTest.js +++ b/tests/unit/ReportUtilsTest.js @@ -391,4 +391,21 @@ describe('ReportUtils', () => { expect(iouOptions.includes(CONST.IOU.IOU_TYPE.SEND)).toBe(true); }); }); + + describe('getReportIDFromLink', () => { + it('should get the correct reportID from a deep link', () => { + 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://staging.expensify.cash/r/75431276')).toBe('75431276'); + expect(ReportUtils.getReportIDFromLink('https://new.expensify.com/r/75431276')).toBe('75431276'); + }); + + it('shouldn\'t get the correct reportID from a deep link', () => { + expect(ReportUtils.getReportIDFromLink('new-expensify-not-valid://r/75431276')).toBe(''); + expect(ReportUtils.getReportIDFromLink('new-expensify://settings')).toBe(''); + }); + }); }); diff --git a/tests/unit/generateMonthMatrixTest.js b/tests/unit/generateMonthMatrixTest.js new file mode 100644 index 000000000000..b36ccc29f547 --- /dev/null +++ b/tests/unit/generateMonthMatrixTest.js @@ -0,0 +1,95 @@ +import generateMonthMatrix from '../../src/components/CalendarPicker/generateMonthMatrix'; + +describe('generateMonthMatrix', () => { + it('returns the correct matrix for January 2022', () => { + const expected = [ + [null, null, null, null, null, null, 1], + [2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22], + [23, 24, 25, 26, 27, 28, 29], + [30, 31, null, null, null, null, null], + ]; + expect(generateMonthMatrix(2022, 0)).toEqual(expected); + }); + + it('returns the correct matrix for February 2022', () => { + const expected = [ + [null, null, 1, 2, 3, 4, 5], + [6, 7, 8, 9, 10, 11, 12], + [13, 14, 15, 16, 17, 18, 19], + [20, 21, 22, 23, 24, 25, 26], + [27, 28, null, null, null, null, null], + ]; + expect(generateMonthMatrix(2022, 1)).toEqual(expected); + }); + + it('returns the correct matrix for leap year February 2020', () => { + const expected = [ + [null, null, null, null, null, null, 1], + [2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22], + [23, 24, 25, 26, 27, 28, 29], + ]; + expect(generateMonthMatrix(2020, 1)).toEqual(expected); + }); + + it('returns the correct matrix for March 2022', () => { + const expected = [ + [null, null, 1, 2, 3, 4, 5], + [6, 7, 8, 9, 10, 11, 12], + [13, 14, 15, 16, 17, 18, 19], + [20, 21, 22, 23, 24, 25, 26], + [27, 28, 29, 30, 31, null, null], + ]; + expect(generateMonthMatrix(2022, 2)).toEqual(expected); + }); + + it('returns the correct matrix for April 2022', () => { + const expected = [ + [null, null, null, null, null, 1, 2], + [3, 4, 5, 6, 7, 8, 9], + [10, 11, 12, 13, 14, 15, 16], + [17, 18, 19, 20, 21, 22, 23], + [24, 25, 26, 27, 28, 29, 30], + ]; + expect(generateMonthMatrix(2022, 3)).toEqual(expected); + }); + + it('returns the correct matrix for December 2022', () => { + const expected = [ + [null, null, null, null, 1, 2, 3], + [4, 5, 6, 7, 8, 9, 10], + [11, 12, 13, 14, 15, 16, 17], + [18, 19, 20, 21, 22, 23, 24], + [25, 26, 27, 28, 29, 30, 31], + ]; + expect(generateMonthMatrix(2022, 11)).toEqual(expected); + }); + + it('throws an error if month is less than 0', () => { + expect(() => generateMonthMatrix(2022, -1)).toThrow(); + }); + + it('throws an error if month is greater than 11', () => { + expect(() => generateMonthMatrix(2022, 12)).toThrow(); + }); + + it('throws an error if year is negative', () => { + expect(() => generateMonthMatrix(-1, 0)).toThrow(); + }); + + it('throws an error if year or month is not a number', () => { + expect(() => generateMonthMatrix()).toThrow(); + expect(() => generateMonthMatrix(2022, 'invalid')).toThrow(); + expect(() => generateMonthMatrix('2022', '0')).toThrow(); + expect(() => generateMonthMatrix(null, undefined)).toThrow(); + }); + + it('returns a matrix with 6 rows and 7 columns for January 2022', () => { + const matrix = generateMonthMatrix(2022, 0); + expect(matrix.length).toBe(6); + expect(matrix[0].length).toBe(7); + }); +}); diff --git a/web/index.html b/web/index.html index 2cd2fce8ad04..4da68485e4c8 100644 --- a/web/index.html +++ b/web/index.html @@ -34,8 +34,9 @@ #drag-area { -webkit-app-region: drag; } - input[type=text] { - -webkit-user-select: text !important; + input::placeholder { + user-select: none; + -webkit-user-select: none } .disable-select * { -webkit-user-select: none !important;