require('../../../pages/iou/SplitBillDetailsPage').default,
+ SplitDetails_Edit_Request: () => require('../../../pages/EditSplitBillPage').default,
+ SplitDetails_Edit_Currency: () => require('../../../pages/iou/IOUCurrencySelection').default,
});
const DetailsModalStackNavigator = createModalStackNavigator({
@@ -141,6 +143,7 @@ const SettingsModalStackNavigator = createModalStackNavigator({
Settings_App_Download_Links: () => require('../../../pages/settings/AppDownloadLinks').default,
Settings_Lounge_Access: () => require('../../../pages/settings/Profile/LoungeAccessPage').default,
Settings_Wallet: () => require('../../../pages/settings/Wallet/WalletPage').default,
+ Settings_Wallet_Cards_Digital_Details_Update_Address: () => require('../../../pages/settings/Profile/PersonalDetails/AddressPage').default,
Settings_Wallet_DomainCards: () => require('../../../pages/settings/Wallet/ExpensifyCardPage').default,
Settings_Wallet_ReportVirtualCardFraud: () => require('../../../pages/settings/Wallet/ReportVirtualCardFraudPage').default,
Settings_Wallet_Card_Activate: () => require('../../../pages/settings/Wallet/ActivatePhysicalCardPage').default,
diff --git a/src/libs/Navigation/AppNavigator/PublicScreens.js b/src/libs/Navigation/AppNavigator/PublicScreens.js
index 7a87530a2d9e..7b0afb787278 100644
--- a/src/libs/Navigation/AppNavigator/PublicScreens.js
+++ b/src/libs/Navigation/AppNavigator/PublicScreens.js
@@ -8,6 +8,7 @@ import defaultScreenOptions from './defaultScreenOptions';
import UnlinkLoginPage from '../../../pages/UnlinkLoginPage';
import AppleSignInDesktopPage from '../../../pages/signin/AppleSignInDesktopPage';
import GoogleSignInDesktopPage from '../../../pages/signin/GoogleSignInDesktopPage';
+import SAMLSignInPage from '../../../pages/signin/SAMLSignInPage';
const RootStack = createStackNavigator();
@@ -44,6 +45,11 @@ function PublicScreens() {
options={defaultScreenOptions}
component={GoogleSignInDesktopPage}
/>
+
);
}
diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js
index 3e3dc59dcd80..6bbf53ffa6ea 100644
--- a/src/libs/Navigation/Navigation.js
+++ b/src/libs/Navigation/Navigation.js
@@ -3,7 +3,6 @@ import lodashGet from 'lodash/get';
import {CommonActions, getPathFromState, StackActions} from '@react-navigation/native';
import {getActionFromState} from '@react-navigation/core';
import Log from '../Log';
-import DomUtils from '../DomUtils';
import linkTo from './linkTo';
import ROUTES from '../../ROUTES';
import linkingConfig from './linkingConfig';
@@ -81,7 +80,7 @@ const getActiveRouteIndex = function (route, index) {
/**
* Main navigation method for redirecting to a route.
* @param {String} route
- * @param {String} type - Type of action to perform. Currently UP is supported.
+ * @param {String} [type] - Type of action to perform. Currently UP is supported.
*/
function navigate(route = ROUTES.HOME, type) {
if (!canNavigate('navigate', {route})) {
@@ -92,11 +91,6 @@ function navigate(route = ROUTES.HOME, type) {
return;
}
- // A pressed navigation button will remain focused, keeping its tooltip visible, even if it's supposed to be out of view.
- // To prevent that we blur the button manually (especially for Safari, where the mouse leave event is missing).
- // More info: https://github.com/Expensify/App/issues/13146
- DomUtils.blurActiveElement();
-
linkTo(navigationRef.current, route, type);
}
@@ -178,6 +172,11 @@ function dismissModal(targetReportID) {
const action = getActionFromState(state, linkingConfig.config);
action.type = 'REPLACE';
navigationRef.current.dispatch(action);
+ // If not-found page is in the route stack, we need to close it
+ } else if (targetReportID && _.some(rootState.routes, (route) => route.name === SCREENS.NOT_FOUND)) {
+ const lastRouteIndex = rootState.routes.length - 1;
+ const centralRouteIndex = _.findLastIndex(rootState.routes, (route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR);
+ navigationRef.current.dispatch({...StackActions.pop(lastRouteIndex - centralRouteIndex), target: rootState.key});
} else {
navigationRef.current.dispatch({...StackActions.pop(), target: rootState.key});
}
diff --git a/src/libs/Navigation/NavigationRoot.js b/src/libs/Navigation/NavigationRoot.js
index 34a52adfeca9..c7a3b14e4fb0 100644
--- a/src/libs/Navigation/NavigationRoot.js
+++ b/src/libs/Navigation/NavigationRoot.js
@@ -101,7 +101,7 @@ function NavigationRoot(props) {
const animateStatusBarBackgroundColor = () => {
const currentRoute = navigationRef.getCurrentRoute();
- const currentScreenBackgroundColor = themeColors.PAGE_BACKGROUND_COLORS[currentRoute.name] || themeColors.appBG;
+ const currentScreenBackgroundColor = (currentRoute.params && currentRoute.params.backgroundColor) || themeColors.PAGE_BACKGROUND_COLORS[currentRoute.name] || themeColors.appBG;
prevStatusBarBackgroundColor.current = statusBarBackgroundColor.current;
statusBarBackgroundColor.current = currentScreenBackgroundColor;
diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js
index 60182b925a32..8a68ec9c0d07 100644
--- a/src/libs/Navigation/linkingConfig.js
+++ b/src/libs/Navigation/linkingConfig.js
@@ -15,9 +15,13 @@ export default {
[SCREENS.CONCIERGE]: ROUTES.CONCIERGE,
AppleSignInDesktop: ROUTES.APPLE_SIGN_IN,
GoogleSignInDesktop: ROUTES.GOOGLE_SIGN_IN,
+ SAMLSignIn: ROUTES.SAML_SIGN_IN,
[SCREENS.DESKTOP_SIGN_IN_REDIRECT]: ROUTES.DESKTOP_SIGN_IN_REDIRECT,
[SCREENS.REPORT_ATTACHMENTS]: ROUTES.REPORT_ATTACHMENTS.route,
+ // Demo routes
+ [CONST.DEMO_PAGES.MONEY2020]: ROUTES.MONEY2020,
+
// Sidebar
[SCREENS.HOME]: {
path: ROUTES.HOME,
@@ -70,7 +74,7 @@ export default {
exact: true,
},
Settings_Wallet_DomainCards: {
- path: ROUTES.SETTINGS_WALLET_DOMAINCARDS.route,
+ path: ROUTES.SETTINGS_WALLET_DOMAINCARD.route,
exact: true,
},
Settings_Wallet_ReportVirtualCardFraud: {
@@ -93,6 +97,10 @@ export default {
path: ROUTES.SETTINGS_WALLET_CARD_ACTIVATE.route,
exact: true,
},
+ Settings_Wallet_Cards_Digital_Details_Update_Address: {
+ path: ROUTES.SETTINGS_WALLET_CARD_DIGITAL_DETAILS_UPDATE_ADDRESS.route,
+ exact: true,
+ },
Settings_Add_Debit_Card: {
path: ROUTES.SETTINGS_ADD_DEBIT_CARD,
exact: true,
@@ -358,6 +366,8 @@ export default {
SplitDetails: {
screens: {
SplitDetails_Root: ROUTES.SPLIT_BILL_DETAILS.route,
+ SplitDetails_Edit_Request: ROUTES.EDIT_SPLIT_BILL.route,
+ SplitDetails_Edit_Currency: ROUTES.EDIT_SPLIT_BILL_CURRENCY.route,
},
},
Task_Details: {
diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js
index 4467746475aa..fb6c6e4f493e 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -364,7 +364,8 @@ function getLastMessageTextForReport(report) {
if (ReportUtils.isReportMessageAttachment({text: report.lastMessageText, html: report.lastMessageHtml, translationKey: report.lastMessageTranslationKey})) {
lastMessageTextFromReport = `[${Localize.translateLocal(report.lastMessageTranslationKey || 'common.attachment')}]`;
} else if (ReportActionUtils.isMoneyRequestAction(lastReportAction)) {
- lastMessageTextFromReport = ReportUtils.getReportPreviewMessage(report, lastReportAction, true);
+ const properSchemaForMoneyRequestMessage = ReportUtils.getReportPreviewMessage(report, lastReportAction, true);
+ lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(properSchemaForMoneyRequestMessage);
} else if (ReportActionUtils.isReportPreviewAction(lastReportAction)) {
const iouReport = ReportUtils.getReport(ReportActionUtils.getIOUReportIDFromReportActionPreview(lastReportAction));
const lastIOUMoneyReport = _.find(
@@ -444,6 +445,7 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, {
isArchivedRoom: false,
shouldShowSubscript: false,
isPolicyExpenseChat: false,
+ isOwnPolicyExpenseChat: false,
isExpenseReport: false,
policyID: null,
isOptimisticPersonalDetail: false,
@@ -463,12 +465,13 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, {
result.isChatRoom = ReportUtils.isChatRoom(report);
result.isDefaultRoom = ReportUtils.isDefaultRoom(report);
result.isArchivedRoom = ReportUtils.isArchivedRoom(report);
- result.isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report);
result.isExpenseReport = ReportUtils.isExpenseReport(report);
result.isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report);
result.isThread = ReportUtils.isChatThread(report);
result.isTaskReport = ReportUtils.isTaskReport(report);
result.shouldShowSubscript = ReportUtils.shouldReportShowSubscript(report);
+ result.isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report);
+ result.isOwnPolicyExpenseChat = report.isOwnPolicyExpenseChat || false;
result.allReportErrors = getAllReportErrors(report, reportActions);
result.brickRoadIndicator = !_.isEmpty(result.allReportErrors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '';
result.pendingAction = report.pendingFields ? report.pendingFields.addWorkspaceRoom || report.pendingFields.createChat : null;
@@ -553,6 +556,10 @@ function getPolicyExpenseReportOption(report) {
forcePolicyNamePreview: false,
},
);
+
+ // Update text & alternateText because createOption returns workspace name only if report is owned by the user
+ option.text = ReportUtils.getPolicyName(expenseReport);
+ option.alternateText = Localize.translateLocal('workspace.common.workspace');
option.selected = report.selected;
return option;
}
diff --git a/src/libs/Performance.js b/src/libs/Performance.tsx
similarity index 52%
rename from src/libs/Performance.js
rename to src/libs/Performance.tsx
index 0207fd20c564..cfb5e258c9f8 100644
--- a/src/libs/Performance.js
+++ b/src/libs/Performance.tsx
@@ -1,39 +1,73 @@
-import _ from 'underscore';
-import lodashTransform from 'lodash/transform';
import React, {Profiler, forwardRef} from 'react';
import {Alert, InteractionManager} from 'react-native';
+import lodashTransform from 'lodash/transform';
+import isObject from 'lodash/isObject';
+import isEqual from 'lodash/isEqual';
+import {Performance as RNPerformance, PerformanceEntry, PerformanceMark, PerformanceMeasure} from 'react-native-performance';
+import {PerformanceObserverEntryList} from 'react-native-performance/lib/typescript/performance-observer';
import * as Metrics from './Metrics';
import getComponentDisplayName from './getComponentDisplayName';
import CONST from '../CONST';
import isE2ETestSession from './E2E/isE2ETestSession';
-/** @type {import('react-native-performance').Performance} */
-let rnPerformance;
+type WrappedComponentConfig = {id: string};
+
+type PerformanceEntriesCallback = (entry: PerformanceEntry) => void;
+
+type Phase = 'mount' | 'update';
+
+type WithRenderTraceHOC = >(WrappedComponent: React.ComponentType
) => React.ComponentType
>;
+
+type BlankHOC =
>(Component: React.ComponentType
) => React.ComponentType
;
+
+type SetupPerformanceObserver = () => void;
+type DiffObject = (object: Record, base: Record) => Record;
+type GetPerformanceMetrics = () => PerformanceEntry[];
+type PrintPerformanceMetrics = () => void;
+type MarkStart = (name: string, detail?: Record) => PerformanceMark | void;
+type MarkEnd = (name: string, detail?: Record) => PerformanceMark | void;
+type MeasureFailSafe = (measureName: string, startOrMeasureOptions: string, endMark: string) => void;
+type MeasureTTI = (endMark: string) => void;
+type TraceRender = (id: string, phase: Phase, actualDuration: number, baseDuration: number, startTime: number, commitTime: number, interactions: Set) => PerformanceMeasure | void;
+type WithRenderTrace = ({id}: WrappedComponentConfig) => WithRenderTraceHOC | BlankHOC;
+type SubscribeToMeasurements = (callback: PerformanceEntriesCallback) => void;
+
+type PerformanceModule = {
+ diffObject: DiffObject;
+ setupPerformanceObserver: SetupPerformanceObserver;
+ getPerformanceMetrics: GetPerformanceMetrics;
+ printPerformanceMetrics: PrintPerformanceMetrics;
+ markStart: MarkStart;
+ markEnd: MarkEnd;
+ measureFailSafe: MeasureFailSafe;
+ measureTTI: MeasureTTI;
+ traceRender: TraceRender;
+ withRenderTrace: WithRenderTrace;
+ subscribeToMeasurements: SubscribeToMeasurements;
+};
+
+let rnPerformance: RNPerformance;
/**
* Deep diff between two objects. Useful for figuring out what changed about an object from one render to the next so
* that state and props updates can be optimized.
- *
- * @param {Object} object
- * @param {Object} base
- * @return {Object}
*/
-function diffObject(object, base) {
- function changes(obj, comparisonObject) {
+function diffObject(object: Record, base: Record): Record {
+ function changes(obj: Record, comparisonObject: Record): Record {
return lodashTransform(obj, (result, value, key) => {
- if (_.isEqual(value, comparisonObject[key])) {
+ if (isEqual(value, comparisonObject[key])) {
return;
}
// eslint-disable-next-line no-param-reassign
- result[key] = _.isObject(value) && _.isObject(comparisonObject[key]) ? changes(value, comparisonObject[key]) : value;
+ result[key] = isObject(value) && isObject(comparisonObject[key]) ? changes(value as Record, comparisonObject[key] as Record) : value;
});
}
return changes(object, base);
}
-const Performance = {
+const Performance: PerformanceModule = {
// When performance monitoring is disabled the implementations are blank
diffObject,
setupPerformanceObserver: () => {},
@@ -44,7 +78,11 @@ const Performance = {
measureFailSafe: () => {},
measureTTI: () => {},
traceRender: () => {},
- withRenderTrace: () => (Component) => Component,
+ withRenderTrace:
+ () =>
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ >(Component: React.ComponentType
): React.ComponentType
=>
+ Component,
subscribeToMeasurements: () => {},
};
@@ -53,20 +91,21 @@ if (Metrics.canCapturePerformanceMetrics()) {
perfModule.setResourceLoggingEnabled(true);
rnPerformance = perfModule.default;
- Performance.measureFailSafe = (measureName, startOrMeasureOptions, endMark) => {
+ Performance.measureFailSafe = (measureName: string, startOrMeasureOptions: string, endMark: string) => {
try {
rnPerformance.measure(measureName, startOrMeasureOptions, endMark);
} catch (error) {
// Sometimes there might be no start mark recorded and the measure will fail with an error
- console.debug(error.message);
+ if (error instanceof Error) {
+ console.debug(error.message);
+ }
}
};
/**
* Measures the TTI time. To be called when the app is considered to be interactive.
- * @param {String} [endMark] Optional end mark name
*/
- Performance.measureTTI = (endMark) => {
+ Performance.measureTTI = (endMark: string) => {
// Make sure TTI is captured when the app is really usable
InteractionManager.runAfterInteractions(() => {
requestAnimationFrame(() => {
@@ -88,8 +127,8 @@ if (Metrics.canCapturePerformanceMetrics()) {
performanceReported.setupDefaultFlipperReporter();
// Monitor some native marks that we want to put on the timeline
- new perfModule.PerformanceObserver((list, observer) => {
- list.getEntries().forEach((entry) => {
+ new perfModule.PerformanceObserver((list: PerformanceObserverEntryList, observer: PerformanceObserver) => {
+ list.getEntries().forEach((entry: PerformanceEntry) => {
if (entry.name === 'nativeLaunchEnd') {
Performance.measureFailSafe('nativeLaunch', 'nativeLaunchStart', 'nativeLaunchEnd');
}
@@ -108,8 +147,8 @@ if (Metrics.canCapturePerformanceMetrics()) {
}).observe({type: 'react-native-mark', buffered: true});
// Monitor for "_end" marks and capture "_start" to "_end" measures
- new perfModule.PerformanceObserver((list) => {
- list.getEntriesByType('mark').forEach((mark) => {
+ new perfModule.PerformanceObserver((list: PerformanceObserverEntryList) => {
+ list.getEntriesByType('mark').forEach((mark: PerformanceEntry) => {
if (mark.name.endsWith('_end')) {
const end = mark.name;
const name = end.replace(/_end$/, '');
@@ -125,65 +164,64 @@ if (Metrics.canCapturePerformanceMetrics()) {
}).observe({type: 'mark', buffered: true});
};
- Performance.getPerformanceMetrics = () =>
- _.chain([
+ Performance.getPerformanceMetrics = (): PerformanceEntry[] =>
+ [
...rnPerformance.getEntriesByName('nativeLaunch'),
...rnPerformance.getEntriesByName('runJsBundle'),
...rnPerformance.getEntriesByName('jsBundleDownload'),
...rnPerformance.getEntriesByName('TTI'),
...rnPerformance.getEntriesByName('regularAppStart'),
...rnPerformance.getEntriesByName('appStartedToReady'),
- ])
- .filter((entry) => entry.duration > 0)
- .value();
+ ].filter((entry) => entry.duration > 0);
/**
* Outputs performance stats. We alert these so that they are easy to access in release builds.
*/
Performance.printPerformanceMetrics = () => {
const stats = Performance.getPerformanceMetrics();
- const statsAsText = _.map(stats, (entry) => `\u2022 ${entry.name}: ${entry.duration.toFixed(1)}ms`).join('\n');
+ const statsAsText = stats.map((entry) => `\u2022 ${entry.name}: ${entry.duration.toFixed(1)}ms`).join('\n');
if (stats.length > 0) {
Alert.alert('Performance', statsAsText);
}
};
- Performance.subscribeToMeasurements = (callback) => {
- new perfModule.PerformanceObserver((list) => {
+ Performance.subscribeToMeasurements = (callback: PerformanceEntriesCallback) => {
+ new perfModule.PerformanceObserver((list: PerformanceObserverEntryList) => {
list.getEntriesByType('measure').forEach(callback);
}).observe({type: 'measure', buffered: true});
};
/**
* Add a start mark to the performance entries
- * @param {string} name
- * @param {Object} [detail]
- * @returns {PerformanceMark}
*/
- Performance.markStart = (name, detail) => rnPerformance.mark(`${name}_start`, {detail});
+ Performance.markStart = (name: string, detail?: Record): PerformanceMark => rnPerformance.mark(`${name}_start`, {detail});
/**
* Add an end mark to the performance entries
* A measure between start and end is captured automatically
- * @param {string} name
- * @param {Object} [detail]
- * @returns {PerformanceMark}
*/
- Performance.markEnd = (name, detail) => rnPerformance.mark(`${name}_end`, {detail});
+ Performance.markEnd = (name: string, detail?: Record): PerformanceMark => rnPerformance.mark(`${name}_end`, {detail});
/**
* Put data emitted by Profiler components on the timeline
- * @param {string} id the "id" prop of the Profiler tree that has just committed
- * @param {'mount'|'update'} phase either "mount" (if the tree just mounted) or "update" (if it re-rendered)
- * @param {number} actualDuration time spent rendering the committed update
- * @param {number} baseDuration estimated time to render the entire subtree without memoization
- * @param {number} startTime when React began rendering this update
- * @param {number} commitTime when React committed this update
- * @param {Set} interactions the Set of interactions belonging to this update
- * @returns {PerformanceMeasure}
+ * @param id the "id" prop of the Profiler tree that has just committed
+ * @param phase either "mount" (if the tree just mounted) or "update" (if it re-rendered)
+ * @param actualDuration time spent rendering the committed update
+ * @param baseDuration estimated time to render the entire subtree without memoization
+ * @param startTime when React began rendering this update
+ * @param commitTime when React committed this update
+ * @param interactions the Set of interactions belonging to this update
*/
- Performance.traceRender = (id, phase, actualDuration, baseDuration, startTime, commitTime, interactions) =>
+ Performance.traceRender = (
+ id: string,
+ phase: Phase,
+ actualDuration: number,
+ baseDuration: number,
+ startTime: number,
+ commitTime: number,
+ interactions: Set,
+ ): PerformanceMeasure =>
rnPerformance.measure(id, {
start: startTime,
duration: actualDuration,
@@ -197,14 +235,12 @@ if (Metrics.canCapturePerformanceMetrics()) {
/**
* A HOC that captures render timings of the Wrapped component
- * @param {object} config
- * @param {string} config.id
- * @returns {function(React.Component): React.FunctionComponent}
*/
Performance.withRenderTrace =
- ({id}) =>
- (WrappedComponent) => {
- const WithRenderTrace = forwardRef((props, ref) => (
+ ({id}: WrappedComponentConfig) =>
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ >(WrappedComponent: React.ComponentType
): React.ComponentType
> => {
+ const WithRenderTrace: React.ComponentType
> = forwardRef((props: P, ref) => (
));
- WithRenderTrace.displayName = `withRenderTrace(${getComponentDisplayName(WrappedComponent)})`;
+ WithRenderTrace.displayName = `withRenderTrace(${getComponentDisplayName(WrappedComponent as React.ComponentType)})`;
return WithRenderTrace;
};
}
diff --git a/src/libs/PolicyUtils.js b/src/libs/PolicyUtils.js
index 347a825f59cc..6bbae72f1d80 100644
--- a/src/libs/PolicyUtils.js
+++ b/src/libs/PolicyUtils.js
@@ -174,7 +174,7 @@ function getMemberAccountIDsForWorkspace(policyMembers, personalDetails) {
if (!personalDetail || !personalDetail.login) {
return;
}
- memberEmailsToAccountIDs[personalDetail.login] = accountID;
+ memberEmailsToAccountIDs[personalDetail.login] = Number(accountID);
});
return memberEmailsToAccountIDs;
}
diff --git a/src/libs/Pusher/EventType.js b/src/libs/Pusher/EventType.ts
similarity index 97%
rename from src/libs/Pusher/EventType.js
rename to src/libs/Pusher/EventType.ts
index 85ccc5e17242..89e8a0ca0260 100644
--- a/src/libs/Pusher/EventType.js
+++ b/src/libs/Pusher/EventType.ts
@@ -11,4 +11,4 @@ export default {
MULTIPLE_EVENT_TYPE: {
ONYX_API_UPDATE: 'onyxApiUpdate',
},
-};
+} as const;
diff --git a/src/libs/Pusher/library/index.js b/src/libs/Pusher/library/index.js
deleted file mode 100644
index 12cfae7df02f..000000000000
--- a/src/libs/Pusher/library/index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-/**
- * We use the standard pusher-js module to support pusher on web environments.
- * @see: https://github.com/pusher/pusher-js
- */
-import Pusher from 'pusher-js/with-encryption';
-
-export default Pusher;
diff --git a/src/libs/Pusher/library/index.native.js b/src/libs/Pusher/library/index.native.js
deleted file mode 100644
index 7b87d0c8bdfb..000000000000
--- a/src/libs/Pusher/library/index.native.js
+++ /dev/null
@@ -1,7 +0,0 @@
-/**
- * We use the pusher-js/react-native module to support pusher on native environments.
- * @see: https://github.com/pusher/pusher-js
- */
-import Pusher from 'pusher-js/react-native';
-
-export default Pusher;
diff --git a/src/libs/Pusher/library/index.native.ts b/src/libs/Pusher/library/index.native.ts
new file mode 100644
index 000000000000..f50834366515
--- /dev/null
+++ b/src/libs/Pusher/library/index.native.ts
@@ -0,0 +1,10 @@
+/**
+ * We use the pusher-js/react-native module to support pusher on native environments.
+ * @see: https://github.com/pusher/pusher-js
+ */
+import PusherImplementation from 'pusher-js/react-native';
+import Pusher from './types';
+
+const PusherNative: Pusher = PusherImplementation;
+
+export default PusherNative;
diff --git a/src/libs/Pusher/library/index.ts b/src/libs/Pusher/library/index.ts
new file mode 100644
index 000000000000..6a7104a1d2a5
--- /dev/null
+++ b/src/libs/Pusher/library/index.ts
@@ -0,0 +1,10 @@
+/**
+ * We use the standard pusher-js module to support pusher on web environments.
+ * @see: https://github.com/pusher/pusher-js
+ */
+import PusherImplementation from 'pusher-js/with-encryption';
+import type Pusher from './types';
+
+const PusherWeb: Pusher = PusherImplementation;
+
+export default PusherWeb;
diff --git a/src/libs/Pusher/library/types.ts b/src/libs/Pusher/library/types.ts
new file mode 100644
index 000000000000..cc8c70fccdbb
--- /dev/null
+++ b/src/libs/Pusher/library/types.ts
@@ -0,0 +1,10 @@
+import PusherClass from 'pusher-js/with-encryption';
+import {LiteralUnion} from 'type-fest';
+
+type Pusher = typeof PusherClass;
+
+type SocketEventName = LiteralUnion<'error' | 'connected' | 'disconnected' | 'state_change', string>;
+
+export default Pusher;
+
+export type {SocketEventName};
diff --git a/src/libs/Pusher/pusher.js b/src/libs/Pusher/pusher.ts
similarity index 72%
rename from src/libs/Pusher/pusher.js
rename to src/libs/Pusher/pusher.ts
index 4f2b63d36c0c..dad963e933fe 100644
--- a/src/libs/Pusher/pusher.js
+++ b/src/libs/Pusher/pusher.ts
@@ -1,9 +1,48 @@
import Onyx from 'react-native-onyx';
-import _ from 'underscore';
+import {Channel, ChannelAuthorizerGenerator, Options} from 'pusher-js/with-encryption';
+import isObject from 'lodash/isObject';
+import {LiteralUnion, ValueOf} from 'type-fest';
import ONYXKEYS from '../../ONYXKEYS';
import Pusher from './library';
import TYPE from './EventType';
import Log from '../Log';
+import DeepValueOf from '../../types/utils/DeepValueOf';
+import {SocketEventName} from './library/types';
+import CONST from '../../CONST';
+import {OnyxUpdateEvent, OnyxUpdatesFromServer} from '../../types/onyx';
+
+type States = {
+ previous: string;
+ current: string;
+};
+
+type Args = {
+ appKey: string;
+ cluster: string;
+ authEndpoint: string;
+};
+
+type PushJSON = OnyxUpdateEvent[] | OnyxUpdatesFromServer;
+
+type EventCallbackError = {type: ValueOf; data: {code: number}};
+
+type ChunkedDataEvents = {chunks: unknown[]; receivedFinal: boolean};
+
+type EventData = {id?: string; chunk?: unknown; final?: boolean; index: number};
+
+type SocketEventCallback = (eventName: SocketEventName, data?: States | EventCallbackError) => void;
+
+type PusherWithAuthParams = InstanceType & {
+ config: {
+ auth?: {
+ params?: unknown;
+ };
+ };
+};
+
+type PusherEventName = LiteralUnion, string>;
+
+type PusherSubscribtionErrorData = {type?: string; error?: string; status?: string};
let shouldForceOffline = false;
Onyx.connect({
@@ -16,33 +55,23 @@ Onyx.connect({
},
});
-let socket;
+let socket: PusherWithAuthParams | null;
let pusherSocketID = '';
-const socketEventCallbacks = [];
-let customAuthorizer;
+const socketEventCallbacks: SocketEventCallback[] = [];
+let customAuthorizer: ChannelAuthorizerGenerator;
/**
* Trigger each of the socket event callbacks with the event information
- *
- * @param {String} eventName
- * @param {*} data
*/
-function callSocketEventCallbacks(eventName, data) {
- _.each(socketEventCallbacks, (cb) => cb(eventName, data));
+function callSocketEventCallbacks(eventName: SocketEventName, data?: EventCallbackError | States) {
+ socketEventCallbacks.forEach((cb) => cb(eventName, data));
}
/**
* Initialize our pusher lib
- *
- * @param {Object} args
- * @param {String} args.appKey
- * @param {String} args.cluster
- * @param {String} args.authEndpoint
- * @param {Object} [params]
- * @public
- * @returns {Promise} resolves when Pusher has connected
+ * @returns resolves when Pusher has connected
*/
-function init(args, params) {
+function init(args: Args, params?: unknown): Promise {
return new Promise((resolve) => {
if (socket) {
return resolve();
@@ -55,7 +84,7 @@ function init(args, params) {
// }
// };
- const options = {
+ const options: Options = {
cluster: args.cluster,
authEndpoint: args.authEndpoint,
};
@@ -65,7 +94,6 @@ function init(args, params) {
}
socket = new Pusher(args.appKey, options);
-
// If we want to pass params in our requests to api.php we'll need to add it to socket.config.auth.params
// as per the documentation
// (https://pusher.com/docs/channels/using_channels/connection#channels-options-parameter).
@@ -77,21 +105,21 @@ function init(args, params) {
}
// Listen for connection errors and log them
- socket.connection.bind('error', (error) => {
+ socket?.connection.bind('error', (error: EventCallbackError) => {
callSocketEventCallbacks('error', error);
});
- socket.connection.bind('connected', () => {
- pusherSocketID = socket.connection.socket_id;
+ socket?.connection.bind('connected', () => {
+ pusherSocketID = socket?.connection.socket_id ?? '';
callSocketEventCallbacks('connected');
resolve();
});
- socket.connection.bind('disconnected', () => {
+ socket?.connection.bind('disconnected', () => {
callSocketEventCallbacks('disconnected');
});
- socket.connection.bind('state_change', (states) => {
+ socket?.connection.bind('state_change', (states: States) => {
callSocketEventCallbacks('state_change', states);
});
});
@@ -99,12 +127,8 @@ function init(args, params) {
/**
* Returns a Pusher channel for a channel name
- *
- * @param {String} channelName
- *
- * @returns {Channel}
*/
-function getChannel(channelName) {
+function getChannel(channelName: string): Channel | undefined {
if (!socket) {
return;
}
@@ -114,19 +138,14 @@ function getChannel(channelName) {
/**
* Binds an event callback to a channel + eventName
- * @param {Pusher.Channel} channel
- * @param {String} eventName
- * @param {Function} [eventCallback]
- *
- * @private
*/
-function bindEventToChannel(channel, eventName, eventCallback = () => {}) {
+function bindEventToChannel(channel: Channel | undefined, eventName: PusherEventName, eventCallback: (data: PushJSON) => void = () => {}) {
if (!eventName) {
return;
}
- const chunkedDataEvents = {};
- const callback = (eventData) => {
+ const chunkedDataEvents: Record = {};
+ const callback = (eventData: string | Record | EventData) => {
if (shouldForceOffline) {
Log.info('[Pusher] Ignoring a Push event because shouldForceOffline = true');
return;
@@ -134,7 +153,7 @@ function bindEventToChannel(channel, eventName, eventCallback = () => {}) {
let data;
try {
- data = _.isObject(eventData) ? eventData : JSON.parse(eventData);
+ data = isObject(eventData) ? eventData : JSON.parse(eventData);
} catch (err) {
Log.alert('[Pusher] Unable to parse single JSON event data from Pusher', {error: err, eventData});
return;
@@ -164,7 +183,7 @@ function bindEventToChannel(channel, eventName, eventCallback = () => {}) {
// Only call the event callback if we've received the last packet and we don't have any holes in the complete
// packet.
- if (chunkedEvent.receivedFinal && chunkedEvent.chunks.length === _.keys(chunkedEvent.chunks).length) {
+ if (chunkedEvent.receivedFinal && chunkedEvent.chunks.length === Object.keys(chunkedEvent.chunks).length) {
try {
eventCallback(JSON.parse(chunkedEvent.chunks.join('')));
} catch (err) {
@@ -181,22 +200,14 @@ function bindEventToChannel(channel, eventName, eventCallback = () => {}) {
}
};
- channel.bind(eventName, callback);
+ channel?.bind(eventName, callback);
}
/**
* Subscribe to a channel and an event
- *
- * @param {String} channelName
- * @param {String} eventName
- * @param {Function} [eventCallback]
- * @param {Function} [onResubscribe] Callback to be called when reconnection happen
- *
- * @return {Promise}
- *
- * @public
+ * @param [onResubscribe] Callback to be called when reconnection happen
*/
-function subscribe(channelName, eventName, eventCallback = () => {}, onResubscribe = () => {}) {
+function subscribe(channelName: string, eventName: PusherEventName, eventCallback: (data: PushJSON) => void = () => {}, onResubscribe = () => {}): Promise {
return new Promise((resolve, reject) => {
// We cannot call subscribe() before init(). Prevent any attempt to do this on dev.
if (!socket) {
@@ -226,7 +237,7 @@ function subscribe(channelName, eventName, eventCallback = () => {}, onResubscri
onResubscribe();
});
- channel.bind('pusher:subscription_error', (data = {}) => {
+ channel.bind('pusher:subscription_error', (data: PusherSubscribtionErrorData = {}) => {
const {type, error, status} = data;
Log.hmmm('[Pusher] Issue authenticating with Pusher during subscribe attempt.', {
channelName,
@@ -245,12 +256,8 @@ function subscribe(channelName, eventName, eventCallback = () => {}, onResubscri
/**
* Unsubscribe from a channel and optionally a specific event
- *
- * @param {String} channelName
- * @param {String} [eventName]
- * @public
*/
-function unsubscribe(channelName, eventName = '') {
+function unsubscribe(channelName: string, eventName: PusherEventName = '') {
const channel = getChannel(channelName);
if (!channel) {
@@ -269,18 +276,14 @@ function unsubscribe(channelName, eventName = '') {
Log.info('[Pusher] Unsubscribing from channel', false, {channelName});
channel.unbind();
- socket.unsubscribe(channelName);
+ socket?.unsubscribe(channelName);
}
}
/**
* Are we already in the process of subscribing to this channel?
- *
- * @param {String} channelName
- *
- * @returns {Boolean}
*/
-function isAlreadySubscribing(channelName) {
+function isAlreadySubscribing(channelName: string): boolean {
if (!socket) {
return false;
}
@@ -291,12 +294,8 @@ function isAlreadySubscribing(channelName) {
/**
* Are we already subscribed to this channel?
- *
- * @param {String} channelName
- *
- * @returns {Boolean}
*/
-function isSubscribed(channelName) {
+function isSubscribed(channelName: string): boolean {
if (!socket) {
return false;
}
@@ -307,12 +306,8 @@ function isSubscribed(channelName) {
/**
* Sends an event over a specific event/channel in pusher.
- *
- * @param {String} channelName
- * @param {String} eventName
- * @param {Object} payload
*/
-function sendEvent(channelName, eventName, payload) {
+function sendEvent(channelName: string, eventName: PusherEventName, payload: Record) {
// Check to see if we are subscribed to this channel before sending the event. Sending client events over channels
// we are not subscribed too will throw errors and cause reconnection attempts. Subscriptions are not instant and
// can happen later than we expect.
@@ -325,15 +320,13 @@ function sendEvent(channelName, eventName, payload) {
return;
}
- socket.send_event(eventName, payload, channelName);
+ socket?.send_event(eventName, payload, channelName);
}
/**
* Register a method that will be triggered when a socket event happens (like disconnecting)
- *
- * @param {Function} cb
*/
-function registerSocketEventCallback(cb) {
+function registerSocketEventCallback(cb: SocketEventCallback) {
socketEventCallbacks.push(cb);
}
@@ -341,10 +334,8 @@ function registerSocketEventCallback(cb) {
* A custom authorizer allows us to take a more fine-grained approach to
* authenticating Pusher. e.g. we can handle failed attempts to authorize
* with an expired authToken and retry the attempt.
- *
- * @param {Function} authorizer
*/
-function registerCustomAuthorizer(authorizer) {
+function registerCustomAuthorizer(authorizer: ChannelAuthorizerGenerator) {
customAuthorizer = authorizer;
}
@@ -376,18 +367,13 @@ function reconnect() {
socket.connect();
}
-/**
- * @returns {String}
- */
-function getPusherSocketID() {
+function getPusherSocketID(): string {
return pusherSocketID;
}
if (window) {
/**
* Pusher socket for debugging purposes
- *
- * @returns {Function}
*/
window.getPusherInstance = () => socket;
}
@@ -407,3 +393,5 @@ export {
TYPE,
getPusherSocketID,
};
+
+export type {EventCallbackError, States, PushJSON};
diff --git a/src/libs/PusherConnectionManager.ts b/src/libs/PusherConnectionManager.ts
index 4ab08d6dc760..9b1f6ebe1b2a 100644
--- a/src/libs/PusherConnectionManager.ts
+++ b/src/libs/PusherConnectionManager.ts
@@ -1,11 +1,10 @@
-import {ValueOf} from 'type-fest';
+import {ChannelAuthorizationCallback} from 'pusher-js/with-encryption';
import * as Pusher from './Pusher/pusher';
import * as Session from './actions/Session';
import Log from './Log';
import CONST from '../CONST';
-
-type EventCallbackError = {type: ValueOf; data: {code: number}};
-type CustomAuthorizerChannel = {name: string};
+import {SocketEventName} from './Pusher/library/types';
+import {EventCallbackError, States} from './Pusher/pusher';
function init() {
/**
@@ -14,30 +13,32 @@ function init() {
* current valid token to generate the signed auth response
* needed to subscribe to Pusher channels.
*/
- Pusher.registerCustomAuthorizer((channel: CustomAuthorizerChannel) => ({
- authorize: (socketID: string, callback: () => void) => {
- Session.authenticatePusher(socketID, channel.name, callback);
+ Pusher.registerCustomAuthorizer((channel) => ({
+ authorize: (socketId: string, callback: ChannelAuthorizationCallback) => {
+ Session.authenticatePusher(socketId, channel.name, callback);
},
}));
- Pusher.registerSocketEventCallback((eventName: string, error: EventCallbackError) => {
+ Pusher.registerSocketEventCallback((eventName: SocketEventName, error?: EventCallbackError | States) => {
switch (eventName) {
case 'error': {
- const errorType = error?.type;
- const code = error?.data?.code;
- if (errorType === CONST.ERROR.PUSHER_ERROR && code === 1006) {
- // 1006 code happens when a websocket connection is closed. There may or may not be a reason attached indicating why the connection was closed.
- // https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.5
- Log.hmmm('[PusherConnectionManager] Channels Error 1006', {error});
- } else if (errorType === CONST.ERROR.PUSHER_ERROR && code === 4201) {
- // This means the connection was closed because Pusher did not receive a reply from the client when it pinged them for a response
- // https://pusher.com/docs/channels/library_auth_reference/pusher-websockets-protocol/#4200-4299
- Log.hmmm('[PusherConnectionManager] Pong reply not received', {error});
- } else if (errorType === CONST.ERROR.WEB_SOCKET_ERROR) {
- // It's not clear why some errors are wrapped in a WebSocketError type - this error could mean different things depending on the contents.
- Log.hmmm('[PusherConnectionManager] WebSocketError', {error});
- } else {
- Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} [PusherConnectionManager] Unknown error event`, {error});
+ if (error && 'type' in error) {
+ const errorType = error?.type;
+ const code = error?.data?.code;
+ if (errorType === CONST.ERROR.PUSHER_ERROR && code === 1006) {
+ // 1006 code happens when a websocket connection is closed. There may or may not be a reason attached indicating why the connection was closed.
+ // https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.5
+ Log.hmmm('[PusherConnectionManager] Channels Error 1006', {error});
+ } else if (errorType === CONST.ERROR.PUSHER_ERROR && code === 4201) {
+ // This means the connection was closed because Pusher did not receive a reply from the client when it pinged them for a response
+ // https://pusher.com/docs/channels/library_auth_reference/pusher-websockets-protocol/#4200-4299
+ Log.hmmm('[PusherConnectionManager] Pong reply not received', {error});
+ } else if (errorType === CONST.ERROR.WEB_SOCKET_ERROR) {
+ // It's not clear why some errors are wrapped in a WebSocketError type - this error could mean different things depending on the contents.
+ Log.hmmm('[PusherConnectionManager] WebSocketError', {error});
+ } else {
+ Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} [PusherConnectionManager] Unknown error event`, {error});
+ }
}
break;
}
diff --git a/src/libs/PusherUtils.ts b/src/libs/PusherUtils.ts
index 5baa4b68d5f8..d47283f21bbf 100644
--- a/src/libs/PusherUtils.ts
+++ b/src/libs/PusherUtils.ts
@@ -4,9 +4,7 @@ import Log from './Log';
import NetworkConnection from './NetworkConnection';
import * as Pusher from './Pusher/pusher';
import CONST from '../CONST';
-import {OnyxUpdateEvent, OnyxUpdatesFromServer} from '../types/onyx';
-
-type PushJSON = OnyxUpdateEvent[] | OnyxUpdatesFromServer;
+import {PushJSON} from './Pusher/pusher';
type Callback = (data: OnyxUpdate[]) => Promise;
diff --git a/src/libs/ReportActionComposeFocusManager.ts b/src/libs/ReportActionComposeFocusManager.ts
index ca4f9d77898b..65466fa4a204 100644
--- a/src/libs/ReportActionComposeFocusManager.ts
+++ b/src/libs/ReportActionComposeFocusManager.ts
@@ -1,5 +1,7 @@
import React from 'react';
import {TextInput} from 'react-native';
+import ROUTES from '../ROUTES';
+import Navigation from './Navigation/Navigation';
type FocusCallback = () => void;
@@ -28,6 +30,11 @@ function onComposerFocus(callback: FocusCallback, isMainComposer = false) {
* Request focus on the ReportActionComposer
*/
function focus() {
+ /** 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;
+ }
+
if (typeof focusCallback !== 'function') {
if (typeof mainComposerFocusCallback !== 'function') {
return;
diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js
index 258582d9f653..fa1883dd9b98 100644
--- a/src/libs/ReportActionsUtils.js
+++ b/src/libs/ReportActionsUtils.js
@@ -68,6 +68,14 @@ function isDeletedParentAction(reportAction) {
return lodashGet(reportAction, ['message', 0, 'isDeletedParentAction'], false) && lodashGet(reportAction, 'childVisibleActionCount', 0) > 0;
}
+/**
+ * @param {Object} reportAction
+ * @returns {Boolean}
+ */
+function isReversedTransaction(reportAction) {
+ return lodashGet(reportAction, ['message', 0, 'isReversedTransaction'], false) && lodashGet(reportAction, 'childVisibleActionCount', 0) > 0;
+}
+
/**
* @param {Object} reportAction
* @returns {Boolean}
@@ -352,7 +360,7 @@ function shouldReportActionBeVisible(reportAction, key) {
// All other actions are displayed except thread parents, deleted, or non-pending actions
const isDeleted = isDeletedAction(reportAction);
const isPending = !!reportAction.pendingAction;
- return !isDeleted || isPending || isDeletedParentAction(reportAction);
+ return !isDeleted || isPending || isDeletedParentAction(reportAction) || isReversedTransaction(reportAction);
}
/**
@@ -673,6 +681,7 @@ export {
isTransactionThread,
isSentMoneyReportAction,
isDeletedParentAction,
+ isReversedTransaction,
isReportPreviewAction,
isModifiedExpenseAction,
getIOUReportIDFromReportActionPreview,
diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js
index c0b8b5620bfa..0b7bbfd61461 100644
--- a/src/libs/ReportUtils.js
+++ b/src/libs/ReportUtils.js
@@ -1267,6 +1267,17 @@ function isWaitingForTaskCompleteFromAssignee(report, parentReportAction = {}) {
return isTaskReport(report) && isReportManager(report) && isOpenTaskReport(report, parentReportAction);
}
+/**
+ * Returns number of transactions that are nonReimbursable
+ *
+ * @param {Object|null} iouReportID
+ * @returns {Number}
+ */
+function hasNonReimbursableTransactions(iouReportID) {
+ const allTransactions = TransactionUtils.getAllReportTransactions(iouReportID);
+ return _.filter(allTransactions, (transaction) => transaction.reimbursable === false).length > 0;
+}
+
/**
* @param {Object} report
* @param {Object} allReportsDict
@@ -1344,6 +1355,10 @@ function getMoneyRequestReportName(report, policy = undefined) {
return `${payerPaidAmountMesssage} • ${Localize.translateLocal('iou.pending')}`;
}
+ if (hasNonReimbursableTransactions(report.reportID)) {
+ return Localize.translateLocal('iou.payerSpentAmount', {payer: payerName, amount: formattedAmount});
+ }
+
if (report.hasOutstandingIOU) {
return Localize.translateLocal('iou.payerOwesAmount', {payer: payerName, amount: formattedAmount});
}
@@ -1373,6 +1388,8 @@ function getTransactionDetails(transaction, createdDateFormat = CONST.DATE.FNS_F
tag: TransactionUtils.getTag(transaction),
mccGroup: TransactionUtils.getMCCGroup(transaction),
cardID: TransactionUtils.getCardID(transaction),
+ originalAmount: TransactionUtils.getOriginalAmount(transaction),
+ originalCurrency: TransactionUtils.getOriginalCurrency(transaction),
};
}
@@ -1477,7 +1494,11 @@ function hasMissingSmartscanFields(iouReportID) {
* @returns {String}
*/
function getTransactionReportName(reportAction) {
- if (ReportActionsUtils.isDeletedParentAction(reportAction)) {
+ if (ReportActionsUtils.isReversedTransaction(reportAction)) {
+ return Localize.translateLocal('parentReportAction.reversedTransaction');
+ }
+
+ if (ReportActionsUtils.isDeletedAction(reportAction)) {
return Localize.translateLocal('parentReportAction.deletedRequest');
}
@@ -1515,6 +1536,20 @@ function getReportPreviewMessage(report, reportAction = {}, shouldConsiderReceip
return reportActionMessage;
}
+ if (!isIOUReport(report) && ReportActionsUtils.isSplitBillAction(reportAction)) {
+ // This covers group chats where the last action is a split bill action
+ const linkedTransaction = TransactionUtils.getLinkedTransaction(reportAction);
+ if (_.isEmpty(linkedTransaction)) {
+ return reportActionMessage;
+ }
+ if (TransactionUtils.isReceiptBeingScanned(linkedTransaction)) {
+ return Localize.translateLocal('iou.receiptScanning');
+ }
+ const {amount, currency, comment} = getTransactionDetails(linkedTransaction);
+ const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency);
+ return Localize.translateLocal('iou.didSplitAmount', {formattedAmount, comment});
+ }
+
const totalAmount = getMoneyRequestTotal(report);
const payerName = isExpenseReport(report) ? getPolicyName(report) : getDisplayNameForParticipant(report.managerID, true);
const formattedAmount = CurrencyUtils.convertToDisplayString(totalAmount, report.currency);
@@ -1548,7 +1583,8 @@ function getReportPreviewMessage(report, reportAction = {}, shouldConsiderReceip
return Localize.translateLocal('iou.waitingOnBankAccount', {submitterDisplayName});
}
- return Localize.translateLocal('iou.payerOwesAmount', {payer: payerName, amount: formattedAmount});
+ const containsNonReimbursable = hasNonReimbursableTransactions(report.reportID);
+ return Localize.translateLocal(containsNonReimbursable ? 'iou.payerSpentAmount' : 'iou.payerOwesAmount', {payer: payerName, amount: formattedAmount});
}
/**
@@ -2121,6 +2157,7 @@ function buildOptimisticIOUReport(payeeAccountID, payerAccountID, total, chatRep
reportID: generateReportID(),
state: CONST.REPORT.STATE.SUBMITTED,
stateNum: isSendingMoney ? CONST.REPORT.STATE_NUM.SUBMITTED : CONST.REPORT.STATE_NUM.PROCESSING,
+ statusNum: isSendingMoney ? CONST.REPORT.STATUS.REIMBURSED : CONST.REPORT.STATE_NUM.PROCESSING,
total,
// We don't translate reportName because the server response is always in English
@@ -2576,6 +2613,7 @@ function buildOptimisticTaskReportAction(taskReportID, actionName, message = '')
* @param {String} notificationPreference
* @param {String} parentReportActionID
* @param {String} parentReportID
+ * @param {String} welcomeMessage
* @returns {Object}
*/
function buildOptimisticChatReport(
@@ -2591,6 +2629,7 @@ function buildOptimisticChatReport(
notificationPreference = CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS,
parentReportActionID = '',
parentReportID = '',
+ welcomeMessage = '',
) {
const currentTime = DateUtils.getDBTime();
return {
@@ -2617,7 +2656,7 @@ function buildOptimisticChatReport(
stateNum: 0,
statusNum: 0,
visibility,
- welcomeMessage: '',
+ welcomeMessage,
writeCapability,
};
}
@@ -2832,6 +2871,7 @@ function buildOptimisticTaskReport(ownerAccountID, assigneeAccountID = 0, parent
policyID,
stateNum: CONST.REPORT.STATE_NUM.OPEN,
statusNum: CONST.REPORT.STATUS.OPEN,
+ notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS,
};
}
@@ -3395,8 +3435,12 @@ function getMoneyRequestOptions(report, reportParticipants) {
// User created policy rooms and default rooms like #admins or #announce will always have the Split Bill option
// unless there are no participants at all (e.g. #admins room for a policy with only 1 admin)
// DM chats will have the Split Bill option only when there are at least 3 people in the chat.
- // There is no Split Bill option for Workspace chats, IOU or Expense reports which are threads
- if ((isChatRoom(report) && participants.length > 0) || (hasMultipleParticipants && !isPolicyExpenseChat(report) && !isMoneyRequestReport(report)) || isControlPolicyExpenseChat(report)) {
+ // There is no Split Bill option for IOU or Expense reports which are threads
+ if (
+ (isChatRoom(report) && participants.length > 0) ||
+ (hasMultipleParticipants && !isPolicyExpenseChat(report) && !isMoneyRequestReport(report)) ||
+ (isControlPolicyExpenseChat(report) && report.isOwnPolicyExpenseChat)
+ ) {
return [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT];
}
@@ -3552,7 +3596,8 @@ function shouldDisableWriteActions(report) {
* @returns {String}
*/
function getOriginalReportID(reportID, reportAction) {
- return isThreadFirstChat(reportAction, reportID) ? lodashGet(allReports, [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, 'parentReportID']) : reportID;
+ const currentReportAction = ReportActionsUtils.getReportAction(reportID, reportAction.reportActionID);
+ return isThreadFirstChat(reportAction, reportID) && _.isEmpty(currentReportAction) ? lodashGet(allReports, [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, 'parentReportID']) : reportID;
}
/**
@@ -3967,6 +4012,7 @@ export {
areAllRequestsBeingSmartScanned,
getReportPreviewDisplayTransactions,
getTransactionsWithReceipts,
+ hasNonReimbursableTransactions,
hasMissingSmartscanFields,
getIOUReportActionDisplayMessage,
isWaitingForTaskCompleteFromAssignee,
diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js
index 7a32db660021..314a1d63760e 100644
--- a/src/libs/SidebarUtils.js
+++ b/src/libs/SidebarUtils.js
@@ -347,17 +347,17 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale,
if ((result.isChatRoom || result.isPolicyExpenseChat || result.isThread || result.isTaskReport) && !result.isArchivedRoom) {
const lastAction = visibleReportActionItems[report.reportID];
- if (lodashGet(lastAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.RENAMED) {
+ if (lastAction && lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) {
const newName = lodashGet(lastAction, 'originalMessage.newName', '');
result.alternateText = Localize.translate(preferredLocale, 'newRoomPage.roomRenamedTo', {newName});
- } else if (lodashGet(lastAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED) {
+ } else if (lastAction && lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED) {
result.alternateText = `${Localize.translate(preferredLocale, 'task.messages.reopened')}`;
- } else if (lodashGet(lastAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED) {
+ } else if (lastAction && lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED) {
result.alternateText = `${Localize.translate(preferredLocale, 'task.messages.completed')}`;
- } else if (lodashGet(lastAction, 'actionName', '') !== CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && lastActorDisplayName && lastMessageTextFromReport) {
+ } else if (lastAction && lastAction.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && lastActorDisplayName && lastMessageTextFromReport) {
result.alternateText = `${lastActorDisplayName}: ${lastMessageText}`;
} else {
- result.alternateText = lastMessageTextFromReport.length > 0 ? lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet');
+ result.alternateText = lastAction && lastMessageTextFromReport.length > 0 ? lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet');
}
} else {
if (!lastMessageText) {
diff --git a/src/libs/SuggestionUtils.js b/src/libs/SuggestionUtils.js
index aa2640d006c8..9c3e92799334 100644
--- a/src/libs/SuggestionUtils.js
+++ b/src/libs/SuggestionUtils.js
@@ -26,4 +26,22 @@ function trimLeadingSpace(str) {
return str.slice(0, 1) === ' ' ? str.slice(1) : str;
}
-export {getMaxArrowIndex, trimLeadingSpace};
+/**
+ * Checks if space is available to render large suggestion menu
+ * @param {Number} listHeight
+ * @param {Number} composerHeight
+ * @param {Number} totalSuggestions
+ * @returns {Boolean}
+ */
+function hasEnoughSpaceForLargeSuggestionMenu(listHeight, composerHeight, totalSuggestions) {
+ const maxSuggestions = CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER;
+ const chatFooterHeight = CONST.CHAT_FOOTER_SECONDARY_ROW_HEIGHT + 2 * CONST.CHAT_FOOTER_SECONDARY_ROW_PADDING;
+ const availableHeight = listHeight - composerHeight - chatFooterHeight;
+ const menuHeight =
+ (!totalSuggestions || totalSuggestions > maxSuggestions ? maxSuggestions : totalSuggestions) * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT +
+ CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_INNER_PADDING * 2;
+
+ return availableHeight > menuHeight;
+}
+
+export {getMaxArrowIndex, trimLeadingSpace, hasEnoughSpaceForLargeSuggestionMenu};
diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts
index 2b7729abc442..77fc4f04f99d 100644
--- a/src/libs/TransactionUtils.ts
+++ b/src/libs/TransactionUtils.ts
@@ -59,16 +59,13 @@ function buildOptimisticTransaction(
commentJSON.originalTransactionID = originalTransactionID;
}
- // For the SmartScan to run successfully, we need to pass the merchant field empty to the API
- const defaultMerchant = !receipt || Object.keys(receipt).length === 0 ? CONST.TRANSACTION.DEFAULT_MERCHANT : '';
-
return {
transactionID,
amount,
currency,
reportID,
comment: commentJSON,
- merchant: merchant || defaultMerchant,
+ merchant: merchant || CONST.TRANSACTION.DEFAULT_MERCHANT,
created: created || DateUtils.getDBTime(),
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
receipt,
@@ -83,21 +80,35 @@ function hasReceipt(transaction: Transaction | undefined | null): boolean {
return !!transaction?.receipt?.state;
}
-function areRequiredFieldsEmpty(transaction: Transaction): boolean {
- return (
+function isMerchantMissing(transaction: Transaction) {
+ const isMerchantEmpty =
+ transaction.merchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || transaction.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || transaction.merchant === '';
+
+ const isModifiedMerchantEmpty =
+ !transaction.modifiedMerchant ||
transaction.modifiedMerchant === CONST.TRANSACTION.UNKNOWN_MERCHANT ||
transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT ||
- (transaction.modifiedMerchant === '' &&
- (transaction.merchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || transaction.merchant === '' || transaction.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT)) ||
- (transaction.modifiedAmount === 0 && transaction.amount === 0) ||
- (transaction.modifiedCreated === '' && transaction.created === '')
- );
+ transaction.modifiedMerchant === '';
+
+ return isMerchantEmpty && isModifiedMerchantEmpty;
+}
+
+function isAmountMissing(transaction: Transaction) {
+ return transaction.amount === 0 && (!transaction.modifiedAmount || transaction.modifiedAmount === 0);
+}
+
+function isCreatedMissing(transaction: Transaction) {
+ return transaction.created === '' && (!transaction.created || transaction.modifiedCreated === '');
+}
+
+function areRequiredFieldsEmpty(transaction: Transaction): boolean {
+ return isMerchantMissing(transaction) || isAmountMissing(transaction) || isCreatedMissing(transaction);
}
/**
* Given the edit made to the money request, return an updated transaction object.
*/
-function getUpdatedTransaction(transaction: Transaction, transactionChanges: TransactionChanges, isFromExpenseReport: boolean): Transaction {
+function getUpdatedTransaction(transaction: Transaction, transactionChanges: TransactionChanges, isFromExpenseReport: boolean, shouldUpdateReceiptState = true): Transaction {
// Only changing the first level fields so no need for deep clone now
const updatedTransaction = {...transaction};
let shouldStopSmartscan = false;
@@ -144,7 +155,13 @@ function getUpdatedTransaction(transaction: Transaction, transactionChanges: Tra
updatedTransaction.tag = transactionChanges.tag;
}
- if (shouldStopSmartscan && transaction?.receipt && Object.keys(transaction.receipt).length > 0 && transaction?.receipt?.state !== CONST.IOU.RECEIPT_STATE.OPEN) {
+ if (
+ shouldUpdateReceiptState &&
+ shouldStopSmartscan &&
+ transaction?.receipt &&
+ Object.keys(transaction.receipt).length > 0 &&
+ transaction?.receipt?.state !== CONST.IOU.RECEIPT_STATE.OPEN
+ ) {
updatedTransaction.receipt.state = CONST.IOU.RECEIPT_STATE.OPEN;
}
@@ -217,6 +234,21 @@ function getCurrency(transaction: Transaction): string {
return transaction?.currency ?? CONST.CURRENCY.USD;
}
+/**
+ * Return the original currency field from the transaction.
+ */
+function getOriginalCurrency(transaction: Transaction): string {
+ return transaction?.originalCurrency ?? '';
+}
+
+/**
+ * Return the absolute value of the original amount field from the transaction.
+ */
+function getOriginalAmount(transaction: Transaction): number {
+ const amount = transaction?.originalAmount ?? 0;
+ return Math.abs(amount);
+}
+
/**
* Return the merchant field from the transaction, return the modifiedMerchant if present.
*/
@@ -285,6 +317,9 @@ function isDistanceRequest(transaction: Transaction): boolean {
return type === CONST.TRANSACTION.TYPE.CUSTOM_UNIT && customUnitName === CONST.CUSTOM_UNITS.NAME_DISTANCE;
}
+/**
+ * Determine whether a transaction is made with an Expensify card.
+ */
function isExpensifyCardTransaction(transaction: Transaction): boolean {
if (!transaction.cardID) {
return false;
@@ -292,6 +327,9 @@ function isExpensifyCardTransaction(transaction: Transaction): boolean {
return isExpensifyCard(transaction.cardID);
}
+/**
+ * Check if the transaction status is set to Pending.
+ */
function isPending(transaction: Transaction): boolean {
if (!transaction.status) {
return false;
@@ -299,6 +337,9 @@ function isPending(transaction: Transaction): boolean {
return transaction.status === CONST.TRANSACTION.STATUS.PENDING;
}
+/**
+ * Check if the transaction status is set to Posted.
+ */
function isPosted(transaction: Transaction): boolean {
if (!transaction.status) {
return false;
@@ -420,6 +461,8 @@ export {
getAmount,
getCurrency,
getCardID,
+ getOriginalCurrency,
+ getOriginalAmount,
getMerchant,
getMCCGroup,
getCreated,
@@ -438,6 +481,10 @@ export {
isPending,
isPosted,
getWaypoints,
+ isAmountMissing,
+ isMerchantMissing,
+ isCreatedMissing,
+ areRequiredFieldsEmpty,
hasMissingSmartscanFields,
getWaypointIndex,
waypointHasValidAddress,
diff --git a/src/libs/UnreadIndicatorUpdater/updateUnread/index.website.js b/src/libs/UnreadIndicatorUpdater/updateUnread/index.website.js
index 244eaf805d10..4c829239ef14 100644
--- a/src/libs/UnreadIndicatorUpdater/updateUnread/index.website.js
+++ b/src/libs/UnreadIndicatorUpdater/updateUnread/index.website.js
@@ -3,6 +3,7 @@
*/
import CONFIG from '../../../CONFIG';
+let unreadTotalCount = 0;
/**
* Set the page title on web
*
@@ -10,7 +11,7 @@ import CONFIG from '../../../CONFIG';
*/
function updateUnread(totalCount) {
const hasUnread = totalCount !== 0;
-
+ unreadTotalCount = totalCount;
// This setTimeout is required because due to how react rendering messes with the DOM, the document title can't be modified synchronously, and we must wait until all JS is done
// running before setting the title.
setTimeout(() => {
@@ -22,4 +23,8 @@ function updateUnread(totalCount) {
}, 0);
}
+window.addEventListener('popstate', () => {
+ updateUnread(unreadTotalCount);
+});
+
export default updateUnread;
diff --git a/src/libs/actions/App.js b/src/libs/actions/App.js
index 7500af6d829e..2dbc1001f068 100644
--- a/src/libs/actions/App.js
+++ b/src/libs/actions/App.js
@@ -44,6 +44,19 @@ Onyx.connect({
callback: (val) => (preferredLocale = val),
});
+let priorityMode;
+Onyx.connect({
+ key: ONYXKEYS.NVP_PRIORITY_MODE,
+ callback: (nextPriorityMode) => {
+ // When someone switches their priority mode we need to fetch all their chats because only #focus mode works with a subset of a user's chats. This is only possible via the OpenApp command.
+ if (nextPriorityMode === CONST.PRIORITY_MODE.DEFAULT && priorityMode === CONST.PRIORITY_MODE.GSD) {
+ // eslint-disable-next-line no-use-before-define
+ openApp();
+ }
+ priorityMode = nextPriorityMode;
+ },
+});
+
let resolveIsReadyPromise;
const isReadyToOpenApp = new Promise((resolve) => {
resolveIsReadyPromise = resolve;
@@ -207,7 +220,8 @@ function getOnyxDataForOpenOrReconnect(isOpenApp = false) {
*/
function openApp() {
getPolicyParamsForOpenOrReconnect().then((policyParams) => {
- API.read('OpenApp', policyParams, getOnyxDataForOpenOrReconnect(true));
+ const params = {enablePriorityModeFilter: true, ...policyParams};
+ API.read('OpenApp', params, getOnyxDataForOpenOrReconnect(true));
});
}
@@ -336,6 +350,40 @@ function createWorkspaceAndNavigateToIt(policyOwnerEmail = '', makeMeAdmin = fal
.then(endSignOnTransition);
}
+/**
+ * Create a new draft workspace and navigate to it
+ *
+ * @param {String} [policyOwnerEmail] Optional, the email of the account to make the owner of the policy
+ * @param {String} [policyName] Optional, custom policy name we will use for created workspace
+ * @param {Boolean} [transitionFromOldDot] Optional, if the user is transitioning from old dot
+ */
+function createWorkspaceWithPolicyDraftAndNavigateToIt(policyOwnerEmail = '', policyName = '', transitionFromOldDot = false) {
+ const policyID = Policy.generatePolicyID();
+ Policy.createDraftInitialWorkspace(policyOwnerEmail, policyName, policyID);
+
+ Navigation.isNavigationReady()
+ .then(() => {
+ if (transitionFromOldDot) {
+ // We must call goBack() to remove the /transition route from history
+ Navigation.goBack(ROUTES.HOME);
+ }
+ Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(policyID));
+ })
+ .then(endSignOnTransition);
+}
+
+/**
+ * Create a new workspace and delete the draft
+ *
+ * @param {String} [policyID] the ID of the policy to use
+ * @param {String} [policyName] custom policy name we will use for created workspace
+ * @param {String} [policyOwnerEmail] Optional, the email of the account to make the owner of the policy
+ * @param {Boolean} [makeMeAdmin] Optional, leave the calling account as an admin on the policy
+ */
+function savePolicyDraftByNewWorkspace(policyID, policyName, policyOwnerEmail = '', makeMeAdmin = false) {
+ Policy.createWorkspace(policyOwnerEmail, makeMeAdmin, policyName, policyID);
+}
+
/**
* This action runs when the Navigator is ready and the current route changes
*
@@ -513,4 +561,6 @@ export {
createWorkspaceAndNavigateToIt,
getMissingOnyxUpdates,
finalReconnectAppAfterActivatingReliableUpdates,
+ savePolicyDraftByNewWorkspace,
+ createWorkspaceWithPolicyDraftAndNavigateToIt,
};
diff --git a/src/libs/actions/BankAccounts.js b/src/libs/actions/BankAccounts.js
deleted file mode 100644
index b1cb09a8a5e2..000000000000
--- a/src/libs/actions/BankAccounts.js
+++ /dev/null
@@ -1,441 +0,0 @@
-import Onyx from 'react-native-onyx';
-import CONST from '../../CONST';
-import * as API from '../API';
-import ONYXKEYS from '../../ONYXKEYS';
-import * as ErrorUtils from '../ErrorUtils';
-import * as PlaidDataProps from '../../pages/ReimbursementAccount/plaidDataPropTypes';
-import Navigation from '../Navigation/Navigation';
-import ROUTES from '../../ROUTES';
-import * as ReimbursementAccount from './ReimbursementAccount';
-
-export {
- goToWithdrawalAccountSetupStep,
- setBankAccountFormValidationErrors,
- resetReimbursementAccount,
- resetFreePlanBankAccount,
- hideBankAccountErrors,
- setWorkspaceIDForReimbursementAccount,
- setBankAccountSubStep,
- updateReimbursementAccountDraft,
- requestResetFreePlanBankAccount,
- cancelResetFreePlanBankAccount,
-} from './ReimbursementAccount';
-export {openPlaidBankAccountSelector, openPlaidBankLogin} from './Plaid';
-export {openOnfidoFlow, answerQuestionsForWallet, verifyIdentity, acceptWalletTerms} from './Wallet';
-
-function clearPlaid() {
- Onyx.set(ONYXKEYS.PLAID_LINK_TOKEN, '');
-
- return Onyx.set(ONYXKEYS.PLAID_DATA, PlaidDataProps.plaidDataDefaultProps);
-}
-
-function openPlaidView() {
- clearPlaid().then(() => ReimbursementAccount.setBankAccountSubStep(CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID));
-}
-
-/**
- * Open the personal bank account setup flow, with an optional exitReportID to redirect to once the flow is finished.
- * @param {String} exitReportID
- */
-function openPersonalBankAccountSetupView(exitReportID) {
- clearPlaid().then(() => {
- if (exitReportID) {
- Onyx.merge(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {exitReportID});
- }
- Navigation.navigate(ROUTES.SETTINGS_ADD_BANK_ACCOUNT);
- });
-}
-
-function clearPersonalBankAccount() {
- clearPlaid();
- Onyx.set(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {});
-}
-
-function clearOnfidoToken() {
- Onyx.merge(ONYXKEYS.ONFIDO_TOKEN, '');
-}
-
-/**
- * Helper method to build the Onyx data required during setup of a Verified Business Bank Account
- *
- * @returns {Object}
- */
-function getVBBADataForOnyx() {
- return {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- value: {
- isLoading: true,
- errors: null,
- },
- },
- ],
- successData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- value: {
- isLoading: false,
- errors: null,
- },
- },
- ],
- failureData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- value: {
- isLoading: false,
- errors: ErrorUtils.getMicroSecondOnyxError('walletPage.addBankAccountFailure'),
- },
- },
- ],
- };
-}
-
-/**
- * Submit Bank Account step with Plaid data so php can perform some checks.
- *
- * @param {Number} bankAccountID
- * @param {Object} selectedPlaidBankAccount
- */
-function connectBankAccountWithPlaid(bankAccountID, selectedPlaidBankAccount) {
- const commandName = 'ConnectBankAccountWithPlaid';
-
- const parameters = {
- bankAccountID,
- routingNumber: selectedPlaidBankAccount.routingNumber,
- accountNumber: selectedPlaidBankAccount.accountNumber,
- bank: selectedPlaidBankAccount.bankName,
- plaidAccountID: selectedPlaidBankAccount.plaidAccountID,
- plaidAccessToken: selectedPlaidBankAccount.plaidAccessToken,
- };
-
- API.write(commandName, parameters, getVBBADataForOnyx());
-}
-
-/**
- * Adds a bank account via Plaid
- *
- * @param {Object} account
- * @TODO offline pattern for this command will have to be added later once the pattern B design doc is complete
- */
-function addPersonalBankAccount(account) {
- const commandName = 'AddPersonalBankAccount';
-
- const parameters = {
- addressName: account.addressName,
- routingNumber: account.routingNumber,
- accountNumber: account.accountNumber,
- isSavings: account.isSavings,
- setupType: 'plaid',
- bank: account.bankName,
- plaidAccountID: account.plaidAccountID,
- plaidAccessToken: account.plaidAccessToken,
- };
-
- const onyxData = {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.PERSONAL_BANK_ACCOUNT,
- value: {
- isLoading: true,
- errors: null,
- plaidAccountID: account.plaidAccountID,
- },
- },
- ],
- successData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.PERSONAL_BANK_ACCOUNT,
- value: {
- isLoading: false,
- errors: null,
- shouldShowSuccess: true,
- },
- },
- ],
- failureData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.PERSONAL_BANK_ACCOUNT,
- value: {
- isLoading: false,
- errors: ErrorUtils.getMicroSecondOnyxError('walletPage.addBankAccountFailure'),
- },
- },
- ],
- };
-
- API.write(commandName, parameters, onyxData);
-}
-
-function deletePaymentBankAccount(bankAccountID) {
- API.write(
- 'DeletePaymentBankAccount',
- {
- bankAccountID,
- },
- {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.BANK_ACCOUNT_LIST}`,
- value: {[bankAccountID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}},
- },
- ],
-
- // Sometimes pusher updates aren't received when we close the App while still offline,
- // so we are setting the bankAccount to null here to ensure that it gets cleared out once we come back online.
- successData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.BANK_ACCOUNT_LIST}`,
- value: {[bankAccountID]: null},
- },
- ],
- },
- );
-}
-
-/**
- * Update the user's personal information on the bank account in database.
- *
- * This action is called by the requestor step in the Verified Bank Account flow
- *
- * @param {Object} params
- *
- * @param {String} [params.dob]
- * @param {String} [params.firstName]
- * @param {String} [params.lastName]
- * @param {String} [params.requestorAddressStreet]
- * @param {String} [params.requestorAddressCity]
- * @param {String} [params.requestorAddressState]
- * @param {String} [params.requestorAddressZipCode]
- * @param {String} [params.ssnLast4]
- * @param {String} [params.isControllingOfficer]
- * @param {Object} [params.onfidoData]
- * @param {Boolean} [params.isOnfidoSetupComplete]
- */
-function updatePersonalInformationForBankAccount(params) {
- API.write('UpdatePersonalInformationForBankAccount', params, getVBBADataForOnyx());
-}
-
-/**
- * @param {Number} bankAccountID
- * @param {String} validateCode
- */
-function validateBankAccount(bankAccountID, validateCode) {
- API.write(
- 'ValidateBankAccountWithTransactions',
- {
- bankAccountID,
- validateCode,
- },
- {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- value: {
- isLoading: true,
- errors: null,
- },
- },
- ],
- successData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- value: {
- isLoading: false,
- },
- },
- ],
- failureData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- value: {
- isLoading: false,
- },
- },
- ],
- },
- );
-}
-
-function openReimbursementAccountPage(stepToOpen, subStep, localCurrentStep) {
- const onyxData = {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- value: {
- isLoading: true,
- },
- },
- ],
- successData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- value: {
- isLoading: false,
- },
- },
- ],
- failureData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- value: {
- isLoading: false,
- },
- },
- ],
- };
-
- const param = {
- stepToOpen,
- subStep,
- localCurrentStep,
- };
-
- return API.read('OpenReimbursementAccountPage', param, onyxData);
-}
-
-/**
- * Updates the bank account in the database with the company step data
- *
- * @param {Object} bankAccount
- * @param {Number} [bankAccount.bankAccountID]
- *
- * Fields from BankAccount step
- * @param {String} [bankAccount.routingNumber]
- * @param {String} [bankAccount.accountNumber]
- * @param {String} [bankAccount.bankName]
- * @param {String} [bankAccount.plaidAccountID]
- * @param {String} [bankAccount.plaidAccessToken]
- * @param {Boolean} [bankAccount.isSavings]
- *
- * Fields from Company step
- * @param {String} [bankAccount.companyName]
- * @param {String} [bankAccount.addressStreet]
- * @param {String} [bankAccount.addressCity]
- * @param {String} [bankAccount.addressState]
- * @param {String} [bankAccount.addressZipCode]
- * @param {String} [bankAccount.companyPhone]
- * @param {String} [bankAccount.website]
- * @param {String} [bankAccount.companyTaxID]
- * @param {String} [bankAccount.incorporationType]
- * @param {String} [bankAccount.incorporationState]
- * @param {String} [bankAccount.incorporationDate]
- * @param {Boolean} [bankAccount.hasNoConnectionToCannabis]
- * @param {String} policyID
- */
-function updateCompanyInformationForBankAccount(bankAccount, policyID) {
- API.write('UpdateCompanyInformationForBankAccount', {...bankAccount, policyID}, getVBBADataForOnyx());
-}
-
-/**
- * Add beneficial owners for the bank account, accept the ACH terms and conditions and verify the accuracy of the information provided
- *
- * @param {Object} params
- *
- * // ACH Contract Step
- * @param {Boolean} [params.ownsMoreThan25Percent]
- * @param {Boolean} [params.hasOtherBeneficialOwners]
- * @param {Boolean} [params.acceptTermsAndConditions]
- * @param {Boolean} [params.certifyTrueInformation]
- * @param {String} [params.beneficialOwners]
- */
-function updateBeneficialOwnersForBankAccount(params) {
- API.write('UpdateBeneficialOwnersForBankAccount', {...params}, getVBBADataForOnyx());
-}
-
-/**
- * Create the bank account with manually entered data.
- *
- * @param {number} [bankAccountID]
- * @param {String} [accountNumber]
- * @param {String} [routingNumber]
- * @param {String} [plaidMask]
- *
- */
-function connectBankAccountManually(bankAccountID, accountNumber, routingNumber, plaidMask) {
- API.write(
- 'ConnectBankAccountManually',
- {
- bankAccountID,
- accountNumber,
- routingNumber,
- plaidMask,
- },
- getVBBADataForOnyx(),
- );
-}
-
-/**
- * Verify the user's identity via Onfido
- *
- * @param {Number} bankAccountID
- * @param {Object} onfidoData
- */
-function verifyIdentityForBankAccount(bankAccountID, onfidoData) {
- API.write(
- 'VerifyIdentityForBankAccount',
- {
- bankAccountID,
- onfidoData: JSON.stringify(onfidoData),
- },
- getVBBADataForOnyx(),
- );
-}
-
-function openWorkspaceView() {
- API.read('OpenWorkspaceView');
-}
-
-function handlePlaidError(bankAccountID, error, error_description, plaidRequestID) {
- API.write('BankAccount_HandlePlaidError', {
- bankAccountID,
- error,
- error_description,
- plaidRequestID,
- });
-}
-
-/**
- * Set the reimbursement account loading so that it happens right away, instead of when the API command is processed.
- *
- * @param {Boolean} isLoading
- */
-function setReimbursementAccountLoading(isLoading) {
- Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {isLoading});
-}
-
-export {
- addPersonalBankAccount,
- clearOnfidoToken,
- clearPersonalBankAccount,
- clearPlaid,
- openPlaidView,
- connectBankAccountManually,
- connectBankAccountWithPlaid,
- deletePaymentBankAccount,
- handlePlaidError,
- openPersonalBankAccountSetupView,
- openReimbursementAccountPage,
- updateBeneficialOwnersForBankAccount,
- updateCompanyInformationForBankAccount,
- updatePersonalInformationForBankAccount,
- openWorkspaceView,
- validateBankAccount,
- verifyIdentityForBankAccount,
- setReimbursementAccountLoading,
-};
diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts
new file mode 100644
index 000000000000..249d7de9293a
--- /dev/null
+++ b/src/libs/actions/BankAccounts.ts
@@ -0,0 +1,444 @@
+import Onyx from 'react-native-onyx';
+import CONST from '../../CONST';
+import * as API from '../API';
+import ONYXKEYS from '../../ONYXKEYS';
+import * as ErrorUtils from '../ErrorUtils';
+import * as PlaidDataProps from '../../pages/ReimbursementAccount/plaidDataPropTypes';
+import Navigation from '../Navigation/Navigation';
+import ROUTES from '../../ROUTES';
+import * as ReimbursementAccount from './ReimbursementAccount';
+import type PlaidBankAccount from '../../types/onyx/PlaidBankAccount';
+import type {ACHContractStepProps, BankAccountStepProps, CompanyStepProps, OnfidoData, ReimbursementAccountProps, RequestorStepProps} from '../../types/onyx/ReimbursementAccountDraft';
+import type {OnyxData} from '../../types/onyx/Request';
+import type {BankAccountStep, BankAccountSubStep} from '../../types/onyx/ReimbursementAccount';
+
+export {
+ goToWithdrawalAccountSetupStep,
+ setBankAccountFormValidationErrors,
+ resetReimbursementAccount,
+ resetFreePlanBankAccount,
+ hideBankAccountErrors,
+ setWorkspaceIDForReimbursementAccount,
+ setBankAccountSubStep,
+ updateReimbursementAccountDraft,
+ requestResetFreePlanBankAccount,
+ cancelResetFreePlanBankAccount,
+} from './ReimbursementAccount';
+export {openPlaidBankAccountSelector, openPlaidBankLogin} from './Plaid';
+export {openOnfidoFlow, answerQuestionsForWallet, verifyIdentity, acceptWalletTerms} from './Wallet';
+
+type BankAccountCompanyInformation = BankAccountStepProps & CompanyStepProps & ReimbursementAccountProps;
+
+type ReimbursementAccountStep = BankAccountStep | '';
+
+type ReimbursementAccountSubStep = BankAccountSubStep | '';
+
+function clearPlaid(): Promise {
+ Onyx.set(ONYXKEYS.PLAID_LINK_TOKEN, '');
+
+ return Onyx.set(ONYXKEYS.PLAID_DATA, PlaidDataProps.plaidDataDefaultProps);
+}
+
+function openPlaidView() {
+ clearPlaid().then(() => ReimbursementAccount.setBankAccountSubStep(CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID));
+}
+
+/**
+ * Open the personal bank account setup flow, with an optional exitReportID to redirect to once the flow is finished.
+ */
+function openPersonalBankAccountSetupView(exitReportID: string) {
+ clearPlaid().then(() => {
+ if (exitReportID) {
+ Onyx.merge(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {exitReportID});
+ }
+ Navigation.navigate(ROUTES.SETTINGS_ADD_BANK_ACCOUNT);
+ });
+}
+
+function clearPersonalBankAccount() {
+ clearPlaid();
+ Onyx.set(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {});
+}
+
+function clearOnfidoToken() {
+ Onyx.merge(ONYXKEYS.ONFIDO_TOKEN, '');
+}
+
+/**
+ * Helper method to build the Onyx data required during setup of a Verified Business Bank Account
+ */
+function getVBBADataForOnyx(currentStep?: BankAccountStep): OnyxData {
+ return {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ value: {
+ isLoading: true,
+ errors: null,
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ value: {
+ isLoading: false,
+ errors: null,
+ // When setting up a bank account, we save the draft form values in Onyx.
+ // When we update the information for a step, the value of some fields that are returned from the API
+ // can be different from the value that we stored as the draft in Onyx (i.e. the phone number is formatted).
+ // This is why we store the current step used to call the API in order to update the corresponding draft data in Onyx.
+ // If currentStep is undefined that means this step don't need to update the data of the draft in Onyx.
+ draftStep: currentStep,
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ value: {
+ isLoading: false,
+ errors: ErrorUtils.getMicroSecondOnyxError('walletPage.addBankAccountFailure'),
+ },
+ },
+ ],
+ };
+}
+
+/**
+ * Submit Bank Account step with Plaid data so php can perform some checks.
+ */
+function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAccount: PlaidBankAccount) {
+ const commandName = 'ConnectBankAccountWithPlaid';
+
+ type ConnectBankAccountWithPlaidParams = {
+ bankAccountID: number;
+ routingNumber: string;
+ accountNumber: string;
+ bank?: string;
+ plaidAccountID: string;
+ plaidAccessToken: string;
+ };
+
+ const parameters: ConnectBankAccountWithPlaidParams = {
+ bankAccountID,
+ routingNumber: selectedPlaidBankAccount.routingNumber,
+ accountNumber: selectedPlaidBankAccount.accountNumber,
+ bank: selectedPlaidBankAccount.bankName,
+ plaidAccountID: selectedPlaidBankAccount.plaidAccountID,
+ plaidAccessToken: selectedPlaidBankAccount.plaidAccessToken,
+ };
+
+ API.write(commandName, parameters, getVBBADataForOnyx());
+}
+
+/**
+ * Adds a bank account via Plaid
+ *
+ * @TODO offline pattern for this command will have to be added later once the pattern B design doc is complete
+ */
+function addPersonalBankAccount(account: PlaidBankAccount) {
+ const commandName = 'AddPersonalBankAccount';
+
+ type AddPersonalBankAccountParams = {
+ addressName: string;
+ routingNumber: string;
+ accountNumber: string;
+ isSavings: boolean;
+ setupType: string;
+ bank?: string;
+ plaidAccountID: string;
+ plaidAccessToken: string;
+ };
+
+ const parameters: AddPersonalBankAccountParams = {
+ addressName: account.addressName,
+ routingNumber: account.routingNumber,
+ accountNumber: account.accountNumber,
+ isSavings: account.isSavings,
+ setupType: 'plaid',
+ bank: account.bankName,
+ plaidAccountID: account.plaidAccountID,
+ plaidAccessToken: account.plaidAccessToken,
+ };
+
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.PERSONAL_BANK_ACCOUNT,
+ value: {
+ isLoading: true,
+ errors: null,
+ plaidAccountID: account.plaidAccountID,
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.PERSONAL_BANK_ACCOUNT,
+ value: {
+ isLoading: false,
+ errors: null,
+ shouldShowSuccess: true,
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.PERSONAL_BANK_ACCOUNT,
+ value: {
+ isLoading: false,
+ errors: ErrorUtils.getMicroSecondOnyxError('walletPage.addBankAccountFailure'),
+ },
+ },
+ ],
+ };
+
+ API.write(commandName, parameters, onyxData);
+}
+
+function deletePaymentBankAccount(bankAccountID: number) {
+ type DeletePaymentBankAccountParams = {bankAccountID: number};
+
+ const parameters: DeletePaymentBankAccountParams = {bankAccountID};
+
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.BANK_ACCOUNT_LIST}`,
+ value: {[bankAccountID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}},
+ },
+ ],
+
+ // Sometimes pusher updates aren't received when we close the App while still offline,
+ // so we are setting the bankAccount to null here to ensure that it gets cleared out once we come back online.
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.BANK_ACCOUNT_LIST}`,
+ value: {[bankAccountID]: null},
+ },
+ ],
+ };
+
+ API.write('DeletePaymentBankAccount', parameters, onyxData);
+}
+
+/**
+ * Update the user's personal information on the bank account in database.
+ *
+ * This action is called by the requestor step in the Verified Bank Account flow
+ */
+function updatePersonalInformationForBankAccount(params: RequestorStepProps) {
+ API.write('UpdatePersonalInformationForBankAccount', params, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.REQUESTOR));
+}
+
+function validateBankAccount(bankAccountID: number, validateCode: string) {
+ type ValidateBankAccountWithTransactionsParams = {
+ bankAccountID: number;
+ validateCode: string;
+ };
+
+ const parameters: ValidateBankAccountWithTransactionsParams = {
+ bankAccountID,
+ validateCode,
+ };
+
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ value: {
+ isLoading: true,
+ errors: null,
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ value: {
+ isLoading: false,
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ value: {
+ isLoading: false,
+ },
+ },
+ ],
+ };
+
+ API.write('ValidateBankAccountWithTransactions', parameters, onyxData);
+}
+
+function clearReimbursementAccount() {
+ Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, null);
+}
+
+function openReimbursementAccountPage(stepToOpen: ReimbursementAccountStep, subStep: ReimbursementAccountSubStep, localCurrentStep: ReimbursementAccountStep) {
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ value: {
+ isLoading: true,
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ value: {
+ isLoading: false,
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ value: {
+ isLoading: false,
+ },
+ },
+ ],
+ };
+
+ type OpenReimbursementAccountPageParams = {
+ stepToOpen: ReimbursementAccountStep;
+ subStep: ReimbursementAccountSubStep;
+ localCurrentStep: ReimbursementAccountStep;
+ };
+
+ const parameters: OpenReimbursementAccountPageParams = {
+ stepToOpen,
+ subStep,
+ localCurrentStep,
+ };
+
+ return API.read('OpenReimbursementAccountPage', parameters, onyxData);
+}
+
+/**
+ * Updates the bank account in the database with the company step data
+ */
+function updateCompanyInformationForBankAccount(bankAccount: BankAccountCompanyInformation, policyID: string) {
+ type UpdateCompanyInformationForBankAccountParams = BankAccountCompanyInformation & {policyID: string};
+
+ const parameters: UpdateCompanyInformationForBankAccountParams = {...bankAccount, policyID};
+
+ API.write('UpdateCompanyInformationForBankAccount', parameters, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.COMPANY));
+}
+
+/**
+ * Add beneficial owners for the bank account, accept the ACH terms and conditions and verify the accuracy of the information provided
+ */
+function updateBeneficialOwnersForBankAccount(params: ACHContractStepProps) {
+ API.write('UpdateBeneficialOwnersForBankAccount', params, getVBBADataForOnyx());
+}
+
+/**
+ * Create the bank account with manually entered data.
+ *
+ */
+function connectBankAccountManually(bankAccountID: number, accountNumber?: string, routingNumber?: string, plaidMask?: string) {
+ type ConnectBankAccountManuallyParams = {
+ bankAccountID: number;
+ accountNumber?: string;
+ routingNumber?: string;
+ plaidMask?: string;
+ };
+
+ const parameters: ConnectBankAccountManuallyParams = {
+ bankAccountID,
+ accountNumber,
+ routingNumber,
+ plaidMask,
+ };
+
+ API.write('ConnectBankAccountManually', parameters, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT));
+}
+
+/**
+ * Verify the user's identity via Onfido
+ */
+function verifyIdentityForBankAccount(bankAccountID: number, onfidoData: OnfidoData) {
+ type VerifyIdentityForBankAccountParams = {
+ bankAccountID: number;
+ onfidoData: string;
+ };
+
+ const parameters: VerifyIdentityForBankAccountParams = {
+ bankAccountID,
+ onfidoData: JSON.stringify(onfidoData),
+ };
+
+ API.write('VerifyIdentityForBankAccount', parameters, getVBBADataForOnyx());
+}
+
+function openWorkspaceView() {
+ API.read('OpenWorkspaceView', {}, {});
+}
+
+function handlePlaidError(bankAccountID: number, error: string, errorDescription: string, plaidRequestID: string) {
+ type BankAccountHandlePlaidErrorParams = {
+ bankAccountID: number;
+ error: string;
+ errorDescription: string;
+ plaidRequestID: string;
+ };
+
+ const parameters: BankAccountHandlePlaidErrorParams = {
+ bankAccountID,
+ error,
+ errorDescription,
+ plaidRequestID,
+ };
+
+ API.write('BankAccount_HandlePlaidError', parameters);
+}
+
+/**
+ * Set the reimbursement account loading so that it happens right away, instead of when the API command is processed.
+ */
+function setReimbursementAccountLoading(isLoading: boolean) {
+ Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {isLoading});
+}
+
+export {
+ addPersonalBankAccount,
+ clearOnfidoToken,
+ clearPersonalBankAccount,
+ clearPlaid,
+ openPlaidView,
+ connectBankAccountManually,
+ connectBankAccountWithPlaid,
+ deletePaymentBankAccount,
+ handlePlaidError,
+ openPersonalBankAccountSetupView,
+ clearReimbursementAccount,
+ openReimbursementAccountPage,
+ updateBeneficialOwnersForBankAccount,
+ updateCompanyInformationForBankAccount,
+ updatePersonalInformationForBankAccount,
+ openWorkspaceView,
+ validateBankAccount,
+ verifyIdentityForBankAccount,
+ setReimbursementAccountLoading,
+};
diff --git a/src/libs/actions/DemoActions.js b/src/libs/actions/DemoActions.js
new file mode 100644
index 000000000000..29c983c35262
--- /dev/null
+++ b/src/libs/actions/DemoActions.js
@@ -0,0 +1,70 @@
+import Config from 'react-native-config';
+import Onyx from 'react-native-onyx';
+import lodashGet from 'lodash/get';
+import * as API from '../API';
+import * as ReportUtils from '../ReportUtils';
+import Navigation from '../Navigation/Navigation';
+import ROUTES from '../../ROUTES';
+import ONYXKEYS from '../../ONYXKEYS';
+
+let currentUserEmail;
+Onyx.connect({
+ key: ONYXKEYS.SESSION,
+ callback: (val) => {
+ currentUserEmail = lodashGet(val, 'email', '');
+ },
+});
+
+function runMoney2020Demo() {
+ // Try to navigate to existing demo chat if it exists in Onyx
+ const money2020AccountID = Number(Config ? Config.EXPENSIFY_ACCOUNT_ID_MONEY2020 : 15864555);
+ const existingChatReport = ReportUtils.getChatByParticipants([money2020AccountID]);
+ if (existingChatReport) {
+ // We must call goBack() to remove the demo route from nav history
+ Navigation.goBack();
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(existingChatReport.reportID));
+ return;
+ }
+
+ // We use makeRequestWithSideEffects here because we need to get the chat report ID to navigate to it after it's created
+ // eslint-disable-next-line rulesdir/no-api-side-effects-method
+ API.makeRequestWithSideEffects('CreateChatReport', {
+ emailList: `${currentUserEmail},money2020@expensify.com`,
+ activationConference: 'money2020',
+ }).then((response) => {
+ // If there's no response or no reportID in the response, navigate the user home so user doesn't get stuck.
+ if (!response || !response.reportID) {
+ Navigation.goBack();
+ Navigation.navigate(ROUTES.HOME);
+ return;
+ }
+
+ // Get reportID & navigate to it
+ // Note: We must call goBack() to remove the demo route from history
+ const chatReportID = response.reportID;
+ Navigation.goBack();
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(chatReportID));
+ });
+}
+
+/**
+ * Runs code for specific demos, based on the provided URL
+ *
+ * @param {String} url - URL user is navigating to via deep link (or regular link in web)
+ */
+function runDemoByURL(url = '') {
+ const cleanUrl = (url || '').toLowerCase();
+
+ if (cleanUrl.endsWith(ROUTES.MONEY2020)) {
+ Onyx.set(ONYXKEYS.DEMO_INFO, {
+ money2020: {
+ isBeginningDemo: true,
+ },
+ });
+ } else {
+ // No demo is being run, so clear out demo info
+ Onyx.set(ONYXKEYS.DEMO_INFO, null);
+ }
+}
+
+export {runMoney2020Demo, runDemoByURL};
diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js
index bc6ee9be4e0b..422dd8fbb03a 100644
--- a/src/libs/actions/IOU.js
+++ b/src/libs/actions/IOU.js
@@ -1,6 +1,7 @@
import Onyx from 'react-native-onyx';
import _ from 'underscore';
import lodashGet from 'lodash/get';
+import lodashHas from 'lodash/has';
import Str from 'expensify-common/lib/str';
import {format} from 'date-fns';
import CONST from '../../CONST';
@@ -53,6 +54,15 @@ Onyx.connect({
},
});
+let allDraftSplitTransactions;
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT,
+ waitForCollectionCallback: true,
+ callback: (val) => {
+ allDraftSplitTransactions = val || {};
+ },
+});
+
let allRecentlyUsedTags = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS,
@@ -1055,6 +1065,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
let oneOnOneChatReport;
let isNewOneOnOneChatReport = false;
let shouldCreateOptimisticPersonalDetails = false;
+ const personalDetailExists = lodashHas(allPersonalDetails, accountID);
// If this is a split between two people only and the function
// wasn't provided with an existing group chat report id
@@ -1063,11 +1074,11 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
// entering code that creates optimistic personal details
if ((!hasMultipleParticipants && !existingSplitChatReportID) || isOwnPolicyExpenseChat) {
oneOnOneChatReport = splitChatReport;
- shouldCreateOptimisticPersonalDetails = !existingSplitChatReport;
+ shouldCreateOptimisticPersonalDetails = !existingSplitChatReport && !personalDetailExists;
} else {
const existingChatReport = ReportUtils.getChatByParticipants([accountID]);
isNewOneOnOneChatReport = !existingChatReport;
- shouldCreateOptimisticPersonalDetails = isNewOneOnOneChatReport;
+ shouldCreateOptimisticPersonalDetails = isNewOneOnOneChatReport && !personalDetailExists;
oneOnOneChatReport = existingChatReport || ReportUtils.buildOptimisticChatReport([accountID]);
}
@@ -1294,7 +1305,18 @@ function startSplitBill(participants, currentUserLogin, currentUserAccountID, co
const receiptObject = {state, source};
// ReportID is -2 (aka "deleted") on the group transaction
- const splitTransaction = TransactionUtils.buildOptimisticTransaction(0, CONST.CURRENCY.USD, CONST.REPORT.SPLIT_REPORTID, comment, '', '', '', '', receiptObject, filename);
+ const splitTransaction = TransactionUtils.buildOptimisticTransaction(
+ 0,
+ CONST.CURRENCY.USD,
+ CONST.REPORT.SPLIT_REPORTID,
+ comment,
+ '',
+ '',
+ '',
+ CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT,
+ receiptObject,
+ filename,
+ );
// Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat
const splitChatCreatedReportAction = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmailForIOUSplit);
@@ -1410,7 +1432,7 @@ function startSplitBill(participants, currentUserLogin, currentUserAccountID, co
errors: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'),
},
[splitIOUReportAction.reportActionID]: {
- errors: ErrorUtils.getMicroSecondOnyxError('report.genericCreateFailureMessage'),
+ errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
},
},
},
@@ -1489,6 +1511,237 @@ function startSplitBill(participants, currentUserLogin, currentUserAccountID, co
Report.notifyNewAction(splitChatReport.chatReportID, currentUserAccountID);
}
+/** Used for editing a split bill while it's still scanning or when SmartScan fails, it completes a split bill started by startSplitBill above.
+ *
+ * @param {number} chatReportID - The group chat or workspace reportID
+ * @param {Object} reportAction - The split action that lives in the chatReport above
+ * @param {Object} updatedTransaction - The updated **draft** split transaction
+ * @param {Number} sessionAccountID - accountID of the current user
+ * @param {String} sessionEmail - email of the current user
+ */
+function completeSplitBill(chatReportID, reportAction, updatedTransaction, sessionAccountID, sessionEmail) {
+ const currentUserEmailForIOUSplit = OptionsListUtils.addSMSDomainIfPhoneNumber(sessionEmail);
+ const {transactionID} = updatedTransaction;
+ const unmodifiedTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
+
+ // Save optimistic updated transaction and action
+ const optimisticData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ ...updatedTransaction,
+ receipt: {
+ state: CONST.IOU.RECEIPT_STATE.OPEN,
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`,
+ value: {
+ [reportAction.reportActionID]: {
+ lastModified: DateUtils.getDBTime(),
+ whisperedToAccountIDs: [],
+ },
+ },
+ },
+ ];
+
+ const successData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {pendingAction: null},
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`,
+ value: null,
+ },
+ ];
+
+ const failureData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ ...unmodifiedTransaction,
+ errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`,
+ value: {
+ [reportAction.reportActionID]: {
+ ...reportAction,
+ errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
+ },
+ },
+ },
+ ];
+
+ const splitParticipants = updatedTransaction.comment.splits;
+ const {modifiedAmount: amount, modifiedCurrency: currency} = updatedTransaction;
+
+ // Exclude the current user when calculating the split amount, `calculateAmount` takes it into account
+ const splitAmount = IOUUtils.calculateAmount(splitParticipants.length - 1, amount, currency, false);
+
+ const splits = [{email: currentUserEmailForIOUSplit}];
+ _.each(splitParticipants, (participant) => {
+ // Skip creating the transaction for the current user
+ if (participant.email === currentUserEmailForIOUSplit) {
+ return;
+ }
+ const isPolicyExpenseChat = !_.isEmpty(participant.policyID);
+
+ if (!isPolicyExpenseChat) {
+ // In case this is still the optimistic accountID saved in the splits array, return early as we cannot know
+ // if there is an existing chat between the split creator and this participant
+ // Instead, we will rely on Auth generating the report IDs and the user won't see any optimistic chats or reports created
+ const participantPersonalDetails = allPersonalDetails[participant.accountID] || {};
+ if (!participantPersonalDetails || participantPersonalDetails.isOptimisticPersonalDetail) {
+ splits.push({
+ email: participant.email,
+ });
+ return;
+ }
+ }
+
+ let oneOnOneChatReport;
+ let isNewOneOnOneChatReport = false;
+ if (isPolicyExpenseChat) {
+ // The workspace chat reportID is saved in the splits array when starting a split bill with a workspace
+ oneOnOneChatReport = allReports[`${ONYXKEYS.COLLECTION.REPORT}${participant.chatReportID}`];
+ } else {
+ const existingChatReport = ReportUtils.getChatByParticipants([participant.accountID]);
+ isNewOneOnOneChatReport = !existingChatReport;
+ oneOnOneChatReport = existingChatReport || ReportUtils.buildOptimisticChatReport([participant.accountID]);
+ }
+
+ let oneOnOneIOUReport = lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.iouReportID}`, undefined);
+ const shouldCreateNewOneOnOneIOUReport =
+ _.isUndefined(oneOnOneIOUReport) || (isPolicyExpenseChat && ReportUtils.isControlPolicyExpenseReport(oneOnOneIOUReport) && ReportUtils.isReportApproved(oneOnOneIOUReport));
+
+ if (shouldCreateNewOneOnOneIOUReport) {
+ oneOnOneIOUReport = isPolicyExpenseChat
+ ? ReportUtils.buildOptimisticExpenseReport(oneOnOneChatReport.reportID, participant.policyID, sessionAccountID, splitAmount, currency)
+ : ReportUtils.buildOptimisticIOUReport(sessionAccountID, participant.accountID, splitAmount, oneOnOneChatReport.reportID, currency);
+ } else if (isPolicyExpenseChat) {
+ // Because of the Expense reports are stored as negative values, we subtract the total from the amount
+ oneOnOneIOUReport.total -= splitAmount;
+ } else {
+ oneOnOneIOUReport = IOUUtils.updateIOUOwnerAndTotal(oneOnOneIOUReport, sessionAccountID, splitAmount, currency);
+ }
+
+ const oneOnOneTransaction = TransactionUtils.buildOptimisticTransaction(
+ isPolicyExpenseChat ? -splitAmount : splitAmount,
+ currency,
+ oneOnOneIOUReport.reportID,
+ updatedTransaction.comment.comment,
+ updatedTransaction.modifiedCreated,
+ CONST.IOU.MONEY_REQUEST_TYPE.SPLIT,
+ transactionID,
+ updatedTransaction.modifiedMerchant,
+ {...updatedTransaction.receipt, state: CONST.IOU.RECEIPT_STATE.OPEN},
+ updatedTransaction.filename,
+ );
+
+ const oneOnOneCreatedActionForChat = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmailForIOUSplit);
+ const oneOnOneCreatedActionForIOU = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmailForIOUSplit);
+ const oneOnOneIOUAction = ReportUtils.buildOptimisticIOUReportAction(
+ CONST.IOU.REPORT_ACTION_TYPE.CREATE,
+ splitAmount,
+ currency,
+ updatedTransaction.comment.comment,
+ [participant],
+ oneOnOneTransaction.transactionID,
+ '',
+ oneOnOneIOUReport.reportID,
+ );
+
+ let oneOnOneReportPreviewAction = ReportActionsUtils.getReportPreviewAction(oneOnOneChatReport.reportID, oneOnOneIOUReport.reportID);
+ if (oneOnOneReportPreviewAction) {
+ oneOnOneReportPreviewAction = ReportUtils.updateReportPreview(oneOnOneIOUReport, oneOnOneReportPreviewAction);
+ } else {
+ oneOnOneReportPreviewAction = ReportUtils.buildOptimisticReportPreview(oneOnOneChatReport, oneOnOneIOUReport, '', oneOnOneTransaction);
+ }
+
+ const [oneOnOneOptimisticData, oneOnOneSuccessData, oneOnOneFailureData] = buildOnyxDataForMoneyRequest(
+ oneOnOneChatReport,
+ oneOnOneIOUReport,
+ oneOnOneTransaction,
+ oneOnOneCreatedActionForChat,
+ oneOnOneCreatedActionForIOU,
+ oneOnOneIOUAction,
+ {},
+ oneOnOneReportPreviewAction,
+ {},
+ {},
+ isNewOneOnOneChatReport,
+ shouldCreateNewOneOnOneIOUReport,
+ );
+
+ splits.push({
+ email: participant.email,
+ accountID: participant.accountID,
+ policyID: participant.policyID,
+ iouReportID: oneOnOneIOUReport.reportID,
+ chatReportID: oneOnOneChatReport.reportID,
+ transactionID: oneOnOneTransaction.transactionID,
+ reportActionID: oneOnOneIOUAction.reportActionID,
+ createdChatReportActionID: oneOnOneCreatedActionForChat.reportActionID,
+ createdIOUReportActionID: oneOnOneCreatedActionForIOU.reportActionID,
+ reportPreviewReportActionID: oneOnOneReportPreviewAction.reportActionID,
+ });
+
+ optimisticData.push(...oneOnOneOptimisticData);
+ successData.push(...oneOnOneSuccessData);
+ failureData.push(...oneOnOneFailureData);
+ });
+
+ const {
+ amount: transactionAmount,
+ currency: transactionCurrency,
+ created: transactionCreated,
+ merchant: transactionMerchant,
+ comment: transactionComment,
+ } = ReportUtils.getTransactionDetails(updatedTransaction);
+
+ API.write(
+ 'CompleteSplitBill',
+ {
+ transactionID,
+ amount: transactionAmount,
+ currency: transactionCurrency,
+ created: transactionCreated,
+ merchant: transactionMerchant,
+ comment: transactionComment,
+ splits: JSON.stringify(splits),
+ },
+ {optimisticData, successData, failureData},
+ );
+ Navigation.dismissModal(chatReportID);
+ Report.notifyNewAction(chatReportID, sessionAccountID);
+}
+
+/**
+ * @param {String} transactionID
+ * @param {Object} transactionChanges
+ */
+function setDraftSplitTransaction(transactionID, transactionChanges = {}) {
+ let draftSplitTransaction = allDraftSplitTransactions[`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`];
+
+ if (!draftSplitTransaction) {
+ draftSplitTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
+ }
+
+ const updatedTransaction = TransactionUtils.getUpdatedTransaction(draftSplitTransaction, transactionChanges, false, false);
+
+ Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, updatedTransaction);
+}
+
/**
* @param {String} transactionID
* @param {Number} transactionThreadReportID
@@ -1535,7 +1788,8 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC
updatedMoneyRequestReport.lastMessageHtml = lastMessage[0].html;
// Update the last message of the chat report
- const messageText = Localize.translateLocal('iou.payerOwesAmount', {
+ const hasNonReimbursableTransactions = ReportUtils.hasNonReimbursableTransactions(iouReport);
+ const messageText = Localize.translateLocal(hasNonReimbursableTransactions ? 'iou.payerSpentAmount' : 'iou.payerOwesAmount', {
payer: updatedMoneyRequestReport.managerEmail,
amount: CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, updatedMoneyRequestReport.currency),
});
@@ -1755,7 +2009,8 @@ function deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView
updatedIOUReport.lastVisibleActionCreated = lastVisibleAction.created;
updatedReportPreviewAction = {...reportPreviewAction};
- const messageText = Localize.translateLocal('iou.payerOwesAmount', {
+ const hasNonReimbursableTransactions = ReportUtils.hasNonReimbursableTransactions(iouReport);
+ const messageText = Localize.translateLocal(hasNonReimbursableTransactions ? 'iou.payerSpentAmount' : 'iou.payerOwesAmount', {
payer: updatedIOUReport.managerEmail,
amount: CurrencyUtils.convertToDisplayString(updatedIOUReport.total, updatedIOUReport.currency),
});
@@ -2587,9 +2842,12 @@ function setMoneyRequestReceipt(receiptPath, receiptFilename) {
Onyx.merge(ONYXKEYS.IOU, {receiptPath, receiptFilename, merchant: ''});
}
-function createEmptyTransaction() {
+function setUpDistanceTransaction() {
const transactionID = NumberUtils.rand64();
- Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {transactionID});
+ Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
+ transactionID,
+ comment: {type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE}},
+ });
Onyx.merge(ONYXKEYS.IOU, {transactionID});
}
@@ -2660,7 +2918,9 @@ export {
deleteMoneyRequest,
splitBill,
splitBillAndOpenReport,
+ setDraftSplitTransaction,
startSplitBill,
+ completeSplitBill,
requestMoney,
sendMoneyElsewhere,
approveMoneyRequest,
@@ -2682,7 +2942,7 @@ export {
setMoneyRequestBillable,
setMoneyRequestParticipants,
setMoneyRequestReceipt,
- createEmptyTransaction,
+ setUpDistanceTransaction,
navigateToNextPage,
updateDistanceRequest,
replaceReceipt,
diff --git a/src/libs/actions/InputFocus/index.desktop.ts b/src/libs/actions/InputFocus/index.desktop.ts
new file mode 100644
index 000000000000..b6cf1aba6138
--- /dev/null
+++ b/src/libs/actions/InputFocus/index.desktop.ts
@@ -0,0 +1,29 @@
+import Onyx from 'react-native-onyx';
+import ONYXKEYS from '../../../ONYXKEYS';
+import ReportActionComposeFocusManager from '../../ReportActionComposeFocusManager';
+
+function inputFocusChange(focus: boolean) {
+ Onyx.set(ONYXKEYS.INPUT_FOCUSED, focus);
+}
+
+let refSave: HTMLElement | undefined;
+function composerFocusKeepFocusOn(ref: HTMLElement, isFocused: boolean, modal: {willAlertModalBecomeVisible: boolean; isVisible: boolean}, onyxFocused: boolean) {
+ if (isFocused && !onyxFocused) {
+ inputFocusChange(true);
+ ref.focus();
+ }
+ if (isFocused) {
+ refSave = ref;
+ }
+ if (!isFocused && !onyxFocused && !modal.willAlertModalBecomeVisible && !modal.isVisible && refSave) {
+ if (!ReportActionComposeFocusManager.isFocused()) {
+ refSave.focus();
+ } else {
+ refSave = undefined;
+ }
+ }
+}
+
+const callback = (method: () => void) => method();
+
+export {composerFocusKeepFocusOn, inputFocusChange, callback};
diff --git a/src/libs/actions/InputFocus/index.ts b/src/libs/actions/InputFocus/index.ts
new file mode 100644
index 000000000000..1840b0625626
--- /dev/null
+++ b/src/libs/actions/InputFocus/index.ts
@@ -0,0 +1,5 @@
+function inputFocusChange() {}
+function composerFocusKeepFocusOn() {}
+const callback = () => {};
+
+export {composerFocusKeepFocusOn, inputFocusChange, callback};
diff --git a/src/libs/actions/InputFocus/index.website.ts b/src/libs/actions/InputFocus/index.website.ts
new file mode 100644
index 000000000000..7c044b169a03
--- /dev/null
+++ b/src/libs/actions/InputFocus/index.website.ts
@@ -0,0 +1,30 @@
+import Onyx from 'react-native-onyx';
+import ONYXKEYS from '../../../ONYXKEYS';
+import * as Browser from '../../Browser';
+import ReportActionComposeFocusManager from '../../ReportActionComposeFocusManager';
+
+function inputFocusChange(focus: boolean) {
+ Onyx.set(ONYXKEYS.INPUT_FOCUSED, focus);
+}
+
+let refSave: HTMLElement | undefined;
+function composerFocusKeepFocusOn(ref: HTMLElement, isFocused: boolean, modal: {willAlertModalBecomeVisible: boolean; isVisible: boolean}, onyxFocused: boolean) {
+ if (isFocused && !onyxFocused) {
+ inputFocusChange(true);
+ ref.focus();
+ }
+ if (isFocused) {
+ refSave = ref;
+ }
+ if (!isFocused && !onyxFocused && !modal.willAlertModalBecomeVisible && !modal.isVisible && refSave) {
+ if (!ReportActionComposeFocusManager.isFocused()) {
+ refSave.focus();
+ } else {
+ refSave = undefined;
+ }
+ }
+}
+
+const callback = (method: () => void) => !Browser.isMobile() && method();
+
+export {composerFocusKeepFocusOn, inputFocusChange, callback};
diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js
index 1a73b148e100..53753e193fb1 100644
--- a/src/libs/actions/Policy.js
+++ b/src/libs/actions/Policy.js
@@ -909,6 +909,48 @@ function buildOptimisticCustomUnits() {
};
}
+/**
+ * Optimistically creates a Policy Draft for a new workspace
+ *
+ * @param {String} [policyOwnerEmail] Optional, the email of the account to make the owner of the policy
+ * @param {String} [policyName] Optional, custom policy name we will use for created workspace
+ * @param {String} [policyID] Optional, custom policy id we will use for created workspace
+ */
+function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', policyID = generatePolicyID()) {
+ const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail);
+ const {customUnits} = buildOptimisticCustomUnits();
+
+ const optimisticData = [
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`,
+ value: {
+ id: policyID,
+ type: CONST.POLICY.TYPE.FREE,
+ name: workspaceName,
+ role: CONST.POLICY.ROLE.ADMIN,
+ owner: sessionEmail,
+ isPolicyExpenseChatEnabled: true,
+ outputCurrency: lodashGet(allPersonalDetails, [sessionAccountID, 'localCurrencyCode'], CONST.CURRENCY.USD),
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ customUnits,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS}${policyID}`,
+ value: {
+ [sessionAccountID]: {
+ role: CONST.POLICY.ROLE.ADMIN,
+ errors: {},
+ },
+ },
+ },
+ ];
+
+ Onyx.update(optimisticData);
+}
+
/**
* Optimistically creates a new workspace and default workspace chats
*
@@ -1027,6 +1069,16 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`,
value: expenseReportActionData,
},
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS}${policyID}`,
+ value: null,
+ },
],
successData: [
{
@@ -1131,6 +1183,7 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName
],
},
);
+
return adminsChatReportID;
}
@@ -1259,4 +1312,5 @@ export {
clearErrors,
openDraftWorkspaceRequest,
buildOptimisticPolicyRecentlyUsedCategories,
+ createDraftInitialWorkspace,
};
diff --git a/src/libs/actions/ReimbursementAccount/index.js b/src/libs/actions/ReimbursementAccount/index.js
index 49ff30e7be8e..68774d0ba8b0 100644
--- a/src/libs/actions/ReimbursementAccount/index.js
+++ b/src/libs/actions/ReimbursementAccount/index.js
@@ -31,6 +31,7 @@ function setWorkspaceIDForReimbursementAccount(workspaceID) {
*/
function updateReimbursementAccountDraft(bankAccountData) {
Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT_DRAFT, bankAccountData);
+ Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {draftStep: undefined});
}
/**
diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js
index 27a02b1fc75f..af1b4a0ac1dd 100644
--- a/src/libs/actions/Report.js
+++ b/src/libs/actions/Report.js
@@ -1,6 +1,7 @@
import {InteractionManager} from 'react-native';
import _ from 'underscore';
import lodashGet from 'lodash/get';
+import lodashDebounce from 'lodash/debounce';
import ExpensiMark from 'expensify-common/lib/ExpensiMark';
import Onyx from 'react-native-onyx';
import Str from 'expensify-common/lib/str';
@@ -1401,9 +1402,15 @@ function updateWriteCapabilityAndNavigate(report, newValue) {
/**
* Navigates to the 1:1 report with Concierge
+ *
+ * @param {Boolean} ignoreConciergeReportID - Flag to ignore conciergeChatReportID during navigation. The default behavior is to not ignore.
*/
-function navigateToConciergeChat() {
- if (!conciergeChatReportID) {
+function navigateToConciergeChat(ignoreConciergeReportID = false) {
+ // If conciergeChatReportID contains a concierge report ID, we navigate to the concierge chat using the stored report ID.
+ // Otherwise, we would find the concierge chat and navigate to it.
+ // Now, when user performs sign-out and a sign-in again, conciergeChatReportID may contain a stale value.
+ // In order to prevent navigation to a stale value, we use ignoreConciergeReportID to forcefully find and navigate to concierge chat.
+ if (!conciergeChatReportID || ignoreConciergeReportID) {
// In order to avoid creating concierge repeatedly,
// we need to ensure that the server data has been successfully pulled
Welcome.serverDataIsReadyPromise().then(() => {
@@ -1423,8 +1430,9 @@ function navigateToConciergeChat() {
* @param {String} visibility
* @param {Array} policyMembersAccountIDs
* @param {String} writeCapability
+ * @param {String} welcomeMessage
*/
-function addPolicyReport(policyID, reportName, visibility, policyMembersAccountIDs, writeCapability = CONST.REPORT.WRITE_CAPABILITIES.ALL) {
+function addPolicyReport(policyID, reportName, visibility, policyMembersAccountIDs, writeCapability = CONST.REPORT.WRITE_CAPABILITIES.ALL, welcomeMessage = '') {
// The participants include the current user (admin), and for restricted rooms, the policy members. Participants must not be empty.
const members = visibility === CONST.REPORT.VISIBILITY.RESTRICTED ? policyMembersAccountIDs : [];
const participants = _.unique([currentUserAccountID, ...members]);
@@ -1441,6 +1449,9 @@ function addPolicyReport(policyID, reportName, visibility, policyMembersAccountI
// The room might contain all policy members so notifying always should be opt-in only.
CONST.REPORT.NOTIFICATION_PREFERENCE.DAILY,
+ '',
+ '',
+ welcomeMessage,
);
const createdReportAction = ReportUtils.buildOptimisticCreatedReportAction(CONST.POLICY.OWNER_EMAIL_FAKE);
@@ -1505,6 +1516,7 @@ function addPolicyReport(policyID, reportName, visibility, policyMembersAccountI
reportID: policyReport.reportID,
createdReportActionID: createdReportAction.reportActionID,
writeCapability,
+ welcomeMessage,
},
{optimisticData, successData, failureData},
);
@@ -1897,7 +1909,7 @@ function openReportFromDeepLink(url, isAuthenticated) {
InteractionManager.runAfterInteractions(() => {
Session.waitForUserSignIn().then(() => {
if (route === ROUTES.CONCIERGE) {
- navigateToConciergeChat();
+ navigateToConciergeChat(true);
return;
}
Navigation.navigate(route, CONST.NAVIGATION.TYPE.PUSH);
@@ -2211,7 +2223,63 @@ function savePrivateNotesDraft(reportID, note) {
Onyx.merge(`${ONYXKEYS.COLLECTION.PRIVATE_NOTES_DRAFT}${reportID}`, note);
}
+/**
+ * @private
+ * @param {string} searchInput
+ */
+function searchForReports(searchInput) {
+ // We do not try to make this request while offline because it sets a loading indicator optimistically
+ if (isNetworkOffline) {
+ Onyx.set(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, false);
+ return;
+ }
+
+ API.read(
+ 'SearchForReports',
+ {searchInput},
+ {
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.IS_SEARCHING_FOR_REPORTS,
+ value: false,
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.IS_SEARCHING_FOR_REPORTS,
+ value: false,
+ },
+ ],
+ },
+ );
+}
+
+/**
+ * @private
+ * @param {string} searchInput
+ */
+const debouncedSearchInServer = lodashDebounce(searchForReports, CONST.TIMING.SEARCH_FOR_REPORTS_DEBOUNCE_TIME, {leading: false});
+
+/**
+ * @param {string} searchInput
+ */
+function searchInServer(searchInput) {
+ if (isNetworkOffline) {
+ Onyx.set(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, false);
+ return;
+ }
+
+ // Why not set this in optimistic data? It won't run until the API request happens and while the API request is debounced
+ // we want to show the loading state right away. Otherwise, we will see a flashing UI where the client options are sorted and
+ // tell the user there are no options, then we start searching, and tell them there are no options again.
+ Onyx.set(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, true);
+ debouncedSearchInServer(searchInput);
+}
+
export {
+ searchInServer,
addComment,
addAttachment,
reconnect,
diff --git a/src/libs/actions/Session/index.js b/src/libs/actions/Session/index.js
index 117a092c3875..3b623a42689d 100644
--- a/src/libs/actions/Session/index.js
+++ b/src/libs/actions/Session/index.js
@@ -316,7 +316,7 @@ function signInWithShortLivedAuthToken(email, authToken) {
// If the user is signing in with a different account from the current app, should not pass the auto-generated login as it may be tied to the old account.
// scene 1: the user is transitioning to newDot from a different account on oldDot.
// scene 2: the user is transitioning to desktop app from a different account on web app.
- const oldPartnerUserID = credentials.login === email ? credentials.autoGeneratedLogin : '';
+ const oldPartnerUserID = credentials.login === email && credentials.autoGeneratedLogin ? credentials.autoGeneratedLogin : '';
API.read('SignInWithShortLivedAuthToken', {authToken, oldPartnerUserID, skipReauthentication: true}, {optimisticData, successData, failureData});
}
@@ -541,6 +541,10 @@ function clearAccountMessages() {
});
}
+function setAccountError(error) {
+ Onyx.merge(ONYXKEYS.ACCOUNT, {errors: ErrorUtils.getMicroSecondOnyxError(error)});
+}
+
// It's necessary to throttle requests to reauthenticate since calling this multiple times will cause Pusher to
// reconnect each time when we only need to reconnect once. This way, if an authToken is expired and we try to
// subscribe to a bunch of channels at once we will only reauthenticate and force reconnect Pusher once.
@@ -807,6 +811,7 @@ export {
unlinkLogin,
clearSignInData,
clearAccountMessages,
+ setAccountError,
authenticatePusher,
reauthenticatePusher,
invalidateCredentials,
diff --git a/src/libs/actions/Timing.js b/src/libs/actions/Timing.ts
similarity index 76%
rename from src/libs/actions/Timing.js
rename to src/libs/actions/Timing.ts
index 2be2cdc6fa63..13f40bab87c9 100644
--- a/src/libs/actions/Timing.js
+++ b/src/libs/actions/Timing.ts
@@ -4,15 +4,20 @@ import Firebase from '../Firebase';
import * as API from '../API';
import Log from '../Log';
-let timestampData = {};
+type TimestampData = {
+ startTime: number;
+ shouldUseFirebase: boolean;
+};
+
+let timestampData: Record = {};
/**
* Start a performance timing measurement
*
- * @param {String} eventName
- * @param {Boolean} shouldUseFirebase - adds an additional trace in Firebase
+ * @param eventName
+ * @param shouldUseFirebase - adds an additional trace in Firebase
*/
-function start(eventName, shouldUseFirebase = false) {
+function start(eventName: string, shouldUseFirebase = false) {
timestampData[eventName] = {startTime: Date.now(), shouldUseFirebase};
if (!shouldUseFirebase) {
@@ -25,11 +30,11 @@ function start(eventName, shouldUseFirebase = false) {
/**
* End performance timing. Measure the time between event start/end in milliseconds, and push to Grafana
*
- * @param {String} eventName - event name used as timestamp key
- * @param {String} [secondaryName] - optional secondary event name, passed to grafana
- * @param {number} [maxExecutionTime] - optional amount of time (ms) to wait before logging a warn
+ * @param eventName - event name used as timestamp key
+ * @param [secondaryName] - optional secondary event name, passed to grafana
+ * @param [maxExecutionTime] - optional amount of time (ms) to wait before logging a warn
*/
-function end(eventName, secondaryName = '', maxExecutionTime = 0) {
+function end(eventName: string, secondaryName = '', maxExecutionTime = 0) {
if (!timestampData[eventName]) {
return;
}
diff --git a/src/libs/localFileDownload/index.android.js b/src/libs/localFileDownload/index.android.ts
similarity index 88%
rename from src/libs/localFileDownload/index.android.js
rename to src/libs/localFileDownload/index.android.ts
index b3e39e7a7a53..ad13b5c5cfa7 100644
--- a/src/libs/localFileDownload/index.android.js
+++ b/src/libs/localFileDownload/index.android.ts
@@ -1,15 +1,13 @@
import RNFetchBlob from 'react-native-blob-util';
import * as FileUtils from '../fileDownload/FileUtils';
+import LocalFileDownload from './types';
/**
* Writes a local file to the app's internal directory with the given fileName
* and textContent, so we're able to copy it to the Android public download dir.
* After the file is copied, it is removed from the internal dir.
- *
- * @param {String} fileName
- * @param {String} textContent
*/
-export default function localFileDownload(fileName, textContent) {
+const localFileDownload: LocalFileDownload = (fileName, textContent) => {
const newFileName = FileUtils.appendTimeToFileName(fileName);
const dir = RNFetchBlob.fs.dirs.DocumentDir;
const path = `${dir}/${newFileName}.txt`;
@@ -34,4 +32,6 @@ export default function localFileDownload(fileName, textContent) {
RNFetchBlob.fs.unlink(path);
});
});
-}
+};
+
+export default localFileDownload;
diff --git a/src/libs/localFileDownload/index.ios.js b/src/libs/localFileDownload/index.ios.ts
similarity index 82%
rename from src/libs/localFileDownload/index.ios.js
rename to src/libs/localFileDownload/index.ios.ts
index 1241f5a535db..3597ea5f6d3c 100644
--- a/src/libs/localFileDownload/index.ios.js
+++ b/src/libs/localFileDownload/index.ios.ts
@@ -1,16 +1,14 @@
import {Share} from 'react-native';
import RNFetchBlob from 'react-native-blob-util';
import * as FileUtils from '../fileDownload/FileUtils';
+import LocalFileDownload from './types';
/**
* Writes a local file to the app's internal directory with the given fileName
* and textContent, so we're able to share it using iOS' share API.
* After the file is shared, it is removed from the internal dir.
- *
- * @param {String} fileName
- * @param {String} textContent
*/
-export default function localFileDownload(fileName, textContent) {
+const localFileDownload: LocalFileDownload = (fileName, textContent) => {
const newFileName = FileUtils.appendTimeToFileName(fileName);
const dir = RNFetchBlob.fs.dirs.DocumentDir;
const path = `${dir}/${newFileName}.txt`;
@@ -20,4 +18,6 @@ export default function localFileDownload(fileName, textContent) {
RNFetchBlob.fs.unlink(path);
});
});
-}
+};
+
+export default localFileDownload;
diff --git a/src/libs/localFileDownload/index.js b/src/libs/localFileDownload/index.ts
similarity index 77%
rename from src/libs/localFileDownload/index.js
rename to src/libs/localFileDownload/index.ts
index 427928834c9c..7b9b4973d5c1 100644
--- a/src/libs/localFileDownload/index.js
+++ b/src/libs/localFileDownload/index.ts
@@ -1,18 +1,18 @@
import * as FileUtils from '../fileDownload/FileUtils';
+import LocalFileDownload from './types';
/**
* Creates a Blob with the given fileName and textContent, then dynamically
* creates a temporary anchor, just to programmatically click it, so the file
* is downloaded by the browser.
- *
- * @param {String} fileName
- * @param {String} textContent
*/
-export default function localFileDownload(fileName, textContent) {
+const localFileDownload: LocalFileDownload = (fileName, textContent) => {
const blob = new Blob([textContent], {type: 'text/plain'});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = FileUtils.appendTimeToFileName(`${fileName}.txt`);
link.href = url;
link.click();
-}
+};
+
+export default localFileDownload;
diff --git a/src/libs/localFileDownload/types.ts b/src/libs/localFileDownload/types.ts
new file mode 100644
index 000000000000..2086e2334d39
--- /dev/null
+++ b/src/libs/localFileDownload/types.ts
@@ -0,0 +1,3 @@
+type LocalFileDownload = (fileName: string, textContent: string) => void;
+
+export default LocalFileDownload;
diff --git a/src/pages/DemoSetupPage.js b/src/pages/DemoSetupPage.js
index 5d4b99a0daf9..5432bea0c806 100644
--- a/src/pages/DemoSetupPage.js
+++ b/src/pages/DemoSetupPage.js
@@ -1,9 +1,11 @@
-import React from 'react';
+import React, {useCallback} from 'react';
import PropTypes from 'prop-types';
import {useFocusEffect} from '@react-navigation/native';
import FullScreenLoadingIndicator from '../components/FullscreenLoadingIndicator';
import Navigation from '../libs/Navigation/Navigation';
import ROUTES from '../ROUTES';
+import CONST from '../CONST';
+import * as DemoActions from '../libs/actions/DemoActions';
const propTypes = {
/** Navigation route context info provided by react navigation */
@@ -18,12 +20,16 @@ const propTypes = {
* route that led the user here. Now, it's just used to route the user home so we
* don't show them a "Hmm... It's not here" message (which looks broken).
*/
-function DemoSetupPage() {
- useFocusEffect(() => {
- Navigation.isNavigationReady().then(() => {
- Navigation.goBack(ROUTES.HOME);
- });
- });
+function DemoSetupPage(props) {
+ useFocusEffect(
+ useCallback(() => {
+ if (props.route.name === CONST.DEMO_PAGES.MONEY2020) {
+ DemoActions.runMoney2020Demo();
+ } else {
+ Navigation.goBack(ROUTES.HOME);
+ }
+ }, [props.route.name]),
+ );
return ;
}
diff --git a/src/pages/EditRequestAmountPage.js b/src/pages/EditRequestAmountPage.js
index 9f72c9afbc23..d65fdafb3b59 100644
--- a/src/pages/EditRequestAmountPage.js
+++ b/src/pages/EditRequestAmountPage.js
@@ -1,13 +1,11 @@
import React, {useCallback, useRef} from 'react';
-import {InteractionManager} from 'react-native';
import {useFocusEffect} from '@react-navigation/native';
import PropTypes from 'prop-types';
+import CONST from '../CONST';
+import useLocalize from '../hooks/useLocalize';
import ScreenWrapper from '../components/ScreenWrapper';
import HeaderWithBackButton from '../components/HeaderWithBackButton';
-import Navigation from '../libs/Navigation/Navigation';
-import useLocalize from '../hooks/useLocalize';
import MoneyRequestAmountForm from './iou/steps/MoneyRequestAmountForm';
-import ROUTES from '../ROUTES';
const propTypes = {
/** Transaction default amount value */
@@ -19,36 +17,25 @@ const propTypes = {
/** Callback to fire when the Save button is pressed */
onSubmit: PropTypes.func.isRequired,
- /** reportID for the transaction thread */
- reportID: PropTypes.string.isRequired,
+ /** Callback to fire when we press on the currency */
+ onNavigateToCurrency: PropTypes.func.isRequired,
};
-function EditRequestAmountPage({defaultAmount, defaultCurrency, onSubmit, reportID}) {
+function EditRequestAmountPage({defaultAmount, defaultCurrency, onNavigateToCurrency, onSubmit}) {
const {translate} = useLocalize();
- const textInput = useRef(null);
- const focusTextInput = () => {
- // Component may not be initialized due to navigation transitions
- // Wait until interactions are complete before trying to focus
- InteractionManager.runAfterInteractions(() => {
- // Focus text input
- if (!textInput.current) {
- return;
- }
-
- textInput.current.focus();
- });
- };
-
- const navigateToCurrencySelectionPage = () => {
- // Remove query from the route and encode it.
- const activeRoute = encodeURIComponent(Navigation.getActiveRoute().replace(/\?.*/, ''));
- Navigation.navigate(ROUTES.EDIT_CURRENCY_REQUEST.getRoute(reportID, defaultCurrency, activeRoute));
- };
+ const textInput = useRef(null);
+ const focusTimeoutRef = useRef(null);
useFocusEffect(
useCallback(() => {
- focusTextInput();
+ focusTimeoutRef.current = setTimeout(() => textInput.current && textInput.current.focus(), CONST.ANIMATED_TRANSITION);
+ return () => {
+ if (!focusTimeoutRef.current) {
+ return;
+ }
+ clearTimeout(focusTimeoutRef.current);
+ };
}, []),
);
@@ -64,7 +51,7 @@ function EditRequestAmountPage({defaultAmount, defaultCurrency, onSubmit, report
currency={defaultCurrency}
amount={defaultAmount}
ref={(e) => (textInput.current = e)}
- onCurrencyButtonPress={navigateToCurrencySelectionPage}
+ onCurrencyButtonPress={onNavigateToCurrency}
onSubmitButtonPress={onSubmit}
/>
diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js
index 8b19c7cb7b60..a85f490bbb42 100644
--- a/src/pages/EditRequestPage.js
+++ b/src/pages/EditRequestPage.js
@@ -5,6 +5,7 @@ import lodashValues from 'lodash/values';
import {withOnyx} from 'react-native-onyx';
import CONST from '../CONST';
import ONYXKEYS from '../ONYXKEYS';
+import ROUTES from '../ROUTES';
import compose from '../libs/compose';
import Navigation from '../libs/Navigation/Navigation';
import * as ReportActionsUtils from '../libs/ReportActionsUtils';
@@ -205,6 +206,10 @@ function EditRequestPage({betas, report, route, parentReport, policy, session, p
currency: defaultCurrency,
});
}}
+ onNavigateToCurrency={() => {
+ const activeRoute = encodeURIComponent(Navigation.getActiveRoute().replace(/\?.*/, ''));
+ Navigation.navigate(ROUTES.EDIT_CURRENCY_REQUEST.getRoute(report.reportID, defaultCurrency, activeRoute));
+ }}
/>
);
}
diff --git a/src/pages/EditRequestReceiptPage.js b/src/pages/EditRequestReceiptPage.js
index 6744f027b404..54ed5a8897a4 100644
--- a/src/pages/EditRequestReceiptPage.js
+++ b/src/pages/EditRequestReceiptPage.js
@@ -1,5 +1,6 @@
import React, {useState} from 'react';
import PropTypes from 'prop-types';
+import {View} from 'react-native';
import ScreenWrapper from '../components/ScreenWrapper';
import HeaderWithBackButton from '../components/HeaderWithBackButton';
import Navigation from '../libs/Navigation/Navigation';
@@ -40,17 +41,21 @@ function EditRequestReceiptPage({route, transactionID}) {
testID={EditRequestReceiptPage.displayName}
headerGapStyles={isDraggingOver ? [styles.receiptDropHeaderGap] : []}
>
-
-
-
-
+ {({safeAreaPaddingBottomStyle}) => (
+
+
+
+
+
+
+ )}
);
}
diff --git a/src/pages/EditSplitBillPage.js b/src/pages/EditSplitBillPage.js
new file mode 100644
index 000000000000..d10803cd40ea
--- /dev/null
+++ b/src/pages/EditSplitBillPage.js
@@ -0,0 +1,161 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import lodashGet from 'lodash/get';
+import {withOnyx} from 'react-native-onyx';
+import CONST from '../CONST';
+import ROUTES from '../ROUTES';
+import ONYXKEYS from '../ONYXKEYS';
+import compose from '../libs/compose';
+import transactionPropTypes from '../components/transactionPropTypes';
+import * as ReportUtils from '../libs/ReportUtils';
+import * as IOU from '../libs/actions/IOU';
+import * as CurrencyUtils from '../libs/CurrencyUtils';
+import Navigation from '../libs/Navigation/Navigation';
+import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView';
+import EditRequestDescriptionPage from './EditRequestDescriptionPage';
+import EditRequestMerchantPage from './EditRequestMerchantPage';
+import EditRequestCreatedPage from './EditRequestCreatedPage';
+import EditRequestAmountPage from './EditRequestAmountPage';
+
+const propTypes = {
+ /** Route from navigation */
+ route: PropTypes.shape({
+ /** Params from the route */
+ params: PropTypes.shape({
+ /** The transaction field we are editing */
+ field: PropTypes.string,
+
+ /** The chat reportID of the split */
+ reportID: PropTypes.string,
+
+ /** reportActionID of the split action */
+ reportActionID: PropTypes.string,
+ }),
+ }).isRequired,
+
+ /** The current transaction */
+ transaction: transactionPropTypes.isRequired,
+
+ /** The draft transaction that holds data to be persisted on the current transaction */
+ draftTransaction: transactionPropTypes,
+};
+
+const defaultProps = {
+ draftTransaction: undefined,
+};
+
+function EditSplitBillPage({route, transaction, draftTransaction}) {
+ const fieldToEdit = lodashGet(route, ['params', 'field'], '');
+ const reportID = lodashGet(route, ['params', 'reportID'], '');
+ const reportActionID = lodashGet(route, ['params', 'reportActionID'], '');
+
+ const {
+ amount: transactionAmount,
+ currency: transactionCurrency,
+ comment: transactionDescription,
+ merchant: transactionMerchant,
+ created: transactionCreated,
+ } = draftTransaction ? ReportUtils.getTransactionDetails(draftTransaction) : ReportUtils.getTransactionDetails(transaction);
+
+ const defaultCurrency = lodashGet(route, 'params.currency', '') || transactionCurrency;
+
+ function navigateBackToSplitDetails() {
+ Navigation.navigate(ROUTES.SPLIT_BILL_DETAILS.getRoute(reportID, reportActionID));
+ }
+
+ function setDraftSplitTransaction(transactionChanges) {
+ IOU.setDraftSplitTransaction(transaction.transactionID, transactionChanges);
+ navigateBackToSplitDetails();
+ }
+
+ if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DESCRIPTION) {
+ return (
+ {
+ setDraftSplitTransaction({
+ comment: transactionChanges.comment.trim(),
+ });
+ }}
+ />
+ );
+ }
+
+ if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DATE) {
+ return (
+ {
+ setDraftSplitTransaction({
+ created: transactionChanges.created,
+ });
+ }}
+ />
+ );
+ }
+
+ if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT) {
+ return (
+ {
+ const amount = CurrencyUtils.convertToBackendAmount(Number.parseFloat(transactionChanges));
+
+ setDraftSplitTransaction({
+ amount,
+ currency: defaultCurrency,
+ });
+ }}
+ onNavigateToCurrency={() => {
+ const activeRoute = encodeURIComponent(Navigation.getActiveRoute().replace(/\?.*/, ''));
+ Navigation.navigate(ROUTES.EDIT_SPLIT_BILL_CURRENCY.getRoute(reportID, reportActionID, defaultCurrency, activeRoute));
+ }}
+ />
+ );
+ }
+
+ if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.MERCHANT) {
+ return (
+ {
+ setDraftSplitTransaction({merchant: transactionChanges.merchant.trim()});
+ }}
+ />
+ );
+ }
+
+ return ;
+}
+
+EditSplitBillPage.displayName = 'EditSplitBillPage';
+EditSplitBillPage.propTypes = propTypes;
+EditSplitBillPage.defaultProps = defaultProps;
+export default compose(
+ withOnyx({
+ reportActions: {
+ key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${route.params.reportID}`,
+ canEvict: false,
+ },
+ }),
+ // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file
+ withOnyx({
+ transaction: {
+ key: ({route, reportActions}) => {
+ const reportAction = reportActions[`${route.params.reportActionID.toString()}`];
+ return `${ONYXKEYS.COLLECTION.TRANSACTION}${lodashGet(reportAction, 'originalMessage.IOUTransactionID', 0)}`;
+ },
+ },
+ draftTransaction: {
+ key: ({route, reportActions}) => {
+ const reportAction = reportActions[`${route.params.reportActionID.toString()}`];
+ return `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${lodashGet(reportAction, 'originalMessage.IOUTransactionID', 0)}`;
+ },
+ },
+ }),
+)(EditSplitBillPage);
diff --git a/src/pages/EnablePayments/AdditionalDetailsStep.js b/src/pages/EnablePayments/AdditionalDetailsStep.js
index bd068ad9abcc..13091ab3f845 100644
--- a/src/pages/EnablePayments/AdditionalDetailsStep.js
+++ b/src/pages/EnablePayments/AdditionalDetailsStep.js
@@ -23,7 +23,6 @@ import DatePicker from '../../components/DatePicker';
import Form from '../../components/Form';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../components/withCurrentUserPersonalDetails';
import * as PersonalDetails from '../../libs/actions/PersonalDetails';
-import OfflineIndicator from '../../components/OfflineIndicator';
const propTypes = {
...withLocalizePropTypes,
@@ -148,6 +147,7 @@ function AdditionalDetailsStep({walletAdditionalDetails, translate, currentUserP
if (!_.isEmpty(walletAdditionalDetails.questions)) {
return (
-