diff --git a/android/app/build.gradle b/android/app/build.gradle
index fac395a44a62..dc48f3137f27 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -110,8 +110,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1009005800
- versionName "9.0.58-0"
+ versionCode 1009005801
+ versionName "9.0.58-1"
// Supported language variants must be declared here to avoid from being removed during the compilation.
// This also helps us to not include unnecessary language variants in the APK.
resConfigs "en", "es"
diff --git a/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md b/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md
index 8fffec75e744..782e939e991e 100644
--- a/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md
+++ b/docs/articles/new-expensify/expensify-card/Upgrade-to-the-new-Expensify-Card-from-Visa.md
@@ -12,7 +12,7 @@ When you upgrade the Expensify Cards to the new program, you'll have access to e
- Unique naming for each virtual card for simplified expense categorization
{% include info.html %}
-The Expensify Card upgrade must be completed by November 1, 2024.
+The Expensify Card upgrade must be completed by December 1, 2024.
{% include end-info.html %}
# Upgrade your company’s Expensify Card program
diff --git a/docs/assets/images/SageConfigureIntegrationConfigureButton.png b/docs/assets/images/SageConfigureIntegrationConfigureButton.png
new file mode 100644
index 000000000000..e3ec52bacbb0
Binary files /dev/null and b/docs/assets/images/SageConfigureIntegrationConfigureButton.png differ
diff --git a/docs/assets/images/SageConfigureUserDefinedDimensionsFilter.png b/docs/assets/images/SageConfigureUserDefinedDimensionsFilter.png
new file mode 100644
index 000000000000..f126bb10dc51
Binary files /dev/null and b/docs/assets/images/SageConfigureUserDefinedDimensionsFilter.png differ
diff --git a/docs/assets/images/SageConnectCreatingWorkspace.png b/docs/assets/images/SageConnectCreatingWorkspace.png
new file mode 100644
index 000000000000..6084d0a8c7fb
Binary files /dev/null and b/docs/assets/images/SageConnectCreatingWorkspace.png differ
diff --git a/docs/assets/images/SageConnectEnableSage.png b/docs/assets/images/SageConnectEnableSage.png
new file mode 100644
index 000000000000..25b43a510c15
Binary files /dev/null and b/docs/assets/images/SageConnectEnableSage.png differ
diff --git a/docs/assets/images/SageConnectEnterCredentials.png b/docs/assets/images/SageConnectEnterCredentials.png
new file mode 100644
index 000000000000..63772972290d
Binary files /dev/null and b/docs/assets/images/SageConnectEnterCredentials.png differ
diff --git a/docs/assets/images/SageConnectSettingUpWebServicesUser.png b/docs/assets/images/SageConnectSettingUpWebServicesUser.png
new file mode 100644
index 000000000000..0fd3bb68c3d2
Binary files /dev/null and b/docs/assets/images/SageConnectSettingUpWebServicesUser.png differ
diff --git a/docs/assets/images/SageConnectSubscriptionSettings.png b/docs/assets/images/SageConnectSubscriptionSettings.png
new file mode 100644
index 000000000000..2e74d27c71e6
Binary files /dev/null and b/docs/assets/images/SageConnectSubscriptionSettings.png differ
diff --git a/docs/assets/images/SageConnectTimeandExpenseSequenceNumbers.png b/docs/assets/images/SageConnectTimeandExpenseSequenceNumbers.png
new file mode 100644
index 000000000000..8750c1ed596b
Binary files /dev/null and b/docs/assets/images/SageConnectTimeandExpenseSequenceNumbers.png differ
diff --git a/docs/assets/images/SageConnectWebServicesAuthorizations.png b/docs/assets/images/SageConnectWebServicesAuthorizations.png
new file mode 100644
index 000000000000..d0b9a786d1cc
Binary files /dev/null and b/docs/assets/images/SageConnectWebServicesAuthorizations.png differ
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 5699c8d71b57..04030d1972f0 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -40,7 +40,7 @@
CFBundleVersion
- 9.0.58.0
+ 9.0.58.1
FullStory
OrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index feeb2077fa53..a1fc5be5e7ae 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature
????
CFBundleVersion
- 9.0.58.0
+ 9.0.58.1
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 91382b58e4b8..4fedc3fe0674 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -13,7 +13,7 @@
CFBundleShortVersionString
9.0.58
CFBundleVersion
- 9.0.58.0
+ 9.0.58.1
NSExtension
NSExtensionPointIdentifier
diff --git a/package-lock.json b/package-lock.json
index 9d03e4f5e883..d5a9815fdf29 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "9.0.58-0",
+ "version": "9.0.58-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.0.58-0",
+ "version": "9.0.58-1",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -116,7 +116,7 @@
"react-native-url-polyfill": "^2.0.0",
"react-native-view-shot": "3.8.0",
"react-native-vision-camera": "4.0.0-beta.13",
- "react-native-web": "^0.19.12",
+ "react-native-web": "0.19.13",
"react-native-webview": "13.8.6",
"react-plaid-link": "3.3.2",
"react-web-config": "^1.0.0",
@@ -35825,8 +35825,9 @@
}
},
"node_modules/react-native-web": {
- "version": "0.19.12",
- "license": "MIT",
+ "version": "0.19.13",
+ "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.13.tgz",
+ "integrity": "sha512-etv3bN8rJglrRCp/uL4p7l8QvUNUC++QwDbdZ8CB7BvZiMvsxfFIRM1j04vxNldG3uo2puRd6OSWR3ibtmc29A==",
"dependencies": {
"@babel/runtime": "^7.18.6",
"@react-native/normalize-colors": "^0.74.1",
diff --git a/package.json b/package.json
index c223a599ae51..9c2bad6fdbd7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.0.58-0",
+ "version": "9.0.58-1",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
@@ -173,7 +173,7 @@
"react-native-url-polyfill": "^2.0.0",
"react-native-view-shot": "3.8.0",
"react-native-vision-camera": "4.0.0-beta.13",
- "react-native-web": "^0.19.12",
+ "react-native-web": "0.19.13",
"react-native-webview": "13.8.6",
"react-plaid-link": "3.3.2",
"react-web-config": "^1.0.0",
diff --git a/patches/react-native-web+0.19.12+001+initial.patch b/patches/react-native-web+0.19.13+001+initial.patch
similarity index 95%
rename from patches/react-native-web+0.19.12+001+initial.patch
rename to patches/react-native-web+0.19.13+001+initial.patch
index c77cfc7829ed..75efdf4da117 100644
--- a/patches/react-native-web+0.19.12+001+initial.patch
+++ b/patches/react-native-web+0.19.13+001+initial.patch
@@ -1,8 +1,8 @@
diff --git a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js
-index e137def..c3e5054 100644
+index 1f52b73..53b1a83 100644
--- a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js
+++ b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js
-@@ -285,7 +285,7 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -287,7 +287,7 @@ class VirtualizedList extends StateSafePureComponent {
// REACT-NATIVE-WEB patch to preserve during future RN merges: Support inverted wheel scroller.
constructor(_props) {
@@ -11,7 +11,7 @@ index e137def..c3e5054 100644
super(_props);
this._getScrollMetrics = () => {
return this._scrollMetrics;
-@@ -520,6 +520,11 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -522,6 +522,11 @@ class VirtualizedList extends StateSafePureComponent {
visibleLength,
zoomScale
};
@@ -23,7 +23,7 @@ index e137def..c3e5054 100644
this._updateViewableItems(this.props, this.state.cellsAroundViewport);
if (!this.props) {
return;
-@@ -569,7 +574,7 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -571,7 +576,7 @@ class VirtualizedList extends StateSafePureComponent {
this._updateCellsToRender = () => {
this._updateViewableItems(this.props, this.state.cellsAroundViewport);
this.setState((state, props) => {
@@ -32,7 +32,7 @@ index e137def..c3e5054 100644
var renderMask = VirtualizedList._createRenderMask(props, cellsAroundViewport, this._getNonViewportRenderRegions(props));
if (cellsAroundViewport.first === state.cellsAroundViewport.first && cellsAroundViewport.last === state.cellsAroundViewport.last && renderMask.equals(state.renderMask)) {
return null;
-@@ -589,7 +594,7 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -591,7 +596,7 @@ class VirtualizedList extends StateSafePureComponent {
return {
index,
item,
@@ -41,7 +41,7 @@ index e137def..c3e5054 100644
isViewable
};
};
-@@ -621,12 +626,10 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -623,12 +628,10 @@ class VirtualizedList extends StateSafePureComponent {
};
this._getFrameMetrics = (index, props) => {
var data = props.data,
@@ -55,7 +55,7 @@ index e137def..c3e5054 100644
if (!frame || frame.index !== index) {
if (getItemLayout) {
/* $FlowFixMe[prop-missing] (>=0.63.0 site=react_native_fb) This comment
-@@ -650,7 +653,7 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -652,7 +655,7 @@ class VirtualizedList extends StateSafePureComponent {
// The last cell we rendered may be at a new index. Bail if we don't know
// where it is.
@@ -64,7 +64,7 @@ index e137def..c3e5054 100644
return [];
}
var first = focusedCellIndex;
-@@ -690,9 +693,15 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -692,9 +695,15 @@ class VirtualizedList extends StateSafePureComponent {
}
}
var initialRenderRegion = VirtualizedList._initialRenderRegion(_props);
@@ -81,7 +81,7 @@ index e137def..c3e5054 100644
};
// REACT-NATIVE-WEB patch to preserve during future RN merges: Support inverted wheel scroller.
-@@ -748,6 +757,26 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -750,6 +759,26 @@ class VirtualizedList extends StateSafePureComponent {
}
}
}
@@ -108,7 +108,7 @@ index e137def..c3e5054 100644
static _createRenderMask(props, cellsAroundViewport, additionalRegions) {
var itemCount = props.getItemCount(props.data);
invariant(cellsAroundViewport.first >= 0 && cellsAroundViewport.last >= cellsAroundViewport.first - 1 && cellsAroundViewport.last < itemCount, "Invalid cells around viewport \"[" + cellsAroundViewport.first + ", " + cellsAroundViewport.last + "]\" was passed to VirtualizedList._createRenderMask");
-@@ -796,7 +825,7 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -798,7 +827,7 @@ class VirtualizedList extends StateSafePureComponent {
}
}
}
@@ -117,7 +117,7 @@ index e137def..c3e5054 100644
var data = props.data,
getItemCount = props.getItemCount;
var onEndReachedThreshold = onEndReachedThresholdOrDefault(props.onEndReachedThreshold);
-@@ -819,17 +848,9 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -821,17 +850,9 @@ class VirtualizedList extends StateSafePureComponent {
last: Math.min(cellsAroundViewport.last + renderAhead, getItemCount(data) - 1)
};
} else {
@@ -138,7 +138,7 @@ index e137def..c3e5054 100644
return cellsAroundViewport.last >= getItemCount(data) ? VirtualizedList._constrainToItemCount(cellsAroundViewport, props) : cellsAroundViewport;
}
newCellsAroundViewport = computeWindowedRenderLimits(props, maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch), windowSizeOrDefault(props.windowSize), cellsAroundViewport, this.__getFrameMetricsApprox, this._scrollMetrics);
-@@ -902,16 +923,36 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -904,16 +925,36 @@ class VirtualizedList extends StateSafePureComponent {
}
}
static getDerivedStateFromProps(newProps, prevState) {
@@ -177,7 +177,7 @@ index e137def..c3e5054 100644
};
}
_pushCells(cells, stickyHeaderIndices, stickyIndicesFromProps, first, last, inversionStyle) {
-@@ -934,7 +975,7 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -936,7 +977,7 @@ class VirtualizedList extends StateSafePureComponent {
last = Math.min(end, last);
var _loop = function _loop() {
var item = getItem(data, ii);
@@ -186,7 +186,7 @@ index e137def..c3e5054 100644
_this._indicesToKeys.set(ii, key);
if (stickyIndicesFromProps.has(ii + stickyOffset)) {
stickyHeaderIndices.push(cells.length);
-@@ -969,20 +1010,23 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -971,20 +1012,23 @@ class VirtualizedList extends StateSafePureComponent {
}
static _constrainToItemCount(cells, props) {
var itemCount = props.getItemCount(props.data);
@@ -216,8 +216,8 @@ index e137def..c3e5054 100644
if (props.keyExtractor != null) {
return props.keyExtractor(item, index);
}
-@@ -1022,7 +1066,12 @@ class VirtualizedList extends StateSafePureComponent {
- cells.push( /*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, {
+@@ -1024,7 +1068,12 @@ class VirtualizedList extends StateSafePureComponent {
+ cells.push(/*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, {
cellKey: this._getCellKey() + '-header',
key: "$header"
- }, /*#__PURE__*/React.createElement(View, {
@@ -230,7 +230,7 @@ index e137def..c3e5054 100644
onLayout: this._onLayoutHeader,
style: [inversionStyle, this.props.ListHeaderComponentStyle]
},
-@@ -1124,7 +1173,11 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -1126,7 +1175,11 @@ class VirtualizedList extends StateSafePureComponent {
// TODO: Android support
invertStickyHeaders: this.props.invertStickyHeaders !== undefined ? this.props.invertStickyHeaders : this.props.inverted,
stickyHeaderIndices,
@@ -243,7 +243,7 @@ index e137def..c3e5054 100644
});
this._hasMore = this.state.cellsAroundViewport.last < itemCount - 1;
var innerRet = /*#__PURE__*/React.createElement(VirtualizedListContextProvider, {
-@@ -1317,8 +1370,12 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -1319,8 +1372,12 @@ class VirtualizedList extends StateSafePureComponent {
onStartReached = _this$props8.onStartReached,
onStartReachedThreshold = _this$props8.onStartReachedThreshold,
onEndReached = _this$props8.onEndReached,
@@ -258,7 +258,7 @@ index e137def..c3e5054 100644
var _this$_scrollMetrics2 = this._scrollMetrics,
contentLength = _this$_scrollMetrics2.contentLength,
visibleLength = _this$_scrollMetrics2.visibleLength,
-@@ -1358,16 +1415,10 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -1360,16 +1417,10 @@ class VirtualizedList extends StateSafePureComponent {
// and call onStartReached only once for a given content length,
// and only if onEndReached is not being executed
else if (onStartReached != null && this.state.cellsAroundViewport.first === 0 && isWithinStartThreshold && this._scrollMetrics.contentLength !== this._sentStartForContentLength) {
@@ -279,7 +279,7 @@ index e137def..c3e5054 100644
}
// If the user scrolls away from the start or end and back again,
-@@ -1433,6 +1484,11 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -1435,6 +1486,11 @@ class VirtualizedList extends StateSafePureComponent {
*/
_updateViewableItems(props, cellsAroundViewport) {
diff --git a/patches/react-native-web+0.19.12+002+fixLastSpacer.patch b/patches/react-native-web+0.19.13+002+fixLastSpacer.patch
similarity index 94%
rename from patches/react-native-web+0.19.12+002+fixLastSpacer.patch
rename to patches/react-native-web+0.19.13+002+fixLastSpacer.patch
index 581298613492..c400dcfc8cca 100644
--- a/patches/react-native-web+0.19.12+002+fixLastSpacer.patch
+++ b/patches/react-native-web+0.19.13+002+fixLastSpacer.patch
@@ -1,5 +1,5 @@
diff --git a/node_modules/react-native-web/dist/modules/AccessibilityUtil/propsToAccessibilityComponent.js b/node_modules/react-native-web/dist/modules/AccessibilityUtil/propsToAccessibilityComponent.js
-index 9c9a533..7794181 100644
+index 7d1d587..de51afe 100644
--- a/node_modules/react-native-web/dist/modules/AccessibilityUtil/propsToAccessibilityComponent.js
+++ b/node_modules/react-native-web/dist/modules/AccessibilityUtil/propsToAccessibilityComponent.js
@@ -27,7 +27,8 @@ var roleComponents = {
@@ -13,7 +13,7 @@ index 9c9a533..7794181 100644
var emptyObject = {};
var propsToAccessibilityComponent = function propsToAccessibilityComponent(props) {
diff --git a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js
-index 7f6c880..b05da08 100644
+index 53b1a83..5689220 100644
--- a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js
+++ b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js
@@ -78,14 +78,6 @@ function scrollEventThrottleOrDefault(scrollEventThrottle) {
@@ -31,7 +31,7 @@ index 7f6c880..b05da08 100644
/**
* Base implementation for the more convenient [``](https://reactnative.dev/docs/flatlist)
-@@ -1107,7 +1099,8 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -1109,7 +1101,8 @@ class VirtualizedList extends StateSafePureComponent {
_keylessItemComponentName = '';
var spacerKey = this._getSpacerKey(!horizontal);
var renderRegions = this.state.renderMask.enumerateRegions();
diff --git a/patches/react-native-web+0.19.12+003+image-header-support.patch b/patches/react-native-web+0.19.13+003+image-header-support.patch
similarity index 95%
rename from patches/react-native-web+0.19.12+003+image-header-support.patch
rename to patches/react-native-web+0.19.13+003+image-header-support.patch
index d0a490a4ed70..15e83ce31f8a 100644
--- a/patches/react-native-web+0.19.12+003+image-header-support.patch
+++ b/patches/react-native-web+0.19.13+003+image-header-support.patch
@@ -1,8 +1,8 @@
diff --git a/node_modules/react-native-web/dist/exports/Image/index.js b/node_modules/react-native-web/dist/exports/Image/index.js
-index 9649d27..66ef95c 100644
+index 348831d..ca40ee8 100644
--- a/node_modules/react-native-web/dist/exports/Image/index.js
+++ b/node_modules/react-native-web/dist/exports/Image/index.js
-@@ -135,7 +135,22 @@ function resolveAssetUri(source) {
+@@ -137,7 +137,22 @@ function resolveAssetUri(source) {
}
return uri;
}
@@ -13,7 +13,7 @@ index 9649d27..66ef95c 100644
+ if (onError) {
+ onError({
+ nativeEvent: {
-+ error: "Failed to load resource " + uri + " (404)"
++ error: "Failed to load resource " + uri
+ }
+ });
+ }
@@ -26,14 +26,14 @@ index 9649d27..66ef95c 100644
var _ariaLabel = props['aria-label'],
accessibilityLabel = props.accessibilityLabel,
blurRadius = props.blurRadius,
-@@ -238,16 +253,10 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => {
+@@ -240,16 +255,10 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => {
}
}, function error() {
updateState(ERRORED);
- if (onError) {
- onError({
- nativeEvent: {
-- error: "Failed to load resource " + uri + " (404)"
+- error: "Failed to load resource " + uri
- }
- });
- }
@@ -47,7 +47,7 @@ index 9649d27..66ef95c 100644
});
}
function abortPendingRequest() {
-@@ -279,10 +288,79 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => {
+@@ -281,10 +290,79 @@ var Image = /*#__PURE__*/React.forwardRef((props, ref) => {
suppressHydrationWarning: true
}), hiddenImage, createTintColorSVG(tintColor, filterRef.current));
});
diff --git a/patches/react-native-web+0.19.12+004+fixPointerEventDown.patch b/patches/react-native-web+0.19.13+004+fixPointerEventDown.patch
similarity index 100%
rename from patches/react-native-web+0.19.12+004+fixPointerEventDown.patch
rename to patches/react-native-web+0.19.13+004+fixPointerEventDown.patch
diff --git a/patches/react-native-web+0.19.12+005+osr-improvement.patch b/patches/react-native-web+0.19.13+005+osr-improvement.patch
similarity index 94%
rename from patches/react-native-web+0.19.12+005+osr-improvement.patch
rename to patches/react-native-web+0.19.13+005+osr-improvement.patch
index b1afa699e7a2..d0a952172768 100644
--- a/patches/react-native-web+0.19.12+005+osr-improvement.patch
+++ b/patches/react-native-web+0.19.13+005+osr-improvement.patch
@@ -1,8 +1,8 @@
diff --git a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js
-index bede95b..2aef4c6 100644
+index 5689220..df40877 100644
--- a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js
+++ b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js
-@@ -332,7 +332,7 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -334,7 +334,7 @@ class VirtualizedList extends StateSafePureComponent {
zoomScale: 1
};
this._scrollRef = null;
@@ -11,7 +11,7 @@ index bede95b..2aef4c6 100644
this._sentEndForContentLength = 0;
this._totalCellLength = 0;
this._totalCellsMeasured = 0;
-@@ -684,16 +684,18 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -686,16 +686,18 @@ class VirtualizedList extends StateSafePureComponent {
});
}
}
@@ -32,7 +32,7 @@ index bede95b..2aef4c6 100644
};
// REACT-NATIVE-WEB patch to preserve during future RN merges: Support inverted wheel scroller.
-@@ -919,13 +921,13 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -921,13 +923,13 @@ class VirtualizedList extends StateSafePureComponent {
// first and last could be stale (e.g. if a new, shorter items props is passed in), so we make
// sure we're rendering a reasonable range here.
var itemCount = newProps.getItemCount(newProps.data);
@@ -48,7 +48,7 @@ index bede95b..2aef4c6 100644
if (newProps.maintainVisibleContentPosition != null && prevFirstVisibleItemKey != null && newFirstVisibleItemKey != null) {
if (newFirstVisibleItemKey !== prevFirstVisibleItemKey) {
// Fast path if items were added at the start of the list.
-@@ -944,7 +946,8 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -946,7 +948,8 @@ class VirtualizedList extends StateSafePureComponent {
cellsAroundViewport: constrainedCells,
renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells),
firstVisibleItemKey: newFirstVisibleItemKey,
@@ -58,7 +58,7 @@ index bede95b..2aef4c6 100644
};
}
_pushCells(cells, stickyHeaderIndices, stickyIndicesFromProps, first, last, inversionStyle) {
-@@ -1220,7 +1223,7 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -1222,7 +1225,7 @@ class VirtualizedList extends StateSafePureComponent {
return ret;
}
}
@@ -67,7 +67,7 @@ index bede95b..2aef4c6 100644
var _this$props7 = this.props,
data = _this$props7.data,
extraData = _this$props7.extraData;
-@@ -1244,6 +1247,11 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -1246,6 +1249,11 @@ class VirtualizedList extends StateSafePureComponent {
if (hiPriInProgress) {
this._hiPriInProgress = false;
}
@@ -79,7 +79,7 @@ index bede95b..2aef4c6 100644
}
// Used for preventing scrollToIndex from being called multiple times for initialScrollIndex
-@@ -1407,8 +1415,8 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -1409,8 +1417,8 @@ class VirtualizedList extends StateSafePureComponent {
// Next check if the user just scrolled within the start threshold
// and call onStartReached only once for a given content length,
// and only if onEndReached is not being executed
@@ -90,7 +90,7 @@ index bede95b..2aef4c6 100644
onStartReached({
distanceFromStart
});
-@@ -1417,7 +1425,7 @@ class VirtualizedList extends StateSafePureComponent {
+@@ -1419,7 +1427,7 @@ class VirtualizedList extends StateSafePureComponent {
// If the user scrolls away from the start or end and back again,
// cause onStartReached or onEndReached to be triggered again
else {
@@ -100,7 +100,7 @@ index bede95b..2aef4c6 100644
}
}
diff --git a/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js
-index 459f017..d20115c 100644
+index 459f017..fb2d269 100644
--- a/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js
+++ b/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js
@@ -79,6 +79,7 @@ type State = {
diff --git a/patches/react-native-web+0.19.12+006+remove focus trap from modal.patch b/patches/react-native-web+0.19.13+006+remove-focus-trap-from-modal.patch
similarity index 88%
rename from patches/react-native-web+0.19.12+006+remove focus trap from modal.patch
rename to patches/react-native-web+0.19.13+006+remove-focus-trap-from-modal.patch
index 14dbc88b0b1c..eac73db57e35 100644
--- a/patches/react-native-web+0.19.12+006+remove focus trap from modal.patch
+++ b/patches/react-native-web+0.19.13+006+remove-focus-trap-from-modal.patch
@@ -1,8 +1,8 @@
diff --git a/node_modules/react-native-web/dist/exports/Modal/index.js b/node_modules/react-native-web/dist/exports/Modal/index.js
-index d5df021..e2c46cf 100644
+index a9a7c36..522ef93 100644
--- a/node_modules/react-native-web/dist/exports/Modal/index.js
+++ b/node_modules/react-native-web/dist/exports/Modal/index.js
-@@ -86,13 +86,11 @@ var Modal = /*#__PURE__*/React.forwardRef((props, forwardedRef) => {
+@@ -88,13 +88,11 @@ var Modal = /*#__PURE__*/React.forwardRef((props, forwardedRef) => {
onDismiss: onDismissCallback,
onShow: onShowCallback,
visible: visible
diff --git a/patches/react-native-web+0.19.12+007+fix-scrollable-overflown-text.patch b/patches/react-native-web+0.19.13+007+fix-scrollable-overflown-text.patch
similarity index 84%
rename from patches/react-native-web+0.19.12+007+fix-scrollable-overflown-text.patch
rename to patches/react-native-web+0.19.13+007+fix-scrollable-overflown-text.patch
index 11b85afcf86c..304a57ad0657 100644
--- a/patches/react-native-web+0.19.12+007+fix-scrollable-overflown-text.patch
+++ b/patches/react-native-web+0.19.13+007+fix-scrollable-overflown-text.patch
@@ -1,8 +1,8 @@
diff --git a/node_modules/react-native-web/dist/exports/Text/index.js b/node_modules/react-native-web/dist/exports/Text/index.js
-index 8c5f79b..4a47f80 100644
+index 4130386..1076f55 100644
--- a/node_modules/react-native-web/dist/exports/Text/index.js
+++ b/node_modules/react-native-web/dist/exports/Text/index.js
-@@ -166,7 +166,7 @@ var styles = StyleSheet.create({
+@@ -176,7 +176,7 @@ var styles = StyleSheet.create({
textMultiLine: {
display: '-webkit-box',
maxWidth: '100%',
@@ -12,10 +12,10 @@ index 8c5f79b..4a47f80 100644
WebkitBoxOrient: 'vertical'
},
diff --git a/node_modules/react-native-web/src/exports/Text/index.js b/node_modules/react-native-web/src/exports/Text/index.js
-index 071ae10..e43042c 100644
+index f79e82c..f27ccec 100644
--- a/node_modules/react-native-web/src/exports/Text/index.js
+++ b/node_modules/react-native-web/src/exports/Text/index.js
-@@ -219,7 +219,7 @@ const styles = StyleSheet.create({
+@@ -223,7 +223,7 @@ const styles = StyleSheet.create({
textMultiLine: {
display: '-webkit-box',
maxWidth: '100%',
diff --git a/src/CONST.ts b/src/CONST.ts
index 23a220e88ddb..e0e78f04fe60 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -314,9 +314,6 @@ const CONST = {
ANIMATED_HIGHLIGHT_END_DURATION: 2000,
ANIMATED_TRANSITION: 300,
ANIMATED_TRANSITION_FROM_VALUE: 100,
- ANIMATED_PROGRESS_BAR_DELAY: 300,
- ANIMATED_PROGRESS_BAR_OPACITY_DURATION: 300,
- ANIMATED_PROGRESS_BAR_DURATION: 750,
ANIMATION_IN_TIMING: 100,
ANIMATION_DIRECTION: {
IN: 'in',
@@ -1278,6 +1275,7 @@ const CONST = {
UNREAD_UPDATE_DEBOUNCE_TIME: 300,
SEARCH_FILTER_OPTIONS: 'search_filter_options',
USE_DEBOUNCED_STATE_DELAY: 300,
+ LIST_SCROLLING_DEBOUNCE_TIME: 200,
},
PRIORITY_MODE: {
GSD: 'gsd',
diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx
index 5305155ae495..70966a05b918 100644
--- a/src/components/AttachmentPicker/index.native.tsx
+++ b/src/components/AttachmentPicker/index.native.tsx
@@ -448,7 +448,7 @@ function AttachmentPicker({
title={translate(item.textTranslationKey)}
onPress={() => selectItem(item)}
focused={focusedIndex === menuIndex}
- wrapperStyle={StyleUtils.getItemBackgroundColorStyle(false, focusedIndex === menuIndex, theme.activeComponentBG, theme.hoverComponentBG)}
+ wrapperStyle={StyleUtils.getItemBackgroundColorStyle(false, focusedIndex === menuIndex, false, theme.activeComponentBG, theme.hoverComponentBG)}
/>
))}
diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx
index 1b1f7fbdcf15..e9021ec11c03 100644
--- a/src/components/Button/index.tsx
+++ b/src/components/Button/index.tsx
@@ -293,11 +293,11 @@ function Button(
)}
@@ -312,6 +312,7 @@ function Button(
small={small}
medium={medium}
large={large}
+ isButtonIcon
/>
) : (
)}
diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx
index 287ec3359175..9906f9b04c3c 100644
--- a/src/components/DatePicker/CalendarPicker/index.tsx
+++ b/src/components/DatePicker/CalendarPicker/index.tsx
@@ -1,6 +1,6 @@
import {addMonths, endOfDay, endOfMonth, format, getYear, isSameDay, parseISO, setDate, setYear, startOfDay, startOfMonth, subMonths} from 'date-fns';
import {Str} from 'expensify-common';
-import React, {useState} from 'react';
+import React, {useRef, useState} from 'react';
import {View} from 'react-native';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
@@ -51,6 +51,7 @@ function CalendarPicker({
const themeStyles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {preferredLocale, translate} = useLocalize();
+ const pressableRef = useRef(null);
const [currentDateView, setCurrentDateView] = useState(getInitialCurrentDateView(value, minDate, maxDate));
@@ -148,7 +149,11 @@ function CalendarPicker({
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
>
setIsYearPickerVisible(true)}
+ onPress={() => {
+ pressableRef?.current?.blur();
+ setIsYearPickerVisible(true);
+ }}
+ ref={pressableRef}
style={[themeStyles.alignItemsCenter, themeStyles.flexRow, themeStyles.flex1, themeStyles.justifyContentStart]}
wrapperStyle={[themeStyles.alignItemsCenter]}
hoverDimmingValue={1}
diff --git a/src/components/Icon/index.tsx b/src/components/Icon/index.tsx
index b4da5c0b0fa2..4ec4556e1c86 100644
--- a/src/components/Icon/index.tsx
+++ b/src/components/Icon/index.tsx
@@ -40,9 +40,6 @@ type IconProps = {
/** Is icon pressed */
pressed?: boolean;
- /** Is icon will be used with text */
- hasText?: boolean;
-
/** Additional styles to add to the Icon */
additionalStyles?: StyleProp;
@@ -51,6 +48,9 @@ type IconProps = {
/** Determines how the image should be resized to fit its container */
contentFit?: ImageContentFit;
+
+ /** Determines whether the icon is being used within a button. The icon size will remain the same for both icon-only buttons and buttons with text. */
+ isButtonIcon?: boolean;
};
function Icon({
@@ -59,7 +59,6 @@ function Icon({
height = variables.iconSizeNormal,
fill = undefined,
small = false,
- hasText = false,
large = false,
medium = false,
inline = false,
@@ -68,10 +67,11 @@ function Icon({
pressed = false,
testID = '',
contentFit = 'cover',
+ isButtonIcon = false,
}: IconProps) {
const StyleUtils = useStyleUtils();
const styles = useThemeStyles();
- const {width: iconWidth, height: iconHeight} = StyleUtils.getIconWidthAndHeightStyle(small, medium, large, width, height, hasText);
+ const {width: iconWidth, height: iconHeight} = StyleUtils.getIconWidthAndHeightStyle(small, medium, large, width, height, isButtonIcon);
const iconStyles = [StyleUtils.getWidthAndHeightStyle(width ?? 0, height), IconWrapperStyles, styles.pAbsolute, additionalStyles];
if (inline) {
diff --git a/src/components/LoadingBar.tsx b/src/components/LoadingBar.tsx
deleted file mode 100644
index 163ffe2aa66b..000000000000
--- a/src/components/LoadingBar.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import React, {useEffect} from 'react';
-import Animated, {cancelAnimation, Easing, runOnJS, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming} from 'react-native-reanimated';
-import useThemeStyles from '@hooks/useThemeStyles';
-import CONST from '@src/CONST';
-
-type LoadingBarProps = {
- // Whether or not to show the loading bar
- shouldShow: boolean;
-};
-
-function LoadingBar({shouldShow}: LoadingBarProps) {
- const left = useSharedValue(0);
- const width = useSharedValue(0);
- const opacity = useSharedValue(0);
- const isVisible = useSharedValue(false);
- const styles = useThemeStyles();
-
- useEffect(() => {
- if (shouldShow) {
- // eslint-disable-next-line react-compiler/react-compiler
- isVisible.value = true;
- left.value = 0;
- width.value = 0;
- opacity.value = withTiming(1, {duration: CONST.ANIMATED_PROGRESS_BAR_OPACITY_DURATION});
- left.value = withDelay(
- CONST.ANIMATED_PROGRESS_BAR_DELAY,
- withRepeat(
- withSequence(
- withTiming(0, {duration: 0}),
- withTiming(0, {duration: CONST.ANIMATED_PROGRESS_BAR_DURATION, easing: Easing.bezier(0.65, 0, 0.35, 1)}),
- withTiming(100, {duration: CONST.ANIMATED_PROGRESS_BAR_DURATION, easing: Easing.bezier(0.65, 0, 0.35, 1)}),
- ),
- -1,
- false,
- ),
- );
-
- width.value = withDelay(
- CONST.ANIMATED_PROGRESS_BAR_DELAY,
- withRepeat(
- withSequence(
- withTiming(0, {duration: 0}),
- withTiming(100, {duration: CONST.ANIMATED_PROGRESS_BAR_DURATION, easing: Easing.bezier(0.65, 0, 0.35, 1)}),
- withTiming(0, {duration: CONST.ANIMATED_PROGRESS_BAR_DURATION, easing: Easing.bezier(0.65, 0, 0.35, 1)}),
- ),
- -1,
- false,
- ),
- );
- } else if (isVisible.value) {
- opacity.value = withTiming(0, {duration: CONST.ANIMATED_PROGRESS_BAR_OPACITY_DURATION}, () => {
- runOnJS(() => {
- isVisible.value = false;
- cancelAnimation(left);
- cancelAnimation(width);
- });
- });
- }
- // we want to update only when shouldShow changes
- // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
- }, [shouldShow]);
-
- const animatedIndicatorStyle = useAnimatedStyle(() => {
- return {
- left: `${left.value}%`,
- width: `${width.value}%`,
- };
- });
-
- const animatedContainerStyle = useAnimatedStyle(() => {
- return {
- opacity: opacity.value,
- };
- });
-
- return (
-
- {isVisible.value ? : null}
-
- );
-}
-
-LoadingBar.displayName = 'ProgressBar';
-
-export default LoadingBar;
diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx
index c94938c3d103..3caf7a15d50e 100644
--- a/src/components/MoneyReportHeader.tsx
+++ b/src/components/MoneyReportHeader.tsx
@@ -134,7 +134,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea
const shouldShowPayButton = canIOUBePaid || onlyShowPayElsewhere;
- const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(moneyRequestReport, policy), [moneyRequestReport, policy]);
+ const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(moneyRequestReport, policy) && !hasOnlyPendingTransactions, [moneyRequestReport, policy, hasOnlyPendingTransactions]);
const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(moneyRequestReport);
diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx
index 01fd15c52bb4..3d8f2a6bed33 100755
--- a/src/components/MoneyRequestConfirmationList.tsx
+++ b/src/components/MoneyRequestConfirmationList.tsx
@@ -8,7 +8,6 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'
import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import {MouseProvider} from '@hooks/useMouseContext';
-import usePermissions from '@hooks/usePermissions';
import usePrevious from '@hooks/usePrevious';
import useThemeStyles from '@hooks/useThemeStyles';
import blurActiveElement from '@libs/Accessibility/blurActiveElement';
@@ -195,7 +194,6 @@ function MoneyRequestConfirmationList({
const styles = useThemeStyles();
const {translate, toLocaleDigit} = useLocalize();
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
- const {canUseP2PDistanceRequests} = usePermissions(iouType);
const isTypeRequest = iouType === CONST.IOU.TYPE.SUBMIT;
const isTypeSplit = iouType === CONST.IOU.TYPE.SPLIT;
@@ -214,9 +212,9 @@ function MoneyRequestConfirmationList({
const defaultRate = defaultMileageRate?.customUnitRateID ?? '';
const lastSelectedRate = lastSelectedDistanceRates?.[policy?.id ?? ''] ?? defaultRate;
- const rateID = canUseP2PDistanceRequests ? lastSelectedRate : defaultRate;
+ const rateID = lastSelectedRate;
IOU.setCustomUnitRateID(transactionID, rateID);
- }, [defaultMileageRate, customUnitRateID, lastSelectedDistanceRates, policy?.id, canUseP2PDistanceRequests, transactionID, isDistanceRequest]);
+ }, [defaultMileageRate, customUnitRateID, lastSelectedDistanceRates, policy?.id, transactionID, isDistanceRequest]);
const mileageRate = DistanceRequestUtils.getRate({transaction, policy, policyDraft});
const rate = mileageRate.rate;
@@ -305,6 +303,8 @@ function MoneyRequestConfirmationList({
return false;
};
+ const routeError = Object.values(transaction?.errorFields?.route ?? {}).at(0);
+
useEffect(() => {
if (shouldDisplayFieldError && didConfirmSplit) {
setFormError('iou.error.genericSmartscanFailureMessage');
@@ -719,6 +719,9 @@ function MoneyRequestConfirmationList({
*/
const confirm = useCallback(
(paymentMethod: PaymentMethodType | undefined) => {
+ if (routeError) {
+ return;
+ }
if (iouType === CONST.IOU.TYPE.INVOICE && !hasInvoicingDetails(policy)) {
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_COMPANY_INFO.getRoute(iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()));
return;
@@ -791,6 +794,7 @@ function MoneyRequestConfirmationList({
transactionID,
reportID,
policy,
+ routeError,
],
);
@@ -806,6 +810,16 @@ function MoneyRequestConfirmationList({
}, []),
);
+ const errorMessage = useMemo(() => {
+ if (routeError) {
+ return routeError;
+ }
+ if (isTypeSplit && !shouldShowReadOnlySplits) {
+ return debouncedFormError && translate(debouncedFormError);
+ }
+ return formError && translate(formError);
+ }, [routeError, isTypeSplit, shouldShowReadOnlySplits, debouncedFormError, formError, translate]);
+
const footerContent = useMemo(() => {
if (isReadOnly) {
return;
@@ -848,38 +862,22 @@ function MoneyRequestConfirmationList({
return (
<>
- {!!formError && (
+ {!!errorMessage && (
)}
{button}
>
);
- }, [
- isReadOnly,
- isTypeSplit,
- iouType,
- confirm,
- bankAccountRoute,
- iouCurrencyCode,
- policyID,
- splitOrRequestOptions,
- formError,
- styles.ph1,
- styles.mb2,
- shouldShowReadOnlySplits,
- debouncedFormError,
- translate,
- ]);
+ }, [isReadOnly, iouType, confirm, bankAccountRoute, iouCurrencyCode, policyID, splitOrRequestOptions, styles.ph1, styles.mb2, errorMessage]);
const listFooterContent = (
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))}
- disabled={didConfirm}
- // todo: handle edit for transaction while moving from track expense
- interactive={!isReadOnly && !isMovingTransactionFromTrackExpense}
- />
- ),
- shouldShow: isDistanceRequest && !canUseP2PDistanceRequests,
- isSupplementary: false,
- },
{
item: (
),
- shouldShow: isDistanceRequest && canUseP2PDistanceRequests,
+ shouldShow: isDistanceRequest,
isSupplementary: false,
},
{
@@ -398,7 +372,7 @@ function MoneyRequestConfirmationListFooter({
interactive={!!rate && !isReadOnly && isPolicyExpenseChat}
/>
),
- shouldShow: isDistanceRequest && canUseP2PDistanceRequests,
+ shouldShow: isDistanceRequest,
isSupplementary: false,
},
{
@@ -692,7 +666,6 @@ export default memo(
MoneyRequestConfirmationListFooter,
(prevProps, nextProps) =>
lodashIsEqual(prevProps.action, nextProps.action) &&
- prevProps.canUseP2PDistanceRequests === nextProps.canUseP2PDistanceRequests &&
prevProps.currency === nextProps.currency &&
prevProps.didConfirm === nextProps.didConfirm &&
prevProps.distance === nextProps.distance &&
@@ -711,7 +684,6 @@ export default memo(
prevProps.isEditingSplitBill === nextProps.isEditingSplitBill &&
prevProps.isMerchantEmpty === nextProps.isMerchantEmpty &&
prevProps.isMerchantRequired === nextProps.isMerchantRequired &&
- prevProps.isMovingTransactionFromTrackExpense === nextProps.isMovingTransactionFromTrackExpense &&
prevProps.isPolicyExpenseChat === nextProps.isPolicyExpenseChat &&
prevProps.isReadOnly === nextProps.isReadOnly &&
prevProps.isTypeInvoice === nextProps.isTypeInvoice &&
diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx
index 2f5537be6145..9b5c0b1b6f56 100644
--- a/src/components/PopoverMenu.tsx
+++ b/src/components/PopoverMenu.tsx
@@ -264,7 +264,13 @@ function PopoverMenu({
}
setFocusedIndex(menuIndex);
}}
- wrapperStyle={StyleUtils.getItemBackgroundColorStyle(!!item.isSelected, focusedIndex === menuIndex, theme.activeComponentBG, theme.hoverComponentBG)}
+ wrapperStyle={StyleUtils.getItemBackgroundColorStyle(
+ !!item.isSelected,
+ focusedIndex === menuIndex,
+ item.disabled ?? false,
+ theme.activeComponentBG,
+ theme.hoverComponentBG,
+ )}
shouldRemoveHoverBackground={item.isSelected}
titleStyle={StyleSheet.flatten([styles.flex1, item.titleStyle])}
// Spread other props dynamically
diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
index a774b5c18c55..336b7dea9654 100644
--- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
+++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
@@ -294,8 +294,13 @@ function MoneyRequestPreviewContent({
const navigateToReviewFields = () => {
const backTo = route.params.backTo;
- const comparisonResult = TransactionUtils.compareDuplicateTransactionFields(reviewingTransactionID);
- Transaction.setReviewDuplicatesKey({...comparisonResult.keep, duplicates, transactionID: transaction?.transactionID ?? ''});
+
+ // Clear the draft before selecting a different expense to prevent merging fields from the previous expense
+ // (e.g., category, tag, tax) that may be not enabled/available in the new expense's policy.
+ Transaction.abandonReviewDuplicateTransactions();
+ const comparisonResult = TransactionUtils.compareDuplicateTransactionFields(reviewingTransactionID, transaction?.reportID ?? '');
+ Transaction.setReviewDuplicatesKey({...comparisonResult.keep, duplicates, transactionID: transaction?.transactionID ?? '', reportID: transaction?.reportID});
+
if ('merchant' in comparisonResult.change) {
Navigation.navigate(ROUTES.TRANSACTION_DUPLICATE_REVIEW_MERCHANT_PAGE.getRoute(route.params?.threadReportID, backTo));
} else if ('category' in comparisonResult.change) {
diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx
index b85ecad20f2e..994d9e2c0f67 100644
--- a/src/components/ReportActionItem/MoneyRequestView.tsx
+++ b/src/components/ReportActionItem/MoneyRequestView.tsx
@@ -15,7 +15,6 @@ import ViolationMessages from '@components/ViolationMessages';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
-import usePermissions from '@hooks/usePermissions';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useViolations from '@hooks/useViolations';
@@ -102,7 +101,6 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals
const parentReportAction = parentReportActions?.[report?.parentReportActionID ?? '-1'];
const isTrackExpense = ReportUtils.isTrackExpenseReport(report);
- const {canUseP2PDistanceRequests} = usePermissions(isTrackExpense ? CONST.IOU.TYPE.TRACK : undefined);
const moneyRequestReport = parentReport;
const linkedTransactionID = useMemo(() => {
const originalMessage = parentReportAction && ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction) : undefined;
@@ -307,7 +305,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals
[transactionAmount, isSettled, isCancelled, isPolicyExpenseChat, isEmptyMerchant, transactionDate, readonly, hasErrors, hasViolations, translate, getViolationsForField],
);
- const distanceRequestFields = canUseP2PDistanceRequests ? (
+ const distanceRequestFields = (
<>
>
- ) : (
-
-
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(
- CONST.IOU.ACTION.EDIT,
- iouType,
- transaction?.transactionID ?? '-1',
- report?.reportID ?? '-1',
- Navigation.getReportRHPActiveRoute(),
- ),
- )
- }
- />
-
);
const isReceiptAllowed = !isPaidReport && !isInvoice;
diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx
index 5665909185c4..a330be3d5ff6 100644
--- a/src/components/Search/SearchPageHeader.tsx
+++ b/src/components/Search/SearchPageHeader.tsx
@@ -340,7 +340,10 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) {
}
const inputQueryJSON = SearchQueryUtils.buildSearchQueryJSON(inputValue);
if (inputQueryJSON) {
- const standardizedQuery = SearchQueryUtils.standardizeQueryJSON(inputQueryJSON, cardList, taxRates);
+ // Todo traverse the tree to update all the display values into id values; this is only temporary until autocomplete code from SearchRouter is implement here
+ // After https://github.com/Expensify/App/pull/51633 is merged, autocomplete functionality will be included into this component, and `getFindIDFromDisplayValue` can be removed
+ const computeNodeValueFn = SearchQueryUtils.getFindIDFromDisplayValue(cardList, taxRates);
+ const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(inputQueryJSON, computeNodeValueFn);
const query = SearchQueryUtils.buildSearchQueryString(standardizedQuery);
SearchActions.clearAllFilters();
Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query}));
diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx
index 83d7d5d89b20..e65b12deb64b 100644
--- a/src/components/Search/SearchRouter/SearchRouter.tsx
+++ b/src/components/Search/SearchRouter/SearchRouter.tsx
@@ -1,4 +1,5 @@
import {useNavigationState} from '@react-navigation/native';
+import {Str} from 'expensify-common';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
@@ -6,15 +7,16 @@ import type {ValueOf} from 'type-fest';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import {usePersonalDetails} from '@components/OnyxProvider';
import {useOptionsList} from '@components/OptionListContextProvider';
-import type {AutocompleteRange, SearchQueryJSON} from '@components/Search/types';
+import type {SearchAutocompleteQueryRange, SearchQueryString} from '@components/Search/types';
import type {SelectionListHandle} from '@components/SelectionList/types';
-import useActiveWorkspaceFromNavigationState from '@hooks/useActiveWorkspaceFromNavigationState';
+import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useDebouncedState from '@hooks/useDebouncedState';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useLocalize from '@hooks/useLocalize';
import usePolicy from '@hooks/usePolicy';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as CardUtils from '@libs/CardUtils';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import {getAllTaxRates} from '@libs/PolicyUtils';
import type {OptionData} from '@libs/ReportUtils';
@@ -34,9 +36,13 @@ import Timing from '@userActions/Timing';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
+import type PersonalDetails from '@src/types/onyx/PersonalDetails';
+import {getQueryWithSubstitutions} from './getQueryWithSubstitutions';
+import type {SubstitutionMap} from './getQueryWithSubstitutions';
+import {getUpdatedSubstitutionsMap} from './getUpdatedSubstitutionsMap';
import SearchRouterInput from './SearchRouterInput';
import SearchRouterList from './SearchRouterList';
-import type {ItemWithQuery} from './SearchRouterList';
+import type {AutocompleteItemData} from './SearchRouterList';
type SearchRouterProps = {
onRouterClose: () => void;
@@ -48,7 +54,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
const [betas] = useOnyx(ONYXKEYS.BETAS);
const [recentSearches] = useOnyx(ONYXKEYS.RECENT_SEARCHES);
const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false});
- const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]);
+ const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]);
+ const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({});
const {shouldUseNarrowLayout} = useResponsiveLayout();
const listRef = useRef(null);
@@ -58,41 +65,6 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
return state?.routes.at(-1)?.params?.reportID;
});
- const activeWorkspaceID = useActiveWorkspaceFromNavigationState();
- const policy = usePolicy(activeWorkspaceID);
- const typeAutocompleteList = Object.values(CONST.SEARCH.DATA_TYPES);
- const statusAutocompleteList = Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP});
- const expenseTypes = Object.values(CONST.SEARCH.TRANSACTION_TYPE);
- const allTaxRates = getAllTaxRates();
- const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(allTaxRates, policy), [policy, allTaxRates]);
- const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST);
- const cardAutocompleteList = Object.values(cardList ?? {}).map((card) => card.bank);
- const personalDetailsForParticipants = usePersonalDetails();
- const participantsAutocompleteList = Object.values(personalDetailsForParticipants)
- .filter((details) => details && details?.login)
- // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
- .map((details) => details?.login as string);
-
- const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
- const [allRecentCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES);
- const categoryAutocompleteList = useMemo(() => {
- return getAutocompleteCategories(allPolicyCategories, activeWorkspaceID);
- }, [activeWorkspaceID, allPolicyCategories]);
- const recentCategoriesAutocompleteList = useMemo(() => {
- return getAutocompleteRecentCategories(allRecentCategories, activeWorkspaceID);
- }, [activeWorkspaceID, allRecentCategories]);
-
- const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST);
- const currencyAutocompleteList = Object.keys(currencyList ?? {});
- const [recentCurrencyAutocompleteList] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES);
-
- const [allPoliciesTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS);
- const [allRecentTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS);
- const tagAutocompleteList = useMemo(() => {
- return getAutocompleteTags(allPoliciesTags, activeWorkspaceID);
- }, [activeWorkspaceID, allPoliciesTags]);
- const recentTagsAutocompleteList = getAutocompleteRecentTags(allRecentTags, activeWorkspaceID);
-
const sortedRecentSearches = useMemo(() => {
return Object.values(recentSearches ?? {}).sort((a, b) => b.timestamp.localeCompare(a.timestamp));
}, [recentSearches]);
@@ -137,14 +109,52 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
return reports.slice(0, 10);
}, [debouncedInputValue, filteredOptions, searchOptions]);
- useEffect(() => {
- Report.searchInServer(debouncedInputValue.trim());
- }, [debouncedInputValue]);
-
const contextualReportData = contextualReportID ? searchOptions.recentReports?.find((option) => option.reportID === contextualReportID) : undefined;
+ const {activeWorkspaceID} = useActiveWorkspace();
+ const policy = usePolicy(activeWorkspaceID);
+
+ const typeAutocompleteList = Object.values(CONST.SEARCH.DATA_TYPES);
+ const statusAutocompleteList = Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP});
+ const expenseTypes = Object.values(CONST.SEARCH.TRANSACTION_TYPE);
+ const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST);
+ const cardAutocompleteList = Object.values(cardList);
+ const personalDetailsForParticipants = usePersonalDetails();
+
+ const participantsAutocompleteList = useMemo(
+ () =>
+ Object.values(personalDetailsForParticipants)
+ .filter((details): details is NonNullable => !!(details && details?.login))
+ .map((details) => ({
+ name: details.displayName ?? Str.removeSMSDomain(details.login ?? ''),
+ accountID: details?.accountID.toString(),
+ })),
+ [personalDetailsForParticipants],
+ );
+ const allTaxRates = getAllTaxRates();
+ const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(allTaxRates, policy), [policy, allTaxRates]);
+ const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
+ const [allRecentCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES);
+ const categoryAutocompleteList = useMemo(() => {
+ return getAutocompleteCategories(allPolicyCategories, activeWorkspaceID);
+ }, [activeWorkspaceID, allPolicyCategories]);
+ const recentCategoriesAutocompleteList = useMemo(() => {
+ return getAutocompleteRecentCategories(allRecentCategories, activeWorkspaceID);
+ }, [activeWorkspaceID, allRecentCategories]);
+
+ const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST);
+ const currencyAutocompleteList = Object.keys(currencyList ?? {});
+ const [recentCurrencyAutocompleteList] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES);
+
+ const [allPoliciesTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS);
+ const [allRecentTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS);
+ const tagAutocompleteList = useMemo(() => {
+ return getAutocompleteTags(allPoliciesTags, activeWorkspaceID);
+ }, [activeWorkspaceID, allPoliciesTags]);
+ const recentTagsAutocompleteList = getAutocompleteRecentTags(allRecentTags, activeWorkspaceID);
+
const updateAutocomplete = useCallback(
- (autocompleteValue: string, ranges: AutocompleteRange[], autocompleteType?: ValueOf) => {
+ (autocompleteValue: string, ranges: SearchAutocompleteQueryRange[], autocompleteType?: ValueOf) => {
const alreadyAutocompletedKeys: string[] = [];
ranges.forEach((range) => {
if (!autocompleteType || range.key !== autocompleteType) {
@@ -152,6 +162,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
}
alreadyAutocompletedKeys.push(range.value.toLowerCase());
});
+
+ let filteredAutocompleteSuggestions: AutocompleteItemData[] | undefined;
switch (autocompleteType) {
case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG: {
const autocompleteList = autocompleteValue ? tagAutocompleteList : recentTagsAutocompleteList ?? [];
@@ -159,13 +171,12 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
.filter((tag) => tag.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tag))
.sort()
.slice(0, 10);
- setAutocompleteSuggestions(
- filteredTags.map((tagName) => ({
- text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG}:${tagName}`,
- query: `${SearchQueryUtils.sanitizeSearchValue(tagName)}`,
- })),
- );
- return;
+
+ filteredAutocompleteSuggestions = filteredTags.map((tagName) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG,
+ text: tagName,
+ }));
+ break;
}
case CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY: {
const autocompleteList = autocompleteValue ? categoryAutocompleteList : recentCategoriesAutocompleteList;
@@ -175,13 +186,12 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
})
.sort()
.slice(0, 10);
- setAutocompleteSuggestions(
- filteredCategories.map((categoryName) => ({
- text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY}:${categoryName}`,
- query: `${SearchQueryUtils.sanitizeSearchValue(categoryName)}`,
- })),
- );
- return;
+
+ filteredAutocompleteSuggestions = filteredCategories.map((categoryName) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY,
+ text: categoryName,
+ }));
+ break;
}
case CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY: {
const autocompleteList = autocompleteValue ? currencyAutocompleteList : recentCurrencyAutocompleteList ?? [];
@@ -189,92 +199,110 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
.filter((currency) => currency.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(currency.toLowerCase()))
.sort()
.slice(0, 10);
- setAutocompleteSuggestions(
- filteredCurrencies.map((currencyName) => ({
- text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY}:${currencyName}`,
- query: `${currencyName}`,
- })),
- );
- return;
+
+ filteredAutocompleteSuggestions = filteredCurrencies.map((currencyName) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY,
+ text: currencyName,
+ }));
+ break;
}
case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE: {
const filteredTaxRates = taxAutocompleteList
- .filter((tax) => tax.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tax.toLowerCase()))
+ .filter((tax) => tax.taxRateName.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tax.taxRateName.toLowerCase()))
.sort()
.slice(0, 10);
- setAutocompleteSuggestions(
- filteredTaxRates.map((tax) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE}:${tax}`, query: `${SearchQueryUtils.sanitizeSearchValue(tax)}`})),
- );
- return;
+ filteredAutocompleteSuggestions = filteredTaxRates.map((tax) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE,
+ text: tax.taxRateName,
+ autocompleteID: tax.taxRateIds.join(','),
+ }));
+
+ break;
}
case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: {
const filteredParticipants = participantsAutocompleteList
- .filter((participant) => participant.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.toLowerCase()))
+ .filter(
+ (participant) => participant.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase()),
+ )
.sort()
.slice(0, 10);
- setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM}:${participant}`, query: `${participant}`})));
- return;
+ filteredAutocompleteSuggestions = filteredParticipants.map((participant) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM,
+ text: participant.name,
+ autocompleteID: participant.accountID,
+ }));
+ break;
}
case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: {
const filteredParticipants = participantsAutocompleteList
- .filter((participant) => participant.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.toLowerCase()))
+ .filter(
+ (participant) => participant.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase()),
+ )
.sort()
.slice(0, 10);
- setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${participant}`, query: `${participant}`})));
- return;
+ filteredAutocompleteSuggestions = filteredParticipants.map((participant) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO,
+ text: participant.name,
+ autocompleteID: participant.accountID,
+ }));
+ break;
}
case CONST.SEARCH.SYNTAX_FILTER_KEYS.IN: {
const filteredChats = searchOptions.recentReports
.filter((chat) => chat.text?.toLowerCase()?.includes(autocompleteValue.toLowerCase()))
.sort((chatA, chatB) => (chatA > chatB ? 1 : -1))
.slice(0, 10);
- setAutocompleteSuggestions(filteredChats.map((chat) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${chat.text}`, query: `${chat.reportID}`})));
- return;
+ filteredAutocompleteSuggestions = filteredChats.map((chat) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.IN,
+ text: chat.text ?? '',
+ autocompleteID: chat.reportID,
+ }));
+ break;
}
case CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE: {
const filteredTypes = typeAutocompleteList
.filter((type) => type.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(type.toLowerCase()))
.sort();
- setAutocompleteSuggestions(filteredTypes.map((type) => ({text: `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${type}`, query: `${type}`})));
- return;
+ filteredAutocompleteSuggestions = filteredTypes.map((type) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE, text: type}));
+ break;
}
case CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS: {
const filteredStatuses = statusAutocompleteList
.filter((status) => status.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(status))
.sort()
.slice(0, 10);
- setAutocompleteSuggestions(filteredStatuses.map((status) => ({text: `${CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS}:${status}`, query: `${status}`})));
- return;
+ filteredAutocompleteSuggestions = filteredStatuses.map((status) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS, text: status}));
+ break;
}
case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE: {
const filteredExpenseTypes = expenseTypes
.filter((expenseType) => expenseType.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(expenseType))
.sort();
- setAutocompleteSuggestions(
- filteredExpenseTypes.map((expenseType) => ({
- text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE}:${expenseType}`,
- query: `${expenseType}`,
- })),
- );
- return;
+
+ filteredAutocompleteSuggestions = filteredExpenseTypes.map((expenseType) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE,
+ text: expenseType,
+ }));
+ break;
}
case CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID: {
const filteredCards = cardAutocompleteList
- .filter((card) => card.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(card.toLowerCase()))
+ .filter((card) => card.bank.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(card.bank.toLowerCase()))
.sort()
.slice(0, 10);
- setAutocompleteSuggestions(
- filteredCards.map((card) => ({
- text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID}:${card}`,
- query: `${card}`,
- })),
- );
- return;
+
+ filteredAutocompleteSuggestions = filteredCards.map((card) => ({
+ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID,
+ text: CardUtils.getCardDescription(card.cardID),
+ autocompleteID: card.cardID.toString(),
+ }));
+ break;
}
default: {
- setAutocompleteSuggestions(undefined);
+ filteredAutocompleteSuggestions = undefined;
}
}
+ setAutocompleteSuggestions(filteredAutocompleteSuggestions);
},
[
tagAutocompleteList,
@@ -293,6 +321,10 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
],
);
+ useEffect(() => {
+ Report.searchInServer(debouncedInputValue.trim());
+ }, [debouncedInputValue]);
+
const onSearchChange = useCallback(
(userQuery: string) => {
let newUserQuery = userQuery;
@@ -302,29 +334,44 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
setTextInputValue(newUserQuery);
const autocompleteParsedQuery = parseForAutocomplete(newUserQuery);
updateAutocomplete(autocompleteParsedQuery?.autocomplete?.value ?? '', autocompleteParsedQuery?.ranges ?? [], autocompleteParsedQuery?.autocomplete?.key);
+
+ const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions);
+ setAutocompleteSubstitutions(updatedSubstitutionsMap);
+
if (newUserQuery) {
listRef.current?.updateAndScrollToFocusedIndex(0);
} else {
listRef.current?.updateAndScrollToFocusedIndex(-1);
}
},
- [autocompleteSuggestions, setTextInputValue, updateAutocomplete],
+ [autocompleteSubstitutions, autocompleteSuggestions, setTextInputValue, updateAutocomplete],
);
const onSearchSubmit = useCallback(
- (query: SearchQueryJSON | undefined) => {
- if (!query) {
+ (queryString: SearchQueryString) => {
+ const cleanedQueryString = getQueryWithSubstitutions(queryString, autocompleteSubstitutions);
+ const queryJSON = SearchQueryUtils.buildSearchQueryJSON(cleanedQueryString);
+ if (!queryJSON) {
return;
}
+
onRouterClose();
- const standardizedQuery = SearchQueryUtils.standardizeQueryJSON(query, cardList, allTaxRates);
- const queryString = SearchQueryUtils.buildSearchQueryString(standardizedQuery);
- Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: queryString}));
+
+ const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(queryJSON, SearchQueryUtils.getUpdatedAmountValue);
+ const query = SearchQueryUtils.buildSearchQueryString(standardizedQuery);
+ Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query}));
+
setTextInputValue('');
},
- [allTaxRates, cardList, onRouterClose, setTextInputValue],
+ [autocompleteSubstitutions, onRouterClose, setTextInputValue],
);
+ const onAutocompleteSuggestionClick = (autocompleteKey: string, autocompleteID: string) => {
+ const substitutions = {...autocompleteSubstitutions, [autocompleteKey]: autocompleteID};
+
+ setAutocompleteSubstitutions(substitutions);
+ };
+
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => {
onRouterClose();
});
@@ -347,7 +394,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
isFullWidth={shouldUseNarrowLayout}
updateSearch={onSearchChange}
onSubmit={() => {
- onSearchSubmit(SearchQueryUtils.buildSearchQueryJSON(textInputValue));
+ onSearchSubmit(textInputValue);
}}
routerListRef={listRef}
shouldShowOfflineMessage
@@ -363,9 +410,10 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
reportForContextualSearch={contextualReportData}
recentSearches={sortedRecentSearches?.slice(0, 5)}
recentReports={recentReports}
- autocompleteItems={autocompleteSuggestions}
+ autocompleteSuggestions={autocompleteSuggestions}
onSearchSubmit={onSearchSubmit}
closeRouter={onRouterClose}
+ onAutocompleteSuggestionClick={onAutocompleteSuggestionClick}
ref={listRef}
/>
diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx
index c3799ce5579e..cc854ff926c3 100644
--- a/src/components/Search/SearchRouter/SearchRouterList.tsx
+++ b/src/components/Search/SearchRouter/SearchRouterList.tsx
@@ -3,7 +3,7 @@ import type {ForwardedRef} from 'react';
import {useOnyx} from 'react-native-onyx';
import * as Expensicons from '@components/Icon/Expensicons';
import {usePersonalDetails} from '@components/OnyxProvider';
-import type {SearchQueryJSON} from '@components/Search/types';
+import type {SearchFilterKey, SearchQueryString} from '@components/Search/types';
import SelectionList from '@components/SelectionList';
import SearchQueryListItem from '@components/SelectionList/Search/SearchQueryListItem';
import type {SearchQueryItem, SearchQueryListItemProps} from '@components/SelectionList/Search/SearchQueryListItem';
@@ -16,20 +16,26 @@ import Navigation from '@libs/Navigation/Navigation';
import Performance from '@libs/Performance';
import {getAllTaxRates} from '@libs/PolicyUtils';
import type {OptionData} from '@libs/ReportUtils';
-import {trimSearchQueryForAutocomplete} from '@libs/SearchAutocompleteUtils';
+import {getQueryWithoutAutocompletedPart} from '@libs/SearchAutocompleteUtils';
import * as SearchQueryUtils from '@libs/SearchQueryUtils';
import * as Report from '@userActions/Report';
import Timing from '@userActions/Timing';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
+import {getSubstitutionMapKey} from './getQueryWithSubstitutions';
-type ItemWithQuery = {
+type SearchQueryItemData = {
query: string;
- id?: string;
text?: string;
};
+type AutocompleteItemData = {
+ filterKey: SearchFilterKey;
+ text: string;
+ autocompleteID?: string;
+};
+
type SearchRouterListProps = {
/** value of TextInput */
textInputValue: string;
@@ -41,20 +47,23 @@ type SearchRouterListProps = {
setTextInputValue: (text: string) => void;
/** Recent searches */
- recentSearches: Array | undefined;
+ recentSearches: Array | undefined;
/** Recent reports */
recentReports: OptionData[];
/** Autocomplete items */
- autocompleteItems: ItemWithQuery[] | undefined;
+ autocompleteSuggestions: AutocompleteItemData[] | undefined;
/** Callback to submit query when selecting a list item */
- onSearchSubmit: (query: SearchQueryJSON | undefined) => void;
+ onSearchSubmit: (query: SearchQueryString) => void;
/** Context present when opening SearchRouter from a report, invoice or workspace page */
reportForContextualSearch?: OptionData;
+ /** Callback to run when user clicks a suggestion item that contains autocomplete data */
+ onAutocompleteSuggestionClick: (autocompleteKey: string, autocompleteID: string) => void;
+
/** Callback to close and clear SearchRouter */
closeRouter: () => void;
};
@@ -64,21 +73,25 @@ const setPerformanceTimersEnd = () => {
Performance.markEnd(CONST.TIMING.SEARCH_ROUTER_RENDER);
};
-function getContextualSearchQuery(reportID: string) {
- return `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${CONST.SEARCH.DATA_TYPES.CHAT} in:${reportID}`;
+function getContextualSearchQuery(reportName: string) {
+ return `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${CONST.SEARCH.DATA_TYPES.CHAT} ${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${SearchQueryUtils.sanitizeSearchValue(reportName)}`;
}
function isSearchQueryItem(item: OptionData | SearchQueryItem): item is SearchQueryItem {
- if ('singleIcon' in item && item.singleIcon && 'query' in item && item.query) {
- return true;
- }
- return false;
+ return 'searchItemType' in item;
}
function isSearchQueryListItem(listItem: UserListItemProps | SearchQueryListItemProps): listItem is SearchQueryListItemProps {
return isSearchQueryItem(listItem.item);
}
+function getItemHeight(item: OptionData | SearchQueryItem) {
+ if (isSearchQueryItem(item)) {
+ return 44;
+ }
+ return 64;
+}
+
function SearchRouterItem(props: UserListItemProps | SearchQueryListItemProps) {
const styles = useThemeStyles();
@@ -100,7 +113,18 @@ function SearchRouterItem(props: UserListItemProps | SearchQueryList
}
function SearchRouterList(
- {textInputValue, updateSearchValue, setTextInputValue, reportForContextualSearch, recentSearches, autocompleteItems, recentReports, onSearchSubmit, closeRouter}: SearchRouterListProps,
+ {
+ textInputValue,
+ updateSearchValue,
+ setTextInputValue,
+ reportForContextualSearch,
+ recentSearches,
+ autocompleteSuggestions,
+ recentReports,
+ onSearchSubmit,
+ onAutocompleteSuggestionClick,
+ closeRouter,
+ }: SearchRouterListProps,
ref: ForwardedRef,
) {
const styles = useThemeStyles();
@@ -119,7 +143,7 @@ function SearchRouterList(
{
text: textInputValue,
singleIcon: Expensicons.MagnifyingGlass,
- query: textInputValue,
+ searchQuery: textInputValue,
itemStyle: styles.activeComponentBG,
keyForList: 'findItem',
searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH,
@@ -129,12 +153,14 @@ function SearchRouterList(
}
if (reportForContextualSearch && !textInputValue) {
+ const reportQueryValue = reportForContextualSearch.text ?? reportForContextualSearch.alternateText ?? reportForContextualSearch.reportID;
sections.push({
data: [
{
text: `${translate('search.searchIn')} ${reportForContextualSearch.text ?? reportForContextualSearch.alternateText}`,
singleIcon: Expensicons.MagnifyingGlass,
- query: getContextualSearchQuery(reportForContextualSearch.reportID),
+ searchQuery: reportQueryValue,
+ autocompleteID: reportForContextualSearch.reportID,
itemStyle: styles.activeComponentBG,
keyForList: 'contextualSearch',
searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION,
@@ -143,12 +169,13 @@ function SearchRouterList(
});
}
- const autocompleteData = autocompleteItems?.map(({text, query}) => {
+ const autocompleteData = autocompleteSuggestions?.map(({filterKey, text, autocompleteID}) => {
return {
- text,
+ text: getSubstitutionMapKey(filterKey, text),
singleIcon: Expensicons.MagnifyingGlass,
- query,
- keyForList: query,
+ searchQuery: text,
+ autocompleteID,
+ keyForList: autocompleteID ?? text, // in case we have a unique identifier then use it because text might not be unique
searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION,
};
});
@@ -162,7 +189,7 @@ function SearchRouterList(
return {
text: searchQueryJSON ? SearchQueryUtils.buildUserReadableQueryString(searchQueryJSON, personalDetails, cardList, reports, taxRates) : query,
singleIcon: Expensicons.History,
- query,
+ searchQuery: query,
keyForList: timestamp,
searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH,
};
@@ -178,20 +205,30 @@ function SearchRouterList(
const onSelectRow = useCallback(
(item: OptionData | SearchQueryItem) => {
if (isSearchQueryItem(item)) {
- if (!item?.query) {
+ if (!item.searchQuery) {
return;
}
- if (item?.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) {
- updateSearchValue(`${item?.query} `);
+ if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) {
+ const searchQuery = getContextualSearchQuery(item.searchQuery);
+ updateSearchValue(`${searchQuery} `);
+
+ if (item.autocompleteID) {
+ const autocompleteKey = `${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${item.searchQuery}`;
+ onAutocompleteSuggestionClick(autocompleteKey, item.autocompleteID);
+ }
return;
}
- if (item?.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) {
- const trimmedUserSearchQuery = trimSearchQueryForAutocomplete(textInputValue);
- updateSearchValue(`${trimmedUserSearchQuery}${item?.query} `);
+ if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) {
+ const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue);
+ updateSearchValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(item.searchQuery)} `);
+
+ if (item.autocompleteID && item.text) {
+ onAutocompleteSuggestionClick(item.text, item.autocompleteID);
+ }
return;
}
- onSearchSubmit(SearchQueryUtils.buildSearchQueryJSON(item?.query));
+ onSearchSubmit(item.searchQuery);
}
// Handle selection of "Recent chat"
@@ -202,27 +239,25 @@ function SearchRouterList(
Report.navigateToAndOpenReport(item.login ? [item.login] : [], false);
}
},
- [closeRouter, textInputValue, onSearchSubmit, updateSearchValue],
+ [closeRouter, textInputValue, onSearchSubmit, updateSearchValue, onAutocompleteSuggestionClick],
);
const onArrowFocus = useCallback(
(focusedItem: OptionData | SearchQueryItem) => {
- if (!isSearchQueryItem(focusedItem) || focusedItem?.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION || !textInputValue) {
+ if (!isSearchQueryItem(focusedItem) || focusedItem?.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION || !focusedItem.searchQuery) {
return;
}
- const trimmedUserSearchQuery = trimSearchQueryForAutocomplete(textInputValue);
- setTextInputValue(`${trimmedUserSearchQuery}${focusedItem?.query} `);
+
+ const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue);
+ setTextInputValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(focusedItem.searchQuery)} `);
+
+ if (focusedItem.autocompleteID && focusedItem.text) {
+ onAutocompleteSuggestionClick(focusedItem.text, focusedItem.autocompleteID);
+ }
},
- [setTextInputValue, textInputValue],
+ [setTextInputValue, textInputValue, onAutocompleteSuggestionClick],
);
- const getItemHeight = useCallback((item: OptionData | SearchQueryItem) => {
- if (isSearchQueryItem(item)) {
- return 44;
- }
- return 64;
- }, []);
-
return (
sections={sections}
@@ -244,4 +279,4 @@ function SearchRouterList(
export default forwardRef(SearchRouterList);
export {SearchRouterItem};
-export type {ItemWithQuery};
+export type {AutocompleteItemData};
diff --git a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts
new file mode 100644
index 000000000000..117745fee480
--- /dev/null
+++ b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts
@@ -0,0 +1,50 @@
+import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types';
+import * as parser from '@libs/SearchParser/autocompleteParser';
+
+type SubstitutionMap = Record;
+
+const getSubstitutionMapKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`;
+
+/**
+ * Given a plaintext query and a SubstitutionMap object, this function will return a transformed query where:
+ * - any autocomplete mention in the original query will be substituted with an id taken from `substitutions` object
+ * - anything that does not match will stay as is
+ *
+ * Ex:
+ * query: `A from:@johndoe A`
+ * substitutions: {
+ * from:@johndoe: 9876
+ * }
+ * return: `A from:9876 A`
+ */
+function getQueryWithSubstitutions(changedQuery: string, substitutions: SubstitutionMap) {
+ const parsed = parser.parse(changedQuery) as {ranges: SearchAutocompleteQueryRange[]};
+
+ const searchAutocompleteQueryRanges = parsed.ranges;
+
+ if (searchAutocompleteQueryRanges.length === 0) {
+ return changedQuery;
+ }
+
+ let resultQuery = changedQuery;
+ let lengthDiff = 0;
+
+ for (const range of searchAutocompleteQueryRanges) {
+ const itemKey = getSubstitutionMapKey(range.key, range.value);
+ const substitutionEntry = substitutions[itemKey];
+
+ if (substitutionEntry) {
+ const substitutionStart = range.start + lengthDiff;
+ const substitutionEnd = range.start + range.length;
+
+ // generate new query but substituting "user-typed" value with the entity id/email from substitutions
+ resultQuery = resultQuery.slice(0, substitutionStart) + substitutionEntry + changedQuery.slice(substitutionEnd);
+ lengthDiff = lengthDiff + substitutionEntry.length - range.length;
+ }
+ }
+
+ return resultQuery;
+}
+
+export {getQueryWithSubstitutions, getSubstitutionMapKey};
+export type {SubstitutionMap};
diff --git a/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts
new file mode 100644
index 000000000000..ee7bf3850259
--- /dev/null
+++ b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts
@@ -0,0 +1,43 @@
+import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types';
+import * as parser from '@libs/SearchParser/autocompleteParser';
+import type {SubstitutionMap} from './getQueryWithSubstitutions';
+
+const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`;
+
+/**
+ * Given a plaintext query and a SubstitutionMap object,
+ * this function will remove any substitution keys that do not appear in the query and return an updated object
+ *
+ * Ex:
+ * query: `Test from:John1`
+ * substitutions: {
+ * from:SomeOtherJohn: 12345
+ * }
+ * return: {}
+ */
+function getUpdatedSubstitutionsMap(query: string, substitutions: SubstitutionMap): SubstitutionMap {
+ const parsedQuery = parser.parse(query) as {ranges: SearchAutocompleteQueryRange[]};
+
+ const searchAutocompleteQueryRanges = parsedQuery.ranges;
+
+ if (searchAutocompleteQueryRanges.length === 0) {
+ return {};
+ }
+
+ const autocompleteQueryKeys = searchAutocompleteQueryRanges.map((range) => getSubstitutionsKey(range.key, range.value));
+
+ // Build a new substitutions map consisting of only the keys from old map, that appear in query
+ const updatedSubstitutionMap = autocompleteQueryKeys.reduce((map, key) => {
+ if (substitutions[key]) {
+ // eslint-disable-next-line no-param-reassign
+ map[key] = substitutions[key];
+ }
+
+ return map;
+ }, {} as SubstitutionMap);
+
+ return updatedSubstitutionMap;
+}
+
+// eslint-disable-next-line import/prefer-default-export
+export {getUpdatedSubstitutionsMap};
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx
index 9238488361b0..a13b816fd8b8 100644
--- a/src/components/Search/index.tsx
+++ b/src/components/Search/index.tsx
@@ -466,6 +466,7 @@ function Search({queryJSON, onSearchListScroll, contentContainerStyle}: SearchPr
}
contentContainerStyle={[contentContainerStyle, styles.pb3]}
scrollEventThrottle={1}
+ shouldKeepFocusedItemAtTopOfViewableArea={type === CONST.SEARCH.DATA_TYPES.CHAT}
/>
);
}
diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts
index 2fb034131c86..3e5c158660f1 100644
--- a/src/components/Search/types.ts
+++ b/src/components/Search/types.ts
@@ -1,4 +1,4 @@
-import type {ValueOf} from 'react-native-gesture-handler/lib/typescript/typeUtils';
+import type {ValueOf} from 'type-fest';
import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types';
import type CONST from '@src/CONST';
import type {SearchDataTypes, SearchReport} from '@src/types/onyx/SearchResults';
@@ -56,10 +56,14 @@ type QueryFilter = {
value: string | number;
};
-type AdvancedFiltersKeys = ValueOf;
+type SearchFilterKey =
+ | ValueOf
+ | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE
+ | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS
+ | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.POLICY_ID;
type QueryFilters = Array<{
- key: AdvancedFiltersKeys;
+ key: SearchFilterKey;
filters: QueryFilter[];
}>;
@@ -82,18 +86,18 @@ type SearchQueryJSON = {
flatFilters: QueryFilters;
} & SearchQueryAST;
-type AutocompleteRange = {
- key: ValueOf;
+type SearchAutocompleteResult = {
+ autocomplete: SearchAutocompleteQueryRange | null;
+ ranges: SearchAutocompleteQueryRange[];
+};
+
+type SearchAutocompleteQueryRange = {
+ key: SearchFilterKey;
length: number;
start: number;
value: string;
};
-type SearchAutocompleteResult = {
- autocomplete: AutocompleteRange | null;
- ranges: AutocompleteRange[];
-};
-
export type {
SelectedTransactionInfo,
SelectedTransactions,
@@ -107,11 +111,11 @@ export type {
ASTNode,
QueryFilter,
QueryFilters,
- AdvancedFiltersKeys,
+ SearchFilterKey,
ExpenseSearchStatus,
InvoiceSearchStatus,
TripSearchStatus,
ChatSearchStatus,
SearchAutocompleteResult,
- AutocompleteRange,
+ SearchAutocompleteQueryRange,
};
diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx
index 15a82e327b9a..6570ef020786 100644
--- a/src/components/SelectionList/BaseListItem.tsx
+++ b/src/components/SelectionList/BaseListItem.tsx
@@ -98,13 +98,21 @@ function BaseListItem({
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, [CONST.INNER_BOX_SHADOW_ELEMENT]: true}}
onMouseDown={(e) => e.preventDefault()}
id={keyForList ?? ''}
- style={[pressableStyle, isFocused && StyleUtils.getItemBackgroundColorStyle(!!item.isSelected, !!isFocused, theme.activeComponentBG, theme.hoverComponentBG)]}
+ style={[
+ pressableStyle,
+ isFocused && StyleUtils.getItemBackgroundColorStyle(!!item.isSelected, !!isFocused, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG),
+ ]}
onFocus={onFocus}
onMouseLeave={handleMouseLeave}
tabIndex={item.tabIndex}
wrapperStyle={pressableWrapperStyle}
>
-
+
{typeof children === 'function' ? children(hovered) : children}
{!canSelectMultiple && !!item.isSelected && !rightHandSideComponent && (
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index 3e1b3a3c2d70..e51cfe0152b3 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -1,4 +1,5 @@
import {useFocusEffect, useIsFocused} from '@react-navigation/native';
+import lodashDebounce from 'lodash/debounce';
import isEmpty from 'lodash/isEmpty';
import type {ForwardedRef} from 'react';
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
@@ -24,11 +25,12 @@ import useSingleExecution from '@hooks/useSingleExecution';
import useThemeStyles from '@hooks/useThemeStyles';
import getSectionsWithIndexOffset from '@libs/getSectionsWithIndexOffset';
import Log from '@libs/Log';
-import * as SearchUIUtils from '@libs/SearchUIUtils';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import arraysEqual from '@src/utils/arraysEqual';
+import BaseSelectionListItemRenderer from './BaseSelectionListItemRenderer';
+import FocusAwareCellRendererComponent from './FocusAwareCellRendererComponent';
import type {BaseSelectionListProps, ButtonOrCheckBoxRoles, FlattenedSectionsReturn, ListItem, SectionListDataType, SectionWithIndexOffset, SelectionListHandle} from './types';
const getDefaultItemHeight = () => variables.optionRowHeight;
@@ -107,6 +109,9 @@ function BaseSelectionList(
scrollEventThrottle,
contentContainerStyle,
shouldHighlightSelectedItem = false,
+ shouldKeepFocusedItemAtTopOfViewableArea = false,
+ shouldDebounceScrolling = false,
+ shouldPreventActiveCellVirtualization = false,
}: BaseSelectionListProps,
ref: ForwardedRef,
) {
@@ -126,6 +131,20 @@ function BaseSelectionList(
const [currentPage, setCurrentPage] = useState(1);
const isTextInputFocusedRef = useRef(false);
const {singleExecution} = useSingleExecution();
+ const [itemHeights, setItemHeights] = useState>({});
+
+ const onItemLayout = (event: LayoutChangeEvent, itemKey: string | null | undefined) => {
+ if (!itemKey) {
+ return;
+ }
+
+ const {height} = event.nativeEvent.layout;
+
+ setItemHeights((prevHeights) => ({
+ ...prevHeights,
+ [itemKey]: height,
+ }));
+ };
const incrementPage = () => setCurrentPage((prev) => prev + 1);
@@ -151,7 +170,7 @@ function BaseSelectionList(
const selectedOptions: TItem[] = [];
sections.forEach((section, sectionIndex) => {
- const sectionHeaderHeight = variables.optionsListSectionHeaderHeight;
+ const sectionHeaderHeight = !!section.title || !!section.CustomSectionHeader ? variables.optionsListSectionHeaderHeight : 0;
itemLayouts.push({length: sectionHeaderHeight, offset});
offset += sectionHeaderHeight;
@@ -175,7 +194,7 @@ function BaseSelectionList(
disabledIndex += 1;
// Account for the height of the item in getItemLayout
- const fullItemHeight = getItemHeight(item);
+ const fullItemHeight = item?.keyForList && itemHeights[item.keyForList] ? itemHeights[item.keyForList] : getItemHeight(item);
itemLayouts.push({length: fullItemHeight, offset});
offset += fullItemHeight;
@@ -207,7 +226,7 @@ function BaseSelectionList(
itemLayouts,
allSelected: selectedOptions.length > 0 && selectedOptions.length === allOptions.length - disabledOptionsIndexes.length,
};
- }, [canSelectMultiple, sections, customListHeader, customListHeaderHeight, getItemHeight]);
+ }, [canSelectMultiple, sections, customListHeader, customListHeaderHeight, itemHeights, getItemHeight]);
const [slicedSections, ShowMoreButtonInstance] = useMemo(() => {
let remainingOptionsLimit = CONST.MAX_SELECTION_LIST_PAGE_LENGTH * currentPage;
@@ -257,8 +276,20 @@ function BaseSelectionList(
const itemIndex = item.index ?? -1;
const sectionIndex = item.sectionIndex ?? -1;
+ let viewOffsetToKeepFocusedItemAtTopOfViewableArea = 0;
+
+ // Since there are always two items above the focused item in viewable area, and items can grow beyond the screen size
+ // in searchType chat, the focused item may move out of view. To prevent this, we will ensure that the focused item remains at
+ // the top of the viewable area at all times by adjusting the viewOffset.
+ if (shouldKeepFocusedItemAtTopOfViewableArea) {
+ const firstPreviousItem = index > 0 ? flattenedSections.allOptions.at(index - 1) : undefined;
+ const firstPreviousItemHeight = firstPreviousItem && firstPreviousItem.keyForList ? itemHeights[firstPreviousItem.keyForList] : 0;
+ const secondPreviousItem = index > 1 ? flattenedSections.allOptions.at(index - 2) : undefined;
+ const secondPreviousItemHeight = secondPreviousItem && secondPreviousItem?.keyForList ? itemHeights[secondPreviousItem.keyForList] : 0;
+ viewOffsetToKeepFocusedItemAtTopOfViewableArea = firstPreviousItemHeight + secondPreviousItemHeight;
+ }
- listRef.current.scrollToLocation({sectionIndex, itemIndex, animated, viewOffset: variables.contentHeaderHeight});
+ listRef.current.scrollToLocation({sectionIndex, itemIndex, animated, viewOffset: variables.contentHeaderHeight - viewOffsetToKeepFocusedItemAtTopOfViewableArea});
},
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
@@ -275,6 +306,8 @@ function BaseSelectionList(
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, [flattenedSections.disabledArrowKeyOptionsIndexes]);
+ const debouncedScrollToIndex = useMemo(() => lodashDebounce(scrollToIndex, CONST.TIMING.LIST_SCROLLING_DEBOUNCE_TIME, {leading: true, trailing: true}), [scrollToIndex]);
+
// If `initiallyFocusedOptionKey` is not passed, we fall back to `-1`, to avoid showing the highlight on the first member
const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({
initialFocusedIndex: flattenedSections.allOptions.findIndex((option) => option.keyForList === initiallyFocusedOptionKey),
@@ -286,7 +319,7 @@ function BaseSelectionList(
if (focusedItem) {
onArrowFocus(focusedItem);
}
- scrollToIndex(index, true);
+ (shouldDebounceScrolling ? debouncedScrollToIndex : scrollToIndex)(index, true);
},
isFocused,
});
@@ -301,36 +334,50 @@ function BaseSelectionList(
* @param item - the list item
* @param indexToFocus - the list item index to focus
*/
- const selectRow = (item: TItem, indexToFocus?: number) => {
- // In single-selection lists we don't care about updating the focused index, because the list is closed after selecting an item
- if (canSelectMultiple) {
- if (sections.length > 1) {
- // If the list has only 1 section (e.g. Workspace Members list), we do nothing.
- // If the list has multiple sections (e.g. Workspace Invite list), and `shouldUnfocusRow` is false,
- // we focus the first one after all the selected (selected items are always at the top).
- const selectedOptionsCount = item.isSelected ? flattenedSections.selectedOptions.length - 1 : flattenedSections.selectedOptions.length + 1;
-
- if (!item.isSelected) {
- // If we're selecting an item, scroll to it's position at the top, so we can see it
- scrollToIndex(Math.max(selectedOptionsCount - 1, 0), true);
+ const selectRow = useCallback(
+ (item: TItem, indexToFocus?: number) => {
+ // In single-selection lists we don't care about updating the focused index, because the list is closed after selecting an item
+ if (canSelectMultiple) {
+ if (sections.length > 1) {
+ // If the list has only 1 section (e.g. Workspace Members list), we do nothing.
+ // If the list has multiple sections (e.g. Workspace Invite list), and `shouldUnfocusRow` is false,
+ // we focus the first one after all the selected (selected items are always at the top).
+ const selectedOptionsCount = item.isSelected ? flattenedSections.selectedOptions.length - 1 : flattenedSections.selectedOptions.length + 1;
+
+ if (!item.isSelected) {
+ // If we're selecting an item, scroll to it's position at the top, so we can see it
+ scrollToIndex(Math.max(selectedOptionsCount - 1, 0), true);
+ }
}
- }
- if (shouldShowTextInput) {
- clearInputAfterSelect();
+ if (shouldShowTextInput) {
+ clearInputAfterSelect();
+ }
}
- }
- if (shouldUpdateFocusedIndex && typeof indexToFocus === 'number') {
- setFocusedIndex(indexToFocus);
- }
+ if (shouldUpdateFocusedIndex && typeof indexToFocus === 'number') {
+ setFocusedIndex(indexToFocus);
+ }
- onSelectRow(item);
+ onSelectRow(item);
- if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && innerTextInputRef.current) {
- innerTextInputRef.current.focus();
- }
- };
+ if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && innerTextInputRef.current) {
+ innerTextInputRef.current.focus();
+ }
+ },
+ [
+ canSelectMultiple,
+ sections.length,
+ flattenedSections.selectedOptions.length,
+ scrollToIndex,
+ shouldShowTextInput,
+ clearInputAfterSelect,
+ shouldUpdateFocusedIndex,
+ setFocusedIndex,
+ onSelectRow,
+ shouldPreventDefaultFocusOnSelectRow,
+ ],
+ );
const selectAllRow = () => {
onSelectAll?.();
@@ -442,51 +489,35 @@ function BaseSelectionList(
// We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade.
const showTooltip = shouldShowTooltips && normalizedIndex < 10;
- const handleOnCheckboxPress = () => {
- if (SearchUIUtils.isReportListItemType(item)) {
- return onCheckboxPress;
- }
- return onCheckboxPress ? () => onCheckboxPress(item) : undefined;
- };
-
return (
- <>
- onItemLayout(event, item?.keyForList)}>
+ {
- if (shouldSingleExecuteRowSelect) {
- singleExecution(() => selectRow(item, index))();
- } else {
- selectRow(item, index);
- }
- }}
- onCheckboxPress={handleOnCheckboxPress()}
- onDismissError={() => onDismissError?.(item)}
+ shouldSingleExecuteRowSelect={shouldSingleExecuteRowSelect}
+ selectRow={selectRow}
+ onCheckboxPress={onCheckboxPress}
+ onDismissError={onDismissError}
shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow}
- // We're already handling the Enter key press in the useKeyboardShortcut hook, so we don't want the list item to submit the form
- shouldPreventEnterKeySubmit
rightHandSideComponent={rightHandSideComponent}
- keyForList={item.keyForList ?? ''}
isMultilineSupported={isRowMultilineSupported}
isAlternateTextMultilineSupported={isAlternateTextMultilineSupported}
alternateTextNumberOfLines={alternateTextNumberOfLines}
- onFocus={() => {
- if (shouldIgnoreFocus || isDisabled) {
- return;
- }
- setFocusedIndex(normalizedIndex);
- }}
+ shouldIgnoreFocus={shouldIgnoreFocus}
+ setFocusedIndex={setFocusedIndex}
+ normalizedIndex={normalizedIndex}
shouldSyncFocus={!isTextInputFocusedRef.current}
- shouldHighlightSelectedItem={shouldHighlightSelectedItem}
wrapperStyle={listItemWrapperStyle}
+ shouldHighlightSelectedItem={shouldHighlightSelectedItem}
+ singleExecution={singleExecution}
/>
- {item.footerContent && item.footerContent}
- >
+
);
};
@@ -765,6 +796,7 @@ function BaseSelectionList(
onEndReachedThreshold={onEndReachedThreshold}
scrollEventThrottle={scrollEventThrottle}
contentContainerStyle={contentContainerStyle}
+ CellRendererComponent={shouldPreventActiveCellVirtualization ? FocusAwareCellRendererComponent : undefined}
/>
{children}
>
diff --git a/src/components/SelectionList/BaseSelectionListItemRenderer.tsx b/src/components/SelectionList/BaseSelectionListItemRenderer.tsx
new file mode 100644
index 000000000000..b08d2ae2cfbc
--- /dev/null
+++ b/src/components/SelectionList/BaseSelectionListItemRenderer.tsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import type useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
+import type useSingleExecution from '@hooks/useSingleExecution';
+import * as SearchUIUtils from '@libs/SearchUIUtils';
+import type {BaseListItemProps, BaseSelectionListProps, ListItem} from './types';
+
+type BaseSelectionListItemRendererProps = Omit, 'onSelectRow'> &
+ Pick, 'ListItem' | 'shouldHighlightSelectedItem' | 'shouldIgnoreFocus' | 'shouldSingleExecuteRowSelect'> & {
+ index: number;
+ selectRow: (item: TItem, indexToFocus?: number) => void;
+ setFocusedIndex: ReturnType[1];
+ normalizedIndex: number;
+ singleExecution: ReturnType['singleExecution'];
+ };
+
+function BaseSelectionListItemRenderer({
+ ListItem,
+ item,
+ index,
+ isFocused,
+ isDisabled,
+ showTooltip,
+ canSelectMultiple,
+ onLongPressRow,
+ shouldSingleExecuteRowSelect,
+ selectRow,
+ onCheckboxPress,
+ onDismissError,
+ shouldPreventDefaultFocusOnSelectRow,
+ rightHandSideComponent,
+ isMultilineSupported,
+ isAlternateTextMultilineSupported,
+ alternateTextNumberOfLines,
+ shouldIgnoreFocus,
+ setFocusedIndex,
+ normalizedIndex,
+ shouldSyncFocus,
+ shouldHighlightSelectedItem,
+ wrapperStyle,
+ singleExecution,
+}: BaseSelectionListItemRendererProps) {
+ const handleOnCheckboxPress = () => {
+ if (SearchUIUtils.isReportListItemType(item)) {
+ return onCheckboxPress;
+ }
+ return onCheckboxPress ? () => onCheckboxPress(item) : undefined;
+ };
+
+ return (
+ <>
+ {
+ if (shouldSingleExecuteRowSelect) {
+ singleExecution(() => selectRow(item, index))();
+ } else {
+ selectRow(item, index);
+ }
+ }}
+ onCheckboxPress={handleOnCheckboxPress()}
+ onDismissError={() => onDismissError?.(item)}
+ shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow}
+ // We're already handling the Enter key press in the useKeyboardShortcut hook, so we don't want the list item to submit the form
+ shouldPreventEnterKeySubmit
+ rightHandSideComponent={rightHandSideComponent}
+ keyForList={item.keyForList ?? ''}
+ isMultilineSupported={isMultilineSupported}
+ isAlternateTextMultilineSupported={isAlternateTextMultilineSupported}
+ alternateTextNumberOfLines={alternateTextNumberOfLines}
+ onFocus={() => {
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ if (shouldIgnoreFocus || isDisabled) {
+ return;
+ }
+ setFocusedIndex(normalizedIndex);
+ }}
+ shouldSyncFocus={shouldSyncFocus}
+ shouldHighlightSelectedItem={shouldHighlightSelectedItem}
+ wrapperStyle={wrapperStyle}
+ />
+ {item.footerContent && item.footerContent}
+ >
+ );
+}
+
+BaseSelectionListItemRenderer.displayName = 'BaseSelectionListItemRenderer';
+
+export default BaseSelectionListItemRenderer;
diff --git a/src/components/SelectionList/FocusAwareCellRendererComponent/index.native.tsx b/src/components/SelectionList/FocusAwareCellRendererComponent/index.native.tsx
new file mode 100644
index 000000000000..94833b707acd
--- /dev/null
+++ b/src/components/SelectionList/FocusAwareCellRendererComponent/index.native.tsx
@@ -0,0 +1,3 @@
+const FocusAwareCellRendererComponent = undefined;
+
+export default FocusAwareCellRendererComponent;
diff --git a/src/components/SelectionList/FocusAwareCellRendererComponent/index.tsx b/src/components/SelectionList/FocusAwareCellRendererComponent/index.tsx
new file mode 100644
index 000000000000..1df71d0fcea9
--- /dev/null
+++ b/src/components/SelectionList/FocusAwareCellRendererComponent/index.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import type {FocusEventHandler} from 'react';
+import {View} from 'react-native';
+import type {CellRendererProps} from 'react-native';
+import type {ListItem} from '@components/SelectionList/types';
+
+function FocusAwareCellRendererComponent({onFocusCapture, ...rest}: CellRendererProps) {
+ return (
+
+ );
+}
+
+FocusAwareCellRendererComponent.displayName = 'FocusAwareCellRendererComponent';
+
+export default FocusAwareCellRendererComponent;
diff --git a/src/components/SelectionList/Search/SearchQueryListItem.tsx b/src/components/SelectionList/Search/SearchQueryListItem.tsx
index bba574fa3ac7..77637eed39df 100644
--- a/src/components/SelectionList/Search/SearchQueryListItem.tsx
+++ b/src/components/SelectionList/Search/SearchQueryListItem.tsx
@@ -12,7 +12,8 @@ import type IconAsset from '@src/types/utils/IconAsset';
type SearchQueryItem = ListItem & {
singleIcon?: IconAsset;
- query?: string;
+ searchQuery?: string;
+ autocompleteID?: string;
searchItemType?: ValueOf;
};
diff --git a/src/components/SelectionList/index.tsx b/src/components/SelectionList/index.tsx
index fc788a7e2b4b..ecb63fc31e74 100644
--- a/src/components/SelectionList/index.tsx
+++ b/src/components/SelectionList/index.tsx
@@ -3,6 +3,7 @@ import type {ForwardedRef} from 'react';
import {Keyboard} from 'react-native';
import * as Browser from '@libs/Browser';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
+import CONST from '@src/CONST';
import BaseSelectionList from './BaseSelectionList';
import type {BaseSelectionListProps, ListItem, SelectionListHandle} from './types';
@@ -28,6 +29,33 @@ function SelectionList({onScroll, ...props}: BaseSelecti
};
}, []);
+ const [shouldDebounceScrolling, setShouldDebounceScrolling] = useState(false);
+
+ const checkShouldDebounceScrolling = (event: KeyboardEvent) => {
+ if (!event) {
+ return;
+ }
+
+ // Moving through items using the keyboard triggers scrolling by the browser, so we debounce programmatic scrolling to prevent jittering.
+ if (
+ event.key === CONST.KEYBOARD_SHORTCUTS.ARROW_DOWN.shortcutKey ||
+ event.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey ||
+ event.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey
+ ) {
+ setShouldDebounceScrolling(event.type === 'keydown');
+ }
+ };
+
+ useEffect(() => {
+ document.addEventListener('keydown', checkShouldDebounceScrolling, {passive: true});
+ document.addEventListener('keyup', checkShouldDebounceScrolling, {passive: true});
+
+ return () => {
+ document.removeEventListener('keydown', checkShouldDebounceScrolling);
+ document.removeEventListener('keyup', checkShouldDebounceScrolling);
+ };
+ }, []);
+
// In SearchPageBottomTab we use useAnimatedScrollHandler from reanimated(for performance reasons) and it returns object instead of function. In that case we cannot change it to a function call, that's why we have to choose between onScroll and defaultOnScroll.
const defaultOnScroll = () => {
// Only dismiss the keyboard whenever the user scrolls the screen
@@ -46,6 +74,7 @@ function SelectionList({onScroll, ...props}: BaseSelecti
// Ignore the focus if it's caused by a touch event on mobile chrome.
// For example, a long press will trigger a focus event on mobile chrome.
shouldIgnoreFocus={Browser.isMobileChrome() && isScreenTouched}
+ shouldDebounceScrolling={shouldDebounceScrolling}
/>
);
}
diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts
index 8fb50456182c..e0dc8e2c3729 100644
--- a/src/components/SelectionList/types.ts
+++ b/src/components/SelectionList/types.ts
@@ -597,6 +597,15 @@ type BaseSelectionListProps = Partial & {
/** Whether we highlight all the selected items */
shouldHighlightSelectedItem?: boolean;
+
+ /** Determines if the focused item should remain at the top of the viewable area when navigating with arrow keys */
+ shouldKeepFocusedItemAtTopOfViewableArea?: boolean;
+
+ /** Whether to debounce scrolling on focused index change */
+ shouldDebounceScrolling?: boolean;
+
+ /** Whether to prevent the active cell from being virtualized and losing focus in browsers */
+ shouldPreventActiveCellVirtualization?: boolean;
} & TRightHandSideComponent;
type SelectionListHandle = {
diff --git a/src/components/SelectionListWithModal/index.tsx b/src/components/SelectionListWithModal/index.tsx
index 25123d5454d4..2ea739f531c8 100644
--- a/src/components/SelectionListWithModal/index.tsx
+++ b/src/components/SelectionListWithModal/index.tsx
@@ -79,7 +79,7 @@ function SelectionListWithModal(
const handleLongPressRow = (item: TItem) => {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- if (!turnOnSelectionModeOnLongPress || !isSmallScreenWidth || item?.isDisabled || item?.isDisabledCheckbox) {
+ if (!turnOnSelectionModeOnLongPress || !isSmallScreenWidth || item?.isDisabled || item?.isDisabledCheckbox || !isFocused) {
return;
}
setLongPressedItem(item);
diff --git a/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx b/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx
index 5d1ea0d85d0b..c0b8c32cedcb 100644
--- a/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx
+++ b/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx
@@ -45,21 +45,22 @@ function ProgressBar({duration, position, seekPosition}: ProgressBarProps) {
};
const pan = Gesture.Pan()
+ .runOnJS(true)
.onBegin((event) => {
- runOnJS(setIsSliderPressed)(true);
- runOnJS(checkVideoPlaying)(onCheckVideoPlaying);
- runOnJS(pauseVideo)();
- runOnJS(progressBarInteraction)(event);
+ setIsSliderPressed(true);
+ checkVideoPlaying(onCheckVideoPlaying);
+ pauseVideo();
+ progressBarInteraction(event);
})
.onChange((event) => {
- runOnJS(progressBarInteraction)(event);
+ progressBarInteraction(event);
})
.onFinalize(() => {
- runOnJS(setIsSliderPressed)(false);
+ setIsSliderPressed(false);
if (!wasVideoPlayingOnCheck.value) {
return;
}
- runOnJS(playVideo)();
+ playVideo();
});
useEffect(() => {
diff --git a/src/hooks/usePermissions.ts b/src/hooks/usePermissions.ts
index 22200304fdd5..e60825b610e9 100644
--- a/src/hooks/usePermissions.ts
+++ b/src/hooks/usePermissions.ts
@@ -1,13 +1,12 @@
import {useContext, useMemo} from 'react';
import {BetasContext} from '@components/OnyxProvider';
import Permissions from '@libs/Permissions';
-import type {IOUType} from '@src/CONST';
type PermissionKey = keyof typeof Permissions;
type UsePermissions = Partial>;
let permissionKey: PermissionKey;
-export default function usePermissions(iouType: IOUType | undefined = undefined): UsePermissions {
+export default function usePermissions(): UsePermissions {
const betas = useContext(BetasContext);
return useMemo(() => {
const permissions: UsePermissions = {};
@@ -16,10 +15,10 @@ export default function usePermissions(iouType: IOUType | undefined = undefined)
if (betas) {
const checkerFunction = Permissions[permissionKey];
- permissions[permissionKey] = checkerFunction(betas, iouType);
+ permissions[permissionKey] = checkerFunction(betas);
}
}
return permissions;
- }, [betas, iouType]);
+ }, [betas]);
}
diff --git a/src/hooks/useReportIDs.tsx b/src/hooks/useReportIDs.tsx
index 7c35f2661336..284d80f737f2 100644
--- a/src/hooks/useReportIDs.tsx
+++ b/src/hooks/useReportIDs.tsx
@@ -2,12 +2,10 @@ import React, {createContext, useCallback, useContext, useMemo} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import {useOnyx} from 'react-native-onyx';
import {getPolicyEmployeeListByIdWithoutCurrentUser} from '@libs/PolicyUtils';
-import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import SidebarUtils from '@libs/SidebarUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type * as OnyxTypes from '@src/types/onyx';
-import type {Message} from '@src/types/onyx/ReportAction';
import mapOnyxCollectionItems from '@src/utils/mapOnyxCollectionItems';
import useActiveWorkspace from './useActiveWorkspace';
import useCurrentReportID from './useCurrentReportID';
@@ -34,33 +32,6 @@ const ReportIDsContext = createContext({
policyMemberAccountIDs: [],
});
-/**
- * This function (and the few below it), narrow down the data from Onyx to just the properties that we want to trigger a re-render of the component. This helps minimize re-rendering
- * and makes the entire component more performant because it's not re-rendering when a bunch of properties change which aren't ever used in the UI.
- */
-const reportActionsSelector = (reportActions: OnyxEntry): ReportActionsSelector =>
- (reportActions &&
- Object.values(reportActions)
- .filter(Boolean)
- .map((reportAction) => {
- const {reportActionID, actionName, errors = []} = reportAction;
- const originalMessage = ReportActionsUtils.getOriginalMessage(reportAction);
- const message = ReportActionsUtils.getReportActionMessage(reportAction);
- const decision = message?.moderationDecision?.decision;
-
- return {
- reportActionID,
- actionName,
- errors,
- message: [
- {
- moderationDecision: {decision},
- },
- ] as Message[],
- originalMessage,
- };
- })) as ReportActionsSelector;
-
const policySelector = (policy: OnyxEntry): PolicySelector =>
(policy && {
type: policy.type,
@@ -84,7 +55,6 @@ function ReportIDsContextProvider({
const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE, {initialValue: CONST.PRIORITY_MODE.DEFAULT});
const [chatReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: (c) => mapOnyxCollectionItems(c, policySelector)});
- const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {selector: (c) => mapOnyxCollectionItems(c, reportActionsSelector)});
const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
const [reportsDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT);
const [betas] = useOnyx(ONYXKEYS.BETAS);
@@ -99,20 +69,10 @@ function ReportIDsContextProvider({
const getOrderedReportIDs = useCallback(
(currentReportID?: string) =>
- SidebarUtils.getOrderedReportIDs(
- currentReportID ?? null,
- chatReports,
- betas,
- policies,
- priorityMode,
- allReportActions,
- transactionViolations,
- activeWorkspaceID,
- policyMemberAccountIDs,
- ),
+ SidebarUtils.getOrderedReportIDs(currentReportID ?? null, chatReports, betas, policies, priorityMode, transactionViolations, activeWorkspaceID, policyMemberAccountIDs),
// we need reports draft in deps array for reloading of list when reportsDrafts will change
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
- [chatReports, betas, policies, priorityMode, allReportActions, transactionViolations, activeWorkspaceID, policyMemberAccountIDs, reportsDrafts],
+ [chatReports, betas, policies, priorityMode, transactionViolations, activeWorkspaceID, policyMemberAccountIDs, reportsDrafts],
);
const orderedReportIDs = useMemo(() => getOrderedReportIDs(), [getOrderedReportIDs]);
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 11a31c836add..2ffc2bd21cca 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -3108,8 +3108,8 @@ const translations = {
},
type: {
free: 'Gratis',
- control: 'Control',
- collect: 'Recolectar',
+ control: 'Controlar',
+ collect: 'Recopilar',
},
companyCards: {
addCompanyCards: 'Agregar tarjetas de empresa',
@@ -3256,9 +3256,9 @@ const translations = {
changeCardMonthlyLimitTypeWarning: ({limit}: CharacterLimitParams) =>
`Si cambias el tipo de límite de esta tarjeta a Mensual, las nuevas transacciones serán rechazadas porque ya se ha alcanzado el límite de ${limit} mensual.`,
addShippingDetails: 'Añadir detalles de envío',
- issuedCard: ({assignee}: AssigneeParams) => `¡emitió a ${assignee} una Tarjeta Expensify! La tarjeta llegará en 2-3 días laborables.`,
- issuedCardNoShippingDetails: ({assignee}: AssigneeParams) => `¡emitió a ${assignee} una Tarjeta Expensify! La tarjeta se enviará una vez que se agreguen los detalles de envío.`,
- issuedCardVirtual: ({assignee, link}: IssueVirtualCardParams) => `¡emitió a ${assignee} una ${link} virtual! La tarjeta puede utilizarse inmediatamente.`,
+ issuedCard: ({assignee}: AssigneeParams) => `emitió a ${assignee} una Tarjeta Expensify. La tarjeta llegará en 2-3 días laborables.`,
+ issuedCardNoShippingDetails: ({assignee}: AssigneeParams) => `emitió a ${assignee} una Tarjeta Expensify. La tarjeta se enviará una vez que se agreguen los detalles de envío.`,
+ issuedCardVirtual: ({assignee, link}: IssueVirtualCardParams) => `emitió a ${assignee} una ${link} virtual. La tarjeta puede utilizarse inmediatamente.`,
addedShippingDetails: ({assignee}: AssigneeParams) => `${assignee} agregó los detalles de envío. La Tarjeta Expensify llegará en 2-3 días hábiles.`,
verifyingHeader: 'Verificando',
bankAccountVerifiedHeader: 'Cuenta bancaria verificada',
@@ -4066,52 +4066,52 @@ const translations = {
reportFields: {
title: 'Los campos',
description: `Los campos de informe permiten especificar detalles a nivel de cabecera, distintos de las etiquetas que pertenecen a los gastos en partidas individuales. Estos detalles pueden incluir nombres de proyectos específicos, información sobre viajes de negocios, ubicaciones, etc.`,
- onlyAvailableOnPlan: 'Los campos de informe sólo están disponibles en el plan Control, a partir de ',
+ onlyAvailableOnPlan: 'Los campos de informe sólo están disponibles en el plan Controlar, a partir de ',
},
[CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: {
title: 'NetSuite',
description: `Disfruta de la sincronización automática y reduce las entradas manuales con la integración Expensify + NetSuite. Obtén información financiera en profundidad y en tiempo real con la compatibilidad nativa y personalizada con segmentos, incluida la asignación de proyectos y clientes.`,
- onlyAvailableOnPlan: 'Nuestra integración NetSuite sólo está disponible en el plan Control, a partir de ',
+ onlyAvailableOnPlan: 'Nuestra integración NetSuite sólo está disponible en el plan Controlar, a partir de ',
},
[CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: {
title: 'Sage Intacct',
description: `Disfruta de una sincronización automatizada y reduce las entradas manuales con la integración Expensify + Sage Intacct. Obtén información financiera en profundidad y en tiempo real con dimensiones definidas por el usuario, así como codificación de gastos por departamento, clase, ubicación, cliente y proyecto (trabajo).`,
- onlyAvailableOnPlan: 'Nuestra integración Sage Intacct sólo está disponible en el plan Control, a partir de ',
+ onlyAvailableOnPlan: 'Nuestra integración Sage Intacct sólo está disponible en el plan Controlar, a partir de ',
},
[CONST.POLICY.CONNECTIONS.NAME.QBD]: {
title: 'QuickBooks Desktop',
description: `Disfruta de la sincronización automática y reduce las entradas manuales con la integración de Expensify + QuickBooks Desktop. Obtén la máxima eficiencia con una conexión bidireccional en tiempo real y la codificación de gastos por clase, artículo, cliente y proyecto.`,
- onlyAvailableOnPlan: 'Nuestra integración con QuickBooks Desktop solo está disponible en el plan Control, que comienza en ',
+ onlyAvailableOnPlan: 'Nuestra integración con QuickBooks Desktop solo está disponible en el plan Controlar, que comienza en ',
},
[CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id]: {
title: 'Aprobaciones anticipadas',
description: `Si quieres añadir más niveles de aprobación, o simplemente asegurarte de que los gastos más importantes reciben otro vistazo, no hay problema. Las aprobaciones avanzadas ayudan a realizar las comprobaciones adecuadas a cada nivel para mantener los gastos de tu equipo bajo control.`,
- onlyAvailableOnPlan: 'Las aprobaciones avanzadas sólo están disponibles en el plan Control, con precios desde ',
+ onlyAvailableOnPlan: 'Las aprobaciones avanzadas sólo están disponibles en el plan Controlar, con precios desde ',
},
glCodes: {
title: 'Códigos de libro mayor',
description: `Añada códigos de libro mayor a sus categorías para exportar fácilmente los gastos a sus sistemas de contabilidad y nómina.`,
- onlyAvailableOnPlan: 'Los códigos de libro mayor solo están disponibles en el plan Control, a partir de ',
+ onlyAvailableOnPlan: 'Los códigos de libro mayor solo están disponibles en el plan Controlar, a partir de ',
},
glAndPayrollCodes: {
title: 'Códigos de libro mayor y nómina',
description: `Añada códigos de libro mayor y nómina a sus categorías para exportar fácilmente los gastos a sus sistemas de contabilidad y nómina.`,
- onlyAvailableOnPlan: 'Los códigos de libro mayor y nómina solo están disponibles en el plan Control, a partir de ',
+ onlyAvailableOnPlan: 'Los códigos de libro mayor y nómina solo están disponibles en el plan Controlar, a partir de ',
},
taxCodes: {
title: 'Código de impuesto',
description: `Añada código de impuesto mayor a sus categorías para exportar fácilmente los gastos a sus sistemas de contabilidad y nómina.`,
- onlyAvailableOnPlan: 'Los código de impuesto mayor solo están disponibles en el plan Control, a partir de ',
+ onlyAvailableOnPlan: 'Los código de impuesto mayor solo están disponibles en el plan Controlar, a partir de ',
},
companyCards: {
title: 'Tarjetas de empresa',
description: `Conecta tus tarjetas corporativas existentes a Expensify, asígnalas a empleados e importa transacciones automáticamente.`,
- onlyAvailableOnPlan: 'Las tarjetas de empresa solo están disponibles en el plan Control, a partir de ',
+ onlyAvailableOnPlan: 'Las tarjetas de empresa solo están disponibles en el plan Controlar, a partir de ',
},
rules: {
title: 'Reglas',
description: `Las reglas se ejecutan en segundo plano y mantienen tus gastos bajo control para que no tengas que preocuparte por los detalles pequeños.\n\nExige detalles de los gastos, como recibos y descripciones, establece límites y valores predeterminados, y automatiza las aprobaciones y los pagos, todo en un mismo lugar.`,
- onlyAvailableOnPlan: 'Las reglas están disponibles solo en el plan Control, que comienza en ',
+ onlyAvailableOnPlan: 'Las reglas están disponibles solo en el plan Controlar, que comienza en ',
},
note: {
upgradeWorkspace: 'Mejore su espacio de trabajo para acceder a esta función, o',
@@ -4125,7 +4125,7 @@ const translations = {
upgradeToUnlock: 'Desbloquear esta función',
completed: {
headline: 'Has mejorado tu espacio de trabajo.',
- successMessage: ({policyName}: ReportPolicyNameParams) => `Has actualizado con éxito ${policyName} al plan Control.`,
+ successMessage: ({policyName}: ReportPolicyNameParams) => `Has actualizado con éxito ${policyName} al plan Controlar.`,
viewSubscription: 'Ver su suscripción',
moreDetails: 'para obtener más información.',
gotIt: 'Entendido, gracias.',
@@ -5482,7 +5482,7 @@ const translations = {
yourPlan: {
title: 'Tu plan',
collect: {
- title: 'Recolectar',
+ title: 'Recopilar',
priceAnnual: ({lower, upper}: YourPlanPriceParams) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`,
pricePayPerUse: ({lower, upper}: YourPlanPriceParams) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`,
benefit1: 'SmartScans ilimitados y seguimiento de la distancia',
@@ -5494,10 +5494,10 @@ const translations = {
benefit7: 'Reportes e informes personalizados',
},
control: {
- title: 'Control',
+ title: 'Controlar',
priceAnnual: ({lower, upper}: YourPlanPriceParams) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`,
pricePayPerUse: ({lower, upper}: YourPlanPriceParams) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`,
- benefit1: 'Todo en Recolectar, más:',
+ benefit1: 'Todo en Recopilar, más:',
benefit2: 'Integraciones con NetSuite y Sage Intacct',
benefit3: 'Sincronización de Certinia y Workday',
benefit4: 'Varios aprobadores de gastos',
diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts
index 286f952b3484..7087605e24c5 100644
--- a/src/libs/DistanceRequestUtils.ts
+++ b/src/libs/DistanceRequestUtils.ts
@@ -277,7 +277,7 @@ function convertToDistanceInMeters(distance: number, unit: Unit): number {
/**
* Returns custom unit rate ID for the distance transaction
*/
-function getCustomUnitRateID(reportID: string, shouldUseDefault?: boolean) {
+function getCustomUnitRateID(reportID: string) {
const allReports = ReportConnection.getAllReports();
const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`];
@@ -288,7 +288,7 @@ function getCustomUnitRateID(reportID: string, shouldUseDefault?: boolean) {
const distanceUnit = Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
const lastSelectedDistanceRateID = lastSelectedDistanceRates?.[policy?.id ?? '-1'] ?? '-1';
const lastSelectedDistanceRate = distanceUnit?.rates[lastSelectedDistanceRateID] ?? {};
- if (lastSelectedDistanceRate.enabled && lastSelectedDistanceRateID && !shouldUseDefault) {
+ if (lastSelectedDistanceRate.enabled && lastSelectedDistanceRateID) {
customUnitRateID = lastSelectedDistanceRateID;
} else {
customUnitRateID = getDefaultMileageRate(policy)?.customUnitRateID ?? '-1';
diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
index 93b3954d2f2b..34bdf866dbb8 100644
--- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
@@ -7,6 +7,7 @@ import {PressableWithFeedback} from '@components/Pressable';
import type {SearchQueryString} from '@components/Search/types';
import Text from '@components/Text';
import useActiveWorkspace from '@hooks/useActiveWorkspace';
+import useCurrentReportID from '@hooks/useCurrentReportID';
import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -65,15 +66,23 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {activeWorkspaceID} = useActiveWorkspace();
+ const {currentReportID} = useCurrentReportID() ?? {currentReportID: null};
const [user] = useOnyx(ONYXKEYS.USER);
+ const [betas] = useOnyx(ONYXKEYS.BETAS);
+ const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE);
const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
+ const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS);
const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
- const [chatTabBrickRoad, setChatTabBrickRoad] = useState(getChatTabBrickRoad(activeWorkspaceID));
+ const [chatTabBrickRoad, setChatTabBrickRoad] = useState(
+ getChatTabBrickRoad(activeWorkspaceID, currentReportID, reports, betas, policies, priorityMode, transactionViolations),
+ );
useEffect(() => {
- setChatTabBrickRoad(getChatTabBrickRoad(activeWorkspaceID));
- }, [activeWorkspaceID, transactionViolations, reports, reportActions]);
+ setChatTabBrickRoad(getChatTabBrickRoad(activeWorkspaceID, currentReportID, reports, betas, policies, priorityMode, transactionViolations));
+ // We need to get a new brick road state when report actions are updated, otherwise we'll be showing an outdated brick road.
+ // That's why reportActions is added as a dependency here
+ }, [activeWorkspaceID, transactionViolations, reports, reportActions, betas, policies, priorityMode, currentReportID]);
const navigateToChats = useCallback(() => {
if (selectedTab === SCREENS.HOME) {
@@ -118,6 +127,12 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) {
selectedTab={selectedTab}
chatTabBrickRoad={chatTabBrickRoad}
activeWorkspaceID={activeWorkspaceID}
+ reports={reports}
+ currentReportID={currentReportID}
+ betas={betas}
+ policies={policies}
+ transactionViolations={transactionViolations}
+ priorityMode={priorityMode}
/>
)}
diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView.tsx
index 054ced8bc9bb..354529941e0c 100644
--- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView.tsx
@@ -1,6 +1,6 @@
import React, {useCallback, useMemo} from 'react';
import {View} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
+import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
import Icon from '@components/Icon';
@@ -21,12 +21,18 @@ import ONYXKEYS from '@src/ONYXKEYS';
import type {Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
-import type {ReimbursementAccount} from '@src/types/onyx';
+import type {Beta, Policy, PriorityMode, ReimbursementAccount, Report, TransactionViolations} from '@src/types/onyx';
type DebugTabViewProps = {
selectedTab?: string;
chatTabBrickRoad: BrickRoad;
activeWorkspaceID?: string;
+ currentReportID: string | null;
+ reports: OnyxCollection;
+ betas: OnyxEntry;
+ policies: OnyxCollection;
+ transactionViolations: OnyxCollection;
+ priorityMode: OnyxEntry;
};
function getSettingsMessage(status: IndicatorStatus | undefined): TranslationPaths | undefined {
@@ -91,7 +97,7 @@ function getSettingsRoute(status: IndicatorStatus | undefined, reimbursementAcco
}
}
-function DebugTabView({selectedTab = '', chatTabBrickRoad, activeWorkspaceID}: DebugTabViewProps) {
+function DebugTabView({selectedTab = '', chatTabBrickRoad, activeWorkspaceID, currentReportID, reports, betas, policies, transactionViolations, priorityMode}: DebugTabViewProps) {
const StyleUtils = useStyleUtils();
const theme = useTheme();
const styles = useThemeStyles();
@@ -131,7 +137,7 @@ function DebugTabView({selectedTab = '', chatTabBrickRoad, activeWorkspaceID}: D
const navigateTo = useCallback(() => {
if (selectedTab === SCREENS.HOME && !!chatTabBrickRoad) {
- const report = getChatTabBrickRoadReport(activeWorkspaceID);
+ const report = getChatTabBrickRoadReport(activeWorkspaceID, currentReportID, reports, betas, policies, priorityMode, transactionViolations);
if (report) {
Navigation.navigate(ROUTES.DEBUG_REPORT.getRoute(report.reportID));
@@ -144,7 +150,7 @@ function DebugTabView({selectedTab = '', chatTabBrickRoad, activeWorkspaceID}: D
Navigation.navigate(route);
}
}
- }, [selectedTab, chatTabBrickRoad, activeWorkspaceID, status, reimbursementAccount, policyIDWithErrors]);
+ }, [selectedTab, chatTabBrickRoad, activeWorkspaceID, currentReportID, reports, betas, policies, priorityMode, transactionViolations, status, reimbursementAccount, policyIDWithErrors]);
if (!([SCREENS.HOME, SCREENS.SETTINGS.ROOT] as string[]).includes(selectedTab) || !indicator) {
return null;
diff --git a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx
index 18cb758c5703..a5746f6f8e81 100644
--- a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx
@@ -1,6 +1,9 @@
import type {ParamListBase, PartialState, RouterConfigOptions, StackNavigationState} from '@react-navigation/native';
import {StackRouter} from '@react-navigation/native';
+import Onyx from 'react-native-onyx';
import getIsNarrowLayout from '@libs/getIsNarrowLayout';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
import SCREENS from '@src/SCREENS';
import type {FullScreenNavigatorRouterOptions} from './types';
@@ -8,12 +11,29 @@ type StackState = StackNavigationState | PartialState state.routes.some((route) => route.name === screenName);
+let isLoadingReportData = true;
+Onyx.connect({
+ key: ONYXKEYS.IS_LOADING_REPORT_DATA,
+ initWithStoredValues: false,
+ callback: (value) => (isLoadingReportData = value ?? false),
+});
+
function adaptStateIfNecessary(state: StackState) {
const isNarrowLayout = getIsNarrowLayout();
const workspaceCentralPane = state.routes.at(-1);
+ const policyID =
+ workspaceCentralPane?.params && 'policyID' in workspaceCentralPane.params && typeof workspaceCentralPane.params.policyID === 'string'
+ ? workspaceCentralPane.params.policyID
+ : undefined;
+ const policy = PolicyUtils.getPolicy(policyID ?? '');
+ const isPolicyAccessible = PolicyUtils.isPolicyAccessible(policy);
// There should always be WORKSPACE.INITIAL screen in the state to make sure go back works properly if we deeplinkg to a subpage of settings.
+ // The only exception is when the workspace is invalid or inaccessible.
if (!isAtLeastOneInState(state, SCREENS.WORKSPACE.INITIAL)) {
+ if (isNarrowLayout && !isLoadingReportData && !isPolicyAccessible) {
+ return;
+ }
// @ts-expect-error Updating read only property
// noinspection JSConstantReassignment
state.stale = true; // eslint-disable-line
diff --git a/src/libs/Navigation/AppNavigator/createCustomPlatformStackBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomPlatformStackBottomTabNavigator/BottomTabBar.tsx
index 8ac3845b52c2..0c5e9bf20741 100644
--- a/src/libs/Navigation/AppNavigator/createCustomPlatformStackBottomTabNavigator/BottomTabBar.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomPlatformStackBottomTabNavigator/BottomTabBar.tsx
@@ -8,11 +8,13 @@ import {PressableWithFeedback} from '@components/Pressable';
import type {SearchQueryString} from '@components/Search/types';
import Tooltip from '@components/Tooltip';
import useActiveWorkspace from '@hooks/useActiveWorkspace';
+import useCurrentReportID from '@hooks/useCurrentReportID';
import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import * as Session from '@libs/actions/Session';
import interceptAnonymousUser from '@libs/interceptAnonymousUser';
+import DebugTabView from '@libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView';
import Navigation from '@libs/Navigation/Navigation';
import type {AuthScreensParamList} from '@libs/Navigation/types';
import {isCentralPaneName} from '@libs/NavigationUtils';
@@ -72,12 +74,23 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) {
const navigation = useNavigation();
const {activeWorkspaceID} = useActiveWorkspace();
const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP);
- const transactionViolations = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
- const [chatTabBrickRoad, setChatTabBrickRoad] = useState(getChatTabBrickRoad(activeWorkspaceID));
+ const {currentReportID} = useCurrentReportID() ?? {currentReportID: null};
+ const [user] = useOnyx(ONYXKEYS.USER);
+ const [betas] = useOnyx(ONYXKEYS.BETAS);
+ const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE);
+ const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
+ const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
+ const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS);
+ const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
+ const [chatTabBrickRoad, setChatTabBrickRoad] = useState(
+ getChatTabBrickRoad(activeWorkspaceID, currentReportID, reports, betas, policies, priorityMode, transactionViolations),
+ );
useEffect(() => {
- setChatTabBrickRoad(getChatTabBrickRoad(activeWorkspaceID));
- }, [activeWorkspaceID, transactionViolations]);
+ setChatTabBrickRoad(getChatTabBrickRoad(activeWorkspaceID, currentReportID, reports, betas, policies, priorityMode, transactionViolations));
+ // We need to get a new brick road state when report actions are updated, otherwise we'll be showing an outdated brick road.
+ // That's why reportActions is added as a dependency here
+ }, [activeWorkspaceID, transactionViolations, reports, reportActions, betas, policies, priorityMode, currentReportID]);
useEffect(() => {
const navigationState = navigation.getState();
@@ -138,51 +151,66 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) {
}, [activeWorkspaceID, selectedTab]);
return (
-
-
-
-
-
- {!!chatTabBrickRoad && (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
+ <>
+ {!!user?.isDebugModeEnabled && (
+
+ )}
+
+
+
+
+
+ {!!chatTabBrickRoad && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+ >
);
}
diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts
index d5e9c5229a89..d54668bf3f69 100644
--- a/src/libs/Navigation/Navigation.ts
+++ b/src/libs/Navigation/Navigation.ts
@@ -12,6 +12,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
import type {HybridAppRoute, Route} from '@src/ROUTES';
import ROUTES, {HYBRID_APP_ROUTES} from '@src/ROUTES';
import {PROTECTED_SCREENS} from '@src/SCREENS';
+import type {Screen} from '@src/SCREENS';
import type {Report} from '@src/types/onyx';
import originalCloseRHPFlow from './closeRHPFlow';
import originalDismissModal from './dismissModal';
@@ -418,6 +419,20 @@ function getTopMostCentralPaneRouteFromRootState() {
return getTopmostCentralPaneRoute(navigationRef.getRootState() as State);
}
+function removeScreenFromNavigationState(screen: Screen) {
+ isNavigationReady().then(() => {
+ navigationRef.dispatch((state) => {
+ const routes = state.routes?.filter((item) => item.name !== screen);
+
+ return CommonActions.reset({
+ ...state,
+ routes,
+ index: routes.length < state.routes.length ? state.index - 1 : state.index,
+ });
+ });
+ });
+}
+
export default {
setShouldPopAllStateOnUP,
navigate,
@@ -442,6 +457,7 @@ export default {
closeRHPFlow,
setNavigationActionToMicrotaskQueue,
getTopMostCentralPaneRouteFromRootState,
+ removeScreenFromNavigationState,
};
export {navigationRef};
diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts
index 0853bd9c18ce..b0591d1ad42b 100644
--- a/src/libs/Permissions.ts
+++ b/src/libs/Permissions.ts
@@ -1,6 +1,5 @@
import type {OnyxEntry} from 'react-native-onyx';
import CONST from '@src/CONST';
-import type {IOUType} from '@src/CONST';
import type Beta from '@src/types/onyx/Beta';
function canUseAllBetas(betas: OnyxEntry): boolean {
@@ -15,11 +14,6 @@ function canUseDupeDetection(betas: OnyxEntry): boolean {
return !!betas?.includes(CONST.BETAS.DUPE_DETECTION) || canUseAllBetas(betas);
}
-function canUseP2PDistanceRequests(betas: OnyxEntry, iouType: IOUType | undefined): boolean {
- // Allow using P2P distance request for TrackExpense outside of the beta, because that project doesn't want to be limited by the more cautious P2P distance beta
- return !!betas?.includes(CONST.BETAS.P2P_DISTANCE_REQUESTS) || canUseAllBetas(betas) || iouType === CONST.IOU.TYPE.TRACK;
-}
-
function canUseSpotnanaTravel(betas: OnyxEntry): boolean {
return !!betas?.includes(CONST.BETAS.SPOTNANA_TRAVEL) || canUseAllBetas(betas);
}
@@ -52,7 +46,6 @@ export default {
canUseDefaultRooms,
canUseLinkPreviews,
canUseDupeDetection,
- canUseP2PDistanceRequests,
canUseSpotnanaTravel,
canUseNetSuiteUSATax,
canUseCombinedTrackSubmit,
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index c596357585bc..9050d3601046 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -1064,6 +1064,10 @@ function getActivePolicy(): OnyxEntry {
return getPolicy(activePolicyId);
}
+function isPolicyAccessible(policy: OnyxEntry): boolean {
+ return !isEmptyObject(policy) && (Object.keys(policy).length !== 1 || isEmptyObject(policy.errors)) && !!policy?.id;
+}
+
export {
canEditTaxRate,
extractPolicyIDFromPath,
@@ -1181,6 +1185,7 @@ export {
getNetSuiteImportCustomFieldLabel,
getAllPoliciesLength,
getActivePolicy,
+ isPolicyAccessible,
};
export type {MemberEmailsToAccountIDs};
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index d8133991d62b..a62716975c01 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -7326,8 +7326,7 @@ function canCreateRequest(report: OnyxEntry, policy: OnyxEntry,
return requestOptions.includes(iouType);
}
-function getWorkspaceChats(policyID: string, accountIDs: number[]): Array> {
- const allReports = ReportConnection.getAllReports();
+function getWorkspaceChats(policyID: string, accountIDs: number[], allReports: OnyxCollection = ReportConnection.getAllReports()): Array> {
return Object.values(allReports ?? {}).filter((report) => isPolicyExpenseChat(report) && (report?.policyID ?? '-1') === policyID && accountIDs.includes(report?.ownerAccountID ?? -1));
}
diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts
index f33e2a82d445..fd427b7480c6 100644
--- a/src/libs/SearchAutocompleteUtils.ts
+++ b/src/libs/SearchAutocompleteUtils.ts
@@ -5,6 +5,10 @@ import type {Policy, PolicyCategories, PolicyTagLists, RecentlyUsedCategories, R
import {getTagNamesFromTagsLists} from './PolicyUtils';
import * as autocompleteParser from './SearchParser/autocompleteParser';
+/**
+ * Parses given query using the autocomplete parser.
+ * This is a smaller and simpler version of search parser used for autocomplete displaying logic.
+ */
function parseForAutocomplete(text: string) {
try {
const parsedAutocomplete = autocompleteParser.parse(text) as SearchAutocompleteResult;
@@ -14,6 +18,9 @@ function parseForAutocomplete(text: string) {
}
}
+/**
+ * Returns data for computing the `Tag` filter autocomplete list.
+ */
function getAutocompleteTags(allPoliciesTagsLists: OnyxCollection, policyID?: string) {
const singlePolicyTagsList: PolicyTagLists | undefined = allPoliciesTagsLists?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`];
if (!singlePolicyTagsList) {
@@ -28,6 +35,9 @@ function getAutocompleteTags(allPoliciesTagsLists: OnyxCollection, policyID?: string) {
const singlePolicyRecentTags: RecentlyUsedTags | undefined = allRecentTags?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`];
if (!singlePolicyRecentTags) {
@@ -41,6 +51,9 @@ function getAutocompleteRecentTags(allRecentTags: OnyxCollection, policyID?: string) {
const singlePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`];
if (!singlePolicyCategories) {
@@ -51,6 +64,9 @@ function getAutocompleteCategories(allPolicyCategories: OnyxCollection category.name);
}
+/**
+ * Returns data for computing the recent categories autocomplete list.
+ */
function getAutocompleteRecentCategories(allRecentCategories: OnyxCollection, policyID?: string) {
const singlePolicyRecentCategories = allRecentCategories?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${policyID}`];
if (!singlePolicyRecentCategories) {
@@ -61,18 +77,43 @@ function getAutocompleteRecentCategories(allRecentCategories: OnyxCollection category);
}
-function getAutocompleteTaxList(allTaxRates: Record, policy?: OnyxEntry) {
+/**
+ * Returns data for computing the `Tax` filter autocomplete list
+ *
+ * Please note: taxes are stored in a quite convoluted and non-obvious way, and there can be multiple taxes with the same id
+ * because tax ids are generated based on a tax name, so they look like this: `id_My_Tax` and are not numeric.
+ * That is why this function may seem a bit complex.
+ */
+function getAutocompleteTaxList(taxRates: Record, policy?: OnyxEntry) {
if (policy) {
- return Object.keys(policy?.taxRates?.taxes ?? {}).map((taxRateName) => taxRateName);
+ const policyTaxes = policy?.taxRates?.taxes ?? {};
+
+ return Object.keys(policyTaxes).map((taxID) => ({
+ taxRateName: policyTaxes[taxID].name,
+ taxRateIds: [taxID],
+ }));
}
- return Object.keys(allTaxRates).map((taxRateName) => taxRateName);
+
+ return Object.keys(taxRates).map((taxName) => ({
+ taxRateName: taxName,
+ taxRateIds: taxRates[taxName].map((id) => taxRates[id] ?? id).flat(),
+ }));
}
-function trimSearchQueryForAutocomplete(searchQuery: string) {
- const lastColonIndex = searchQuery.lastIndexOf(':');
- const lastCommaIndex = searchQuery.lastIndexOf(',');
- const trimmedUserSearchQuery = lastColonIndex > lastCommaIndex ? searchQuery.slice(0, lastColonIndex + 1) : searchQuery.slice(0, lastCommaIndex + 1);
- return trimmedUserSearchQuery;
+/**
+ * Given a query string, this function parses it with the autocomplete parser
+ * and returns only the part of the string before autocomplete.
+ *
+ * Ex: "test from:john@doe" -> "test from:"
+ */
+function getQueryWithoutAutocompletedPart(searchQuery: string) {
+ const parsedQuery = parseForAutocomplete(searchQuery);
+ if (!parsedQuery?.autocomplete) {
+ return searchQuery;
+ }
+
+ const sliceEnd = parsedQuery.autocomplete.start;
+ return searchQuery.slice(0, sliceEnd);
}
export {
@@ -82,5 +123,5 @@ export {
getAutocompleteCategories,
getAutocompleteRecentCategories,
getAutocompleteTaxList,
- trimSearchQueryForAutocomplete,
+ getQueryWithoutAutocompletedPart,
};
diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js
index be57ff8a67a5..bd114b56e099 100644
--- a/src/libs/SearchParser/autocompleteParser.js
+++ b/src/libs/SearchParser/autocompleteParser.js
@@ -186,12 +186,13 @@ function peg$parse(input, options) {
var peg$c8 = "expenseType";
var peg$c9 = "type";
var peg$c10 = "status";
- var peg$c11 = "!=";
- var peg$c12 = ">=";
- var peg$c13 = ">";
- var peg$c14 = "<=";
- var peg$c15 = "<";
- var peg$c16 = "\"";
+ var peg$c11 = "cardID";
+ var peg$c12 = "!=";
+ var peg$c13 = ">=";
+ var peg$c14 = ">";
+ var peg$c15 = "<=";
+ var peg$c16 = "<";
+ var peg$c17 = "\"";
var peg$r0 = /^[:=]/;
var peg$r1 = /^[^ ,"\t\n\r]/;
@@ -211,21 +212,22 @@ function peg$parse(input, options) {
var peg$e9 = peg$literalExpectation("expenseType", false);
var peg$e10 = peg$literalExpectation("type", false);
var peg$e11 = peg$literalExpectation("status", false);
- var peg$e12 = peg$otherExpectation("operator");
- var peg$e13 = peg$classExpectation([":", "="], false, false);
- var peg$e14 = peg$literalExpectation("!=", false);
- var peg$e15 = peg$literalExpectation(">=", false);
- var peg$e16 = peg$literalExpectation(">", false);
- var peg$e17 = peg$literalExpectation("<=", false);
- var peg$e18 = peg$literalExpectation("<", false);
- var peg$e19 = peg$otherExpectation("quote");
- var peg$e20 = peg$classExpectation([" ", ",", "\"", "\t", "\n", "\r"], true, false);
- var peg$e21 = peg$literalExpectation("\"", false);
- var peg$e22 = peg$classExpectation(["\"", "\r", "\n"], true, false);
- var peg$e23 = peg$classExpectation([" ", ",", "\t", "\n", "\r"], true, false);
- var peg$e24 = peg$otherExpectation("word");
- var peg$e25 = peg$otherExpectation("whitespace");
- var peg$e26 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false);
+ var peg$e12 = peg$literalExpectation("cardID", false);
+ var peg$e13 = peg$otherExpectation("operator");
+ var peg$e14 = peg$classExpectation([":", "="], false, false);
+ var peg$e15 = peg$literalExpectation("!=", false);
+ var peg$e16 = peg$literalExpectation(">=", false);
+ var peg$e17 = peg$literalExpectation(">", false);
+ var peg$e18 = peg$literalExpectation("<=", false);
+ var peg$e19 = peg$literalExpectation("<", false);
+ var peg$e20 = peg$otherExpectation("quote");
+ var peg$e21 = peg$classExpectation([" ", ",", "\"", "\t", "\n", "\r"], true, false);
+ var peg$e22 = peg$literalExpectation("\"", false);
+ var peg$e23 = peg$classExpectation(["\"", "\r", "\n"], true, false);
+ var peg$e24 = peg$classExpectation([" ", ",", "\t", "\n", "\r"], true, false);
+ var peg$e25 = peg$otherExpectation("word");
+ var peg$e26 = peg$otherExpectation("whitespace");
+ var peg$e27 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false);
var peg$f0 = function(ranges) { return { autocomplete, ranges }; };
var peg$f1 = function(filters) { return filters.filter(Boolean).flat(); };
@@ -644,6 +646,15 @@ function peg$parse(input, options) {
s1 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e11); }
}
+ if (s1 === peg$FAILED) {
+ if (input.substr(peg$currPos, 6) === peg$c11) {
+ s1 = peg$c11;
+ peg$currPos += 6;
+ } else {
+ s1 = peg$FAILED;
+ if (peg$silentFails === 0) { peg$fail(peg$e12); }
+ }
+ }
}
}
}
@@ -740,7 +751,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e13); }
+ if (peg$silentFails === 0) { peg$fail(peg$e14); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
@@ -749,12 +760,12 @@ function peg$parse(input, options) {
s0 = s1;
if (s0 === peg$FAILED) {
s0 = peg$currPos;
- if (input.substr(peg$currPos, 2) === peg$c11) {
- s1 = peg$c11;
+ if (input.substr(peg$currPos, 2) === peg$c12) {
+ s1 = peg$c12;
peg$currPos += 2;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e14); }
+ if (peg$silentFails === 0) { peg$fail(peg$e15); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
@@ -763,12 +774,12 @@ function peg$parse(input, options) {
s0 = s1;
if (s0 === peg$FAILED) {
s0 = peg$currPos;
- if (input.substr(peg$currPos, 2) === peg$c12) {
- s1 = peg$c12;
+ if (input.substr(peg$currPos, 2) === peg$c13) {
+ s1 = peg$c13;
peg$currPos += 2;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e15); }
+ if (peg$silentFails === 0) { peg$fail(peg$e16); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
@@ -778,11 +789,11 @@ function peg$parse(input, options) {
if (s0 === peg$FAILED) {
s0 = peg$currPos;
if (input.charCodeAt(peg$currPos) === 62) {
- s1 = peg$c13;
+ s1 = peg$c14;
peg$currPos++;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e16); }
+ if (peg$silentFails === 0) { peg$fail(peg$e17); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
@@ -791,12 +802,12 @@ function peg$parse(input, options) {
s0 = s1;
if (s0 === peg$FAILED) {
s0 = peg$currPos;
- if (input.substr(peg$currPos, 2) === peg$c14) {
- s1 = peg$c14;
+ if (input.substr(peg$currPos, 2) === peg$c15) {
+ s1 = peg$c15;
peg$currPos += 2;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e17); }
+ if (peg$silentFails === 0) { peg$fail(peg$e18); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
@@ -806,11 +817,11 @@ function peg$parse(input, options) {
if (s0 === peg$FAILED) {
s0 = peg$currPos;
if (input.charCodeAt(peg$currPos) === 60) {
- s1 = peg$c15;
+ s1 = peg$c16;
peg$currPos++;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e18); }
+ if (peg$silentFails === 0) { peg$fail(peg$e19); }
}
if (s1 !== peg$FAILED) {
peg$savedPos = s0;
@@ -825,7 +836,7 @@ function peg$parse(input, options) {
peg$silentFails--;
if (s0 === peg$FAILED) {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e12); }
+ if (peg$silentFails === 0) { peg$fail(peg$e13); }
}
return s0;
@@ -842,7 +853,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s2 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e20); }
+ if (peg$silentFails === 0) { peg$fail(peg$e21); }
}
while (s2 !== peg$FAILED) {
s1.push(s2);
@@ -851,15 +862,15 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s2 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e20); }
+ if (peg$silentFails === 0) { peg$fail(peg$e21); }
}
}
if (input.charCodeAt(peg$currPos) === 34) {
- s2 = peg$c16;
+ s2 = peg$c17;
peg$currPos++;
} else {
s2 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e21); }
+ if (peg$silentFails === 0) { peg$fail(peg$e22); }
}
if (s2 !== peg$FAILED) {
s3 = [];
@@ -868,7 +879,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s4 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e22); }
+ if (peg$silentFails === 0) { peg$fail(peg$e23); }
}
while (s4 !== peg$FAILED) {
s3.push(s4);
@@ -877,15 +888,15 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s4 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e22); }
+ if (peg$silentFails === 0) { peg$fail(peg$e23); }
}
}
if (input.charCodeAt(peg$currPos) === 34) {
- s4 = peg$c16;
+ s4 = peg$c17;
peg$currPos++;
} else {
s4 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e21); }
+ if (peg$silentFails === 0) { peg$fail(peg$e22); }
}
if (s4 !== peg$FAILED) {
s5 = [];
@@ -894,7 +905,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s6 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e23); }
+ if (peg$silentFails === 0) { peg$fail(peg$e24); }
}
while (s6 !== peg$FAILED) {
s5.push(s6);
@@ -903,7 +914,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s6 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e23); }
+ if (peg$silentFails === 0) { peg$fail(peg$e24); }
}
}
peg$savedPos = s0;
@@ -919,7 +930,7 @@ function peg$parse(input, options) {
peg$silentFails--;
if (s0 === peg$FAILED) {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e19); }
+ if (peg$silentFails === 0) { peg$fail(peg$e20); }
}
return s0;
@@ -936,7 +947,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s2 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e23); }
+ if (peg$silentFails === 0) { peg$fail(peg$e24); }
}
if (s2 !== peg$FAILED) {
while (s2 !== peg$FAILED) {
@@ -946,7 +957,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s2 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e23); }
+ if (peg$silentFails === 0) { peg$fail(peg$e24); }
}
}
} else {
@@ -960,7 +971,7 @@ function peg$parse(input, options) {
peg$silentFails--;
if (s0 === peg$FAILED) {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e24); }
+ if (peg$silentFails === 0) { peg$fail(peg$e25); }
}
return s0;
@@ -988,7 +999,7 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e26); }
+ if (peg$silentFails === 0) { peg$fail(peg$e27); }
}
while (s1 !== peg$FAILED) {
s0.push(s1);
@@ -997,12 +1008,12 @@ function peg$parse(input, options) {
peg$currPos++;
} else {
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e26); }
+ if (peg$silentFails === 0) { peg$fail(peg$e27); }
}
}
peg$silentFails--;
s1 = peg$FAILED;
- if (peg$silentFails === 0) { peg$fail(peg$e25); }
+ if (peg$silentFails === 0) { peg$fail(peg$e26); }
return s0;
}
diff --git a/src/libs/SearchParser/autocompleteParser.peggy b/src/libs/SearchParser/autocompleteParser.peggy
index 89d89fd07cd4..e2a8bed9a9cc 100644
--- a/src/libs/SearchParser/autocompleteParser.peggy
+++ b/src/libs/SearchParser/autocompleteParser.peggy
@@ -61,6 +61,7 @@ autocompleteKey "key"
/ "expenseType"
/ "type"
/ "status"
+ / "cardID"
)
identifier
diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts
index 62d00f8091ed..5e2a6d737984 100644
--- a/src/libs/SearchQueryUtils.ts
+++ b/src/libs/SearchQueryUtils.ts
@@ -125,11 +125,11 @@ function getFilters(queryJSON: SearchQueryJSON) {
return;
}
- if (typeof node?.left === 'object' && node.left) {
+ if (typeof node.left === 'object' && node.left) {
traverse(node.left);
}
- if (typeof node?.right === 'object' && node.right && !Array.isArray(node.right)) {
+ if (typeof node.right === 'object' && node.right && !Array.isArray(node.right)) {
traverse(node.right);
}
@@ -148,7 +148,7 @@ function getFilters(queryJSON: SearchQueryJSON) {
node.right.forEach((element) => {
filterArray.push({
operator: node.operator,
- value: element as string | number,
+ value: element,
});
});
}
@@ -163,52 +163,66 @@ function getFilters(queryJSON: SearchQueryJSON) {
}
/**
- * @private
* Given a filter name and its value, this function returns the corresponding ID found in Onyx data.
+ * Returns a function that can be used as a computeNodeValue callback for traversing the filters tree
*/
-function findIDFromDisplayValue(filterName: ValueOf, filter: string | string[], cardList: OnyxTypes.CardList, taxRates: Record) {
- if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) {
- if (typeof filter === 'string') {
- const email = filter;
- return PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? filter;
+function getFindIDFromDisplayValue(cardList: OnyxTypes.CardList, taxRates: Record) {
+ return (filterName: ValueOf, filter: string | string[]) => {
+ if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) {
+ if (typeof filter === 'string') {
+ const email = filter;
+ return PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? filter;
+ }
+ const emails = filter;
+ return emails.map((email) => PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? email);
}
- const emails = filter;
- return emails.map((email) => PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? email);
- }
- if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) {
- const names = Array.isArray(filter) ? filter : ([filter] as string[]);
- return names.map((name) => taxRates[name] ?? name).flat();
- }
- if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) {
- if (typeof filter === 'string') {
- const bank = filter;
- const ids =
- Object.values(cardList)
- .filter((card) => card.bank === bank)
- .map((card) => card.cardID.toString()) ?? filter;
- return ids.length > 0 ? ids : bank;
+ if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) {
+ const names = Array.isArray(filter) ? filter : ([filter] as string[]);
+ return names.map((name) => taxRates[name] ?? name).flat();
}
- const banks = filter;
- return banks
- .map(
- (bank) =>
+ if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) {
+ if (typeof filter === 'string') {
+ const bank = filter;
+ const ids =
Object.values(cardList)
.filter((card) => card.bank === bank)
- .map((card) => card.cardID.toString()) ?? bank,
- )
- .flat();
- }
- if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) {
- if (typeof filter === 'string') {
- const backendAmount = CurrencyUtils.convertToBackendAmount(Number(filter));
- return Number.isNaN(backendAmount) ? filter : backendAmount.toString();
+ .map((card) => card.cardID.toString()) ?? filter;
+ return ids.length > 0 ? ids : bank;
+ }
+ const banks = filter;
+ return banks
+ .map(
+ (bank) =>
+ Object.values(cardList)
+ .filter((card) => card.bank === bank)
+ .map((card) => card.cardID.toString()) ?? bank,
+ )
+ .flat();
+ }
+ if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) {
+ return getUpdatedAmountValue(filterName, filter);
}
- return filter.map((amount) => {
- const backendAmount = CurrencyUtils.convertToBackendAmount(Number(amount));
- return Number.isNaN(backendAmount) ? amount : backendAmount.toString();
- });
+
+ return filter;
+ };
+}
+
+/**
+ * Returns an updated amount value for query filters, correctly formatted to "backend" amount
+ */
+function getUpdatedAmountValue(filterName: ValueOf, filter: string | string[]) {
+ if (filterName !== CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) {
+ return filter;
}
- return filter;
+
+ if (typeof filter === 'string') {
+ const backendAmount = CurrencyUtils.convertToBackendAmount(Number(filter));
+ return Number.isNaN(backendAmount) ? filter : backendAmount.toString();
+ }
+ return filter.map((amount) => {
+ const backendAmount = CurrencyUtils.convertToBackendAmount(Number(amount));
+ return Number.isNaN(backendAmount) ? amount : backendAmount.toString();
+ });
}
/**
@@ -561,7 +575,9 @@ function buildUserReadableQueryString(
})
.flat();
- displayQueryFilters = taxRateNames.map((taxRate) => ({
+ const uniqueTaxRateNames = [...new Set(taxRateNames)];
+
+ displayQueryFilters = uniqueTaxRateNames.map((taxRate) => ({
operator: queryFilter.at(0)?.operator ?? CONST.SEARCH.SYNTAX_OPERATORS.AND,
value: taxRate,
}));
@@ -610,23 +626,23 @@ function isCannedSearchQuery(queryJSON: SearchQueryJSON) {
/**
* Given a search query, this function will standardize the query by replacing display values with their corresponding IDs.
*/
-function standardizeQueryJSON(queryJSON: SearchQueryJSON, cardList: OnyxTypes.CardList, taxRates: Record) {
+function traverseAndUpdatedQuery(queryJSON: SearchQueryJSON, computeNodeValue: (left: ValueOf, right: string | string[]) => string | string[]) {
const standardQuery = cloneDeep(queryJSON);
const filters = standardQuery.filters;
const traverse = (node: ASTNode) => {
if (!node.operator) {
return;
}
- if (typeof node.left === 'object' && node.left) {
+ if (typeof node.left === 'object') {
traverse(node.left);
}
- if (typeof node.right === 'object' && node.right && !Array.isArray(node.right)) {
+ if (typeof node.right === 'object' && !Array.isArray(node.right)) {
traverse(node.right);
}
- if (typeof node.left !== 'object') {
+ if (typeof node.left !== 'object' && (Array.isArray(node.right) || typeof node.right === 'string')) {
// eslint-disable-next-line no-param-reassign
- node.right = findIDFromDisplayValue(node.left, node.right as string | string[], cardList, taxRates);
+ node.right = computeNodeValue(node.left, node.right);
}
};
@@ -647,6 +663,8 @@ export {
getPolicyIDFromSearchQuery,
buildCannedSearchQuery,
isCannedSearchQuery,
- standardizeQueryJSON,
+ traverseAndUpdatedQuery,
+ getFindIDFromDisplayValue,
+ getUpdatedAmountValue,
sanitizeSearchValue,
};
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index e7399a6d3982..d47cee3745a0 100644
--- a/src/libs/SidebarUtils.ts
+++ b/src/libs/SidebarUtils.ts
@@ -2,7 +2,7 @@ import {Str} from 'expensify-common';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
-import type {PolicySelector, ReportActionsSelector} from '@hooks/useReportIDs';
+import type {PolicySelector} from '@hooks/useReportIDs';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PersonalDetails, PersonalDetailsList, ReportActions, TransactionViolation} from '@src/types/onyx';
@@ -92,7 +92,6 @@ function getOrderedReportIDs(
betas: OnyxEntry,
policies: OnyxCollection,
priorityMode: OnyxEntry,
- allReportActions: OnyxCollection,
transactionViolations: OnyxCollection,
currentPolicyID = '',
policyMemberAccountIDs: number[] = [],
diff --git a/src/libs/Sound/BaseSound.ts b/src/libs/Sound/BaseSound.ts
index e7fc5fadd259..1b1853eb30a6 100644
--- a/src/libs/Sound/BaseSound.ts
+++ b/src/libs/Sound/BaseSound.ts
@@ -1,11 +1,15 @@
import Onyx from 'react-native-onyx';
+import getPlatform from '@libs/getPlatform';
import ONYXKEYS from '@src/ONYXKEYS';
let isMuted = false;
Onyx.connect({
- key: ONYXKEYS.USER,
- callback: (val) => (isMuted = !!val?.isMutedAllSounds),
+ key: ONYXKEYS.NVP_MUTED_PLATFORMS,
+ callback: (val) => {
+ const platform = getPlatform(true);
+ isMuted = !!val?.[platform];
+ },
});
const SOUNDS = {
diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts
index 17d0e361e7d2..6d08a128a253 100644
--- a/src/libs/TransactionUtils/index.ts
+++ b/src/libs/TransactionUtils/index.ts
@@ -4,6 +4,8 @@ import lodashSet from 'lodash/set';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
+import {getPolicyCategoriesData} from '@libs/actions/Policy/Category';
+import {getPolicyTagsData} from '@libs/actions/Policy/Tag';
import type {TransactionMergeParams} from '@libs/API/parameters';
import {getCurrencyDecimals} from '@libs/CurrencyUtils';
import DateUtils from '@libs/DateUtils';
@@ -1034,7 +1036,7 @@ function removeSettledAndApprovedTransactions(transactionIDs: string[]) {
* 6. It returns the 'keep' and 'change' objects.
*/
-function compareDuplicateTransactionFields(transactionID: string): {keep: Partial; change: FieldsToChange} {
+function compareDuplicateTransactionFields(transactionID: string, reportID: string): {keep: Partial; change: FieldsToChange} {
const transactionViolations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`];
const duplicates = transactionViolations?.find((violation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION)?.data?.duplicates ?? [];
const transactions = removeSettledAndApprovedTransactions([transactionID, ...duplicates]).map((item) => getTransaction(item));
@@ -1095,7 +1097,10 @@ function compareDuplicateTransactionFields(transactionID: string): {keep: Partia
const keys = fieldsToCompare[fieldName];
const firstTransaction = transactions.at(0);
const isFirstTransactionCommentEmptyObject = typeof firstTransaction?.comment === 'object' && firstTransaction?.comment?.comment === '';
+ const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? null;
+ const policy = PolicyUtils.getPolicy(report?.policyID);
+ const areAllFieldsEqualForKey = areAllFieldsEqual(transactions, (item) => keys.map((key) => item?.[key]).join('|'));
if (fieldName === 'description') {
const allCommentsAreEqual = areAllCommentsEqual(transactions, firstTransaction);
const allCommentsAreEmpty = isFirstTransactionCommentEmptyObject && transactions.every((item) => getDescription(item) === '');
@@ -1110,7 +1115,52 @@ function compareDuplicateTransactionFields(transactionID: string): {keep: Partia
} else {
processChanges(fieldName, transactions, keys);
}
- } else if (areAllFieldsEqual(transactions, (item) => keys.map((key) => item?.[key]).join('|'))) {
+ } else if (fieldName === 'taxCode') {
+ const differentValues = getDifferentValues(transactions, keys);
+ const validTaxes = differentValues?.filter((taxID) => {
+ const tax = PolicyUtils.getTaxByID(policy, (taxID as string) ?? '');
+ return tax?.name && !tax.isDisabled && tax.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
+ });
+
+ if (!areAllFieldsEqualForKey && validTaxes.length > 1) {
+ change[fieldName] = validTaxes;
+ } else if (areAllFieldsEqualForKey) {
+ keep[fieldName] = firstTransaction?.[keys[0]] ?? firstTransaction?.[keys[1]];
+ }
+ } else if (fieldName === 'category') {
+ const differentValues = getDifferentValues(transactions, keys);
+ const policyCategories = getPolicyCategoriesData(report?.policyID ?? '-1');
+ const availableCategories = Object.values(policyCategories)
+ .filter((category) => differentValues.includes(category.name) && category.enabled && category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)
+ .map((e) => e.name);
+
+ if (!areAllFieldsEqualForKey && policy?.areCategoriesEnabled && (availableCategories.length > 1 || (availableCategories.length === 1 && differentValues.includes('')))) {
+ change[fieldName] = [...availableCategories, ...(differentValues.includes('') ? [''] : [])];
+ } else if (areAllFieldsEqualForKey) {
+ keep[fieldName] = firstTransaction?.[keys[0]] ?? firstTransaction?.[keys[1]];
+ }
+ } else if (fieldName === 'tag') {
+ const policyTags = getPolicyTagsData(report?.policyID ?? '-1');
+ const isMultiLevelTags = PolicyUtils.isMultiLevelTags(policyTags);
+ if (isMultiLevelTags) {
+ if (areAllFieldsEqualForKey || !policy?.areTagsEnabled) {
+ keep[fieldName] = firstTransaction?.[keys[0]] ?? firstTransaction?.[keys[1]];
+ } else {
+ processChanges(fieldName, transactions, keys);
+ }
+ } else {
+ const differentValues = getDifferentValues(transactions, keys);
+ const policyTagsObj = Object.values(Object.values(policyTags).at(0)?.tags ?? {});
+ const availableTags = policyTagsObj
+ .filter((tag) => differentValues.includes(tag.name) && tag.enabled && tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)
+ .map((e) => e.name);
+ if (!areAllFieldsEqualForKey && policy?.areTagsEnabled && (availableTags.length > 1 || (availableTags.length === 1 && differentValues.includes('')))) {
+ change[fieldName] = [...availableTags, ...(differentValues.includes('') ? [''] : [])];
+ } else if (areAllFieldsEqualForKey) {
+ keep[fieldName] = firstTransaction?.[keys[0]] ?? firstTransaction?.[keys[1]];
+ }
+ }
+ } else if (areAllFieldsEqualForKey) {
keep[fieldName] = firstTransaction?.[keys[0]] ?? firstTransaction?.[keys[1]];
} else {
processChanges(fieldName, transactions, keys);
diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts
index a27d518fe727..e06382edffdc 100644
--- a/src/libs/WorkspacesSettingsUtils.ts
+++ b/src/libs/WorkspacesSettingsUtils.ts
@@ -2,17 +2,18 @@ import Onyx from 'react-native-onyx';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import type {LocaleContextProps} from '@components/LocaleContextProvider';
+import type {PolicySelector} from '@hooks/useReportIDs';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {Policy, ReimbursementAccount, Report, ReportAction, ReportActions, TransactionViolations} from '@src/types/onyx';
+import type {Beta, Policy, PriorityMode, ReimbursementAccount, Report, ReportAction, ReportActions, TransactionViolation, TransactionViolations} from '@src/types/onyx';
import type {PolicyConnectionSyncProgress, Unit} from '@src/types/onyx/Policy';
import {isConnectionInProgress} from './actions/connections';
import * as CurrencyUtils from './CurrencyUtils';
import {hasCustomUnitsError, hasEmployeeListError, hasPolicyError, hasSyncError, hasTaxRateError} from './PolicyUtils';
import * as ReportActionsUtils from './ReportActionsUtils';
-import * as ReportConnection from './ReportConnection';
import * as ReportUtils from './ReportUtils';
+import SidebarUtils from './SidebarUtils';
type CheckingMethod = () => boolean;
@@ -119,12 +120,23 @@ function hasWorkspaceSettingsRBR(policy: Policy) {
return Object.keys(reimbursementAccount?.errors ?? {}).length > 0 || hasPolicyError(policy) || hasCustomUnitsError(policy) || policyMemberError || taxRateError;
}
-function getChatTabBrickRoadReport(policyID?: string): OnyxEntry {
- const allReports = ReportConnection.getAllReports();
- if (!allReports) {
+function getChatTabBrickRoadReport(
+ policyID: string | undefined,
+ currentReportId: string | null,
+ reports: OnyxCollection,
+ betas: OnyxEntry,
+ policies: OnyxCollection,
+ priorityMode: OnyxEntry,
+ transactionViolations: OnyxCollection,
+ policyMemberAccountIDs: number[] = [],
+): OnyxEntry {
+ const reportIDs = SidebarUtils.getOrderedReportIDs(currentReportId, reports, betas, policies, priorityMode, transactionViolations, policyID, policyMemberAccountIDs);
+ if (!reportIDs.length) {
return undefined;
}
+ const allReports = reportIDs.map((reportID) => reports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]);
+
// If policyID is undefined, then all reports are checked whether they contain any brick road
const policyReports = policyID ? Object.values(allReports).filter((report) => report?.policyID === policyID) : Object.values(allReports);
@@ -150,8 +162,17 @@ function getChatTabBrickRoadReport(policyID?: string): OnyxEntry {
return undefined;
}
-function getChatTabBrickRoad(policyID?: string): BrickRoad | undefined {
- const report = getChatTabBrickRoadReport(policyID);
+function getChatTabBrickRoad(
+ policyID: string | undefined,
+ currentReportId: string | null,
+ reports: OnyxCollection,
+ betas: OnyxEntry,
+ policies: OnyxCollection,
+ priorityMode: OnyxEntry,
+ transactionViolations: OnyxCollection,
+ policyMemberAccountIDs: number[] = [],
+): BrickRoad | undefined {
+ const report = getChatTabBrickRoadReport(policyID, currentReportId, reports, betas, policies, priorityMode, transactionViolations, policyMemberAccountIDs);
return report ? getBrickRoadForPolicy(report) : undefined;
}
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index 7a72df9f1d87..6a2885814540 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -7049,9 +7049,19 @@ function canApproveIOU(iouReport: OnyxTypes.OnyxInputOrEntry,
const iouSettled = ReportUtils.isSettled(iouReport?.reportID);
const reportNameValuePairs = ReportUtils.getReportNameValuePairs(iouReport?.reportID);
const isArchivedReport = ReportUtils.isArchivedRoom(iouReport, reportNameValuePairs);
- const unheldTotalIsZero = iouReport && iouReport.unheldTotal === 0;
+ let isTransactionBeingScanned = false;
+ const reportTransactions = TransactionUtils.getAllReportTransactions(iouReport?.reportID);
+ for (const transaction of reportTransactions) {
+ const hasReceipt = TransactionUtils.hasReceipt(transaction);
+ const isReceiptBeingScanned = TransactionUtils.isReceiptBeingScanned(transaction);
+
+ // If transaction has receipt (scan) and its receipt is being scanned, we shouldn't be able to Approve
+ if (hasReceipt && isReceiptBeingScanned) {
+ isTransactionBeingScanned = true;
+ }
+ }
- return isCurrentUserManager && !isOpenExpenseReport && !isApproved && !iouSettled && !isArchivedReport && !unheldTotalIsZero;
+ return isCurrentUserManager && !isOpenExpenseReport && !isApproved && !iouSettled && !isArchivedReport && !isTransactionBeingScanned;
}
function canIOUBePaid(
diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts
index 78b0f2dec9e2..41771ac5aa0e 100644
--- a/src/libs/actions/Policy/Category.ts
+++ b/src/libs/actions/Policy/Category.ts
@@ -1346,6 +1346,10 @@ function setPolicyCategoryTax(policyID: string, categoryName: string, taxID: str
API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX, parameters, onyxData);
}
+function getPolicyCategoriesData(policyID: string) {
+ return allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`] ?? {};
+}
+
export {
getPolicyCategories,
openPolicyCategoriesPage,
@@ -1370,4 +1374,5 @@ export {
setPolicyCategoryTax,
importPolicyCategories,
downloadCategoriesCSV,
+ getPolicyCategoriesData,
};
diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts
index d5b2adc54de3..8fb551cdec81 100644
--- a/src/libs/actions/Policy/Member.ts
+++ b/src/libs/actions/Policy/Member.ts
@@ -159,7 +159,10 @@ function buildAnnounceRoomMembersOnyxData(policyID: string, accountIDs: number[]
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport?.reportID}`,
value: {
- participants: announceReport?.participants ?? null,
+ participants: accountIDs.reduce((acc, curr) => {
+ Object.assign(acc, {[curr]: null});
+ return acc;
+ }, {}),
pendingChatMembers: announceReport?.pendingChatMembers ?? null,
},
});
diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts
index b419431bbbb3..1dd6178d3159 100644
--- a/src/libs/actions/Policy/Policy.ts
+++ b/src/libs/actions/Policy/Policy.ts
@@ -1013,6 +1013,16 @@ function createPolicyExpenseChats(policyID: string, invitedEmailsToAccountIDs: I
isLoadingInitialReportActions: false,
},
});
+
+ workspaceMembersChats.onyxFailureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticReport.reportID}`,
+ value: {
+ errorFields: {
+ createChat: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage'),
+ },
+ },
+ });
});
return workspaceMembersChats;
}
@@ -2630,7 +2640,7 @@ function enableExpensifyCard(policyID: string, enabled: boolean) {
}
}
-function enableCompanyCards(policyID: string, enabled: boolean) {
+function enableCompanyCards(policyID: string, enabled: boolean, disableRedirect = false) {
const authToken = NetworkStore.getAuthToken();
const onyxData: OnyxData = {
@@ -2675,7 +2685,7 @@ function enableCompanyCards(policyID: string, enabled: boolean) {
API.write(WRITE_COMMANDS.ENABLE_POLICY_COMPANY_CARDS, parameters, onyxData);
- if (enabled && getIsNarrowLayout()) {
+ if (enabled && getIsNarrowLayout() && !disableRedirect) {
navigateWhenEnableFeature(policyID);
}
}
diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts
index 7708921f57b5..772e748ad4f2 100644
--- a/src/libs/actions/Policy/Tag.ts
+++ b/src/libs/actions/Policy/Tag.ts
@@ -1040,6 +1040,10 @@ function downloadTagsCSV(policyID: string, onDownloadFailed: () => void) {
fileDownload(ApiUtils.getCommandURL({command: WRITE_COMMANDS.EXPORT_TAGS_CSV}), fileName, '', false, formData, CONST.NETWORK.METHOD.POST, onDownloadFailed);
}
+function getPolicyTagsData(policyID: string) {
+ return allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {};
+}
+
export {
buildOptimisticPolicyRecentlyUsedTags,
setPolicyRequiresTag,
@@ -1058,6 +1062,7 @@ export {
setPolicyTagApprover,
importPolicyTags,
downloadTagsCSV,
+ getPolicyTagsData,
};
export type {NewCustomUnit};
diff --git a/src/libs/getPlatform/index.ts b/src/libs/getPlatform/index.ts
index 5f5b45ac6e7d..aedb4610673e 100644
--- a/src/libs/getPlatform/index.ts
+++ b/src/libs/getPlatform/index.ts
@@ -1,6 +1,10 @@
+import * as Browser from '@libs/Browser';
import CONST from '@src/CONST';
import type Platform from './types';
-export default function getPlatform(): Platform {
+export default function getPlatform(shouldMobileWebBeDistinctFromWeb = false): Platform {
+ if (shouldMobileWebBeDistinctFromWeb && Browser.isMobile()) {
+ return CONST.PLATFORM.MOBILEWEB;
+ }
return CONST.PLATFORM.WEB;
}
diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx
index 9ec3691f49a8..9e438f0549e2 100644
--- a/src/pages/ReportDetailsPage.tsx
+++ b/src/pages/ReportDetailsPage.tsx
@@ -298,6 +298,10 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) {
const shouldShowCancelPaymentButton = caseID === CASES.MONEY_REPORT && isPayer && isSettled && ReportUtils.isExpenseReport(moneyRequestReport);
const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReport?.chatReportID ?? '-1'}`);
+ const iouTransactionID = ReportActionsUtils.isMoneyRequestAction(requestParentReportAction)
+ ? ReportActionsUtils.getOriginalMessage(requestParentReportAction)?.IOUTransactionID ?? ''
+ : '';
+
const cancelPayment = useCallback(() => {
if (!chatReport) {
return;
@@ -371,6 +375,42 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) {
});
}
+ if (isTrackExpenseReport) {
+ const actionReportID = ReportUtils.getOriginalReportID(report.reportID, parentReportAction) ?? '0';
+ const whisperAction = ReportActionsUtils.getTrackExpenseActionableWhisper(iouTransactionID, moneyRequestReport?.reportID ?? '0');
+ const actionableWhisperReportActionID = whisperAction?.reportActionID ?? '0';
+ items.push({
+ key: CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS,
+ translationKey: 'actionableMentionTrackExpense.submit',
+ icon: Expensicons.Send,
+ isAnonymousAction: false,
+ shouldShowRightIcon: true,
+ action: () => {
+ ReportUtils.createDraftTransactionAndNavigateToParticipantSelector(iouTransactionID, actionReportID, CONST.IOU.ACTION.SUBMIT, actionableWhisperReportActionID);
+ },
+ });
+ items.push({
+ key: CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS,
+ translationKey: 'actionableMentionTrackExpense.categorize',
+ icon: Expensicons.Folder,
+ isAnonymousAction: false,
+ shouldShowRightIcon: true,
+ action: () => {
+ ReportUtils.createDraftTransactionAndNavigateToParticipantSelector(iouTransactionID, actionReportID, CONST.IOU.ACTION.CATEGORIZE, actionableWhisperReportActionID);
+ },
+ });
+ items.push({
+ key: CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS,
+ translationKey: 'actionableMentionTrackExpense.share',
+ icon: Expensicons.UserPlus,
+ isAnonymousAction: false,
+ shouldShowRightIcon: true,
+ action: () => {
+ ReportUtils.createDraftTransactionAndNavigateToParticipantSelector(iouTransactionID, actionReportID, CONST.IOU.ACTION.SHARE, actionableWhisperReportActionID);
+ },
+ });
+ }
+
// Prevent displaying private notes option for threads and task reports
if (!isChatThread && !isMoneyRequestReport && !isInvoiceReport && !isTaskReport) {
items.push({
@@ -517,6 +557,10 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) {
isExpenseReport,
backTo,
canActionTask,
+ isTrackExpenseReport,
+ iouTransactionID,
+ parentReportAction,
+ moneyRequestReport?.reportID,
]);
const displayNamesWithTooltips = useMemo(() => {
@@ -590,10 +634,6 @@ function ReportDetailsPage({policies, report, route}: ReportDetailsPageProps) {
);
}, [report, icons, isMoneyRequestReport, isInvoiceReport, isGroupChat, isThread, styles]);
- const iouTransactionID = ReportActionsUtils.isMoneyRequestAction(requestParentReportAction)
- ? ReportActionsUtils.getOriginalMessage(requestParentReportAction)?.IOUTransactionID ?? ''
- : '';
-
const canHoldUnholdReportAction = ReportUtils.canHoldUnholdReportAction(moneyRequestAction);
const shouldShowHoldAction =
caseID !== CASES.DEFAULT &&
diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx
index ce4daabc983a..58fd159b5bed 100644
--- a/src/pages/Search/AdvancedSearchFilters.tsx
+++ b/src/pages/Search/AdvancedSearchFilters.tsx
@@ -9,7 +9,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import {usePersonalDetails} from '@components/OnyxProvider';
import ScrollView from '@components/ScrollView';
-import type {AdvancedFiltersKeys} from '@components/Search/types';
+import type {SearchFilterKey} from '@components/Search/types';
import useLocalize from '@hooks/useLocalize';
import useSingleExecution from '@hooks/useSingleExecution';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -150,8 +150,8 @@ const sortOptionsWithEmptyValue = (a: string, b: string) => {
return localeCompare(a, b);
};
-function getFilterDisplayTitle(filters: Partial, fieldName: AdvancedFiltersKeys, translate: LocaleContextProps['translate']) {
- if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE) {
+function getFilterDisplayTitle(filters: Partial, filterKey: SearchFilterKey, translate: LocaleContextProps['translate']) {
+ if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE) {
// the value of date filter is a combination of dateBefore + dateAfter values
const {dateAfter, dateBefore} = filters;
let dateValue = '';
@@ -168,7 +168,7 @@ function getFilterDisplayTitle(filters: Partial, fiel
return dateValue;
}
- if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) {
+ if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) {
const {lessThan, greaterThan} = filters;
if (lessThan && greaterThan) {
return translate('search.filters.amount.between', {
@@ -186,32 +186,32 @@ function getFilterDisplayTitle(filters: Partial, fiel
return;
}
- if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY && filters[fieldName]) {
- const filterArray = filters[fieldName] ?? [];
+ if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY && filters[filterKey]) {
+ const filterArray = filters[filterKey] ?? [];
return filterArray.sort(localeCompare).join(', ');
}
- if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY && filters[fieldName]) {
- const filterArray = filters[fieldName] ?? [];
+ if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY && filters[filterKey]) {
+ const filterArray = filters[filterKey] ?? [];
return filterArray
.sort(sortOptionsWithEmptyValue)
.map((value) => (value === CONST.SEARCH.EMPTY_VALUE ? translate('search.noCategory') : value))
.join(', ');
}
- if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG && filters[fieldName]) {
- const filterArray = filters[fieldName] ?? [];
+ if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG && filters[filterKey]) {
+ const filterArray = filters[filterKey] ?? [];
return filterArray
.sort(sortOptionsWithEmptyValue)
.map((value) => (value === CONST.SEARCH.EMPTY_VALUE ? translate('search.noTag') : value))
.join(', ');
}
- if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION) {
- return filters[fieldName];
+ if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION) {
+ return filters[filterKey];
}
- const filterValue = filters[fieldName];
+ const filterValue = filters[filterKey];
return Array.isArray(filterValue) ? filterValue.join(', ') : filterValue;
}
diff --git a/src/pages/TransactionDuplicate/Confirmation.tsx b/src/pages/TransactionDuplicate/Confirmation.tsx
index 87748a9697a7..90497a05a4fc 100644
--- a/src/pages/TransactionDuplicate/Confirmation.tsx
+++ b/src/pages/TransactionDuplicate/Confirmation.tsx
@@ -40,7 +40,7 @@ function Confirmation() {
const [reviewDuplicates, reviewDuplicatesResult] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES);
const transaction = useMemo(() => TransactionUtils.buildNewTransactionAfterReviewingDuplicates(reviewDuplicates), [reviewDuplicates]);
const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? '');
- const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID);
+ const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1');
const {goBack} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'confirmation', route.params.threadReportID, route.params.backTo);
const [report, reportResult] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.threadReportID}`);
const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`);
diff --git a/src/pages/TransactionDuplicate/ReviewBillable.tsx b/src/pages/TransactionDuplicate/ReviewBillable.tsx
index 666741daf303..166c61209a42 100644
--- a/src/pages/TransactionDuplicate/ReviewBillable.tsx
+++ b/src/pages/TransactionDuplicate/ReviewBillable.tsx
@@ -1,6 +1,7 @@
import type {RouteProp} from '@react-navigation/native';
import {useRoute} from '@react-navigation/native';
import React, {useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import useLocalize from '@hooks/useLocalize';
@@ -8,6 +9,7 @@ import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'
import {setReviewDuplicatesKey} from '@libs/actions/Transaction';
import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types';
import * as TransactionUtils from '@libs/TransactionUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import type {FieldItemType} from './ReviewFields';
import ReviewFields from './ReviewFields';
@@ -16,7 +18,8 @@ function ReviewBillable() {
const route = useRoute>();
const {translate} = useLocalize();
const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? '');
- const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID);
+ const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES);
+ const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1');
const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString());
const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation(
Object.keys(compareResult.change ?? {}),
diff --git a/src/pages/TransactionDuplicate/ReviewCategory.tsx b/src/pages/TransactionDuplicate/ReviewCategory.tsx
index 09cbdcd28327..b28cb6863137 100644
--- a/src/pages/TransactionDuplicate/ReviewCategory.tsx
+++ b/src/pages/TransactionDuplicate/ReviewCategory.tsx
@@ -1,6 +1,7 @@
import type {RouteProp} from '@react-navigation/native';
import {useRoute} from '@react-navigation/native';
import React, {useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import useLocalize from '@hooks/useLocalize';
@@ -8,6 +9,7 @@ import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'
import {setReviewDuplicatesKey} from '@libs/actions/Transaction';
import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types';
import * as TransactionUtils from '@libs/TransactionUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import type {FieldItemType} from './ReviewFields';
import ReviewFields from './ReviewFields';
@@ -16,7 +18,8 @@ function ReviewCategory() {
const route = useRoute>();
const {translate} = useLocalize();
const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? '');
- const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID);
+ const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES);
+ const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1');
const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString());
const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation(
Object.keys(compareResult.change ?? {}),
diff --git a/src/pages/TransactionDuplicate/ReviewDescription.tsx b/src/pages/TransactionDuplicate/ReviewDescription.tsx
index 3d74d8cc36e1..d3c379517cf1 100644
--- a/src/pages/TransactionDuplicate/ReviewDescription.tsx
+++ b/src/pages/TransactionDuplicate/ReviewDescription.tsx
@@ -1,6 +1,7 @@
import type {RouteProp} from '@react-navigation/native';
import {useRoute} from '@react-navigation/native';
import React, {useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import useLocalize from '@hooks/useLocalize';
@@ -8,6 +9,7 @@ import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'
import {setReviewDuplicatesKey} from '@libs/actions/Transaction';
import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types';
import * as TransactionUtils from '@libs/TransactionUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import type {FieldItemType} from './ReviewFields';
import ReviewFields from './ReviewFields';
@@ -16,7 +18,8 @@ function ReviewDescription() {
const route = useRoute>();
const {translate} = useLocalize();
const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? '');
- const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID);
+ const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES);
+ const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1');
const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString());
const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation(
Object.keys(compareResult.change ?? {}),
diff --git a/src/pages/TransactionDuplicate/ReviewMerchant.tsx b/src/pages/TransactionDuplicate/ReviewMerchant.tsx
index 47dd43d1d334..d49a67d7d911 100644
--- a/src/pages/TransactionDuplicate/ReviewMerchant.tsx
+++ b/src/pages/TransactionDuplicate/ReviewMerchant.tsx
@@ -1,6 +1,7 @@
import type {RouteProp} from '@react-navigation/native';
import {useRoute} from '@react-navigation/native';
import React, {useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import useLocalize from '@hooks/useLocalize';
@@ -8,6 +9,7 @@ import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'
import {setReviewDuplicatesKey} from '@libs/actions/Transaction';
import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types';
import * as TransactionUtils from '@libs/TransactionUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import type {FieldItemType} from './ReviewFields';
import ReviewFields from './ReviewFields';
@@ -16,7 +18,8 @@ function ReviewMerchant() {
const route = useRoute>();
const {translate} = useLocalize();
const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? '');
- const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID);
+ const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES);
+ const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1');
const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString());
const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation(
Object.keys(compareResult.change ?? {}),
diff --git a/src/pages/TransactionDuplicate/ReviewReimbursable.tsx b/src/pages/TransactionDuplicate/ReviewReimbursable.tsx
index 0b932e8085db..361b92c2af5a 100644
--- a/src/pages/TransactionDuplicate/ReviewReimbursable.tsx
+++ b/src/pages/TransactionDuplicate/ReviewReimbursable.tsx
@@ -1,6 +1,7 @@
import type {RouteProp} from '@react-navigation/native';
import {useRoute} from '@react-navigation/native';
import React, {useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import useLocalize from '@hooks/useLocalize';
@@ -8,6 +9,7 @@ import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation'
import {setReviewDuplicatesKey} from '@libs/actions/Transaction';
import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types';
import * as TransactionUtils from '@libs/TransactionUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import type {FieldItemType} from './ReviewFields';
import ReviewFields from './ReviewFields';
@@ -16,7 +18,8 @@ function ReviewReimbursable() {
const route = useRoute>();
const {translate} = useLocalize();
const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? '');
- const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID);
+ const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES);
+ const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1');
const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString());
const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation(
Object.keys(compareResult.change ?? {}),
diff --git a/src/pages/TransactionDuplicate/ReviewTag.tsx b/src/pages/TransactionDuplicate/ReviewTag.tsx
index 03fb627abd8e..16138865cfd0 100644
--- a/src/pages/TransactionDuplicate/ReviewTag.tsx
+++ b/src/pages/TransactionDuplicate/ReviewTag.tsx
@@ -1,6 +1,7 @@
import type {RouteProp} from '@react-navigation/native';
import {useRoute} from '@react-navigation/native';
import React, {useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import useLocalize from '@hooks/useLocalize';
@@ -9,6 +10,7 @@ import {setReviewDuplicatesKey} from '@libs/actions/Transaction';
import type {TransactionDuplicateNavigatorParamList} from '@libs/Navigation/types';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as TransactionUtils from '@libs/TransactionUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import type {FieldItemType} from './ReviewFields';
import ReviewFields from './ReviewFields';
@@ -18,7 +20,8 @@ function ReviewTag() {
const {translate} = useLocalize();
const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? '');
- const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID);
+ const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES);
+ const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1');
const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString());
const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation(
Object.keys(compareResult.change ?? {}),
diff --git a/src/pages/TransactionDuplicate/ReviewTaxCode.tsx b/src/pages/TransactionDuplicate/ReviewTaxCode.tsx
index 78b7c1934715..857a93429f00 100644
--- a/src/pages/TransactionDuplicate/ReviewTaxCode.tsx
+++ b/src/pages/TransactionDuplicate/ReviewTaxCode.tsx
@@ -20,10 +20,11 @@ import ReviewFields from './ReviewFields';
function ReviewTaxRate() {
const route = useRoute>();
const {translate} = useLocalize();
- const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.threadReportID}`);
+ const [reviewDuplicates] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES);
+ const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reviewDuplicates?.reportID ?? route.params.threadReportID}`);
const policy = PolicyUtils.getPolicy(report?.policyID ?? '');
const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? '');
- const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID);
+ const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID, reviewDuplicates?.reportID ?? '-1');
const stepNames = Object.keys(compareResult.change ?? {}).map((key, index) => (index + 1).toString());
const {currentScreenIndex, goBack, navigateToNextScreen} = useReviewDuplicatesNavigation(
Object.keys(compareResult.change ?? {}),
diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx
index 0ca9dcdc2de3..4c3ed5c705a5 100644
--- a/src/pages/home/ReportScreen.tsx
+++ b/src/pages/home/ReportScreen.tsx
@@ -10,7 +10,6 @@ import {useOnyx} from 'react-native-onyx';
import Banner from '@components/Banner';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import DragAndDropProvider from '@components/DragAndDrop/Provider';
-import LoadingBar from '@components/LoadingBar';
import MoneyReportHeader from '@components/MoneyReportHeader';
import MoneyRequestHeader from '@components/MoneyRequestHeader';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
@@ -130,7 +129,6 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP);
const [workspaceTooltip] = useOnyx(ONYXKEYS.NVP_WORKSPACE_TOOLTIP);
const wasLoadingApp = usePrevious(isLoadingApp);
- const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA, {initialValue: true});
const finishedLoadingApp = wasLoadingApp && !isLoadingApp;
const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(parentReportAction);
const prevIsDeletedParentAction = usePrevious(isDeletedParentAction);
@@ -758,7 +756,6 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
needsOffscreenAlphaCompositing
>
{headerView}
- {shouldUseNarrowLayout && !!isLoadingReportData && }
{!!report && ReportUtils.isTaskReport(report) && shouldUseNarrowLayout && ReportUtils.isOpenTaskReport(report, parentReportAction) && (
diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx
index a0e2f65a89a0..2953036f6af7 100644
--- a/src/pages/home/report/ReportActionItem.tsx
+++ b/src/pages/home/report/ReportActionItem.tsx
@@ -33,7 +33,6 @@ import {ShowContextMenuContext} from '@components/ShowContextMenuContext';
import Text from '@components/Text';
import UnreadActionIndicator from '@components/UnreadActionIndicator';
import useLocalize from '@hooks/useLocalize';
-import usePermissions from '@hooks/usePermissions';
import usePrevious from '@hooks/usePrevious';
import useReportScrollManager from '@hooks/useReportScrollManager';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
@@ -53,7 +52,6 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
import SelectionScraper from '@libs/SelectionScraper';
import shouldRenderAddPaymentCard from '@libs/shouldRenderAppPaymentCard';
-import * as TransactionUtils from '@libs/TransactionUtils';
import {ReactionListContext} from '@pages/home/ReportScreenContext';
import * as BankAccounts from '@userActions/BankAccounts';
import * as EmojiPickerAction from '@userActions/EmojiPickerAction';
@@ -198,7 +196,6 @@ function ReportActionItem({
const downloadedPreviews = useRef([]);
const prevDraftMessage = usePrevious(draftMessage);
const [isUserValidated] = useOnyx(ONYXKEYS.USER, {selector: (user) => !!user?.validated});
- const {canUseP2PDistanceRequests} = usePermissions();
// The app would crash due to subscribing to the entire report collection if parentReportID is an empty string. So we should have a fallback ID here.
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID || -1}`);
@@ -421,18 +418,14 @@ function ReportActionItem({
if (ReportActionsUtils.isActionableTrackExpense(action)) {
const transactionID = ReportActionsUtils.getOriginalMessage(action)?.transactionID;
return [
- ...(!TransactionUtils.isDistanceRequest(TransactionUtils.getTransaction(transactionID ?? '-1')) || canUseP2PDistanceRequests
- ? [
- {
- text: 'actionableMentionTrackExpense.submit',
- key: `${action.reportActionID}-actionableMentionTrackExpense-submit`,
- onPress: () => {
- ReportUtils.createDraftTransactionAndNavigateToParticipantSelector(transactionID ?? '0', reportID, CONST.IOU.ACTION.SUBMIT, action.reportActionID);
- },
- isMediumSized: true,
- } as ActionableItem,
- ]
- : []),
+ {
+ text: 'actionableMentionTrackExpense.submit',
+ key: `${action.reportActionID}-actionableMentionTrackExpense-submit`,
+ onPress: () => {
+ ReportUtils.createDraftTransactionAndNavigateToParticipantSelector(transactionID ?? '0', reportID, CONST.IOU.ACTION.SUBMIT, action.reportActionID);
+ },
+ isMediumSized: true,
+ },
{
text: 'actionableMentionTrackExpense.categorize',
key: `${action.reportActionID}-actionableMentionTrackExpense-categorize`,
@@ -505,7 +498,7 @@ function ReportActionItem({
onPress: () => Report.resolveActionableMentionWhisper(reportID, action, CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.NOTHING),
},
];
- }, [action, isActionableWhisper, reportID, canUseP2PDistanceRequests]);
+ }, [action, isActionableWhisper, reportID]);
/**
* Get the content of ReportActionItem
diff --git a/src/pages/home/report/withReportOrNotFound.tsx b/src/pages/home/report/withReportOrNotFound.tsx
index d74dc84249d4..b42e9ca5e878 100644
--- a/src/pages/home/report/withReportOrNotFound.tsx
+++ b/src/pages/home/report/withReportOrNotFound.tsx
@@ -1,5 +1,6 @@
/* eslint-disable rulesdir/no-negated-variables */
import type {RouteProp} from '@react-navigation/native';
+import {useIsFocused} from '@react-navigation/native';
import type {ComponentType, ForwardedRef, RefAttributes} from 'react';
import React, {useEffect} from 'react';
import {useOnyx} from 'react-native-onyx';
@@ -60,6 +61,7 @@ export default function (
const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${props.route.params.reportID}`);
const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA);
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${props.route.params.reportID}`);
+ const isFocused = useIsFocused();
const contentShown = React.useRef(false);
const isReportIdInRoute = !!props.route.params.reportID?.length;
const isReportLoaded = !isEmptyObject(report) && !!report?.reportID;
@@ -86,7 +88,7 @@ export default function (
// If the content was shown, but it's not anymore, that means the report was deleted, and we are probably navigating out of this screen.
// Return null for this case to avoid rendering FullScreenLoadingIndicator or NotFoundPage when animating transition.
// eslint-disable-next-line react-compiler/react-compiler
- if (shouldShowNotFoundPage && contentShown.current) {
+ if (shouldShowNotFoundPage && contentShown.current && !isFocused) {
return null;
}
diff --git a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.tsx b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.tsx
index 77c21d4ab2e1..e77f2000b85f 100644
--- a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.tsx
+++ b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.tsx
@@ -1,7 +1,6 @@
import React, {useEffect} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
-import LoadingBar from '@components/LoadingBar';
import ScreenWrapper from '@components/ScreenWrapper';
import useActiveWorkspaceFromNavigationState from '@hooks/useActiveWorkspaceFromNavigationState';
import useLocalize from '@hooks/useLocalize';
@@ -32,8 +31,6 @@ function BaseSidebarScreen() {
const {shouldUseNarrowLayout} = useResponsiveLayout();
const [activeWorkspace] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activeWorkspaceID ?? -1}`);
- const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA, {initialValue: true});
-
useEffect(() => {
Performance.markStart(CONST.TIMING.SIDEBAR_LOADED);
Timing.start(CONST.TIMING.SIDEBAR_LOADED);
@@ -65,7 +62,6 @@ function BaseSidebarScreen() {
activeWorkspaceID={activeWorkspaceID}
shouldDisplaySearch={shouldDisplaySearch}
/>
-
{
+ if (isEmptyObject(activePolicy) || !activePolicy?.isPolicyExpenseChatEnabled) {
+ return {} as OnyxTypes.Report;
+ }
+ const policyChatsForActivePolicy = ReportUtils.getWorkspaceChats(activePolicyID ?? '-1', [session?.accountID ?? -1], allReports);
+ return policyChatsForActivePolicy.length > 0 ? policyChatsForActivePolicy.at(0) : ({} as OnyxTypes.Report);
+ }, [activePolicy, activePolicyID, session?.accountID, allReports]);
const [quickActionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${quickActionReport?.policyID}`);
const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: (c) => mapOnyxCollectionItems(c, policySelector)});
const [hasSeenTrackTraining] = useOnyx(ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING);
@@ -179,10 +189,13 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl
const avatars = ReportUtils.getIcons(quickActionReport, personalDetails);
return avatars.length <= 1 || ReportUtils.isPolicyExpenseChat(quickActionReport) ? avatars : avatars.filter((avatar) => avatar.id !== session?.accountID);
}
+ if (!isEmptyObject(policyChatForActivePolicy)) {
+ return ReportUtils.getIcons(policyChatForActivePolicy, personalDetails);
+ }
return [];
// Policy is needed as a dependency in order to update the shortcut details when the workspace changes
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
- }, [personalDetails, session?.accountID, quickActionReport, quickActionPolicy]);
+ }, [personalDetails, session?.accountID, quickActionReport, quickActionPolicy, policyChatForActivePolicy]);
const renderQuickActionTooltip = useCallback(
() => (
@@ -217,16 +230,18 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl
return quickAction?.action === CONST.QUICK_ACTIONS.SEND_MONEY && displayName.length === 0;
}, [personalDetails, quickActionReport, quickAction?.action, quickActionAvatars]);
- const navigateToQuickAction = () => {
- const selectOption = (onSelected: () => void, shouldRestrictAction: boolean) => {
+ const selectOption = useCallback(
+ (onSelected: () => void, shouldRestrictAction: boolean) => {
if (shouldRestrictAction && quickActionReport?.policyID && SubscriptionUtils.shouldRestrictUserBillableActions(quickActionReport.policyID)) {
Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(quickActionReport.policyID));
return;
}
-
onSelected();
- };
+ },
+ [quickActionReport?.policyID],
+ );
+ const navigateToQuickAction = useCallback(() => {
const isValidReport = !(isEmptyObject(quickActionReport) || ReportUtils.isArchivedRoom(quickActionReport, reportNameValuePairs));
const quickActionReportID = isValidReport ? quickActionReport?.reportID ?? '-1' : ReportUtils.generateReportID();
@@ -266,7 +281,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl
break;
default:
}
- };
+ }, [quickAction, quickActionReport, reportNameValuePairs, selectOption]);
/**
* Check if LHN status changed from active to inactive.
@@ -397,6 +412,77 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl
];
}, [canUseCombinedTrackSubmit, translate, selfDMReportID, hasSeenTrackTraining, isOffline]);
+ const quickActionMenuItems = useMemo(() => {
+ // Define common properties in baseQuickAction
+ const baseQuickAction = {
+ label: translate('quickAction.header'),
+ isLabelHoverable: false,
+ floatRightAvatars: quickActionAvatars,
+ floatRightAvatarSize: CONST.AVATAR_SIZE.SMALL,
+ numberOfLinesDescription: 1,
+ tooltipAnchorAlignment: {
+ vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
+ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
+ },
+ tooltipShiftHorizontal: styles.popoverMenuItem.paddingHorizontal,
+ tooltipShiftVertical: styles.popoverMenuItem.paddingVertical / 2,
+ renderTooltipContent: renderQuickActionTooltip,
+ tooltipWrapperStyle: styles.quickActionTooltipWrapper,
+ };
+
+ if (quickAction?.action) {
+ return [
+ {
+ ...baseQuickAction,
+ icon: getQuickActionIcon(quickAction?.action),
+ text: quickActionTitle,
+ description: !hideQABSubtitle ? ReportUtils.getReportName(quickActionReport) ?? translate('quickAction.updateDestination') : '',
+ onSelected: () => interceptAnonymousUser(() => navigateToQuickAction()),
+ shouldShowSubscriptRightAvatar: ReportUtils.isPolicyExpenseChat(quickActionReport),
+ shouldRenderTooltip: quickAction.isFirstQuickAction,
+ },
+ ];
+ }
+ if (!isEmptyObject(policyChatForActivePolicy)) {
+ return [
+ {
+ ...baseQuickAction,
+ icon: Expensicons.ReceiptScan,
+ text: translate('quickAction.scanReceipt'),
+ description: ReportUtils.getReportName(policyChatForActivePolicy),
+ onSelected: () =>
+ interceptAnonymousUser(() => {
+ selectOption(() => {
+ const isValidReport = !(isEmptyObject(policyChatForActivePolicy) || ReportUtils.isArchivedRoom(policyChatForActivePolicy, reportNameValuePairs));
+ const quickActionReportID = isValidReport ? policyChatForActivePolicy?.reportID ?? '-1' : ReportUtils.generateReportID();
+ IOU.startMoneyRequest(CONST.IOU.TYPE.SUBMIT, quickActionReportID ?? '-1', CONST.IOU.REQUEST_TYPE.SCAN, true);
+ }, true);
+ }),
+ shouldShowSubscriptRightAvatar: true,
+ shouldRenderTooltip: false,
+ },
+ ];
+ }
+
+ return [];
+ }, [
+ translate,
+ quickActionAvatars,
+ styles.popoverMenuItem.paddingHorizontal,
+ styles.popoverMenuItem.paddingVertical,
+ styles.quickActionTooltipWrapper,
+ renderQuickActionTooltip,
+ quickAction?.action,
+ quickAction?.isFirstQuickAction,
+ policyChatForActivePolicy,
+ quickActionTitle,
+ hideQABSubtitle,
+ quickActionReport,
+ navigateToQuickAction,
+ selectOption,
+ reportNameValuePairs,
+ ]);
+
return (
interceptAnonymousUser(() => navigateToQuickAction()),
- shouldShowSubscriptRightAvatar: ReportUtils.isPolicyExpenseChat(quickActionReport),
- shouldRenderTooltip: quickAction.isFirstQuickAction,
- tooltipAnchorAlignment: {
- vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
- horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
- },
- tooltipShiftHorizontal: styles.popoverMenuItem.paddingHorizontal,
- tooltipShiftVertical: styles.popoverMenuItem.paddingVertical / 2,
- renderTooltipContent: renderQuickActionTooltip,
- tooltipWrapperStyle: styles.quickActionTooltipWrapper,
- },
- ]
- : []),
+ ...quickActionMenuItems,
]}
withoutOverlay
anchorRef={fabRef}
diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx
index 8a6ff75fee80..f095fac4d6b1 100644
--- a/src/pages/iou/request/IOURequestStartPage.tsx
+++ b/src/pages/iou/request/IOURequestStartPage.tsx
@@ -47,7 +47,7 @@ function IOURequestStartPage({
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${route?.params.transactionID || -1}`);
const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
- const {canUseP2PDistanceRequests, canUseCombinedTrackSubmit} = usePermissions(iouType);
+ const {canUseCombinedTrackSubmit} = usePermissions();
const tabTitles = {
[CONST.IOU.TYPE.REQUEST]: translate('iou.submitExpense'),
@@ -73,11 +73,6 @@ function IOURequestStartPage({
IOU.initMoneyRequest(reportID, policy, isFromGlobalCreate, transaction?.iouRequestType, transactionRequestType);
}, [transaction, policy, reportID, iouType, isFromGlobalCreate, transactionRequestType, isLoadingSelectedTab]);
- const isExpenseChat = ReportUtils.isPolicyExpenseChat(report);
- const isExpenseReport = ReportUtils.isExpenseReport(report);
- const shouldDisplayDistanceRequest =
- !!canUseCombinedTrackSubmit || !!canUseP2PDistanceRequests || isExpenseChat || isExpenseReport || (isFromGlobalCreate && iouType !== CONST.IOU.TYPE.SPLIT);
-
const navigateBack = () => {
Navigation.closeRHPFlow();
};
@@ -164,15 +159,13 @@ function IOURequestStartPage({
)}
- {shouldDisplayDistanceRequest && (
-
- {() => (
-
-
-
- )}
-
- )}
+
+ {() => (
+
+
+
+ )}
+
) : (
{
if (!areOptionsInitialized) {
@@ -168,7 +147,7 @@ function MoneyRequestParticipantsSelector({
const newOptions = OptionsListUtils.filterOptions(defaultOptions, debouncedSearchTerm, {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- canInviteUser: (canUseP2PDistanceRequests || iouRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE) && !isCategorizeOrShareAction,
+ canInviteUser: !isCategorizeOrShareAction,
selectedOptions: participants as Participant[],
excludeLogins: CONST.EXPENSIFY_EMAILS,
maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW,
@@ -176,7 +155,7 @@ function MoneyRequestParticipantsSelector({
preferRecentExpenseReports: action === CONST.IOU.ACTION.CREATE,
});
return newOptions;
- }, [areOptionsInitialized, defaultOptions, debouncedSearchTerm, participants, isPaidGroupPolicy, canUseP2PDistanceRequests, iouRequestType, isCategorizeOrShareAction, action]);
+ }, [areOptionsInitialized, defaultOptions, debouncedSearchTerm, participants, isPaidGroupPolicy, isCategorizeOrShareAction, action]);
/**
* Returns the sections needed for the OptionsSelector
@@ -327,10 +306,7 @@ function MoneyRequestParticipantsSelector({
const hasPolicyExpenseChatParticipant = participants.some((participant) => participant.isPolicyExpenseChat);
const shouldShowSplitBillErrorMessage = participants.length > 1 && hasPolicyExpenseChatParticipant;
- // canUseP2PDistanceRequests is true if the iouType is track expense, but we don't want to allow splitting distance with track expense yet
const isAllowedToSplit =
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- (canUseP2PDistanceRequests || iouRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE) &&
![CONST.IOU.TYPE.PAY, CONST.IOU.TYPE.TRACK, CONST.IOU.TYPE.INVOICE].some((option) => option === iouType) &&
![CONST.IOU.ACTION.SHARE, CONST.IOU.ACTION.SUBMIT, CONST.IOU.ACTION.CATEGORIZE].some((option) => option === action);
@@ -483,8 +459,4 @@ function MoneyRequestParticipantsSelector({
MoneyRequestParticipantsSelector.displayName = 'MoneyTemporaryForRefactorRequestParticipantsSelector';
-export default memo(
- MoneyRequestParticipantsSelector,
- (prevProps, nextProps) =>
- lodashIsEqual(prevProps.participants, nextProps.participants) && prevProps.iouRequestType === nextProps.iouRequestType && prevProps.iouType === nextProps.iouType,
-);
+export default memo(MoneyRequestParticipantsSelector, (prevProps, nextProps) => lodashIsEqual(prevProps.participants, nextProps.participants) && prevProps.iouType === nextProps.iouType);
diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx
index 65e041180408..c956acadb7b0 100644
--- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx
+++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx
@@ -3,7 +3,6 @@ import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import {useOnyx} from 'react-native-onyx';
import FormHelpMessage from '@components/FormHelpMessage';
import useLocalize from '@hooks/useLocalize';
-import usePermissions from '@hooks/usePermissions';
import useThemeStyles from '@hooks/useThemeStyles';
import {READ_COMMANDS} from '@libs/API/types';
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
@@ -38,7 +37,6 @@ function IOURequestStepParticipants({
const {translate} = useLocalize();
const styles = useThemeStyles();
const isFocused = useIsFocused();
- const {canUseP2PDistanceRequests} = usePermissions(iouType);
const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? -1}`);
// We need to set selectedReportID if user has navigated back from confirmation page and navigates to confirmation page with already selected participant
@@ -91,7 +89,7 @@ function IOURequestStepParticipants({
HttpUtils.cancelPendingRequests(READ_COMMANDS.SEARCH_FOR_REPORTS);
const firstParticipantReportID = val.at(0)?.reportID ?? '';
- const rateID = DistanceRequestUtils.getCustomUnitRateID(firstParticipantReportID, !canUseP2PDistanceRequests);
+ const rateID = DistanceRequestUtils.getCustomUnitRateID(firstParticipantReportID);
const isInvoice = iouType === CONST.IOU.TYPE.INVOICE && ReportUtils.isInvoiceRoomWithID(firstParticipantReportID);
numberOfParticipants.current = val.length;
@@ -108,7 +106,7 @@ function IOURequestStepParticipants({
// When a participant is selected, the reportID needs to be saved because that's the reportID that will be used in the confirmation step.
selectedReportID.current = firstParticipantReportID || reportID;
},
- [iouType, reportID, transactionID, canUseP2PDistanceRequests],
+ [iouType, reportID, transactionID],
);
const goToNextStep = useCallback(() => {
@@ -154,7 +152,7 @@ function IOURequestStepParticipants({
return;
}
- const rateID = DistanceRequestUtils.getCustomUnitRateID(selfDMReportID, !canUseP2PDistanceRequests);
+ const rateID = DistanceRequestUtils.getCustomUnitRateID(selfDMReportID);
IOU.setCustomUnitRateID(transactionID, rateID);
IOU.setMoneyRequestParticipantsFromReport(transactionID, ReportUtils.getReport(selfDMReportID));
const iouConfirmationPageRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, CONST.IOU.TYPE.TRACK, transactionID, selfDMReportID);
@@ -192,7 +190,6 @@ function IOURequestStepParticipants({
onFinish={goToNextStep}
onTrackExpensePress={trackExpense}
iouType={iouType}
- iouRequestType={iouRequestType}
action={action}
shouldDisplayTrackExpenseButton={shouldDisplayTrackExpenseButton}
/>
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
index eac7fe2f1164..f7e575b898fd 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
@@ -30,6 +30,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import getPhotoSource from '@libs/fileDownload/getPhotoSource';
import getCurrentPosition from '@libs/getCurrentPosition';
+import getPlatform from '@libs/getPlatform';
import * as IOUUtils from '@libs/IOUUtils';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
@@ -75,7 +76,9 @@ function IOURequestStepScan({
const policy = usePolicy(report?.policyID);
const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID ?? -1}`);
- const [user] = useOnyx(ONYXKEYS.USER);
+ const platform = getPlatform(true);
+ const [mutedPlatforms = {}] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS);
+ const isPlatformMuted = mutedPlatforms[platform];
const [cameraPermissionStatus, setCameraPermissionStatus] = useState(null);
const [didCapturePhoto, setDidCapturePhoto] = useState(false);
const [isLoadingReceipt, setIsLoadingReceipt] = useState(false);
@@ -494,7 +497,7 @@ function IOURequestStepScan({
camera?.current
?.takePhoto({
flash: flash && hasFlash ? 'on' : 'off',
- enableShutterSound: !user?.isMutedAllSounds,
+ enableShutterSound: !isPlatformMuted,
})
.then((photo: PhotoFile) => {
// Store the receipt on the transaction object in Onyx
@@ -540,7 +543,7 @@ function IOURequestStepScan({
didCapturePhoto,
flash,
hasFlash,
- user?.isMutedAllSounds,
+ isPlatformMuted,
translate,
transactionID,
isEditing,
diff --git a/src/pages/settings/Preferences/PreferencesPage.tsx b/src/pages/settings/Preferences/PreferencesPage.tsx
index 5dee30518533..6616d342aa3c 100755
--- a/src/pages/settings/Preferences/PreferencesPage.tsx
+++ b/src/pages/settings/Preferences/PreferencesPage.tsx
@@ -13,7 +13,6 @@ import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
-import * as Browser from '@libs/Browser';
import getPlatform from '@libs/getPlatform';
import LocaleUtils from '@libs/LocaleUtils';
import Navigation from '@libs/Navigation/Navigation';
@@ -25,7 +24,7 @@ import ROUTES from '@src/ROUTES';
function PreferencesPage() {
const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE);
- const platform = Browser.isMobile() ? CONST.PLATFORM.MOBILEWEB : getPlatform();
+ const platform = getPlatform(true);
const [mutedPlatforms = {}] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS);
const isPlatformMuted = mutedPlatforms[platform];
const [user] = useOnyx(ONYXKEYS.USER);
diff --git a/src/pages/settings/Profile/TimezoneSelectPage.tsx b/src/pages/settings/Profile/TimezoneSelectPage.tsx
index 326db5481d37..cee713065de7 100644
--- a/src/pages/settings/Profile/TimezoneSelectPage.tsx
+++ b/src/pages/settings/Profile/TimezoneSelectPage.tsx
@@ -78,6 +78,7 @@ function TimezoneSelectPage({currentUserPersonalDetails}: TimezoneSelectPageProp
showScrollIndicator
shouldShowTooltips={false}
ListItem={RadioListItem}
+ shouldPreventActiveCellVirtualization
/>
);
diff --git a/src/pages/workspace/AccessOrNotFoundWrapper.tsx b/src/pages/workspace/AccessOrNotFoundWrapper.tsx
index 45bce8c2d1ba..9bda7f3972f9 100644
--- a/src/pages/workspace/AccessOrNotFoundWrapper.tsx
+++ b/src/pages/workspace/AccessOrNotFoundWrapper.tsx
@@ -17,6 +17,7 @@ import type {IOUType} from '@src/CONST';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
+import SCREENS from '@src/SCREENS';
import type * as OnyxTypes from '@src/types/onyx';
import type {PolicyFeatureName} from '@src/types/onyx/Policy';
import callOrReturn from '@src/types/utils/callOrReturn';
@@ -152,7 +153,7 @@ function AccessOrNotFoundWrapper({
return acc && accessFunction(policy, login, report, allPolicies ?? null, iouType);
}, true);
- const isPolicyNotAccessible = isEmptyObject(policy) || (Object.keys(policy).length === 1 && !isEmptyObject(policy.errors)) || !policy?.id;
+ const isPolicyNotAccessible = !PolicyUtils.isPolicyAccessible(policy);
const shouldShowNotFoundPage = (!isMoneyRequest && !isFromGlobalCreate && isPolicyNotAccessible) || !isPageAccessible || !isPolicyFeatureEnabled || shouldBeBlocked;
// We only update the feature state if it isn't pending.
@@ -165,6 +166,14 @@ function AccessOrNotFoundWrapper({
setIsPolicyFeatureEnabled(isFeatureEnabled);
}, [pendingField, isOffline, isFeatureEnabled]);
+ useEffect(() => {
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ if (isLoadingReportData || !isPolicyNotAccessible) {
+ return;
+ }
+ Navigation.removeScreenFromNavigationState(SCREENS.WORKSPACE.INITIAL);
+ }, [isLoadingReportData, isPolicyNotAccessible]);
+
if (shouldShowFullScreenLoadingIndicator) {
return ;
}
diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx
index c14990ab720b..c42fd980470d 100644
--- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx
+++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts.tsx
@@ -152,7 +152,10 @@ function WorkspaceExpensifyCardBankAccounts({route}: WorkspaceExpensifyCardBankA
text={translate('workspace.expensifyCard.gotIt')}
style={[styles.m5]}
pressOnEnter
- onPress={() => Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW.getRoute(policyID))}
+ onPress={() => {
+ Navigation.dismissModal();
+ Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW.getRoute(policyID));
+ }}
/>
>
);
diff --git a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx
index 2a744ce4bd2d..2bec17e0c580 100644
--- a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx
+++ b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx
@@ -74,7 +74,7 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) {
Policy.enablePolicyRules(policyID, true, true);
break;
case CONST.UPGRADE_FEATURE_INTRO_MAPPING.companyCards.id:
- Policy.enableCompanyCards(policyID, true);
+ Policy.enableCompanyCards(policyID, true, true);
break;
default:
}
diff --git a/src/styles/index.ts b/src/styles/index.ts
index fa47a4b071ce..b08987459a1a 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -5307,20 +5307,6 @@ const styles = (theme: ThemeColors) =>
left: 12,
},
- progressBarWrapper: {
- height: 2,
- width: '100%',
- backgroundColor: theme.border,
- borderRadius: 5,
- overflow: 'hidden',
- },
-
- progressBar: {
- height: '100%',
- backgroundColor: theme.success,
- width: '100%',
- },
-
qbdSetupLinkBox: {
backgroundColor: theme.hoverComponentBG,
borderRadius: variables.componentBorderRadiusMedium,
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index f517a19c5ebf..02778b4ca351 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -490,14 +490,14 @@ function getWidthAndHeightStyle(width: number, height?: number): Pick {
+function getIconWidthAndHeightStyle(small: boolean, medium: boolean, large: boolean, width: number, height: number, isButtonIcon: boolean): Pick {
switch (true) {
case small:
- return {width: hasText ? variables.iconSizeExtraSmall : variables.iconSizeSmall, height: hasText ? variables.iconSizeExtraSmall : variables?.iconSizeSmall};
+ return {width: isButtonIcon ? variables.iconSizeExtraSmall : variables.iconSizeSmall, height: isButtonIcon ? variables.iconSizeExtraSmall : variables?.iconSizeSmall};
case medium:
- return {width: hasText ? variables.iconSizeSmall : variables.iconSizeNormal, height: hasText ? variables.iconSizeSmall : variables.iconSizeNormal};
+ return {width: isButtonIcon ? variables.iconSizeSmall : variables.iconSizeNormal, height: isButtonIcon ? variables.iconSizeSmall : variables.iconSizeNormal};
case large:
- return {width: hasText ? variables.iconSizeNormal : variables.iconSizeLarge, height: hasText ? variables.iconSizeNormal : variables.iconSizeLarge};
+ return {width: isButtonIcon ? variables.iconSizeNormal : variables.iconSizeLarge, height: isButtonIcon ? variables.iconSizeNormal : variables.iconSizeLarge};
default: {
return {width, height};
}
@@ -1119,9 +1119,12 @@ function getAmountWidth(amount: string): number {
return width;
}
-function getItemBackgroundColorStyle(isSelected: boolean, isFocused: boolean, selectedBG: string, focusedBG: string): ViewStyle {
+function getItemBackgroundColorStyle(isSelected: boolean, isFocused: boolean, isDisabled: boolean, selectedBG: string, focusedBG: string): ViewStyle {
let backgroundColor;
- if (isSelected) {
+
+ if (isDisabled) {
+ backgroundColor = undefined;
+ } else if (isSelected) {
backgroundColor = selectedBG;
} else if (isFocused) {
backgroundColor = focusedBG;
diff --git a/src/types/onyx/ReviewDuplicates.ts b/src/types/onyx/ReviewDuplicates.ts
index 0682ed0a7f7c..6c5ccbd93481 100644
--- a/src/types/onyx/ReviewDuplicates.ts
+++ b/src/types/onyx/ReviewDuplicates.ts
@@ -8,6 +8,9 @@ type ReviewDuplicates = {
/** ID of transaction we want to keep */
transactionID: string;
+ /** ID of the transaction report we want to keep */
+ reportID: string;
+
/** Merchant which user want to keep */
merchant: string;
diff --git a/src/types/onyx/User.ts b/src/types/onyx/User.ts
index 56b7a83d1618..eb5f1d888c46 100644
--- a/src/types/onyx/User.ts
+++ b/src/types/onyx/User.ts
@@ -6,9 +6,6 @@ type User = {
/** Whether we should use the staging version of the secure API server */
shouldUseStagingServer?: boolean;
- /** Whether user muted all sounds in application */
- isMutedAllSounds?: boolean;
-
/** Is the user account validated? */
validated: boolean;
diff --git a/tests/perf-test/SidebarUtils.perf-test.ts b/tests/perf-test/SidebarUtils.perf-test.ts
index 4a6b12d726d9..4ea4e1d04b50 100644
--- a/tests/perf-test/SidebarUtils.perf-test.ts
+++ b/tests/perf-test/SidebarUtils.perf-test.ts
@@ -2,7 +2,6 @@ import {rand} from '@ngneat/falso';
import type {OnyxCollection} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import {measureFunction} from 'reassure';
-import {getReportActionMessage} from '@libs/ReportActionsUtils';
import SidebarUtils from '@libs/SidebarUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -53,25 +52,6 @@ const policies = createCollection(
const mockedBetas = Object.values(CONST.BETAS);
-const allReportActions = Object.fromEntries(
- Object.keys(reportActions).map((key) => [
- `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${key}`,
- [
- {
- errors: reportActions[key].errors ?? [],
- message: [
- {
- moderationDecision: {
- decision: getReportActionMessage(reportActions[key])?.moderationDecision?.decision,
- },
- },
- ],
- reportActionID: reportActions[key].reportActionID,
- },
- ],
- ]),
-) as unknown as OnyxCollection;
-
const currentReportId = '1';
const transactionViolations = {} as OnyxCollection;
@@ -114,13 +94,11 @@ describe('SidebarUtils', () => {
test('[SidebarUtils] getOrderedReportIDs on 15k reports for default priorityMode', async () => {
await waitForBatchedUpdates();
- await measureFunction(() =>
- SidebarUtils.getOrderedReportIDs(currentReportId, allReports, mockedBetas, policies, CONST.PRIORITY_MODE.DEFAULT, allReportActions, transactionViolations),
- );
+ await measureFunction(() => SidebarUtils.getOrderedReportIDs(currentReportId, allReports, mockedBetas, policies, CONST.PRIORITY_MODE.DEFAULT, transactionViolations));
});
test('[SidebarUtils] getOrderedReportIDs on 15k reports for GSD priorityMode', async () => {
await waitForBatchedUpdates();
- await measureFunction(() => SidebarUtils.getOrderedReportIDs(currentReportId, allReports, mockedBetas, policies, CONST.PRIORITY_MODE.GSD, allReportActions, transactionViolations));
+ await measureFunction(() => SidebarUtils.getOrderedReportIDs(currentReportId, allReports, mockedBetas, policies, CONST.PRIORITY_MODE.GSD, transactionViolations));
});
});
diff --git a/tests/unit/Search/getQueryWithSubstitutionsTest.ts b/tests/unit/Search/getQueryWithSubstitutionsTest.ts
new file mode 100644
index 000000000000..8ca2eec31256
--- /dev/null
+++ b/tests/unit/Search/getQueryWithSubstitutionsTest.ts
@@ -0,0 +1,92 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+// we need "dirty" object key names in these tests
+import {getQueryWithSubstitutions} from '@src/components/Search/SearchRouter/getQueryWithSubstitutions';
+
+describe('getQueryWithSubstitutions should compute and return correct new query', () => {
+ test('when both queries contain no substitutions', () => {
+ // given this previous query: "foo"
+ const userTypedQuery = 'foo bar';
+ const substitutionsMock = {};
+
+ const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock);
+
+ expect(result).toBe('foo bar');
+ });
+
+ test('when query has a substitution and plain text was added after it', () => {
+ // given this previous query: "foo from:@mateusz"
+ const userTypedQuery = 'foo from:Mat test';
+ const substitutionsMock = {
+ 'from:Mat': '@mateusz',
+ };
+
+ const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock);
+
+ expect(result).toBe('foo from:@mateusz test');
+ });
+
+ test('when query has a substitution and plain text was added after before it', () => {
+ // given this previous query: "foo from:@mateusz1"
+ const userTypedQuery = 'foo bar from:Mat1';
+ const substitutionsMock = {
+ 'from:Mat1': '@mateusz1',
+ };
+
+ const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock);
+
+ expect(result).toBe('foo bar from:@mateusz1');
+ });
+
+ test('when query has a substitution and then it was removed', () => {
+ // given this previous query: "foo from:@mateusz"
+ const userTypedQuery = 'foo from:Ma';
+ const substitutionsMock = {
+ 'from:Mat': '@mateusz',
+ };
+
+ const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock);
+
+ expect(result).toBe('foo from:Ma');
+ });
+
+ test('when query has a substitution and then it was changed', () => {
+ // given this previous query: "foo from:@mateusz1"
+ const userTypedQuery = 'foo from:Maat1';
+ const substitutionsMock = {
+ 'from:Mat1': '@mateusz1',
+ };
+
+ const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock);
+
+ expect(result).toBe('foo from:Maat1');
+ });
+
+ test('when query has multiple substitutions and one was changed on the last position', () => {
+ // given this previous query: "foo in:123,456 from:@jakub"
+ // oldHumanReadableQ = 'foo in:admin,admins from:Jakub'
+ const userTypedQuery = 'foo in:admin,admins from:Jakub2';
+ const substitutionsMock = {
+ 'in:admin': '123',
+ 'in:admins': '456',
+ 'from:Jakub': '@jakub',
+ };
+
+ const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock);
+
+ expect(result).toBe('foo in:123,456 from:Jakub2');
+ });
+
+ test('when query has multiple substitutions and one was changed in the middle', () => {
+ // given this previous query: "foo in:aabbccdd123,zxcv123 from:@jakub"
+ const userTypedQuery = 'foo in:wave2,waveControl from:zzzz';
+
+ const substM = {
+ 'in:wave': 'aabbccdd123',
+ 'in:waveControl': 'zxcv123',
+ };
+
+ const result = getQueryWithSubstitutions(userTypedQuery, substM);
+
+ expect(result).toBe('foo in:wave2,zxcv123 from:zzzz');
+ });
+});
diff --git a/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts b/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts
new file mode 100644
index 000000000000..43829af9f873
--- /dev/null
+++ b/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts
@@ -0,0 +1,55 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+// we need "dirty" object key names in these tests
+import {getUpdatedSubstitutionsMap} from '@src/components/Search/SearchRouter/getUpdatedSubstitutionsMap';
+
+describe('getUpdatedSubstitutionsMap should return updated and cleaned substitutions map', () => {
+ test('when there were no substitutions', () => {
+ const userTypedQuery = 'foo bar';
+ const substitutionsMock = {};
+
+ const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock);
+
+ expect(result).toStrictEqual({});
+ });
+
+ test('when query has a substitution and it did not change', () => {
+ const userTypedQuery = 'foo from:Mat';
+ const substitutionsMock = {
+ 'from:Mat': '@mateusz',
+ };
+
+ const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock);
+
+ expect(result).toStrictEqual({
+ 'from:Mat': '@mateusz',
+ });
+ });
+
+ test('when query has a substitution and it changed', () => {
+ const userTypedQuery = 'foo from:Johnny';
+ const substitutionsMock = {
+ 'from:Steven': '@steven',
+ };
+
+ const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock);
+
+ expect(result).toStrictEqual({});
+ });
+
+ test('when query has multiple substitutions and some changed but some stayed', () => {
+ const userTypedQuery = 'from:Johnny to:Steven category:Fruitzzzz';
+ const substitutionsMock = {
+ 'from:Johnny': '@johnny',
+ 'to:Steven': '@steven',
+ 'from:OldName': '@oldName',
+ 'category:Fruit': '123456',
+ };
+
+ const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock);
+
+ expect(result).toStrictEqual({
+ 'from:Johnny': '@johnny',
+ 'to:Steven': '@steven',
+ });
+ });
+});