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', + }); + }); +});