diff --git a/.github/actions/javascript/bumpVersion/index.js b/.github/actions/javascript/bumpVersion/index.js
index 31a4c6d4246a..1132c137061e 100644
--- a/.github/actions/javascript/bumpVersion/index.js
+++ b/.github/actions/javascript/bumpVersion/index.js
@@ -19,6 +19,7 @@ const getBuildVersion = __nccwpck_require__(4016);
const BUILD_GRADLE_PATH = process.env.NODE_ENV === 'test' ? path.resolve(__dirname, '../../android/app/build.gradle') : './android/app/build.gradle';
const PLIST_PATH = './ios/NewExpensify/Info.plist';
const PLIST_PATH_TEST = './ios/NewExpensifyTests/Info.plist';
+const PLIST_PATH_NSE = './ios/NotificationServiceExtension/Info.plist';
exports.BUILD_GRADLE_PATH = BUILD_GRADLE_PATH;
exports.PLIST_PATH = PLIST_PATH;
@@ -90,8 +91,10 @@ exports.updateiOSVersion = function updateiOSVersion(version) {
// Update Plists
execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${shortVersion}" ${PLIST_PATH}`);
execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${shortVersion}" ${PLIST_PATH_TEST}`);
+ execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${shortVersion}" ${PLIST_PATH_NSE}`);
execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${cfVersion}" ${PLIST_PATH}`);
execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${cfVersion}" ${PLIST_PATH_TEST}`);
+ execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${cfVersion}" ${PLIST_PATH_NSE}`);
// Return the cfVersion so we can set the NEW_IOS_VERSION in ios.yml
return cfVersion;
diff --git a/.github/libs/nativeVersionUpdater.js b/.github/libs/nativeVersionUpdater.js
index 07d36d823c78..ab129f4eb04a 100644
--- a/.github/libs/nativeVersionUpdater.js
+++ b/.github/libs/nativeVersionUpdater.js
@@ -10,6 +10,7 @@ const getBuildVersion = require('semver/functions/prerelease');
const BUILD_GRADLE_PATH = process.env.NODE_ENV === 'test' ? path.resolve(__dirname, '../../android/app/build.gradle') : './android/app/build.gradle';
const PLIST_PATH = './ios/NewExpensify/Info.plist';
const PLIST_PATH_TEST = './ios/NewExpensifyTests/Info.plist';
+const PLIST_PATH_NSE = './ios/NotificationServiceExtension/Info.plist';
exports.BUILD_GRADLE_PATH = BUILD_GRADLE_PATH;
exports.PLIST_PATH = PLIST_PATH;
@@ -81,8 +82,10 @@ exports.updateiOSVersion = function updateiOSVersion(version) {
// Update Plists
execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${shortVersion}" ${PLIST_PATH}`);
execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${shortVersion}" ${PLIST_PATH_TEST}`);
+ execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${shortVersion}" ${PLIST_PATH_NSE}`);
execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${cfVersion}" ${PLIST_PATH}`);
execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${cfVersion}" ${PLIST_PATH_TEST}`);
+ execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${cfVersion}" ${PLIST_PATH_NSE}`);
// Return the cfVersion so we can set the NEW_IOS_VERSION in ios.yml
return cfVersion;
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 395e87664e99..645f36ef876a 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -96,8 +96,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001042300
- versionName "1.4.23-0"
+ versionCode 1001042400
+ versionName "1.4.24-0"
}
flavorDimensions "default"
diff --git a/docs/articles/expensify-classic/get-paid-back/Referral-Program.md b/docs/articles/expensify-classic/get-paid-back/Referral-Program.md
index 4cc646c613a1..24605dd17d3f 100644
--- a/docs/articles/expensify-classic/get-paid-back/Referral-Program.md
+++ b/docs/articles/expensify-classic/get-paid-back/Referral-Program.md
@@ -1,56 +1,49 @@
---
-title: Expensify Referral Program
-description: Send your joining link, submit a receipt or invoice, and we'll pay you if your referral adopts Expensify.
+title: Earn money with Expensify referrals
+description: Get paid with the Expensify referral program! Share your link, earn $250 per successful sign-up, and enjoy unlimited income potential. It’s that easy.
redirect_from: articles/other/Referral-Program/
---
-# About
+# Earn money with Expensify referrals
-Expensify has grown thanks to our users who love Expensify so much that they tell their friends, colleagues, managers, and fellow business founders to use it, too.
+Picture this: You've found Expensify and it's transformed your approach to expense management and financial organization. You love it so much that you can't help but recommend it to friends, family, and colleagues. Wouldn’t it be nice if you could get rewarded just for spreading the word?
-As a thank you, every time you bring a new user into the platform who directly or indirectly leads to the adoption of a paid annual plan on Expensify, you will earn $250.
+With Expensify referrals, you can. Every time someone you invite to the platform signs up for a paid annual plan on Expensify, you’ll earn $250. Think of it as a thank-you gift from us to you!
-# How to get paid for referring people to Expensify
+## How to get paid for Expensify referrals
-1. Submit a report or invoice, or share your referral link with anyone you know who is spending too much time on expenses, or works at a company that could benefit from using Expensify.
+Here are a few easy ways to get paid for Expensify friend referrals:
-2. You will get $250 for any referred business that commits to an annual subscription, has 2 or more active users, and makes two monthly payments.
+- Submit an expense report to your boss (even just one receipt!)
+- Send an invoice to a client or customer
+- Share your referral link with a friend
+ - To find your referral link, open your Expensify mobile app and go to **Settings > Refer a friend, earn cash! > Share invite link**.
-That’s right! You can refer anyone working at any company you know.
+**If the person you referred commits to an annual subscription with two or more active users and makes two monthly payments, you’ll get $250. Cha-ching!**
-If their company goes on to become an Expensify customer with an annual subscription, and you are the earliest recorded referrer of a user on that company’s paid Expensify Policy, you'll get paid a referral reward.
+## Who can you refer?
-The best way to start is to submit any receipt to your manager (you'll get paid back and set yourself up for $250 if they start a subscription: win-win!)
+You can refer anyone who might benefit from Expensify. Seriously. Anybody.
-Referral rewards for the Spring/Summer 2023 campaign will be paid by direct deposit.
+Know a small business owner? Refer them! An [accountant](https://use.expensify.com/accountants-program)? Refer them! A best friend from childhood who keeps losing paper receipts? Refer them!
-{% include faq-begin.md %}
+Plus, you can [refer an unlimited amount of new users](https://use.expensify.com/blog/earn-50000-by-referring-your-friends-to-expensify/) with the Expensify referral program, so your earning potential is truly sky-high.
-- **How will I know if I am the first person to refer a company to Expensify?**
+## Common questions about Expensify benefits
-Successful referrers are notified after their referral pays for 2 months of an annual subscription. We will check for the earliest recorded referrer of a user on the policy, and if that is you, then we will let you know.
+Still have questions about the Expensify referral program? We’ve got answers. Check out our FAQ below.
-- **How will you pay me if I am successful?**
+### How will I know if I am the first person to refer someone to Expensify?
-In the Spring 2023 campaign, Expensify will be paying successful referrers via direct deposit to the Deposit-Only account you have on file. Referral payouts will happen once a month for the duration of the campaign. If you do not have a Deposit-Only account at the time of your referral payout, your deposit will be processed in the next batch.
+You’ll know if you’re the first person to refer someone to Expensify if we reach out to let you know that they’ve successfully adopted Expensify and have paid for two months of an annual subscription.
-Learn how to add a Deposit-Only account [here](https://community.expensify.com/discussion/4641/how-to-add-a-deposit-only-bank-account-both-personal-and-business).
+Simply put, we check for the earliest recorded referrer of a member on the workspace, and if that’s you, then we’ll let you know.
-- **I’m outside of the US, how do I get paid?**
+### My referral wasn’t counted! How can I appeal?
-While our referral payouts are in USD, you will be able to get paid via a Wise Borderless account. Learn more [here](https://community.expensify.com/discussion/5940/how-to-get-reimbursed-outside-the-us-with-wise-for-non-us-employees).
+If you think your Expensify friend referral wasn’t counted, please send a message to concierge@expensify.com with the email of the person you referred. Our team will review the referral and get back to you.
-- **My referral wasn’t counted! How can I appeal?**
+## Share the Expensify love — and get paid in the process
-Expensify reserves the right to modify the terms of the referral program at any time, and pays out referral bonuses for eligible companies at its own discretion.
-
-Please send a message to concierge@expensify.com with the billing owner of the company you have referred and our team will review the referral and get back to you.
-
-- **Where can I find my referral link?**
-
-Expensify members who are opted-in for our newsletters will have received an email containing their unique referral link.
-
-On the mobile app, go to **Settings** > **Invite a Friend** > **Share Invite Link** to retrieve your referral link.
-
-{% include faq-end.md %}
+Who needs a side hustle when you have Expensify? With Expensify benefits, it’s not just about managing your expenses — it's about expanding your income too. Share your Expensify referral link now or send over an invoice to unlock unlimited earning potential.
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index ff0d5c910e6e..443e35990496 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 1.4.23
+ 1.4.24CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.23.0
+ 1.4.24.0ITSAppUsesNonExemptEncryptionLSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 80cb37367088..7756d9837e9a 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 1.4.23
+ 1.4.24CFBundleSignature????CFBundleVersion
- 1.4.23.0
+ 1.4.24.0
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 57421ebf9b75..ccc7422fe3b4 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -9,5 +9,9 @@
NSExtensionPrincipalClass$(PRODUCT_MODULE_NAME).NotificationService
+ CFBundleVersion
+ 1.4.23.0
+ CFBundleShortVersionString
+ 1.4.23
-
+
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 11a5ee7f2e56..b1af06e89fed 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.23-0",
+ "version": "1.4.24-0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.23-0",
+ "version": "1.4.24-0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -161,6 +161,7 @@
"@testing-library/jest-native": "5.4.1",
"@testing-library/react-native": "11.5.1",
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
+ "@types/canvas-size": "^1.2.2",
"@types/concurrently": "^7.0.0",
"@types/jest": "^29.5.2",
"@types/jest-when": "^3.5.2",
@@ -20916,6 +20917,12 @@
"@types/responselike": "^1.0.0"
}
},
+ "node_modules/@types/canvas-size": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@types/canvas-size/-/canvas-size-1.2.2.tgz",
+ "integrity": "sha512-yuTXFWC4tHV3lt5ZtbIP9VeeMNbDYm5mPyqaQnaMuSSx2mjsfZGXMNmHTnfdsR5qZdB6dtbaV5IP2PKv79vmKg==",
+ "dev": true
+ },
"node_modules/@types/concurrently": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@types/concurrently/-/concurrently-7.0.0.tgz",
@@ -71204,6 +71211,12 @@
"@types/responselike": "^1.0.0"
}
},
+ "@types/canvas-size": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@types/canvas-size/-/canvas-size-1.2.2.tgz",
+ "integrity": "sha512-yuTXFWC4tHV3lt5ZtbIP9VeeMNbDYm5mPyqaQnaMuSSx2mjsfZGXMNmHTnfdsR5qZdB6dtbaV5IP2PKv79vmKg==",
+ "dev": true
+ },
"@types/concurrently": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@types/concurrently/-/concurrently-7.0.0.tgz",
diff --git a/package.json b/package.json
index d7bc53713f21..192f42ee45fc 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.23-0",
+ "version": "1.4.24-0",
"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.",
@@ -209,6 +209,7 @@
"@testing-library/jest-native": "5.4.1",
"@testing-library/react-native": "11.5.1",
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
+ "@types/canvas-size": "^1.2.2",
"@types/concurrently": "^7.0.0",
"@types/jest": "^29.5.2",
"@types/jest-when": "^3.5.2",
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 89ddbdc06883..98e3856f4544 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -458,6 +458,7 @@ type OnyxValues = {
[ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM]: boolean;
[ONYXKEYS.COLLECTION.SECURITY_GROUP]: OnyxTypes.SecurityGroup;
[ONYXKEYS.COLLECTION.TRANSACTION]: OnyxTypes.Transaction;
+ [ONYXKEYS.COLLECTION.TRANSACTION_DRAFT]: OnyxTypes.Transaction;
[ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags;
[ONYXKEYS.COLLECTION.SELECTED_TAB]: string;
[ONYXKEYS.COLLECTION.PRIVATE_NOTES_DRAFT]: string;
diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx
index 4580f3b7e4d4..e9e1054427b9 100644
--- a/src/components/AvatarWithDisplayName.tsx
+++ b/src/components/AvatarWithDisplayName.tsx
@@ -141,6 +141,7 @@ function AvatarWithDisplayName({
)}
{!!subtitle && (
diff --git a/src/components/ContextMenuItem.js b/src/components/ContextMenuItem.tsx
similarity index 67%
rename from src/components/ContextMenuItem.js
rename to src/components/ContextMenuItem.tsx
index e7d2bda3a667..781d2f718bcf 100644
--- a/src/components/ContextMenuItem.js
+++ b/src/components/ContextMenuItem.tsx
@@ -1,58 +1,52 @@
-import PropTypes from 'prop-types';
+import type {ForwardedRef} from 'react';
import React, {forwardRef, useImperativeHandle} from 'react';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import useThrottledButtonState from '@hooks/useThrottledButtonState';
import useWindowDimensions from '@hooks/useWindowDimensions';
import getButtonState from '@libs/getButtonState';
+import type IconAsset from '@src/types/utils/IconAsset';
import BaseMiniContextMenuItem from './BaseMiniContextMenuItem';
import Icon from './Icon';
-import sourcePropTypes from './Image/sourcePropTypes';
import MenuItem from './MenuItem';
-const propTypes = {
+type ContextMenuItemProps = {
/** Icon Component */
- icon: sourcePropTypes.isRequired,
+ icon: IconAsset;
/** Text to display */
- text: PropTypes.string.isRequired,
+ text: string;
/** Icon to show when interaction was successful */
- successIcon: sourcePropTypes,
+ successIcon?: IconAsset;
/** Text to show when interaction was successful */
- successText: PropTypes.string,
+ successText?: string;
/** Whether to show the mini menu */
- isMini: PropTypes.bool,
+ isMini?: boolean;
/** Callback to fire when the item is pressed */
- onPress: PropTypes.func.isRequired,
+ onPress: () => void;
/** A description text to show under the title */
- description: PropTypes.string,
+ description?: string;
/** The action accept for anonymous user or not */
- isAnonymousAction: PropTypes.bool,
+ isAnonymousAction?: boolean;
/** Whether the menu item is focused or not */
- isFocused: PropTypes.bool,
-
- /** Forwarded ref to ContextMenuItem */
- innerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
+ isFocused?: boolean;
};
-const defaultProps = {
- isMini: false,
- successIcon: null,
- successText: '',
- description: '',
- isAnonymousAction: false,
- isFocused: false,
- innerRef: null,
+type ContextMenuItemHandle = {
+ triggerPressAndUpdateSuccess?: () => void;
};
-function ContextMenuItem({onPress, successIcon, successText, icon, text, isMini, description, isAnonymousAction, isFocused, innerRef}) {
+function ContextMenuItem(
+ {onPress, successIcon, successText = '', icon, text, isMini = false, description = '', isAnonymousAction = false, isFocused = false}: ContextMenuItemProps,
+ ref: ForwardedRef,
+) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {windowWidth} = useWindowDimensions();
@@ -66,12 +60,12 @@ function ContextMenuItem({onPress, successIcon, successText, icon, text, isMini,
// We only set the success state when we have icon or text to represent the success state
// We may want to replace this check by checking the Result from OnPress Callback in future.
- if (successIcon || successText) {
+ if (!!successIcon || successText) {
setThrottledButtonInactive();
}
};
- useImperativeHandle(innerRef, () => ({triggerPressAndUpdateSuccess}));
+ useImperativeHandle(ref, () => ({triggerPressAndUpdateSuccess}));
const itemIcon = !isThrottledButtonActive && successIcon ? successIcon : icon;
const itemText = !isThrottledButtonActive && successText ? successText : text;
@@ -107,18 +101,6 @@ function ContextMenuItem({onPress, successIcon, successText, icon, text, isMini,
);
}
-ContextMenuItem.propTypes = propTypes;
-ContextMenuItem.defaultProps = defaultProps;
ContextMenuItem.displayName = 'ContextMenuItem';
-const ContextMenuItemWithRef = forwardRef((props, ref) => (
-
-));
-
-ContextMenuItemWithRef.displayName = 'ContextMenuItemWithRef';
-
-export default ContextMenuItemWithRef;
+export default forwardRef(ContextMenuItem);
diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx
index 2e9996a92f87..5f75bf535319 100644
--- a/src/components/FloatingActionButton.tsx
+++ b/src/components/FloatingActionButton.tsx
@@ -1,5 +1,5 @@
import type {ForwardedRef} from 'react';
-import React, {useEffect, useRef} from 'react';
+import React, {forwardRef, useEffect, useRef} from 'react';
import type {GestureResponderEvent, Role} from 'react-native';
import {Platform, View} from 'react-native';
import Animated, {createAnimatedPropAdapter, Easing, interpolateColor, processColor, useAnimatedProps, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
@@ -134,4 +134,4 @@ function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: Flo
FloatingActionButton.displayName = 'FloatingActionButton';
-export default FloatingActionButton;
+export default forwardRef(FloatingActionButton);
diff --git a/src/components/Form.js b/src/components/Form.js
deleted file mode 100644
index 7b6f587e7bd1..000000000000
--- a/src/components/Form.js
+++ /dev/null
@@ -1,592 +0,0 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
-import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
-import {Keyboard, ScrollView, StyleSheet} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
-import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
-import * as ErrorUtils from '@libs/ErrorUtils';
-import FormUtils from '@libs/FormUtils';
-import * as ValidationUtils from '@libs/ValidationUtils';
-import Visibility from '@libs/Visibility';
-import stylePropTypes from '@styles/stylePropTypes';
-import * as FormActions from '@userActions/FormActions';
-import CONST from '@src/CONST';
-import FormAlertWithSubmitButton from './FormAlertWithSubmitButton';
-import FormSubmit from './FormSubmit';
-import networkPropTypes from './networkPropTypes';
-import {withNetwork} from './OnyxProvider';
-import SafeAreaConsumer from './SafeAreaConsumer';
-import ScrollViewWithContext from './ScrollViewWithContext';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-
-const propTypes = {
- /** A unique Onyx key identifying the form */
- formID: PropTypes.string.isRequired,
-
- /** Text to be displayed in the submit button */
- submitButtonText: PropTypes.string,
-
- /** Controls the submit button's visibility */
- isSubmitButtonVisible: PropTypes.bool,
-
- /** Callback to validate the form */
- validate: PropTypes.func,
-
- /** Callback to submit the form */
- onSubmit: PropTypes.func.isRequired,
-
- /** Children to render. */
- children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired,
-
- /* Onyx Props */
-
- /** Contains the form state that must be accessed outside of the component */
- formState: PropTypes.shape({
- /** Controls the loading state of the form */
- isLoading: PropTypes.bool,
-
- /** Server side errors keyed by microtime */
- errors: PropTypes.objectOf(PropTypes.string),
-
- /** Field-specific server side errors keyed by microtime */
- errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
- }),
-
- /** Contains draft values for each input in the form */
- // eslint-disable-next-line react/forbid-prop-types
- draftValues: PropTypes.object,
-
- /** Should the button be enabled when offline */
- enabledWhenOffline: PropTypes.bool,
-
- /** Whether the form submit action is dangerous */
- isSubmitActionDangerous: PropTypes.bool,
-
- /** Whether the validate() method should run on input changes */
- shouldValidateOnChange: PropTypes.bool,
-
- /** Whether the validate() method should run on blur */
- shouldValidateOnBlur: PropTypes.bool,
-
- /** Whether ScrollWithContext should be used instead of regular ScrollView.
- * Set to true when there's a nested Picker component in Form.
- */
- scrollContextEnabled: PropTypes.bool,
-
- /** Container styles */
- style: stylePropTypes,
-
- /** Submit button container styles */
- // eslint-disable-next-line react/forbid-prop-types
- submitButtonStyles: PropTypes.arrayOf(PropTypes.object),
-
- /** Custom content to display in the footer after submit button */
- footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
-
- /** Information about the network */
- network: networkPropTypes.isRequired,
-
- /** Style for the error message for submit button */
- errorMessageStyle: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
-
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {
- isSubmitButtonVisible: true,
- formState: {
- isLoading: false,
- },
- draftValues: {},
- enabledWhenOffline: false,
- isSubmitActionDangerous: false,
- scrollContextEnabled: false,
- shouldValidateOnChange: true,
- shouldValidateOnBlur: true,
- footerContent: null,
- style: [],
- errorMessageStyle: [],
- submitButtonStyles: [],
- validate: () => ({}),
- submitButtonText: '',
-};
-
-const Form = forwardRef((props, forwardedRef) => {
- const styles = useThemeStyles();
- const [errors, setErrors] = useState({});
- const [inputValues, setInputValues] = useState(() => ({...props.draftValues}));
- const formRef = useRef(null);
- const formContentRef = useRef(null);
- const inputRefs = useRef({});
- const touchedInputs = useRef({});
- const focusedInput = useRef(null);
- const isFirstRender = useRef(true);
-
- const {validate, onSubmit, children} = props;
-
- const hasServerError = useMemo(() => Boolean(props.formState) && !_.isEmpty(props.formState.errors), [props.formState]);
-
- /**
- * @param {Object} values - An object containing the value of each inputID, e.g. {inputID1: value1, inputID2: value2}
- * @returns {Object} - An object containing the errors for each inputID, e.g. {inputID1: error1, inputID2: error2}
- */
- const onValidate = useCallback(
- (values, shouldClearServerError = true) => {
- // Trim all string values
- const trimmedStringValues = ValidationUtils.prepareValues(values);
-
- if (shouldClearServerError) {
- FormActions.setErrors(props.formID, null);
- }
- FormActions.setErrorFields(props.formID, null);
-
- // Run any validations passed as a prop
- const validationErrors = validate(trimmedStringValues);
-
- // Validate the input for html tags. It should supercede any other error
- _.each(trimmedStringValues, (inputValue, inputID) => {
- // If the input value is empty OR is non-string, we don't need to validate it for HTML tags
- if (!inputValue || !_.isString(inputValue)) {
- return;
- }
- const foundHtmlTagIndex = inputValue.search(CONST.VALIDATE_FOR_HTML_TAG_REGEX);
- const leadingSpaceIndex = inputValue.search(CONST.VALIDATE_FOR_LEADINGSPACES_HTML_TAG_REGEX);
-
- // Return early if there are no HTML characters
- if (leadingSpaceIndex === -1 && foundHtmlTagIndex === -1) {
- return;
- }
-
- const matchedHtmlTags = inputValue.match(CONST.VALIDATE_FOR_HTML_TAG_REGEX);
- let isMatch = _.some(CONST.WHITELISTED_TAGS, (r) => r.test(inputValue));
- // Check for any matches that the original regex (foundHtmlTagIndex) matched
- if (matchedHtmlTags) {
- // Check if any matched inputs does not match in WHITELISTED_TAGS list and return early if needed.
- for (let i = 0; i < matchedHtmlTags.length; i++) {
- const htmlTag = matchedHtmlTags[i];
- isMatch = _.some(CONST.WHITELISTED_TAGS, (r) => r.test(htmlTag));
- if (!isMatch) {
- break;
- }
- }
- }
-
- if (isMatch && leadingSpaceIndex === -1) {
- return;
- }
-
- // Add a validation error here because it is a string value that contains HTML characters
- validationErrors[inputID] = 'common.error.invalidCharacter';
- });
-
- if (!_.isObject(validationErrors)) {
- throw new Error('Validate callback must return an empty object or an object with shape {inputID: error}');
- }
-
- const touchedInputErrors = _.pick(validationErrors, (inputValue, inputID) => Boolean(touchedInputs.current[inputID]));
-
- if (!_.isEqual(errors, touchedInputErrors)) {
- setErrors(touchedInputErrors);
- }
-
- return touchedInputErrors;
- },
- [props.formID, validate, errors],
- );
-
- useEffect(() => {
- // We want to skip Form validation on initial render.
- // This also avoids a bug where we immediately clear server errors when the loading indicator unmounts and Form remounts with server errors.
- if (isFirstRender.current) {
- isFirstRender.current = false;
- return;
- }
-
- onValidate(inputValues);
-
- // eslint-disable-next-line react-hooks/exhaustive-deps -- we just want to revalidate the form on update if the preferred locale changed on another device so that errors get translated
- }, [props.preferredLocale]);
-
- const errorMessage = useMemo(() => {
- const latestErrorMessage = ErrorUtils.getLatestErrorMessage(props.formState);
- return typeof latestErrorMessage === 'string' ? latestErrorMessage : '';
- }, [props.formState]);
-
- /**
- * @param {String} inputID - The inputID of the input being touched
- */
- const setTouchedInput = useCallback(
- (inputID) => {
- touchedInputs.current[inputID] = true;
- },
- [touchedInputs],
- );
-
- const submit = useCallback(() => {
- // Return early if the form is already submitting to avoid duplicate submission
- if (props.formState.isLoading) {
- return;
- }
-
- // Trim all string values
- const trimmedStringValues = ValidationUtils.prepareValues(inputValues);
-
- // Touches all form inputs so we can validate the entire form
- _.each(inputRefs.current, (inputRef, inputID) => (touchedInputs.current[inputID] = true));
-
- // Validate form and return early if any errors are found
- if (!_.isEmpty(onValidate(trimmedStringValues))) {
- return;
- }
-
- // Do not submit form if network is offline and the form is not enabled when offline
- if (props.network.isOffline && !props.enabledWhenOffline) {
- return;
- }
-
- // Call submit handler
- onSubmit(trimmedStringValues);
- }, [props.formState.isLoading, props.network.isOffline, props.enabledWhenOffline, inputValues, onValidate, onSubmit]);
-
- /**
- * Resets the form
- */
- const resetForm = useCallback(
- (optionalValue) => {
- _.each(inputValues, (inputRef, inputID) => {
- setInputValues((prevState) => {
- const copyPrevState = _.clone(prevState);
-
- touchedInputs.current[inputID] = false;
- copyPrevState[inputID] = optionalValue[inputID] || '';
-
- return copyPrevState;
- });
- });
- setErrors({});
- },
- [inputValues],
- );
-
- useImperativeHandle(forwardedRef, () => ({
- resetForm,
- }));
-
- /**
- * Loops over Form's children and automatically supplies Form props to them
- *
- * @param {Array | Function | Node} children - An array containing all Form children
- * @returns {React.Component}
- */
- const childrenWrapperWithProps = useCallback(
- (childNodes) => {
- const childrenElements = React.Children.map(childNodes, (child) => {
- // Just render the child if it is not a valid React element, e.g. text within a component
- if (!React.isValidElement(child)) {
- return child;
- }
-
- // Depth first traversal of the render tree as the input element is likely to be the last node
- if (child.props.children) {
- return React.cloneElement(child, {
- children: childrenWrapperWithProps(child.props.children),
- });
- }
-
- // Look for any inputs nested in a custom component, e.g AddressForm or IdentityForm
- if (_.isFunction(child.type)) {
- const childNode = new child.type(child.props);
-
- // If the custom component has a render method, use it to get the nested children
- const nestedChildren = _.isFunction(childNode.render) ? childNode.render() : childNode;
-
- // Render the custom component if it's a valid React element
- // If the custom component has nested children, Loop over them and supply From props
- if (React.isValidElement(nestedChildren) || lodashGet(nestedChildren, 'props.children')) {
- return childrenWrapperWithProps(nestedChildren);
- }
-
- // Just render the child if it's custom component not a valid React element, or if it hasn't children
- return child;
- }
-
- // We check if the child has the inputID prop.
- // We don't want to pass form props to non form components, e.g. View, Text, etc
- if (!child.props.inputID) {
- return child;
- }
-
- // We clone the child passing down all form props
- const inputID = child.props.inputID;
- let defaultValue;
-
- // We need to make sure that checkboxes have correct
- // value assigned from the list of draft values
- // https://github.com/Expensify/App/issues/16885#issuecomment-1520846065
- if (_.isBoolean(props.draftValues[inputID])) {
- defaultValue = props.draftValues[inputID];
- } else {
- defaultValue = props.draftValues[inputID] || child.props.defaultValue;
- }
-
- // We want to initialize the input value if it's undefined
- if (_.isUndefined(inputValues[inputID])) {
- // eslint-disable-next-line es/no-nullish-coalescing-operators
- inputValues[inputID] = defaultValue ?? '';
- }
-
- // We force the form to set the input value from the defaultValue props if there is a saved valid value
- if (child.props.shouldUseDefaultValue) {
- inputValues[inputID] = child.props.defaultValue;
- }
-
- if (!_.isUndefined(child.props.value)) {
- inputValues[inputID] = child.props.value;
- }
-
- const errorFields = lodashGet(props.formState, 'errorFields', {});
- const fieldErrorMessage =
- _.chain(errorFields[inputID])
- .keys()
- .sortBy()
- .reverse()
- .map((key) => errorFields[inputID][key])
- .first()
- .value() || '';
-
- return React.cloneElement(child, {
- ref: (node) => {
- inputRefs.current[inputID] = node;
-
- const {ref} = child;
- if (_.isFunction(ref)) {
- ref(node);
- }
- },
- value: inputValues[inputID],
- // As the text input is controlled, we never set the defaultValue prop
- // as this is already happening by the value prop.
- defaultValue: undefined,
- errorText: errors[inputID] || fieldErrorMessage,
- onFocus: (event) => {
- focusedInput.current = inputID;
- if (_.isFunction(child.props.onFocus)) {
- child.props.onFocus(event);
- }
- },
- onBlur: (event) => {
- // Only run validation when user proactively blurs the input.
- if (Visibility.isVisible() && Visibility.hasFocus()) {
- const relatedTargetId = lodashGet(event, 'nativeEvent.relatedTarget.id');
- // We delay the validation in order to prevent Checkbox loss of focus when
- // the user are focusing a TextInput and proceeds to toggle a CheckBox in
- // web and mobile web platforms.
-
- setTimeout(() => {
- if (
- relatedTargetId &&
- _.includes([CONST.OVERLAY.BOTTOM_BUTTON_NATIVE_ID, CONST.OVERLAY.TOP_BUTTON_NATIVE_ID, CONST.BACK_BUTTON_NATIVE_ID], relatedTargetId)
- ) {
- return;
- }
- setTouchedInput(inputID);
- if (props.shouldValidateOnBlur) {
- onValidate(inputValues, !hasServerError);
- }
- }, 200);
- }
-
- if (_.isFunction(child.props.onBlur)) {
- child.props.onBlur(event);
- }
- },
- onTouched: () => {
- setTouchedInput(inputID);
- },
- onInputChange: (value, key) => {
- const inputKey = key || inputID;
-
- if (focusedInput.current && focusedInput.current !== inputKey) {
- setTouchedInput(focusedInput.current);
- }
-
- setInputValues((prevState) => {
- const newState = {
- ...prevState,
- [inputKey]: value,
- };
-
- if (props.shouldValidateOnChange) {
- onValidate(newState);
- }
- return newState;
- });
-
- if (child.props.shouldSaveDraft) {
- FormActions.setDraftValues(props.formID, {[inputKey]: value});
- }
-
- if (child.props.onValueChange) {
- child.props.onValueChange(value, inputKey);
- }
- },
- });
- });
-
- return childrenElements;
- },
- [
- errors,
- inputRefs,
- inputValues,
- onValidate,
- props.draftValues,
- props.formID,
- props.formState,
- setTouchedInput,
- props.shouldValidateOnBlur,
- props.shouldValidateOnChange,
- hasServerError,
- ],
- );
-
- const scrollViewContent = useCallback(
- (safeAreaPaddingBottomStyle) => (
-
- {childrenWrapperWithProps(_.isFunction(children) ? children({inputValues}) : children)}
- {props.isSubmitButtonVisible && (
- 0 || Boolean(errorMessage) || !_.isEmpty(props.formState.errorFields)}
- isLoading={props.formState.isLoading}
- message={_.isEmpty(props.formState.errorFields) ? errorMessage : null}
- onSubmit={submit}
- footerContent={props.footerContent}
- onFixTheErrorsLinkPressed={() => {
- const errorFields = !_.isEmpty(errors) ? errors : props.formState.errorFields;
- const focusKey = _.find(_.keys(inputRefs.current), (key) => _.keys(errorFields).includes(key));
- const focusInput = inputRefs.current[focusKey];
-
- // Dismiss the keyboard for non-text fields by checking if the component has the isFocused method, as only TextInput has this method.
- if (typeof focusInput.isFocused !== 'function') {
- Keyboard.dismiss();
- }
-
- // We subtract 10 to scroll slightly above the input
- if (focusInput.measureLayout && typeof focusInput.measureLayout === 'function') {
- // We measure relative to the content root, not the scroll view, as that gives
- // consistent results across mobile and web
- focusInput.measureLayout(formContentRef.current, (x, y) => formRef.current.scrollTo({y: y - 10, animated: false}));
- }
-
- // Focus the input after scrolling, as on the Web it gives a slightly better visual result
- if (focusInput.focus && typeof focusInput.focus === 'function') {
- focusInput.focus();
- }
- }}
- containerStyles={[styles.mh0, styles.mt5, styles.flex1, ...props.submitButtonStyles]}
- enabledWhenOffline={props.enabledWhenOffline}
- isSubmitActionDangerous={props.isSubmitActionDangerous}
- useSmallerSubmitButtonSize={props.useSmallerSubmitButtonSize}
- disablePressOnEnter
- errorMessageStyle={props.errorMessageStyle}
- />
- )}
-
- ),
-
- [
- props.style,
- props.isSubmitButtonVisible,
- props.submitButtonText,
- props.useSmallerSubmitButtonSize,
- props.errorMessageStyle,
- props.formState.errorFields,
- props.formState.isLoading,
- props.footerContent,
- props.submitButtonStyles,
- props.enabledWhenOffline,
- props.isSubmitActionDangerous,
- submit,
- childrenWrapperWithProps,
- children,
- inputValues,
- errors,
- errorMessage,
- styles.mh0,
- styles.mt5,
- styles.flex1,
- ],
- );
-
- useEffect(() => {
- _.each(inputRefs.current, (inputRef, inputID) => {
- if (inputRef) {
- return;
- }
-
- delete inputRefs.current[inputID];
- delete touchedInputs.current[inputID];
-
- setInputValues((prevState) => {
- const copyPrevState = _.clone(prevState);
-
- delete copyPrevState[inputID];
-
- return copyPrevState;
- });
- });
- // We need to verify that all references and values are still actual.
- // We should not store it when e.g. some input has been unmounted.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [children]);
-
- return (
-
- {({safeAreaPaddingBottomStyle}) =>
- props.scrollContextEnabled ? (
-
- {scrollViewContent(safeAreaPaddingBottomStyle)}
-
- ) : (
-
- {scrollViewContent(safeAreaPaddingBottomStyle)}
-
- )
- }
-
- );
-});
-
-Form.displayName = 'Form';
-Form.propTypes = propTypes;
-Form.defaultProps = defaultProps;
-
-export default compose(
- withLocalize,
- withNetwork(),
- withOnyx({
- formState: {
- key: (props) => props.formID,
- },
- draftValues: {
- key: (props) => FormUtils.getDraftKey(props.formID),
- },
- }),
-)(Form);
diff --git a/src/components/LHNOptionsList/LHNOptionsList.js b/src/components/LHNOptionsList/LHNOptionsList.js
index 71b14b6fadcd..d1bf02b08191 100644
--- a/src/components/LHNOptionsList/LHNOptionsList.js
+++ b/src/components/LHNOptionsList/LHNOptionsList.js
@@ -1,7 +1,7 @@
import {FlashList} from '@shopify/flash-list';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
-import React, {useCallback} from 'react';
+import React, {memo, useCallback} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
@@ -190,4 +190,4 @@ export default compose(
key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT,
},
}),
-)(LHNOptionsList);
+)(memo(LHNOptionsList));
diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx
index ce44db72598a..334fa9895205 100644
--- a/src/components/MenuItem.tsx
+++ b/src/components/MenuItem.tsx
@@ -66,7 +66,7 @@ type MenuItemProps = (IconProps | AvatarProps | NoIcon) & {
badgeText?: string;
/** Used to apply offline styles to child text components */
- style?: ViewStyle;
+ style?: StyleProp;
/** Any additional styles to apply */
wrapperStyle?: StyleProp;
@@ -291,7 +291,7 @@ function MenuItem(
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
- const combinedStyle = StyleUtils.combineStyles(style ?? {}, styles.popoverMenuItem);
+ const combinedStyle = [style, styles.popoverMenuItem];
const {isSmallScreenWidth} = useWindowDimensions();
const [html, setHtml] = useState('');
const titleRef = useRef('');
diff --git a/src/components/OptionsList/BaseOptionsList.js b/src/components/OptionsList/BaseOptionsList.js
index bd3695eb7aa9..8b24066af969 100644
--- a/src/components/OptionsList/BaseOptionsList.js
+++ b/src/components/OptionsList/BaseOptionsList.js
@@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
-import React, {forwardRef, memo, useEffect, useRef} from 'react';
+import React, {forwardRef, memo, useEffect, useMemo, useRef} from 'react';
import {View} from 'react-native';
import _ from 'underscore';
import OptionRow from '@components/OptionRow';
@@ -36,268 +36,278 @@ const defaultProps = {
...optionsListDefaultProps,
};
-function BaseOptionsList({
- keyboardDismissMode,
- onScrollBeginDrag,
- onScroll,
- listStyles,
- focusedIndex,
- selectedOptions,
- headerMessage,
- isLoading,
- sections,
- onLayout,
- hideSectionHeaders,
- shouldHaveOptionSeparator,
- showTitleTooltip,
- optionHoveredStyle,
- contentContainerStyles,
- sectionHeaderStyle,
- showScrollIndicator,
- listContainerStyles: listContainerStylesProp,
- shouldDisableRowInnerPadding,
- shouldPreventDefaultFocusOnSelectRow,
- disableFocusOptions,
- canSelectMultipleOptions,
- shouldShowMultipleOptionSelectorAsButton,
- multipleOptionSelectorButtonText,
- onAddToSelection,
- highlightSelectedOptions,
- onSelectRow,
- boldStyle,
- isDisabled,
- innerRef,
- isRowMultilineSupported,
- isLoadingNewOptions,
- nestedScrollEnabled,
- bounces,
- renderFooterContent,
-}) {
- const styles = useThemeStyles();
- const flattenedData = useRef();
- const previousSections = usePrevious(sections);
- const didLayout = useRef(false);
-
- const listContainerStyles = listContainerStylesProp || [styles.flex1];
-
- /**
- * This helper function is used to memoize the computation needed for getItemLayout. It is run whenever section data changes.
- *
- * @returns {Array