diff --git a/android/app/build.gradle b/android/app/build.gradle
index 58ec3a1e1310..b1538d22de8f 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -98,8 +98,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001046009
- versionName "1.4.60-9"
+ versionCode 1001046100
+ versionName "1.4.61-0"
}
flavorDimensions "default"
diff --git a/assets/images/avatars/fallback-avatar.svg b/assets/images/avatars/fallback-avatar.svg
index b4584d910190..69293d72aed9 100644
--- a/assets/images/avatars/fallback-avatar.svg
+++ b/assets/images/avatars/fallback-avatar.svg
@@ -1 +1,10 @@
-
\ No newline at end of file
+
+
+
diff --git a/docs/articles/expensify-classic/reports/Expense-Rules.md b/docs/articles/expensify-classic/expenses/Expense-Rules.md
similarity index 100%
rename from docs/articles/expensify-classic/reports/Expense-Rules.md
rename to docs/articles/expensify-classic/expenses/Expense-Rules.md
diff --git a/docs/articles/expensify-classic/expensify-billing/Annual-Subscription.md b/docs/articles/expensify-classic/expensify-billing/Annual-Subscription.md
deleted file mode 100644
index 67a96610633d..000000000000
--- a/docs/articles/expensify-classic/expensify-billing/Annual-Subscription.md
+++ /dev/null
@@ -1,45 +0,0 @@
----
-title: Annual Subscription
-description: Learn more about managing your Annual Subscription.
----
-# Overview
-An Annual Subscription offers a 50% cost savings on active user pricing while allowing your company to manage multiple Workspaces across your organization and maintain predictable cost for your Expensify activity.
-
-_For pricing details, see [expensify.com/pricing](http://www.expensify.com/pricing), and find more ways to save with the Expensify Card here._
-
-# How to set subscription size
-When you first create a subscription, the best practice is to set your subscription size by entering the average number of active users you expect to have each month for the next year. For example, if you expect to have an average of 10 users each month, even if they are not always the same users, set your subscription size to 10. No need to provision and deprovision access to your team, so you still enjoy flexible usage across the entire company!
-
-If your Workspaces have more than 10 active users in a month, you will pay the unbundled Pay-per-use rate for the additional users. If you’d like to avoid this, you can enable Auto Increase so your subscription size increases based on Workspace user activity.
-
-An ‘Active User’ is anyone who chats, creates, submits, approves, reimburses, or exports a report in Expensify. This includes actions taken by Copilots and any automated settings.
-
-To set your subscription size, go to **Settings > Workspaces > Groups > Subscription**.
-
-If you do not set a specific subscription size, this will be automatically updated based on your past activity:
-
-* If you’ve never had activity in Expensify, your subscription size will be set after your first month. Work with your Setup Specialist or Account Manager to determine the best subscription size for your team!
-
-* For existing Workspaces switching to an Annual Subscription, the subscription size is set to the number of active users on your last month’s billing history.
-
-* If Auto Increase is not selected, and you have more active users than you’ve input as the subscription size, you will be billed for those at the Pay-per-use rate.
-
-# How to adjust subscription size
-You can add users to your subscription at any time. However, note that when your subscription size is increased, you will start a new 12-month subscription at that new subscription size.
-
-You can increase your subscription size manually or automatically.
-
-* To manually increase the size, just update the number in your subscription settings (**Settings > Workspaces > Groups > Subscription**).
-
-* To automatically increase your subscription size, enable **Auto Increase**. This feature manages your subscription by automatically increasing the count whenever there is activity that exceeds your subscription size. (**Settings > Workspaces > Groups > Subscription**)
-
-Note: After increasing your subscription size, you won't be able to decrease it for the next 12 months. If your active user numbers tend to fluctuate, you might want to keep this feature disabled. This way, you'll only pay for additional active users in the months they are active. Keep in mind that increasing the subscription size will reset your 12-month subscription period.
-
-# How to disable Auto Renew
-By default, your subscription is set to automatically renew after a year. To disable this, click the toggle from your subscription settings before the current subscription ends. (**Settings > Workspaces > Groups > Subscription**)
-
-If Auto Renew is disabled, then the last bill at the annual rate will be issued on the date listed under the Auto Renew settings. For example, if your subscription expires on March 1, 2021, then February 2021 will be the last month billed at the annual rate. If you do not set a new subscription, March activity will be billed at the Pay-per-use rate.
-
-We recommend that you review your user count annually on a proactive basis. Set a reminder to review your active user numbers a month before your subscription expires! If you’d like assistance determining your subscription number, please contact your Account Manager or concierge@expensify.com.
-
-If you need to decrease your subscription size, you can do this in the first billing month before you are billed. Using the example above, this would be during March 2021.
diff --git a/docs/articles/expensify-classic/workspaces/Currency.md b/docs/articles/expensify-classic/workspaces/Currency.md
new file mode 100644
index 000000000000..77b5fbbb3ebc
--- /dev/null
+++ b/docs/articles/expensify-classic/workspaces/Currency.md
@@ -0,0 +1,63 @@
+---
+title: Report Currency
+description: Understanding expense and report currency
+---
+
+# Overview
+As a workspace admin, you can choose a default currency for your employees' expense reports, and we’ll automatically convert any expenses into that currency.
+
+Here are a few essential things to remember:
+
+- Currency settings for a workspace apply to all expenses under that workspace. If you need different default currencies for certain employees, creating separate workspaces and configuring the currency settings is best.
+- As an admin, the currency settings you establish in the workspace will take precedence over any currency settings individual users may have in their accounts.
+- Currency is a workspace-level setting, meaning the currency you set will determine the currency for all expenses submitted on that workspace.
+
+# How to select the currency on a workspace
+
+## As an admin on a group workspace
+
+1. Sign into your Expensify web account
+2. Go to **Settings > Workspaces > Group > _[Workspace Name]_> Reports > Report Basics**
+3. Adjust the **Report Output Currency**
+
+## On an individual workspace
+
+1. Sign into your Expensify web account
+2. Go to **Settings > Workspaces > Individual >_[Workspace Name]_> Reports > Report Basics**
+3. Adjust the **Report Output Currency**
+
+Please note the currency setting on an individual workspace is overridden when you submit a report on a group workspace.
+
+# Deep Dive
+
+## Conversion Rates
+
+Using data from Open Exchange Rates, Expensify takes the average rate on the day the expense occurred to convert an expense from one currency to another. The conversion rate can vary depending on when the expense happened since the rate is determined after the market closes on that specific date.
+
+If the markets aren’t open on the day the expense takes place (i.e., on a Saturday), Expensify will use the daily average rate from the last available market day before the purchase took place.
+
+When an expense is logged for a future date, possibly to anticipate a purchase that has yet to occur, we'll use the most recent available data. This means the report's value may change up to the day of that expense.
+
+## Managing expenses for employees in several different countries
+
+Suppose you have employees scattered across the globe who submit expense reports in various currencies. The best way to manage those expenses is to create separate group workspaces for each location or region where your employees are based.
+
+Then, set the default currency for that workspace to match the currency in which the employees are reimbursed.
+
+For example, if you have employees in the US, France, Japan, and India, you’d want to create four separate workspaces, add the employees to each, and then set the corresponding currency for each workspace.
+
+{% include faq-begin.md %}
+
+## I have expenses in several different currencies. How will this show up on a report?
+
+If you're traveling to foreign countries during a reporting period and making purchases in various currencies, each expense is imported with the currency of the purchase.
+
+On your expense report, Expensify will automatically convert each expense to the default currency set for the group workspace.
+
+## How does the currency of an expense impact the conversion rate?
+
+Expenses entered in a foreign currency are automatically converted to the default currency on your workspace. The conversion uses the day’s average trading rate pulled from [Open Exchange Rates](https://openexchangerates.org/).
+
+If you want to bypass the exchange rate conversion, you can manually enter an expense in your default currency instead.
+
+{% include faq-end.md %}
diff --git a/docs/articles/new-expensify/bank-accounts-and-payments/Connect-a-Bank-Account.md b/docs/articles/new-expensify/bank-accounts-and-payments/Connect-a-Bank-Account.md
index a618c80f98ed..bc0676231544 100644
--- a/docs/articles/new-expensify/bank-accounts-and-payments/Connect-a-Bank-Account.md
+++ b/docs/articles/new-expensify/bank-accounts-and-payments/Connect-a-Bank-Account.md
@@ -96,7 +96,7 @@ If you need to enable direct debits from your verified bank account, your bank w
If using Expensify to process Bill payments, you'll also need to whitelist the ACH IDs from our partner [Stripe](https://support.stripe.com/questions/ach-direct-debit-company-ids-for-stripe?):
- The ACH CompanyIDs (1800948598 and 4270465600)
-- The ACH Originator Name (Stripe Payments company)
+- The ACH Originator Name (expensify.com)
If using Expensify to process international reimbursements from your USD bank account, you'll also need to whitelist the ACH IDs from our partner CorPay:
- The ACH CompanyIDs (1522304924 and 2522304924)
diff --git a/docs/redirects.csv b/docs/redirects.csv
index 32fc61642bda..ca87274a2fbb 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -77,3 +77,6 @@ https://help.expensify.com/articles/expensify-classic/workspace-and-domain-setti
https://help.expensify.com/articles/expensify-classic/workspace-and-domain-settings/Reimbursement,https://help.expensify.com/articles/expensify-classic/send-payments/Reimbursing-Reports
https://help.expensify.com/articles/expensify-classic/workspace-and-domain-settings/Tags,https://help.expensify.com/articles/expensify-classic/workspaces/Tags
https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/User-Roles,https://help.expensify.com/articles/expensify-classic/workspaces/Change-member-workspace-roles
+https://help.expensify.com/articles/expensify-classic/workspace-and-domain-settings/tax-tracking,https://help.expensify.com/articles/expensify-classic/expenses/expenses/Apply-Tax
+https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/User-Roles.html,https://help.expensify.com/expensify-classic/hubs/copilots-and-delegates/
+https://help.expensify.com/articles/expensify-classic/expensify-billing/Billing-Owner,https://help.expensify.com/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account
diff --git a/ios/NewExpensify/AppDelegate.mm b/ios/NewExpensify/AppDelegate.mm
index f5ddba46f5f1..f4f7f3bc8dbc 100644
--- a/ios/NewExpensify/AppDelegate.mm
+++ b/ios/NewExpensify/AppDelegate.mm
@@ -44,7 +44,12 @@ - (BOOL)application:(UIApplication *)application
// stopped by a native module in the JS so we can measure total time starting
// in the native layer and ending in the JS layer.
[RCTStartupTimer start];
-
+
+ if (![[NSUserDefaults standardUserDefaults] boolForKey:@"isFirstRunComplete"]) {
+ [UIApplication sharedApplication].applicationIconBadgeNumber = 0;
+ [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"isFirstRunComplete"];
+ }
+
return YES;
}
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 48c18cf62213..ad29816ca2d4 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.4.60
+ 1.4.61
CFBundleSignature
????
CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.60.9
+ 1.4.61.0
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 59e4044fd085..30ebc444e07c 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 1.4.60
+ 1.4.61
CFBundleSignature
????
CFBundleVersion
- 1.4.60.9
+ 1.4.61.0
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index caf9d3c95ccb..5cfad9bf91a6 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName
$(PRODUCT_NAME)
CFBundleShortVersionString
- 1.4.60
+ 1.4.61
CFBundleVersion
- 1.4.60.9
+ 1.4.61.0
NSExtension
NSExtensionPointIdentifier
diff --git a/package-lock.json b/package-lock.json
index 6fc21583c1b8..eb9259d59064 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.60-9",
+ "version": "1.4.61-0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.60-9",
+ "version": "1.4.61-0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -202,7 +202,7 @@
"css-loader": "^6.7.2",
"diff-so-fancy": "^1.3.0",
"dotenv": "^16.0.3",
- "electron": "^29.0.0",
+ "electron": "^29.2.0",
"electron-builder": "24.13.2",
"eslint": "^7.6.0",
"eslint-config-airbnb-typescript": "^17.1.0",
@@ -25715,10 +25715,11 @@
}
},
"node_modules/electron": {
- "version": "29.0.0",
+ "version": "29.2.0",
+ "resolved": "https://registry.npmjs.org/electron/-/electron-29.2.0.tgz",
+ "integrity": "sha512-ALKrCN52RG4g9prx4DriXSPnY5WoiyRUCNp7zEVQuoiNOpHTNqMMpRidQAHzntV4hajF1LMWHVoBkwqIs1jHhg==",
"dev": true,
"hasInstallScript": true,
- "license": "MIT",
"dependencies": {
"@electron/get": "^2.0.0",
"@types/node": "^20.9.0",
diff --git a/package.json b/package.json
index 3c142ab9013a..d910a1d7c721 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.60-9",
+ "version": "1.4.61-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.",
@@ -253,7 +253,7 @@
"css-loader": "^6.7.2",
"diff-so-fancy": "^1.3.0",
"dotenv": "^16.0.3",
- "electron": "^29.0.0",
+ "electron": "^29.2.0",
"electron-builder": "24.13.2",
"eslint": "^7.6.0",
"eslint-config-airbnb-typescript": "^17.1.0",
diff --git a/src/App.tsx b/src/App.tsx
index 61874dc72fb0..a3a9f7a3f3b6 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -16,7 +16,6 @@ import HTMLEngineProvider from './components/HTMLEngineProvider';
import InitialURLContextProvider from './components/InitialURLContextProvider';
import {LocaleContextProvider} from './components/LocaleContextProvider';
import OnyxProvider from './components/OnyxProvider';
-import OptionsListContextProvider from './components/OptionListContextProvider';
import PopoverContextProvider from './components/PopoverProvider';
import SafeArea from './components/SafeArea';
import ScrollOffsetContextProvider from './components/ScrollOffsetContextProvider';
@@ -83,7 +82,6 @@ function App({url}: AppProps) {
FullScreenContextProvider,
VolumeContextProvider,
VideoPopoverMenuContextProvider,
- OptionsListContextProvider,
]}
>
diff --git a/src/CONST.ts b/src/CONST.ts
index 6d1195ff5c79..f9229d5185b4 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -864,6 +864,11 @@ const CONST = {
RIGHT: 'right',
},
POPOVER_MENU_PADDING: 8,
+ RESTORE_FOCUS_TYPE: {
+ DEFAULT: 'default',
+ DELETE: 'delete',
+ PRESERVE: 'preserve',
+ },
},
TIMING: {
CALCULATE_MOST_RECENT_LAST_MODIFIED_ACTION: 'calc_most_recent_last_modified_action',
diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx
index 2b2d0a60f657..358f5333bfba 100644
--- a/src/components/Avatar.tsx
+++ b/src/components/Avatar.tsx
@@ -74,21 +74,26 @@ function Avatar({
setImageError(false);
}, [source]);
- if (!source) {
- return null;
- }
-
const isWorkspace = type === CONST.ICON_TYPE_WORKSPACE;
const iconSize = StyleUtils.getAvatarSize(size);
const imageStyle: StyleProp = [StyleUtils.getAvatarStyle(size), imageStyles, styles.noBorderRadius];
const iconStyle = imageStyles ? [StyleUtils.getAvatarStyle(size), styles.bgTransparent, imageStyles] : undefined;
- const iconFillColor = isWorkspace ? StyleUtils.getDefaultWorkspaceAvatarColor(name).fill : fill;
+ // We pass the color styles down to the SVG for the workspace and fallback avatar.
+ const useFallBackAvatar = imageError || source === Expensicons.FallbackAvatar || !source;
const fallbackAvatar = isWorkspace ? ReportUtils.getDefaultWorkspaceAvatar(name) : fallbackIcon || Expensicons.FallbackAvatar;
const fallbackAvatarTestID = isWorkspace ? ReportUtils.getDefaultWorkspaceAvatarTestID(name) : fallbackIconTestID || 'SvgFallbackAvatar Icon';
-
- const avatarSource = imageError ? fallbackAvatar : source;
+ const avatarSource = useFallBackAvatar ? fallbackAvatar : source;
+
+ let iconColors;
+ if (isWorkspace) {
+ iconColors = StyleUtils.getDefaultWorkspaceAvatarColor(name);
+ } else if (useFallBackAvatar) {
+ iconColors = StyleUtils.getBackgroundColorAndFill(theme.border, theme.icon);
+ } else {
+ iconColors = null;
+ }
return (
@@ -107,13 +112,8 @@ function Avatar({
src={avatarSource}
height={iconSize}
width={iconSize}
- fill={imageError ? theme.offline : iconFillColor}
- additionalStyles={[
- StyleUtils.getAvatarBorderStyle(size, type),
- isWorkspace && StyleUtils.getDefaultWorkspaceAvatarColor(name),
- imageError && StyleUtils.getBackgroundColorStyle(theme.fallbackIconColor),
- iconAdditionalStyles,
- ]}
+ fill={imageError ? iconColors?.fill ?? theme.offline : iconColors?.fill ?? fill}
+ additionalStyles={[StyleUtils.getAvatarBorderStyle(size, type), iconColors, iconAdditionalStyles]}
/>
)}
diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx
index 5d01b05bb51f..69cc6b208652 100755
--- a/src/components/Composer/index.tsx
+++ b/src/components/Composer/index.tsx
@@ -388,13 +388,20 @@ function Composer(
disabled={isDisabled}
onKeyPress={handleKeyPress}
onFocus={(e) => {
- ReportActionComposeFocusManager.onComposerFocus(() => {
- if (!textInput.current) {
- return;
- }
-
- textInput.current.focus();
- });
+ if (isReportActionCompose) {
+ ReportActionComposeFocusManager.onComposerFocus(null);
+ } else {
+ // While a user edits a comment, if they open the LHN menu, we want to ensure that
+ // the focus returns to the message edit composer after they click on a menu item (e.g. mark as read).
+ // To achieve this, we re-assign the focus callback here.
+ ReportActionComposeFocusManager.onComposerFocus(() => {
+ if (!textInput.current) {
+ return;
+ }
+
+ textInput.current.focus();
+ });
+ }
props.onFocus?.(e);
}}
diff --git a/src/components/EmojiPicker/EmojiPicker.js b/src/components/EmojiPicker/EmojiPicker.js
index fbfa4563d70e..410d600dcce2 100644
--- a/src/components/EmojiPicker/EmojiPicker.js
+++ b/src/components/EmojiPicker/EmojiPicker.js
@@ -213,6 +213,8 @@ const EmojiPicker = forwardRef((props, ref) => {
anchorDimensions={emojiAnchorDimension.current}
avoidKeyboard
shoudSwitchPositionIfOverflow
+ shouldEnableNewFocusManagement
+ restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE}
>
,
) {
@@ -56,6 +59,14 @@ function BaseModal(
const isVisibleRef = useRef(isVisible);
const wasVisible = usePrevious(isVisible);
+ const modalId = useMemo(() => ComposerFocusManager.getId(), []);
+ const saveFocusState = () => {
+ if (shouldEnableNewFocusManagement) {
+ ComposerFocusManager.saveFocusState(modalId);
+ }
+ ComposerFocusManager.resetReadyToFocus(modalId);
+ };
+
/**
* Hides modal
* @param callHideCallback - Should we call the onModalHide callback
@@ -70,11 +81,9 @@ function BaseModal(
onModalHide();
}
Modal.onModalDidClose();
- if (!fullscreen) {
- ComposerFocusManager.setReadyToFocus();
- }
+ ComposerFocusManager.refocusAfterModalFullyClosed(modalId, restoreFocusType);
},
- [shouldSetModalVisibility, onModalHide, fullscreen],
+ [shouldSetModalVisibility, onModalHide, restoreFocusType, modalId],
);
useEffect(() => {
@@ -126,7 +135,7 @@ function BaseModal(
};
const handleDismissModal = () => {
- ComposerFocusManager.setReadyToFocus();
+ ComposerFocusManager.setReadyToFocus(modalId);
};
const {
@@ -190,7 +199,7 @@ function BaseModal(
onModalShow={handleShowModal}
propagateSwipe={propagateSwipe}
onModalHide={hideModal}
- onModalWillShow={() => ComposerFocusManager.resetReadyToFocus()}
+ onModalWillShow={saveFocusState}
onDismiss={handleDismissModal}
onSwipeComplete={() => onClose?.()}
swipeDirection={swipeDirection}
@@ -214,12 +223,14 @@ function BaseModal(
avoidKeyboard={avoidKeyboard}
customBackdrop={shouldUseCustomBackdrop ? : undefined}
>
-
- {children}
-
+
+
+ {children}
+
+
);
}
diff --git a/src/components/Modal/ModalContent.tsx b/src/components/Modal/ModalContent.tsx
new file mode 100644
index 000000000000..49d3b049220f
--- /dev/null
+++ b/src/components/Modal/ModalContent.tsx
@@ -0,0 +1,23 @@
+import type {ReactNode} from 'react';
+import React from 'react';
+
+type ModalContentProps = {
+ /** Modal contents */
+ children: ReactNode;
+
+ /**
+ * Callback method fired after modal content is unmounted.
+ * isVisible is not enough to cover all modal close cases,
+ * such as closing the attachment modal through the browser's back button.
+ * */
+ onDismiss: () => void;
+};
+
+function ModalContent({children, onDismiss = () => {}}: ModalContentProps) {
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ React.useEffect(() => () => onDismiss?.(), []);
+ return children;
+}
+ModalContent.displayName = 'ModalContent';
+
+export default ModalContent;
diff --git a/src/components/Modal/index.android.tsx b/src/components/Modal/index.android.tsx
index 86a1fd272185..7cb2c6083752 100644
--- a/src/components/Modal/index.android.tsx
+++ b/src/components/Modal/index.android.tsx
@@ -1,17 +1,7 @@
import React from 'react';
-import {AppState} from 'react-native';
-import ComposerFocusManager from '@libs/ComposerFocusManager';
import BaseModal from './BaseModal';
import type BaseModalProps from './types';
-AppState.addEventListener('focus', () => {
- ComposerFocusManager.setReadyToFocus();
-});
-
-AppState.addEventListener('blur', () => {
- ComposerFocusManager.resetReadyToFocus();
-});
-
// Only want to use useNativeDriver on Android. It has strange flashes issue on IOS
// https://github.com/react-native-modal/react-native-modal#the-modal-flashes-in-a-weird-way-when-animating
function Modal({useNativeDriver = true, ...rest}: BaseModalProps) {
diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts
index 9c394fdf0289..6111987e9c8d 100644
--- a/src/components/Modal/types.ts
+++ b/src/components/Modal/types.ts
@@ -68,6 +68,15 @@ type BaseModalProps = Partial & {
/** Should we use a custom backdrop for the modal? (This prevents focus issues on desktop) */
shouldUseCustomBackdrop?: boolean;
+
+ /**
+ * Whether the modal should enable the new focus manager.
+ * We are attempting to migrate to a new refocus manager, adding this property for gradual migration.
+ * */
+ shouldEnableNewFocusManagement?: boolean;
+
+ /** How to re-focus after the modal is dismissed */
+ restoreFocusType?: ValueOf;
};
export default BaseModalProps;
diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx
index d36c8c43d322..14227d6a2051 100644
--- a/src/components/MoneyReportHeader.tsx
+++ b/src/components/MoneyReportHeader.tsx
@@ -77,7 +77,7 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money
const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false);
const [paymentType, setPaymentType] = useState();
const [requestType, setRequestType] = useState<'pay' | 'approve'>();
- const canAllowSettlement = ReportUtils.hasUpdatedTotal(moneyRequestReport);
+ const canAllowSettlement = ReportUtils.hasUpdatedTotal(moneyRequestReport, policy);
const policyType = policy?.type;
const isPayer = ReportUtils.isPayer(session, moneyRequestReport);
const isDraft = ReportUtils.isOpenExpenseReport(moneyRequestReport);
@@ -106,7 +106,7 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money
const shouldShowAnyButton = shouldShowSettlementButton || shouldShowApproveButton || shouldShowSubmitButton || shouldShowNextStep;
const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport);
const formattedAmount = CurrencyUtils.convertToDisplayString(reimbursableSpend, moneyRequestReport.currency);
- const [nonHeldAmount, fullAmount] = ReportUtils.getNonHeldAndFullAmount(moneyRequestReport);
+ const [nonHeldAmount, fullAmount] = ReportUtils.getNonHeldAndFullAmount(moneyRequestReport, policy);
const displayedAmount = ReportUtils.hasHeldExpenses(moneyRequestReport.reportID) && canAllowSettlement ? nonHeldAmount : formattedAmount;
const isMoreContentShown = shouldShowNextStep || (shouldShowAnyButton && isSmallScreenWidth);
@@ -119,7 +119,7 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money
if (ReportUtils.hasHeldExpenses(moneyRequestReport.reportID)) {
setIsHoldMenuVisible(true);
} else if (chatReport) {
- IOU.payMoneyRequest(type, chatReport, moneyRequestReport, false);
+ IOU.payMoneyRequest(type, chatReport, moneyRequestReport, true);
}
};
diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
index 8c236d020645..30a84210f77f 100755
--- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
+++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
@@ -329,7 +329,6 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
const isMerchantRequired = isPolicyExpenseChat && !isScanRequest && shouldShowMerchant;
const isCategoryRequired = canUseViolations && lodashGet(policy, 'requiresCategory', false);
- const isTagRequired = canUseViolations && lodashGet(policy, 'requiresTag', false);
useEffect(() => {
if ((!isMerchantRequired && isMerchantEmpty) || !merchantError) {
@@ -534,7 +533,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
if (updatedTagsString !== TransactionUtils.getTag(transaction) && updatedTagsString) {
IOU.setMoneyRequestTag(transaction.transactionID, updatedTagsString);
}
- }, [policyTagLists, transaction, policyTags, isTagRequired, canUseViolations]);
+ }, [policyTagLists, transaction, policyTags, canUseViolations]);
/**
* @param {Object} option
@@ -642,6 +641,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
onPress={confirm}
enablePaymentsRoute={ROUTES.IOU_SEND_ENABLE_PAYMENTS}
addBankAccountRoute={bankAccountRoute}
+ shouldShowPersonalBankAccountOption
currency={iouCurrencyCode}
policyID={policyID}
buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE}
@@ -829,28 +829,38 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
shouldShow: shouldShowCategories,
isSupplementary: !isCategoryRequired,
},
- ..._.map(policyTagLists, ({name}, index) => ({
- item: (
-
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.CREATE, iouType, index, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()),
- )
- }
- style={[styles.moneyRequestMenuItem]}
- disabled={didConfirm}
- interactive={!isReadOnly}
- rightLabel={isTagRequired ? translate('common.required') : ''}
- />
- ),
- shouldShow: shouldShowTags,
- isSupplementary: !isTagRequired,
- })),
+ ..._.map(policyTagLists, ({name, required}, index) => {
+ const isTagRequired = isUndefined(required) ? false : canUseViolations && required;
+ return {
+ item: (
+
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(
+ CONST.IOU.ACTION.CREATE,
+ iouType,
+ index,
+ transaction.transactionID,
+ reportID,
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ )
+ }
+ style={[styles.moneyRequestMenuItem]}
+ disabled={didConfirm}
+ interactive={!isReadOnly}
+ rightLabel={isTagRequired ? translate('common.required') : ''}
+ />
+ ),
+ shouldShow: shouldShowTags,
+ isSupplementary: !isTagRequired,
+ };
+ }),
{
item: (
reportActions.clearReportFieldErrors(report.reportID, reportField)}
>
- {({report}) => (
+ {({report, transactionThreadReport}) => (
Navigation.navigate(ROUTES.TRANSACTION_RECEIPT.getRoute(report?.reportID ?? '', transaction?.transactionID ?? ''))}
+ onPress={() =>
+ Navigation.navigate(ROUTES.TRANSACTION_RECEIPT.getRoute(transactionThreadReport?.reportID ?? report?.reportID ?? '', transaction?.transactionID ?? ''))
+ }
accessibilityLabel={translate('accessibilityHints.viewAttachment')}
accessibilityRole={CONST.ROLE.BUTTON}
>
diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx
index efe0b71b1012..190343e48abd 100644
--- a/src/components/ReportActionItem/ReportPreview.tsx
+++ b/src/components/ReportActionItem/ReportPreview.tsx
@@ -128,7 +128,7 @@ function ReportPreview({
const isOpenExpenseReport = isPolicyExpenseChat && ReportUtils.isOpenExpenseReport(iouReport);
const isApproved = ReportUtils.isReportApproved(iouReport);
- const canAllowSettlement = ReportUtils.hasUpdatedTotal(iouReport);
+ const canAllowSettlement = ReportUtils.hasUpdatedTotal(iouReport, policy);
const allTransactions = TransactionUtils.getAllReportTransactions(iouReportID);
const transactionsWithReceipts = ReportUtils.getTransactionsWithReceipts(iouReportID);
const numberOfScanningReceipts = transactionsWithReceipts.filter((transaction) => TransactionUtils.isReceiptBeingScanned(transaction)).length;
diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx
index cd1a40b5ef5d..038a1dbcec8d 100644
--- a/src/components/SelectionList/BaseListItem.tsx
+++ b/src/components/SelectionList/BaseListItem.tsx
@@ -81,6 +81,15 @@ function BaseListItem({
)}
+ {!item.isSelected && !!item.brickRoadIndicator && (
+
+
+
+ )}
+
{rightHandSideComponentRender()}
{FooterComponent}
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index 8dd7577de779..80d6ce122719 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -71,6 +71,9 @@ function BaseSelectionList(
textInputRef,
headerMessageStyle,
shouldHideListOnInitialRender = true,
+ textInputIconLeft,
+ sectionTitleStyles,
+ textInputAutoFocus = true,
}: BaseSelectionListProps,
ref: ForwardedRef,
) {
@@ -79,7 +82,7 @@ function BaseSelectionList(
const listRef = useRef>>(null);
const innerTextInputRef = useRef(null);
const focusTimeoutRef = useRef(null);
- const shouldShowTextInput = !!textInputLabel;
+ const shouldShowTextInput = !!textInputLabel || !!textInputIconLeft;
const shouldShowSelectAll = !!onSelectAll;
const activeElementRole = useActiveElementRole();
const isFocused = useIsFocused();
@@ -310,7 +313,7 @@ function BaseSelectionList(
// We do this so that we can reference the height in `getItemLayout` –
// we need to know the heights of all list items up-front in order to synchronously compute the layout of any given list item.
// So be aware that if you adjust the content of the section header (for example, change the font size), you may need to adjust this explicit height as well.
-
+
{section.title}
);
@@ -377,6 +380,9 @@ function BaseSelectionList(
/** Focuses the text input when the component comes into focus and after any navigation animations finish. */
useFocusEffect(
useCallback(() => {
+ if (!textInputAutoFocus) {
+ return;
+ }
if (shouldShowTextInput) {
focusTimeoutRef.current = setTimeout(() => {
if (!innerTextInputRef.current) {
@@ -391,7 +397,7 @@ function BaseSelectionList(
}
clearTimeout(focusTimeoutRef.current);
};
- }, [shouldShowTextInput]),
+ }, [shouldShowTextInput, textInputAutoFocus]),
);
const prevTextInputValue = usePrevious(textInputValue);
@@ -494,8 +500,12 @@ function BaseSelectionList(
return;
}
- // eslint-disable-next-line no-param-reassign
- textInputRef.current = element as RNTextInput;
+ if (typeof textInputRef === 'function') {
+ textInputRef(element as RNTextInput);
+ } else {
+ // eslint-disable-next-line no-param-reassign
+ textInputRef.current = element as RNTextInput;
+ }
}}
label={textInputLabel}
accessibilityLabel={textInputLabel}
@@ -508,6 +518,7 @@ function BaseSelectionList(
inputMode={inputMode}
selectTextOnFocus
spellCheck={false}
+ iconLeft={textInputIconLeft}
onSubmitEditing={selectFocusedOption}
blurOnSubmit={!!flattenedSections.allOptions.length}
isLoading={isLoadingNewOptions}
@@ -515,7 +526,9 @@ function BaseSelectionList(
/>
)}
- {!!headerMessage && (
+ {/* If we are loading new options we will avoid showing any header message. This is mostly because one of the header messages says there are no options. */}
+ {/* This is misleading because we might be in the process of loading fresh options from the server. */}
+ {!isLoadingNewOptions && !!headerMessage && (
{headerMessage}
diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts
index e401dd5456b2..38c5f03fcae6 100644
--- a/src/components/SelectionList/types.ts
+++ b/src/components/SelectionList/types.ts
@@ -1,10 +1,12 @@
import type {MutableRefObject, ReactElement, ReactNode} from 'react';
import type {GestureResponderEvent, InputModeOptions, LayoutChangeEvent, SectionListData, StyleProp, TextInput, TextStyle, ViewStyle} from 'react-native';
import type {MaybePhraseKey} from '@libs/Localize';
+import type {BrickRoad} from '@libs/WorkspacesSettingsUtils';
import type CONST from '@src/CONST';
import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon';
import type {ReceiptErrors} from '@src/types/onyx/Transaction';
import type ChildrenProps from '@src/types/utils/ChildrenProps';
+import type IconAsset from '@src/types/utils/IconAsset';
import type InviteMemberListItem from './InviteMemberListItem';
import type RadioListItem from './RadioListItem';
import type TableListItem from './TableListItem';
@@ -33,7 +35,7 @@ type CommonListItemProps = {
onDismissError?: (item: TItem) => void;
/** Component to display on the right side */
- rightHandSideComponent?: ((item: TItem) => ReactElement) | ReactElement | null;
+ rightHandSideComponent?: ((item: TItem) => ReactElement | null) | ReactElement | null;
/** Styles for the pressable component */
pressableStyle?: StyleProp;
@@ -110,6 +112,8 @@ type ListItem = {
/** The search value from the selection list */
searchText?: string | null;
+
+ brickRoadIndicator?: BrickRoad | '' | null;
};
type ListItemProps = CommonListItemProps & {
@@ -214,6 +218,12 @@ type BaseSelectionListProps = Partial & {
/** Max length for the text input */
textInputMaxLength?: number;
+ /** Icon to display on the left side of TextInput */
+ textInputIconLeft?: IconAsset;
+
+ /** Whether text input should be focused */
+ textInputAutoFocus?: boolean;
+
/** Callback to fire when the text input changes */
onChangeText?: (text: string) => void;
@@ -221,7 +231,7 @@ type BaseSelectionListProps = Partial & {
inputMode?: InputModeOptions;
/** Item `keyForList` to focus initially */
- initiallyFocusedOptionKey?: string;
+ initiallyFocusedOptionKey?: string | null;
/** Callback to fire when the list is scrolled */
onScroll?: () => void;
@@ -272,13 +282,13 @@ type BaseSelectionListProps = Partial & {
disableKeyboardShortcuts?: boolean;
/** Styles to apply to SelectionList container */
- containerStyle?: ViewStyle;
+ containerStyle?: StyleProp;
/** Whether keyboard is visible on the screen */
isKeyboardShown?: boolean;
/** Component to display on the right side of each child */
- rightHandSideComponent?: ((item: ListItem) => ReactElement) | ReactElement | null;
+ rightHandSideComponent?: ((item: ListItem) => ReactElement | null) | ReactElement | null;
/** Whether to show the loading indicator for new options */
isLoadingNewOptions?: boolean;
@@ -296,7 +306,10 @@ type BaseSelectionListProps = Partial & {
isRowMultilineSupported?: boolean;
/** Ref for textInput */
- textInputRef?: MutableRefObject;
+ textInputRef?: MutableRefObject | ((ref: TextInput | null) => void);
+
+ /** Styles for the section title */
+ sectionTitleStyles?: StyleProp;
/**
* When true, the list won't be visible until the list layout is measured. This prevents the list from "blinking" as it's scrolled to the bottom which is recommended for large lists.
diff --git a/src/components/ShowContextMenuContext.ts b/src/components/ShowContextMenuContext.ts
index 374ca8a2f1e5..3a996a8d2c64 100644
--- a/src/components/ShowContextMenuContext.ts
+++ b/src/components/ShowContextMenuContext.ts
@@ -13,6 +13,7 @@ type ShowContextMenuContextProps = {
anchor: ContextMenuAnchor;
report: OnyxEntry;
action: OnyxEntry;
+ transactionThreadReport: OnyxEntry;
checkIfContextMenuActive: () => void;
};
@@ -20,6 +21,7 @@ const ShowContextMenuContext = createContext({
anchor: null,
report: null,
action: null,
+ transactionThreadReport: null,
checkIfContextMenuActive: () => {},
});
diff --git a/src/components/TagPicker/index.tsx b/src/components/TagPicker/index.tsx
index 54ad016173b7..b9d2d61efa7d 100644
--- a/src/components/TagPicker/index.tsx
+++ b/src/components/TagPicker/index.tsx
@@ -1,10 +1,9 @@
import React, {useMemo, useState} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import type {EdgeInsets} from 'react-native-safe-area-context';
-import OptionsSelector from '@components/OptionsSelector';
+import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
import useLocalize from '@hooks/useLocalize';
-import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
@@ -41,12 +40,6 @@ type TagPickerProps = TagPickerOnyxProps & {
/** Callback to submit the selected tag */
onSubmit: () => void;
- /**
- * Safe area insets required for reflecting the portion of the view,
- * that is not covered by navigation bars, tab bars, toolbars, and other ancestor views.
- */
- insets: EdgeInsets;
-
/** Should show the selected option that is disabled? */
shouldShowDisabledAndSelectedOption?: boolean;
@@ -54,9 +47,8 @@ type TagPickerProps = TagPickerOnyxProps & {
tagListIndex: number;
};
-function TagPicker({selectedTag, tagListName, policyTags, tagListIndex, policyRecentlyUsedTags, shouldShowDisabledAndSelectedOption = false, insets, onSubmit}: TagPickerProps) {
+function TagPicker({selectedTag, tagListName, policyTags, tagListIndex, policyRecentlyUsedTags, shouldShowDisabledAndSelectedOption = false, onSubmit}: TagPickerProps) {
const styles = useThemeStyles();
- const StyleUtils = useStyleUtils();
const {translate} = useLocalize();
const [searchValue, setSearchValue] = useState('');
@@ -100,22 +92,14 @@ function TagPicker({selectedTag, tagListName, policyTags, tagListIndex, policyRe
const selectedOptionKey = sections[0]?.data?.filter((policyTag) => policyTag.searchText === selectedTag)?.[0]?.keyForList;
return (
- ) => void;
+import type {View} from 'react-native';
+import {TextInput} from 'react-native';
+import type {ValueOf} from 'type-fest';
+import CONST from '@src/CONST';
+import isWindowReadyToFocus from './isWindowReadyToFocus';
-function resetReadyToFocus() {
- isReadyToFocusPromise = new Promise((resolve) => {
- resolveIsReadyToFocus = resolve;
+type ModalId = number | undefined;
+
+type InputElement = (TextInput & HTMLElement) | null;
+
+type RestoreFocusType = ValueOf | undefined;
+
+type ModalContainer = (View & HTMLElement) | undefined | null;
+
+/**
+ * So far, modern browsers only support the file cancel event in some newer versions
+ * (i.e., Chrome: 113+ / Firefox: 91+ / Safari 16.4+), and there is no standard feature detection method available.
+ * We will introduce the isInUploadingContext field to isolate the impact of the upload modal on the other modals.
+ */
+type FocusMapValue = {
+ input: InputElement;
+ isInUploadingContext?: boolean;
+};
+
+type PromiseMapValue = {
+ ready: Promise;
+ resolve: () => void;
+};
+
+let focusedInput: InputElement = null;
+let uniqueModalId = 1;
+const focusMap = new Map();
+const activeModals: ModalId[] = [];
+const promiseMap = new Map();
+
+/**
+ * Returns the ref of the currently focused text field, if one exists.
+ * react-native-web doesn't support `currentlyFocusedInput`, so we need to make it compatible by using `currentlyFocusedField` instead.
+ */
+function getActiveInput() {
+ return (TextInput.State.currentlyFocusedInput ? TextInput.State.currentlyFocusedInput() : TextInput.State.currentlyFocusedField()) as InputElement;
+}
+
+/**
+ * On web platform, if the modal is displayed by a click, the blur event is fired before the modal appears,
+ * so we need to cache the focused input in the pointerdown handler, which is fired before the blur event.
+ */
+function saveFocusedInput() {
+ focusedInput = getActiveInput();
+}
+
+/**
+ * If a click does not display the modal, we also should clear the cached value to avoid potential issues.
+ */
+function clearFocusedInput() {
+ if (!focusedInput) {
+ return;
+ }
+
+ // For the PopoverWithMeasuredContent component, Modal is only mounted after onLayout event is triggered,
+ // this event is placed within a setTimeout in react-native-web,
+ // so we can safely clear the cached value only after this event.
+ setTimeout(() => (focusedInput = null), CONST.ANIMATION_IN_TIMING);
+}
+
+/**
+ * When a TextInput is unmounted, we also should release the reference here to avoid potential issues.
+ *
+ */
+function releaseInput(input: InputElement) {
+ if (!input) {
+ return;
+ }
+ if (input === focusedInput) {
+ focusedInput = null;
+ }
+ focusMap.forEach((value, key) => {
+ if (value.input !== input) {
+ return;
+ }
+ focusMap.delete(key);
});
}
-function setReadyToFocus() {
- if (!resolveIsReadyToFocus) {
+function getId() {
+ return uniqueModalId++;
+}
+
+/**
+ * Save the focus state when opening the modal.
+ */
+function saveFocusState(id: ModalId, isInUploadingContext = false, shouldClearFocusWithType = false, container: ModalContainer = undefined) {
+ const activeInput = getActiveInput();
+
+ // For popoverWithoutOverlay, react calls autofocus before useEffect.
+ const input = focusedInput ?? activeInput;
+ focusedInput = null;
+ if (activeModals.indexOf(id) < 0) {
+ activeModals.push(id);
+ }
+
+ if (shouldClearFocusWithType) {
+ focusMap.forEach((value, key) => {
+ if (value.isInUploadingContext !== isInUploadingContext) {
+ return;
+ }
+ focusMap.delete(key);
+ });
+ }
+
+ if (container?.contains(input)) {
return;
}
- resolveIsReadyToFocus();
+ focusMap.set(id, {input, isInUploadingContext});
+ input?.blur();
}
-function isReadyToFocus(): Promise {
- return isReadyToFocusPromise;
+/**
+ * On web platform, if we intentionally click on another input box, there is no need to restore focus.
+ * Additionally, if we are closing the RHP, we can ignore the focused input.
+ */
+function focus(input: InputElement, shouldIgnoreFocused = false) {
+ const activeInput = getActiveInput();
+ if (!input || (activeInput && !shouldIgnoreFocused)) {
+ return;
+ }
+ isWindowReadyToFocus().then(() => input.focus());
}
+function tryRestoreTopmostFocus(shouldIgnoreFocused: boolean, isInUploadingContext = false) {
+ const topmost = [...focusMap].filter(([, v]) => v.input && v.isInUploadingContext === isInUploadingContext).at(-1);
+ if (topmost === undefined) {
+ return;
+ }
+ const [modalId, {input}] = topmost;
+
+ // This modal is still active
+ if (activeModals.indexOf(modalId) >= 0) {
+ return;
+ }
+ focus(input, shouldIgnoreFocused);
+ focusMap.delete(modalId);
+}
+
+/**
+ * Restore the focus state after the modal is dismissed.
+ */
+function restoreFocusState(id: ModalId, shouldIgnoreFocused = false, restoreFocusType: RestoreFocusType = CONST.MODAL.RESTORE_FOCUS_TYPE.DEFAULT, isInUploadingContext = false) {
+ if (!id || !activeModals.length) {
+ return;
+ }
+ const activeModalIndex = activeModals.indexOf(id);
+
+ // This id has been removed from the stack.
+ if (activeModalIndex < 0) {
+ return;
+ }
+ activeModals.splice(activeModalIndex, 1);
+ if (restoreFocusType === CONST.MODAL.RESTORE_FOCUS_TYPE.PRESERVE) {
+ return;
+ }
+
+ const {input} = focusMap.get(id) ?? {};
+ focusMap.delete(id);
+ if (restoreFocusType === CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE) {
+ return;
+ }
+
+ // This modal is not the topmost one, do not restore it.
+ if (activeModals.length > activeModalIndex) {
+ if (input) {
+ const lastId = activeModals.at(-1);
+ focusMap.set(lastId, {...focusMap.get(lastId), input});
+ }
+ return;
+ }
+ if (input) {
+ focus(input, shouldIgnoreFocused);
+ return;
+ }
+
+ // Try to find the topmost one and restore it
+ tryRestoreTopmostFocus(shouldIgnoreFocused, isInUploadingContext);
+}
+
+function resetReadyToFocus(id: ModalId) {
+ const promise: PromiseMapValue = {
+ ready: Promise.resolve(),
+ resolve: () => {},
+ };
+ promise.ready = new Promise((resolve) => {
+ promise.resolve = resolve;
+ });
+ promiseMap.set(id, promise);
+}
+
+/**
+ * Backward compatibility, for cases without an ModalId param, it's fine to just take the topmost one.
+ */
+function getTopmostModalId() {
+ if (promiseMap.size < 1) {
+ return 0;
+ }
+ return [...promiseMap.keys()].at(-1);
+}
+
+function setReadyToFocus(id?: ModalId) {
+ const key = id ?? getTopmostModalId();
+ const promise = promiseMap.get(key);
+ if (!promise) {
+ return;
+ }
+ promise.resolve?.();
+ promiseMap.delete(key);
+}
+
+function isReadyToFocus(id?: ModalId) {
+ const key = id ?? getTopmostModalId();
+ const promise = promiseMap.get(key);
+ if (!promise) {
+ return Promise.resolve();
+ }
+ return promise.ready;
+}
+
+function refocusAfterModalFullyClosed(id: ModalId, restoreType: RestoreFocusType, isInUploadingContext?: boolean) {
+ isReadyToFocus(id)?.then(() => restoreFocusState(id, false, restoreType, isInUploadingContext));
+}
+
+export type {InputElement};
+
export default {
+ getId,
+ saveFocusedInput,
+ clearFocusedInput,
+ releaseInput,
+ saveFocusState,
+ restoreFocusState,
resetReadyToFocus,
setReadyToFocus,
isReadyToFocus,
+ refocusAfterModalFullyClosed,
+ tryRestoreTopmostFocus,
};
diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts
index 4d4f8d425681..44c7682b47f2 100644
--- a/src/libs/DateUtils.ts
+++ b/src/libs/DateUtils.ts
@@ -267,7 +267,7 @@ function formatToLongDateWithWeekday(datetime: string | Date): string {
* @returns Sunday
*/
function formatToDayOfWeek(datetime: Date): string {
- return format(new Date(datetime), CONST.DATE.WEEKDAY_TIME_FORMAT);
+ return format(datetime, CONST.DATE.WEEKDAY_TIME_FORMAT);
}
/**
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
index 295daa1938e7..fde0202d3d2f 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
@@ -2,6 +2,7 @@ import React, {memo, useEffect, useMemo, useRef} from 'react';
import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import Onyx, {withOnyx} from 'react-native-onyx';
+import OptionsListContextProvider from '@components/OptionListContextProvider';
import useOnboardingLayout from '@hooks/useOnboardingLayout';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -266,130 +267,132 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie
}, []);
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx
index 38bfe4af9ab6..fd5282a8cfcd 100644
--- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx
@@ -17,19 +17,21 @@ import * as Session from '@userActions/Session';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {Policy} from '@src/types/onyx';
+import type {Policy, Session as SessionType} from '@src/types/onyx';
type TopBarOnyxProps = {
policy: OnyxEntry;
+ session: OnyxEntry>;
};
// eslint-disable-next-line react/no-unused-prop-types
type TopBarProps = {activeWorkspaceID?: string} & TopBarOnyxProps;
-function TopBar({policy}: TopBarProps) {
+function TopBar({policy, session}: TopBarProps) {
const styles = useThemeStyles();
const theme = useTheme();
const {translate} = useLocalize();
+ const isAnonymousUser = Session.isAnonymousUser(session);
const headerBreadcrumb = policy?.name
? {type: CONST.BREADCRUMB_TYPE.STRONG, text: policy.name}
@@ -57,7 +59,7 @@ function TopBar({policy}: TopBarProps) {
/>
- {Session.isAnonymousUser() ? (
+ {isAnonymousUser ? (
) : (
@@ -84,4 +86,8 @@ export default withOnyx({
policy: {
key: ({activeWorkspaceID}) => `${ONYXKEYS.COLLECTION.POLICY}${activeWorkspaceID}`,
},
+ session: {
+ key: ONYXKEYS.SESSION,
+ selector: (session) => session && {authTokenType: session.authTokenType},
+ },
})(TopBar);
diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts
index 8b0afe2d24c3..0e76596ba8fa 100644
--- a/src/libs/NextStepUtils.ts
+++ b/src/libs/NextStepUtils.ts
@@ -81,7 +81,7 @@ function buildNextStep(
const {policyID = '', ownerAccountID = -1, managerID = -1} = report;
const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] ?? ({} as Policy);
- const {submitsTo, harvesting, isPreventSelfApprovalEnabled, preventSelfApproval, autoReportingFrequency, autoReportingOffset} = policy;
+ const {submitsTo, harvesting, preventSelfApproval, autoReportingFrequency, autoReportingOffset} = policy;
const isOwner = currentUserAccountID === ownerAccountID;
const isManager = currentUserAccountID === managerID;
const isSelfApproval = currentUserAccountID === submitsTo;
@@ -172,7 +172,7 @@ function buildNextStep(
}
// Prevented self submitting
- if ((isPreventSelfApprovalEnabled ?? preventSelfApproval) && isSelfApproval) {
+ if (preventSelfApproval && isSelfApproval) {
optimisticNextStep.message = [
{
text: "Oops! Looks like you're submitting to ",
@@ -255,6 +255,20 @@ function buildNextStep(
break;
}
+ // Generates an optimistic nextStep once a report has been closed for example in the case of Submit and Close approval flow
+ case CONST.REPORT.STATUS_NUM.CLOSED:
+ optimisticNextStep = {
+ type,
+ title: 'Finished!',
+ message: [
+ {
+ text: 'No further action required!',
+ },
+ ],
+ };
+
+ break;
+
// Generates an optimistic nextStep once a report has been approved
case CONST.REPORT.STATUS_NUM.APPROVED:
// Self review
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index 3acbd9232c87..e1a3e9207ad8 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -157,6 +157,9 @@ type GetOptionsConfig = {
includeSelectedOptions?: boolean;
includeTaxRates?: boolean;
taxRates?: TaxRatesWithDefault;
+ includePolicyReportFieldOptions?: boolean;
+ policyReportFieldOptions?: string[];
+ recentlyUsedPolicyReportFieldOptions?: string[];
transactionViolations?: OnyxCollection;
};
@@ -184,6 +187,7 @@ type GetOptions = {
categoryOptions: CategoryTreeSection[];
tagOptions: CategorySection[];
taxRatesOptions: CategorySection[];
+ policyReportFieldOptions?: CategorySection[] | null;
};
type PreviewConfig = {showChatPreviewLine?: boolean; forcePolicyNamePreview?: boolean; showPersonalDetails?: boolean};
@@ -1105,7 +1109,7 @@ function getCategoryListSections(
*
* @param tags - an initial tag array
*/
-function getTagsOptions(tags: Array>): Option[] {
+function getTagsOptions(tags: Array>, selectedOptions?: SelectedTagOption[]): Option[] {
return tags.map((tag) => {
// This is to remove unnecessary escaping backslash in tag name sent from backend.
const cleanedName = PolicyUtils.getCleanedTagName(tag.name);
@@ -1115,6 +1119,7 @@ function getTagsOptions(tags: Array>): Optio
searchText: tag.name,
tooltipText: cleanedName,
isDisabled: !tag.enabled,
+ isSelected: selectedOptions?.some((selectedTag) => selectedTag.name === tag.name),
};
});
}
@@ -1146,7 +1151,7 @@ function getTagListSections(
// "Selected" section
title: '',
shouldShow: false,
- data: getTagsOptions(selectedTagOptions),
+ data: getTagsOptions(selectedTagOptions, selectedOptions),
});
return tagSections;
@@ -1159,7 +1164,7 @@ function getTagListSections(
// "Search" section
title: '',
shouldShow: true,
- data: getTagsOptions(searchTags),
+ data: getTagsOptions(searchTags, selectedOptions),
});
return tagSections;
@@ -1170,7 +1175,7 @@ function getTagListSections(
// "All" section when items amount less than the threshold
title: '',
shouldShow: false,
- data: getTagsOptions(enabledTags),
+ data: getTagsOptions(enabledTags, selectedOptions),
});
return tagSections;
@@ -1195,7 +1200,7 @@ function getTagListSections(
// "Selected" section
title: '',
shouldShow: true,
- data: getTagsOptions(selectedTagOptions),
+ data: getTagsOptions(selectedTagOptions, selectedOptions),
});
}
@@ -1206,7 +1211,7 @@ function getTagListSections(
// "Recent" section
title: Localize.translateLocal('common.recent'),
shouldShow: true,
- data: getTagsOptions(cutRecentlyUsedTags),
+ data: getTagsOptions(cutRecentlyUsedTags, selectedOptions),
});
}
@@ -1214,7 +1219,7 @@ function getTagListSections(
// "All" section when items amount more than the threshold
title: Localize.translateLocal('common.all'),
shouldShow: true,
- data: getTagsOptions(filteredTags),
+ data: getTagsOptions(filteredTags, selectedOptions),
});
return tagSections;
@@ -1229,6 +1234,81 @@ function hasEnabledTags(policyTagList: Array
return hasEnabledOptions(policyTagValueList);
}
+/**
+ * Transforms the provided report field options into option objects.
+ *
+ * @param reportFieldOptions - an initial report field options array
+ */
+function getReportFieldOptions(reportFieldOptions: string[]): Option[] {
+ return reportFieldOptions.map((name) => ({
+ text: name,
+ keyForList: name,
+ searchText: name,
+ tooltipText: name,
+ isDisabled: false,
+ }));
+}
+
+/**
+ * Build the section list for report field options
+ */
+function getReportFieldOptionsSection(options: string[], recentlyUsedOptions: string[], selectedOptions: Array>, searchInputValue: string) {
+ const reportFieldOptionsSections = [];
+ const selectedOptionKeys = selectedOptions.map(({text, keyForList, name}) => text ?? keyForList ?? name ?? '').filter((o) => !!o);
+ let indexOffset = 0;
+
+ if (searchInputValue) {
+ const searchOptions = options.filter((option) => option.toLowerCase().includes(searchInputValue.toLowerCase()));
+
+ reportFieldOptionsSections.push({
+ // "Search" section
+ title: '',
+ shouldShow: true,
+ indexOffset,
+ data: getReportFieldOptions(searchOptions),
+ });
+
+ return reportFieldOptionsSections;
+ }
+
+ const filteredRecentlyUsedOptions = recentlyUsedOptions.filter((recentlyUsedOption) => !selectedOptionKeys.includes(recentlyUsedOption));
+ const filteredOptions = options.filter((option) => !selectedOptionKeys.includes(option));
+
+ if (selectedOptionKeys.length) {
+ reportFieldOptionsSections.push({
+ // "Selected" section
+ title: '',
+ shouldShow: true,
+ indexOffset,
+ data: getReportFieldOptions(selectedOptionKeys),
+ });
+
+ indexOffset += selectedOptionKeys.length;
+ }
+
+ if (filteredRecentlyUsedOptions.length > 0) {
+ reportFieldOptionsSections.push({
+ // "Recent" section
+ title: Localize.translateLocal('common.recent'),
+ shouldShow: true,
+ indexOffset,
+ data: getReportFieldOptions(filteredRecentlyUsedOptions),
+ });
+
+ indexOffset += filteredRecentlyUsedOptions.length;
+ }
+
+ reportFieldOptionsSections.push({
+ // "All" section when items amount more than the threshold
+ title: Localize.translateLocal('common.all'),
+ shouldShow: true,
+ indexOffset,
+ data: getReportFieldOptions(filteredOptions),
+ });
+
+ return reportFieldOptionsSections;
+}
+
/**
* Transforms tax rates to a new object format - to add codes and new name with concatenated name and value.
*
@@ -1454,6 +1534,9 @@ function getOptions(
includeTaxRates,
taxRates,
includeSelfDM = false,
+ includePolicyReportFieldOptions = false,
+ policyReportFieldOptions = [],
+ recentlyUsedPolicyReportFieldOptions = [],
}: GetOptionsConfig,
): GetOptions {
if (includeCategories) {
@@ -1498,6 +1581,20 @@ function getOptions(
};
}
+ if (includePolicyReportFieldOptions) {
+ const transformedPolicyReportFieldOptions = getReportFieldOptionsSection(policyReportFieldOptions, recentlyUsedPolicyReportFieldOptions, selectedOptions, searchInputValue);
+ return {
+ recentReports: [],
+ personalDetails: [],
+ userToInvite: null,
+ currentUserOption: null,
+ categoryOptions: [],
+ tagOptions: [],
+ taxRatesOptions: [],
+ policyReportFieldOptions: transformedPolicyReportFieldOptions,
+ };
+ }
+
const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchInputValue)));
const searchValue = parsedPhoneNumber.possible ? parsedPhoneNumber.number?.e164 : searchInputValue.toLowerCase();
const topmostReportId = Navigation.getTopmostReportId() ?? '';
@@ -1881,6 +1978,9 @@ function getFilteredOptions(
includeTaxRates = false,
taxRates: TaxRatesWithDefault = {} as TaxRatesWithDefault,
includeSelfDM = false,
+ includePolicyReportFieldOptions = false,
+ policyReportFieldOptions: string[] = [],
+ recentlyUsedPolicyReportFieldOptions: string[] = [],
) {
return getOptions(
{reports, personalDetails},
@@ -1905,6 +2005,9 @@ function getFilteredOptions(
includeTaxRates,
taxRates,
includeSelfDM,
+ includePolicyReportFieldOptions,
+ policyReportFieldOptions,
+ recentlyUsedPolicyReportFieldOptions,
},
);
}
@@ -2138,4 +2241,4 @@ export {
getTaxRatesSection,
};
-export type {MemberForList, CategorySection, GetOptions, OptionList, SearchOption, PayeePersonalDetails, Category, TaxRatesOption};
+export type {MemberForList, CategorySection, CategoryTreeSection, GetOptions, OptionList, SearchOption, PayeePersonalDetails, Category, TaxRatesOption};
diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts
index c9ea65781117..081d1139bf5d 100644
--- a/src/libs/PersonalDetailsUtils.ts
+++ b/src/libs/PersonalDetailsUtils.ts
@@ -17,11 +17,18 @@ type FirstAndLastName = {
let personalDetails: Array = [];
let allPersonalDetails: OnyxEntry = {};
+let emailToPersonalDetailsCache: Record = {};
Onyx.connect({
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
callback: (val) => {
personalDetails = Object.values(val ?? {});
allPersonalDetails = val;
+ emailToPersonalDetailsCache = personalDetails.reduce((acc: Record, detail) => {
+ if (detail?.login) {
+ acc[detail.login.toLowerCase()] = detail;
+ }
+ return acc;
+ }, {});
},
});
@@ -77,7 +84,7 @@ function getPersonalDetailsByIDs(accountIDs: number[], currentUserAccountID: num
}
function getPersonalDetailByEmail(email: string): PersonalDetails | undefined {
- return (Object.values(allPersonalDetails ?? {}) as PersonalDetails[]).find((detail) => detail?.login === email);
+ return emailToPersonalDetailsCache[email.toLowerCase()];
}
/**
diff --git a/src/libs/ReportActionComposeFocusManager.ts b/src/libs/ReportActionComposeFocusManager.ts
index 123d97987e14..11c1fd04329f 100644
--- a/src/libs/ReportActionComposeFocusManager.ts
+++ b/src/libs/ReportActionComposeFocusManager.ts
@@ -3,7 +3,7 @@ import type {TextInput} from 'react-native';
import ROUTES from '@src/ROUTES';
import Navigation from './Navigation/Navigation';
-type FocusCallback = () => void;
+type FocusCallback = (shouldFocusForNonBlurInputOnTapOutside?: boolean) => void;
const composerRef = React.createRef();
const editComposerRef = React.createRef();
@@ -18,7 +18,7 @@ let mainComposerFocusCallback: FocusCallback | null = null;
*
* @param callback callback to register
*/
-function onComposerFocus(callback: FocusCallback, isMainComposer = false) {
+function onComposerFocus(callback: FocusCallback | null, isMainComposer = false) {
if (isMainComposer) {
mainComposerFocusCallback = callback;
} else {
@@ -29,7 +29,7 @@ function onComposerFocus(callback: FocusCallback, isMainComposer = false) {
/**
* Request focus on the ReportActionComposer
*/
-function focus() {
+function focus(shouldFocusForNonBlurInputOnTapOutside?: boolean) {
/** Do not trigger the refocusing when the active route is not the report route, */
if (!Navigation.isActiveRoute(ROUTES.REPORT_WITH_ID.getRoute(Navigation.getTopmostReportId() ?? ''))) {
return;
@@ -40,7 +40,7 @@ function focus() {
return;
}
- mainComposerFocusCallback();
+ mainComposerFocusCallback(shouldFocusForNonBlurInputOnTapOutside);
return;
}
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 2cb8b205061b..f9b850f3a655 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -29,6 +29,7 @@ import type {
ReportMetadata,
Session,
Task,
+ TaxRate,
Transaction,
TransactionViolation,
UserWallet,
@@ -416,6 +417,9 @@ type OptionData = {
isDisabled?: boolean | null;
name?: string | null;
isSelfDM?: boolean | null;
+ reportID?: string;
+ enabled?: boolean;
+ data?: Partial;
} & Report;
type OnyxDataTaskAssigneeChat = {
@@ -995,7 +999,7 @@ function filterReportsByPolicyIDAndMemberAccountIDs(reports: Report[], policyMem
/**
* Given an array of reports, return them sorted by the last read timestamp.
*/
-function sortReportsByLastRead(reports: Report[], reportMetadata: OnyxCollection): Array> {
+function sortReportsByLastRead(reports: Array>, reportMetadata: OnyxCollection): Array> {
return reports
.filter((report) => !!report?.reportID && !!(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]?.lastVisitTime ?? report?.lastReadTime))
.sort((a, b) => {
@@ -2783,7 +2787,7 @@ function getModifiedExpenseOriginalMessage(
}
if ('taxCode' in transactionChanges) {
- originalMessage.oldTaxRate = policy?.taxRates?.taxes[TransactionUtils.getTaxCode(oldTransaction)].value;
+ originalMessage.oldTaxRate = policy?.taxRates?.taxes[TransactionUtils.getTaxCode(oldTransaction)]?.value;
originalMessage.taxRate = transactionChanges?.taxCode && policy?.taxRates?.taxes[transactionChanges?.taxCode].value;
}
@@ -4879,6 +4883,7 @@ function canRequestMoney(report: OnyxEntry, policy: OnyxEntry, o
* - Send option should show for:
* - DMs
* - Split options should show for:
+ * - DMs
* - chat/ policy rooms with more than 1 participants
* - groups chats with 3 and more participants
* - corporate workspace chats
@@ -4904,7 +4909,6 @@ function getMoneyRequestOptions(report: OnyxEntry, policy: OnyxEntry currentUserPersonalDetails?.accountID !== accountID);
const hasSingleOtherParticipantInReport = otherParticipants.length === 1;
- const hasMultipleOtherParticipants = otherParticipants.length > 1;
let options: Array> = [];
if (isSelfDM(report)) {
@@ -4913,11 +4917,11 @@ function getMoneyRequestOptions(report: OnyxEntry, policy: OnyxEntry 0) ||
- (isDM(report) && hasMultipleOtherParticipants) ||
+ (isDM(report) && otherParticipants.length > 0) ||
(isGroupChat(report) && otherParticipants.length > 0) ||
(isPolicyExpenseChat(report) && report?.isOwnPolicyExpenseChat)
) {
@@ -5548,7 +5552,7 @@ function shouldDisplayThreadReplies(reportAction: OnyxEntry, repor
/**
* Check if money report has any transactions updated optimistically
*/
-function hasUpdatedTotal(report: OnyxEntry): boolean {
+function hasUpdatedTotal(report: OnyxEntry, policy: OnyxEntry): boolean {
if (!report) {
return true;
}
@@ -5556,18 +5560,19 @@ function hasUpdatedTotal(report: OnyxEntry): boolean {
const transactions = TransactionUtils.getAllReportTransactions(report.reportID);
const hasPendingTransaction = transactions.some((transaction) => !!transaction.pendingAction);
const hasTransactionWithDifferentCurrency = transactions.some((transaction) => transaction.currency !== report.currency);
+ const hasDifferentWorkspaceCurrency = report.pendingFields?.createChat && isExpenseReport(report) && report.currency !== policy?.outputCurrency;
- return !(hasPendingTransaction && hasTransactionWithDifferentCurrency) && !(hasHeldExpenses(report.reportID) && report?.unheldTotal === undefined);
+ return !(hasPendingTransaction && (hasTransactionWithDifferentCurrency || hasDifferentWorkspaceCurrency)) && !(hasHeldExpenses(report.reportID) && report?.unheldTotal === undefined);
}
/**
* Return held and full amount formatted with used currency
*/
-function getNonHeldAndFullAmount(iouReport: OnyxEntry): string[] {
+function getNonHeldAndFullAmount(iouReport: OnyxEntry, policy: OnyxEntry): string[] {
const transactions = TransactionUtils.getAllReportTransactions(iouReport?.reportID ?? '');
const hasPendingTransaction = transactions.some((transaction) => !!transaction.pendingAction);
- if (hasUpdatedTotal(iouReport) && hasPendingTransaction) {
+ if (hasUpdatedTotal(iouReport, policy) && hasPendingTransaction) {
const unheldTotal = transactions.reduce((currentVal, transaction) => currentVal - (!TransactionUtils.isOnHold(transaction) ? transaction.amount : 0), 0);
return [CurrencyUtils.convertToDisplayString(unheldTotal, iouReport?.currency ?? ''), CurrencyUtils.convertToDisplayString((iouReport?.total ?? 0) * -1, iouReport?.currency ?? '')];
diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts
index af28133ecf3f..4244f20d0bc3 100644
--- a/src/libs/TransactionUtils.ts
+++ b/src/libs/TransactionUtils.ts
@@ -625,7 +625,7 @@ function getEnabledTaxRateCount(options: TaxRates) {
*/
function getDefaultTaxName(taxRates: TaxRatesWithDefault, transaction?: Transaction) {
const defaultTaxKey = taxRates.defaultExternalID;
- const defaultTaxName = (defaultTaxKey && `${taxRates.taxes[defaultTaxKey].name} (${taxRates.taxes[defaultTaxKey].value}) • ${Localize.translateLocal('common.default')}`) || '';
+ const defaultTaxName = (defaultTaxKey && `${taxRates.taxes[defaultTaxKey]?.name} (${taxRates.taxes[defaultTaxKey]?.value}) • ${Localize.translateLocal('common.default')}`) || '';
return transaction?.taxRate?.text ?? defaultTaxName;
}
@@ -633,9 +633,9 @@ function getDefaultTaxName(taxRates: TaxRatesWithDefault, transaction?: Transact
* Gets the tax name
*/
function getTaxName(taxes: TaxRates, transactionTaxCode: string) {
- const taxName = `${taxes[transactionTaxCode].name}`;
- const taxValue = `${taxes[transactionTaxCode].value}`;
- return transactionTaxCode ? `${taxName} (${taxValue})` : '';
+ const taxName = taxes[transactionTaxCode]?.name ?? '';
+ const taxValue = taxes[transactionTaxCode]?.value ?? '';
+ return transactionTaxCode && taxName && taxValue ? `${taxName} (${taxValue})` : '';
}
export {
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index ab49305b5f0b..caba0d28564c 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -2734,7 +2734,7 @@ function splitBill(
API.write(WRITE_COMMANDS.SPLIT_BILL, parameters, onyxData);
resetMoneyRequestInfo();
- Navigation.dismissModal();
+ Navigation.dismissModal(existingSplitChatReportID);
Report.notifyNewAction(splitData.chatReportID, currentUserAccountID);
}
@@ -4743,8 +4743,8 @@ function submitReport(expenseReport: OnyxTypes.Report) {
const parentReport = ReportUtils.getReport(expenseReport.parentReportID);
const policy = getPolicy(expenseReport.policyID);
const isCurrentUserManager = currentUserPersonalDetails.accountID === expenseReport.managerID;
- const optimisticNextStep = NextStepUtils.buildNextStep(expenseReport, CONST.REPORT.STATUS_NUM.SUBMITTED);
const isSubmitAndClosePolicy = PolicyUtils.isSubmitAndClose(policy);
+ const optimisticNextStep = NextStepUtils.buildNextStep(expenseReport, isSubmitAndClosePolicy ? CONST.REPORT.STATUS_NUM.CLOSED : CONST.REPORT.STATUS_NUM.SUBMITTED);
const optimisticData: OnyxUpdate[] = !isSubmitAndClosePolicy
? [
@@ -4769,11 +4769,6 @@ function submitReport(expenseReport: OnyxTypes.Report) {
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
},
},
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`,
- value: optimisticNextStep,
- },
]
: [
{
@@ -4787,6 +4782,12 @@ function submitReport(expenseReport: OnyxTypes.Report) {
},
];
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`,
+ value: optimisticNextStep,
+ });
+
if (parentReport?.reportID) {
optimisticData.push({
onyxMethod: Onyx.METHOD.MERGE,
@@ -4822,24 +4823,22 @@ function submitReport(expenseReport: OnyxTypes.Report) {
stateNum: CONST.REPORT.STATE_NUM.OPEN,
},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`,
+ value: currentNextStep,
+ },
];
if (!isSubmitAndClosePolicy) {
- failureData.push(
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`,
- value: {
- [optimisticSubmittedReportAction.reportActionID]: {
- errors: ErrorUtils.getMicroSecondOnyxError('iou.error.other'),
- },
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`,
+ value: {
+ [optimisticSubmittedReportAction.reportActionID]: {
+ errors: ErrorUtils.getMicroSecondOnyxError('iou.error.other'),
},
},
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`,
- value: currentNextStep,
- },
- );
+ });
}
if (parentReport?.reportID) {
diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts
index 42a2852e42a6..ef6d839dfc2d 100644
--- a/src/libs/actions/Policy.ts
+++ b/src/libs/actions/Policy.ts
@@ -1951,6 +1951,11 @@ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', pol
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
customUnits,
makeMeAdmin,
+ autoReporting: true,
+ approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL,
+ harvesting: {
+ enabled: true,
+ },
},
},
{
@@ -2009,6 +2014,11 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName
isPolicyExpenseChatEnabled: true,
outputCurrency,
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ autoReporting: true,
+ approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL,
+ harvesting: {
+ enabled: true,
+ },
customUnits,
areCategoriesEnabled: true,
areTagsEnabled: false,
@@ -2497,6 +2507,11 @@ function createWorkspaceFromIOUPayment(iouReport: Report | EmptyObject): string
// Setting the currency to USD as we can only add the VBBA for this policy currency right now
outputCurrency: CONST.CURRENCY.USD,
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ autoReporting: true,
+ approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL,
+ harvesting: {
+ enabled: true,
+ },
customUnits,
areCategoriesEnabled: true,
areTagsEnabled: false,
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index 179ee87862ff..91128ac89178 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -70,16 +70,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
import type {Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
import INPUT_IDS from '@src/types/form/NewRoomForm';
-import type {
- NewGroupChatDraft,
- PersonalDetails,
- PersonalDetailsList,
- PolicyReportField,
- RecentlyUsedReportFields,
- ReportActionReactions,
- ReportMetadata,
- ReportUserIsTyping,
-} from '@src/types/onyx';
+import type {PersonalDetails, PersonalDetailsList, PolicyReportField, RecentlyUsedReportFields, ReportActionReactions, ReportMetadata, ReportUserIsTyping} from '@src/types/onyx';
import type {Decision, OriginalMessageIOU} from '@src/types/onyx/OriginalMessage';
import type {NotificationPreference, RoomVisibility, WriteCapability} from '@src/types/onyx/Report';
import type Report from '@src/types/onyx/Report';
@@ -212,12 +203,6 @@ Onyx.connect({
callback: (val) => (allRecentlyUsedReportFields = val),
});
-let newGroupDraft: OnyxEntry;
-Onyx.connect({
- key: ONYXKEYS.NEW_GROUP_CHAT_DRAFT,
- callback: (value) => (newGroupDraft = value),
-});
-
function clearGroupChat() {
Onyx.set(ONYXKEYS.NEW_GROUP_CHAT_DRAFT, null);
}
@@ -799,14 +784,15 @@ function navigateToAndOpenReport(userLogins: string[], shouldDismissModal = true
let newChat: ReportUtils.OptimisticChatReport | EmptyObject = {};
let chat: OnyxEntry | EmptyObject = {};
const participantAccountIDs = PersonalDetailsUtils.getAccountIDsByLogins(userLogins);
+ const isGroupChat = participantAccountIDs.length > 1;
// If we are not creating a new Group Chat then we are creating a 1:1 DM and will look for an existing chat
- if (!newGroupDraft) {
+ if (!isGroupChat) {
chat = ReportUtils.getChatByParticipants(participantAccountIDs);
}
if (isEmptyObject(chat)) {
- if (newGroupDraft) {
+ if (isGroupChat) {
newChat = ReportUtils.buildOptimisticChatReport(
participantAccountIDs,
reportName,
@@ -1612,6 +1598,18 @@ function updateReportName(reportID: string, value: string, previousValue: string
API.write(WRITE_COMMANDS.SET_REPORT_NAME, parameters, {optimisticData, failureData, successData});
}
+function clearReportFieldErrors(reportID: string, reportField: PolicyReportField) {
+ const fieldKey = ReportUtils.getReportFieldKey(reportField.fieldID);
+ Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {
+ pendingFields: {
+ [fieldKey]: null,
+ },
+ errorFields: {
+ [fieldKey]: null,
+ },
+ });
+}
+
function updateReportField(reportID: string, reportField: PolicyReportField, previousReportField: PolicyReportField) {
const fieldKey = ReportUtils.getReportFieldKey(reportField.fieldID);
const recentlyUsedValues = allRecentlyUsedReportFields?.[fieldKey] ?? [];
@@ -1692,6 +1690,65 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre
API.write(WRITE_COMMANDS.SET_REPORT_FIELD, parameters, {optimisticData, failureData, successData});
}
+function deleteReportField(reportID: string, reportField: PolicyReportField) {
+ const fieldKey = ReportUtils.getReportFieldKey(reportField.fieldID);
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: {
+ fieldList: {
+ [fieldKey]: null,
+ },
+ pendingFields: {
+ [fieldKey]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ },
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: {
+ fieldList: {
+ [fieldKey]: reportField,
+ },
+ pendingFields: {
+ [fieldKey]: null,
+ },
+ errorFields: {
+ [fieldKey]: ErrorUtils.getMicroSecondOnyxError('report.genericUpdateReportFieldFailureMessage'),
+ },
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: {
+ pendingFields: {
+ [fieldKey]: null,
+ },
+ errorFields: {
+ [fieldKey]: null,
+ },
+ },
+ },
+ ];
+
+ const parameters = {
+ reportID,
+ fieldID: fieldKey,
+ };
+
+ API.write(WRITE_COMMANDS.DELETE_REPORT_FIELD, parameters, {optimisticData, failureData, successData});
+}
+
function updateDescription(reportID: string, previousValue: string, newValue: string) {
// No change needed, navigate back
if (previousValue === newValue) {
@@ -3024,6 +3081,8 @@ export {
clearNewRoomFormError,
updateReportField,
updateReportName,
+ deleteReportField,
+ clearReportFieldErrors,
resolveActionableMentionWhisper,
updateRoomVisibility,
setGroupDraft,
diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts
index 17004baef43e..7f7531a094fa 100644
--- a/src/libs/actions/Session/index.ts
+++ b/src/libs/actions/Session/index.ts
@@ -2,7 +2,7 @@ import throttle from 'lodash/throttle';
import type {ChannelAuthorizationData} from 'pusher-js/types/src/core/auth/options';
import type {ChannelAuthorizationCallback} from 'pusher-js/with-encryption';
import {InteractionManager, Linking, NativeModules} from 'react-native';
-import type {OnyxUpdate} from 'react-native-onyx';
+import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import * as PersistedRequests from '@libs/actions/PersistedRequests';
@@ -175,8 +175,8 @@ function signOut() {
/**
* Checks if the account is an anonymous account.
*/
-function isAnonymousUser(): boolean {
- return session.authTokenType === CONST.AUTH_TOKEN_TYPES.ANONYMOUS;
+function isAnonymousUser(sessionParam?: OnyxEntry): boolean {
+ return (sessionParam?.authTokenType ?? session.authTokenType) === CONST.AUTH_TOKEN_TYPES.ANONYMOUS;
}
function hasStashedSession(): boolean {
diff --git a/src/libs/focusComposerWithDelay/index.ts b/src/libs/focusComposerWithDelay/index.ts
index 75e8f6ca8a67..cbd81b884d12 100644
--- a/src/libs/focusComposerWithDelay/index.ts
+++ b/src/libs/focusComposerWithDelay/index.ts
@@ -1,4 +1,5 @@
import ComposerFocusManager from '@libs/ComposerFocusManager';
+import isWindowReadyToFocus from '@libs/isWindowReadyToFocus';
import * as EmojiPickerAction from '@userActions/EmojiPickerAction';
import setTextInputSelection from './setTextInputSelection';
import type {FocusComposerWithDelay, InputType} from './types';
@@ -26,7 +27,7 @@ function focusComposerWithDelay(textInput: InputType | null): FocusComposerWithD
}
return;
}
- ComposerFocusManager.isReadyToFocus().then(() => {
+ Promise.all([ComposerFocusManager.isReadyToFocus(), isWindowReadyToFocus()]).then(() => {
if (!textInput) {
return;
}
diff --git a/src/libs/isWindowReadyToFocus/index.android.ts b/src/libs/isWindowReadyToFocus/index.android.ts
new file mode 100644
index 000000000000..b9cca1b5a294
--- /dev/null
+++ b/src/libs/isWindowReadyToFocus/index.android.ts
@@ -0,0 +1,27 @@
+import {AppState} from 'react-native';
+
+let isWindowReadyPromise = Promise.resolve();
+let resolveWindowReadyToFocus: () => void;
+
+AppState.addEventListener('focus', () => {
+ if (!resolveWindowReadyToFocus) {
+ return;
+ }
+ resolveWindowReadyToFocus();
+});
+
+AppState.addEventListener('blur', () => {
+ isWindowReadyPromise = new Promise((resolve) => {
+ resolveWindowReadyToFocus = resolve;
+ });
+});
+
+/**
+ * If we want to show the soft keyboard reliably, we need to ensure that the input's window gains focus first.
+ * Fortunately, we only need to manage the focus of the app window now,
+ * so we can achieve this by listening to the 'focus' event of the AppState.
+ * See {@link https://developer.android.com/develop/ui/views/touch-and-input/keyboard-input/visibility#ShowReliably}
+ */
+const isWindowReadyToFocus = () => isWindowReadyPromise;
+
+export default isWindowReadyToFocus;
diff --git a/src/libs/isWindowReadyToFocus/index.ts b/src/libs/isWindowReadyToFocus/index.ts
new file mode 100644
index 000000000000..7ae3930c0c1d
--- /dev/null
+++ b/src/libs/isWindowReadyToFocus/index.ts
@@ -0,0 +1,3 @@
+const isWindowReadyToFocus = () => Promise.resolve();
+
+export default isWindowReadyToFocus;
diff --git a/src/pages/EditReportFieldDatePage.tsx b/src/pages/EditReportFieldDate.tsx
similarity index 53%
rename from src/pages/EditReportFieldDatePage.tsx
rename to src/pages/EditReportFieldDate.tsx
index 3d60884d3cfc..e7021f9123d6 100644
--- a/src/pages/EditReportFieldDatePage.tsx
+++ b/src/pages/EditReportFieldDate.tsx
@@ -4,9 +4,7 @@ import DatePicker from '@components/DatePicker';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
import type {AnimatedTextInputRef} from '@components/RNTextInput';
-import ScreenWrapper from '@components/ScreenWrapper';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
@@ -46,40 +44,29 @@ function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, f
);
return (
- {
- inputRef.current?.focus();
- }}
- testID={EditReportFieldDatePage.displayName}
+
-
-
-
- {/* @ts-expect-error TODO: Remove this once DatePicker (https://github.com/Expensify/App/issues/25148) is migrated to TypeScript. */}
-
- InputComponent={DatePicker}
- inputID={fieldKey}
- name={fieldKey}
- defaultValue={fieldValue}
- label={fieldName}
- accessibilityLabel={fieldName}
- role={CONST.ROLE.PRESENTATION}
- maxDate={CONST.CALENDAR_PICKER.MAX_DATE}
- minDate={CONST.CALENDAR_PICKER.MIN_DATE}
- ref={inputRef}
- />
-
-
-
+
+
+
+
);
}
diff --git a/src/pages/EditReportFieldDropdown.tsx b/src/pages/EditReportFieldDropdown.tsx
new file mode 100644
index 000000000000..225051238e2b
--- /dev/null
+++ b/src/pages/EditReportFieldDropdown.tsx
@@ -0,0 +1,125 @@
+import React, {useCallback, useMemo} from 'react';
+import {withOnyx} from 'react-native-onyx';
+import type {OnyxEntry} from 'react-native-onyx';
+import Icon from '@components/Icon';
+import * as Expensicons from '@components/Icon/Expensicons';
+import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
+import type {ListItem} from '@components/SelectionList/types';
+import useDebouncedState from '@hooks/useDebouncedState';
+import useLocalize from '@hooks/useLocalize';
+import useTheme from '@hooks/useTheme';
+import * as OptionsListUtils from '@libs/OptionsListUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {RecentlyUsedReportFields} from '@src/types/onyx';
+
+type EditReportFieldDropdownPageComponentProps = {
+ /** Value of the policy report field */
+ fieldValue: string;
+
+ /** Key of the policy report field */
+ fieldKey: string;
+
+ /** ID of the policy this report field belongs to */
+ // eslint-disable-next-line react/no-unused-prop-types
+ policyID: string;
+
+ /** Options of the policy report field */
+ fieldOptions: string[];
+
+ /** Callback to fire when the Save button is pressed */
+ onSubmit: (form: Record) => void;
+};
+
+type EditReportFieldDropdownPageOnyxProps = {
+ recentlyUsedReportFields: OnyxEntry;
+};
+
+type EditReportFieldDropdownPageProps = EditReportFieldDropdownPageComponentProps & EditReportFieldDropdownPageOnyxProps;
+
+function EditReportFieldDropdownPage({onSubmit, fieldKey, fieldValue, fieldOptions, recentlyUsedReportFields}: EditReportFieldDropdownPageProps) {
+ const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState('');
+ const theme = useTheme();
+ const {translate} = useLocalize();
+ const recentlyUsedOptions = useMemo(() => recentlyUsedReportFields?.[fieldKey] ?? [], [recentlyUsedReportFields, fieldKey]);
+
+ const itemRightSideComponent = useCallback(
+ (item: ListItem) => {
+ if (item.text === fieldValue) {
+ return (
+
+ );
+ }
+
+ return null;
+ },
+ [theme.iconSuccessFill, fieldValue],
+ );
+
+ const [sections, headerMessage] = useMemo(() => {
+ const validFieldOptions = fieldOptions?.filter((option) => !!option);
+
+ const {policyReportFieldOptions} = OptionsListUtils.getFilteredOptions(
+ [],
+ [],
+ [],
+ debouncedSearchValue,
+ [
+ {
+ keyForList: fieldValue,
+ searchText: fieldValue,
+ text: fieldValue,
+ },
+ ],
+ [],
+ false,
+ false,
+ false,
+ {},
+ [],
+ false,
+ {},
+ [],
+ false,
+ false,
+ undefined,
+ undefined,
+ undefined,
+ true,
+ validFieldOptions,
+ recentlyUsedOptions,
+ );
+
+ const policyReportFieldData = policyReportFieldOptions?.[0]?.data ?? [];
+ const header = OptionsListUtils.getHeaderMessageForNonUserList(policyReportFieldData.length > 0, debouncedSearchValue);
+
+ return [policyReportFieldOptions, header];
+ }, [recentlyUsedOptions, debouncedSearchValue, fieldValue, fieldOptions]);
+
+ const selectedOptionKey = useMemo(() => (sections?.[0]?.data ?? []).filter((option) => option.searchText === fieldValue)?.[0]?.keyForList, [sections, fieldValue]);
+ return (
+ onSubmit({[fieldKey]: !option?.text || fieldValue === option.text ? '' : option.text})}
+ initiallyFocusedOptionKey={selectedOptionKey ?? undefined}
+ onChangeText={setSearchValue}
+ headerMessage={headerMessage}
+ ListItem={RadioListItem}
+ isRowMultilineSupported
+ rightHandSideComponent={itemRightSideComponent}
+ />
+ );
+}
+
+EditReportFieldDropdownPage.displayName = 'EditReportFieldDropdownPage';
+
+export default withOnyx({
+ recentlyUsedReportFields: {
+ key: () => ONYXKEYS.RECENTLY_USED_REPORT_FIELDS,
+ },
+})(EditReportFieldDropdownPage);
diff --git a/src/pages/EditReportFieldDropdownPage.tsx b/src/pages/EditReportFieldDropdownPage.tsx
deleted file mode 100644
index e887860ae155..000000000000
--- a/src/pages/EditReportFieldDropdownPage.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-import React, {useMemo, useState} from 'react';
-import {withOnyx} from 'react-native-onyx';
-import type {OnyxEntry} from 'react-native-onyx';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import OptionsSelector from '@components/OptionsSelector';
-import ScreenWrapper from '@components/ScreenWrapper';
-import useLocalize from '@hooks/useLocalize';
-import useStyleUtils from '@hooks/useStyleUtils';
-import useThemeStyles from '@hooks/useThemeStyles';
-import ONYXKEYS from '@src/ONYXKEYS';
-import type {RecentlyUsedReportFields} from '@src/types/onyx';
-
-type EditReportFieldDropdownPageComponentProps = {
- /** Value of the policy report field */
- fieldValue: string;
-
- /** Name of the policy report field */
- fieldName: string;
-
- /** Key of the policy report field */
- fieldKey: string;
-
- /** ID of the policy this report field belongs to */
- // eslint-disable-next-line react/no-unused-prop-types
- policyID: string;
-
- /** Options of the policy report field */
- fieldOptions: string[];
-
- /** Callback to fire when the Save button is pressed */
- onSubmit: (form: Record) => void;
-};
-
-type EditReportFieldDropdownPageOnyxProps = {
- recentlyUsedReportFields: OnyxEntry;
-};
-
-type EditReportFieldDropdownPageProps = EditReportFieldDropdownPageComponentProps & EditReportFieldDropdownPageOnyxProps;
-
-type ReportFieldDropdownData = {
- text: string;
- keyForList: string;
- searchText: string;
- tooltipText: string;
-};
-
-type ReportFieldDropdownSectionItem = {
- data: ReportFieldDropdownData[];
- shouldShow: boolean;
- title?: string;
-};
-
-function EditReportFieldDropdownPage({fieldName, onSubmit, fieldKey, fieldValue, fieldOptions, recentlyUsedReportFields}: EditReportFieldDropdownPageProps) {
- const [searchValue, setSearchValue] = useState('');
- const styles = useThemeStyles();
- const {getSafeAreaMargins} = useStyleUtils();
- const {translate} = useLocalize();
- const recentlyUsedOptions = useMemo(() => recentlyUsedReportFields?.[fieldKey] ?? [], [recentlyUsedReportFields, fieldKey]);
-
- const {sections, headerMessage} = useMemo(() => {
- let newHeaderMessage = '';
- const newSections: ReportFieldDropdownSectionItem[] = [];
-
- if (searchValue) {
- const filteredOptions = fieldOptions.filter((option) => option.toLowerCase().includes(searchValue.toLowerCase()));
- newHeaderMessage = !filteredOptions.length ? translate('common.noResultsFound') : '';
- newSections.push({
- shouldShow: false,
- data: filteredOptions.map((option) => ({
- text: option,
- keyForList: option,
- searchText: option,
- tooltipText: option,
- })),
- });
- } else {
- const selectedValue = fieldValue;
- if (selectedValue) {
- newSections.push({
- shouldShow: false,
- data: [
- {
- text: selectedValue,
- keyForList: selectedValue,
- searchText: selectedValue,
- tooltipText: selectedValue,
- },
- ],
- });
- }
-
- const filteredRecentlyUsedOptions = recentlyUsedOptions.filter((option) => option !== selectedValue && fieldOptions.includes(option));
- if (filteredRecentlyUsedOptions.length > 0) {
- newSections.push({
- title: translate('common.recents'),
- shouldShow: true,
- data: filteredRecentlyUsedOptions.map((option) => ({
- text: option,
- keyForList: option,
- searchText: option,
- tooltipText: option,
- })),
- });
- }
-
- const filteredFieldOptions = fieldOptions.filter((option) => option !== selectedValue);
- if (filteredFieldOptions.length > 0) {
- newSections.push({
- title: translate('common.all'),
- shouldShow: true,
- data: filteredFieldOptions.map((option) => ({
- text: option,
- keyForList: option,
- searchText: option,
- tooltipText: option,
- })),
- });
- }
- }
-
- return {sections: newSections, headerMessage: newHeaderMessage};
- }, [fieldValue, fieldOptions, recentlyUsedOptions, searchValue, translate]);
-
- return (
-
- {({insets}) => (
- <>
-
- ) =>
- onSubmit({
- [fieldKey]: fieldValue === option.text ? '' : option.text,
- })
- }
- onChangeText={setSearchValue}
- highlightSelectedOptions
- isRowMultilineSupported
- headerMessage={headerMessage}
- />
- >
- )}
-
- );
-}
-
-EditReportFieldDropdownPage.displayName = 'EditReportFieldDropdownPage';
-
-export default withOnyx({
- recentlyUsedReportFields: {
- key: () => ONYXKEYS.RECENTLY_USED_REPORT_FIELDS,
- },
-})(EditReportFieldDropdownPage);
diff --git a/src/pages/EditReportFieldPage.tsx b/src/pages/EditReportFieldPage.tsx
index 72a472db3da0..6cc93d05ebbc 100644
--- a/src/pages/EditReportFieldPage.tsx
+++ b/src/pages/EditReportFieldPage.tsx
@@ -1,18 +1,25 @@
import Str from 'expensify-common/lib/str';
-import React from 'react';
+import React, {useState} from 'react';
import {withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
+import ConfirmModal from '@components/ConfirmModal';
import type {FormOnyxValues} from '@components/Form/types';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import type {ThreeDotsMenuItem} from '@components/HeaderWithBackButton/types';
+import * as Expensicons from '@components/Icon/Expensicons';
import ScreenWrapper from '@components/ScreenWrapper';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
import Navigation from '@libs/Navigation/Navigation';
import * as ReportUtils from '@libs/ReportUtils';
import * as ReportActions from '@src/libs/actions/Report';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy, Report} from '@src/types/onyx';
-import EditReportFieldDatePage from './EditReportFieldDatePage';
-import EditReportFieldDropdownPage from './EditReportFieldDropdownPage';
-import EditReportFieldTextPage from './EditReportFieldTextPage';
+import EditReportFieldDate from './EditReportFieldDate';
+import EditReportFieldDropdown from './EditReportFieldDropdown';
+import EditReportFieldText from './EditReportFieldText';
type EditReportFieldPageOnyxProps = {
/** The report object for the expense report */
@@ -40,9 +47,13 @@ type EditReportFieldPageProps = EditReportFieldPageOnyxProps & {
};
function EditReportFieldPage({route, policy, report}: EditReportFieldPageProps) {
+ const {windowWidth} = useWindowDimensions();
+ const styles = useThemeStyles();
const fieldKey = ReportUtils.getReportFieldKey(route.params.fieldID);
const reportField = report?.fieldList?.[fieldKey] ?? policy?.fieldList?.[fieldKey];
const isDisabled = ReportUtils.isReportFieldDisabled(report, reportField ?? null, policy);
+ const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
+ const {translate} = useLocalize();
if (!reportField || !report || isDisabled) {
return (
@@ -73,44 +84,78 @@ function EditReportFieldPage({route, policy, report}: EditReportFieldPageProps)
Navigation.dismissModal(report?.reportID);
};
+ const handleReportFieldDelete = () => {
+ ReportActions.deleteReportField(report.reportID, reportField);
+ Navigation.dismissModal(report?.reportID);
+ };
+
const fieldValue = isReportFieldTitle ? report.reportName ?? '' : reportField.value ?? reportField.defaultValue;
- if (reportField.type === 'text' || isReportFieldTitle) {
- return (
-
- );
+ const menuItems: ThreeDotsMenuItem[] = [];
+
+ const isReportFieldDeletable = reportField.deletable && !isReportFieldTitle;
+
+ if (isReportFieldDeletable) {
+ menuItems.push({icon: Expensicons.Trashcan, text: translate('common.delete'), onSelected: () => setIsDeleteModalVisible(true)});
}
- if (reportField.type === 'date') {
- return (
-
+
- );
- }
- if (reportField.type === 'dropdown') {
- return (
- !reportField.disabledOptions[index])}
- onSubmit={handleReportFieldChange}
+ setIsDeleteModalVisible(false)}
+ prompt={translate('workspace.reportFields.deleteConfirmation')}
+ confirmText={translate('common.delete')}
+ cancelText={translate('common.cancel')}
+ danger
/>
- );
- }
+
+ {(reportField.type === 'text' || isReportFieldTitle) && (
+
+ )}
+
+ {reportField.type === 'date' && (
+
+ )}
+
+ {reportField.type === 'dropdown' && (
+ !reportField.disabledOptions[index])}
+ onSubmit={handleReportFieldChange}
+ />
+ )}
+
+ );
}
EditReportFieldPage.displayName = 'EditReportFieldPage';
diff --git a/src/pages/EditReportFieldTextPage.tsx b/src/pages/EditReportFieldText.tsx
similarity index 58%
rename from src/pages/EditReportFieldTextPage.tsx
rename to src/pages/EditReportFieldText.tsx
index 1a6cf96fb37a..d89724f0228b 100644
--- a/src/pages/EditReportFieldTextPage.tsx
+++ b/src/pages/EditReportFieldText.tsx
@@ -3,9 +3,7 @@ import {View} from 'react-native';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
import type {AnimatedTextInputRef} from '@components/RNTextInput';
-import ScreenWrapper from '@components/ScreenWrapper';
import TextInput from '@components/TextInput';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -46,37 +44,27 @@ function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, f
);
return (
- {
- inputRef.current?.focus();
- }}
- testID={EditReportFieldTextPage.displayName}
+
-
-
-
-
-
-
-
+
+
+
+
);
}
diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js
index ec49e32a5f0f..bef826300af2 100644
--- a/src/pages/EditRequestPage.js
+++ b/src/pages/EditRequestPage.js
@@ -73,7 +73,7 @@ const defaultProps = {
};
const getTaxAmount = (transactionAmount, transactionTaxCode, taxRates) => {
- const percentage = (transactionTaxCode ? taxRates.taxes[transactionTaxCode].value : taxRates.defaultValue) || '';
+ const percentage = (transactionTaxCode && taxRates.taxes[transactionTaxCode] ? taxRates.taxes[transactionTaxCode].value : taxRates.defaultValue) || '';
return CurrencyUtils.convertToBackendAmount(Number.parseFloat(TransactionUtils.calculateTaxAmount(percentage, transactionAmount)));
};
diff --git a/src/pages/EditRequestTagPage.js b/src/pages/EditRequestTagPage.js
index 1aead9ee1f6e..fd9064f8b6fc 100644
--- a/src/pages/EditRequestTagPage.js
+++ b/src/pages/EditRequestTagPage.js
@@ -43,24 +43,19 @@ function EditRequestTagPage({defaultTag, policyID, tagListName, tagListIndex, on
shouldEnableMaxHeight
testID={EditRequestTagPage.displayName}
>
- {({insets}) => (
- <>
-
- {translate('iou.tagSelection')}
-
- >
- )}
+
+ {translate('iou.tagSelection')}
+
);
}
diff --git a/src/pages/WorkspaceSwitcherPage.tsx b/src/pages/WorkspaceSwitcherPage.tsx
index 6f077f764474..631d377e34cd 100644
--- a/src/pages/WorkspaceSwitcherPage.tsx
+++ b/src/pages/WorkspaceSwitcherPage.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useEffect, useMemo, useState} from 'react';
+import React, {useCallback, useMemo, useState} from 'react';
import {View} from 'react-native';
import type {OnyxCollection} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
@@ -7,9 +7,10 @@ import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import {MagnifyingGlass} from '@components/Icon/Expensicons';
import OptionRow from '@components/OptionRow';
-import OptionsSelector from '@components/OptionsSelector';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import UserListItem from '@components/SelectionList/UserListItem';
import Text from '@components/Text';
import Tooltip from '@components/Tooltip';
import useActiveWorkspace from '@hooks/useActiveWorkspace';
@@ -57,7 +58,6 @@ function WorkspaceSwitcherPage({policies}: WorkspaceSwitcherPageProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {isOffline} = useNetwork();
- const [selectedOption, setSelectedOption] = useState();
const [searchTerm, setSearchTerm] = useState('');
const {inputCallbackRef} = useAutoFocusInput();
const {translate} = useLocalize();
@@ -105,11 +105,6 @@ function WorkspaceSwitcherPage({policies}: WorkspaceSwitcherPageProps) {
const {policyID} = option;
- if (policyID) {
- setSelectedOption(option);
- } else {
- setSelectedOption(undefined);
- }
setActiveWorkspaceID(policyID);
Navigation.goBack();
if (policyID !== activeWorkspaceID) {
@@ -141,8 +136,9 @@ function WorkspaceSwitcherPage({policies}: WorkspaceSwitcherPageProps) {
boldStyle: hasUnreadData(policy?.id),
keyForList: policy?.id,
isPolicyAdmin: PolicyUtils.isPolicyAdmin(policy),
+ isSelected: policy?.id === activeWorkspaceID,
}));
- }, [policies, getIndicatorTypeForPolicy, hasUnreadData, isOffline]);
+ }, [policies, getIndicatorTypeForPolicy, hasUnreadData, isOffline, activeWorkspaceID]);
const filteredAndSortedUserWorkspaces = useMemo(
() =>
@@ -236,28 +232,20 @@ function WorkspaceSwitcherPage({policies}: WorkspaceSwitcherPageProps) {
{usersWorkspaces.length > 0 ? (
- = CONST.WORKSPACE_SWITCHER.MINIMUM_WORKSPACES_TO_SHOW_SEARCH}
+ textInputValue={searchTerm}
onChangeText={setSearchTerm}
- selectedOptions={selectedOption ? [selectedOption] : []}
onSelectRow={selectPolicy}
shouldPreventDefaultFocusOnSelectRow
headerMessage={headerMessage}
- highlightSelectedOptions
- shouldShowOptions
- autoFocus={false}
- canSelectMultipleOptions={false}
- shouldShowSubscript={false}
- showTitleTooltip={false}
- contentContainerStyles={[styles.pt0, styles.mt0]}
- textIconLeft={MagnifyingGlass}
- // Null is to avoid selecting unfocused option when Global selected, undefined is to focus selected workspace
- initiallyFocusedOptionKey={!activeWorkspaceID ? null : undefined}
+ containerStyle={[styles.pt0, styles.mt0]}
+ textInputIconLeft={usersWorkspaces.length >= CONST.WORKSPACE_SWITCHER.MINIMUM_WORKSPACES_TO_SHOW_SEARCH ? MagnifyingGlass : undefined}
+ initiallyFocusedOptionKey={activeWorkspaceID}
+ textInputAutoFocus={false}
/>
) : (
@@ -269,7 +257,6 @@ function WorkspaceSwitcherPage({policies}: WorkspaceSwitcherPageProps) {
setSearchTerm,
searchTerm,
selectPolicy,
- selectedOption,
styles,
theme.textSupporting,
translate,
@@ -281,14 +268,6 @@ function WorkspaceSwitcherPage({policies}: WorkspaceSwitcherPageProps) {
],
);
- useEffect(() => {
- if (!activeWorkspaceID) {
- return;
- }
- const optionToSet = usersWorkspaces.find((option) => option.policyID === activeWorkspaceID);
- setSelectedOption(optionToSet);
- }, [activeWorkspaceID, usersWorkspaces]);
-
return (
{
if (closePopover) {
hideContextMenu(false, () => {
- ReportActionComposeFocusManager.focus();
+ InteractionManager.runAfterInteractions(() => {
+ // Normally the focus callback of the main composer doesn't focus when willBlurTextInputOnTapOutside
+ // is false, so we need to pass true here to override this condition.
+ ReportActionComposeFocusManager.focus(true);
+ });
Report.navigateToAndOpenChildReport(reportAction?.childReportID ?? '0', reportAction, reportID);
});
return;
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
index bfcef66e7c54..f8147dfda81d 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
@@ -591,8 +591,8 @@ function ComposerWithSuggestions(
const setUpComposeFocusManager = useCallback(() => {
// This callback is used in the contextMenuActions to manage giving focus back to the compose input.
- ReportActionComposeFocusManager.onComposerFocus(() => {
- if (!willBlurTextInputOnTapOutside || !isFocused) {
+ ReportActionComposeFocusManager.onComposerFocus((shouldFocusForNonBlurInputOnTapOutside = false) => {
+ if ((!willBlurTextInputOnTapOutside && !shouldFocusForNonBlurInputOnTapOutside) || !isFocused) {
return;
}
diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx
index eeeb5b95273c..02d7a14f4b0e 100644
--- a/src/pages/home/report/ReportActionItem.tsx
+++ b/src/pages/home/report/ReportActionItem.tsx
@@ -366,9 +366,10 @@ function ReportActionItem({
anchor: popoverAnchorRef.current,
report,
action,
+ transactionThreadReport,
checkIfContextMenuActive: toggleContextMenuFromActiveReportAction,
}),
- [report, action, toggleContextMenuFromActiveReportAction],
+ [report, action, toggleContextMenuFromActiveReportAction, transactionThreadReport],
);
const actionableItemButtons: ActionableItem[] = useMemo(() => {
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.js b/src/pages/iou/request/step/IOURequestStepScan/index.native.js
index f3c02db81234..83ca90e7330b 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.native.js
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.js
@@ -72,7 +72,7 @@ function IOURequestStepScan({
const theme = useTheme();
const styles = useThemeStyles();
const device = useCameraDevice('back', {
- physicalDevices: ['wide-angle-camera'],
+ physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'],
});
const hasFlash = device != null && device.hasFlash;
diff --git a/src/pages/iou/request/step/IOURequestStepTag.js b/src/pages/iou/request/step/IOURequestStepTag.js
index ed55628ecaa9..3693e1cf9449 100644
--- a/src/pages/iou/request/step/IOURequestStepTag.js
+++ b/src/pages/iou/request/step/IOURequestStepTag.js
@@ -142,19 +142,14 @@ function IOURequestStepTag({
testID={IOURequestStepTag.displayName}
shouldShowNotFoundPage={shouldShowNotFoundPage}
>
- {({insets}) => (
- <>
- {translate('iou.tagSelection')}
-
- >
- )}
+ {translate('iou.tagSelection')}
+
);
}
diff --git a/src/pages/iou/steps/MoneyRequestAmountForm.tsx b/src/pages/iou/steps/MoneyRequestAmountForm.tsx
index 755da80fb93f..ca7bb694599a 100644
--- a/src/pages/iou/steps/MoneyRequestAmountForm.tsx
+++ b/src/pages/iou/steps/MoneyRequestAmountForm.tsx
@@ -232,7 +232,7 @@ function MoneyRequestAmountForm(
*/
const submitAndNavigateToNextPage = useCallback(() => {
// Skip the check for tax amount form as 0 is a valid input
- if (!isTaxAmountForm && isAmountInvalid(currentAmount)) {
+ if (!currentAmount.length || (!isTaxAmountForm && isAmountInvalid(currentAmount))) {
setFormError('iou.error.invalidAmount');
return;
}
diff --git a/src/pages/settings/AboutPage/ShareLogList/BaseShareLogList.tsx b/src/pages/settings/AboutPage/ShareLogList/BaseShareLogList.tsx
index cee62380a011..578efbe5317b 100644
--- a/src/pages/settings/AboutPage/ShareLogList/BaseShareLogList.tsx
+++ b/src/pages/settings/AboutPage/ShareLogList/BaseShareLogList.tsx
@@ -1,65 +1,52 @@
-import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
-import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
+import React, {useMemo} from 'react';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import {useBetas} from '@components/OnyxProvider';
import {useOptionsList} from '@components/OptionListContextProvider';
-import OptionsSelector from '@components/OptionsSelector';
import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import UserListItem from '@components/SelectionList/UserListItem';
+import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
-import useThemeStyles from '@hooks/useThemeStyles';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
-import ONYXKEYS from '@src/ONYXKEYS';
+import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import type {Report} from '@src/types/onyx';
-import type {BaseShareLogListOnyxProps, BaseShareLogListProps} from './types';
+import type {BaseShareLogListProps} from './types';
-function BaseShareLogList({betas, onAttachLogToReport}: BaseShareLogListProps) {
- const [searchValue, setSearchValue] = useState('');
- const [searchOptions, setSearchOptions] = useState>({
- recentReports: [],
- personalDetails: [],
- userToInvite: null,
- });
+function BaseShareLogList({onAttachLogToReport}: BaseShareLogListProps) {
+ const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState('');
const {isOffline} = useNetwork();
const {translate} = useLocalize();
- const styles = useThemeStyles();
- const isMounted = useRef(false);
+ const betas = useBetas();
const {options, areOptionsInitialized} = useOptionsList();
- const updateOptions = useCallback(() => {
+
+ const searchOptions = useMemo(() => {
+ if (!areOptionsInitialized) {
+ return {
+ recentReports: [],
+ personalDetails: [],
+ userToInvite: undefined,
+ headerMessage: '',
+ };
+ }
const {
recentReports: localRecentReports,
personalDetails: localPersonalDetails,
userToInvite: localUserToInvite,
- } = OptionsListUtils.getShareLogOptions(options, searchValue.trim(), betas ?? []);
+ } = OptionsListUtils.getShareLogOptions(options, debouncedSearchValue.trim(), betas ?? []);
- setSearchOptions({
+ const header = OptionsListUtils.getHeaderMessage((localRecentReports?.length || 0) + (localPersonalDetails?.length || 0) !== 0, Boolean(localUserToInvite), debouncedSearchValue);
+
+ return {
recentReports: localRecentReports,
personalDetails: localPersonalDetails,
userToInvite: localUserToInvite,
- });
- }, [betas, options, searchValue]);
-
- useEffect(() => {
- if (!areOptionsInitialized) {
- return;
- }
-
- updateOptions();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [options, areOptionsInitialized]);
-
- useEffect(() => {
- if (!isMounted.current) {
- isMounted.current = true;
- return;
- }
-
- updateOptions();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [searchValue]);
+ headerMessage: header,
+ };
+ }, [areOptionsInitialized, options, debouncedSearchValue, betas]);
const sections = useMemo(() => {
const sectionsList = [];
@@ -84,17 +71,7 @@ function BaseShareLogList({betas, onAttachLogToReport}: BaseShareLogListProps) {
}
return sectionsList;
- }, [searchOptions.personalDetails, searchOptions.recentReports, searchOptions.userToInvite, translate]);
-
- const headerMessage = OptionsListUtils.getHeaderMessage(
- searchOptions.recentReports.length + searchOptions.personalDetails.length !== 0,
- Boolean(searchOptions.userToInvite),
- searchValue,
- );
-
- const onChangeText = (value = '') => {
- setSearchValue(value);
- };
+ }, [searchOptions?.personalDetails, searchOptions?.recentReports, searchOptions?.userToInvite, translate]);
const attachLogToReport = (option: Report) => {
if (!option.reportID) {
@@ -110,28 +87,23 @@ function BaseShareLogList({betas, onAttachLogToReport}: BaseShareLogListProps) {
testID={BaseShareLogList.displayName}
includeSafeAreaPaddingBottom={false}
>
- {({safeAreaPaddingBottomStyle}) => (
+ {({didScreenTransitionEnd}) => (
<>
Navigation.goBack(ROUTES.SETTINGS_CONSOLE)}
/>
-
-
-
+
>
)}
@@ -140,12 +112,4 @@ function BaseShareLogList({betas, onAttachLogToReport}: BaseShareLogListProps) {
BaseShareLogList.displayName = 'ShareLogPage';
-export default withOnyx({
- reports: {
- key: ONYXKEYS.COLLECTION.REPORT,
- },
- betas: {
- key: ONYXKEYS.BETAS,
- initialValue: [],
- },
-})(BaseShareLogList);
+export default BaseShareLogList;
diff --git a/src/pages/settings/AboutPage/ShareLogList/types.ts b/src/pages/settings/AboutPage/ShareLogList/types.ts
index abbdbfb88e0b..500641a3da42 100644
--- a/src/pages/settings/AboutPage/ShareLogList/types.ts
+++ b/src/pages/settings/AboutPage/ShareLogList/types.ts
@@ -1,21 +1,10 @@
-import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
-import type {Beta, Report} from '@src/types/onyx';
-
-type BaseShareLogListOnyxProps = {
- /** Beta features list */
- betas: OnyxEntry;
-
- /** All reports shared with the user */
- reports: OnyxCollection;
-};
-
type ShareLogListProps = {
/** The source of the log file to share */
logSource: string;
};
-type BaseShareLogListProps = BaseShareLogListOnyxProps & {
+type BaseShareLogListProps = {
onAttachLogToReport: (reportID: string, filename: string) => void;
};
-export type {BaseShareLogListOnyxProps, BaseShareLogListProps, ShareLogListProps};
+export type {BaseShareLogListProps, ShareLogListProps};
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index a3357b8982a1..e9efc84e8807 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -38,11 +38,11 @@ import type {
EReceiptColorName,
EreceiptColorStyle,
ParsableStyle,
+ SVGAvatarColorStyle,
TextColorStyle,
- WorkspaceColorStyle,
} from './types';
-const workspaceColorOptions: WorkspaceColorStyle[] = [
+const workspaceColorOptions: SVGAvatarColorStyle[] = [
{backgroundColor: colors.blue200, fill: colors.blue700},
{backgroundColor: colors.blue400, fill: colors.blue800},
{backgroundColor: colors.blue700, fill: colors.blue200},
@@ -276,6 +276,13 @@ function getDefaultWorkspaceAvatarColor(workspaceName: string): ViewStyle {
return workspaceColorOptions[colorHash];
}
+/**
+ * Helper method to return formatted backgroundColor and fill styles
+ */
+function getBackgroundColorAndFill(backgroundColor: string, fill: string): SVGAvatarColorStyle {
+ return {backgroundColor, fill};
+}
+
/**
* Helper method to return eReceipt color code
*/
@@ -1123,6 +1130,7 @@ const staticStyleUtils = {
getComposeTextAreaPadding,
getColorStyle,
getDefaultWorkspaceAvatarColor,
+ getBackgroundColorAndFill,
getDirectionStyle,
getDropDownButtonHeight,
getEmojiPickerListHeight,
@@ -1228,7 +1236,7 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
height: avatarSize,
width: avatarSize,
borderRadius: avatarSize,
- backgroundColor: theme.offline,
+ backgroundColor: theme.border,
};
},
diff --git a/src/styles/utils/types.ts b/src/styles/utils/types.ts
index 5fe844f0f358..7dc70dc2e64e 100644
--- a/src/styles/utils/types.ts
+++ b/src/styles/utils/types.ts
@@ -40,7 +40,7 @@ type ButtonSizeValue = ValueOf;
type ButtonStateName = ValueOf;
type AvatarSize = {width: number};
-type WorkspaceColorStyle = {backgroundColor: ColorValue; fill: ColorValue};
+type SVGAvatarColorStyle = {backgroundColor: ColorValue; fill: ColorValue};
type EreceiptColorStyle = {backgroundColor: ColorValue; color: ColorValue};
type TextColorStyle = {color: string};
@@ -55,7 +55,7 @@ export type {
ButtonSizeValue,
ButtonStateName,
AvatarSize,
- WorkspaceColorStyle,
+ SVGAvatarColorStyle,
EreceiptColorStyle,
TextColorStyle,
};
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
index a06dd28f85e3..d4f726dd0d4f 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -317,9 +317,6 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback<
enabled: boolean;
};
- /** @deprecated Whether the scheduled submit is enabled */
- isPreventSelfApprovalEnabled?: boolean;
-
/** Whether the self approval or submitting is enabled */
preventSelfApproval?: boolean;
diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts
index caae8a055aa9..251f70226000 100644
--- a/tests/actions/IOUTest.ts
+++ b/tests/actions/IOUTest.ts
@@ -3048,7 +3048,11 @@ describe('actions/IOU', () => {
let chatReport: OnyxEntry;
return waitForBatchedUpdates()
.then(() => {
- PolicyActions.createWorkspace(CARLOS_EMAIL, true, "Carlos's Workspace");
+ const policyID = PolicyActions.generatePolicyID();
+ PolicyActions.createWorkspace(CARLOS_EMAIL, true, "Carlos's Workspace", policyID);
+
+ // Change the approval mode for the policy since default is Submit and Close
+ PolicyActions.setWorkspaceApprovalMode(policyID, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC);
return waitForBatchedUpdates();
})
.then(
@@ -3144,6 +3148,110 @@ describe('actions/IOU', () => {
}),
);
});
+ it('correctly submits a report with Submit and Close approval mode', () => {
+ const amount = 10000;
+ const comment = '💸💸💸💸';
+ const merchant = 'NASDAQ';
+ let expenseReport: OnyxEntry;
+ let chatReport: OnyxEntry;
+ return waitForBatchedUpdates()
+ .then(() => {
+ PolicyActions.createWorkspace(CARLOS_EMAIL, true, "Carlos's Workspace");
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ Onyx.disconnect(connectionID);
+ chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT) ?? null;
+
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(() => {
+ if (chatReport) {
+ IOU.requestMoney(
+ chatReport,
+ amount,
+ CONST.CURRENCY.USD,
+ '',
+ merchant,
+ RORY_EMAIL,
+ RORY_ACCOUNT_ID,
+ {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID},
+ comment,
+ {},
+ );
+ }
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ Onyx.disconnect(connectionID);
+ expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE) ?? null;
+ Onyx.merge(`report_${expenseReport?.reportID}`, {
+ statusNum: 0,
+ stateNum: 0,
+ });
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ Onyx.disconnect(connectionID);
+ expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE) ?? null;
+
+ // Verify report is a draft
+ expect(expenseReport?.stateNum).toBe(0);
+ expect(expenseReport?.statusNum).toBe(0);
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(() => {
+ if (expenseReport) {
+ IOU.submitReport(expenseReport);
+ }
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ Onyx.disconnect(connectionID);
+ expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE) ?? null;
+
+ // Report is closed since the default policy settings is Submit and Close
+ expect(expenseReport?.stateNum).toBe(2);
+ expect(expenseReport?.statusNum).toBe(2);
+ resolve();
+ },
+ });
+ }),
+ );
+ });
it('correctly implements error handling', () => {
const amount = 10000;
const comment = '💸💸💸💸';
diff --git a/tests/e2e/compare/output/console.ts b/tests/e2e/compare/output/console.ts
index de8e5d913893..77170e43f4a6 100644
--- a/tests/e2e/compare/output/console.ts
+++ b/tests/e2e/compare/output/console.ts
@@ -13,6 +13,8 @@ type Entry = {
type Data = {
significance: Entry[];
meaningless: Entry[];
+ errors?: string[];
+ warnings?: string[];
};
const printRegularLine = (entry: Entry) => {
@@ -36,4 +38,4 @@ export default (data: Data) => {
console.debug('');
};
-export type {Entry};
+export type {Data, Entry};
diff --git a/tests/e2e/compare/output/markdown.js b/tests/e2e/compare/output/markdown.ts
similarity index 57%
rename from tests/e2e/compare/output/markdown.js
rename to tests/e2e/compare/output/markdown.ts
index 119830a5bb2c..34bc3251c422 100644
--- a/tests/e2e/compare/output/markdown.js
+++ b/tests/e2e/compare/output/markdown.ts
@@ -1,80 +1,85 @@
// From: https://raw.githubusercontent.com/callstack/reassure/main/packages/reassure-compare/src/output/markdown.ts
import fs from 'node:fs/promises';
import path from 'path';
-import _ from 'underscore';
+import type {Stats} from 'tests/e2e/measure/math';
import * as Logger from '../../utils/logger';
+import type {Data, Entry} from './console';
import * as format from './format';
import markdownTable from './markdownTable';
const tableHeader = ['Name', 'Duration'];
-const collapsibleSection = (title, content) => `\n${title}
\n\n${content}\n \n\n`;
+const collapsibleSection = (title: string, content: string) => `\n${title}
\n\n${content}\n \n\n`;
-const buildDurationDetails = (title, entry) => {
+const buildDurationDetails = (title: string, entry: Stats) => {
const relativeStdev = entry.stdev / entry.mean;
- return _.filter(
- [
- `**${title}**`,
- `Mean: ${format.formatDuration(entry.mean)}`,
- `Stdev: ${format.formatDuration(entry.stdev)} (${format.formatPercent(relativeStdev)})`,
- entry.entries ? `Runs: ${entry.entries.join(' ')}` : '',
- ],
- Boolean,
- ).join('
');
+ return [
+ `**${title}**`,
+ `Mean: ${format.formatDuration(entry.mean)}`,
+ `Stdev: ${format.formatDuration(entry.stdev)} (${format.formatPercent(relativeStdev)})`,
+ entry.entries ? `Runs: ${entry.entries.join(' ')}` : '',
+ ]
+ .filter(Boolean)
+ .join('
');
};
-const buildDurationDetailsEntry = (entry) =>
- _.filter(['baseline' in entry ? buildDurationDetails('Baseline', entry.baseline) : '', 'current' in entry ? buildDurationDetails('Current', entry.current) : ''], Boolean).join(
- '
',
- );
+const buildDurationDetailsEntry = (entry: Entry) =>
+ ['baseline' in entry ? buildDurationDetails('Baseline', entry.baseline) : '', 'current' in entry ? buildDurationDetails('Current', entry.current) : '']
+ .filter(Boolean)
+ .join('
');
+
+const formatEntryDuration = (entry: Entry): string => {
+ let formattedDuration = '';
-const formatEntryDuration = (entry) => {
if ('baseline' in entry && 'current' in entry) {
- return format.formatDurationDiffChange(entry);
+ formattedDuration = format.formatDurationDiffChange(entry);
}
+
if ('baseline' in entry) {
- return format.formatDuration(entry.baseline.mean);
+ formattedDuration = format.formatDuration(entry.baseline.mean);
}
+
if ('current' in entry) {
- return format.formatDuration(entry.current.mean);
+ formattedDuration = format.formatDuration(entry.current.mean);
}
- return '';
+
+ return formattedDuration;
};
-const buildDetailsTable = (entries) => {
+const buildDetailsTable = (entries: Entry[]) => {
if (!entries.length) {
return '';
}
- const rows = _.map(entries, (entry) => [entry.name, buildDurationDetailsEntry(entry)]);
+ const rows = entries.map((entry) => [entry.name, buildDurationDetailsEntry(entry)]);
const content = markdownTable([tableHeader, ...rows]);
return collapsibleSection('Show details', content);
};
-const buildSummaryTable = (entries, collapse = false) => {
+const buildSummaryTable = (entries: Entry[], collapse = false) => {
if (!entries.length) {
return '_There are no entries_';
}
- const rows = _.map(entries, (entry) => [entry.name, formatEntryDuration(entry)]);
+ const rows = entries.map((entry) => [entry.name, formatEntryDuration(entry)]);
const content = markdownTable([tableHeader, ...rows]);
return collapse ? collapsibleSection('Show entries', content) : content;
};
-const buildMarkdown = (data) => {
+const buildMarkdown = (data: Data) => {
let result = '## Performance Comparison Report 📊';
- if (data.errors && data.errors.length) {
+ if (data.errors?.length) {
result += '\n\n### Errors\n';
data.errors.forEach((message) => {
result += ` 1. 🛑 ${message}\n`;
});
}
- if (data.warnings && data.warnings.length) {
+ if (data.warnings?.length) {
result += '\n\n### Warnings\n';
data.warnings.forEach((message) => {
result += ` 1. 🟡 ${message}\n`;
@@ -92,7 +97,7 @@ const buildMarkdown = (data) => {
return result;
};
-const writeToFile = (filePath, content) =>
+const writeToFile = (filePath: string, content: string) =>
fs
.writeFile(filePath, content)
.then(() => {
@@ -106,7 +111,7 @@ const writeToFile = (filePath, content) =>
throw error;
});
-const writeToMarkdown = (filePath, data) => {
+const writeToMarkdown = (filePath: string, data: Data) => {
const markdown = buildMarkdown(data);
return writeToFile(filePath, markdown).catch((error) => {
console.error(error);
diff --git a/tests/perf-test/SidebarLinks.perf-test.js b/tests/perf-test/SidebarLinks.perf-test.tsx
similarity index 79%
rename from tests/perf-test/SidebarLinks.perf-test.js
rename to tests/perf-test/SidebarLinks.perf-test.tsx
index 0b10718fd0c4..2848015d5c63 100644
--- a/tests/perf-test/SidebarLinks.perf-test.js
+++ b/tests/perf-test/SidebarLinks.perf-test.tsx
@@ -1,32 +1,33 @@
import {fireEvent, screen} from '@testing-library/react-native';
import Onyx from 'react-native-onyx';
import {measurePerformance} from 'reassure';
-import _ from 'underscore';
-import CONST from '../../src/CONST';
-import ONYXKEYS from '../../src/ONYXKEYS';
-import variables from '../../src/styles/variables';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
import * as LHNTestUtils from '../utils/LHNTestUtils';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates';
-jest.mock('../../src/libs/Permissions');
-jest.mock('../../src/hooks/usePermissions.ts');
-jest.mock('../../src/libs/Navigation/Navigation');
-jest.mock('../../src/components/Icon/Expensicons');
+jest.mock('@libs/Permissions');
+jest.mock('@hooks/usePermissions.ts');
+jest.mock('@libs/Navigation/Navigation');
+jest.mock('@components/Icon/Expensicons');
jest.mock('@react-navigation/native');
const getMockedReportsMap = (length = 100) => {
- const mockReports = Array.from({length}, (__, i) => {
- const reportID = i + 1;
- const participants = [1, 2];
- const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${reportID}`;
- const report = LHNTestUtils.getFakeReport(participants, 1, true);
-
- return {[reportKey]: report};
- });
-
- return _.assign({}, ...mockReports);
+ const mockReports = Object.fromEntries(
+ Array.from({length}, (value, index) => {
+ const reportID = index + 1;
+ const participants = [1, 2];
+ const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${reportID}`;
+ const report = LHNTestUtils.getFakeReport(participants, 1, true);
+
+ return [reportKey, report];
+ }),
+ );
+
+ return mockReports;
};
const mockedResponseMap = getMockedReportsMap(500);
@@ -36,11 +37,9 @@ describe('SidebarLinks', () => {
Onyx.init({
keys: ONYXKEYS,
safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS],
- registerStorageEventListener: () => {},
});
Onyx.multiSet({
- [ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS],
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
diff --git a/tests/unit/DateUtilsTest.js b/tests/unit/DateUtilsTest.ts
similarity index 81%
rename from tests/unit/DateUtilsTest.js
rename to tests/unit/DateUtilsTest.ts
index a752eea1a990..9df0113168e4 100644
--- a/tests/unit/DateUtilsTest.js
+++ b/tests/unit/DateUtilsTest.ts
@@ -1,9 +1,11 @@
+/* eslint-disable @typescript-eslint/naming-convention */
import {addDays, addMinutes, format, setHours, setMinutes, subDays, subHours, subMinutes, subSeconds} from 'date-fns';
import {format as tzFormat, utcToZonedTime} from 'date-fns-tz';
import Onyx from 'react-native-onyx';
-import CONST from '../../src/CONST';
-import DateUtils from '../../src/libs/DateUtils';
-import ONYXKEYS from '../../src/ONYXKEYS';
+import DateUtils from '@libs/DateUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
const LOCALE = CONST.LOCALES.EN;
@@ -13,14 +15,27 @@ describe('DateUtils', () => {
Onyx.init({
keys: ONYXKEYS,
initialKeyStates: {
- [ONYXKEYS.SESSION]: {accountID: 999},
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: {999: {timezone: {selected: UTC}}},
+ [ONYXKEYS.SESSION]: {
+ accountID: 999,
+ },
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: {
+ '999': {
+ accountID: 999,
+ timezone: {
+ // UTC is not recognized as a valid timezone but
+ // in these tests we want to use it to avoid issues
+ // because of daylight saving time
+ selected: UTC as SelectedTimezone,
+ },
+ },
+ },
},
});
return waitForBatchedUpdates();
});
afterEach(() => {
+ jest.restoreAllMocks();
jest.useRealTimers();
Onyx.clear();
});
@@ -39,7 +54,7 @@ describe('DateUtils', () => {
});
it('formatToDayOfWeek should return a weekday', () => {
- const weekDay = DateUtils.formatToDayOfWeek(datetime);
+ const weekDay = DateUtils.formatToDayOfWeek(new Date(datetime));
expect(weekDay).toBe('Monday');
});
it('formatToLocalTime should return a date in a local format', () => {
@@ -53,32 +68,35 @@ describe('DateUtils', () => {
});
it('should fallback to current date when getLocalDateFromDatetime is failing', () => {
- const localDate = DateUtils.getLocalDateFromDatetime(LOCALE, undefined, 'InvalidTimezone');
+ const localDate = DateUtils.getLocalDateFromDatetime(LOCALE, undefined, 'InvalidTimezone' as SelectedTimezone);
expect(localDate.getTime()).not.toBeNaN();
});
it('should return the date in calendar time when calling datetimeToCalendarTime', () => {
- const today = setMinutes(setHours(new Date(), 14), 32);
+ const today = setMinutes(setHours(new Date(), 14), 32).toString();
expect(DateUtils.datetimeToCalendarTime(LOCALE, today)).toBe('Today at 2:32 PM');
- const tomorrow = addDays(setMinutes(setHours(new Date(), 14), 32), 1);
+ const tomorrow = addDays(setMinutes(setHours(new Date(), 14), 32), 1).toString();
expect(DateUtils.datetimeToCalendarTime(LOCALE, tomorrow)).toBe('Tomorrow at 2:32 PM');
- const yesterday = setMinutes(setHours(subDays(new Date(), 1), 7), 43);
+ const yesterday = setMinutes(setHours(subDays(new Date(), 1), 7), 43).toString();
expect(DateUtils.datetimeToCalendarTime(LOCALE, yesterday)).toBe('Yesterday at 7:43 AM');
- const date = setMinutes(setHours(new Date('2022-11-05'), 10), 17);
+ const date = setMinutes(setHours(new Date('2022-11-05'), 10), 17).toString();
expect(DateUtils.datetimeToCalendarTime(LOCALE, date)).toBe('Nov 5, 2022 at 10:17 AM');
- const todayLowercaseDate = setMinutes(setHours(new Date(), 14), 32);
+ const todayLowercaseDate = setMinutes(setHours(new Date(), 14), 32).toString();
expect(DateUtils.datetimeToCalendarTime(LOCALE, todayLowercaseDate, false, undefined, true)).toBe('today at 2:32 PM');
});
it('should update timezone if automatic and selected timezone do not match', () => {
- Intl.DateTimeFormat = jest.fn(() => ({
- resolvedOptions: () => ({timeZone: 'America/Chicago'}),
- }));
- Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {999: {timezone: {selected: UTC, automatic: true}}}).then(() => {
+ jest.spyOn(Intl, 'DateTimeFormat').mockImplementation(
+ () =>
+ ({
+ resolvedOptions: () => ({timeZone: 'America/Chicago'}),
+ } as Intl.DateTimeFormat),
+ );
+ Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {'999': {accountID: 999, timezone: {selected: 'Europe/London', automatic: true}}}).then(() => {
const result = DateUtils.getCurrentTimezone();
expect(result).toEqual({
selected: 'America/Chicago',
@@ -88,10 +106,13 @@ describe('DateUtils', () => {
});
it('should not update timezone if automatic and selected timezone match', () => {
- Intl.DateTimeFormat = jest.fn(() => ({
- resolvedOptions: () => ({timeZone: UTC}),
- }));
- Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {999: {timezone: {selected: UTC, automatic: true}}}).then(() => {
+ jest.spyOn(Intl, 'DateTimeFormat').mockImplementation(
+ () =>
+ ({
+ resolvedOptions: () => ({timeZone: UTC}),
+ } as Intl.DateTimeFormat),
+ );
+ Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {'999': {accountID: 999, timezone: {selected: 'Europe/London', automatic: true}}}).then(() => {
const result = DateUtils.getCurrentTimezone();
expect(result).toEqual({
selected: UTC,
@@ -102,7 +123,7 @@ describe('DateUtils', () => {
it('canUpdateTimezone should return true when lastUpdatedTimezoneTime is more than 5 minutes ago', () => {
// Use fake timers to control the current time
- jest.useFakeTimers('modern');
+ jest.useFakeTimers();
jest.setSystemTime(addMinutes(new Date(), 6));
const isUpdateTimezoneAllowed = DateUtils.canUpdateTimezone();
expect(isUpdateTimezoneAllowed).toBe(true);
@@ -110,20 +131,20 @@ describe('DateUtils', () => {
it('canUpdateTimezone should return false when lastUpdatedTimezoneTime is less than 5 minutes ago', () => {
// Use fake timers to control the current time
- jest.useFakeTimers('modern');
+ jest.useFakeTimers();
jest.setSystemTime(addMinutes(new Date(), 4));
const isUpdateTimezoneAllowed = DateUtils.canUpdateTimezone();
expect(isUpdateTimezoneAllowed).toBe(false);
});
it('should return the date in calendar time when calling datetimeToRelative', () => {
- const aFewSecondsAgo = subSeconds(new Date(), 10);
+ const aFewSecondsAgo = subSeconds(new Date(), 10).toString();
expect(DateUtils.datetimeToRelative(LOCALE, aFewSecondsAgo)).toBe('less than a minute ago');
- const aMinuteAgo = subMinutes(new Date(), 1);
+ const aMinuteAgo = subMinutes(new Date(), 1).toString();
expect(DateUtils.datetimeToRelative(LOCALE, aMinuteAgo)).toBe('1 minute ago');
- const anHourAgo = subHours(new Date(), 1);
+ const anHourAgo = subHours(new Date(), 1).toString();
expect(DateUtils.datetimeToRelative(LOCALE, anHourAgo)).toBe('about 1 hour ago');
});
diff --git a/tests/unit/NextStepUtilsTest.ts b/tests/unit/NextStepUtilsTest.ts
index 200a8f52349e..072a06748da9 100644
--- a/tests/unit/NextStepUtilsTest.ts
+++ b/tests/unit/NextStepUtilsTest.ts
@@ -442,6 +442,24 @@ describe('libs/NextStepUtils', () => {
expect(result).toMatchObject(optimisticNextStep);
});
+
+ test('submit and close approval mode', () => {
+ report.ownerAccountID = strangeAccountID;
+ optimisticNextStep.title = 'Finished!';
+ optimisticNextStep.message = [
+ {
+ text: 'No further action required!',
+ },
+ ];
+
+ return Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
+ approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL,
+ }).then(() => {
+ const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.CLOSED);
+
+ expect(result).toMatchObject(optimisticNextStep);
+ });
+ });
});
describe('it generates an optimistic nextStep once a report has been approved', () => {
@@ -553,13 +571,5 @@ describe('libs/NextStepUtils', () => {
expect(result).toMatchObject(optimisticNextStep);
});
});
-
- describe('it generates a nullable optimistic nextStep', () => {
- test('closed status', () => {
- const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.CLOSED);
-
- expect(result).toBeNull();
- });
- });
});
});
diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.ts
similarity index 85%
rename from tests/unit/OptionsListUtilsTest.js
rename to tests/unit/OptionsListUtilsTest.ts
index d590236e5256..baefd1bd6d66 100644
--- a/tests/unit/OptionsListUtilsTest.js
+++ b/tests/unit/OptionsListUtilsTest.ts
@@ -1,29 +1,34 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import type {OnyxCollection} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
-import _ from 'underscore';
-import CONST from '../../src/CONST';
-import * as OptionsListUtils from '../../src/libs/OptionsListUtils';
-import * as ReportUtils from '../../src/libs/ReportUtils';
-import ONYXKEYS from '../../src/ONYXKEYS';
+import type {SelectedTagOption} from '@components/TagPicker';
+import CONST from '@src/CONST';
+import * as OptionsListUtils from '@src/libs/OptionsListUtils';
+import * as ReportUtils from '@src/libs/ReportUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {PersonalDetails, Policy, PolicyCategories, Report, TaxRatesWithDefault} from '@src/types/onyx';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+type PersonalDetailsList = Record;
+
describe('OptionsListUtils', () => {
// Given a set of reports with both single participants and multiple participants some pinned and some not
- const REPORTS = {
- 1: {
+ const REPORTS: OnyxCollection = {
+ '1': {
lastReadTime: '2021-01-14 11:25:39.295',
lastVisibleActionCreated: '2022-11-22 03:26:02.015',
isPinned: false,
- reportID: 1,
+ reportID: '1',
participantAccountIDs: [2, 1],
visibleChatMemberAccountIDs: [2, 1],
reportName: 'Iron Man, Mister Fantastic',
type: CONST.REPORT.TYPE.CHAT,
},
- 2: {
+ '2': {
lastReadTime: '2021-01-14 11:25:39.296',
lastVisibleActionCreated: '2022-11-22 03:26:02.016',
isPinned: false,
- reportID: 2,
+ reportID: '2',
participantAccountIDs: [3],
visibleChatMemberAccountIDs: [3],
reportName: 'Spider-Man',
@@ -31,41 +36,41 @@ describe('OptionsListUtils', () => {
},
// This is the only report we are pinning in this test
- 3: {
+ '3': {
lastReadTime: '2021-01-14 11:25:39.297',
lastVisibleActionCreated: '2022-11-22 03:26:02.170',
isPinned: true,
- reportID: 3,
+ reportID: '3',
participantAccountIDs: [1],
visibleChatMemberAccountIDs: [1],
reportName: 'Mister Fantastic',
type: CONST.REPORT.TYPE.CHAT,
},
- 4: {
+ '4': {
lastReadTime: '2021-01-14 11:25:39.298',
lastVisibleActionCreated: '2022-11-22 03:26:02.180',
isPinned: false,
- reportID: 4,
+ reportID: '4',
participantAccountIDs: [4],
visibleChatMemberAccountIDs: [4],
reportName: 'Black Panther',
type: CONST.REPORT.TYPE.CHAT,
},
- 5: {
+ '5': {
lastReadTime: '2021-01-14 11:25:39.299',
lastVisibleActionCreated: '2022-11-22 03:26:02.019',
isPinned: false,
- reportID: 5,
+ reportID: '5',
participantAccountIDs: [5],
visibleChatMemberAccountIDs: [5],
reportName: 'Invisible Woman',
type: CONST.REPORT.TYPE.CHAT,
},
- 6: {
+ '6': {
lastReadTime: '2021-01-14 11:25:39.300',
lastVisibleActionCreated: '2022-11-22 03:26:02.020',
isPinned: false,
- reportID: 6,
+ reportID: '6',
participantAccountIDs: [6],
visibleChatMemberAccountIDs: [6],
reportName: 'Thor',
@@ -73,11 +78,11 @@ describe('OptionsListUtils', () => {
},
// Note: This report has the largest lastVisibleActionCreated
- 7: {
+ '7': {
lastReadTime: '2021-01-14 11:25:39.301',
lastVisibleActionCreated: '2022-11-22 03:26:03.999',
isPinned: false,
- reportID: 7,
+ reportID: '7',
participantAccountIDs: [7],
visibleChatMemberAccountIDs: [7],
reportName: 'Captain America',
@@ -85,11 +90,11 @@ describe('OptionsListUtils', () => {
},
// Note: This report has no lastVisibleActionCreated
- 8: {
+ '8': {
lastReadTime: '2021-01-14 11:25:39.301',
lastVisibleActionCreated: '2022-11-22 03:26:02.000',
isPinned: false,
- reportID: 8,
+ reportID: '8',
participantAccountIDs: [12],
visibleChatMemberAccountIDs: [12],
reportName: 'Silver Surfer',
@@ -97,23 +102,23 @@ describe('OptionsListUtils', () => {
},
// Note: This report has an IOU
- 9: {
+ '9': {
lastReadTime: '2021-01-14 11:25:39.302',
lastVisibleActionCreated: '2022-11-22 03:26:02.998',
isPinned: false,
- reportID: 9,
+ reportID: '9',
participantAccountIDs: [8],
visibleChatMemberAccountIDs: [8],
reportName: 'Mister Sinister',
- iouReportID: 100,
+ iouReportID: '100',
type: CONST.REPORT.TYPE.CHAT,
},
// This report is an archived room – it does not have a name and instead falls back on oldPolicyName
- 10: {
+ '10': {
lastReadTime: '2021-01-14 11:25:39.200',
lastVisibleActionCreated: '2022-11-22 03:26:02.001',
- reportID: 10,
+ reportID: '10',
isPinned: false,
participantAccountIDs: [2, 7],
visibleChatMemberAccountIDs: [2, 7],
@@ -130,71 +135,81 @@ describe('OptionsListUtils', () => {
};
// And a set of personalDetails some with existing reports and some without
- const PERSONAL_DETAILS = {
+ const PERSONAL_DETAILS: PersonalDetailsList = {
// These exist in our reports
- 1: {
+ '1': {
accountID: 1,
displayName: 'Mister Fantastic',
login: 'reedrichards@expensify.com',
isSelected: true,
+ reportID: '1',
},
- 2: {
+ '2': {
accountID: 2,
displayName: 'Iron Man',
login: 'tonystark@expensify.com',
+ reportID: '1',
},
- 3: {
+ '3': {
accountID: 3,
displayName: 'Spider-Man',
login: 'peterparker@expensify.com',
+ reportID: '1',
},
- 4: {
+ '4': {
accountID: 4,
displayName: 'Black Panther',
login: 'tchalla@expensify.com',
+ reportID: '1',
},
- 5: {
+ '5': {
accountID: 5,
displayName: 'Invisible Woman',
login: 'suestorm@expensify.com',
+ reportID: '1',
},
- 6: {
+ '6': {
accountID: 6,
displayName: 'Thor',
login: 'thor@expensify.com',
+ reportID: '1',
},
- 7: {
+ '7': {
accountID: 7,
displayName: 'Captain America',
login: 'steverogers@expensify.com',
+ reportID: '1',
},
- 8: {
+ '8': {
accountID: 8,
displayName: 'Mr Sinister',
login: 'mistersinister@marauders.com',
+ reportID: '1',
},
// These do not exist in reports at all
- 9: {
+ '9': {
accountID: 9,
displayName: 'Black Widow',
login: 'natasharomanoff@expensify.com',
+ reportID: '',
},
- 10: {
+ '10': {
accountID: 10,
displayName: 'The Incredible Hulk',
login: 'brucebanner@expensify.com',
+ reportID: '',
},
};
- const REPORTS_WITH_CONCIERGE = {
+ const REPORTS_WITH_CONCIERGE: OnyxCollection = {
...REPORTS,
- 11: {
+ '11': {
lastReadTime: '2021-01-14 11:25:39.302',
lastVisibleActionCreated: '2022-11-22 03:26:02.022',
isPinned: false,
- reportID: 11,
+ reportID: '11',
participantAccountIDs: [999],
visibleChatMemberAccountIDs: [999],
reportName: 'Concierge',
@@ -202,13 +217,13 @@ describe('OptionsListUtils', () => {
},
};
- const REPORTS_WITH_CHRONOS = {
+ const REPORTS_WITH_CHRONOS: OnyxCollection = {
...REPORTS,
- 12: {
+ '12': {
lastReadTime: '2021-01-14 11:25:39.302',
lastVisibleActionCreated: '2022-11-22 03:26:02.022',
isPinned: false,
- reportID: 12,
+ reportID: '12',
participantAccountIDs: [1000],
visibleChatMemberAccountIDs: [1000],
reportName: 'Chronos',
@@ -216,13 +231,13 @@ describe('OptionsListUtils', () => {
},
};
- const REPORTS_WITH_RECEIPTS = {
+ const REPORTS_WITH_RECEIPTS: OnyxCollection = {
...REPORTS,
- 13: {
+ '13': {
lastReadTime: '2021-01-14 11:25:39.302',
lastVisibleActionCreated: '2022-11-22 03:26:02.022',
isPinned: false,
- reportID: 13,
+ reportID: '13',
participantAccountIDs: [1001],
visibleChatMemberAccountIDs: [1001],
reportName: 'Receipts',
@@ -230,67 +245,77 @@ describe('OptionsListUtils', () => {
},
};
- const REPORTS_WITH_WORKSPACE_ROOMS = {
+ const REPORTS_WITH_WORKSPACE_ROOMS: OnyxCollection = {
...REPORTS,
- 14: {
+ '14': {
lastReadTime: '2021-01-14 11:25:39.302',
lastVisibleActionCreated: '2022-11-22 03:26:02.022',
isPinned: false,
- reportID: 14,
+ reportID: '14',
participantAccountIDs: [1, 10, 3],
visibleChatMemberAccountIDs: [1, 10, 3],
reportName: '',
oldPolicyName: 'Avengers Room',
- isArchivedRoom: false,
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS,
isOwnPolicyExpenseChat: true,
type: CONST.REPORT.TYPE.CHAT,
},
};
- const PERSONAL_DETAILS_WITH_CONCIERGE = {
+ const PERSONAL_DETAILS_WITH_CONCIERGE: PersonalDetailsList = {
...PERSONAL_DETAILS,
- 999: {
+ '999': {
accountID: 999,
displayName: 'Concierge',
login: 'concierge@expensify.com',
+ reportID: '',
},
};
- const PERSONAL_DETAILS_WITH_CHRONOS = {
+ const PERSONAL_DETAILS_WITH_CHRONOS: PersonalDetailsList = {
...PERSONAL_DETAILS,
- 1000: {
+ '1000': {
accountID: 1000,
displayName: 'Chronos',
login: 'chronos@expensify.com',
+ reportID: '',
},
};
- const PERSONAL_DETAILS_WITH_RECEIPTS = {
+ const PERSONAL_DETAILS_WITH_RECEIPTS: PersonalDetailsList = {
...PERSONAL_DETAILS,
- 1001: {
+ '1001': {
accountID: 1001,
displayName: 'Receipts',
login: 'receipts@expensify.com',
+ reportID: '',
},
};
- const PERSONAL_DETAILS_WITH_PERIODS = {
+ const PERSONAL_DETAILS_WITH_PERIODS: PersonalDetailsList = {
...PERSONAL_DETAILS,
- 1002: {
+ '1002': {
accountID: 1002,
displayName: 'The Flash',
login: 'barry.allen@expensify.com',
+ reportID: '',
},
};
- const POLICY = {
- policyID: 'ABC123',
+ const policyID = 'ABC123';
+
+ const POLICY: Policy = {
+ id: policyID,
name: 'Hero Policy',
+ role: 'user',
+ type: 'free',
+ owner: '',
+ outputCurrency: '',
+ isPolicyExpenseChatEnabled: false,
};
// Set the currently logged in user, report data, and personal details
@@ -299,22 +324,23 @@ describe('OptionsListUtils', () => {
keys: ONYXKEYS,
initialKeyStates: {
[ONYXKEYS.SESSION]: {accountID: 2, email: 'tonystark@expensify.com'},
- [`${ONYXKEYS.COLLECTION.REPORT}100`]: {
+ [`${ONYXKEYS.COLLECTION.REPORT}100` as const]: {
+ reportID: '',
ownerAccountID: 8,
- total: '1000',
+ total: 1000,
},
- [`${ONYXKEYS.COLLECTION.POLICY}${POLICY.policyID}`]: POLICY,
+ [`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const]: POLICY,
},
});
Onyx.registerLogger(() => {});
return waitForBatchedUpdates().then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS));
});
- let OPTIONS = {};
- let OPTIONS_WITH_CONCIERGE = {};
- let OPTIONS_WITH_CHRONOS = {};
- let OPTIONS_WITH_RECEIPTS = {};
- let OPTIONS_WITH_WORKSPACES = {};
+ let OPTIONS: OptionsListUtils.OptionList;
+ let OPTIONS_WITH_CONCIERGE: OptionsListUtils.OptionList;
+ let OPTIONS_WITH_CHRONOS: OptionsListUtils.OptionList;
+ let OPTIONS_WITH_RECEIPTS: OptionsListUtils.OptionList;
+ let OPTIONS_WITH_WORKSPACES: OptionsListUtils.OptionList;
beforeEach(() => {
OPTIONS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS);
@@ -331,7 +357,7 @@ describe('OptionsListUtils', () => {
expect(results.personalDetails.length).toBe(2);
// Then all of the reports should be shown including the archived rooms.
- expect(results.recentReports.length).toBe(_.size(OPTIONS.reports));
+ expect(results.recentReports.length).toBe(Object.values(OPTIONS.reports).length);
// When we filter again but provide a searchValue
results = OptionsListUtils.getSearchOptions(OPTIONS, 'spider');
@@ -372,7 +398,7 @@ describe('OptionsListUtils', () => {
// We should expect all personalDetails to be returned,
// minus the currently logged in user and recent reports count
- expect(results.personalDetails.length).toBe(_.size(OPTIONS.personalDetails) - 1 - MAX_RECENT_REPORTS);
+ expect(results.personalDetails.length).toBe(Object.values(OPTIONS.personalDetails).length - 1 - MAX_RECENT_REPORTS);
// We should expect personal details sorted alphabetically
expect(results.personalDetails[0].text).toBe('Black Widow');
@@ -381,8 +407,8 @@ describe('OptionsListUtils', () => {
expect(results.personalDetails[3].text).toBe('The Incredible Hulk');
// Then the result which has an existing report should also have the reportID attached
- const personalDetailWithExistingReport = _.find(results.personalDetails, (personalDetail) => personalDetail.login === 'peterparker@expensify.com');
- expect(personalDetailWithExistingReport.reportID).toBe(2);
+ const personalDetailWithExistingReport = results.personalDetails.find((personalDetail) => personalDetail.login === 'peterparker@expensify.com');
+ expect(personalDetailWithExistingReport?.reportID).toBe('2');
// When we only pass personal details
results = OptionsListUtils.getFilteredOptions([], OPTIONS.personalDetails, [], '');
@@ -427,28 +453,28 @@ describe('OptionsListUtils', () => {
// Concierge is included in the results by default. We should expect all the personalDetails to show
// (minus the 5 that are already showing and the currently logged in user)
- expect(results.personalDetails.length).toBe(_.size(OPTIONS_WITH_CONCIERGE.personalDetails) - 1 - MAX_RECENT_REPORTS);
+ expect(results.personalDetails.length).toBe(Object.values(OPTIONS_WITH_CONCIERGE.personalDetails).length - 1 - MAX_RECENT_REPORTS);
expect(results.recentReports).toEqual(expect.arrayContaining([expect.objectContaining({login: 'concierge@expensify.com'})]));
// Test by excluding Concierge from the results
results = OptionsListUtils.getFilteredOptions(OPTIONS_WITH_CONCIERGE.reports, OPTIONS_WITH_CONCIERGE.personalDetails, [], '', [], [CONST.EMAIL.CONCIERGE]);
// All the personalDetails should be returned minus the currently logged in user and Concierge
- expect(results.personalDetails.length).toBe(_.size(OPTIONS_WITH_CONCIERGE.personalDetails) - 2 - MAX_RECENT_REPORTS);
+ expect(results.personalDetails.length).toBe(Object.values(OPTIONS_WITH_CONCIERGE.personalDetails).length - 2 - MAX_RECENT_REPORTS);
expect(results.personalDetails).not.toEqual(expect.arrayContaining([expect.objectContaining({login: 'concierge@expensify.com'})]));
// Test by excluding Chronos from the results
results = OptionsListUtils.getFilteredOptions(OPTIONS_WITH_CHRONOS.reports, OPTIONS_WITH_CHRONOS.personalDetails, [], '', [], [CONST.EMAIL.CHRONOS]);
// All the personalDetails should be returned minus the currently logged in user and Concierge
- expect(results.personalDetails.length).toBe(_.size(OPTIONS_WITH_CHRONOS.personalDetails) - 2 - MAX_RECENT_REPORTS);
+ expect(results.personalDetails.length).toBe(Object.values(OPTIONS_WITH_CHRONOS.personalDetails).length - 2 - MAX_RECENT_REPORTS);
expect(results.personalDetails).not.toEqual(expect.arrayContaining([expect.objectContaining({login: 'chronos@expensify.com'})]));
// Test by excluding Receipts from the results
results = OptionsListUtils.getFilteredOptions(OPTIONS_WITH_RECEIPTS.reports, OPTIONS_WITH_RECEIPTS.personalDetails, [], '', [], [CONST.EMAIL.RECEIPTS]);
// All the personalDetails should be returned minus the currently logged in user and Concierge
- expect(results.personalDetails.length).toBe(_.size(OPTIONS_WITH_RECEIPTS.personalDetails) - 2 - MAX_RECENT_REPORTS);
+ expect(results.personalDetails.length).toBe(Object.values(OPTIONS_WITH_RECEIPTS.personalDetails).length - 2 - MAX_RECENT_REPORTS);
expect(results.personalDetails).not.toEqual(expect.arrayContaining([expect.objectContaining({login: 'receipts@expensify.com'})]));
});
@@ -461,7 +487,7 @@ describe('OptionsListUtils', () => {
// And we should expect all the personalDetails to show (minus the 5 that are already
// showing and the currently logged in user)
- expect(results.personalDetails.length).toBe(_.size(OPTIONS.personalDetails) - 6);
+ expect(results.personalDetails.length).toBe(Object.values(OPTIONS.personalDetails).length - 6);
// We should expect personal details sorted alphabetically
expect(results.personalDetails[0].text).toBe('Black Widow');
@@ -470,8 +496,8 @@ describe('OptionsListUtils', () => {
expect(results.personalDetails[3].text).toBe('The Incredible Hulk');
// And none of our personalDetails should include any of the users with recent reports
- const reportLogins = _.map(results.recentReports, (reportOption) => reportOption.login);
- const personalDetailsOverlapWithReports = _.every(results.personalDetails, (personalDetailOption) => _.contains(reportLogins, personalDetailOption.login));
+ const reportLogins = results.recentReports.map((reportOption) => reportOption.login);
+ const personalDetailsOverlapWithReports = results.personalDetails.every((personalDetailOption) => reportLogins.includes(personalDetailOption.login));
expect(personalDetailsOverlapWithReports).toBe(false);
// When we search for an option that is only in a personalDetail with no existing report
@@ -500,15 +526,15 @@ describe('OptionsListUtils', () => {
// Then one of our older report options (not in our five most recent) should appear in the personalDetails
// but not in recentReports
- expect(_.every(results.recentReports, (option) => option.login !== 'peterparker@expensify.com')).toBe(true);
- expect(_.every(results.personalDetails, (option) => option.login !== 'peterparker@expensify.com')).toBe(false);
+ expect(results.recentReports.every((option) => option.login !== 'peterparker@expensify.com')).toBe(true);
+ expect(results.personalDetails.every((option) => option.login !== 'peterparker@expensify.com')).toBe(false);
// When we provide a "selected" option to getFilteredOptions()
results = OptionsListUtils.getFilteredOptions(OPTIONS.reports, OPTIONS.personalDetails, [], '', [{login: 'peterparker@expensify.com'}]);
// Then the option should not appear anywhere in either list
- expect(_.every(results.recentReports, (option) => option.login !== 'peterparker@expensify.com')).toBe(true);
- expect(_.every(results.personalDetails, (option) => option.login !== 'peterparker@expensify.com')).toBe(true);
+ expect(results.recentReports.every((option) => option.login !== 'peterparker@expensify.com')).toBe(true);
+ expect(results.personalDetails.every((option) => option.login !== 'peterparker@expensify.com')).toBe(true);
// When we add a search term for which no options exist and the searchValue itself
// is not a potential email or phone
@@ -544,7 +570,7 @@ describe('OptionsListUtils', () => {
expect(results.recentReports.length).toBe(0);
expect(results.personalDetails.length).toBe(0);
expect(results.userToInvite).not.toBe(null);
- expect(results.userToInvite.login).toBe('+15005550006');
+ expect(results.userToInvite?.login).toBe('+15005550006');
// When we add a search term for which no options exist and the searchValue itself
// is a potential phone number with country code added
@@ -555,7 +581,7 @@ describe('OptionsListUtils', () => {
expect(results.recentReports.length).toBe(0);
expect(results.personalDetails.length).toBe(0);
expect(results.userToInvite).not.toBe(null);
- expect(results.userToInvite.login).toBe('+15005550006');
+ expect(results.userToInvite?.login).toBe('+15005550006');
// When we add a search term for which no options exist and the searchValue itself
// is a potential phone number with special characters added
@@ -566,7 +592,7 @@ describe('OptionsListUtils', () => {
expect(results.recentReports.length).toBe(0);
expect(results.personalDetails.length).toBe(0);
expect(results.userToInvite).not.toBe(null);
- expect(results.userToInvite.login).toBe('+18003243233');
+ expect(results.userToInvite?.login).toBe('+18003243233');
// When we use a search term for contact number that contains alphabet characters
results = OptionsListUtils.getFilteredOptions(OPTIONS.reports, OPTIONS.personalDetails, [], '998243aaaa');
@@ -581,7 +607,7 @@ describe('OptionsListUtils', () => {
// Concierge is included in the results by default. We should expect all the personalDetails to show
// (minus the 5 that are already showing and the currently logged in user)
- expect(results.personalDetails.length).toBe(_.size(OPTIONS_WITH_CONCIERGE.personalDetails) - 6);
+ expect(results.personalDetails.length).toBe(Object.values(OPTIONS_WITH_CONCIERGE.personalDetails).length - 6);
expect(results.recentReports).toEqual(expect.arrayContaining([expect.objectContaining({login: 'concierge@expensify.com'})]));
// Test by excluding Concierge from the results
@@ -589,7 +615,7 @@ describe('OptionsListUtils', () => {
// We should expect all the personalDetails to show (minus the 5 that are already showing,
// the currently logged in user and Concierge)
- expect(results.personalDetails.length).toBe(_.size(OPTIONS_WITH_CONCIERGE.personalDetails) - 7);
+ expect(results.personalDetails.length).toBe(Object.values(OPTIONS_WITH_CONCIERGE.personalDetails).length - 7);
expect(results.personalDetails).not.toEqual(expect.arrayContaining([expect.objectContaining({login: 'concierge@expensify.com'})]));
expect(results.recentReports).not.toEqual(expect.arrayContaining([expect.objectContaining({login: 'concierge@expensify.com'})]));
@@ -598,7 +624,7 @@ describe('OptionsListUtils', () => {
// We should expect all the personalDetails to show (minus the 5 that are already showing,
// the currently logged in user and Concierge)
- expect(results.personalDetails.length).toBe(_.size(OPTIONS_WITH_CHRONOS.personalDetails) - 7);
+ expect(results.personalDetails.length).toBe(Object.values(OPTIONS_WITH_CHRONOS.personalDetails).length - 7);
expect(results.personalDetails).not.toEqual(expect.arrayContaining([expect.objectContaining({login: 'chronos@expensify.com'})]));
expect(results.recentReports).not.toEqual(expect.arrayContaining([expect.objectContaining({login: 'chronos@expensify.com'})]));
@@ -607,30 +633,26 @@ describe('OptionsListUtils', () => {
// We should expect all the personalDetails to show (minus the 5 that are already showing,
// the currently logged in user and Concierge)
- expect(results.personalDetails.length).toBe(_.size(OPTIONS_WITH_RECEIPTS.personalDetails) - 7);
+ expect(results.personalDetails.length).toBe(Object.values(OPTIONS_WITH_RECEIPTS.personalDetails).length - 7);
expect(results.personalDetails).not.toEqual(expect.arrayContaining([expect.objectContaining({login: 'receipts@expensify.com'})]));
expect(results.recentReports).not.toEqual(expect.arrayContaining([expect.objectContaining({login: 'receipts@expensify.com'})]));
});
it('getShareDestinationsOptions()', () => {
// Filter current REPORTS as we do in the component, before getting share destination options
- const filteredReports = _.reduce(
- OPTIONS.reports,
- (filtered, option) => {
- const report = option.item;
- if (ReportUtils.canUserPerformWriteAction(report) && ReportUtils.canCreateTaskInReport(report) && !ReportUtils.isCanceledTaskReport(report)) {
- filtered.push(option);
- }
- return filtered;
- },
- [],
- );
+ const filteredReports = Object.values(OPTIONS.reports).reduce((filtered, option) => {
+ const report = option.item;
+ if (ReportUtils.canUserPerformWriteAction(report) && ReportUtils.canCreateTaskInReport(report) && !ReportUtils.isCanceledTaskReport(report)) {
+ filtered.push(option);
+ }
+ return filtered;
+ }, []);
// When we pass an empty search value
let results = OptionsListUtils.getShareDestinationOptions(filteredReports, OPTIONS.personalDetails, [], '');
// Then we should expect all the recent reports to show but exclude the archived rooms
- expect(results.recentReports.length).toBe(_.size(OPTIONS.reports) - 1);
+ expect(results.recentReports.length).toBe(Object.values(OPTIONS.reports).length - 1);
// When we pass a search value that doesn't match the group chat name
results = OptionsListUtils.getShareDestinationOptions(filteredReports, OPTIONS.personalDetails, [], 'mutants');
@@ -645,23 +667,20 @@ describe('OptionsListUtils', () => {
expect(results.recentReports.length).toBe(1);
// Filter current REPORTS_WITH_WORKSPACE_ROOMS as we do in the component, before getting share destination options
- const filteredReportsWithWorkspaceRooms = _.reduce(
- OPTIONS_WITH_WORKSPACES.reports,
- (filtered, option) => {
- const report = option.item;
- if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) {
- filtered.push(option);
- }
- return filtered;
- },
- [],
- );
+ const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACES.reports).reduce((filtered, option) => {
+ const report = option.item;
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) {
+ filtered.push(option);
+ }
+ return filtered;
+ }, []);
// When we also have a policy to return rooms in the results
results = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], '');
// Then we should expect the DMS, the group chats and the workspace room to show
// We should expect all the recent reports to show, excluding the archived rooms
- expect(results.recentReports.length).toBe(_.size(OPTIONS_WITH_WORKSPACES.reports) - 1);
+ expect(results.recentReports.length).toBe(Object.values(OPTIONS_WITH_WORKSPACES.reports).length - 1);
// When we search for a workspace room
results = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], 'Avengers Room');
@@ -705,31 +724,51 @@ describe('OptionsListUtils', () => {
const emptySearch = '';
const wrongSearch = 'bla bla';
const recentlyUsedCategories = ['Taxi', 'Restaurant'];
- const selectedOptions = [
+ const selectedOptions: Array> = [
{
name: 'Medical',
enabled: true,
},
];
- const smallCategoriesList = {
+ const smallCategoriesList: PolicyCategories = {
Taxi: {
enabled: false,
name: 'Taxi',
+ unencodedName: 'Taxi',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
Restaurant: {
enabled: true,
name: 'Restaurant',
+ unencodedName: 'Restaurant',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
Food: {
enabled: true,
name: 'Food',
+ unencodedName: 'Food',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Food: Meat': {
enabled: true,
name: 'Food: Meat',
+ unencodedName: 'Food: Meat',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
};
- const smallResultList = [
+ const smallResultList: OptionsListUtils.CategoryTreeSection[] = [
{
title: '',
shouldShow: false,
@@ -761,7 +800,7 @@ describe('OptionsListUtils', () => {
],
},
];
- const smallSearchResultList = [
+ const smallSearchResultList: OptionsListUtils.CategoryTreeSection[] = [
{
title: '',
shouldShow: true,
@@ -785,72 +824,142 @@ describe('OptionsListUtils', () => {
],
},
];
- const smallWrongSearchResultList = [
+ const smallWrongSearchResultList: OptionsListUtils.CategoryTreeSection[] = [
{
title: '',
shouldShow: true,
data: [],
},
];
- const largeCategoriesList = {
+ const largeCategoriesList: PolicyCategories = {
Taxi: {
enabled: false,
name: 'Taxi',
+ unencodedName: 'Taxi',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
Restaurant: {
enabled: true,
name: 'Restaurant',
+ unencodedName: 'Restaurant',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
Food: {
enabled: true,
name: 'Food',
+ unencodedName: 'Food',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Food: Meat': {
enabled: true,
name: 'Food: Meat',
+ unencodedName: 'Food: Meat',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Food: Milk': {
enabled: true,
name: 'Food: Milk',
+ unencodedName: 'Food: Milk',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Food: Vegetables': {
enabled: false,
name: 'Food: Vegetables',
+ unencodedName: 'Food: Vegetables',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Cars: Audi': {
enabled: true,
name: 'Cars: Audi',
+ unencodedName: 'Cars: Audi',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Cars: BMW': {
enabled: false,
name: 'Cars: BMW',
+ unencodedName: 'Cars: BMW',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Cars: Mercedes-Benz': {
enabled: true,
name: 'Cars: Mercedes-Benz',
+ unencodedName: 'Cars: Mercedes-Benz',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
Medical: {
enabled: false,
name: 'Medical',
+ unencodedName: 'Medical',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Travel: Meals': {
enabled: true,
name: 'Travel: Meals',
+ unencodedName: 'Travel: Meals',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Travel: Meals: Breakfast': {
enabled: true,
name: 'Travel: Meals: Breakfast',
+ unencodedName: 'Travel: Meals: Breakfast',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Travel: Meals: Dinner': {
enabled: false,
name: 'Travel: Meals: Dinner',
+ unencodedName: 'Travel: Meals: Dinner',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
'Travel: Meals: Lunch': {
enabled: true,
name: 'Travel: Meals: Lunch',
+ unencodedName: 'Travel: Meals: Lunch',
+ areCommentsRequired: false,
+ 'GL Code': '',
+ externalID: '',
+ origin: '',
},
};
- const largeResultList = [
+ const largeResultList: OptionsListUtils.CategoryTreeSection[] = [
{
title: '',
shouldShow: false,
@@ -974,7 +1083,7 @@ describe('OptionsListUtils', () => {
],
},
];
- const largeSearchResultList = [
+ const largeSearchResultList: OptionsListUtils.CategoryTreeSection[] = [
{
title: '',
shouldShow: true,
@@ -1006,7 +1115,7 @@ describe('OptionsListUtils', () => {
],
},
];
- const largeWrongSearchResultList = [
+ const largeWrongSearchResultList: OptionsListUtils.CategoryTreeSection[] = [
{
title: '',
shouldShow: true,
@@ -1014,7 +1123,7 @@ describe('OptionsListUtils', () => {
},
];
const emptyCategoriesList = {};
- const emptySelectedResultList = [
+ const emptySelectedResultList: OptionsListUtils.CategoryTreeSection[] = [
{
title: '',
shouldShow: false,
@@ -1100,25 +1209,29 @@ describe('OptionsListUtils', () => {
name: 'Medical',
},
];
- const smallTagsList = {
+ const smallTagsList: Record = {
Engineering: {
enabled: false,
name: 'Engineering',
+ accountID: null,
},
Medical: {
enabled: true,
name: 'Medical',
+ accountID: null,
},
Accounting: {
enabled: true,
name: 'Accounting',
+ accountID: null,
},
HR: {
enabled: true,
name: 'HR',
+ accountID: null,
},
};
- const smallResultList = [
+ const smallResultList: OptionsListUtils.CategorySection[] = [
{
title: '',
shouldShow: false,
@@ -1130,6 +1243,7 @@ describe('OptionsListUtils', () => {
searchText: 'Accounting',
tooltipText: 'Accounting',
isDisabled: false,
+ isSelected: false,
},
{
text: 'HR',
@@ -1137,6 +1251,7 @@ describe('OptionsListUtils', () => {
searchText: 'HR',
tooltipText: 'HR',
isDisabled: false,
+ isSelected: false,
},
{
text: 'Medical',
@@ -1144,11 +1259,12 @@ describe('OptionsListUtils', () => {
searchText: 'Medical',
tooltipText: 'Medical',
isDisabled: false,
+ isSelected: false,
},
],
},
];
- const smallSearchResultList = [
+ const smallSearchResultList: OptionsListUtils.CategorySection[] = [
{
title: '',
shouldShow: true,
@@ -1159,64 +1275,76 @@ describe('OptionsListUtils', () => {
searchText: 'Accounting',
tooltipText: 'Accounting',
isDisabled: false,
+ isSelected: false,
},
],
},
];
- const smallWrongSearchResultList = [
+ const smallWrongSearchResultList: OptionsListUtils.CategoryTreeSection[] = [
{
title: '',
shouldShow: true,
data: [],
},
];
- const largeTagsList = {
+ const largeTagsList: Record = {
Engineering: {
enabled: false,
name: 'Engineering',
+ accountID: null,
},
Medical: {
enabled: true,
name: 'Medical',
+ accountID: null,
},
Accounting: {
enabled: true,
name: 'Accounting',
+ accountID: null,
},
HR: {
enabled: true,
name: 'HR',
+ accountID: null,
},
Food: {
enabled: true,
name: 'Food',
+ accountID: null,
},
Traveling: {
enabled: false,
name: 'Traveling',
+ accountID: null,
},
Cleaning: {
enabled: true,
name: 'Cleaning',
+ accountID: null,
},
Software: {
enabled: true,
name: 'Software',
+ accountID: null,
},
OfficeSupplies: {
enabled: false,
name: 'Office Supplies',
+ accountID: null,
},
Taxes: {
enabled: true,
name: 'Taxes',
+ accountID: null,
},
Benefits: {
enabled: true,
name: 'Benefits',
+ accountID: null,
},
};
- const largeResultList = [
+ const largeResultList: OptionsListUtils.CategorySection[] = [
{
title: '',
shouldShow: true,
@@ -1227,6 +1355,7 @@ describe('OptionsListUtils', () => {
searchText: 'Medical',
tooltipText: 'Medical',
isDisabled: false,
+ isSelected: true,
},
],
},
@@ -1240,6 +1369,7 @@ describe('OptionsListUtils', () => {
searchText: 'HR',
tooltipText: 'HR',
isDisabled: false,
+ isSelected: false,
},
],
},
@@ -1254,6 +1384,7 @@ describe('OptionsListUtils', () => {
searchText: 'Accounting',
tooltipText: 'Accounting',
isDisabled: false,
+ isSelected: false,
},
{
text: 'Benefits',
@@ -1261,6 +1392,7 @@ describe('OptionsListUtils', () => {
searchText: 'Benefits',
tooltipText: 'Benefits',
isDisabled: false,
+ isSelected: false,
},
{
text: 'Cleaning',
@@ -1268,6 +1400,7 @@ describe('OptionsListUtils', () => {
searchText: 'Cleaning',
tooltipText: 'Cleaning',
isDisabled: false,
+ isSelected: false,
},
{
text: 'Food',
@@ -1275,6 +1408,7 @@ describe('OptionsListUtils', () => {
searchText: 'Food',
tooltipText: 'Food',
isDisabled: false,
+ isSelected: false,
},
{
text: 'HR',
@@ -1282,6 +1416,7 @@ describe('OptionsListUtils', () => {
searchText: 'HR',
tooltipText: 'HR',
isDisabled: false,
+ isSelected: false,
},
{
text: 'Software',
@@ -1289,6 +1424,7 @@ describe('OptionsListUtils', () => {
searchText: 'Software',
tooltipText: 'Software',
isDisabled: false,
+ isSelected: false,
},
{
text: 'Taxes',
@@ -1296,11 +1432,12 @@ describe('OptionsListUtils', () => {
searchText: 'Taxes',
tooltipText: 'Taxes',
isDisabled: false,
+ isSelected: false,
},
],
},
];
- const largeSearchResultList = [
+ const largeSearchResultList: OptionsListUtils.CategorySection[] = [
{
title: '',
shouldShow: true,
@@ -1311,6 +1448,7 @@ describe('OptionsListUtils', () => {
searchText: 'Accounting',
tooltipText: 'Accounting',
isDisabled: false,
+ isSelected: false,
},
{
text: 'Cleaning',
@@ -1318,11 +1456,12 @@ describe('OptionsListUtils', () => {
searchText: 'Cleaning',
tooltipText: 'Cleaning',
isDisabled: false,
+ isSelected: false,
},
],
},
];
- const largeWrongSearchResultList = [
+ const largeWrongSearchResultList: OptionsListUtils.CategoryTreeSection[] = [
{
title: '',
shouldShow: true,
@@ -2076,7 +2215,7 @@ describe('OptionsListUtils', () => {
});
it('sortTags', () => {
- const createTagObjects = (names) => _.map(names, (name) => ({name, enabled: true}));
+ const createTagObjects = (names: string[]) => names.map((name) => ({name, enabled: true}));
const unorderedTagNames = ['10bc', 'b', '0a', '1', '中国', 'b10', '!', '2', '0', '@', 'a1', 'a', '3', 'b1', '日本', '$', '20', '20a', '#', 'a20', 'c', '10'];
const expectedOrderNames = ['!', '#', '$', '0', '0a', '1', '10', '10bc', '2', '20', '20a', '3', '@', 'a', 'a1', 'a20', 'b', 'b1', 'b10', 'c', '中国', '日本'];
@@ -2304,7 +2443,7 @@ describe('OptionsListUtils', () => {
const emptySearch = '';
const wrongSearch = 'bla bla';
- const taxRatesWithDefault = {
+ const taxRatesWithDefault: TaxRatesWithDefault = {
name: 'Tax',
defaultExternalID: 'CODE1',
defaultValue: '0%',
@@ -2313,19 +2452,25 @@ describe('OptionsListUtils', () => {
CODE2: {
name: 'Tax rate 2',
value: '3%',
+ code: 'CODE2',
+ modifiedName: 'Tax rate 2 (3%)',
},
CODE3: {
name: 'Tax option 3',
value: '5%',
+ code: 'CODE3',
+ modifiedName: 'Tax option 3 (5%)',
},
CODE1: {
name: 'Tax exempt 1',
value: '0%',
+ code: 'CODE1',
+ modifiedName: 'Tax exempt 1 (0%) • Default',
},
},
};
- const resultList = [
+ const resultList: OptionsListUtils.CategorySection[] = [
{
title: '',
shouldShow: false,
@@ -2377,7 +2522,7 @@ describe('OptionsListUtils', () => {
},
];
- const searchResultList = [
+ const searchResultList: OptionsListUtils.CategorySection[] = [
{
title: '',
shouldShow: true,
@@ -2400,7 +2545,7 @@ describe('OptionsListUtils', () => {
},
];
- const wrongSearchResultList = [
+ const wrongSearchResultList: OptionsListUtils.CategorySection[] = [
{
title: '',
shouldShow: true,
@@ -2408,19 +2553,19 @@ describe('OptionsListUtils', () => {
},
];
- const result = OptionsListUtils.getFilteredOptions({}, {}, [], emptySearch, [], [], false, false, false, {}, [], false, {}, [], false, false, true, taxRatesWithDefault);
+ const result = OptionsListUtils.getFilteredOptions([], [], [], emptySearch, [], [], false, false, false, {}, [], false, {}, [], false, false, true, taxRatesWithDefault);
expect(result.taxRatesOptions).toStrictEqual(resultList);
- const searchResult = OptionsListUtils.getFilteredOptions({}, {}, [], search, [], [], false, false, false, {}, [], false, {}, [], false, false, true, taxRatesWithDefault);
+ const searchResult = OptionsListUtils.getFilteredOptions([], [], [], search, [], [], false, false, false, {}, [], false, {}, [], false, false, true, taxRatesWithDefault);
expect(searchResult.taxRatesOptions).toStrictEqual(searchResultList);
- const wrongSearchResult = OptionsListUtils.getFilteredOptions({}, {}, [], wrongSearch, [], [], false, false, false, {}, [], false, {}, [], false, false, true, taxRatesWithDefault);
+ const wrongSearchResult = OptionsListUtils.getFilteredOptions([], [], [], wrongSearch, [], [], false, false, false, {}, [], false, {}, [], false, false, true, taxRatesWithDefault);
expect(wrongSearchResult.taxRatesOptions).toStrictEqual(wrongSearchResultList);
});
it('formatMemberForList()', () => {
- const formattedMembers = _.map(PERSONAL_DETAILS, (personalDetail) => OptionsListUtils.formatMemberForList(personalDetail));
+ const formattedMembers = Object.values(PERSONAL_DETAILS).map((personalDetail) => OptionsListUtils.formatMemberForList(personalDetail));
// We're only formatting items inside the array, so the order should be the same as the original PERSONAL_DETAILS array
expect(formattedMembers[0].text).toBe('Mister Fantastic');
@@ -2431,9 +2576,9 @@ describe('OptionsListUtils', () => {
expect(formattedMembers[0].isSelected).toBe(true);
// And all the others to be unselected
- expect(_.every(formattedMembers.slice(1), (personalDetail) => !personalDetail.isSelected)).toBe(true);
+ expect(formattedMembers.slice(1).every((personalDetail) => !personalDetail.isSelected)).toBe(true);
// `isDisabled` is always false
- expect(_.every(formattedMembers, (personalDetail) => !personalDetail.isDisabled)).toBe(true);
+ expect(formattedMembers.every((personalDetail) => !personalDetail.isDisabled)).toBe(true);
});
});
diff --git a/tests/unit/ReportUtilsTest.js b/tests/unit/ReportUtilsTest.ts
similarity index 83%
rename from tests/unit/ReportUtilsTest.js
rename to tests/unit/ReportUtilsTest.ts
index ffd5c9147dc0..f2571cd60e0b 100644
--- a/tests/unit/ReportUtilsTest.js
+++ b/tests/unit/ReportUtilsTest.ts
@@ -1,42 +1,45 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import type {OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
-import _ from 'underscore';
-import CONST from '../../src/CONST';
+import * as ReportUtils from '@libs/ReportUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {PersonalDetailsList, Policy, Report, ReportAction} from '@src/types/onyx';
+import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet';
import * as NumberUtils from '../../src/libs/NumberUtils';
-import * as ReportUtils from '../../src/libs/ReportUtils';
-import ONYXKEYS from '../../src/ONYXKEYS';
import * as LHNTestUtils from '../utils/LHNTestUtils';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
// Be sure to include the mocked permissions library or else the beta tests won't work
-jest.mock('../../src/libs/Permissions');
+jest.mock('@libs/Permissions');
const currentUserEmail = 'bjorn@vikings.net';
const currentUserAccountID = 5;
-const participantsPersonalDetails = {
- 1: {
+const participantsPersonalDetails: PersonalDetailsList = {
+ '1': {
accountID: 1,
displayName: 'Ragnar Lothbrok',
firstName: 'Ragnar',
login: 'ragnar@vikings.net',
},
- 2: {
+ '2': {
accountID: 2,
login: 'floki@vikings.net',
displayName: 'floki@vikings.net',
},
- 3: {
+ '3': {
accountID: 3,
displayName: 'Lagertha Lothbrok',
firstName: 'Lagertha',
login: 'lagertha@vikings.net',
pronouns: 'She/her',
},
- 4: {
+ '4': {
accountID: 4,
login: '+18332403627@expensify.sms',
displayName: '(833) 240-3627',
},
- 5: {
+ '5': {
accountID: 5,
displayName: 'Lagertha Lothbrok',
firstName: 'Lagertha',
@@ -44,20 +47,27 @@ const participantsPersonalDetails = {
pronouns: 'She/her',
},
};
-const policy = {
- policyID: 1,
+
+const policy: Policy = {
+ id: '1',
name: 'Vikings Policy',
+ role: 'user',
+ type: 'free',
+ owner: '',
+ outputCurrency: '',
+ isPolicyExpenseChatEnabled: false,
};
Onyx.init({keys: ONYXKEYS});
describe('ReportUtils', () => {
beforeAll(() => {
+ const policyCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.POLICY, [policy], (current) => current.id);
Onyx.multiSet({
[ONYXKEYS.PERSONAL_DETAILS_LIST]: participantsPersonalDetails,
[ONYXKEYS.SESSION]: {email: currentUserEmail, accountID: currentUserAccountID},
[ONYXKEYS.COUNTRY_CODE]: 1,
- [`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`]: policy,
+ ...policyCollectionDataSet,
});
return waitForBatchedUpdates();
});
@@ -107,6 +117,7 @@ describe('ReportUtils', () => {
test('with displayName', () => {
expect(
ReportUtils.getReportName({
+ reportID: '',
participantAccountIDs: [currentUserAccountID, 1],
}),
).toBe('Ragnar Lothbrok');
@@ -115,6 +126,7 @@ describe('ReportUtils', () => {
test('no displayName', () => {
expect(
ReportUtils.getReportName({
+ reportID: '',
participantAccountIDs: [currentUserAccountID, 2],
}),
).toBe('floki@vikings.net');
@@ -123,6 +135,7 @@ describe('ReportUtils', () => {
test('SMS', () => {
expect(
ReportUtils.getReportName({
+ reportID: '',
participantAccountIDs: [currentUserAccountID, 4],
}),
).toBe('(833) 240-3627');
@@ -132,6 +145,7 @@ describe('ReportUtils', () => {
test('Group DM', () => {
expect(
ReportUtils.getReportName({
+ reportID: '',
participantAccountIDs: [currentUserAccountID, 1, 2, 3, 4],
}),
).toBe('Ragnar, floki@vikings.net, Lagertha, (833) 240-3627');
@@ -139,6 +153,7 @@ describe('ReportUtils', () => {
describe('Default Policy Room', () => {
const baseAdminsRoom = {
+ reportID: '',
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS,
reportName: '#admins',
};
@@ -162,6 +177,7 @@ describe('ReportUtils', () => {
describe('User-Created Policy Room', () => {
const baseUserCreatedRoom = {
+ reportID: '',
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
reportName: '#VikingsChat',
};
@@ -188,8 +204,9 @@ describe('ReportUtils', () => {
test('as member', () => {
expect(
ReportUtils.getReportName({
+ reportID: '',
chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
- policyID: policy.policyID,
+ policyID: policy.id,
isOwnPolicyExpenseChat: true,
ownerAccountID: 1,
}),
@@ -199,8 +216,9 @@ describe('ReportUtils', () => {
test('as admin', () => {
expect(
ReportUtils.getReportName({
+ reportID: '',
chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
- policyID: policy.policyID,
+ policyID: policy.id,
isOwnPolicyExpenseChat: false,
ownerAccountID: 1,
}),
@@ -210,9 +228,10 @@ describe('ReportUtils', () => {
describe('Archived', () => {
const baseArchivedPolicyExpenseChat = {
+ reportID: '',
chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
ownerAccountID: 1,
- policyID: policy.policyID,
+ policyID: policy.id,
oldPolicyName: policy.name,
statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
stateNum: CONST.REPORT.STATE_NUM.APPROVED,
@@ -249,7 +268,7 @@ describe('ReportUtils', () => {
describe('requiresAttentionFromCurrentUser', () => {
it('returns false when there is no report', () => {
- expect(ReportUtils.requiresAttentionFromCurrentUser()).toBe(false);
+ expect(ReportUtils.requiresAttentionFromCurrentUser(null)).toBe(false);
});
it('returns false when the matched IOU report does not have an owner accountID', () => {
const report = {
@@ -324,7 +343,7 @@ describe('ReportUtils', () => {
});
describe('getMoneyRequestOptions', () => {
- const participantsAccountIDs = _.keys(participantsPersonalDetails);
+ const participantsAccountIDs = Object.keys(participantsPersonalDetails).map(Number);
beforeAll(() => {
Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {
@@ -339,8 +358,8 @@ describe('ReportUtils', () => {
describe('return empty iou options if', () => {
it('participants aray contains excluded expensify iou emails', () => {
- const allEmpty = _.every(CONST.EXPENSIFY_ACCOUNT_IDS, (accountID) => {
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions({}, {}, [currentUserAccountID, accountID]);
+ const allEmpty = CONST.EXPENSIFY_ACCOUNT_IDS.every((accountID) => {
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(null, null, [currentUserAccountID, accountID]);
return moneyRequestOptions.length === 0;
});
expect(allEmpty).toBe(true);
@@ -351,7 +370,7 @@ describe('ReportUtils', () => {
...LHNTestUtils.getFakeReport(),
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID]);
expect(moneyRequestOptions.length).toBe(0);
});
@@ -361,7 +380,7 @@ describe('ReportUtils', () => {
chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
isOwnPolicyExpenseChat: false,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID]);
expect(moneyRequestOptions.length).toBe(0);
});
@@ -371,7 +390,7 @@ describe('ReportUtils', () => {
type: CONST.REPORT.TYPE.IOU,
statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID]);
expect(moneyRequestOptions.length).toBe(0);
});
@@ -382,7 +401,7 @@ describe('ReportUtils', () => {
stateNum: CONST.REPORT.STATE_NUM.APPROVED,
statusNum: CONST.REPORT.STATUS_NUM.APPROVED,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID]);
expect(moneyRequestOptions.length).toBe(0);
});
@@ -392,7 +411,7 @@ describe('ReportUtils', () => {
type: CONST.REPORT.TYPE.EXPENSE,
statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID]);
expect(moneyRequestOptions.length).toBe(0);
});
@@ -406,15 +425,20 @@ describe('ReportUtils', () => {
parentReportID: '100',
type: CONST.REPORT.TYPE.EXPENSE,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID]);
expect(moneyRequestOptions.length).toBe(0);
});
});
it("it is a submitted report tied to user's own policy expense chat and the policy does not have Instant Submit frequency", () => {
- const paidPolicy = {
+ const paidPolicy: Policy = {
id: '3f54cca8',
type: CONST.POLICY.TYPE.TEAM,
+ name: '',
+ role: 'user',
+ owner: '',
+ outputCurrency: '',
+ isPolicyExpenseChatEnabled: false,
};
Promise.all([
Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${paidPolicy.id}`, paidPolicy),
@@ -440,17 +464,19 @@ describe('ReportUtils', () => {
describe('return only iou split option if', () => {
it('it is a chat room with more than one participant', () => {
- const onlyHaveSplitOption = _.every(
- [CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, CONST.REPORT.CHAT_TYPE.POLICY_ROOM],
- (chatType) => {
- const report = {
- ...LHNTestUtils.getFakeReport(),
- chatType,
- };
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID, participantsAccountIDs[0]]);
- return moneyRequestOptions.length === 1 && moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT);
- },
- );
+ const onlyHaveSplitOption = [
+ CONST.REPORT.CHAT_TYPE.POLICY_ADMINS,
+ CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE,
+ CONST.REPORT.CHAT_TYPE.DOMAIN_ALL,
+ CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
+ ].every((chatType) => {
+ const report = {
+ ...LHNTestUtils.getFakeReport(),
+ chatType,
+ };
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID, participantsAccountIDs[0]]);
+ return moneyRequestOptions.length === 1 && moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT);
+ });
expect(onlyHaveSplitOption).toBe(true);
});
@@ -459,7 +485,7 @@ describe('ReportUtils', () => {
...LHNTestUtils.getFakeReport(),
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID, ...participantsAccountIDs]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID, ...participantsAccountIDs]);
expect(moneyRequestOptions.length).toBe(1);
expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT)).toBe(true);
});
@@ -469,7 +495,7 @@ describe('ReportUtils', () => {
...LHNTestUtils.getFakeReport(),
chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID, ...participantsAccountIDs]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID, ...participantsAccountIDs]);
expect(moneyRequestOptions.length).toBe(1);
expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT)).toBe(true);
});
@@ -480,7 +506,7 @@ describe('ReportUtils', () => {
type: CONST.REPORT.TYPE.CHAT,
participantsAccountIDs: [currentUserAccountID, ...participantsAccountIDs],
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID, ...participantsAccountIDs]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID, ...participantsAccountIDs.map(Number)]);
expect(moneyRequestOptions.length).toBe(1);
expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT)).toBe(true);
});
@@ -498,7 +524,7 @@ describe('ReportUtils', () => {
parentReportID: '102',
type: CONST.REPORT.TYPE.EXPENSE,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID]);
expect(moneyRequestOptions.length).toBe(1);
expect(moneyRequestOptions.includes(CONST.IOU.TYPE.REQUEST)).toBe(true);
});
@@ -519,8 +545,14 @@ describe('ReportUtils', () => {
};
const paidPolicy = {
type: CONST.POLICY.TYPE.TEAM,
- };
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, paidPolicy, [currentUserAccountID, participantsAccountIDs[0]], true);
+ id: '',
+ name: '',
+ role: 'user',
+ owner: '',
+ outputCurrency: '',
+ isPolicyExpenseChatEnabled: false,
+ } as const;
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, paidPolicy, [currentUserAccountID, participantsAccountIDs[0]]);
expect(moneyRequestOptions.length).toBe(1);
});
});
@@ -532,7 +564,7 @@ describe('ReportUtils', () => {
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID, participantsAccountIDs[0]]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID, participantsAccountIDs[0]]);
expect(moneyRequestOptions.length).toBe(1);
expect(moneyRequestOptions.includes(CONST.IOU.TYPE.REQUEST)).toBe(true);
});
@@ -544,17 +576,22 @@ describe('ReportUtils', () => {
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID, participantsAccountIDs[0]]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID, participantsAccountIDs[0]]);
expect(moneyRequestOptions.length).toBe(1);
expect(moneyRequestOptions.includes(CONST.IOU.TYPE.REQUEST)).toBe(true);
});
it("it is a submitted expense report in user's own policyExpenseChat and the policy has Instant Submit frequency", () => {
- const paidPolicy = {
+ const paidPolicy: Policy = {
id: 'ef72dfeb',
type: CONST.POLICY.TYPE.TEAM,
autoReporting: true,
autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT,
+ name: '',
+ role: 'user',
+ owner: '',
+ outputCurrency: '',
+ isPolicyExpenseChatEnabled: false,
};
Promise.all([
Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${paidPolicy.id}`, paidPolicy),
@@ -585,7 +622,7 @@ describe('ReportUtils', () => {
chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
isOwnPolicyExpenseChat: true,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID, ...participantsAccountIDs]);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID, ...participantsAccountIDs]);
expect(moneyRequestOptions.length).toBe(2);
expect(moneyRequestOptions.includes(CONST.IOU.TYPE.REQUEST)).toBe(true);
expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT)).toBe(true);
@@ -596,8 +633,9 @@ describe('ReportUtils', () => {
...LHNTestUtils.getFakeReport(),
type: CONST.REPORT.TYPE.CHAT,
};
- const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, {}, [currentUserAccountID, participantsAccountIDs[0]]);
- expect(moneyRequestOptions.length).toBe(2);
+ const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, null, [currentUserAccountID, participantsAccountIDs[0]]);
+ expect(moneyRequestOptions.length).toBe(3);
+ expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT)).toBe(true);
expect(moneyRequestOptions.includes(CONST.IOU.TYPE.REQUEST)).toBe(true);
expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SEND)).toBe(true);
});
@@ -622,21 +660,21 @@ describe('ReportUtils', () => {
describe('sortReportsByLastRead', () => {
it('should filter out report without reportID & lastReadTime and sort lastReadTime in ascending order', () => {
- const reports = [
- {reportID: 1, lastReadTime: '2023-07-08 07:15:44.030'},
- {reportID: 2, lastReadTime: null},
- {reportID: 3, lastReadTime: '2023-07-06 07:15:44.030'},
- {reportID: 4, lastReadTime: '2023-07-07 07:15:44.030', type: CONST.REPORT.TYPE.IOU},
- {lastReadTime: '2023-07-09 07:15:44.030'},
- {reportID: 6},
- {},
+ const reports: Array> = [
+ {reportID: '1', lastReadTime: '2023-07-08 07:15:44.030'},
+ {reportID: '2', lastReadTime: undefined},
+ {reportID: '3', lastReadTime: '2023-07-06 07:15:44.030'},
+ {reportID: '4', lastReadTime: '2023-07-07 07:15:44.030', type: CONST.REPORT.TYPE.IOU},
+ {lastReadTime: '2023-07-09 07:15:44.030'} as Report,
+ {reportID: '6'},
+ null,
];
- const sortedReports = [
- {reportID: 3, lastReadTime: '2023-07-06 07:15:44.030'},
- {reportID: 4, lastReadTime: '2023-07-07 07:15:44.030', type: CONST.REPORT.TYPE.IOU},
- {reportID: 1, lastReadTime: '2023-07-08 07:15:44.030'},
+ const sortedReports: Array> = [
+ {reportID: '3', lastReadTime: '2023-07-06 07:15:44.030'},
+ {reportID: '4', lastReadTime: '2023-07-07 07:15:44.030', type: CONST.REPORT.TYPE.IOU},
+ {reportID: '1', lastReadTime: '2023-07-08 07:15:44.030'},
];
- expect(ReportUtils.sortReportsByLastRead(reports)).toEqual(sortedReports);
+ expect(ReportUtils.sortReportsByLastRead(reports, null)).toEqual(sortedReports);
});
});
@@ -656,7 +694,7 @@ describe('ReportUtils', () => {
'',
[{login: 'email1@test.com'}, {login: 'email2@test.com'}],
NumberUtils.rand64(),
- );
+ ) as ReportAction;
expect(ReportUtils.shouldDisableThread(reportAction, reportID)).toBeTruthy();
});
@@ -672,7 +710,7 @@ describe('ReportUtils', () => {
},
],
childVisibleActionCount: 1,
- };
+ } as ReportAction;
expect(ReportUtils.shouldDisableThread(reportAction, reportID)).toBeFalsy();
reportAction.childVisibleActionCount = 0;
@@ -688,7 +726,7 @@ describe('ReportUtils', () => {
.then(() => {
const reportAction = {
childVisibleActionCount: 1,
- };
+ } as ReportAction;
expect(ReportUtils.shouldDisableThread(reportAction, reportID)).toBeFalsy();
reportAction.childVisibleActionCount = 0;
@@ -700,20 +738,20 @@ describe('ReportUtils', () => {
const reportAction = {
actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE,
whisperedToAccountIDs: [123456],
- };
+ } as ReportAction;
expect(ReportUtils.shouldDisableThread(reportAction, reportID)).toBeTruthy();
});
it('should disable on thread first chat', () => {
const reportAction = {
childReportID: reportID,
- };
+ } as ReportAction;
expect(ReportUtils.shouldDisableThread(reportAction, reportID)).toBeTruthy();
});
});
describe('getAllAncestorReportActions', () => {
- const reports = [
+ const reports: Report[] = [
{reportID: '1', lastReadTime: '2024-02-01 04:56:47.233', reportName: 'Report'},
{reportID: '2', lastReadTime: '2024-02-01 04:56:47.233', parentReportActionID: '1', parentReportID: '1', reportName: 'Report'},
{reportID: '3', lastReadTime: '2024-02-01 04:56:47.233', parentReportActionID: '2', parentReportID: '2', reportName: 'Report'},
@@ -721,24 +759,23 @@ describe('ReportUtils', () => {
{reportID: '5', lastReadTime: '2024-02-01 04:56:47.233', parentReportActionID: '4', parentReportID: '4', reportName: 'Report'},
];
- const reportActions = [
- {reportActionID: '1', created: '2024-02-01 04:42:22.965'},
- {reportActionID: '2', created: '2024-02-01 04:42:28.003'},
- {reportActionID: '3', created: '2024-02-01 04:42:31.742'},
- {reportActionID: '4', created: '2024-02-01 04:42:35.619'},
+ const reportActions: ReportAction[] = [
+ {reportActionID: '1', created: '2024-02-01 04:42:22.965', actionName: 'MARKEDREIMBURSED'},
+ {reportActionID: '2', created: '2024-02-01 04:42:28.003', actionName: 'MARKEDREIMBURSED'},
+ {reportActionID: '3', created: '2024-02-01 04:42:31.742', actionName: 'MARKEDREIMBURSED'},
+ {reportActionID: '4', created: '2024-02-01 04:42:35.619', actionName: 'MARKEDREIMBURSED'},
];
beforeAll(() => {
+ const reportCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.REPORT, reports, (report) => report.reportID);
+ const reportActionCollectionDataSet = toCollectionDataSet(
+ ONYXKEYS.COLLECTION.REPORT_ACTIONS,
+ reportActions.map((reportAction) => ({[reportAction.reportActionID]: reportAction})),
+ (actions) => Object.values(actions)[0].reportActionID,
+ );
Onyx.multiSet({
- [`${ONYXKEYS.COLLECTION.REPORT}${reports[0].reportID}`]: reports[0],
- [`${ONYXKEYS.COLLECTION.REPORT}${reports[1].reportID}`]: reports[1],
- [`${ONYXKEYS.COLLECTION.REPORT}${reports[2].reportID}`]: reports[2],
- [`${ONYXKEYS.COLLECTION.REPORT}${reports[3].reportID}`]: reports[3],
- [`${ONYXKEYS.COLLECTION.REPORT}${reports[4].reportID}`]: reports[4],
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reports[0].reportID}`]: {[reportActions[0].reportActionID]: reportActions[0]},
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reports[1].reportID}`]: {[reportActions[1].reportActionID]: reportActions[1]},
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reports[2].reportID}`]: {[reportActions[2].reportActionID]: reportActions[2]},
- [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reports[3].reportID}`]: {[reportActions[3].reportActionID]: reportActions[3]},
+ ...reportCollectionDataSet,
+ ...reportActionCollectionDataSet,
});
return waitForBatchedUpdates();
});