diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml index 831ec0c0b95e..d4577e112d59 100644 --- a/.github/workflows/deployExpensifyHelp.yml +++ b/.github/workflows/deployExpensifyHelp.yml @@ -46,6 +46,7 @@ jobs: - name: Deploy to Cloudflare Pages uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca id: deploy + if: github.event_name != 'pull_request' || (github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork) with: apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml index 4d6597334447..91e244a0ed7c 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -157,6 +157,8 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + GCP_GEOLOCATION_API_KEY: $${{ secrets.GCP_GEOLOCATION_API_KEY_PRODUCTION }} + - name: Build staging desktop app if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} @@ -168,6 +170,7 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + GCP_GEOLOCATION_API_KEY: $${{ secrets.GCP_GEOLOCATION_API_KEY_STAGING }} iOS: name: Build and deploy iOS diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index 3f02430f3c1f..fc9e75e626d3 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -265,6 +265,7 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + GCP_GEOLOCATION_API_KEY: $${{ secrets.GCP_GEOLOCATION_API_KEY_STAGING }} web: name: Build and deploy Web diff --git a/assets/images/document-plus.svg b/assets/images/document-plus.svg new file mode 100644 index 000000000000..cce2e3027cea --- /dev/null +++ b/assets/images/document-plus.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/desktop/main.ts b/desktop/main.ts index cbc12d9d2608..6e14d661b345 100644 --- a/desktop/main.ts +++ b/desktop/main.ts @@ -21,7 +21,7 @@ const {DESKTOP_SHORTCUT_ACCELERATOR, LOCALES} = CONST; // Setup google api key in process environment, we are setting it this way intentionally. It is required by the // geolocation api (window.navigator.geolocation.getCurrentPosition) to work on desktop. // Source: https://github.com/electron/electron/blob/98cd16d336f512406eee3565be1cead86514db7b/docs/api/environment-variables.md#google_api_key -process.env.GOOGLE_API_KEY = CONFIG.GOOGLE_GEOLOCATION_API_KEY; +process.env.GOOGLE_API_KEY = CONFIG.GCP_GEOLOCATION_API_KEY; app.setName('New Expensify'); diff --git a/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md b/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md index c7ae49e02292..096a3d1527be 100644 --- a/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md +++ b/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md @@ -12,7 +12,7 @@ For a quick snapshot of how Expensify Chat works, and New Expensify in general, # What’s Expensify Chat? -Expensify Chat is an instant messaging and payment platform. You can manage all your payments, wether for business or personal, and discuss the transactions themselves. +Expensify Chat is an instant messaging and payment platform. You can manage all your payments, whether for business or personal, and discuss the transactions themselves. With Expensify Chat, you can start a conversation about that missing receipt your employee forgot to submit or chat about splitting that electric bill with your roommates. Expensify makes sending and receiving money as easy as sending and receiving messages. Chat with anyone directly, in groups, or in rooms. diff --git a/ios/Podfile.lock b/ios/Podfile.lock index dd2084b238fb..310003ee8adc 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -39,7 +39,7 @@ PODS: - React-Core - Expo (50.0.4): - ExpoModulesCore - - ExpoImage (1.10.1): + - ExpoImage (1.11.0): - ExpoModulesCore - SDWebImage (~> 5.17.0) - SDWebImageAVIFCoder (~> 0.10.1) @@ -1790,7 +1790,7 @@ SPEC CHECKSUMS: EXAV: 09a4d87fa6b113fbb0ada3aade6799f78271cb44 EXImageLoader: 55080616b2fe9da19ef8c7f706afd9814e279b6b Expo: 1e3bcf9dd99de57a636127057f6b488f0609681a - ExpoImage: 1cdaa65a6a70bb01067e21ad1347ff2d973885f5 + ExpoImage: 390c524542b258f8173f475c1cc71f016444a7be ExpoImageManipulator: c1d7cb865eacd620a35659f3da34c70531f10b59 ExpoModulesCore: 96d1751929ad10622773bb729ab28a8423f0dd0c FBLazyVector: fbc4957d9aa695250b55d879c1d86f79d7e69ab4 @@ -1921,7 +1921,7 @@ SPEC CHECKSUMS: SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 Turf: 13d1a92d969ca0311bbc26e8356cca178ce95da2 VisionCamera: 0a6794d1974aed5d653d0d0cb900493e2583e35a - Yoga: 13c8ef87792450193e117976337b8527b49e8c03 + Yoga: e64aa65de36c0832d04e8c7bd614396c77a80047 PODFILE CHECKSUM: a431c146e1501391834a2f299a74093bac53b530 diff --git a/package-lock.json b/package-lock.json index 61d6a27821cd..118999e83814 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,7 +54,7 @@ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#615f4a8662cd1abea9fdeee4d04847197c5e36ae", "expo": "^50.0.3", "expo-av": "~13.10.4", - "expo-image": "1.10.1", + "expo-image": "1.11.0", "expo-image-manipulator": "11.8.0", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", @@ -27514,8 +27514,9 @@ } }, "node_modules/expo-image": { - "version": "1.10.1", - "license": "MIT", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-1.11.0.tgz", + "integrity": "sha512-CfkSGWIGidxxqzErke16bCS9aefS04qvgjk+T9Nc34LAb3ysBAqCv5hoCnvylHOvi/7jTCC/ouLm5oLLqkDSRQ==", "dependencies": { "@react-native/assets-registry": "~0.73.1" }, diff --git a/package.json b/package.json index 92a6b9cde5e1..3161079012ad 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#615f4a8662cd1abea9fdeee4d04847197c5e36ae", "expo": "^50.0.3", "expo-av": "~13.10.4", - "expo-image": "1.10.1", + "expo-image": "1.11.0", "expo-image-manipulator": "11.8.0", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", diff --git a/patches/expo-image+1.10.1+001+applyFill.patch b/patches/expo-image+1.10.1+001+applyFill.patch deleted file mode 100644 index 5f168040d04d..000000000000 --- a/patches/expo-image+1.10.1+001+applyFill.patch +++ /dev/null @@ -1,112 +0,0 @@ -diff --git a/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt b/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt -index 619daf2..b58a0df 100644 ---- a/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt -+++ b/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt -@@ -1,5 +1,9 @@ - package com.caverock.androidsvg - -+import com.caverock.androidsvg.SVG.SPECIFIED_COLOR -+import com.caverock.androidsvg.SVG.SPECIFIED_FILL -+import com.caverock.androidsvg.SVG.SvgElementBase -+ - internal fun replaceColor(paint: SVG.SvgPaint?, newColor: Int) { - if (paint is SVG.Colour && paint !== SVG.Colour.TRANSPARENT) { - paint.colour = newColor -@@ -19,15 +23,83 @@ internal fun replaceStyles(style: SVG.Style?, newColor: Int) { - replaceColor(style.viewportFill, newColor) - } - --internal fun applyTintColor(element: SVG.SvgObject, newColor: Int) { -- if (element is SVG.SvgElementBase) { -+internal fun hasStyle(element: SvgElementBase): Boolean { -+ if (element.style == null && element.baseStyle == null) { -+ return false -+ } -+ -+ val style = element.style -+ val hasColorInStyle = style != null && -+ ( -+ style.color != null || style.fill != null || style.stroke != null || -+ style.stroke != null || style.stopColor != null || style.solidColor != null -+ ) -+ -+ if (hasColorInStyle) { -+ return true -+ } -+ -+ val baseStyle = element.baseStyle ?: return false -+ return baseStyle.color != null || baseStyle.fill != null || baseStyle.stroke != null || -+ baseStyle.viewportFill != null || baseStyle.stopColor != null || baseStyle.solidColor != null -+} -+ -+internal fun defineStyles(element: SvgElementBase, newColor: Int, hasStyle: Boolean) { -+ if (hasStyle) { -+ return -+ } -+ -+ val style = if (element.style != null) { -+ element.style -+ } else { -+ SVG.Style().also { -+ element.style = it -+ } -+ } -+ -+ val color = SVG.Colour(newColor) -+ when (element) { -+ is SVG.Path, -+ is SVG.Circle, -+ is SVG.Ellipse, -+ is SVG.Rect, -+ is SVG.SolidColor, -+ is SVG.Line, -+ is SVG.Polygon, -+ is SVG.PolyLine -> { -+ style.apply { -+ fill = color -+ -+ specifiedFlags = SPECIFIED_FILL -+ } -+ } -+ -+ is SVG.TextPath -> { -+ style.apply { -+ this.color = color -+ -+ specifiedFlags = SPECIFIED_COLOR -+ } -+ } -+ } -+} -+ -+internal fun applyTintColor(element: SVG.SvgObject, newColor: Int, parentDefinesStyle: Boolean) { -+ val definesStyle = if (element is SvgElementBase) { -+ val hasStyle = parentDefinesStyle || hasStyle(element) -+ - replaceStyles(element.baseStyle, newColor) - replaceStyles(element.style, newColor) -+ defineStyles(element, newColor, hasStyle) -+ -+ hasStyle -+ } else { -+ parentDefinesStyle - } - - if (element is SVG.SvgContainer) { - for (child in element.children) { -- applyTintColor(child, newColor) -+ applyTintColor(child, newColor, definesStyle) - } - } - } -@@ -36,8 +108,9 @@ fun applyTintColor(svg: SVG, newColor: Int) { - val root = svg.rootElement - - replaceStyles(root.style, newColor) -+ val hasStyle = hasStyle(root) - - for (child in root.children) { -- applyTintColor(child, newColor) -+ applyTintColor(child, newColor, hasStyle) - } - } diff --git a/patches/expo-image+1.10.1+002+TintFix.patch b/patches/expo-image+1.10.1+002+TintFix.patch deleted file mode 100644 index 92b56c039b43..000000000000 --- a/patches/expo-image+1.10.1+002+TintFix.patch +++ /dev/null @@ -1,38 +0,0 @@ -diff --git a/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt b/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt -index b58a0df..6b8da3c 100644 ---- a/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt -+++ b/node_modules/expo-image/android/src/main/java/com/caverock/androidsvg/SVGStyler.kt -@@ -107,6 +107,7 @@ internal fun applyTintColor(element: SVG.SvgObject, newColor: Int, parentDefines - fun applyTintColor(svg: SVG, newColor: Int) { - val root = svg.rootElement - -+ replaceStyles(root.baseStyle, newColor) - replaceStyles(root.style, newColor) - val hasStyle = hasStyle(root) - -diff --git a/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt b/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt -index 602b570..8becf72 100644 ---- a/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt -+++ b/node_modules/expo-image/android/src/main/java/expo/modules/image/ExpoImageViewWrapper.kt -@@ -31,6 +31,7 @@ import expo.modules.image.records.ImageLoadEvent - import expo.modules.image.records.ImageProgressEvent - import expo.modules.image.records.ImageTransition - import expo.modules.image.records.SourceMap -+import expo.modules.image.svg.SVGPictureDrawable - import expo.modules.kotlin.AppContext - import expo.modules.kotlin.tracing.beginAsyncTraceBlock - import expo.modules.kotlin.tracing.trace -@@ -127,7 +128,12 @@ class ExpoImageViewWrapper(context: Context, appContext: AppContext) : ExpoView( - internal var tintColor: Int? = null - set(value) { - field = value -- activeView.setTintColor(value) -+ // To apply the tint color to the SVG, we need to recreate the drawable. -+ if (activeView.drawable is SVGPictureDrawable) { -+ shouldRerender = true -+ } else { -+ activeView.setTintColor(value) -+ } - } - - internal var isFocusableProp: Boolean = false diff --git a/src/CONFIG.ts b/src/CONFIG.ts index 37da65f0c305..76ea18d37d5f 100644 --- a/src/CONFIG.ts +++ b/src/CONFIG.ts @@ -21,7 +21,7 @@ const secureExpensifyUrl = Url.addTrailingForwardSlash(get(Config, 'SECURE_EXPEN const useNgrok = get(Config, 'USE_NGROK', 'false') === 'true'; const useWebProxy = get(Config, 'USE_WEB_PROXY', 'true') === 'true'; const expensifyComWithProxy = getPlatform() === 'web' && useWebProxy ? '/' : expensifyURL; -const googleGeolocationAPIKey = get(Config, 'GOOGLE_GEOLOCATION_API_KEY', 'AIzaSyBqg6bMvQU7cPWDKhhzpYqJrTEnSorpiLI'); +const googleGeolocationAPIKey = get(Config, 'GCP_GEOLOCATION_API_KEY', ''); // Throw errors on dev if config variables are not set correctly if (ENVIRONMENT === CONST.ENVIRONMENT.DEV) { @@ -94,5 +94,5 @@ export default { WEB_CLIENT_ID: '921154746561-gpsoaqgqfuqrfsjdf8l7vohfkfj7b9up.apps.googleusercontent.com', IOS_CLIENT_ID: '921154746561-s3uqn2oe4m85tufi6mqflbfbuajrm2i3.apps.googleusercontent.com', }, - GOOGLE_GEOLOCATION_API_KEY: googleGeolocationAPIKey, + GCP_GEOLOCATION_API_KEY: googleGeolocationAPIKey, } as const; diff --git a/src/CONST.ts b/src/CONST.ts index c254f318a9d6..3c53f083abac 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -332,6 +332,7 @@ const CONST = { BETA_COMMENT_LINKING: 'commentLinking', VIOLATIONS: 'violations', REPORT_FIELDS: 'reportFields', + TRACK_EXPENSE: 'trackExpense', P2P_DISTANCE_REQUESTS: 'p2pDistanceRequests', WORKFLOWS_DELAYED_SUBMISSION: 'workflowsDelayedSubmission', }, @@ -358,6 +359,7 @@ const CONST = { NOT_INSTALLED: 'not-installed', }, TAX_RATES: { + CUSTOM_NAME_MAX_LENGTH: 8, NAME_MAX_LENGTH: 50, }, PLATFORM: { @@ -1344,6 +1346,7 @@ const CONST = { SEND: 'send', SPLIT: 'split', REQUEST: 'request', + TRACK_EXPENSE: 'track-expense', }, REQUEST_TYPE: { DISTANCE: 'distance', @@ -1358,6 +1361,7 @@ const CONST = { CANCEL: 'cancel', DELETE: 'delete', APPROVE: 'approve', + TRACK: 'track', }, AMOUNT_MAX_LENGTH: 10, RECEIPT_STATE: { @@ -3414,6 +3418,9 @@ const CONST = { REPORT_FIELD_TITLE_FIELD_ID: 'text_title', + MOBILE_PAGINATION_SIZE: 15, + WEB_PAGINATION_SIZE: 50, + /** Dimensions for illustration shown in Confirmation Modal */ CONFIRM_CONTENT_SVG_SIZE: { HEIGHT: 220, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 51fec780fc9f..d74e691fe10e 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -303,7 +303,6 @@ const ONYXKEYS = { POLICY_TAGS: 'policyTags_', POLICY_RECENTLY_USED_TAGS: 'nvp_recentlyUsedTags_', OLD_POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_', - POLICY_REPORT_FIELDS: 'policyReportFields_', WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_', WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_', REPORT: 'report_', @@ -500,7 +499,6 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.POLICY_MEMBERS]: OnyxTypes.PolicyMembers; [ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS]: OnyxTypes.PolicyMember; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES]: OnyxTypes.RecentlyUsedCategories; - [ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS]: OnyxTypes.PolicyReportFields; [ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST]: OnyxTypes.PolicyMembers; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: OnyxTypes.InvitedEmailsToAccountIDs; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT]: string; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 9ce32835c8d7..1f802c5036e3 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -332,9 +332,9 @@ const ROUTES = { getUrlWithBackToParam(`create/${iouType}/taxAmount/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_CATEGORY: { - route: ':action/:iouType/category/:transactionID/:reportID', - getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`${action}/${iouType}/category/${transactionID}/${reportID}`, backTo), + route: ':action/:iouType/category/:transactionID/:reportID/:reportActionID?', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '', reportActionID?: string) => + getUrlWithBackToParam(`${action}/${iouType}/category/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo), }, MONEY_REQUEST_STEP_CURRENCY: { route: 'create/:iouType/currency/:transactionID/:reportID/:pageIndex?', @@ -347,9 +347,9 @@ const ROUTES = { getUrlWithBackToParam(`${action}/${iouType}/date/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_DESCRIPTION: { - route: ':action/:iouType/description/:transactionID/:reportID', - getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`${action}/${iouType}/description/${transactionID}/${reportID}`, backTo), + route: ':action/:iouType/description/:transactionID/:reportID/:reportActionID?', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '', reportActionID?: string) => + getUrlWithBackToParam(`${action}/${iouType}/description/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo), }, MONEY_REQUEST_STEP_DISTANCE: { route: 'create/:iouType/distance/:transactionID/:reportID', diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index 16f31b9c7eba..396c10151fbf 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -60,7 +60,7 @@ function AvatarWithDisplayName({ const title = ReportUtils.getReportName(report); const subtitle = ReportUtils.getChatRoomSubtitle(report); const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(report); - const isMoneyRequestOrReport = ReportUtils.isMoneyRequestReport(report) || ReportUtils.isMoneyRequest(report); + const isMoneyRequestOrReport = ReportUtils.isMoneyRequestReport(report) || ReportUtils.isMoneyRequest(report) || ReportUtils.isTrackExpenseReport(report); const icons = ReportUtils.getIcons(report, personalDetails, null, '', -1, policy); const ownerPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(report?.ownerAccountID ? [report.ownerAccountID] : [], personalDetails); const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(Object.values(ownerPersonalDetails) as PersonalDetails[], false); diff --git a/src/components/AvatarWithImagePicker.tsx b/src/components/AvatarWithImagePicker.tsx index 8bcda759d26c..e39e940ebf5c 100644 --- a/src/components/AvatarWithImagePicker.tsx +++ b/src/components/AvatarWithImagePicker.tsx @@ -287,11 +287,12 @@ function AvatarWithImagePicker({ return ( - + diff --git a/src/components/FlatList/MVCPFlatList.js b/src/components/FlatList/MVCPFlatList.js index 7fa8b364fb0f..656a0ed7f00e 100644 --- a/src/components/FlatList/MVCPFlatList.js +++ b/src/components/FlatList/MVCPFlatList.js @@ -44,15 +44,15 @@ const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizont if (scrollRef.current == null) { return 0; } - return horizontal ? scrollRef.current.getScrollableNode().scrollLeft : scrollRef.current.getScrollableNode().scrollTop; + return horizontal ? scrollRef.current?.getScrollableNode()?.scrollLeft : scrollRef.current?.getScrollableNode()?.scrollTop; }, [horizontal]); - const getContentView = React.useCallback(() => scrollRef.current?.getScrollableNode().childNodes[0], []); + const getContentView = React.useCallback(() => scrollRef.current?.getScrollableNode()?.childNodes[0], []); const scrollToOffset = React.useCallback( (offset, animated) => { const behavior = animated ? 'smooth' : 'instant'; - scrollRef.current?.getScrollableNode().scroll(horizontal ? {left: offset, behavior} : {top: offset, behavior}); + scrollRef.current?.getScrollableNode()?.scroll(horizontal ? {left: offset, behavior} : {top: offset, behavior}); }, [horizontal], ); @@ -68,12 +68,13 @@ const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizont } const scrollOffset = getScrollOffset(); + lastScrollOffsetRef.current = scrollOffset; const contentViewLength = contentView.childNodes.length; for (let i = mvcpMinIndexForVisible; i < contentViewLength; i++) { const subview = contentView.childNodes[i]; const subviewOffset = horizontal ? subview.offsetLeft : subview.offsetTop; - if (subviewOffset > scrollOffset || i === contentViewLength - 1) { + if (subviewOffset > scrollOffset) { prevFirstVisibleOffsetRef.current = subviewOffset; firstVisibleViewRef.current = subview; break; @@ -126,6 +127,7 @@ const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizont } adjustForMaintainVisibleContentPosition(); + prepareForMaintainVisibleContentPosition(); }); }); mutationObserver.observe(contentView, { @@ -135,7 +137,7 @@ const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizont }); mutationObserverRef.current = mutationObserver; - }, [adjustForMaintainVisibleContentPosition, getContentView, getScrollOffset, scrollToOffset]); + }, [adjustForMaintainVisibleContentPosition, prepareForMaintainVisibleContentPosition, getContentView, getScrollOffset, scrollToOffset]); React.useEffect(() => { if (!isListRenderedRef.current) { @@ -172,13 +174,11 @@ const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizont const onScrollInternal = React.useCallback( (ev) => { - lastScrollOffsetRef.current = getScrollOffset(); - prepareForMaintainVisibleContentPosition(); onScroll?.(ev); }, - [getScrollOffset, prepareForMaintainVisibleContentPosition, onScroll], + [prepareForMaintainVisibleContentPosition, onScroll], ); return ( diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx index 5c2488ca144a..902a96b1bcaf 100644 --- a/src/components/Form/FormWrapper.tsx +++ b/src/components/Form/FormWrapper.tsx @@ -110,7 +110,7 @@ function FormWrapper({ buttonText={submitButtonText} isAlertVisible={((!isEmptyObject(errors) || !isEmptyObject(formState?.errorFields)) && !shouldHideFixErrorsAlert) || !!errorMessage} isLoading={!!formState?.isLoading} - message={typeof errorMessage === 'string' && isEmptyObject(formState?.errorFields) ? errorMessage : undefined} + message={isEmptyObject(formState?.errorFields) ? errorMessage : undefined} onSubmit={onSubmit} footerContent={footerContent} onFixTheErrorsLinkPressed={onFixTheErrorsLinkPressed} diff --git a/src/components/HoldMenuSectionList.tsx b/src/components/HoldMenuSectionList.tsx index aa5dd75ce159..4ffdfa1bd60e 100644 --- a/src/components/HoldMenuSectionList.tsx +++ b/src/components/HoldMenuSectionList.tsx @@ -59,12 +59,7 @@ function HoldMenuSectionList() { /> {translate(section.titleTranslationKey)} - - {translate(section.descriptionTranslationKey)} - + {translate(section.descriptionTranslationKey)} ))} diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 73a091815460..7116ba2aab67 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -42,6 +42,7 @@ import Concierge from '@assets/images/concierge.svg'; import Connect from '@assets/images/connect.svg'; import Copy from '@assets/images/copy.svg'; import CreditCard from '@assets/images/creditcard.svg'; +import DocumentPlus from '@assets/images/document-plus.svg'; import DocumentSlash from '@assets/images/document-slash.svg'; import Document from '@assets/images/document.svg'; import DotIndicatorUnfilled from '@assets/images/dot-indicator-unfilled.svg'; @@ -314,4 +315,5 @@ export { ChatBubbleUnread, ChatBubbleReply, Lightbulb, + DocumentPlus, }; diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList.tsx index 0549e19c2eb4..9ee465369be1 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList.tsx @@ -1,23 +1,35 @@ import type {ForwardedRef} from 'react'; -import React, {forwardRef} from 'react'; -import type {FlatListProps} from 'react-native'; +import React, {forwardRef, useMemo} from 'react'; +import type {FlatListProps, ScrollViewProps} from 'react-native'; import FlatList from '@components/FlatList'; -const WINDOW_SIZE = 15; +type BaseInvertedFlatListProps = FlatListProps & { + shouldEnableAutoScrollToTopThreshold?: boolean; +}; + const AUTOSCROLL_TO_TOP_THRESHOLD = 128; -const maintainVisibleContentPosition = { - minIndexForVisible: 0, - autoscrollToTopThreshold: AUTOSCROLL_TO_TOP_THRESHOLD, -}; +function BaseInvertedFlatList(props: BaseInvertedFlatListProps, ref: ForwardedRef) { + const {shouldEnableAutoScrollToTopThreshold, ...rest} = props; + + const maintainVisibleContentPosition = useMemo(() => { + const config: ScrollViewProps['maintainVisibleContentPosition'] = { + // This needs to be 1 to avoid using loading views as anchors. + minIndexForVisible: 1, + }; + + if (shouldEnableAutoScrollToTopThreshold) { + config.autoscrollToTopThreshold = AUTOSCROLL_TO_TOP_THRESHOLD; + } + + return config; + }, [shouldEnableAutoScrollToTopThreshold]); -function BaseInvertedFlatList(props: FlatListProps, ref: ForwardedRef) { return ( @@ -27,3 +39,5 @@ function BaseInvertedFlatList(props: FlatListProps, ref: ForwardedRef = FlatListProps & { + shouldEnableAutoScrollToTopThreshold?: boolean; +}; + // This is adapted from https://codesandbox.io/s/react-native-dsyse // It's a HACK alert since FlatList has inverted scrolling on web -function InvertedFlatList({onScroll: onScrollProp = () => {}, ...props}: FlatListProps, ref: ForwardedRef) { +function InvertedFlatList({onScroll: onScrollProp = () => {}, ...props}: InvertedFlatListProps, ref: ForwardedRef) { const lastScrollEvent = useRef(null); const scrollEndTimeout = useRef(null); const updateInProgress = useRef(false); diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 923337ba9ada..5065d1cc7c13 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -54,14 +54,6 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti const hasBrickError = optionItem.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; const shouldShowGreenDotIndicator = !hasBrickError && ReportUtils.requiresAttentionFromCurrentUser(optionItem, optionItem.parentReportAction); - - const isHidden = optionItem.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; - - const shouldOverrideHidden = hasBrickError || isFocused || optionItem.isPinned; - if (isHidden && !shouldOverrideHidden) { - return null; - } - const isInFocusMode = viewMode === CONST.OPTION_MODE.COMPACT; const textStyle = isFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText; const textUnreadStyle = optionItem?.isUnread && optionItem.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE ? [textStyle, styles.sidebarLinkTextBold] : [textStyle]; diff --git a/src/components/Lightbox/index.tsx b/src/components/Lightbox/index.tsx index 56852a8e2ea1..86a52c2baf6c 100644 --- a/src/components/Lightbox/index.tsx +++ b/src/components/Lightbox/index.tsx @@ -112,7 +112,7 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan return; } - setContentSize({width: width * PixelRatio.get(), height: height * PixelRatio.get()}); + setContentSize({width, height}); }, [contentSize, setContentSize], ); diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 2e8f80175b56..a4da7e551515 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -665,7 +665,14 @@ function MoneyRequestConfirmationList({ description={translate('common.description')} onPress={() => { Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams()), + ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction?.transactionID ?? '', + reportID, + Navigation.getActiveRouteWithoutParams(), + reportActionID, + ), ); }} style={styles.moneyRequestMenuItem} @@ -757,6 +764,7 @@ function MoneyRequestConfirmationList({ transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams(), + reportActionID, ), ); }} diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index e70e121569fd..5d3231ca0a41 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -71,11 +71,15 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, const deleteTransaction = useCallback(() => { if (parentReportAction) { const iouTransactionID = parentReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? parentReportAction.originalMessage?.IOUTransactionID ?? '' : ''; + if (ReportActionsUtils.isTrackExpenseAction(parentReportAction)) { + IOU.deleteTrackExpense(parentReport?.reportID ?? '', iouTransactionID, parentReportAction, true); + return; + } IOU.deleteMoneyRequest(iouTransactionID, parentReportAction, true); } setIsDeleteModalVisible(false); - }, [parentReportAction, setIsDeleteModalVisible]); + }, [parentReport?.reportID, parentReportAction, setIsDeleteModalVisible]); const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); const isPending = TransactionUtils.isExpensifyCardTransaction(transaction) && TransactionUtils.isPending(transaction); @@ -84,7 +88,7 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, const canHoldOrUnholdRequest = !isSettled && !isApproved && !isDeletedParentAction; // If the report supports adding transactions to it, then it also supports deleting transactions from it. - const canDeleteRequest = isActionOwner && ReportUtils.canAddOrDeleteTransactions(moneyRequestReport) && !isDeletedParentAction; + const canDeleteRequest = isActionOwner && (ReportUtils.canAddOrDeleteTransactions(moneyRequestReport) || ReportUtils.isTrackExpenseReport(report)) && !isDeletedParentAction; const changeMoneyRequestStatus = () => { const iouTransactionID = parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? parentReportAction.originalMessage?.IOUTransactionID ?? '' : ''; @@ -109,7 +113,8 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, if (canHoldOrUnholdRequest) { const isRequestIOU = parentReport?.type === 'iou'; const isHoldCreator = ReportUtils.isHoldCreator(transaction, report?.reportID) && isRequestIOU; - const canModifyStatus = isPolicyAdmin || isActionOwner || isApprover; + const isTrackExpenseReport = ReportUtils.isTrackExpenseReport(report); + const canModifyStatus = !isTrackExpenseReport && (isPolicyAdmin || isActionOwner || isApprover); if (isOnHold && (isHoldCreator || (!isRequestIOU && canModifyStatus))) { threeDotsMenuItems.push({ icon: Expensicons.Stopwatch, diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js index 0d1acc31ecdf..138bfc937926 100755 --- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js +++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js @@ -251,6 +251,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ const isTypeRequest = iouType === CONST.IOU.TYPE.REQUEST; const isTypeSplit = iouType === CONST.IOU.TYPE.SPLIT; const isTypeSend = iouType === CONST.IOU.TYPE.SEND; + const isTypeTrackExpense = iouType === CONST.IOU.TYPE.TRACK_EXPENSE; const canEditDistance = isTypeRequest || (canUseP2PDistanceRequests && isTypeSplit); const {unit, rate, currency} = mileageRate; @@ -381,7 +382,9 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ const splitOrRequestOptions = useMemo(() => { let text; - if (isTypeSplit && iouAmount === 0) { + if (isTypeTrackExpense) { + text = translate('iou.trackExpense'); + } else if (isTypeSplit && iouAmount === 0) { text = translate('iou.split'); } else if ((receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) { text = translate('iou.request'); @@ -398,7 +401,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ value: iouType, }, ]; - }, [isTypeSplit, isTypeRequest, iouType, iouAmount, receiptPath, formattedAmount, isDistanceRequestWithPendingRoute, translate]); + }, [isTypeTrackExpense, isTypeSplit, iouAmount, receiptPath, isTypeRequest, isDistanceRequestWithPendingRoute, iouType, translate, formattedAmount]); const selectedParticipants = useMemo(() => _.filter(pickedParticipants, (participant) => participant.selected), [pickedParticipants]); const personalDetailsOfPayee = useMemo(() => payeePersonalDetails || currentUserPersonalDetails, [payeePersonalDetails, currentUserPersonalDetails]); @@ -446,7 +449,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ } else { const formattedSelectedParticipants = _.map(selectedParticipants, (participant) => ({ ...participant, - isDisabled: !participant.isPolicyExpenseChat && ReportUtils.isOptimisticPersonalDetail(participant.accountID), + isDisabled: !participant.isPolicyExpenseChat && !participant.isSelfDM && ReportUtils.isOptimisticPersonalDetail(participant.accountID), })); sections.push({ title: translate('common.to'), @@ -538,6 +541,11 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ const navigateToReportOrUserDetail = (option) => { const activeRoute = Navigation.getActiveRouteWithoutParams(); + if (option.isSelfDM) { + Navigation.navigate(ROUTES.PROFILE.getRoute(currentUserPersonalDetails.accountID, activeRoute)); + return; + } + if (option.accountID) { Navigation.navigate(ROUTES.PROFILE.getRoute(option.accountID, activeRoute)); } else if (option.reportID) { diff --git a/src/components/OptionRow.tsx b/src/components/OptionRow.tsx index 97ef6885c80f..c72cdd1fd898 100644 --- a/src/components/OptionRow.tsx +++ b/src/components/OptionRow.tsx @@ -229,7 +229,12 @@ function OptionRow({ numberOfLines={isMultilineSupported ? 2 : 1} textStyles={displayNameStyle} shouldUseFullTitle={ - !!option.isChatRoom || !!option.isPolicyExpenseChat || !!option.isMoneyRequestReport || !!option.isThread || !!option.isTaskReport + !!option.isChatRoom || + !!option.isPolicyExpenseChat || + !!option.isMoneyRequestReport || + !!option.isThread || + !!option.isTaskReport || + !!option.isSelfDM } /> {option.alternateText ? ( diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index 60dbfc07966a..e9b0ce3dae3f 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -29,14 +29,11 @@ type MoneyReportViewProps = { /** Policy that the report belongs to */ policy: OnyxEntry; - /** Policy report fields */ - policyReportFields: PolicyReportField[]; - /** Whether we should display the horizontal rule below the component */ shouldShowHorizontalRule: boolean; }; -function MoneyReportView({report, policy, policyReportFields, shouldShowHorizontalRule}: MoneyReportViewProps) { +function MoneyReportView({report, policy, shouldShowHorizontalRule}: MoneyReportViewProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -60,9 +57,9 @@ function MoneyReportView({report, policy, policyReportFields, shouldShowHorizont ]; const sortedPolicyReportFields = useMemo((): PolicyReportField[] => { - const fields = ReportUtils.getAvailableReportFields(report, policyReportFields); + const fields = ReportUtils.getAvailableReportFields(report, Object.values(policy?.fieldList ?? {})); return fields.sort(({orderWeight: firstOrderWeight}, {orderWeight: secondOrderWeight}) => firstOrderWeight - secondOrderWeight); - }, [policyReportFields, report]); + }, [policy, report]); return ( @@ -75,13 +72,14 @@ function MoneyReportView({report, policy, policyReportFields, shouldShowHorizont const isTitleField = ReportUtils.isReportFieldOfTypeTitle(reportField); const fieldValue = isTitleField ? report.reportName : reportField.value ?? reportField.defaultValue; const isFieldDisabled = ReportUtils.isReportFieldDisabled(report, reportField, policy); + const fieldKey = ReportUtils.getReportFieldKey(reportField.fieldID); return ( { if (isSplitBillAction) { @@ -108,14 +110,24 @@ function MoneyRequestAction({ shouldShowPendingConversionMessage = IOUUtils.isIOUReportPendingCurrencyConversion(iouReport); } - return isDeletedParentAction || isReversedTransaction ? ( - ${translate(isReversedTransaction ? 'parentReportAction.reversedTransaction' : 'parentReportAction.deletedRequest')}`} /> - ) : ( + if (isDeletedParentAction || isReversedTransaction) { + let message: TranslationPaths; + if (isReversedTransaction) { + message = 'parentReportAction.reversedTransaction'; + } else if (isTrackExpenseAction) { + message = 'parentReportAction.deletedExpense'; + } else { + message = 'parentReportAction.deletedRequest'; + } + return ${translate(message)}`} />; + } + return ( ; + return lodashIsEmpty(props.iouReport) && !(props.isBillSplit || props.isTrackExpense) ? null : ; } MoneyRequestPreview.displayName = 'MoneyRequestPreview'; diff --git a/src/components/ReportActionItem/MoneyRequestPreview/types.ts b/src/components/ReportActionItem/MoneyRequestPreview/types.ts index 17dd42b2f794..3b3eda4ec30a 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/types.ts +++ b/src/components/ReportActionItem/MoneyRequestPreview/types.ts @@ -56,6 +56,9 @@ type MoneyRequestPreviewProps = MoneyRequestPreviewOnyxProps & { /** True if this is this IOU is a split instead of a 1:1 request */ isBillSplit: boolean; + /** Whether this IOU is a track expense */ + isTrackExpense: boolean; + /** True if the IOU Preview card is hovered */ isHovered?: boolean; diff --git a/src/components/ReportWelcomeText.tsx b/src/components/ReportWelcomeText.tsx index e9bbd0f27bdc..219199c25bc3 100644 --- a/src/components/ReportWelcomeText.tsx +++ b/src/components/ReportWelcomeText.tsx @@ -3,6 +3,7 @@ import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; @@ -33,6 +34,7 @@ type ReportWelcomeTextProps = ReportWelcomeTextOnyxProps & { function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const {canUseTrackExpense} = usePermissions(); const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); const isChatRoom = ReportUtils.isChatRoom(report); const isSelfDM = ReportUtils.isSelfDM(report); @@ -42,7 +44,7 @@ function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextP const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails), isMultipleParticipant); const isUserPolicyAdmin = PolicyUtils.isPolicyAdmin(policy); const roomWelcomeMessage = ReportUtils.getRoomWelcomeMessage(report, isUserPolicyAdmin); - const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, policy, participantAccountIDs); + const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, policy, participantAccountIDs, canUseTrackExpense); const additionalText = moneyRequestOptions.map((item) => translate(`reportActionsView.iouTypes.${item}`)).join(', '); const canEditPolicyDescription = ReportUtils.canEditPolicyDescription(policy); const reportName = ReportUtils.getReportName(report); @@ -158,9 +160,9 @@ function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextP ))} )} - {(moneyRequestOptions.includes(CONST.IOU.TYPE.SEND) || moneyRequestOptions.includes(CONST.IOU.TYPE.REQUEST)) && ( - {translate('reportActionsView.usePlusButton', {additionalText})} - )} + {(moneyRequestOptions.includes(CONST.IOU.TYPE.SEND) || + moneyRequestOptions.includes(CONST.IOU.TYPE.REQUEST) || + moneyRequestOptions.includes(CONST.IOU.TYPE.TRACK_EXPENSE)) && {translate('reportActionsView.usePlusButton', {additionalText})}} ); diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 421e48dfc224..015fd284c0b4 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -455,11 +455,22 @@ function BaseSelectionList( }); /** Calls confirm action when pressing CTRL (CMD) + Enter */ - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, onConfirm ?? selectFocusedOption, { - captureOnInputs: true, - shouldBubble: !flattenedSections.allOptions[focusedIndex], - isActive: !disableKeyboardShortcuts && isFocused, - }); + useKeyboardShortcut( + CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, + (e) => { + const focusedOption = flattenedSections.allOptions[focusedIndex]; + if (onConfirm) { + onConfirm(e, focusedOption); + return; + } + selectFocusedOption(); + }, + { + captureOnInputs: true, + shouldBubble: !flattenedSections.allOptions[focusedIndex], + isActive: !disableKeyboardShortcuts && isFocused, + }, + ); return ( = Partial & { confirmButtonText?: string; /** Callback to fire when the confirm button is pressed */ - onConfirm?: (e?: GestureResponderEvent | KeyboardEvent | undefined) => void; + onConfirm?: (e?: GestureResponderEvent | KeyboardEvent | undefined, option?: TItem) => void; /** Whether to show the vertical scroll indicator */ showScrollIndicator?: boolean; diff --git a/src/components/TaxPicker.tsx b/src/components/TaxPicker.tsx index dad7117bef67..61a13d271e7d 100644 --- a/src/components/TaxPicker.tsx +++ b/src/components/TaxPicker.tsx @@ -53,10 +53,10 @@ function TaxPicker({selectedTaxRate = '', taxRates, insets, onSubmit}: TaxPicker ]; }, [selectedTaxRate, getTaxName]); - const sections = useMemo(() => { - const taxRatesOptions = OptionsListUtils.getTaxRatesSection(taxRates, selectedOptions as OptionsListUtils.Category[], searchValue, selectedTaxRate); - return taxRatesOptions; - }, [taxRates, searchValue, selectedOptions, selectedTaxRate]); + const sections = useMemo( + () => OptionsListUtils.getTaxRatesSection(taxRates, selectedOptions as OptionsListUtils.Category[], searchValue, selectedTaxRate), + [taxRates, searchValue, selectedOptions, selectedTaxRate], + ); const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList(sections[0].data.length > 0, searchValue); diff --git a/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.js b/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx similarity index 68% rename from src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.js rename to src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx index c6eb1a179726..f9dd09db59f4 100644 --- a/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.js +++ b/src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx @@ -1,25 +1,27 @@ -import PropTypes from 'prop-types'; import React, {useEffect, useState} from 'react'; +import type {LayoutChangeEvent, ViewStyle} from 'react-native'; +import type {GestureStateChangeEvent, GestureUpdateEvent, PanGestureChangeEventPayload, PanGestureHandlerEventPayload} from 'react-native-gesture-handler'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import Animated, {runOnJS, useAnimatedStyle, useSharedValue} from 'react-native-reanimated'; import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext'; import useThemeStyles from '@hooks/useThemeStyles'; -const propTypes = { - duration: PropTypes.number.isRequired, +type ProgressBarProps = { + /** Total duration of a video. */ + duration: number; - position: PropTypes.number.isRequired, + /** Position of progress pointer on the bar. */ + position: number; - seekPosition: PropTypes.func.isRequired, + /** Function to seek to a specific position in the video. */ + seekPosition: (newPosition: number) => void; }; -const defaultProps = {}; - -function getProgress(currentPosition, maxPosition) { +function getProgress(currentPosition: number, maxPosition: number): number { return Math.min(Math.max((currentPosition / maxPosition) * 100, 0), 100); } -function ProgressBar({duration, position, seekPosition}) { +function ProgressBar({duration, position, seekPosition}: ProgressBarProps) { const styles = useThemeStyles(); const {pauseVideo, playVideo, checkVideoPlaying} = usePlaybackContext(); const [sliderWidth, setSliderWidth] = useState(1); @@ -27,18 +29,18 @@ function ProgressBar({duration, position, seekPosition}) { const progressWidth = useSharedValue(0); const wasVideoPlayingOnCheck = useSharedValue(false); - const onCheckVideoPlaying = (isPlaying) => { + const onCheckVideoPlaying = (isPlaying: boolean) => { wasVideoPlayingOnCheck.value = isPlaying; }; - const progressBarInteraction = (event) => { + const progressBarInteraction = (event: GestureUpdateEvent | GestureStateChangeEvent) => { const progress = getProgress(event.x, sliderWidth); progressWidth.value = progress; runOnJS(seekPosition)((progress * duration) / 100); }; - const onSliderLayout = (e) => { - setSliderWidth(e.nativeEvent.layout.width); + const onSliderLayout = (event: LayoutChangeEvent) => { + setSliderWidth(event.nativeEvent.layout.width); }; const pan = Gesture.Pan() @@ -66,7 +68,7 @@ function ProgressBar({duration, position, seekPosition}) { progressWidth.value = getProgress(position, duration); }, [duration, isSliderPressed, position, progressWidth]); - const progressBarStyle = useAnimatedStyle(() => ({width: `${progressWidth.value}%`})); + const progressBarStyle: ViewStyle = useAnimatedStyle(() => ({width: `${progressWidth.value}%`})); return ( @@ -85,8 +87,6 @@ function ProgressBar({duration, position, seekPosition}) { ); } -ProgressBar.propTypes = propTypes; -ProgressBar.defaultProps = defaultProps; ProgressBar.displayName = 'ProgressBar'; export default ProgressBar; diff --git a/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.js b/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx similarity index 81% rename from src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.js rename to src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx index 45f47eb87c36..011391ed4c71 100644 --- a/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.js +++ b/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx @@ -1,6 +1,7 @@ -import PropTypes from 'prop-types'; import React, {memo, useCallback, useState} from 'react'; +import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; +import type {GestureStateChangeEvent, GestureUpdateEvent, PanGestureChangeEventPayload, PanGestureHandlerEventPayload} from 'react-native-gesture-handler'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import Animated, {runOnJS, useAnimatedStyle, useDerivedValue} from 'react-native-reanimated'; import Hoverable from '@components/Hoverable'; @@ -10,18 +11,16 @@ import {useVolumeContext} from '@components/VideoPlayerContexts/VolumeContext'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as NumberUtils from '@libs/NumberUtils'; -import stylePropTypes from '@styles/stylePropTypes'; -const propTypes = { - style: stylePropTypes.isRequired, - small: PropTypes.bool, -}; +type VolumeButtonProps = { + /** Style for the volume button. */ + style?: StyleProp; -const defaultProps = { - small: false, + /** Is button icon small. */ + small?: boolean; }; -const getVolumeIcon = (volume) => { +const getVolumeIcon = (volume: number) => { if (volume === 0) { return Expensicons.Mute; } @@ -31,7 +30,7 @@ const getVolumeIcon = (volume) => { return Expensicons.VolumeHigh; }; -function VolumeButton({style, small}) { +function VolumeButton({style, small = false}: VolumeButtonProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {updateVolume, volume} = useVolumeContext(); @@ -39,12 +38,12 @@ function VolumeButton({style, small}) { const [volumeIcon, setVolumeIcon] = useState({icon: getVolumeIcon(volume.value)}); const [isSliderBeingUsed, setIsSliderBeingUsed] = useState(false); - const onSliderLayout = useCallback((e) => { - setSliderHeight(e.nativeEvent.layout.height); + const onSliderLayout = useCallback((event: LayoutChangeEvent) => { + setSliderHeight(event.nativeEvent.layout.height); }, []); const changeVolumeOnPan = useCallback( - (event) => { + (event: GestureStateChangeEvent | GestureUpdateEvent) => { const val = NumberUtils.roundToTwoDecimalPlaces(1 - event.y / sliderHeight); volume.value = NumberUtils.clamp(val, 0, 1); }, @@ -65,7 +64,7 @@ function VolumeButton({style, small}) { const progressBarStyle = useAnimatedStyle(() => ({height: `${volume.value * 100}%`})); - const updateIcon = useCallback((vol) => { + const updateIcon = useCallback((vol: number) => { setVolumeIcon({icon: getVolumeIcon(vol)}); }, []); @@ -98,7 +97,6 @@ function VolumeButton({style, small}) { tooltipText={volume.value === 0 ? translate('videoPlayer.unmute') : translate('videoPlayer.mute')} onPress={() => updateVolume(volume.value === 0 ? 1 : 0)} src={volumeIcon.icon} - fill={styles.white} small={small} shouldForceRenderingTooltipBelow /> @@ -108,8 +106,6 @@ function VolumeButton({style, small}) { ); } -VolumeButton.propTypes = propTypes; -VolumeButton.defaultProps = defaultProps; VolumeButton.displayName = 'VolumeButton'; export default memo(VolumeButton); diff --git a/src/components/VideoPlayer/VideoPlayerControls/index.js b/src/components/VideoPlayer/VideoPlayerControls/index.tsx similarity index 75% rename from src/components/VideoPlayer/VideoPlayerControls/index.js rename to src/components/VideoPlayer/VideoPlayerControls/index.tsx index 262613ce0797..7c61721b67b7 100644 --- a/src/components/VideoPlayer/VideoPlayerControls/index.js +++ b/src/components/VideoPlayer/VideoPlayerControls/index.tsx @@ -1,55 +1,58 @@ -import PropTypes from 'prop-types'; +import type {Video} from 'expo-av'; +import type {MutableRefObject} from 'react'; import React, {useCallback, useMemo, useState} from 'react'; +import type {GestureResponderEvent, LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import Animated from 'react-native-reanimated'; import * as Expensicons from '@components/Icon/Expensicons'; -import refPropTypes from '@components/refPropTypes'; import Text from '@components/Text'; import IconButton from '@components/VideoPlayer/IconButton'; import {convertMillisecondsToTime} from '@components/VideoPlayer/utils'; import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import stylePropTypes from '@styles/stylePropTypes'; import CONST from '@src/CONST'; import ProgressBar from './ProgressBar'; import VolumeButton from './VolumeButton'; -const propTypes = { - duration: PropTypes.number.isRequired, +type VideoPlayerControlsProps = { + /** Duration of a video. */ + duration: number; - position: PropTypes.number.isRequired, + /** Position of progress pointer. */ + position: number; - url: PropTypes.string.isRequired, + /** Url of a video. */ + url: string; - videoPlayerRef: refPropTypes.isRequired, + /** Ref for video player. */ + videoPlayerRef: MutableRefObject @@ -734,7 +740,6 @@ function ReportActionItem({ @@ -881,10 +886,6 @@ export default withOnyx({ }, initialValue: {} as OnyxTypes.Report, }, - policyReportFields: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS}${report.policyID ?? 0}`, - initialValue: {}, - }, policy: { key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID ?? 0}`, initialValue: {} as OnyxTypes.Policy, @@ -925,8 +926,7 @@ export default withOnyx({ prevProps.report?.total === nextProps.report?.total && prevProps.report?.nonReimbursableTotal === nextProps.report?.nonReimbursableTotal && prevProps.linkedReportActionID === nextProps.linkedReportActionID && - lodashIsEqual(prevProps.policyReportFields, nextProps.policyReportFields) && - lodashIsEqual(prevProps.report.reportFields, nextProps.report.reportFields) && + lodashIsEqual(prevProps.report.fieldList, nextProps.report.fieldList) && lodashIsEqual(prevProps.policy, nextProps.policy) && lodashIsEqual(prevParentReportAction, nextParentReportAction) ); diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 366b04634eb0..bcbb7a98c8c5 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -8,6 +8,7 @@ import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StylePr import type {OnyxEntry} from 'react-native-onyx'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import InvertedFlatList from '@components/InvertedFlatList'; +import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/InvertedFlatList/BaseInvertedFlatList'; import {usePersonalDetails} from '@components/OnyxProvider'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; @@ -17,6 +18,7 @@ import useReportScrollManager from '@hooks/useReportScrollManager'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import DateUtils from '@libs/DateUtils'; +import Navigation from '@libs/Navigation/Navigation'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import Visibility from '@libs/Visibility'; @@ -24,10 +26,12 @@ import type {CentralPaneNavigatorParamList} from '@navigation/types'; import variables from '@styles/variables'; import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import FloatingMessageCounter from './FloatingMessageCounter'; +import getInitialNumToRender from './getInitialNumReportActionsToRender'; import ListBoundaryLoader from './ListBoundaryLoader'; import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; @@ -65,10 +69,19 @@ type ReportActionsListProps = WithCurrentUserPersonalDetailsProps & { loadOlderChats: () => void; /** Function to load newer chats */ - loadNewerChats: LoadNewerChats; + loadNewerChats: () => void; /** Whether the composer is in full size */ isComposerFullSize?: boolean; + + /** ID of the list */ + listID: number; + + /** Callback executed on content size change */ + onContentSizeChange: (w: number, h: number) => void; + + /** Should enable auto scroll to top threshold */ + shouldEnableAutoScrollToTopThreshold?: boolean; }; const VERTICAL_OFFSET_THRESHOLD = 200; @@ -124,6 +137,9 @@ function ReportActionsList({ loadOlderChats, onLayout, isComposerFullSize, + listID, + onContentSizeChange, + shouldEnableAutoScrollToTopThreshold, }: ReportActionsListProps) { const personalDetailsList = usePersonalDetails() || CONST.EMPTY_OBJECT; const styles = useThemeStyles(); @@ -132,6 +148,7 @@ function ReportActionsList({ const {isOffline} = useNetwork(); const route = useRoute>(); const opacity = useSharedValue(0); + const reportScrollManager = useReportScrollManager(); const userActiveSince = useRef(null); const lastMessageTime = useRef(null); @@ -152,7 +169,6 @@ function ReportActionsList({ } return cacheUnreadMarkers.get(report.reportID); }; - const reportScrollManager = useReportScrollManager(); const [currentUnreadMarker, setCurrentUnreadMarker] = useState(markerInit); const scrollingVerticalOffset = useRef(0); const readActionSkipped = useRef(false); @@ -162,14 +178,21 @@ function ReportActionsList({ const lastReadTimeRef = useRef(report.lastReadTime); const sortedVisibleReportActions = useMemo( - () => sortedReportActions.filter((reportAction) => isOffline || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors), + () => + sortedReportActions.filter( + (reportAction) => + (isOffline || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors) && + ReportActionsUtils.shouldReportActionBeVisible(reportAction, reportAction.reportActionID), + ), [sortedReportActions, isOffline], ); const lastActionIndex = sortedVisibleReportActions[0]?.reportActionID; const reportActionSize = useRef(sortedVisibleReportActions.length); + const hasNewestReportAction = sortedReportActions?.[0].created === report.lastVisibleActionCreated; const previousLastIndex = useRef(lastActionIndex); + const isLastPendingActionIsDelete = sortedReportActions?.[0]?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const linkedReportActionID = route.params?.reportActionID ?? ''; // This state is used to force a re-render when the user manually marks a message as unread @@ -185,12 +208,17 @@ function ReportActionsList({ }, [opacity]); useEffect(() => { - if (previousLastIndex.current !== lastActionIndex && reportActionSize.current > sortedVisibleReportActions.length) { + if ( + scrollingVerticalOffset.current < AUTOSCROLL_TO_TOP_THRESHOLD && + previousLastIndex.current !== lastActionIndex && + reportActionSize.current > sortedVisibleReportActions.length && + hasNewestReportAction + ) { reportScrollManager.scrollToBottom(); } previousLastIndex.current = lastActionIndex; reportActionSize.current = sortedVisibleReportActions.length; - }, [lastActionIndex, sortedVisibleReportActions.length, reportScrollManager]); + }, [lastActionIndex, sortedVisibleReportActions, reportScrollManager, hasNewestReportAction, linkedReportActionID]); useEffect(() => { // If the reportID changes, we reset the userActiveSince to null, we need to do it because @@ -273,12 +301,27 @@ function ReportActionsList({ }, [report.reportID]); useEffect(() => { + if (linkedReportActionID) { + return; + } InteractionManager.runAfterInteractions(() => { reportScrollManager.scrollToBottom(); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const scrollToBottomForCurrentUserAction = useCallback( + (isFromCurrentUser: boolean) => { + // If a new comment is added and it's from the current user scroll to the bottom otherwise leave the user positioned where + // they are now in the list. + if (!isFromCurrentUser || !hasNewestReportAction) { + return; + } + InteractionManager.runAfterInteractions(() => reportScrollManager.scrollToBottom()); + }, + [hasNewestReportAction, reportScrollManager], + ); + useEffect(() => { // Why are we doing this, when in the cleanup of the useEffect we are already calling the unsubscribe function? // Answer: On web, when navigating to another report screen, the previous report screen doesn't get unmounted, @@ -294,14 +337,7 @@ function ReportActionsList({ // This callback is triggered when a new action arrives via Pusher and the event is emitted from Report.js. This allows us to maintain // a single source of truth for the "new action" event instead of trying to derive that a new action has appeared from looking at props. - const unsubscribe = Report.subscribeToNewActionEvent(report.reportID, (isFromCurrentUser) => { - // If a new comment is added and it's from the current user scroll to the bottom otherwise leave the user positioned where - // they are now in the list. - if (!isFromCurrentUser) { - return; - } - InteractionManager.runAfterInteractions(() => reportScrollManager.scrollToBottom()); - }); + const unsubscribe = Report.subscribeToNewActionEvent(report.reportID, scrollToBottomForCurrentUserAction); const cleanup = () => { if (unsubscribe) { @@ -343,6 +379,11 @@ function ReportActionsList({ }; const scrollToBottomAndMarkReportAsRead = () => { + if (!hasNewestReportAction) { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report.reportID)); + Report.openReport(report.reportID); + return; + } reportScrollManager.scrollToBottom(); readActionSkipped.current = false; Report.readNewestAction(report.reportID); @@ -355,9 +396,12 @@ function ReportActionsList({ const initialNumToRender = useMemo((): number | undefined => { const minimumReportActionHeight = styles.chatItem.paddingTop + styles.chatItem.paddingBottom + variables.fontSizeNormalHeight; const availableHeight = windowHeight - (CONST.CHAT_FOOTER_MIN_HEIGHT + variables.contentHeaderHeight); - const itemsToRender = Math.ceil(availableHeight / minimumReportActionHeight); - return itemsToRender > 0 ? itemsToRender : undefined; - }, [styles.chatItem.paddingBottom, styles.chatItem.paddingTop, windowHeight]); + const numToRender = Math.ceil(availableHeight / minimumReportActionHeight); + if (linkedReportActionID) { + return getInitialNumToRender(numToRender); + } + return numToRender || undefined; + }, [styles.chatItem.paddingBottom, styles.chatItem.paddingTop, windowHeight, linkedReportActionID]); /** * Thread's divider line should hide when the first chat in the thread is marked as unread. @@ -488,10 +532,11 @@ function ReportActionsList({ const extraData = useMemo(() => [isSmallScreenWidth ? currentUnreadMarker : undefined, ReportUtils.isArchivedRoom(report)], [currentUnreadMarker, isSmallScreenWidth, report]); const hideComposer = !ReportUtils.canUserPerformWriteAction(report); const shouldShowReportRecipientLocalTime = ReportUtils.canShowReportRecipientLocalTime(personalDetailsList, report, currentUserPersonalDetails.accountID) && !isComposerFullSize; + const canShowHeader = !isOffline && !hasHeaderRendered.current && scrollingVerticalOffset.current > VERTICAL_OFFSET_THRESHOLD; const contentContainerStyle: StyleProp = useMemo( - () => [styles.chatContentScrollView, isLoadingNewerReportActions ? styles.chatContentScrollViewWithHeaderLoader : {}], - [isLoadingNewerReportActions, styles.chatContentScrollView, styles.chatContentScrollViewWithHeaderLoader], + () => [styles.chatContentScrollView, isLoadingNewerReportActions && canShowHeader ? styles.chatContentScrollViewWithHeaderLoader : {}], + [isLoadingNewerReportActions, styles.chatContentScrollView, styles.chatContentScrollViewWithHeaderLoader, canShowHeader], ); const lastReportAction: OnyxTypes.ReportAction | EmptyObject = useMemo(() => sortedReportActions.at(-1) ?? {}, [sortedReportActions]); @@ -521,9 +566,15 @@ function ReportActionsList({ }, [onLayout], ); + const onContentSizeChangeInner = useCallback( + (w: number, h: number) => { + onContentSizeChange(w, h); + }, + [onContentSizeChange], + ); const listHeaderComponent = useCallback(() => { - if (!isOffline && !hasHeaderRendered.current) { + if (!canShowHeader) { hasHeaderRendered.current = true; return null; } @@ -534,12 +585,15 @@ function ReportActionsList({ isLoadingNewerReportActions={isLoadingNewerReportActions} /> ); - }, [isLoadingNewerReportActions, isOffline]); + }, [isLoadingNewerReportActions, canShowHeader]); + // When performing comment linking, initially 25 items are added to the list. Subsequent fetches add 15 items from the cache or 50 items from the server. + // This is to ensure that the user is able to see the 'scroll to newer comments' button when they do comment linking and have not reached the end of the list yet. + const canScrollToNewerComments = !isLoadingInitialReportActions && !hasNewestReportAction && sortedReportActions.length > 25 && !isLastPendingActionIsDelete; return ( <> @@ -548,7 +602,7 @@ function ReportActionsList({ ref={reportScrollManager.ref} testID="report-actions-list" style={styles.overscrollBehaviorContain} - data={sortedReportActions} + data={sortedVisibleReportActions} renderItem={renderItem} contentContainerStyle={contentContainerStyle} keyExtractor={keyExtractor} @@ -561,9 +615,12 @@ function ReportActionsList({ ListHeaderComponent={listHeaderComponent} keyboardShouldPersistTaps="handled" onLayout={onLayoutInner} + onContentSizeChange={onContentSizeChangeInner} onScroll={trackVerticalScrolling} onScrollToIndexFailed={onScrollToIndexFailed} extraData={extraData} + key={listID} + shouldEnableAutoScrollToTopThreshold={shouldEnableAutoScrollToTopThreshold} /> @@ -574,4 +631,4 @@ ReportActionsList.displayName = 'ReportActionsList'; export default withCurrentUserPersonalDetails(memo(ReportActionsList)); -export type {LoadNewerChats}; +export type {LoadNewerChats, ReportActionsListProps}; diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index 196f3f0671c2..c74bb40a18b6 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -1,7 +1,7 @@ -import {useIsFocused} from '@react-navigation/native'; +import type {RouteProp} from '@react-navigation/native'; +import {useIsFocused, useRoute} from '@react-navigation/native'; import lodashIsEqual from 'lodash/isEqual'; -import lodashThrottle from 'lodash/throttle'; -import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react'; +import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {InteractionManager} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -11,6 +11,8 @@ import useNetwork from '@hooks/useNetwork'; import usePrevious from '@hooks/usePrevious'; import useWindowDimensions from '@hooks/useWindowDimensions'; import getIsReportFullyVisible from '@libs/getIsReportFullyVisible'; +import type {CentralPaneNavigatorParamList} from '@libs/Navigation/types'; +import {generateNewRandomInt} from '@libs/NumberUtils'; import Performance from '@libs/Performance'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import {isUserCreatedPolicyRoom} from '@libs/ReportUtils'; @@ -21,10 +23,11 @@ import * as Report from '@userActions/Report'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; +import getInitialPaginationSize from './getInitialPaginationSize'; import PopoverReactionList from './ReactionList/PopoverReactionList'; import ReportActionsList from './ReportActionsList'; -import type {LoadNewerChats} from './ReportActionsList'; type ReportActionsViewOnyxProps = { /** Session info for the currently logged in user. */ @@ -49,34 +52,63 @@ type ReportActionsViewProps = ReportActionsViewOnyxProps & { /** The report actions are loading newer data */ isLoadingNewerReportActions?: boolean; + + /** Whether the report is ready for comment linking */ + isReadyForCommentLinking?: boolean; }; +const DIFF_BETWEEN_SCREEN_HEIGHT_AND_LIST = 120; +const SPACER = 16; + +let listOldID = Math.round(Math.random() * 100); + function ReportActionsView({ report, session, parentReportAction, - reportActions = [], + reportActions: allReportActions = [], isLoadingInitialReportActions = false, isLoadingOlderReportActions = false, isLoadingNewerReportActions = false, + isReadyForCommentLinking = false, }: ReportActionsViewProps) { useCopySelectionHelper(); const reactionListRef = useContext(ReactionListContext); + const route = useRoute>(); + const reportActionID = route?.params?.reportActionID; const didLayout = useRef(false); const didSubscribeToReportTypingEvents = useRef(false); - const isFirstRender = useRef(true); - const hasCachedActions = useInitialValue(() => reportActions.length > 0); - const mostRecentIOUReportActionID = useMemo(() => ReportActionsUtils.getMostRecentIOURequestActionID(reportActions), [reportActions]); + + // triggerListID is used when navigating to a chat with messages loaded from LHN. Typically, these include thread actions, task actions, etc. Since these messages aren't the latest,we don't maintain their position and instead trigger a recalculation of their positioning in the list. + // we don't set currentReportActionID on initial render as linkedID as it should trigger visibleReportActions after linked message was positioned + const [currentReportActionID, setCurrentReportActionID] = useState(''); + const isFirstLinkedActionRender = useRef(true); + const network = useNetwork(); - const {isSmallScreenWidth} = useWindowDimensions(); + const {isSmallScreenWidth, windowHeight} = useWindowDimensions(); + const contentListHeight = useRef(0); + const isFocused = useIsFocused(); const prevNetworkRef = useRef(network); const prevAuthTokenType = usePrevious(session?.authTokenType); - + const [isNavigatingToLinkedMessage, setNavigatingToLinkedMessage] = useState(!!reportActionID); const prevIsSmallScreenWidthRef = useRef(isSmallScreenWidth); - - const isFocused = useIsFocused(); const reportID = report.reportID; - const hasNewestReportAction = reportActions[0]?.isNewestReportAction; + const isLoading = (!!reportActionID && isLoadingInitialReportActions) || !isReadyForCommentLinking; + + /** + * Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently + * displaying. + */ + const fetchNewerAction = useCallback( + (newestReportAction: OnyxTypes.ReportAction) => { + if (isLoadingNewerReportActions || isLoadingInitialReportActions) { + return; + } + + Report.getNewerActions(reportID, newestReportAction.reportActionID); + }, + [isLoadingNewerReportActions, isLoadingInitialReportActions, reportID], + ); const isReportFullyVisible = useMemo((): boolean => getIsReportFullyVisible(isFocused), [isFocused]); @@ -85,7 +117,7 @@ function ReportActionsView({ return; } - Report.openReport(reportID); + Report.openReport(reportID, reportActionID); }; const reconnectReportIfNecessary = () => { @@ -96,7 +128,72 @@ function ReportActionsView({ Report.reconnect(reportID); }; + useLayoutEffect(() => { + setCurrentReportActionID(''); + }, [route]); + + const listID = useMemo(() => { + isFirstLinkedActionRender.current = true; + const newID = generateNewRandomInt(listOldID, 1, Number.MAX_SAFE_INTEGER); + listOldID = newID; + return newID; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [route, isLoadingInitialReportActions]); + + const indexOfLinkedAction = useMemo(() => { + if (!reportActionID || isLoading) { + return -1; + } + + return allReportActions.findIndex((obj) => String(obj.reportActionID) === String(isFirstLinkedActionRender.current ? reportActionID : currentReportActionID)); + }, [allReportActions, currentReportActionID, reportActionID, isLoading]); + + const reportActions = useMemo(() => { + if (!reportActionID) { + return allReportActions; + } + if (isLoading || indexOfLinkedAction === -1) { + return []; + } + + if (isFirstLinkedActionRender.current) { + return allReportActions.slice(indexOfLinkedAction); + } + const paginationSize = getInitialPaginationSize; + return allReportActions.slice(Math.max(indexOfLinkedAction - paginationSize, 0)); + // currentReportActionID is needed to trigger batching once the report action has been positioned + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reportActionID, allReportActions, indexOfLinkedAction, isLoading, currentReportActionID]); + + const hasMoreCached = reportActions.length < allReportActions.length; + const newestReportAction = useMemo(() => reportActions?.[0], [reportActions]); + const handleReportActionPagination = useCallback( + ({firstReportActionID}: {firstReportActionID: string}) => { + // This function is a placeholder as the actual pagination is handled by visibleReportActions + if (!hasMoreCached) { + isFirstLinkedActionRender.current = false; + fetchNewerAction(newestReportAction); + } + if (isFirstLinkedActionRender.current) { + isFirstLinkedActionRender.current = false; + } + setCurrentReportActionID(firstReportActionID); + }, + [fetchNewerAction, hasMoreCached, newestReportAction], + ); + + const mostRecentIOUReportActionID = useMemo(() => ReportActionsUtils.getMostRecentIOURequestActionID(reportActions), [reportActions]); + const hasCachedActionOnFirstRender = useInitialValue(() => reportActions.length > 0); + const hasNewestReportAction = reportActions[0]?.created === report.lastVisibleActionCreated; + + const oldestReportAction = useMemo(() => reportActions?.at(-1), [reportActions]); + const hasCreatedAction = oldestReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED; + useEffect(() => { + if (reportActionID) { + return; + } + const interactionTask = InteractionManager.runAfterInteractions(() => { openReportIfNecessary(); }); @@ -106,9 +203,22 @@ function ReportActionsView({ interactionTask.cancel(); }; } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (!reportActionID) { + return; + } + + // This function is triggered when a user clicks on a link to navigate to a report. + // For each link click, we retrieve the report data again, even though it may already be cached. + // There should be only one openReport execution per page start or navigating + Report.openReport(reportID, reportActionID); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [route]); + useEffect(() => { const prevNetwork = prevNetworkRef.current; // When returning from offline to online state we want to trigger a request to OpenReport which @@ -170,7 +280,11 @@ function ReportActionsView({ } }, [report.pendingFields, didSubscribeToReportTypingEvents, reportID]); - const oldestReportAction = useMemo(() => reportActions?.at(-1), [reportActions]); + const onContentSizeChange = useCallback((w: number, h: number) => { + contentListHeight.current = h; + }, []); + + const checkIfContentSmallerThanList = useCallback(() => windowHeight - DIFF_BETWEEN_SCREEN_HEIGHT_AND_LIST - SPACER > contentListHeight.current, [windowHeight]); /** * Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently @@ -178,48 +292,44 @@ function ReportActionsView({ */ const loadOlderChats = useCallback(() => { // Only fetch more if we are neither already fetching (so that we don't initiate duplicate requests) nor offline. - if (!!network.isOffline || isLoadingOlderReportActions) { + if (!!network.isOffline || isLoadingOlderReportActions || isLoadingInitialReportActions) { return; } // Don't load more chats if we're already at the beginning of the chat history - if (!oldestReportAction || oldestReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { + if (!oldestReportAction || hasCreatedAction) { return; } // Retrieve the next REPORT.ACTIONS.LIMIT sized page of comments - Report.getOlderActions(reportID); - }, [isLoadingOlderReportActions, network.isOffline, oldestReportAction, reportID]); + Report.getOlderActions(reportID, oldestReportAction.reportActionID); + }, [network.isOffline, isLoadingOlderReportActions, isLoadingInitialReportActions, oldestReportAction, hasCreatedAction, reportID]); - /** - * Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently - * displaying. - */ - const loadNewerChats: LoadNewerChats = useMemo( - () => - lodashThrottle(({distanceFromStart}) => { - if (isLoadingNewerReportActions || isLoadingInitialReportActions || hasNewestReportAction) { - return; - } - - // Ideally, we wouldn't need to use the 'distanceFromStart' variable. However, due to the low value set for 'maxToRenderPerBatch', - // the component undergoes frequent re-renders. This frequent re-rendering triggers the 'onStartReached' callback multiple times. - // - // To mitigate this issue, we use 'CONST.CHAT_HEADER_LOADER_HEIGHT' as a threshold. This ensures that 'onStartReached' is not - // triggered unnecessarily when the chat is initially opened or when the user has reached the end of the list but hasn't scrolled further. - // - // Additionally, we use throttling on the 'onStartReached' callback to further reduce the frequency of its invocation. - // This should be removed once the issue of frequent re-renders is resolved. - // - // onStartReached is triggered during the first render. Since we use OpenReport on the first render and are confident about the message ordering, we can safely skip this call - if (isFirstRender.current || distanceFromStart <= CONST.CHAT_HEADER_LOADER_HEIGHT) { - isFirstRender.current = false; - return; - } - - Report.getNewerActions(reportID); - }, 500), - [isLoadingNewerReportActions, isLoadingInitialReportActions, reportID, hasNewestReportAction], - ); + const loadNewerChats = useCallback(() => { + if (isLoadingInitialReportActions || isLoadingOlderReportActions || network.isOffline || newestReportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return; + } + // Determines if loading older reports is necessary when the content is smaller than the list + // and there are fewer than 23 items, indicating we've reached the oldest message. + const isLoadingOlderReportsFirstNeeded = checkIfContentSmallerThanList() && reportActions.length > 23; + + if ( + (reportActionID && indexOfLinkedAction > -1 && !hasNewestReportAction && !isLoadingOlderReportsFirstNeeded) || + (!reportActionID && !hasNewestReportAction && !isLoadingOlderReportsFirstNeeded) + ) { + handleReportActionPagination({firstReportActionID: newestReportAction?.reportActionID}); + } + }, [ + isLoadingInitialReportActions, + isLoadingOlderReportActions, + checkIfContentSmallerThanList, + reportActionID, + indexOfLinkedAction, + hasNewestReportAction, + handleReportActionPagination, + network.isOffline, + reportActions.length, + newestReportAction, + ]); /** * Runs when the FlatList finishes laying out @@ -230,7 +340,7 @@ function ReportActionsView({ } didLayout.current = true; - Timing.end(CONST.TIMING.SWITCH_REPORT, hasCachedActions ? CONST.TIMING.WARM : CONST.TIMING.COLD); + Timing.end(CONST.TIMING.SWITCH_REPORT, hasCachedActionOnFirstRender ? CONST.TIMING.WARM : CONST.TIMING.COLD); // Capture the init measurement only once not per each chat switch as the value gets overwritten if (!ReportActionsView.initMeasured) { @@ -239,12 +349,68 @@ function ReportActionsView({ } else { Performance.markEnd(CONST.TIMING.SWITCH_REPORT); } - }, [hasCachedActions]); + }, [hasCachedActionOnFirstRender]); + + useEffect(() => { + // Temporary solution for handling REPORTPREVIEW. More details: https://expensify.slack.com/archives/C035J5C9FAP/p1705417778466539?thread_ts=1705035404.136629&cid=C035J5C9FAP + // This code should be removed once REPORTPREVIEW is no longer repositioned. + // We need to call openReport for gaps created by moving REPORTPREVIEW, which causes mismatches in previousReportActionID and reportActionID of adjacent reportActions. The server returns the correct sequence, allowing us to overwrite incorrect data with the correct one. + const shouldOpenReport = + newestReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && + !hasCreatedAction && + isReadyForCommentLinking && + reportActions.length < 24 && + reportActions.length >= 1 && + !isLoadingInitialReportActions && + !isLoadingOlderReportActions && + !isLoadingNewerReportActions; + + if (shouldOpenReport) { + Report.openReport(reportID, reportActionID); + } + }, [ + hasCreatedAction, + reportID, + reportActions, + reportActionID, + newestReportAction?.actionName, + isReadyForCommentLinking, + isLoadingOlderReportActions, + isLoadingNewerReportActions, + isLoadingInitialReportActions, + ]); + + // Check if the first report action in the list is the one we're currently linked to + const isTheFirstReportActionIsLinked = newestReportAction?.reportActionID === reportActionID; + + useEffect(() => { + let timerID: NodeJS.Timeout; + + if (isTheFirstReportActionIsLinked) { + setNavigatingToLinkedMessage(true); + } else { + // After navigating to the linked reportAction, apply this to correctly set + // `autoscrollToTopThreshold` prop when linking to a specific reportAction. + InteractionManager.runAfterInteractions(() => { + // Using a short delay to ensure the view is updated after interactions + timerID = setTimeout(() => setNavigatingToLinkedMessage(false), 10); + }); + } + + return () => { + if (!timerID) { + return; + } + clearTimeout(timerID); + }; + }, [isTheFirstReportActionIsLinked]); // Comments have not loaded at all yet do nothing if (!reportActions.length) { return null; } + // AutoScroll is disabled when we do linking to a specific reportAction + const shouldEnableAutoScroll = hasNewestReportAction && (!reportActionID || !isNavigatingToLinkedMessage); return ( <> @@ -259,6 +425,9 @@ function ReportActionsView({ isLoadingInitialReportActions={isLoadingInitialReportActions} isLoadingOlderReportActions={isLoadingOlderReportActions} isLoadingNewerReportActions={isLoadingNewerReportActions} + listID={listID} + onContentSizeChange={onContentSizeChange} + shouldEnableAutoScrollToTopThreshold={shouldEnableAutoScroll} /> @@ -269,6 +438,9 @@ ReportActionsView.displayName = 'ReportActionsView'; ReportActionsView.initMeasured = false; function arePropsEqual(oldProps: ReportActionsViewProps, newProps: ReportActionsViewProps): boolean { + if (!lodashIsEqual(oldProps.isReadyForCommentLinking, newProps.isReadyForCommentLinking)) { + return false; + } if (!lodashIsEqual(oldProps.reportActions, newProps.reportActions)) { return false; } diff --git a/src/pages/home/report/getInitialNumReportActionsToRender/index.native.ts b/src/pages/home/report/getInitialNumReportActionsToRender/index.native.ts new file mode 100644 index 000000000000..4d0986216e59 --- /dev/null +++ b/src/pages/home/report/getInitialNumReportActionsToRender/index.native.ts @@ -0,0 +1,4 @@ +function getInitialNumToRender(numToRender: number): number { + return numToRender; +} +export default getInitialNumToRender; diff --git a/src/pages/home/report/getInitialNumReportActionsToRender/index.ts b/src/pages/home/report/getInitialNumReportActionsToRender/index.ts new file mode 100644 index 000000000000..cb1f0dfdcded --- /dev/null +++ b/src/pages/home/report/getInitialNumReportActionsToRender/index.ts @@ -0,0 +1,7 @@ +const DEFAULT_NUM_TO_RENDER = 50; + +function getInitialNumToRender(numToRender: number): number { + // For web and desktop environments, it's crucial to set this value equal to or higher than the maxToRenderPerBatch setting. If it's set lower, the 'onStartReached' event will be triggered excessively, every time an additional item enters the virtualized list. + return Math.max(numToRender, DEFAULT_NUM_TO_RENDER); +} +export default getInitialNumToRender; diff --git a/src/pages/home/report/getInitialPaginationSize/index.native.ts b/src/pages/home/report/getInitialPaginationSize/index.native.ts new file mode 100644 index 000000000000..195448f7e450 --- /dev/null +++ b/src/pages/home/report/getInitialPaginationSize/index.native.ts @@ -0,0 +1,3 @@ +import CONST from '@src/CONST'; + +export default CONST.MOBILE_PAGINATION_SIZE; diff --git a/src/pages/home/report/getInitialPaginationSize/index.ts b/src/pages/home/report/getInitialPaginationSize/index.ts new file mode 100644 index 000000000000..87ec6856aa20 --- /dev/null +++ b/src/pages/home/report/getInitialPaginationSize/index.ts @@ -0,0 +1,3 @@ +import CONST from '@src/CONST'; + +export default CONST.WEB_PAGINATION_SIZE; diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js index ec27112ab4b7..abf932eff96d 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js @@ -9,6 +9,7 @@ import withNavigation from '@components/withNavigation'; import withNavigationFocus from '@components/withNavigationFocus'; import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; @@ -75,6 +76,7 @@ function FloatingActionButtonAndPopover(props) { const {translate} = useLocalize(); const [isCreateMenuActive, setIsCreateMenuActive] = useState(false); const fabRef = useRef(null); + const {canUseTrackExpense} = usePermissions(); const prevIsFocused = usePrevious(props.isFocused); @@ -187,13 +189,28 @@ function FloatingActionButtonAndPopover(props) { ), ), }, - ...[ - { - icon: Expensicons.Task, - text: translate('newTaskPage.assignTask'), - onSelected: () => interceptAnonymousUser(() => Task.clearOutTaskInfoAndNavigate()), - }, - ], + ...(canUseTrackExpense + ? [ + { + icon: Expensicons.DocumentPlus, + text: translate('iou.trackExpense'), + onSelected: () => + interceptAnonymousUser(() => + IOU.startMoneyRequest( + CONST.IOU.TYPE.TRACK_EXPENSE, + // When starting to create a track expense from the global FAB, we need to retrieve selfDM reportID. + // If it doesn't exist, we generate a random optimistic reportID and use it for all of the routes in the creation flow. + ReportUtils.findSelfDMReportID() || ReportUtils.generateReportID(), + ), + ), + }, + ] + : []), + { + icon: Expensicons.Task, + text: translate('newTaskPage.assignTask'), + onSelected: () => interceptAnonymousUser(() => Task.clearOutTaskInfoAndNavigate()), + }, { icon: Expensicons.Heart, text: translate('sidebarScreen.saveTheWorld'), diff --git a/src/pages/iou/request/IOURequestStartPage.js b/src/pages/iou/request/IOURequestStartPage.js index 589808824285..cb078fac133c 100644 --- a/src/pages/iou/request/IOURequestStartPage.js +++ b/src/pages/iou/request/IOURequestStartPage.js @@ -78,6 +78,7 @@ function IOURequestStartPage({ [CONST.IOU.TYPE.REQUEST]: translate('iou.requestMoney'), [CONST.IOU.TYPE.SEND]: translate('iou.sendMoney'), [CONST.IOU.TYPE.SPLIT]: translate('iou.splitBill'), + [CONST.IOU.TYPE.TRACK_EXPENSE]: translate('iou.trackExpense'), }; const transactionRequestType = useRef(TransactionUtils.getRequestType(transaction)); const previousIOURequestType = usePrevious(transactionRequestType.current); @@ -109,7 +110,7 @@ function IOURequestStartPage({ const isExpenseChat = ReportUtils.isPolicyExpenseChat(report); const isExpenseReport = ReportUtils.isExpenseReport(report); - const shouldDisplayDistanceRequest = canUseP2PDistanceRequests || isExpenseChat || isExpenseReport || isFromGlobalCreate; + const shouldDisplayDistanceRequest = iouType !== CONST.IOU.TYPE.TRACK_EXPENSE && (canUseP2PDistanceRequests || isExpenseChat || isExpenseReport || isFromGlobalCreate); // Allow the user to create the request if we are creating the request in global menu or the report can create the request const isAllowedToCreateRequest = _.isEmpty(report.reportID) || ReportUtils.canCreateRequest(report, policy, iouType); @@ -157,7 +158,7 @@ function IOURequestStartPage({ title={tabTitles[iouType]} onBackButtonPress={navigateBack} /> - {iouType === CONST.IOU.TYPE.REQUEST || iouType === CONST.IOU.TYPE.SPLIT ? ( + {iouType !== CONST.IOU.TYPE.SEND ? ( { - onParticipantsAdded([ - { - ..._.pick(option, 'accountID', 'login', 'isPolicyExpenseChat', 'reportID', 'searchText'), - selected: true, - }, - ]); - onFinish(); - }; + const addSingleParticipant = useCallback( + (option) => { + onParticipantsAdded([ + { + ..._.pick(option, 'accountID', 'login', 'isPolicyExpenseChat', 'reportID', 'searchText'), + selected: true, + }, + ]); + onFinish(); + }, + [onFinish, onParticipantsAdded], + ); /** * Removes a selected option from list if already selected. If not already selected add this option to the list. @@ -257,13 +260,22 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({ const shouldShowSplitBillErrorMessage = participants.length > 1 && hasPolicyExpenseChatParticipant; const isAllowedToSplit = (canUseP2PDistanceRequests || iouRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE) && iouType !== CONST.IOU.TYPE.SEND; - const handleConfirmSelection = useCallback(() => { - if (shouldShowSplitBillErrorMessage) { - return; - } + const handleConfirmSelection = useCallback( + (keyEvent, option) => { + const shouldAddSingleParticipant = option && !participants.length; + if (shouldShowSplitBillErrorMessage || (!participants.length && !option)) { + return; + } - onFinish(CONST.IOU.TYPE.SPLIT); - }, [shouldShowSplitBillErrorMessage, onFinish]); + if (shouldAddSingleParticipant) { + addSingleParticipant(option); + return; + } + + onFinish(CONST.IOU.TYPE.SPLIT); + }, + [shouldShowSplitBillErrorMessage, onFinish, addSingleParticipant, participants], + ); const footerContent = useMemo( () => ( diff --git a/src/pages/iou/request/step/IOURequestStepCategory.js b/src/pages/iou/request/step/IOURequestStepCategory.js index 38f3f8803c53..4f0c77480c04 100644 --- a/src/pages/iou/request/step/IOURequestStepCategory.js +++ b/src/pages/iou/request/step/IOURequestStepCategory.js @@ -15,6 +15,8 @@ import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; import reportPropTypes from '@pages/reportPropTypes'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; @@ -47,6 +49,18 @@ const propTypes = { /** Collection of tags attached to a policy */ policyTags: tagPropTypes, + + /** The actions from the parent report */ + reportActions: PropTypes.shape(reportActionPropTypes), + + /** Session info for the currently logged in user. */ + session: PropTypes.shape({ + /** Currently logged in user accountID */ + accountID: PropTypes.number, + + /** Currently logged in user email */ + email: PropTypes.string, + }).isRequired, }; const defaultProps = { @@ -56,18 +70,21 @@ const defaultProps = { policy: null, policyTags: null, policyCategories: null, + reportActions: {}, }; function IOURequestStepCategory({ report, route: { - params: {transactionID, backTo, action, iouType}, + params: {transactionID, backTo, action, iouType, reportActionID}, }, transaction, splitDraftTransaction, policy, policyTags, policyCategories, + session, + reportActions, }) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -75,9 +92,12 @@ function IOURequestStepCategory({ const isEditingSplitBill = isEditing && iouType === CONST.IOU.TYPE.SPLIT; const {category: transactionCategory} = ReportUtils.getTransactionDetails(isEditingSplitBill && !lodashIsEmpty(splitDraftTransaction) ? splitDraftTransaction : transaction); - const isPolicyExpenseChat = ReportUtils.isGroupPolicy(report); + const reportAction = reportActions[report.parentReportActionID || reportActionID]; + const shouldShowCategory = ReportUtils.isGroupPolicy(report) && (transactionCategory || OptionsListUtils.hasEnabledOptions(_.values(policyCategories))); + const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT; + const canEditSplitBill = isSplitBill && reportAction && session.accountID === reportAction.actorAccountID && TransactionUtils.areRequiredFieldsEmpty(transaction); // eslint-disable-next-line rulesdir/no-negated-variables - const shouldShowNotFoundPage = !isPolicyExpenseChat || (!transactionCategory && !OptionsListUtils.hasEnabledOptions(_.values(policyCategories))); + const shouldShowNotFoundPage = !shouldShowCategory || (isEditing && (isSplitBill ? !canEditSplitBill : !ReportUtils.canEditMoneyRequest(reportAction))); const navigateBack = () => { Navigation.goBack(backTo); @@ -149,5 +169,23 @@ export default compose( policyTags: { key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`, }, + reportActions: { + key: ({ + report, + route: { + params: {action, iouType}, + }, + }) => { + let reportID = '0'; + if (action === CONST.IOU.ACTION.EDIT) { + reportID = iouType === CONST.IOU.TYPE.SPLIT ? report.reportID : report.parentReportID; + } + return `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`; + }, + canEvict: false, + }, + session: { + key: ONYXKEYS.SESSION, + }, }), )(IOURequestStepCategory); diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.js b/src/pages/iou/request/step/IOURequestStepConfirmation.js index 2c869354d96f..3dd6f08c0ce0 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.js +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.js @@ -100,6 +100,9 @@ function IOURequestStepConfirmation({ if (iouType === CONST.IOU.TYPE.SPLIT) { return translate('iou.split'); } + if (iouType === CONST.IOU.TYPE.TRACK_EXPENSE) { + return translate('iou.trackExpense'); + } if (iouType === CONST.IOU.TYPE.SEND) { return translate('common.send'); } @@ -109,8 +112,8 @@ function IOURequestStepConfirmation({ const participants = useMemo( () => _.map(transaction.participants, (participant) => { - const isPolicyExpenseChat = lodashGet(participant, 'isPolicyExpenseChat', false); - return isPolicyExpenseChat ? OptionsListUtils.getPolicyExpenseReportOption(participant) : OptionsListUtils.getParticipantsOption(participant, personalDetails); + const participantReportID = lodashGet(participant, 'reportID', ''); + return participantReportID ? OptionsListUtils.getReportOption(participant) : OptionsListUtils.getParticipantsOption(participant, personalDetails); }), [transaction.participants, personalDetails], ); @@ -131,7 +134,7 @@ function IOURequestStepConfirmation({ if (policyExpenseChat) { Policy.openDraftWorkspaceRequest(policyExpenseChat.policyID); } - }, [participants, transaction.billable, policy, transactionID]); + }, [isOffline, participants, transaction.billable, policy, transactionID]); const defaultBillable = lodashGet(policy, 'defaultBillable', false); useEffect(() => { @@ -187,13 +190,6 @@ function IOURequestStepConfirmation({ IOU.navigateToStartStepIfScanFileCannotBeRead(receiptFilename, receiptPath, onSuccess, requestType, iouType, transactionID, reportID, receiptType); }, [receiptType, receiptPath, receiptFilename, requestType, iouType, transactionID, reportID]); - useEffect(() => { - const policyExpenseChat = _.find(participants, (participant) => participant.isPolicyExpenseChat); - if (policyExpenseChat) { - Policy.openDraftWorkspaceRequest(policyExpenseChat.policyID); - } - }, [isOffline, participants, transaction.billable, policy]); - /** * @param {Array} selectedParticipants * @param {String} trimmedComment @@ -226,6 +222,54 @@ function IOURequestStepConfirmation({ [report, transaction, transactionTaxCode, transactionTaxAmount, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, policy, policyTags, policyCategories], ); + /** + * @param {Array} selectedParticipants + * @param {String} trimmedComment + * @param {File} [receiptObj] + */ + const trackExpense = useCallback( + (selectedParticipants, trimmedComment, receiptObj, gpsPoints) => { + IOU.trackExpense( + report, + transaction.amount, + transaction.currency, + transaction.created, + transaction.merchant, + currentUserPersonalDetails.login, + currentUserPersonalDetails.accountID, + selectedParticipants[0], + trimmedComment, + receiptObj, + transaction.category, + transaction.tag, + transactionTaxCode, + transactionTaxAmount, + transaction.billable, + policy, + policyTags, + policyCategories, + gpsPoints, + ); + }, + [ + report, + transaction.amount, + transaction.currency, + transaction.created, + transaction.merchant, + transaction.category, + transaction.tag, + transaction.billable, + currentUserPersonalDetails.login, + currentUserPersonalDetails.accountID, + transactionTaxCode, + transactionTaxAmount, + policy, + policyTags, + policyCategories, + ], + ); + /** * @param {Array} selectedParticipants * @param {String} trimmedComment @@ -319,6 +363,41 @@ function IOURequestStepConfirmation({ return; } + if (iouType === CONST.IOU.TYPE.TRACK_EXPENSE) { + if (receiptFile) { + // If the transaction amount is zero, then the money is being requested through the "Scan" flow and the GPS coordinates need to be included. + if (transaction.amount === 0) { + getCurrentPosition( + (successData) => { + trackExpense(selectedParticipants, trimmedComment, receiptFile, { + lat: successData.coords.latitude, + long: successData.coords.longitude, + }); + }, + (errorData) => { + Log.info('[IOURequestStepConfirmation] getCurrentPosition failed', false, errorData); + // When there is an error, the money can still be requested, it just won't include the GPS coordinates + trackExpense(selectedParticipants, trimmedComment, receiptFile); + }, + { + // It's OK to get a cached location that is up to an hour old because the only accuracy needed is the country the user is in + maximumAge: 1000 * 60 * 60, + + // 15 seconds, don't wait too long because the server can always fall back to using the IP address + timeout: 15000, + }, + ); + return; + } + + // Otherwise, the money is being requested through the "Manual" flow with an attached image and the GPS coordinates are not needed. + trackExpense(selectedParticipants, trimmedComment, receiptFile); + return; + } + trackExpense(selectedParticipants, trimmedComment, receiptFile); + return; + } + if (receiptFile) { // If the transaction amount is zero, then the money is being requested through the "Scan" flow and the GPS coordinates need to be included. if (transaction.amount === 0) { @@ -357,7 +436,18 @@ function IOURequestStepConfirmation({ requestMoney(selectedParticipants, trimmedComment); }, - [iouType, transaction, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, report, requestType, createDistanceRequest, requestMoney, receiptFile], + [ + transaction, + iouType, + receiptFile, + requestType, + requestMoney, + currentUserPersonalDetails.login, + currentUserPersonalDetails.accountID, + report.reportID, + trackExpense, + createDistanceRequest, + ], ); /** @@ -417,7 +507,7 @@ function IOURequestStepConfirmation({ `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`, }, + reportActions: { + key: ({ + report, + route: { + params: {action, iouType}, + }, + }) => { + let reportID = '0'; + if (action === CONST.IOU.ACTION.EDIT) { + reportID = iouType === CONST.IOU.TYPE.SPLIT ? report.reportID : report.parentReportID; + } + return `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`; + }, + canEvict: false, + }, + session: { + key: ONYXKEYS.SESSION, + }, }), )(IOURequestStepDescription); diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.js b/src/pages/iou/request/step/IOURequestStepScan/index.js index 577ee8845292..6bf517c30eb0 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.js +++ b/src/pages/iou/request/step/IOURequestStepScan/index.js @@ -93,38 +93,42 @@ function IOURequestStepScan({ return; } - navigator.mediaDevices.getUserMedia({video: {facingMode: {exact: 'environment'}, zoom: {ideal: 1}}}).then((stream) => { - _.forEach(stream.getTracks(), (track) => track.stop()); - // Only Safari 17+ supports zoom constraint - if (Browser.isMobileSafari() && stream.getTracks().length > 0) { - const deviceId = _.chain(stream.getTracks()) - .map((track) => track.getSettings()) - .find((setting) => setting.zoom === 1) - .get('deviceId') - .value(); - if (deviceId) { - setVideoConstraints({deviceId}); - return; + const defaultConstraints = {facingMode: {exact: 'environment'}}; + navigator.mediaDevices + .getUserMedia({video: {facingMode: {exact: 'environment'}, zoom: {ideal: 1}}}) + .then((stream) => { + _.forEach(stream.getTracks(), (track) => track.stop()); + // Only Safari 17+ supports zoom constraint + if (Browser.isMobileSafari() && stream.getTracks().length > 0) { + const deviceId = _.chain(stream.getTracks()) + .map((track) => track.getSettings()) + .find((setting) => setting.zoom === 1) + .get('deviceId') + .value(); + if (deviceId) { + setVideoConstraints({deviceId}); + return; + } } - } - if (!navigator.mediaDevices.enumerateDevices) { - setVideoConstraints({facingMode: {exact: 'environment'}}); - return; - } - navigator.mediaDevices.enumerateDevices().then((devices) => { - const lastBackDeviceId = _.chain(devices) - .filter((item) => item.kind === 'videoinput') - .last() - .get('deviceId', '') - .value(); - - if (!lastBackDeviceId) { - setVideoConstraints({facingMode: {exact: 'environment'}}); + if (!navigator.mediaDevices.enumerateDevices) { + setVideoConstraints(defaultConstraints); return; } - setVideoConstraints({deviceId: lastBackDeviceId}); - }); - }); + navigator.mediaDevices.enumerateDevices().then((devices) => { + const lastBackDeviceId = _.chain(devices) + .filter((item) => item.kind === 'videoinput') + .last() + .get('deviceId', '') + .value(); + + if (!lastBackDeviceId) { + setVideoConstraints(defaultConstraints); + return; + } + setVideoConstraints({deviceId: lastBackDeviceId}); + }); + }) + .catch(() => setVideoConstraints(defaultConstraints)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isTabActive]); @@ -175,7 +179,7 @@ function IOURequestStepScan({ } // If the transaction was created from the global create, the person needs to select participants, so take them there. - if (isFromGlobalCreate) { + if (isFromGlobalCreate && iouType !== CONST.IOU.TYPE.TRACK_EXPENSE) { Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID)); return; } diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.js b/src/pages/iou/request/step/IOURequestStepScan/index.native.js index 8dc8c634508c..03eb12fc3b03 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.js +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.js @@ -187,7 +187,7 @@ function IOURequestStepScan({ } // If the transaction was created from the global create, the person needs to select participants, so take them there. - if (isFromGlobalCreate) { + if (isFromGlobalCreate && iouType !== CONST.IOU.TYPE.TRACK_EXPENSE) { Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID)); return; } diff --git a/src/pages/iou/request/step/IOURequestStepTaxAmountPage.js b/src/pages/iou/request/step/IOURequestStepTaxAmountPage.js index 29263d92078f..7a75e9f48805 100644 --- a/src/pages/iou/request/step/IOURequestStepTaxAmountPage.js +++ b/src/pages/iou/request/step/IOURequestStepTaxAmountPage.js @@ -131,6 +131,7 @@ function IOURequestStepTaxAmountPage({ // inside a report. In this case, the participants can be automatically assigned from the report and the user can skip the participants step and go straight // to the confirm step. if (report.reportID) { + // TODO: Is this really needed at all? IOU.setMoneyRequestParticipantsFromReport(transactionID, report); Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(iouType, transactionID, reportID)); return; diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js index 07b80a371ea6..f64270726f2d 100755 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js @@ -196,25 +196,28 @@ function MoneyRequestParticipantsSelector({ * * @param {Object} option */ - const addSingleParticipant = (option) => { - if (participants.length) { - return; - } - onAddParticipants( - [ - { - accountID: option.accountID, - login: option.login, - isPolicyExpenseChat: option.isPolicyExpenseChat, - reportID: option.reportID, - selected: true, - searchText: option.searchText, - }, - ], - false, - ); - navigateToRequest(); - }; + const addSingleParticipant = useCallback( + (option) => { + if (participants.length) { + return; + } + onAddParticipants( + [ + { + accountID: option.accountID, + login: option.login, + isPolicyExpenseChat: option.isPolicyExpenseChat, + reportID: option.reportID, + selected: true, + searchText: option.searchText, + }, + ], + false, + ); + navigateToRequest(); + }, + [navigateToRequest, onAddParticipants, participants.length], + ); /** * Removes a selected option from list if already selected. If not already selected add this option to the list. @@ -275,13 +278,23 @@ function MoneyRequestParticipantsSelector({ const shouldShowSplitBillErrorMessage = participants.length > 1 && hasPolicyExpenseChatParticipant; const isAllowedToSplit = (canUseP2PDistanceRequests || !isDistanceRequest) && iouType !== CONST.IOU.TYPE.SEND; - const handleConfirmSelection = useCallback(() => { - if (shouldShowSplitBillErrorMessage) { - return; - } + const handleConfirmSelection = useCallback( + (keyEvent, option) => { + const shouldAddSingleParticipant = option && !participants.length; - navigateToSplit(); - }, [shouldShowSplitBillErrorMessage, navigateToSplit]); + if (shouldShowSplitBillErrorMessage || (!participants.length && !option)) { + return; + } + + if (shouldAddSingleParticipant) { + addSingleParticipant(option); + return; + } + + navigateToSplit(); + }, + [shouldShowSplitBillErrorMessage, navigateToSplit, addSingleParticipant, participants.length], + ); const footerContent = useMemo( () => ( diff --git a/src/pages/workspace/WorkspaceProfilePage.tsx b/src/pages/workspace/WorkspaceProfilePage.tsx index 7169d8a4ab7c..d8b407d5cee9 100644 --- a/src/pages/workspace/WorkspaceProfilePage.tsx +++ b/src/pages/workspace/WorkspaceProfilePage.tsx @@ -140,7 +140,7 @@ function WorkspaceProfilePage({policy, currencyList = {}, route}: WorkSpaceProfi type={CONST.ICON_TYPE_WORKSPACE} fallbackIcon={Expensicons.FallbackWorkspaceAvatar} style={[ - isSmallScreenWidth ? styles.mb1 : styles.mb3, + policy?.errorFields?.avatar ?? isSmallScreenWidth ? styles.mb1 : styles.mb3, isSmallScreenWidth ? styles.mtn17 : styles.mtn20, styles.alignItemsStart, styles.sectionMenuItemTopDescription, @@ -158,7 +158,7 @@ function WorkspaceProfilePage({policy, currencyList = {}, route}: WorkSpaceProfi originalFileName={policy?.originalFileName} disabled={readOnly} disabledStyle={styles.cursorDefault} - errorRowStyles={undefined} + errorRowStyles={styles.mt3} /> (null); const {isSmallScreenWidth} = useWindowDimensions(); + const session = useSession(); const policyName = policy?.name ?? ''; const id = policy?.id ?? ''; + const adminEmail = session?.email ?? ''; const urlWithTrailingSlash = Url.addTrailingForwardSlash(environmentURL); - const url = `${urlWithTrailingSlash}${ROUTES.WORKSPACE_PROFILE.getRoute(id)}`; + const url = `${urlWithTrailingSlash}${ROUTES.WORKSPACE_JOIN_USER.getRoute(id, adminEmail)}`; + return ( ) => { + const errors: FormInputErrors = {}; + const customTaxName = values[INPUT_IDS.NAME]; + + if (!ValidationUtils.isRequiredFulfilled(customTaxName)) { + errors.name = 'workspace.taxes.errors.customNameRequired'; + } + + return errors; + }, []); + const submit = ({name}: WorkspaceTaxCustomName) => { setPolicyCustomTaxName(policyID, name); Navigation.goBack(ROUTES.WORKSPACE_TAXES_SETTINGS.getRoute(policyID)); @@ -62,6 +75,7 @@ function WorkspaceTaxesSettingsCustomTaxName({ style={[styles.flexGrow1, styles.ph5]} scrollContextEnabled enabledWhenOffline + validate={validate} onSubmit={submit} > @@ -72,7 +86,7 @@ function WorkspaceTaxesSettingsCustomTaxName({ label={translate('workspace.editor.nameInputLabel')} accessibilityLabel={translate('workspace.editor.nameInputLabel')} defaultValue={policy?.taxRates?.name} - maxLength={CONST.TAX_RATES.NAME_MAX_LENGTH} + maxLength={CONST.TAX_RATES.CUSTOM_NAME_MAX_LENGTH} multiline={false} ref={inputCallbackRef} /> diff --git a/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx b/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx index 1dd65ffb2390..5d2297f47ddd 100644 --- a/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx @@ -80,6 +80,9 @@ function WorkspaceAutoReportingFrequencyPage({policy, route}: WorkspaceAutoRepor if (typeof policy?.autoReportingOffset === 'number') { return toLocaleOrdinal(policy.autoReportingOffset); } + if (typeof policy?.autoReportingOffset === 'string' && parseInt(policy?.autoReportingOffset, 10)) { + return toLocaleOrdinal(parseInt(policy.autoReportingOffset, 10)); + } return translate(`workflowsPage.frequencies.${policy?.autoReportingOffset}`); }; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 54f0a65e0c5b..cc2688e7a137 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -184,6 +184,56 @@ type Connections = { type AutoReportingOffset = number | ValueOf; +type PolicyReportFieldType = 'text' | 'date' | 'dropdown' | 'formula'; + +type PolicyReportField = { + /** Name of the field */ + name: string; + + /** Default value assigned to the field */ + defaultValue: string; + + /** Unique id of the field */ + fieldID: string; + + /** Position at which the field should show up relative to the other fields */ + orderWeight: number; + + /** Type of report field */ + type: PolicyReportFieldType; + + /** Tells if the field is required or not */ + deletable: boolean; + + /** Value of the field */ + value: string | null; + + /** Options to select from if field is of type dropdown */ + values: string[]; + + target: string; + + /** Tax UDFs have keys holding the names of taxes (eg, VAT), values holding percentages (eg, 15%) and a value indicating the currently selected tax value (eg, 15%). */ + keys: string[]; + + /** list of externalIDs, this are either imported from the integrations or auto generated by us, each externalID */ + externalIDs: string[]; + + disabledOptions: boolean[]; + + /** Is this a tax user defined report field */ + isTax: boolean; + + /** This is the selected externalID in an expense. */ + externalID?: string | null; + + /** Automated action or integration that added this report field */ + origin?: string | null; + + /** This is indicates which default value we should use. It was preferred using this over having defaultValue (which we have anyway for historical reasons), since the values are not unique we can't determine which key the defaultValue is referring too. It was also preferred over having defaultKey since the keys are user editable and can be changed. The externalIDs work effectively as an ID, which never changes even after changing the key, value or position of the option. */ + defaultExternalID?: string | null; +}; + type PolicyFeatureName = ValueOf; type PendingJoinRequestPolicy = { @@ -346,6 +396,9 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** All the integration connections attached to the policy */ connections?: Connections; + /** Report fields attached to the policy */ + fieldList?: Record; + /** Whether the Categories feature is enabled */ areCategoriesEnabled?: boolean; @@ -369,4 +422,4 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< export default Policy; -export type {Unit, CustomUnit, Attributes, Rate, TaxRate, TaxRates, TaxRatesWithDefault, PolicyFeatureName, PendingJoinRequestPolicy}; +export type {PolicyReportField, PolicyReportFieldType, Unit, CustomUnit, Attributes, Rate, TaxRate, TaxRates, TaxRatesWithDefault, PolicyFeatureName, PendingJoinRequestPolicy}; diff --git a/src/types/onyx/PolicyReportField.ts b/src/types/onyx/PolicyReportField.ts deleted file mode 100644 index de385070aa25..000000000000 --- a/src/types/onyx/PolicyReportField.ts +++ /dev/null @@ -1,30 +0,0 @@ -type PolicyReportFieldType = 'text' | 'date' | 'dropdown' | 'formula'; - -type PolicyReportField = { - /** Name of the field */ - name: string; - - /** Default value assigned to the field */ - defaultValue: string; - - /** Unique id of the field */ - fieldID: string; - - /** Position at which the field should show up relative to the other fields */ - orderWeight: number; - - /** Type of report field */ - type: PolicyReportFieldType; - - /** Tells if the field is required or not */ - deletable: boolean; - - /** Value of the field */ - value: string; - - /** Options to select from if field is of type dropdown */ - values: string[]; -}; - -type PolicyReportFields = Record; -export type {PolicyReportField, PolicyReportFields}; diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index c34534c0f420..02dfcbbbfc5f 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -4,7 +4,7 @@ import type ONYXKEYS from '@src/ONYXKEYS'; import type CollectionDataSet from '@src/types/utils/CollectionDataSet'; import type * as OnyxCommon from './OnyxCommon'; import type PersonalDetails from './PersonalDetails'; -import type {PolicyReportField} from './PolicyReportField'; +import type {PolicyReportField} from './Policy'; type NotificationPreference = ValueOf; @@ -183,7 +183,7 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback< pendingChatMembers?: PendingChatMember[]; /** If the report contains reportFields, save the field id and its value */ - reportFields?: Record; + fieldList?: Record; }, PolicyReportField['fieldID'] >; diff --git a/src/types/onyx/ReportAction.ts b/src/types/onyx/ReportAction.ts index f6c34fe742a4..ad81ae480cd0 100644 --- a/src/types/onyx/ReportAction.ts +++ b/src/types/onyx/ReportAction.ts @@ -70,6 +70,9 @@ type Message = { /** resolution for actionable mention whisper */ resolution?: ValueOf | null; + + /** The time this report action was deleted */ + deleted?: string; }; type ImageMetadata = { diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 6a134ed80b07..de40dd4cf02f 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -34,12 +34,11 @@ import type {PersonalDetailsList} from './PersonalDetails'; import type PersonalDetails from './PersonalDetails'; import type PlaidData from './PlaidData'; import type Policy from './Policy'; -import type {TaxRate, TaxRates, TaxRatesWithDefault} from './Policy'; +import type {PolicyReportField, TaxRate, TaxRates, TaxRatesWithDefault} from './Policy'; import type {PolicyCategories, PolicyCategory} from './PolicyCategory'; import type PolicyJoinMember from './PolicyJoinMember'; import type {PolicyMembers} from './PolicyMember'; import type PolicyMember from './PolicyMember'; -import type {PolicyReportField, PolicyReportFields} from './PolicyReportField'; import type {PolicyTag, PolicyTagList, PolicyTags} from './PolicyTag'; import type PreferredTheme from './PreferredTheme'; import type PriorityMode from './PriorityMode'; @@ -160,7 +159,6 @@ export type { WorkspaceRateAndUnit, ReportUserIsTyping, PolicyReportField, - PolicyReportFields, RecentlyUsedReportFields, DecisionName, OriginalMessageIOU, diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js index 64c8edb134b1..5e9efcc00617 100644 --- a/tests/actions/IOUTest.js +++ b/tests/actions/IOUTest.js @@ -2231,7 +2231,7 @@ describe('actions/IOU', () => { const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs); // When Opening a thread report with the given details - Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID); + Report.openReport(thread.reportID, '', userLogins, thread, createIOUAction.reportActionID); await waitForBatchedUpdates(); // Then The iou action has the transaction report id as a child report ID @@ -2310,7 +2310,7 @@ describe('actions/IOU', () => { const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs); // When Opening a thread report with the given details - Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID); + Report.openReport(thread.reportID, '', userLogins, thread, createIOUAction.reportActionID); await waitForBatchedUpdates(); // Then The iou action has the transaction report id as a child report ID @@ -2380,7 +2380,7 @@ describe('actions/IOU', () => { const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs); jest.advanceTimersByTime(10); - Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID); + Report.openReport(thread.reportID, '', userLogins, thread, createIOUAction.reportActionID); await waitForBatchedUpdates(); Onyx.connect({ @@ -2472,7 +2472,7 @@ describe('actions/IOU', () => { jest.advanceTimersByTime(10); const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs); - Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID); + Report.openReport(thread.reportID, '', userLogins, thread, createIOUAction.reportActionID); await waitForBatchedUpdates(); @@ -2698,7 +2698,7 @@ describe('actions/IOU', () => { jest.advanceTimersByTime(10); const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs); - Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID); + Report.openReport(thread.reportID, '', userLogins, thread, createIOUAction.reportActionID); await waitForBatchedUpdates(); const allReportActions = await new Promise((resolve) => { diff --git a/tests/e2e/nativeCommands/index.js b/tests/e2e/nativeCommands/index.ts similarity index 74% rename from tests/e2e/nativeCommands/index.js rename to tests/e2e/nativeCommands/index.ts index 90dcb00bbcae..31af618c8ec1 100644 --- a/tests/e2e/nativeCommands/index.js +++ b/tests/e2e/nativeCommands/index.ts @@ -1,14 +1,15 @@ +import type {NativeCommandPayload} from '@libs/E2E/client'; import adbBackspace from './adbBackspace'; import adbTypeText from './adbTypeText'; // eslint-disable-next-line rulesdir/prefer-import-module-contents import {NativeCommandsAction} from './NativeCommandsAction'; -const executeFromPayload = (actionName, payload) => { +const executeFromPayload = (actionName?: string, payload?: NativeCommandPayload): boolean => { switch (actionName) { case NativeCommandsAction.scroll: throw new Error('Not implemented yet'); case NativeCommandsAction.type: - return adbTypeText(payload.text); + return adbTypeText(payload?.text ?? ''); case NativeCommandsAction.backspace: return adbBackspace(); default: diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index c040e634c1ea..0726dbc9c88d 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -253,15 +253,15 @@ function signInAndGetAppWithUnreadChat(): Promise { }, ], }, - 1: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '1'), - 2: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 20), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '2'), - 3: TestHelper.buildTestReportComment(reportAction3CreatedDate, USER_B_ACCOUNT_ID, '3'), - 4: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 40), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '4'), - 5: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 50), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '5'), - 6: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 60), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '6'), - 7: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 70), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '7'), - 8: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 80), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '8'), - 9: TestHelper.buildTestReportComment(reportAction9CreatedDate, USER_B_ACCOUNT_ID, '9'), + 1: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '1', createdReportActionID), + 2: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 20), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '2', '1'), + 3: TestHelper.buildTestReportComment(reportAction3CreatedDate, USER_B_ACCOUNT_ID, '3', '2'), + 4: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 40), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '4', '3'), + 5: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 50), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '5', '4'), + 6: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 60), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '6', '5'), + 7: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 70), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '7', '6'), + 8: TestHelper.buildTestReportComment(format(addSeconds(TEN_MINUTES_AGO, 80), CONST.DATE.FNS_DB_FORMAT_STRING), USER_B_ACCOUNT_ID, '8', '7'), + 9: TestHelper.buildTestReportComment(reportAction9CreatedDate, USER_B_ACCOUNT_ID, '9', '8'), }); await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { [USER_B_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_B_EMAIL, USER_B_ACCOUNT_ID, 'B'), diff --git a/tests/unit/LocaleCompareTest.js b/tests/unit/LocaleCompareTest.ts similarity index 100% rename from tests/unit/LocaleCompareTest.js rename to tests/unit/LocaleCompareTest.ts diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 14c749fc92de..bf528eca3e81 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -305,116 +305,6 @@ describe('ReportActionsUtils', () => { expect(result).toStrictEqual(input); }); - describe('getSortedReportActionsForDisplay with marked the first reportAction', () => { - it('should filter out non-whitelisted actions', () => { - const input: ReportAction[] = [ - { - created: '2022-11-13 22:27:01.825', - reportActionID: '8401445780099176', - actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, - originalMessage: { - html: 'Hello world', - whisperedTo: [], - }, - message: [ - { - html: 'Hello world', - type: 'Action type', - text: 'Action text', - }, - ], - }, - { - created: '2022-11-12 22:27:01.825', - reportActionID: '6401435781022176', - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - originalMessage: { - html: 'Hello world', - whisperedTo: [], - }, - message: [ - { - html: 'Hello world', - type: 'Action type', - text: 'Action text', - }, - ], - }, - { - created: '2022-11-11 22:27:01.825', - reportActionID: '2962390724708756', - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - originalMessage: { - amount: 0, - currency: 'USD', - type: 'split', // change to const - }, - message: [ - { - html: 'Hello world', - type: 'Action type', - text: 'Action text', - }, - ], - }, - { - created: '2022-11-10 22:27:01.825', - reportActionID: '1609646094152486', - actionName: CONST.REPORT.ACTIONS.TYPE.RENAMED, - originalMessage: { - html: 'Hello world', - lastModified: '2022-11-10 22:27:01.825', - oldName: 'old name', - newName: 'new name', - }, - message: [ - { - html: 'Hello world', - type: 'Action type', - text: 'Action text', - }, - ], - }, - { - created: '2022-11-09 22:27:01.825', - reportActionID: '8049485084562457', - actionName: CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.UPDATE_FIELD, - originalMessage: {}, - message: [{html: 'updated the Approval Mode from "Submit and Approve" to "Submit and Close"', type: 'Action type', text: 'Action text'}], - }, - { - created: '2022-11-08 22:27:06.825', - reportActionID: '1661970171066216', - actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED, - originalMessage: { - paymentType: 'ACH', - }, - message: [{html: 'Waiting for the bank account', type: 'Action type', text: 'Action text'}], - }, - { - created: '2022-11-06 22:27:08.825', - reportActionID: '1661970171066220', - actionName: CONST.REPORT.ACTIONS.TYPE.TASKEDITED, - originalMessage: { - html: 'Hello world', - whisperedTo: [], - }, - message: [{html: 'I have changed the task', type: 'Action type', text: 'Action text'}], - }, - ]; - - const resultWithoutNewestFlag = ReportActionsUtils.getSortedReportActionsForDisplay(input); - const resultWithNewestFlag = ReportActionsUtils.getSortedReportActionsForDisplay(input, true); - input.pop(); - // Mark the newest report action as the newest report action - resultWithoutNewestFlag[0] = { - ...resultWithoutNewestFlag[0], - isNewestReportAction: true, - }; - expect(resultWithoutNewestFlag).toStrictEqual(resultWithNewestFlag); - }); - }); - it('should filter out closed actions', () => { const input: ReportAction[] = [ { @@ -551,6 +441,1349 @@ describe('ReportActionsUtils', () => { expect(result).toStrictEqual(input); }); }); + describe('getContinuousReportActionChain', () => { + it('given an input ID of 1, ..., 7 it will return the report actions with id 1 - 7', () => { + const input: ReportAction[] = [ + // Given these sortedReportActions + { + reportActionID: '1', + previousReportActionID: undefined, + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '2', + previousReportActionID: '1', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '3', + previousReportActionID: '2', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '4', + previousReportActionID: '3', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '5', + previousReportActionID: '4', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '6', + previousReportActionID: '5', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '7', + previousReportActionID: '6', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + + // Note: there's a "gap" here because the previousReportActionID (8) does not match the ID of the previous reportAction in the array (7) + { + reportActionID: '9', + previousReportActionID: '8', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '10', + previousReportActionID: '9', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '11', + previousReportActionID: '10', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '12', + previousReportActionID: '11', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + + // Note: another gap + { + reportActionID: '14', + previousReportActionID: '13', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '15', + previousReportActionID: '14', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '16', + previousReportActionID: '15', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '17', + previousReportActionID: '16', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + ]; + + const expectedResult = [ + { + reportActionID: '1', + previousReportActionID: undefined, + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '2', + previousReportActionID: '1', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '3', + previousReportActionID: '2', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '4', + previousReportActionID: '3', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '5', + previousReportActionID: '4', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '6', + previousReportActionID: '5', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '7', + previousReportActionID: '6', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + ]; + // Reversing the input array to simulate descending order sorting as per our data structure + const result = ReportActionsUtils.getContinuousReportActionChain(input.reverse(), '3'); + input.pop(); + expect(result).toStrictEqual(expectedResult.reverse()); + }); + + it('given an input ID of 9, ..., 12 it will return the report actions with id 9 - 12', () => { + const input: ReportAction[] = [ + // Given these sortedReportActions + { + reportActionID: '1', + previousReportActionID: undefined, + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '2', + previousReportActionID: '1', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '3', + previousReportActionID: '2', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '4', + previousReportActionID: '3', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '5', + previousReportActionID: '4', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '6', + previousReportActionID: '5', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '7', + previousReportActionID: '6', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + + // Note: there's a "gap" here because the previousReportActionID (8) does not match the ID of the previous reportAction in the array (7) + { + reportActionID: '9', + previousReportActionID: '8', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '10', + previousReportActionID: '9', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '11', + previousReportActionID: '10', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '12', + previousReportActionID: '11', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + + // Note: another gap + { + reportActionID: '14', + previousReportActionID: '13', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '15', + previousReportActionID: '14', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '16', + previousReportActionID: '15', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '17', + previousReportActionID: '16', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + ]; + + const expectedResult = [ + { + reportActionID: '9', + previousReportActionID: '8', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '10', + previousReportActionID: '9', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '11', + previousReportActionID: '10', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '12', + previousReportActionID: '11', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + ]; + // Reversing the input array to simulate descending order sorting as per our data structure + const result = ReportActionsUtils.getContinuousReportActionChain(input.reverse(), '10'); + input.pop(); + expect(result).toStrictEqual(expectedResult.reverse()); + }); + + it('given an input ID of 14, ..., 17 it will return the report actions with id 14 - 17', () => { + const input = [ + // Given these sortedReportActions + { + reportActionID: '1', + previousReportActionID: undefined, + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '2', + previousReportActionID: '1', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '3', + previousReportActionID: '2', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '4', + previousReportActionID: '3', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '5', + previousReportActionID: '4', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '6', + previousReportActionID: '5', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '7', + previousReportActionID: '6', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + + // Note: there's a "gap" here because the previousReportActionID (8) does not match the ID of the previous reportAction in the array (7) + { + reportActionID: '9', + previousReportActionID: '8', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '10', + previousReportActionID: '9', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '11', + previousReportActionID: '10', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '12', + previousReportActionID: '11', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + + // Note: another gap + { + reportActionID: '14', + previousReportActionID: '13', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '15', + previousReportActionID: '14', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '16', + previousReportActionID: '15', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '17', + previousReportActionID: '16', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + ]; + + const expectedResult = [ + { + reportActionID: '14', + previousReportActionID: '13', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '15', + previousReportActionID: '14', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '16', + previousReportActionID: '15', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '17', + previousReportActionID: '16', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + ]; + // Reversing the input array to simulate descending order sorting as per our data structure + const result = ReportActionsUtils.getContinuousReportActionChain(input.reverse(), '16'); + input.pop(); + expect(result).toStrictEqual(expectedResult.reverse()); + }); + + it('given an input ID of 8 or 13 which are not exist in Onyx it will return an empty array', () => { + const input: ReportAction[] = [ + // Given these sortedReportActions + { + reportActionID: '1', + previousReportActionID: undefined, + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '2', + previousReportActionID: '1', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '3', + previousReportActionID: '2', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '4', + previousReportActionID: '3', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '5', + previousReportActionID: '4', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '6', + previousReportActionID: '5', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '7', + previousReportActionID: '6', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + + // Note: there's a "gap" here because the previousReportActionID (8) does not match the ID of the previous reportAction in the array (7) + { + reportActionID: '9', + previousReportActionID: '8', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '10', + previousReportActionID: '9', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '11', + previousReportActionID: '10', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '12', + previousReportActionID: '11', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + + // Note: another gap + { + reportActionID: '14', + previousReportActionID: '13', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '15', + previousReportActionID: '14', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '16', + previousReportActionID: '15', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + { + reportActionID: '17', + previousReportActionID: '16', + created: '2022-11-13 22:27:01.825', + actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }, + ]; + + const expectedResult: ReportAction[] = []; + // Reversing the input array to simulate descending order sorting as per our data structure + const result = ReportActionsUtils.getContinuousReportActionChain(input.reverse(), '8'); + input.pop(); + expect(result).toStrictEqual(expectedResult.reverse()); + }); + }); describe('getLastVisibleAction', () => { it('should return the last visible action for a report', () => { diff --git a/tests/unit/searchCountryOptionsTest.js b/tests/unit/searchCountryOptionsTest.ts similarity index 100% rename from tests/unit/searchCountryOptionsTest.js rename to tests/unit/searchCountryOptionsTest.ts diff --git a/tests/utils/LHNTestUtils.tsx b/tests/utils/LHNTestUtils.tsx index 85c2d67f80bc..44bfcd46d399 100644 --- a/tests/utils/LHNTestUtils.tsx +++ b/tests/utils/LHNTestUtils.tsx @@ -142,11 +142,14 @@ function getFakeReport(participantAccountIDs = [1, 2], millisecondsInThePast = 0 function getFakeReportAction(actor = 'email1@test.com', millisecondsInThePast = 0): ReportAction { const timestamp = Date.now() - millisecondsInThePast; const created = DateUtils.getDBTime(timestamp); + const previousReportActionID = lastFakeReportActionID; + const reportActionID = ++lastFakeReportActionID; return { actor, actorAccountID: 1, - reportActionID: `${++lastFakeReportActionID}`, + reportActionID: `${reportActionID}`, + previousReportActionID: `${previousReportActionID}`, actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, shouldShow: true, created, @@ -183,7 +186,7 @@ function getFakeReportAction(actor = 'email1@test.com', millisecondsInThePast = }, ], originalMessage: { - childReportID: `${++lastFakeReportActionID}`, + childReportID: `${reportActionID}`, emojiReactions: { heart: { createdAt: '2023-08-28 15:27:52', diff --git a/tests/utils/ReportTestUtils.ts b/tests/utils/ReportTestUtils.ts index 4a4ce89d0296..3948baca3113 100644 --- a/tests/utils/ReportTestUtils.ts +++ b/tests/utils/ReportTestUtils.ts @@ -39,8 +39,8 @@ const getFakeReportAction = (index: number, actionName?: ActionName): ReportActi text: 'email@test.com', }, ], - previousReportActionID: '1', reportActionID: index.toString(), + previousReportActionID: (index === 0 ? 0 : index - 1).toString(), reportActionTimestamp: 1696243169753, sequenceNumber: 0, shouldShow: true, @@ -48,7 +48,11 @@ const getFakeReportAction = (index: number, actionName?: ActionName): ReportActi whisperedToAccountIDs: [], } as ReportAction); -const getMockedSortedReportActions = (length = 100): ReportAction[] => Array.from({length}, (element, index): ReportAction => getFakeReportAction(index)); +const getMockedSortedReportActions = (length = 100): ReportAction[] => + Array.from({length}, (element, index): ReportAction => { + const actionName: ActionName = index === 0 ? 'CREATED' : 'ADDCOMMENT'; + return getFakeReportAction(index + 1, actionName); + }).reverse(); const getMockedReportActionsMap = (length = 100): ReportActions => { const mockReports: ReportActions[] = Array.from({length}, (element, index): ReportActions => { @@ -60,6 +64,7 @@ const getMockedReportActionsMap = (length = 100): ReportActions => { originalMessage: { linkedReportID: reportID.toString(), }, + previousReportActionID: index.toString(), } as ReportAction; return {[reportID]: reportAction}; diff --git a/tests/utils/TestHelper.js b/tests/utils/TestHelper.js index b26c601a1c06..c7bc95c58244 100644 --- a/tests/utils/TestHelper.js +++ b/tests/utils/TestHelper.js @@ -200,9 +200,10 @@ function setPersonalDetails(login, accountID) { * @param {String} created * @param {Number} actorAccountID * @param {String} actionID + * @param {String} previousReportActionID * @returns {Object} */ -function buildTestReportComment(created, actorAccountID, actionID = null) { +function buildTestReportComment(created, actorAccountID, actionID = null, previousReportActionID = null) { const reportActionID = actionID || NumberUtils.rand64(); return { actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, @@ -211,6 +212,7 @@ function buildTestReportComment(created, actorAccountID, actionID = null) { message: [{type: 'COMMENT', html: `Comment ${actionID}`, text: `Comment ${actionID}`}], reportActionID, actorAccountID, + previousReportActionID, }; } diff --git a/tests/utils/collections/reportActions.ts b/tests/utils/collections/reportActions.ts index dcfa896f1ae4..f19e939083d2 100644 --- a/tests/utils/collections/reportActions.ts +++ b/tests/utils/collections/reportActions.ts @@ -33,7 +33,7 @@ export default function createRandomReportAction(index: number): ReportAction { // eslint-disable-next-line @typescript-eslint/no-explicit-any actionName: rand(flattenActionNamesValues(CONST.REPORT.ACTIONS.TYPE)) as any, reportActionID: index.toString(), - previousReportActionID: index.toString(), + previousReportActionID: (index === 0 ? 0 : index - 1).toString(), actorAccountID: index, person: [ {