From 92d3147f02d9b8a8f1e82951bc8742f021c080f7 Mon Sep 17 00:00:00 2001 From: cdOut <88325488+cdOut@users.noreply.github.com> Date: Mon, 8 Jul 2024 12:59:36 +0200 Subject: [PATCH 001/289] add handling for wrapped code text for native devices --- .../InlineCodeBlock/WrappedText.tsx | 108 +++++++++--------- 1 file changed, 51 insertions(+), 57 deletions(-) diff --git a/src/components/InlineCodeBlock/WrappedText.tsx b/src/components/InlineCodeBlock/WrappedText.tsx index 3045c15c471b..a5fe8b18e50a 100644 --- a/src/components/InlineCodeBlock/WrappedText.tsx +++ b/src/components/InlineCodeBlock/WrappedText.tsx @@ -1,6 +1,6 @@ -import React, {Fragment} from 'react'; -import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; -import {View} from 'react-native'; +import React, {useState} from 'react'; +import {TextLayoutEventData, View} from 'react-native'; +import type {NativeSyntheticEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; import {containsOnlyEmojis} from '@libs/EmojiUtils'; @@ -11,77 +11,71 @@ type WrappedTextProps = ChildrenProps & { /** Style to be applied to Text */ textStyles?: StyleProp; - /** - * Style for each individual word (token) in the text. Note that a token can also include whitespace characters between words. - */ + /** Style for each individual word (token) in the text */ wordStyles?: StyleProp; }; -/** - * Breaks the text into matrix - * - * @example - * const text = "My Name is Rajat"; - * const resultMatrix = getTextMatrix(text); - * console.log(resultMatrix); - * // Output: - * // [ - * // ['My', ' ', 'Name', ' ', 'is', ' ', 'Rajat'], - * // ] - */ -function getTextMatrix(text: string): string[][] { - return text.split('\n').map((row) => row.split(CONST.REGEX.SPACE_OR_EMOJI).filter((value) => value !== '')); -} - -/** - * Validates if the text contains any emoji - */ +/** Validates if the text contains any emoji */ function containsEmoji(text: string): boolean { return CONST.REGEX.EMOJIS.test(text); } function WrappedText({children, wordStyles, textStyles}: WrappedTextProps) { + const [lines, setLines] = useState([]); const styles = useThemeStyles(); if (typeof children !== 'string') { return null; } - const textMatrix = getTextMatrix(children); + type TextLayoutEvent = NativeSyntheticEvent; + + const handleTextLayout = (event: TextLayoutEvent) => { + const { + nativeEvent: {lines: textLines}, + } = event; + setLines(textLines.map((line: {text: string}) => line.text)); + }; - return textMatrix.map((rowText, rowIndex) => ( - - {rowText.map((colText, colIndex) => ( - // Outer View is important to vertically center the Text - - - - {Array.from(colText).map((char, charIndex) => - containsOnlyEmojis(char) ? ( - - {char} - - ) : ( - char - ), - )} - + return ( + <> + {!!lines.length ? ( + lines.map((line, index) => ( + + + + {Array.from(line).map((char, charIndex) => + containsOnlyEmojis(char) ? ( + + {char} + + ) : ( + char + ), + )} + + + )) + ) : ( + + + {children} + - ))} - - )); + )} + + ); } WrappedText.displayName = 'WrappedText'; From ebeed08b6f1ce82a8c4566ae1ea9ce03b9a449f2 Mon Sep 17 00:00:00 2001 From: cdOut <88325488+cdOut@users.noreply.github.com> Date: Mon, 8 Jul 2024 13:17:14 +0200 Subject: [PATCH 002/289] fix lint issues --- .../InlineCodeBlock/WrappedText.tsx | 79 ++++++++++--------- 1 file changed, 40 insertions(+), 39 deletions(-) diff --git a/src/components/InlineCodeBlock/WrappedText.tsx b/src/components/InlineCodeBlock/WrappedText.tsx index a5fe8b18e50a..661d3e5eeb7e 100644 --- a/src/components/InlineCodeBlock/WrappedText.tsx +++ b/src/components/InlineCodeBlock/WrappedText.tsx @@ -1,6 +1,6 @@ import React, {useState} from 'react'; -import {TextLayoutEventData, View} from 'react-native'; -import type {NativeSyntheticEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import type {NativeSyntheticEvent, TextLayoutEventData, StyleProp, TextStyle, ViewStyle} from 'react-native'; import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; import {containsOnlyEmojis} from '@libs/EmojiUtils'; @@ -37,45 +37,46 @@ function WrappedText({children, wordStyles, textStyles}: WrappedTextProps) { setLines(textLines.map((line: {text: string}) => line.text)); }; - return ( - <> - {!!lines.length ? ( - lines.map((line, index) => ( - - - - {Array.from(line).map((char, charIndex) => - containsOnlyEmojis(char) ? ( - - {char} - - ) : ( - char - ), - )} - - - - )) - ) : ( - - - {children} + if (!lines.length) { + return ( + + + {children} + + + ); + } + + return (<> + {lines.map((line, index) => ( + + + + {Array.from(line).map((char, charIndex) => + containsOnlyEmojis(char) ? ( + + {char} + + ) : ( + char + ), + )} - )} - - ); + + ))} + ); } WrappedText.displayName = 'WrappedText'; From e8441ef9cafb5b04da8a96fb5ec32067375a6206 Mon Sep 17 00:00:00 2001 From: cdOut <88325488+cdOut@users.noreply.github.com> Date: Mon, 8 Jul 2024 13:18:06 +0200 Subject: [PATCH 003/289] correct imports and prettier --- .../InlineCodeBlock/WrappedText.tsx | 56 ++++++++++--------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/src/components/InlineCodeBlock/WrappedText.tsx b/src/components/InlineCodeBlock/WrappedText.tsx index 661d3e5eeb7e..ef8d66e365dc 100644 --- a/src/components/InlineCodeBlock/WrappedText.tsx +++ b/src/components/InlineCodeBlock/WrappedText.tsx @@ -1,6 +1,6 @@ import React, {useState} from 'react'; import {View} from 'react-native'; -import type {NativeSyntheticEvent, TextLayoutEventData, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {NativeSyntheticEvent, StyleProp, TextLayoutEventData, TextStyle, ViewStyle} from 'react-native'; import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; import {containsOnlyEmojis} from '@libs/EmojiUtils'; @@ -50,33 +50,35 @@ function WrappedText({children, wordStyles, textStyles}: WrappedTextProps) { ); } - return (<> - {lines.map((line, index) => ( - - - - {Array.from(line).map((char, charIndex) => - containsOnlyEmojis(char) ? ( - - {char} - - ) : ( - char - ), - )} - + return ( + <> + {lines.map((line, index) => ( + + + + {Array.from(line).map((char, charIndex) => + containsOnlyEmojis(char) ? ( + + {char} + + ) : ( + char + ), + )} + + - - ))} - ); + ))} + + ); } WrappedText.displayName = 'WrappedText'; From 5764928860c95a9c2df0e09aab59d97c7dd1f530 Mon Sep 17 00:00:00 2001 From: cdOut <88325488+cdOut@users.noreply.github.com> Date: Tue, 10 Sep 2024 17:48:48 +0200 Subject: [PATCH 004/289] split long singular words into mulltiple strings --- .../InlineCodeBlock/WrappedText.tsx | 90 +++++++++++-------- src/styles/variables.ts | 1 + 2 files changed, 56 insertions(+), 35 deletions(-) diff --git a/src/components/InlineCodeBlock/WrappedText.tsx b/src/components/InlineCodeBlock/WrappedText.tsx index ef8d66e365dc..4fd48b63d626 100644 --- a/src/components/InlineCodeBlock/WrappedText.tsx +++ b/src/components/InlineCodeBlock/WrappedText.tsx @@ -1,70 +1,90 @@ -import React, {useState} from 'react'; +import React, {Fragment} from 'react'; +import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; -import type {NativeSyntheticEvent, StyleProp, TextLayoutEventData, TextStyle, ViewStyle} from 'react-native'; import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; import {containsOnlyEmojis} from '@libs/EmojiUtils'; import CONST from '@src/CONST'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import variables from '@styles/variables'; +import useWindowDimensions from '@hooks/useWindowDimensions'; type WrappedTextProps = ChildrenProps & { /** Style to be applied to Text */ textStyles?: StyleProp; - /** Style for each individual word (token) in the text */ + /** + * Style for each individual word (token) in the text. Note that a token can also include whitespace characters between words. + */ wordStyles?: StyleProp; }; -/** Validates if the text contains any emoji */ +/** + * Breaks the text into matrix + * + * @example + * const text = "My Name is Rajat"; + * const resultMatrix = getTextMatrix(text); + * console.log(resultMatrix); + * // Output: + * // [ + * // ['My', ' ', 'Name', ' ', 'is', ' ', 'Rajat'], + * // ] + */ +function getTextMatrix(text: string): string[][] { + return text.split('\n').map((row) => row.split(CONST.REGEX.SPACE_OR_EMOJI).filter((value) => value !== '')); +} + +/** + * Validates if the text contains any emoji + */ function containsEmoji(text: string): boolean { return CONST.REGEX.EMOJIS.test(text); } +/** + * Splits long words into multiple strings + */ +function splitLongWord(word: string, maxLength: number): string[] { + if (word.length <= maxLength) { + return [word]; + } + + return word.match(new RegExp(`.{1,${maxLength}}`, 'g')) || []; +} + function WrappedText({children, wordStyles, textStyles}: WrappedTextProps) { - const [lines, setLines] = useState([]); const styles = useThemeStyles(); + const {windowWidth} = useWindowDimensions(); if (typeof children !== 'string') { return null; } - type TextLayoutEvent = NativeSyntheticEvent; - - const handleTextLayout = (event: TextLayoutEvent) => { - const { - nativeEvent: {lines: textLines}, - } = event; - setLines(textLines.map((line: {text: string}) => line.text)); - }; + const charWidth = variables.fontSizeLabel * variables.fontSizeToWidthRatio; + const charsPerLine = Math.floor(windowWidth / charWidth); - if (!lines.length) { - return ( - - - {children} - - - ); - } + const textMatrix = getTextMatrix(children).map((row) => row.flatMap((word) => splitLongWord(word, charsPerLine))); - return ( - <> - {lines.map((line, index) => ( + return textMatrix.map((rowText, rowIndex) => ( + + {rowText.map((colText, colIndex) => ( + // Outer View is important to vertically center the Text - - - {Array.from(line).map((char, charIndex) => + + + {Array.from(colText).map((char, charIndex) => containsOnlyEmojis(char) ? ( {char} @@ -77,8 +97,8 @@ function WrappedText({children, wordStyles, textStyles}: WrappedTextProps) { ))} - - ); + + )); } WrappedText.displayName = 'WrappedText'; diff --git a/src/styles/variables.ts b/src/styles/variables.ts index c0c058352d00..1cc0a2b5bdf7 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -215,6 +215,7 @@ export default { onboardingModalWidth: 500, welcomeVideoDelay: 1000, explanationModalDelay: 2000, + fontSizeToWidthRatio: getValueUsingPixelRatio(0.8, 1), // The height of the empty list is 14px (2px for borders and 12px for vertical padding) // This is calculated based on the values specified in the 'getGoogleListViewStyle' function of the 'StyleUtils' utility From e74c97e0149df0d5fa7bd9bc9c66093b241c2806 Mon Sep 17 00:00:00 2001 From: cdOut <88325488+cdOut@users.noreply.github.com> Date: Tue, 10 Sep 2024 17:49:40 +0200 Subject: [PATCH 005/289] fix prettier and lint errors --- src/components/InlineCodeBlock/WrappedText.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/InlineCodeBlock/WrappedText.tsx b/src/components/InlineCodeBlock/WrappedText.tsx index 4fd48b63d626..b331ce902fcb 100644 --- a/src/components/InlineCodeBlock/WrappedText.tsx +++ b/src/components/InlineCodeBlock/WrappedText.tsx @@ -3,11 +3,11 @@ import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import {containsOnlyEmojis} from '@libs/EmojiUtils'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; -import variables from '@styles/variables'; -import useWindowDimensions from '@hooks/useWindowDimensions'; type WrappedTextProps = ChildrenProps & { /** Style to be applied to Text */ @@ -50,7 +50,7 @@ function splitLongWord(word: string, maxLength: number): string[] { return [word]; } - return word.match(new RegExp(`.{1,${maxLength}}`, 'g')) || []; + return word.match(new RegExp(`.{1,${maxLength}}`, 'g')) ?? []; } function WrappedText({children, wordStyles, textStyles}: WrappedTextProps) { From 44d0d7d1ab68a87b221a62bc6d347f1afe22dc59 Mon Sep 17 00:00:00 2001 From: cdOut <88325488+cdOut@users.noreply.github.com> Date: Wed, 11 Sep 2024 15:01:32 +0200 Subject: [PATCH 006/289] memoize fontSize and infer it from textStyles --- .../InlineCodeBlock/WrappedText.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/components/InlineCodeBlock/WrappedText.tsx b/src/components/InlineCodeBlock/WrappedText.tsx index b331ce902fcb..f8adb1cd25d4 100644 --- a/src/components/InlineCodeBlock/WrappedText.tsx +++ b/src/components/InlineCodeBlock/WrappedText.tsx @@ -1,4 +1,4 @@ -import React, {Fragment} from 'react'; +import React, {Fragment, useMemo} from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import Text from '@components/Text'; @@ -53,6 +53,21 @@ function splitLongWord(word: string, maxLength: number): string[] { return word.match(new RegExp(`.{1,${maxLength}}`, 'g')) ?? []; } +function getFontSizeFromStyles(textStyles: StyleProp): number { + if (Array.isArray(textStyles)) { + for (let style of textStyles) { + if (style && 'fontSize' in style && style.fontSize) { + return style.fontSize; + } + } + } else if (textStyles && 'fontSize' in textStyles && textStyles.fontSize) { + return textStyles.fontSize; + } + + // if we cannot infer fontSize from styles, a default value is returned + return variables.fontSizeLabel; +} + function WrappedText({children, wordStyles, textStyles}: WrappedTextProps) { const styles = useThemeStyles(); const {windowWidth} = useWindowDimensions(); @@ -61,10 +76,12 @@ function WrappedText({children, wordStyles, textStyles}: WrappedTextProps) { return null; } - const charWidth = variables.fontSizeLabel * variables.fontSizeToWidthRatio; - const charsPerLine = Math.floor(windowWidth / charWidth); + const textMatrix = useMemo(() => { + const fontSize = getFontSizeFromStyles(textStyles); + const charsPerLine = Math.floor(windowWidth / (fontSize * variables.fontSizeToWidthRatio)); - const textMatrix = getTextMatrix(children).map((row) => row.flatMap((word) => splitLongWord(word, charsPerLine))); + return getTextMatrix(children).map((row) => row.flatMap((word) => splitLongWord(word, charsPerLine))); + }, [textStyles]); return textMatrix.map((rowText, rowIndex) => ( Date: Wed, 11 Sep 2024 15:37:31 +0200 Subject: [PATCH 007/289] fix lint errors and refactor code --- src/components/InlineCodeBlock/WrappedText.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/components/InlineCodeBlock/WrappedText.tsx b/src/components/InlineCodeBlock/WrappedText.tsx index f8adb1cd25d4..922262e01b8b 100644 --- a/src/components/InlineCodeBlock/WrappedText.tsx +++ b/src/components/InlineCodeBlock/WrappedText.tsx @@ -55,7 +55,7 @@ function splitLongWord(word: string, maxLength: number): string[] { function getFontSizeFromStyles(textStyles: StyleProp): number { if (Array.isArray(textStyles)) { - for (let style of textStyles) { + for (const style of textStyles) { if (style && 'fontSize' in style && style.fontSize) { return style.fontSize; } @@ -72,16 +72,17 @@ function WrappedText({children, wordStyles, textStyles}: WrappedTextProps) { const styles = useThemeStyles(); const {windowWidth} = useWindowDimensions(); - if (typeof children !== 'string') { - return null; - } - const textMatrix = useMemo(() => { const fontSize = getFontSizeFromStyles(textStyles); const charsPerLine = Math.floor(windowWidth / (fontSize * variables.fontSizeToWidthRatio)); - return getTextMatrix(children).map((row) => row.flatMap((word) => splitLongWord(word, charsPerLine))); - }, [textStyles]); + const childrenString = typeof children === 'string' ? children : ''; + return getTextMatrix(childrenString).map((row) => row.flatMap((word) => splitLongWord(word, charsPerLine))); + }, [textStyles, children, windowWidth]); + + if (typeof children !== 'string') { + return null; + } return textMatrix.map((rowText, rowIndex) => ( Date: Thu, 19 Sep 2024 19:01:24 +0200 Subject: [PATCH 008/289] feat: unread marker for messages while offline --- src/hooks/useNetwork.ts | 31 +++++++++++++++++++-- src/pages/home/report/ReportActionsList.tsx | 26 +++++++++++++++-- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/hooks/useNetwork.ts b/src/hooks/useNetwork.ts index 950d0592b59c..910f99896d4d 100644 --- a/src/hooks/useNetwork.ts +++ b/src/hooks/useNetwork.ts @@ -1,17 +1,20 @@ -import {useContext, useEffect, useRef} from 'react'; +import {useContext, useEffect, useMemo, useRef, useState} from 'react'; import {NetworkContext} from '@components/OnyxProvider'; +import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; +import useLocalize from './useLocalize'; type UseNetworkProps = { onReconnect?: () => void; }; -type UseNetwork = {isOffline: boolean}; +type UseNetwork = {isOffline: boolean; lastOfflineAt?: Date; lastOnlineAt?: Date}; export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = {}): UseNetwork { const callback = useRef(onReconnect); callback.current = onReconnect; + const {preferredLocale} = useLocalize(); const {isOffline, networkStatus} = useContext(NetworkContext) ?? {...CONST.DEFAULT_NETWORK_DATA, networkStatus: CONST.NETWORK.NETWORK_STATUS.UNKNOWN}; const prevOfflineStatusRef = useRef(isOffline); useEffect(() => { @@ -29,6 +32,28 @@ export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = { prevOfflineStatusRef.current = isOffline; }, [isOffline]); + const isOfflineResult = useMemo(() => (networkStatus === CONST.NETWORK.NETWORK_STATUS.UNKNOWN ? false : isOffline), [isOffline, networkStatus]); + + const [lastOfflineAt, setLastOfflineAt] = useState(() => (isOfflineResult ? DateUtils.getLocalDateFromDatetime(preferredLocale) : undefined)); + useEffect(() => { + if (!isOffline) { + return; + } + setLastOfflineAt(DateUtils.getLocalDateFromDatetime(preferredLocale)); + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOffline]); + + const [lastOnlineAt, setLastOnlineAt] = useState(() => (isOfflineResult ? undefined : DateUtils.getLocalDateFromDatetime(preferredLocale))); + useEffect(() => { + if (isOffline) { + return; + } + setLastOnlineAt(DateUtils.getLocalDateFromDatetime(preferredLocale)); + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOffline]); + // If the network status is undefined, we don't treat it as offline. Otherwise, we utilize the isOffline prop. - return {isOffline: networkStatus === CONST.NETWORK.NETWORK_STATUS.UNKNOWN ? false : isOffline}; + return {isOffline: isOfflineResult, lastOfflineAt, lastOnlineAt}; } diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 6828e10e7e3b..ed44677ad1c5 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -122,6 +122,19 @@ function keyExtractor(item: OnyxTypes.ReportAction): string { return item.reportActionID; } +function wasMessageReceivedWhileOffline(message: OnyxTypes.ReportAction, offlineLastAt: Date | undefined, onlineLastAt: Date | undefined, locale: OnyxTypes.Locale): boolean { + if (!onlineLastAt || !offlineLastAt) { + return false; + } + + const messageCreatedAt = DateUtils.getLocalDateFromDatetime(locale, message.created); + + if (messageCreatedAt > offlineLastAt && messageCreatedAt <= onlineLastAt) { + return true; + } + return false; +} + function isMessageUnread(message: OnyxTypes.ReportAction, lastReadTime?: string): boolean { if (!lastReadTime) { return !ReportActionsUtils.isCreatedAction(message); @@ -161,7 +174,8 @@ function ReportActionsList({ const {windowHeight} = useWindowDimensions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {isOffline} = useNetwork(); + const {preferredLocale} = useLocalize(); + const {isOffline, lastOfflineAt, lastOnlineAt} = useNetwork(); const route = useRoute>(); const reportScrollManager = useReportScrollManager(); const userActiveSince = useRef(DateUtils.getDBTime()); @@ -222,6 +236,14 @@ function ReportActionsList({ const unreadMarkerReportActionID = useMemo(() => { const shouldDisplayNewMarker = (reportAction: OnyxTypes.ReportAction, index: number): boolean => { const nextMessage = sortedVisibleReportActions[index + 1]; + + const isCurrentMessageOffline = wasMessageReceivedWhileOffline(reportAction, lastOfflineAt, lastOnlineAt, preferredLocale); + const isNextMessageOffline = (nextMessage && wasMessageReceivedWhileOffline(nextMessage, lastOfflineAt, lastOnlineAt, preferredLocale)) || !nextMessage; + + if (isCurrentMessageOffline && !isNextMessageOffline) { + return true; + } + const isCurrentMessageUnread = isMessageUnread(reportAction, unreadMarkerTime); const isNextMessageRead = !nextMessage || !isMessageUnread(nextMessage, unreadMarkerTime); let shouldDisplay = isCurrentMessageUnread && isNextMessageRead && !ReportActionsUtils.shouldHideNewMarker(reportAction); @@ -245,7 +267,7 @@ function ReportActionsList({ } return null; - }, [accountID, sortedVisibleReportActions, unreadMarkerTime, messageManuallyMarkedUnread]); + }, [sortedVisibleReportActions, lastOfflineAt, lastOnlineAt, preferredLocale, unreadMarkerTime, messageManuallyMarkedUnread, accountID]); /** * Subscribe to read/unread events and update our unreadMarkerTime From 3c91994a84f00a7b00df6ff81e801d4d8f7f5d02 Mon Sep 17 00:00:00 2001 From: Wildan Muhlis Date: Sat, 21 Sep 2024 13:47:17 +0700 Subject: [PATCH 009/289] Calculate routes for pending transaction backup --- src/CONST.ts | 6 + src/hooks/useFetchRoute.ts | 7 +- src/libs/API/types.ts | 1 + src/libs/actions/Transaction.ts | 268 +++++++++++++++++- .../step/IOURequestStepConfirmation.tsx | 2 +- .../request/step/IOURequestStepDistance.tsx | 7 +- src/types/utils/TransactionStateType.ts | 6 + 7 files changed, 285 insertions(+), 12 deletions(-) create mode 100644 src/types/utils/TransactionStateType.ts diff --git a/src/CONST.ts b/src/CONST.ts index 5a0c6f395eb4..87ca20a5d020 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5775,6 +5775,12 @@ const CONST = { REPORT_ACTIONS: 'actions', REPORT_ACTION_PREVIEW: 'preview', }, + + TRANSACTION_STATE: { + CURRENT: 'current', + DRAFT: 'draft', + BACKUP: 'backup', + } } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/hooks/useFetchRoute.ts b/src/hooks/useFetchRoute.ts index 4222f9f0183c..650734b68dc3 100644 --- a/src/hooks/useFetchRoute.ts +++ b/src/hooks/useFetchRoute.ts @@ -1,7 +1,6 @@ import isEqual from 'lodash/isEqual'; import {useEffect} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; -import * as IOUUtils from '@libs/IOUUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import * as TransactionAction from '@userActions/Transaction'; import type {IOUAction} from '@src/CONST'; @@ -9,8 +8,10 @@ import type {Transaction} from '@src/types/onyx'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; import useNetwork from './useNetwork'; import usePrevious from './usePrevious'; +import TransactionState from '@src/types/utils/TransactionStateType'; +import CONST from '@src/CONST'; -export default function useFetchRoute(transaction: OnyxEntry, waypoints: WaypointCollection | undefined, action: IOUAction) { +export default function useFetchRoute(transaction: OnyxEntry, waypoints: WaypointCollection | undefined, action: IOUAction, transactionState: TransactionState = CONST.TRANSACTION_STATE.CURRENT) { const {isOffline} = useNetwork(); const hasRouteError = !!transaction?.errorFields?.route; const hasRoute = TransactionUtils.hasRoute(transaction); @@ -27,7 +28,7 @@ export default function useFetchRoute(transaction: OnyxEntry, waypo return; } - TransactionAction.getRoute(transaction.transactionID, validatedWaypoints, IOUUtils.shouldUseTransactionDraft(action)); + TransactionAction.getRoute(transaction.transactionID, validatedWaypoints, transactionState); }, [shouldFetchRoute, transaction?.transactionID, validatedWaypoints, isOffline, action]); return {shouldFetchRoute, validatedWaypoints}; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 37bdf6b81d6e..1976cce2d13a 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -842,6 +842,7 @@ const READ_COMMANDS = { SEND_PERFORMANCE_TIMING: 'SendPerformanceTiming', GET_ROUTE: 'GetRoute', GET_ROUTE_FOR_DRAFT: 'GetRouteForDraft', + GET_ROUTE_FOR_BACKUP: 'GetRouteForBackup', GET_STATEMENT_PDF: 'GetStatementPDF', OPEN_ONFIDO_FLOW: 'OpenOnfidoFlow', OPEN_INITIAL_SETTINGS_PAGE: 'OpenInitialSettingsPage', diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index e19251b62ce8..506c9a6ece4c 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -16,6 +16,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, RecentWaypoint, ReportAction, ReportActions, ReviewDuplicates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; import type {OnyxData} from '@src/types/onyx/Request'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; +import type TransactionState from '@src/types/utils/TransactionStateType'; let recentWaypoints: RecentWaypoint[] = []; Onyx.connect({ @@ -199,13 +200,27 @@ function removeWaypoint(transaction: OnyxEntry, currentIndex: strin return Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.transactionID}`, newTransaction); } -function getOnyxDataForRouteRequest(transactionID: string, isDraft = false): OnyxData { +function getOnyxDataForRouteRequest(transactionID: string, transactionState: TransactionState = CONST.TRANSACTION_STATE.CURRENT): OnyxData { + let keyPrefix; + switch (transactionState) { + case CONST.TRANSACTION_STATE.DRAFT: + keyPrefix = ONYXKEYS.COLLECTION.TRANSACTION_DRAFT; + break; + case CONST.TRANSACTION_STATE.BACKUP: + keyPrefix = ONYXKEYS.COLLECTION.TRANSACTION_BACKUP; + break; + case CONST.TRANSACTION_STATE.CURRENT: + default: + keyPrefix = ONYXKEYS.COLLECTION.TRANSACTION; + break; + } + return { optimisticData: [ { // Clears any potentially stale error messages from fetching the route onyxMethod: Onyx.METHOD.MERGE, - key: `${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + key: `${keyPrefix}${transactionID}`, value: { comment: { isLoading: true, @@ -220,7 +235,7 @@ function getOnyxDataForRouteRequest(transactionID: string, isDraft = false): Ony successData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + key: `${keyPrefix}${transactionID}`, value: { comment: { isLoading: false, @@ -231,7 +246,7 @@ function getOnyxDataForRouteRequest(transactionID: string, isDraft = false): Ony failureData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + key: `${keyPrefix}${transactionID}`, value: { comment: { isLoading: false, @@ -242,19 +257,258 @@ function getOnyxDataForRouteRequest(transactionID: string, isDraft = false): Ony }; } +function mockGetBackupRoute(transactionID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`, { + "routes": { + "route0": { + "distance": 154384.844, + "geometry": { + "coordinates": [ + [ + 107.61964, + -6.917596 + ], + [ + 107.621458, + -6.918055 + ], + [ + 107.621155, + -6.919264 + ], + [ + 107.621862, + -6.92007 + ], + [ + 107.617256, + -6.922397 + ], + [ + 107.586943, + -6.918774 + ], + [ + 107.585588, + -6.926812 + ], + [ + 107.552806, + -6.938898 + ], + [ + 107.541552, + -6.912423 + ], + [ + 107.507715, + -6.867412 + ], + [ + 107.504802, + -6.856305 + ], + [ + 107.50362, + -6.841229 + ], + [ + 107.471934, + -6.809238 + ], + [ + 107.447898, + -6.798421 + ], + [ + 107.439988, + -6.779357 + ], + [ + 107.440242, + -6.74621 + ], + [ + 107.431506, + -6.708104 + ], + [ + 107.442126, + -6.666515 + ], + [ + 107.427061, + -6.653178 + ], + [ + 107.428849, + -6.642103 + ], + [ + 107.421426, + -6.611517 + ], + [ + 107.434251, + -6.579176 + ], + [ + 107.424954, + -6.559044 + ], + [ + 107.446783, + -6.484271 + ], + [ + 107.444693, + -6.467799 + ], + [ + 107.426815, + -6.442001 + ], + [ + 107.424102, + -6.423177 + ], + [ + 107.402604, + -6.392909 + ], + [ + 107.334314, + -6.355931 + ], + [ + 107.277154, + -6.349437 + ], + [ + 107.256766, + -6.349048 + ], + [ + 107.235902, + -6.355155 + ], + [ + 107.206996, + -6.349584 + ], + [ + 107.099379, + -6.29197 + ], + [ + 106.981406, + -6.249348 + ], + [ + 106.957583, + -6.256527 + ], + [ + 106.916207, + -6.258484 + ], + [ + 106.889886, + -6.244657 + ], + [ + 106.880764, + -6.247631 + ], + [ + 106.833371, + -6.240614 + ], + [ + 106.815565, + -6.224157 + ], + [ + 106.811859, + -6.219031 + ], + [ + 106.817977, + -6.214838 + ], + [ + 106.822696, + -6.195377 + ], + [ + 106.822922, + -6.194464 + ] + ], + "type": "LineString" + } + } + }, + "comment": { + "waypoints": { + "waypoint0": { + "keyForList": "Bandung_1726892371069", + "lat": -6.9174639, + "lng": 107.6191228, + "address": "Bandung, Bandung City, West Java, Indonesia", + "name": "Bandung" + }, + "waypoint1": { + "keyForList": "Jakarta_1726892375416", + "lat": -6.1944491, + "lng": 106.8229198, + "address": "Jakarta, Indonesia", + "name": "Jakarta" + } + }, + "customUnit": { + "quantity": 154384.844 + } + } + } + ) +} + + /** * Gets the route for a set of waypoints * Used so we can generate a map view of the provided waypoints */ -function getRoute(transactionID: string, waypoints: WaypointCollection, isDraft: boolean) { + + +function getRoute(transactionID: string, waypoints: WaypointCollection, routeType: TransactionState = CONST.TRANSACTION_STATE.CURRENT) { + if (routeType === CONST.TRANSACTION_STATE.BACKUP) + { + mockGetBackupRoute(transactionID); + } + const parameters: GetRouteParams = { transactionID, waypoints: JSON.stringify(waypoints), }; - API.read(isDraft ? READ_COMMANDS.GET_ROUTE_FOR_DRAFT : READ_COMMANDS.GET_ROUTE, parameters, getOnyxDataForRouteRequest(transactionID, isDraft)); -} + let command; + switch (routeType) { + case 'draft': + command = READ_COMMANDS.GET_ROUTE_FOR_DRAFT; + break; + case 'current': + command = READ_COMMANDS.GET_ROUTE; + break; + case 'backup': + command = READ_COMMANDS.GET_ROUTE_FOR_BACKUP; + break; + default: + throw new Error('Invalid route type'); + } + API.read(command, parameters, getOnyxDataForRouteRequest(transactionID, routeType === 'draft')); +} /** * Updates all waypoints stored in the transaction specified by the provided transactionID. * diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 6c1457abef62..5c7d697c01be 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -161,7 +161,7 @@ function IOURequestStepConfirmation({ const isPolicyExpenseChat = useMemo(() => participants?.some((participant) => participant.isPolicyExpenseChat), [participants]); const formHasBeenSubmitted = useRef(false); - useFetchRoute(transaction, transaction?.comment?.waypoints, action); + useFetchRoute(transaction, transaction?.comment?.waypoints, action, IOUUtils.shouldUseTransactionDraft(action) ? CONST.TRANSACTION_STATE.DRAFT : CONST.TRANSACTION_STATE.CURRENT); useEffect(() => { const policyExpenseChat = participants?.find((participant) => participant.isPolicyExpenseChat); diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 14597df8e313..12d2cf375ca1 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -90,7 +90,12 @@ function IOURequestStepDistance({ }, [optimisticWaypoints, transaction], ); - const {shouldFetchRoute, validatedWaypoints} = useFetchRoute(transaction, waypoints, action); + + const backupWaypoints = !!transactionBackup?.pendingFields?.waypoints ? transactionBackup?.comment?.waypoints : undefined; + + const { shouldFetchRoute, validatedWaypoints } = useFetchRoute(transaction, waypoints, action, IOUUtils.shouldUseTransactionDraft(action) ? CONST.TRANSACTION_STATE.DRAFT : CONST.TRANSACTION_STATE.CURRENT); + useFetchRoute(transactionBackup, backupWaypoints, action, CONST.TRANSACTION_STATE.BACKUP); + const waypointsList = Object.keys(waypoints); const previousWaypoints = usePrevious(waypoints); const numberOfWaypoints = Object.keys(waypoints).length; diff --git a/src/types/utils/TransactionStateType.ts b/src/types/utils/TransactionStateType.ts new file mode 100644 index 000000000000..81c1438d873c --- /dev/null +++ b/src/types/utils/TransactionStateType.ts @@ -0,0 +1,6 @@ +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; + +type TransactionStateType = ValueOf; + +export default TransactionStateType; \ No newline at end of file From 5f511d271bcf33beddf4c439d5f9454e2df4d41a Mon Sep 17 00:00:00 2001 From: Wildan Muhlis Date: Sat, 21 Sep 2024 15:44:00 +0700 Subject: [PATCH 010/289] fix incorrect param input --- src/libs/actions/Transaction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 506c9a6ece4c..1222e6455770 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -507,7 +507,7 @@ function getRoute(transactionID: string, waypoints: WaypointCollection, routeTyp throw new Error('Invalid route type'); } - API.read(command, parameters, getOnyxDataForRouteRequest(transactionID, routeType === 'draft')); + API.read(command, parameters, getOnyxDataForRouteRequest(transactionID, routeType)); } /** * Updates all waypoints stored in the transaction specified by the provided transactionID. From a53236d45b59a374fb0dfd43f879a6e4456dcba9 Mon Sep 17 00:00:00 2001 From: Wildan Muhlis Date: Sat, 21 Sep 2024 16:05:33 +0700 Subject: [PATCH 011/289] return if use mock data --- src/libs/actions/Transaction.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 1222e6455770..26eae38d2588 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -482,9 +482,11 @@ function mockGetBackupRoute(transactionID: string) { function getRoute(transactionID: string, waypoints: WaypointCollection, routeType: TransactionState = CONST.TRANSACTION_STATE.CURRENT) { + /** For testing, remove when new API endpoint ready, waypoints: Bandung, Jakarta */ if (routeType === CONST.TRANSACTION_STATE.BACKUP) { mockGetBackupRoute(transactionID); + return; } const parameters: GetRouteParams = { From 6d4ff5a05d95a004904fad4fafb4baaf35132f48 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Sat, 21 Sep 2024 20:55:59 +0200 Subject: [PATCH 012/289] add comments --- src/hooks/useNetwork.ts | 4 +++- src/pages/home/report/ReportActionsList.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/hooks/useNetwork.ts b/src/hooks/useNetwork.ts index 910f99896d4d..c95c86edb9fb 100644 --- a/src/hooks/useNetwork.ts +++ b/src/hooks/useNetwork.ts @@ -32,8 +32,10 @@ export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = { prevOfflineStatusRef.current = isOffline; }, [isOffline]); + // If the network status is undefined, we don't treat it as offline. Otherwise, we utilize the isOffline prop. const isOfflineResult = useMemo(() => (networkStatus === CONST.NETWORK.NETWORK_STATUS.UNKNOWN ? false : isOffline), [isOffline, networkStatus]); + // Used to get the last time the user went offline const [lastOfflineAt, setLastOfflineAt] = useState(() => (isOfflineResult ? DateUtils.getLocalDateFromDatetime(preferredLocale) : undefined)); useEffect(() => { if (!isOffline) { @@ -44,6 +46,7 @@ export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOffline]); + // Used to get the last time the user went back online after being offline. const [lastOnlineAt, setLastOnlineAt] = useState(() => (isOfflineResult ? undefined : DateUtils.getLocalDateFromDatetime(preferredLocale))); useEffect(() => { if (isOffline) { @@ -54,6 +57,5 @@ export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOffline]); - // If the network status is undefined, we don't treat it as offline. Otherwise, we utilize the isOffline prop. return {isOffline: isOfflineResult, lastOfflineAt, lastOnlineAt}; } diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index b8e06b08cc42..2281b3798d5c 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -238,9 +238,9 @@ function ReportActionsList({ const shouldDisplayNewMarker = (reportAction: OnyxTypes.ReportAction, index: number): boolean => { const nextMessage = sortedVisibleReportActions[index + 1]; + // If the user recevied new messages while being offline, we want to display the unread marker above the first offline message. const isCurrentMessageOffline = wasMessageReceivedWhileOffline(reportAction, lastOfflineAt, lastOnlineAt, preferredLocale); const isNextMessageOffline = (nextMessage && wasMessageReceivedWhileOffline(nextMessage, lastOfflineAt, lastOnlineAt, preferredLocale)) || !nextMessage; - if (isCurrentMessageOffline && !isNextMessageOffline) { return true; } From 077ba40d0393e364a60731b23dce13aefca16291 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Sat, 21 Sep 2024 20:58:20 +0200 Subject: [PATCH 013/289] add comment and fix logic --- src/hooks/useNetwork.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/hooks/useNetwork.ts b/src/hooks/useNetwork.ts index c95c86edb9fb..5a686b5ab138 100644 --- a/src/hooks/useNetwork.ts +++ b/src/hooks/useNetwork.ts @@ -15,28 +15,29 @@ export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = { callback.current = onReconnect; const {preferredLocale} = useLocalize(); - const {isOffline, networkStatus} = useContext(NetworkContext) ?? {...CONST.DEFAULT_NETWORK_DATA, networkStatus: CONST.NETWORK.NETWORK_STATUS.UNKNOWN}; - const prevOfflineStatusRef = useRef(isOffline); + const {isOffline: isOfflineContext, networkStatus} = useContext(NetworkContext) ?? {...CONST.DEFAULT_NETWORK_DATA, networkStatus: CONST.NETWORK.NETWORK_STATUS.UNKNOWN}; + const prevOfflineStatusRef = useRef(isOfflineContext); useEffect(() => { // If we were offline before and now we are not offline then we just reconnected - const didReconnect = prevOfflineStatusRef.current && !isOffline; + const didReconnect = prevOfflineStatusRef.current && !isOfflineContext; if (!didReconnect) { return; } callback.current(); - }, [isOffline]); + }, [isOfflineContext]); useEffect(() => { // Used to store previous prop values to compare on next render - prevOfflineStatusRef.current = isOffline; - }, [isOffline]); + prevOfflineStatusRef.current = isOfflineContext; + }, [isOfflineContext]); // If the network status is undefined, we don't treat it as offline. Otherwise, we utilize the isOffline prop. - const isOfflineResult = useMemo(() => (networkStatus === CONST.NETWORK.NETWORK_STATUS.UNKNOWN ? false : isOffline), [isOffline, networkStatus]); + const isOffline = useMemo(() => (networkStatus === CONST.NETWORK.NETWORK_STATUS.UNKNOWN ? false : isOfflineContext), [isOfflineContext, networkStatus]); - // Used to get the last time the user went offline - const [lastOfflineAt, setLastOfflineAt] = useState(() => (isOfflineResult ? DateUtils.getLocalDateFromDatetime(preferredLocale) : undefined)); + // Used to get the last time the user went offline. + // Set to a JS Date object if the user was offline before, otherwise undefined. + const [lastOfflineAt, setLastOfflineAt] = useState(() => (isOffline ? DateUtils.getLocalDateFromDatetime(preferredLocale) : undefined)); useEffect(() => { if (!isOffline) { return; @@ -47,7 +48,8 @@ export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = { }, [isOffline]); // Used to get the last time the user went back online after being offline. - const [lastOnlineAt, setLastOnlineAt] = useState(() => (isOfflineResult ? undefined : DateUtils.getLocalDateFromDatetime(preferredLocale))); + // Set to a JS Date object if the user was online before, otherwise undefined. + const [lastOnlineAt, setLastOnlineAt] = useState(() => (isOffline ? undefined : DateUtils.getLocalDateFromDatetime(preferredLocale))); useEffect(() => { if (isOffline) { return; @@ -57,5 +59,5 @@ export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOffline]); - return {isOffline: isOfflineResult, lastOfflineAt, lastOnlineAt}; + return {isOffline, lastOfflineAt, lastOnlineAt}; } From 7b81e1b08a1fe42eb2380b3858289f9bdbfa720b Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Wed, 25 Sep 2024 18:45:14 +0200 Subject: [PATCH 014/289] save optimistic report --- src/libs/actions/Report.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index b038f16d003d..e423e3c082b4 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1363,6 +1363,7 @@ function handleReportChanged(report: OnyxEntry) { if (report?.reportID && report.preexistingReportID) { let callback = () => { Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, null); + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.preexistingReportID}`, {...report, reportID: report.preexistingReportID, preexistingReportID: null}); Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${report.reportID}`, null); }; // Only re-route them if they are still looking at the optimistically created report From 478848ba2484724d682e404b3912df47fd64c614 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Wed, 25 Sep 2024 18:46:09 +0200 Subject: [PATCH 015/289] skip openReport for preexistingReportID --- src/pages/home/ReportScreen.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index b8b551a345ca..73187f10a67b 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -4,7 +4,7 @@ import type {StackScreenProps} from '@react-navigation/stack'; import lodashIsEqual from 'lodash/isEqual'; import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {FlatList, ViewStyle} from 'react-native'; -import {InteractionManager, View} from 'react-native'; +import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import Banner from '@components/Banner'; @@ -104,6 +104,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro const isFocused = useIsFocused(); const prevIsFocused = usePrevious(isFocused); const firstRenderRef = useRef(true); + const isSkippingOpenReport = useRef(false); const flatListRef = useRef(null); const {canUseDefaultRooms} = usePermissions(); const reactionListRef = useRef(null); @@ -421,6 +422,19 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro Report.updateLastVisitTime(reportID); }, [reportID, isFocused]); + useEffect(() => { + const skipOpenReportListener = DeviceEventEmitter.addListener(`switchToPreExistingReport_${reportID}`, ({preexistingReportID}: {preexistingReportID: string}) => { + if (!preexistingReportID) { + return; + } + isSkippingOpenReport.current = true; + }); + + return () => { + skipOpenReportListener.remove(); + }; + }, [reportID]); + const fetchReportIfNeeded = useCallback(() => { // Report ID will be empty when the reports collection is empty. // This could happen when we are loading the collection for the first time after logging in. @@ -443,6 +457,12 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro if (report && !shouldFetchReport(report) && (isInitialPageReady || isLinkedMessagePageReady)) { return; } + // When creating an optimistic report that already exists, we need to skip openReport + // when replacing the optimistic report with the real one received from the server. + if (isSkippingOpenReport.current) { + isSkippingOpenReport.current = false; + return; + } fetchReport(); }, [report, fetchReport, reportIDFromRoute, isLoadingApp, isInitialPageReady, isLinkedMessagePageReady]); From 00740c4b8c3e62f22d5d88616960f3d0bf78b9d9 Mon Sep 17 00:00:00 2001 From: cdOut <88325488+cdOut@users.noreply.github.com> Date: Thu, 26 Sep 2024 16:36:45 +0200 Subject: [PATCH 016/289] remove manual memoization in favor of react-compiler --- src/components/InlineCodeBlock/WrappedText.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/components/InlineCodeBlock/WrappedText.tsx b/src/components/InlineCodeBlock/WrappedText.tsx index 922262e01b8b..51f6b1ae09f2 100644 --- a/src/components/InlineCodeBlock/WrappedText.tsx +++ b/src/components/InlineCodeBlock/WrappedText.tsx @@ -72,13 +72,11 @@ function WrappedText({children, wordStyles, textStyles}: WrappedTextProps) { const styles = useThemeStyles(); const {windowWidth} = useWindowDimensions(); - const textMatrix = useMemo(() => { - const fontSize = getFontSizeFromStyles(textStyles); - const charsPerLine = Math.floor(windowWidth / (fontSize * variables.fontSizeToWidthRatio)); + const fontSize = useMemo(() => getFontSizeFromStyles(textStyles), [textStyles]); + const childrenString = typeof children === 'string' ? children : ''; + const charsPerLine = useMemo(() => Math.floor(windowWidth / (fontSize * variables.fontSizeToWidthRatio)), [windowWidth, fontSize]); - const childrenString = typeof children === 'string' ? children : ''; - return getTextMatrix(childrenString).map((row) => row.flatMap((word) => splitLongWord(word, charsPerLine))); - }, [textStyles, children, windowWidth]); + const textMatrix = getTextMatrix(childrenString).map((row) => row.flatMap((word) => splitLongWord(word, charsPerLine))); if (typeof children !== 'string') { return null; From 3c4ad0295b385450201e304b6a89d8b67178ec98 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 26 Sep 2024 16:39:45 +0200 Subject: [PATCH 017/289] fix: don't show unread marker for own messages --- src/pages/home/report/ReportActionsList.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 75936411b904..72ef3c459871 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -236,10 +236,12 @@ function ReportActionsList({ const nextMessage = sortedVisibleReportActions[index + 1]; // If the user recevied new messages while being offline, we want to display the unread marker above the first offline message. - const isCurrentMessageOffline = wasMessageReceivedWhileOffline(reportAction, lastOfflineAt, lastOnlineAt, preferredLocale); - const isNextMessageOffline = (nextMessage && wasMessageReceivedWhileOffline(nextMessage, lastOfflineAt, lastOnlineAt, preferredLocale)) || !nextMessage; - if (isCurrentMessageOffline && !isNextMessageOffline) { - return true; + if (!ReportActionsUtils.wasActionTakenByCurrentUser(reportAction)) { + const isCurrentMessageOffline = wasMessageReceivedWhileOffline(reportAction, lastOfflineAt, lastOnlineAt, preferredLocale); + const isNextMessageOffline = (nextMessage && wasMessageReceivedWhileOffline(nextMessage, lastOfflineAt, lastOnlineAt, preferredLocale)) || !nextMessage; + if (isCurrentMessageOffline && !isNextMessageOffline) { + return true; + } } const isCurrentMessageUnread = isMessageUnread(reportAction, unreadMarkerTime); From 744e54c3f0aa2bc58949b09903aa179e19e1a62d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Tue, 1 Oct 2024 15:23:05 +0200 Subject: [PATCH 018/289] re-structure offline message detection logic --- src/pages/home/report/ReportActionsList.tsx | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 3d0afb393806..e29abe64f90a 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -228,26 +228,31 @@ function ReportActionsList({ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [report.reportID]); + const isMessageOffline = useCallback( + (m: OnyxTypes.ReportAction) => wasMessageReceivedWhileOffline(m, lastOfflineAt, lastOnlineAt, preferredLocale), + [lastOfflineAt, lastOnlineAt, preferredLocale], + ); + /** * The reportActionID the unread marker should display above */ const unreadMarkerReportActionID = useMemo(() => { - const shouldDisplayNewMarker = (reportAction: OnyxTypes.ReportAction, index: number): boolean => { + const shouldDisplayNewMarker = (message: OnyxTypes.ReportAction, index: number): boolean => { const nextMessage = sortedVisibleReportActions[index + 1]; // If the user recevied new messages while being offline, we want to display the unread marker above the first offline message. - if (!ReportActionsUtils.wasActionTakenByCurrentUser(reportAction)) { - const isCurrentMessageOffline = wasMessageReceivedWhileOffline(reportAction, lastOfflineAt, lastOnlineAt, preferredLocale); - const isNextMessageOffline = (nextMessage && wasMessageReceivedWhileOffline(nextMessage, lastOfflineAt, lastOnlineAt, preferredLocale)) || !nextMessage; + if (!ReportActionsUtils.wasActionTakenByCurrentUser(message)) { + const isCurrentMessageOffline = isMessageOffline(message); + const isNextMessageOffline = nextMessage && !ReportActionsUtils.wasActionTakenByCurrentUser(nextMessage) && isMessageOffline(nextMessage); if (isCurrentMessageOffline && !isNextMessageOffline) { return true; } } - const isCurrentMessageUnread = isMessageUnread(reportAction, unreadMarkerTime); + const isCurrentMessageUnread = isMessageUnread(message, unreadMarkerTime); const isNextMessageRead = !nextMessage || !isMessageUnread(nextMessage, unreadMarkerTime); - const shouldDisplay = isCurrentMessageUnread && isNextMessageRead && !ReportActionsUtils.shouldHideNewMarker(reportAction); - const isWithinVisibleThreshold = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? reportAction.created < (userActiveSince.current ?? '') : true; + const shouldDisplay = isCurrentMessageUnread && isNextMessageRead && !ReportActionsUtils.shouldHideNewMarker(message); + const isWithinVisibleThreshold = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? message.created < (userActiveSince.current ?? '') : true; return shouldDisplay && isWithinVisibleThreshold; }; @@ -260,7 +265,7 @@ function ReportActionsList({ } return null; - }, [sortedVisibleReportActions, lastOfflineAt, lastOnlineAt, preferredLocale, unreadMarkerTime]); + }, [sortedVisibleReportActions, unreadMarkerTime, isMessageOffline]); /** * Subscribe to read/unread events and update our unreadMarkerTime From 0910231da82b97cea870fe3807af5b937c5e007b Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Fri, 4 Oct 2024 08:29:59 +0530 Subject: [PATCH 019/289] Use localize with libs/memoize --- src/libs/Localize/index.ts | 45 ++++++-------------------------------- 1 file changed, 7 insertions(+), 38 deletions(-) diff --git a/src/libs/Localize/index.ts b/src/libs/Localize/index.ts index bd8a34406846..d342b158657f 100644 --- a/src/libs/Localize/index.ts +++ b/src/libs/Localize/index.ts @@ -1,7 +1,7 @@ import * as RNLocalize from 'react-native-localize'; import Onyx from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; import Log from '@libs/Log'; +import memoize from '@libs/memoize'; import type {MessageElementBase, MessageTextElement} from '@libs/MessageElement'; import Config from '@src/CONFIG'; import CONST from '@src/CONST'; @@ -45,28 +45,6 @@ function init() { }, {}); } -/** - * Map to store translated values for each locale. - * This is used to avoid translating the same phrase multiple times. - * - * The data is stored in the following format: - * - * { - * "en": { - * "name": "Name", - * } - * - * Note: We are not storing any translated values for phrases with variables, - * as they have higher chance of being unique, so we'll end up wasting space - * in our cache. - */ -const translationCache = new Map, Map>( - Object.values(CONST.LOCALES).reduce((cache, locale) => { - cache.push([locale, new Map()]); - return cache; - }, [] as Array<[ValueOf, Map]>), -); - /** * Helper function to get the translated string for given * locale and phrase. This function is used to avoid @@ -86,18 +64,6 @@ function getTranslatedPhrase( fallbackLanguage: 'en' | 'es' | null, ...parameters: TranslationParameters ): string | null { - // Get the cache for the above locale - const cacheForLocale = translationCache.get(language); - - // Directly access and assign the translated value from the cache, instead of - // going through map.has() and map.get() to avoid multiple lookups. - const valueFromCache = cacheForLocale?.get(phraseKey); - - // If the phrase is already translated, return the translated value - if (valueFromCache) { - return valueFromCache; - } - const translatedPhrase = translations?.[language]?.[phraseKey]; if (translatedPhrase) { @@ -138,8 +104,6 @@ function getTranslatedPhrase( return translateResult.other(phraseObject.count); } - // We set the translated value in the cache only for the phrases without parameters. - cacheForLocale?.set(phraseKey, translatedPhrase); return translatedPhrase; } @@ -162,6 +126,11 @@ function getTranslatedPhrase( return getTranslatedPhrase(CONST.LOCALES.DEFAULT, phraseKey, null, ...parameters); } +const memoizedGetTranslatedPhrase = memoize(getTranslatedPhrase, { + monitoringName: 'getTranslatedPhrase', + transformKey: ([language, phraseKey, fallbackLanguage, ...parameters]) => `${language}-${phraseKey}-${fallbackLanguage}-${parameters.length > 0 ? JSON.stringify(parameters.at(0)) : ''}`, +}); + /** * Return translated string for given locale and phrase * @@ -174,7 +143,7 @@ function translate(desiredLanguage: 'en' | 'es' // Phrase is not found in full locale, search it in fallback language e.g. es const languageAbbreviation = desiredLanguage.substring(0, 2) as 'en' | 'es'; - const translatedPhrase = getTranslatedPhrase(language, path, languageAbbreviation, ...parameters); + const translatedPhrase = memoizedGetTranslatedPhrase(language, path, languageAbbreviation, ...parameters); if (translatedPhrase !== null && translatedPhrase !== undefined) { return translatedPhrase; } From 3cec7fdda80b3c0ef5312c620d8048ad469092f8 Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Mon, 14 Oct 2024 13:49:23 +0900 Subject: [PATCH 020/289] Added keyFilter to not cache dynamic keys --- src/libs/Localize/index.ts | 7 +++++-- src/libs/memoize/index.ts | 11 +++++++++++ src/libs/memoize/types.ts | 7 +++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/libs/Localize/index.ts b/src/libs/Localize/index.ts index d342b158657f..4a8b0bf168d4 100644 --- a/src/libs/Localize/index.ts +++ b/src/libs/Localize/index.ts @@ -9,6 +9,7 @@ import translations from '@src/languages/translations'; import type {PluralForm, TranslationParameters, TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Locale} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import LocaleListener from './LocaleListener'; import BaseLocaleListener from './LocaleListener/BaseLocaleListener'; @@ -127,8 +128,10 @@ function getTranslatedPhrase( } const memoizedGetTranslatedPhrase = memoize(getTranslatedPhrase, { - monitoringName: 'getTranslatedPhrase', - transformKey: ([language, phraseKey, fallbackLanguage, ...parameters]) => `${language}-${phraseKey}-${fallbackLanguage}-${parameters.length > 0 ? JSON.stringify(parameters.at(0)) : ''}`, + maxArgs: 2, + equality: 'shallow', + // eslint-disable-next-line @typescript-eslint/no-unused-vars + keyFilter: ([language, phraseKey, fallbackLanguage, ...parameters]) => !isEmptyObject(parameters.at(0)), }); /** diff --git a/src/libs/memoize/index.ts b/src/libs/memoize/index.ts index f02b1adbf5ba..1b9c78e793db 100644 --- a/src/libs/memoize/index.ts +++ b/src/libs/memoize/index.ts @@ -60,6 +60,17 @@ function memoize; + + statsEntry.trackTime('processingTime', fnTimeStart); + statsEntry.track('didHit', false); + + return result; + } + const truncatedArgs = truncateArgs(args, options.maxArgs); const key = options.transformKey ? options.transformKey(truncatedArgs) : (truncatedArgs as Key); diff --git a/src/libs/memoize/types.ts b/src/libs/memoize/types.ts index 9ee48c9dc790..71479f637c1d 100644 --- a/src/libs/memoize/types.ts +++ b/src/libs/memoize/types.ts @@ -52,6 +52,13 @@ type Options = { * @returns Key to use for caching */ transformKey?: (truncatedArgs: TakeFirst, MaxArgs>) => Key; + + /** + * Checks if the cache should be skipped for the given arguments. + * @param args Tuple of arguments passed to the memoized function. Does not work with constructable (see description). + * @returns boolean to whether to skip cache lookup and execute the function if true + */ + keyFilter?: (args: IsomorphicParameters) => boolean; } & InternalOptions; type ClientOptions = Partial, keyof InternalOptions>>; From c503b78badd18f4c8b4188580b3c5b74b6a246bf Mon Sep 17 00:00:00 2001 From: Eduardo Date: Tue, 15 Oct 2024 16:54:49 +0200 Subject: [PATCH 021/289] Adding conflict resolver for delete comment --- src/libs/Network/SequentialQueue.ts | 11 +- src/libs/actions/PersistedRequests.ts | 17 +- src/libs/actions/Report.ts | 61 +++- src/types/onyx/Request.ts | 22 +- tests/actions/ReportTest.ts | 471 ++++++++++++++++++++++++++ tests/unit/PersistedRequests.ts | 4 +- 6 files changed, 577 insertions(+), 9 deletions(-) diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index 35c7b2bf779a..a7cb948a1242 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -96,7 +96,7 @@ function process(): Promise { pause(); } - PersistedRequests.remove(requestToProcess); + PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess); RequestThrottle.clear(); return process(); }) @@ -104,7 +104,7 @@ function process(): Promise { // On sign out we cancel any in flight requests from the user. Since that user is no longer signed in their requests should not be retried. // Duplicate records don't need to be retried as they just mean the record already exists on the server if (error.name === CONST.ERROR.REQUEST_CANCELLED || error.message === CONST.ERROR.DUPLICATE_RECORD) { - PersistedRequests.remove(requestToProcess); + PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess); RequestThrottle.clear(); return process(); } @@ -113,7 +113,7 @@ function process(): Promise { .then(process) .catch(() => { Onyx.update(requestToProcess.failureData ?? []); - PersistedRequests.remove(requestToProcess); + PersistedRequests.endRequestAndRemoveFromQueue(requestToProcess); RequestThrottle.clear(); return process(); }); @@ -220,6 +220,11 @@ function push(newRequest: OnyxRequest) { PersistedRequests.save(newRequest); } else if (conflictAction.type === 'replace') { PersistedRequests.update(conflictAction.index, newRequest); + } else if (conflictAction.type === 'delete') { + PersistedRequests.deleteRequestsByIndices(conflictAction.indices); + if (conflictAction.pushNewRequest) { + PersistedRequests.save(newRequest); + } } else { Log.info(`[SequentialQueue] No action performed to command ${newRequest.command} and it will be ignored.`); } diff --git a/src/libs/actions/PersistedRequests.ts b/src/libs/actions/PersistedRequests.ts index fc14e8c2303b..10003b8b4b5e 100644 --- a/src/libs/actions/PersistedRequests.ts +++ b/src/libs/actions/PersistedRequests.ts @@ -53,7 +53,7 @@ function save(requestToPersist: Request) { }); } -function remove(requestToRemove: Request) { +function endRequestAndRemoveFromQueue(requestToRemove: Request) { ongoingRequest = null; /** * We only remove the first matching request because the order of requests matters. @@ -76,6 +76,19 @@ function remove(requestToRemove: Request) { }); } +function deleteRequestsByIndices(indices: number[]) { + // Create a Set from the indices array for efficient lookup + const indicesSet = new Set(indices); + + // Create a new array excluding elements at the specified indices + persistedRequests = persistedRequests.filter((_, index) => !indicesSet.has(index)); + + // Update the persisted requests in storage or state as necessary + Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, persistedRequests).then(() => { + Log.info(`Multiple (${indices.length}) requests removed from the queue. Queue length is ${persistedRequests.length}`); + }); +} + function update(oldRequestIndex: number, newRequest: Request) { const requests = [...persistedRequests]; requests.splice(oldRequestIndex, 1, newRequest); @@ -131,4 +144,4 @@ function getOngoingRequest(): Request | null { return ongoingRequest; } -export {clear, save, getAll, remove, update, getLength, getOngoingRequest, processNextRequest, updateOngoingRequest, rollbackOngoingRequest}; +export {clear, save, getAll, endRequestAndRemoveFromQueue, update, getLength, getOngoingRequest, processNextRequest, updateOngoingRequest, rollbackOngoingRequest, deleteRequestsByIndices}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 95bd2aa0b834..b5e1825a4496 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1414,6 +1414,16 @@ function handleReportChanged(report: OnyxEntry) { } } } +const addNewMessage = new Set([WRITE_COMMANDS.ADD_COMMENT, WRITE_COMMANDS.ADD_ATTACHMENT, WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT]); + +const commentsToBeDeleted = new Set([ + WRITE_COMMANDS.ADD_COMMENT, + WRITE_COMMANDS.ADD_ATTACHMENT, + WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT, + WRITE_COMMANDS.UPDATE_COMMENT, + WRITE_COMMANDS.ADD_EMOJI_REACTION, + WRITE_COMMANDS.REMOVE_EMOJI_REACTION, +]); /** Deletes a comment from the report, basically sets it as empty string */ function deleteReportComment(reportID: string, reportAction: ReportAction) { @@ -1538,7 +1548,56 @@ function deleteReportComment(reportID: string, reportAction: ReportAction) { CachedPDFPaths.clearByKey(reportActionID); - API.write(WRITE_COMMANDS.DELETE_COMMENT, parameters, {optimisticData, successData, failureData}); + API.write( + WRITE_COMMANDS.DELETE_COMMENT, + parameters, + {optimisticData, successData, failureData}, + { + checkAndFixConflictingRequest: (persistedRequests) => { + const indices: number[] = []; + let addCommentFound = false; + + persistedRequests.forEach((request, index) => { + if (!commentsToBeDeleted.has(request.command) || request.data?.reportActionID !== reportActionID) { + return; + } + if (addNewMessage.has(request.command)) { + addCommentFound = true; + } + indices.push(index); + }); + + if (indices.length === 0) { + return { + conflictAction: { + type: 'push', + }, + }; + } + + if (addCommentFound) { + const rollbackData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`, + value: { + [reportActionID]: null, + }, + }, + ]; + Onyx.update(rollbackData); + } + + return { + conflictAction: { + type: 'delete', + indices, + pushNewRequest: !addCommentFound, + }, + }; + }, + }, + ); // if we are linking to the report action, and we are deleting it, and it's not a deleted parent action, // we should navigate to its report in order to not show not found page diff --git a/src/types/onyx/Request.ts b/src/types/onyx/Request.ts index 238e3a8c6a81..085100870943 100644 --- a/src/types/onyx/Request.ts +++ b/src/types/onyx/Request.ts @@ -70,6 +70,26 @@ type ConflictRequestReplace = { index: number; }; +/** + * Model of a conflict request that needs to be deleted from the request queue. + */ +type ConflictRequestDelete = { + /** + * The action to take in case of a conflict. + */ + type: 'delete'; + + /** + * The indices of the requests in the queue that are to be deleted. + */ + indices: number[]; + + /** + * A flag to mark if the new request should be pushed into the queue after deleting the conflicting requests. + */ + pushNewRequest: boolean; +}; + /** * Model of a conflict request that has to be enqueued at the end of request queue. */ @@ -97,7 +117,7 @@ type ConflictActionData = { /** * The action to take in case of a conflict. */ - conflictAction: ConflictRequestReplace | ConflictRequestPush | ConflictRequestNoAction; + conflictAction: ConflictRequestReplace | ConflictRequestDelete | ConflictRequestPush | ConflictRequestNoAction; }; /** diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 0ffb0ee9bc08..dc54c3730ad8 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -1,8 +1,11 @@ /* eslint-disable @typescript-eslint/naming-convention */ import {afterEach, beforeAll, beforeEach, describe, expect, it} from '@jest/globals'; +import {addSeconds, format, subMinutes} from 'date-fns'; import {toZonedTime} from 'date-fns-tz'; import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import * as EmojiUtils from '@libs/EmojiUtils'; import CONST from '@src/CONST'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import * as PersistedRequests from '@src/libs/actions/PersistedRequests'; @@ -757,4 +760,472 @@ describe('actions/Report', () => { expect(reportActionReaction?.[EMOJI.name].users[TEST_USER_ACCOUNT_ID]).toBeUndefined(); }); }); + + it('should remove AddComment and UpdateComment without sending any request when DeleteComment is set', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + + Report.addComment(REPORT_ID, 'Testing a comment'); + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(0); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + Report.editReportComment(REPORT_ID, reportAction, 'Testing an edited comment'); + + // wait for Onyx.connect execute the callback and start processing the queue + await Promise.resolve(); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_COMMENT); + expect(persistedRequests?.at(1)?.command).toBe(WRITE_COMMANDS.UPDATE_COMMENT); + + resolve(); + }, + }); + }); + + // Checking the Report Action exists before dleting it + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).not.toBeNull(); + expect(reportActions?.[reportActionID].reportActionID).toBe(reportActionID); + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(0); + + // Checking the Report Action doesn't exist after deleting it + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).toBeUndefined(); + }, + }); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.UPDATE_COMMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 0); + }); + + it('should send DeleteComment request and remove UpdateComment accordingly', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + + Report.addComment(REPORT_ID, 'Testing a comment'); + + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(1); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + + // wait for Onyx.connect execute the callback and start processing the queue + await Promise.resolve(); + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + + Report.editReportComment(REPORT_ID, reportAction, 'Testing an edited comment'); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.UPDATE_COMMENT); + + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(1); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.UPDATE_COMMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 1); + }); + + it('should send DeleteComment request and remove UpdateComment accordingly', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + + Report.addComment(REPORT_ID, 'Testing a comment'); + + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(1); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + + // wait for Onyx.connect execute the callback and start processing the queue + await Promise.resolve(); + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + + Report.editReportComment(REPORT_ID, reportAction, 'Testing an edited comment'); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.UPDATE_COMMENT); + + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(1); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.UPDATE_COMMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 1); + }); + + it('should send not DeleteComment request and remove AddAttachment accordingly', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + + const file = new File([''], 'test.txt', {type: 'text/plain'}); + Report.addAttachment(REPORT_ID, file); + + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(0); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + + // wait for Onyx.connect execute the callback and start processing the queue + await Promise.resolve(); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_ATTACHMENT); + resolve(); + }, + }); + }); + + // Checking the Report Action exists before dleting it + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).not.toBeNull(); + expect(reportActions?.[reportActionID].reportActionID).toBe(reportActionID); + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(0); + + // Checking the Report Action doesn't exist after deleting it + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).toBeUndefined(); + }, + }); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_ATTACHMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 0); + }, 2000); + + it('should send not DeleteComment request and remove AddTextAndAttachment accordingly', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + const file = new File([''], 'test.txt', {type: 'text/plain'}); + + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + + Report.addAttachment(REPORT_ID, file, 'Attachment with comment'); + + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(0); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + + // wait for Onyx.connect execute the callback and start processing the queue + await Promise.resolve(); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT); + resolve(); + }, + }); + }); + + // Checking the Report Action exists before dleting it + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).not.toBeNull(); + expect(reportActions?.[reportActionID].reportActionID).toBe(reportActionID); + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(0); + + // Checking the Report Action doesn't exist after deleting it + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).toBeUndefined(); + }, + }); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 0); + }); + + it('should not send DeleteComment request and remove any Reactions accordingly', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + jest.spyOn(EmojiUtils, 'hasAccountIDEmojiReacted').mockImplementation(() => true); + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + await Promise.resolve(); + + Report.addComment(REPORT_ID, 'reactions with comment'); + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(0); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + + await waitForBatchedUpdates(); + + Report.toggleEmojiReaction(REPORT_ID, reportAction, {name: 'smile', code: '😄'}, {}); + Report.toggleEmojiReaction( + REPORT_ID, + reportAction, + {name: 'smile', code: '😄'}, + { + smile: { + createdAt: '2024-10-14 14:58:12', + oldestTimestamp: '2024-10-14 14:58:12', + users: { + [`${TEST_USER_ACCOUNT_ID}`]: { + id: `${TEST_USER_ACCOUNT_ID}`, + oldestTimestamp: '2024-10-14 14:58:12', + skinTones: { + '-1': '2024-10-14 14:58:12', + }, + }, + }, + }, + }, + ); + + await waitForBatchedUpdates(); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_COMMENT); + expect(persistedRequests?.at(1)?.command).toBe(WRITE_COMMANDS.ADD_EMOJI_REACTION); + expect(persistedRequests?.at(2)?.command).toBe(WRITE_COMMANDS.REMOVE_EMOJI_REACTION); + resolve(); + }, + }); + }); + + // Checking the Report Action exists before deleting it + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).not.toBeNull(); + expect(reportActions?.[reportActionID].reportActionID).toBe(reportActionID); + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(0); + + // Checking the Report Action doesn't exist after deleting it + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).toBeUndefined(); + }, + }); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_EMOJI_REACTION, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.REMOVE_EMOJI_REACTION, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 0); + }); + + it('should send DeleteComment request and remove any Reactions accordingly', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + jest.spyOn(EmojiUtils, 'hasAccountIDEmojiReacted').mockImplementation(() => true); + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + Report.addComment(REPORT_ID, 'Attachment with comment'); + + // Need the reportActionID to delete the comments + const newComment = PersistedRequests.getAll().at(0); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + + // wait for Onyx.connect execute the callback and start processing the queue + await Promise.resolve(); + + Report.toggleEmojiReaction(REPORT_ID, reportAction, {name: 'smile', code: '😄'}, {}); + Report.toggleEmojiReaction( + REPORT_ID, + reportAction, + {name: 'smile', code: '😄'}, + { + smile: { + createdAt: '2024-10-14 14:58:12', + oldestTimestamp: '2024-10-14 14:58:12', + users: { + [`${TEST_USER_ACCOUNT_ID}`]: { + id: `${TEST_USER_ACCOUNT_ID}`, + oldestTimestamp: '2024-10-14 14:58:12', + skinTones: { + '-1': '2024-10-14 14:58:12', + }, + }, + }, + }, + }, + ); + + await waitForBatchedUpdates(); + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + Onyx.disconnect(connection); + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_EMOJI_REACTION); + expect(persistedRequests?.at(1)?.command).toBe(WRITE_COMMANDS.REMOVE_EMOJI_REACTION); + resolve(); + }, + }); + }); + + Report.deleteReportComment(REPORT_ID, reportAction); + + await waitForBatchedUpdates(); + expect(PersistedRequests.getAll().length).toBe(1); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_EMOJI_REACTION, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.REMOVE_EMOJI_REACTION, 0); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 1); + }); }); diff --git a/tests/unit/PersistedRequests.ts b/tests/unit/PersistedRequests.ts index 7d3a7288ed90..c488b36013ad 100644 --- a/tests/unit/PersistedRequests.ts +++ b/tests/unit/PersistedRequests.ts @@ -36,7 +36,7 @@ describe('PersistedRequests', () => { }); it('remove a request from the PersistedRequests array', () => { - PersistedRequests.remove(request); + PersistedRequests.endRequestAndRemoveFromQueue(request); expect(PersistedRequests.getAll().length).toBe(0); }); @@ -84,7 +84,7 @@ describe('PersistedRequests', () => { it('when removing a request should update the persistedRequests queue and clear the ongoing request', () => { PersistedRequests.processNextRequest(); expect(PersistedRequests.getOngoingRequest()).toEqual(request); - PersistedRequests.remove(request); + PersistedRequests.endRequestAndRemoveFromQueue(request); expect(PersistedRequests.getOngoingRequest()).toBeNull(); expect(PersistedRequests.getAll().length).toBe(0); }); From ecb7aaa4ee279520e671b7b497078fd1ca356b95 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Wed, 16 Oct 2024 17:33:08 +0200 Subject: [PATCH 022/289] Adding case for OpenReport when creating a thread --- src/libs/actions/RequestConflictUtils.ts | 15 ++++- tests/actions/ReportTest.ts | 80 ++++++++++++++++++++---- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts index f8aefbe73d87..022105237210 100644 --- a/src/libs/actions/RequestConflictUtils.ts +++ b/src/libs/actions/RequestConflictUtils.ts @@ -1,5 +1,6 @@ import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {WriteCommand} from '@libs/API/types'; import {WRITE_COMMANDS} from '@libs/API/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type OnyxRequest from '@src/types/onyx/Request'; @@ -43,14 +44,25 @@ function resolveDuplicationConflictAction(persistedRequests: OnyxRequest[], comm function resolveCommentDeletionConflicts(persistedRequests: OnyxRequest[], reportActionID: string, originalReportID: string): ConflictActionData { const indices: number[] = []; + const commentCouldBeThread: Record = {}; let addCommentFound = false; - persistedRequests.forEach((request, index) => { + // If the request will open a Thread, we should not delete the comment and we should send all the requests + if (request.command === WRITE_COMMANDS.OPEN_REPORT && request.data?.parentReportActionID === reportActionID && reportActionID in commentCouldBeThread) { + const indexToRemove = commentCouldBeThread[reportActionID]; + indices.splice(indexToRemove, 1); + return; + } + if (!commentsToBeDeleted.has(request.command) || request.data?.reportActionID !== reportActionID) { return; } + + // If we find a new message, we probably want to remove it and not perform any request given that the server + // doesn't know about it yet. if (addNewMessage.has(request.command)) { addCommentFound = true; + commentCouldBeThread[reportActionID] = index; } indices.push(index); }); @@ -64,6 +76,7 @@ function resolveCommentDeletionConflicts(persistedRequests: OnyxRequest[], repor } if (addCommentFound) { + // The new message performs some changes in Onyx, so we need to rollback those changes. const rollbackData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index dc54c3730ad8..bdb1b0c5e598 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -778,8 +778,7 @@ describe('actions/Report', () => { const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); Report.editReportComment(REPORT_ID, reportAction, 'Testing an edited comment'); - // wait for Onyx.connect execute the callback and start processing the queue - await Promise.resolve(); + await waitForBatchedUpdates(); await new Promise((resolve) => { const connection = Onyx.connect({ @@ -795,12 +794,13 @@ describe('actions/Report', () => { }); }); - // Checking the Report Action exists before dleting it + // Checking the Report Action exists before deleting it await new Promise((resolve) => { const connection = Onyx.connect({ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, callback: (reportActions) => { Onyx.disconnect(connection); + expect(reportActions?.[reportActionID]).not.toBeNull(); expect(reportActions?.[reportActionID].reportActionID).toBe(reportActionID); resolve(); @@ -859,9 +859,7 @@ describe('actions/Report', () => { key: ONYXKEYS.PERSISTED_REQUESTS, callback: (persistedRequests) => { Onyx.disconnect(connection); - expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.UPDATE_COMMENT); - resolve(); }, }); @@ -909,9 +907,7 @@ describe('actions/Report', () => { key: ONYXKEYS.PERSISTED_REQUESTS, callback: (persistedRequests) => { Onyx.disconnect(connection); - expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.UPDATE_COMMENT); - resolve(); }, }); @@ -950,21 +946,20 @@ describe('actions/Report', () => { const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); // wait for Onyx.connect execute the callback and start processing the queue - await Promise.resolve(); + await waitForBatchedUpdates(); await new Promise((resolve) => { const connection = Onyx.connect({ key: ONYXKEYS.PERSISTED_REQUESTS, callback: (persistedRequests) => { Onyx.disconnect(connection); - expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_ATTACHMENT); resolve(); }, }); }); - // Checking the Report Action exists before dleting it + // Checking the Report Action exists before deleting it await new Promise((resolve) => { const connection = Onyx.connect({ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, @@ -1018,21 +1013,20 @@ describe('actions/Report', () => { const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); // wait for Onyx.connect execute the callback and start processing the queue - await Promise.resolve(); + await waitForBatchedUpdates(); await new Promise((resolve) => { const connection = Onyx.connect({ key: ONYXKEYS.PERSISTED_REQUESTS, callback: (persistedRequests) => { Onyx.disconnect(connection); - expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT); resolve(); }, }); }); - // Checking the Report Action exists before dleting it + // Checking the Report Action exists before deleting it await new Promise((resolve) => { const connection = Onyx.connect({ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, @@ -1228,4 +1222,64 @@ describe('actions/Report', () => { TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.REMOVE_EMOJI_REACTION, 0); TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 1); }); + + it('should create and delete thread processing all the requests', async () => { + global.fetch = TestHelper.getGlobalFetchMock(); + + const TEST_USER_ACCOUNT_ID = 1; + const REPORT_ID = '1'; + const TEN_MINUTES_AGO = subMinutes(new Date(), 10); + const created = format(addSeconds(TEN_MINUTES_AGO, 10), CONST.DATE.FNS_DB_FORMAT_STRING); + + await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); + await waitForBatchedUpdates(); + + Report.addComment(REPORT_ID, 'Testing a comment'); + + const newComment = PersistedRequests.getAll().at(0); + const reportActionID = (newComment?.data?.reportActionID as string) ?? '-1'; + const reportAction = TestHelper.buildTestReportComment(created, TEST_USER_ACCOUNT_ID, reportActionID); + + Report.openReport( + REPORT_ID, + undefined, + ['test@user.com'], + { + isOptimisticReport: true, + parentReportID: REPORT_ID, + parentReportActionID: reportActionID, + reportID: '2', + }, + reportActionID, + ); + + Report.deleteReportComment(REPORT_ID, reportAction); + + expect(PersistedRequests.getAll().length).toBe(3); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSISTED_REQUESTS, + callback: (persistedRequests) => { + if (persistedRequests?.length !== 3) { + return; + } + Onyx.disconnect(connection); + + expect(persistedRequests?.at(0)?.command).toBe(WRITE_COMMANDS.ADD_COMMENT); + expect(persistedRequests?.at(1)?.command).toBe(WRITE_COMMANDS.OPEN_REPORT); + expect(persistedRequests?.at(2)?.command).toBe(WRITE_COMMANDS.DELETE_COMMENT); + resolve(); + }, + }); + }); + + Onyx.set(ONYXKEYS.NETWORK, {isOffline: false}); + await waitForBatchedUpdates(); + + // Checking no requests were or will be made + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1); + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 1); + }); }); From 67dbabd49d2c1ba53946013d0b621322020768a0 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Thu, 17 Oct 2024 15:40:02 +0700 Subject: [PATCH 023/289] add limit value for expensify card --- src/CONST.ts | 1 + .../workspace/expensifyCard/WorkspaceEditCardLimitPage.tsx | 4 ++-- src/pages/workspace/expensifyCard/issueNew/LimitStep.tsx | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index 5066899c3806..56d40a66d36f 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -2543,6 +2543,7 @@ const CONST = { MONTHLY: 'monthly', FIXED: 'fixed', }, + LIMIT_VALUE: 20000000, STEP_NAMES: ['1', '2', '3', '4', '5', '6'], STEP: { ASSIGNEE: 'Assignee', diff --git a/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitPage.tsx index bb8515fe85d3..87e03949e093 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitPage.tsx @@ -86,9 +86,9 @@ function WorkspaceEditCardLimitPage({route}: WorkspaceEditCardLimitPageProps) { const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.LIMIT]); - + // We only want integers to be sent as the limit - if (!Number.isInteger(Number(values.limit))) { + if (!Number.isInteger(Number(values.limit)) || Number(values.limit) > CONST.EXPENSIFY_CARD.LIMIT_VALUE) { errors.limit = translate('iou.error.invalidAmount'); } diff --git a/src/pages/workspace/expensifyCard/issueNew/LimitStep.tsx b/src/pages/workspace/expensifyCard/issueNew/LimitStep.tsx index eb7c2e7d8e0f..7d935eea37dc 100644 --- a/src/pages/workspace/expensifyCard/issueNew/LimitStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/LimitStep.tsx @@ -49,9 +49,9 @@ function LimitStep() { const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.LIMIT]); - + // We only want integers to be sent as the limit - if (!Number(values.limit) || !Number.isInteger(Number(values.limit))) { + if (!Number(values.limit) || !Number.isInteger(Number(values.limit)) || Number(values.limit) > CONST.EXPENSIFY_CARD.LIMIT_VALUE) { errors.limit = translate('iou.error.invalidAmount'); } return errors; From 33cd86722c940c442be9cc631adf5bd78ac61121 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Thu, 17 Oct 2024 15:46:51 +0700 Subject: [PATCH 024/289] fix lint --- .../workspace/expensifyCard/WorkspaceEditCardLimitPage.tsx | 2 +- src/pages/workspace/expensifyCard/issueNew/LimitStep.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitPage.tsx index 87e03949e093..a7672c104d90 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceEditCardLimitPage.tsx @@ -86,7 +86,7 @@ function WorkspaceEditCardLimitPage({route}: WorkspaceEditCardLimitPageProps) { const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.LIMIT]); - + // We only want integers to be sent as the limit if (!Number.isInteger(Number(values.limit)) || Number(values.limit) > CONST.EXPENSIFY_CARD.LIMIT_VALUE) { errors.limit = translate('iou.error.invalidAmount'); diff --git a/src/pages/workspace/expensifyCard/issueNew/LimitStep.tsx b/src/pages/workspace/expensifyCard/issueNew/LimitStep.tsx index 7d935eea37dc..0485f21d9dad 100644 --- a/src/pages/workspace/expensifyCard/issueNew/LimitStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/LimitStep.tsx @@ -49,7 +49,7 @@ function LimitStep() { const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.LIMIT]); - + // We only want integers to be sent as the limit if (!Number(values.limit) || !Number.isInteger(Number(values.limit)) || Number(values.limit) > CONST.EXPENSIFY_CARD.LIMIT_VALUE) { errors.limit = translate('iou.error.invalidAmount'); From 118bb207db78a324ebce78e0cb000d84ba988852 Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Thu, 17 Oct 2024 19:39:35 +0700 Subject: [PATCH 025/289] feat: add translations --- src/languages/en.ts | 8 ++++++++ src/languages/es.ts | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/src/languages/en.ts b/src/languages/en.ts index d73015693e7a..d1ef9cbff396 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3177,6 +3177,14 @@ const translations = { issuedCardNoShippingDetails: ({assignee}: AssigneeParams) => `issued ${assignee} an Expensify Card! The card will be shipped once shipping details are added.`, issuedCardVirtual: ({assignee, link}: IssueVirtualCardParams) => `issued ${assignee} a virtual ${link}! The card can be used right away.`, addedShippingDetails: ({assignee}: AssigneeParams) => `${assignee} added shipping details. Expensify Card will arrive in 2-3 business days.`, + verifyingHeader: 'Verifying', + bankAccountVerifiedHeader: 'Bank account verified', + verifyingBankAccount: 'Verifying bank account...', + verifyingBankAccountDescription: 'Hold on while we check that this account can be used for issuing Expensify Cards', + bankAccountVerified: 'Bank account verified!', + bankAccountVerifiedDescription: 'You can now issue Expensify Cards to your workspace members.', + oneMoreStep: 'One more step...', + oneMoreStepDescription: 'Looks like we need to manually verify your bank account. Please head on over to Concierge where your instructions are waiting for you.', }, categories: { deleteCategories: 'Delete categories', diff --git a/src/languages/es.ts b/src/languages/es.ts index a9d35a6f8228..d11806c1db32 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3217,6 +3217,14 @@ const translations = { issuedCardNoShippingDetails: ({assignee}: AssigneeParams) => `¡emitió a ${assignee} una Tarjeta Expensify! La tarjeta se enviará una vez que se agreguen los detalles de envío.`, issuedCardVirtual: ({assignee, link}: IssueVirtualCardParams) => `¡emitió a ${assignee} una ${link} virtual! La tarjeta puede utilizarse inmediatamente.`, addedShippingDetails: ({assignee}: AssigneeParams) => `${assignee} agregó los detalles de envío. La Tarjeta Expensify llegará en 2-3 días hábiles.`, + verifyingHeader: 'Verificando', + bankAccountVerifiedHeader: 'Cuenta bancaria verificada', + verifyingBankAccount: 'Verificando cuenta bancaria...', + verifyingBankAccountDescription: 'Espera mientras comprobamos que esta cuenta se puede utilizar para emitir tarjetas Expensify.', + bankAccountVerified: '¡Cuenta bancaria verificada!', + bankAccountVerifiedDescription: 'Ahora puedes emitir tarjetas de Expensify para los miembros de tu espacio de trabajo.', + oneMoreStep: 'Un paso más', + oneMoreStepDescription: 'Parece que tenemos que verificar manualmente tu cuenta bancaria. Dirígete a Concierge, donde te esperan las instrucciones.', }, categories: { deleteCategories: 'Eliminar categorías', From d7465bf302dc4c1cfed47540c500af19beed8f9e Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 17 Oct 2024 17:15:45 +0200 Subject: [PATCH 026/289] simplify offline message check --- src/hooks/useNetwork.ts | 2 +- src/pages/home/report/ReportActionsList.tsx | 23 +++++++++++---------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/hooks/useNetwork.ts b/src/hooks/useNetwork.ts index 5a686b5ab138..21372daf424e 100644 --- a/src/hooks/useNetwork.ts +++ b/src/hooks/useNetwork.ts @@ -59,5 +59,5 @@ export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOffline]); - return {isOffline, lastOfflineAt, lastOnlineAt}; + return useMemo(() => ({isOffline, lastOfflineAt, lastOnlineAt}), [isOffline, lastOfflineAt, lastOnlineAt]); } diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 1527fb90b459..caa289f87f27 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -229,7 +229,9 @@ function ReportActionsList({ const isMessageOffline = useCallback( (m: OnyxTypes.ReportAction) => wasMessageReceivedWhileOffline(m, lastOfflineAt, lastOnlineAt, preferredLocale), - [lastOfflineAt, lastOnlineAt, preferredLocale], + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + [lastOfflineAt, lastOnlineAt], ); /** @@ -239,18 +241,17 @@ function ReportActionsList({ const shouldDisplayNewMarker = (message: OnyxTypes.ReportAction, index: number): boolean => { const nextMessage = sortedVisibleReportActions.at(index + 1); + const isCurrentMessageUnread = isMessageUnread(message, unreadMarkerTime); + const isNextMessageUnread = nextMessage ? isMessageUnread(nextMessage, unreadMarkerTime) : false; + // If the user recevied new messages while being offline, we want to display the unread marker above the first offline message. - if (!ReportActionsUtils.wasActionTakenByCurrentUser(message)) { - const isCurrentMessageOffline = isMessageOffline(message); - const isNextMessageOffline = nextMessage && !ReportActionsUtils.wasActionTakenByCurrentUser(nextMessage) && isMessageOffline(nextMessage); - if (isCurrentMessageOffline && !isNextMessageOffline) { - return true; - } - } + const wasCurrentMessageReceivedWhileOffline = !ReportActionsUtils.wasActionTakenByCurrentUser(message) && isMessageOffline(message); + const wasNextMessageReceivedWhileOffline = nextMessage && !ReportActionsUtils.wasActionTakenByCurrentUser(nextMessage) && isMessageOffline(nextMessage); + + const shouldDisplayForCurrentMessage = isCurrentMessageUnread || wasCurrentMessageReceivedWhileOffline; + const shouldDisplayForNextMessage = isNextMessageUnread || wasNextMessageReceivedWhileOffline; + const shouldDisplay = shouldDisplayForCurrentMessage && !shouldDisplayForNextMessage && !ReportActionsUtils.shouldHideNewMarker(message); - const isCurrentMessageUnread = isMessageUnread(message, unreadMarkerTime); - const isNextMessageRead = !nextMessage || !isMessageUnread(nextMessage, unreadMarkerTime); - const shouldDisplay = isCurrentMessageUnread && isNextMessageRead && !ReportActionsUtils.shouldHideNewMarker(message); const isWithinVisibleThreshold = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? message.created < (userActiveSince.current ?? '') : true; return shouldDisplay && isWithinVisibleThreshold; }; From dd99fe7e1f229d8b73f31fab2c93137e62595647 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 17 Oct 2024 17:32:33 +0200 Subject: [PATCH 027/289] simplify code --- src/pages/home/report/ReportActionsList.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index caa289f87f27..3f2133044977 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -122,7 +122,7 @@ function keyExtractor(item: OnyxTypes.ReportAction): string { return item.reportActionID; } -function wasMessageReceivedWhileOffline(message: OnyxTypes.ReportAction, offlineLastAt: Date | undefined, onlineLastAt: Date | undefined, locale: OnyxTypes.Locale): boolean { +function wasMessageCreatedWhileOffline(message: OnyxTypes.ReportAction, offlineLastAt: Date | undefined, onlineLastAt: Date | undefined, locale: OnyxTypes.Locale): boolean { if (!onlineLastAt || !offlineLastAt) { return false; } @@ -227,8 +227,8 @@ function ReportActionsList({ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [report.reportID]); - const isMessageOffline = useCallback( - (m: OnyxTypes.ReportAction) => wasMessageReceivedWhileOffline(m, lastOfflineAt, lastOnlineAt, preferredLocale), + const wasMessageReceivedWhileOffline = useCallback( + (m: OnyxTypes.ReportAction) => !ReportActionsUtils.wasActionTakenByCurrentUser && wasMessageCreatedWhileOffline(m, lastOfflineAt, lastOnlineAt, preferredLocale), // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps [lastOfflineAt, lastOnlineAt], @@ -245,11 +245,14 @@ function ReportActionsList({ const isNextMessageUnread = nextMessage ? isMessageUnread(nextMessage, unreadMarkerTime) : false; // If the user recevied new messages while being offline, we want to display the unread marker above the first offline message. - const wasCurrentMessageReceivedWhileOffline = !ReportActionsUtils.wasActionTakenByCurrentUser(message) && isMessageOffline(message); - const wasNextMessageReceivedWhileOffline = nextMessage && !ReportActionsUtils.wasActionTakenByCurrentUser(nextMessage) && isMessageOffline(nextMessage); + const wasCurrentMessageReceivedWhileOffline = wasMessageReceivedWhileOffline(message); + const wasNextMessageReceivedWhileOffline = nextMessage && wasMessageReceivedWhileOffline(nextMessage); const shouldDisplayForCurrentMessage = isCurrentMessageUnread || wasCurrentMessageReceivedWhileOffline; const shouldDisplayForNextMessage = isNextMessageUnread || wasNextMessageReceivedWhileOffline; + + console.log({wasCurrentMessageReceivedWhileOffline, wasNextMessageReceivedWhileOffline, shouldDisplayForCurrentMessage, shouldDisplayForNextMessage}); + const shouldDisplay = shouldDisplayForCurrentMessage && !shouldDisplayForNextMessage && !ReportActionsUtils.shouldHideNewMarker(message); const isWithinVisibleThreshold = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? message.created < (userActiveSince.current ?? '') : true; @@ -266,7 +269,7 @@ function ReportActionsList({ } return null; - }, [sortedVisibleReportActions, unreadMarkerTime, isMessageOffline]); + }, [sortedVisibleReportActions, unreadMarkerTime, wasMessageReceivedWhileOffline]); /** * Subscribe to read/unread events and update our unreadMarkerTime From 0678a2524715927bffeae046fabd3f5a8202a601 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 17 Oct 2024 23:00:30 +0200 Subject: [PATCH 028/289] WIP: improve unread marker check --- src/hooks/useNetwork.ts | 2 +- src/pages/home/report/ReportActionsList.tsx | 23 +++++++++------------ 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/hooks/useNetwork.ts b/src/hooks/useNetwork.ts index 21372daf424e..5a686b5ab138 100644 --- a/src/hooks/useNetwork.ts +++ b/src/hooks/useNetwork.ts @@ -59,5 +59,5 @@ export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOffline]); - return useMemo(() => ({isOffline, lastOfflineAt, lastOnlineAt}), [isOffline, lastOfflineAt, lastOnlineAt]); + return {isOffline, lastOfflineAt, lastOnlineAt}; } diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 3f2133044977..b3ae47214362 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -229,9 +229,7 @@ function ReportActionsList({ const wasMessageReceivedWhileOffline = useCallback( (m: OnyxTypes.ReportAction) => !ReportActionsUtils.wasActionTakenByCurrentUser && wasMessageCreatedWhileOffline(m, lastOfflineAt, lastOnlineAt, preferredLocale), - // eslint-disable-next-line react-compiler/react-compiler - // eslint-disable-next-line react-hooks/exhaustive-deps - [lastOfflineAt, lastOnlineAt], + [lastOfflineAt, lastOnlineAt, preferredLocale], ); /** @@ -239,24 +237,23 @@ function ReportActionsList({ */ const unreadMarkerReportActionID = useMemo(() => { const shouldDisplayNewMarker = (message: OnyxTypes.ReportAction, index: number): boolean => { + const isWithinVisibleThreshold = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? message.created < (userActiveSince.current ?? '') : true; + const nextMessage = sortedVisibleReportActions.at(index + 1); const isCurrentMessageUnread = isMessageUnread(message, unreadMarkerTime); const isNextMessageUnread = nextMessage ? isMessageUnread(nextMessage, unreadMarkerTime) : false; // If the user recevied new messages while being offline, we want to display the unread marker above the first offline message. - const wasCurrentMessageReceivedWhileOffline = wasMessageReceivedWhileOffline(message); - const wasNextMessageReceivedWhileOffline = nextMessage && wasMessageReceivedWhileOffline(nextMessage); - - const shouldDisplayForCurrentMessage = isCurrentMessageUnread || wasCurrentMessageReceivedWhileOffline; - const shouldDisplayForNextMessage = isNextMessageUnread || wasNextMessageReceivedWhileOffline; + const isCurrentMessageOffline = wasMessageReceivedWhileOffline(message); + const isNextMessageOffline = nextMessage ? wasMessageReceivedWhileOffline(nextMessage) : false; - console.log({wasCurrentMessageReceivedWhileOffline, wasNextMessageReceivedWhileOffline, shouldDisplayForCurrentMessage, shouldDisplayForNextMessage}); + const shouldDisplayForNextMessage = isNextMessageUnread || isNextMessageOffline; + const shouldDisplayBecauseUnread = isCurrentMessageUnread && !isNextMessageUnread && !ReportActionsUtils.shouldHideNewMarker(message) && isWithinVisibleThreshold; + const shouldDisplayBecauseOffline = isCurrentMessageOffline && !isNextMessageOffline; + const shouldDisplay = shouldDisplayBecauseOffline || shouldDisplayBecauseUnread; - const shouldDisplay = shouldDisplayForCurrentMessage && !shouldDisplayForNextMessage && !ReportActionsUtils.shouldHideNewMarker(message); - - const isWithinVisibleThreshold = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? message.created < (userActiveSince.current ?? '') : true; - return shouldDisplay && isWithinVisibleThreshold; + return shouldDisplay; }; // Scan through each visible report action until we find the appropriate action to show the unread marker From 1900337a0ca1aeb7573b3eb7cf961d17c6b91842 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 17 Oct 2024 23:16:22 +0200 Subject: [PATCH 029/289] fix: unread marker not shown --- src/hooks/useNetwork.ts | 2 +- src/pages/home/report/ReportActionsList.tsx | 21 +++++++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/hooks/useNetwork.ts b/src/hooks/useNetwork.ts index 5a686b5ab138..21372daf424e 100644 --- a/src/hooks/useNetwork.ts +++ b/src/hooks/useNetwork.ts @@ -59,5 +59,5 @@ export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOffline]); - return {isOffline, lastOfflineAt, lastOnlineAt}; + return useMemo(() => ({isOffline, lastOfflineAt, lastOnlineAt}), [isOffline, lastOfflineAt, lastOnlineAt]); } diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index b3ae47214362..3326dd49dce7 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -228,8 +228,10 @@ function ReportActionsList({ }, [report.reportID]); const wasMessageReceivedWhileOffline = useCallback( - (m: OnyxTypes.ReportAction) => !ReportActionsUtils.wasActionTakenByCurrentUser && wasMessageCreatedWhileOffline(m, lastOfflineAt, lastOnlineAt, preferredLocale), - [lastOfflineAt, lastOnlineAt, preferredLocale], + (message: OnyxTypes.ReportAction) => !ReportActionsUtils.wasActionTakenByCurrentUser(message) && wasMessageCreatedWhileOffline(message, lastOfflineAt, lastOnlineAt, preferredLocale), + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + [lastOfflineAt, lastOnlineAt], ); /** @@ -241,19 +243,18 @@ function ReportActionsList({ const nextMessage = sortedVisibleReportActions.at(index + 1); - const isCurrentMessageUnread = isMessageUnread(message, unreadMarkerTime); - const isNextMessageUnread = nextMessage ? isMessageUnread(nextMessage, unreadMarkerTime) : false; - // If the user recevied new messages while being offline, we want to display the unread marker above the first offline message. const isCurrentMessageOffline = wasMessageReceivedWhileOffline(message); - const isNextMessageOffline = nextMessage ? wasMessageReceivedWhileOffline(nextMessage) : false; + const isNextMessageOffline = !!nextMessage && wasMessageReceivedWhileOffline(nextMessage); + + const isCurrentMessageUnread = isMessageUnread(message, unreadMarkerTime); + const isNextMessageUnread = !!nextMessage && isMessageUnread(nextMessage, unreadMarkerTime); const shouldDisplayForNextMessage = isNextMessageUnread || isNextMessageOffline; - const shouldDisplayBecauseUnread = isCurrentMessageUnread && !isNextMessageUnread && !ReportActionsUtils.shouldHideNewMarker(message) && isWithinVisibleThreshold; - const shouldDisplayBecauseOffline = isCurrentMessageOffline && !isNextMessageOffline; - const shouldDisplay = shouldDisplayBecauseOffline || shouldDisplayBecauseUnread; + const shouldDisplayBecauseOffline = isCurrentMessageOffline && !shouldDisplayForNextMessage; + const shouldDisplayBecauseUnread = isCurrentMessageUnread && !shouldDisplayForNextMessage && !ReportActionsUtils.shouldHideNewMarker(message) && isWithinVisibleThreshold; - return shouldDisplay; + return shouldDisplayBecauseOffline || shouldDisplayBecauseUnread; }; // Scan through each visible report action until we find the appropriate action to show the unread marker From 8ccedd770cd46b5d09263feff1d466091fd2d5a7 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Fri, 18 Oct 2024 13:57:43 +0700 Subject: [PATCH 030/289] Add policyID param to CompleteOnboarding API --- src/libs/API/parameters/CompleteGuidedSetupParams.ts | 1 + src/libs/actions/Report.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/libs/API/parameters/CompleteGuidedSetupParams.ts b/src/libs/API/parameters/CompleteGuidedSetupParams.ts index 1242b9285de9..6ff45ecc424a 100644 --- a/src/libs/API/parameters/CompleteGuidedSetupParams.ts +++ b/src/libs/API/parameters/CompleteGuidedSetupParams.ts @@ -9,6 +9,7 @@ type CompleteGuidedSetupParams = { paymentSelected?: string; companySize?: OnboardingCompanySizeType; userReportedIntegration?: OnboardingAccountingType; + policyID?: string; }; export default CompleteGuidedSetupParams; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 7071c96f8612..acd22cacaa2b 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -3715,6 +3715,7 @@ function completeOnboarding( paymentSelected, companySize, userReportedIntegration, + policyID: onboardingPolicyID, }; API.write(WRITE_COMMANDS.COMPLETE_GUIDED_SETUP, parameters, {optimisticData, successData, failureData}); From b5948092b7b9b3bf581ce05f81e3742a0cdf668f Mon Sep 17 00:00:00 2001 From: Gandalf Date: Sat, 19 Oct 2024 00:22:57 +0530 Subject: [PATCH 031/289] add useNetSuiteCustomListFormSubmit hook Signed-off-by: Gandalf --- src/hooks/useNetSuiteCustomListFormSubmit.ts | 27 ++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/hooks/useNetSuiteCustomListFormSubmit.ts diff --git a/src/hooks/useNetSuiteCustomListFormSubmit.ts b/src/hooks/useNetSuiteCustomListFormSubmit.ts new file mode 100644 index 000000000000..3c9072d0b148 --- /dev/null +++ b/src/hooks/useNetSuiteCustomListFormSubmit.ts @@ -0,0 +1,27 @@ +import type {FormOnyxKeys} from '@components/Form/types'; +import type {OnyxFormKey} from '@src/ONYXKEYS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useStepFormSubmit from './useStepFormSubmit'; +import type {SubStepProps} from './useSubStep/types'; + +type useNetSuiteCustomListFormSubmitParams = Pick & { + formId?: OnyxFormKey; + fieldIds: Array>; + shouldSaveDraft: boolean; +}; + +/** + * Hook for handling submit method in Missing Personal Details substeps. + * When user is in editing mode, we should save values only when user confirms the change + * @param onNext - callback + * @param fieldIds - field IDs for particular step + * @param shouldSaveDraft - if we should save draft values + */ +export default function useNetSuiteCustomListFormSubmit({onNext, fieldIds, shouldSaveDraft}: useNetSuiteCustomListFormSubmitParams) { + return useStepFormSubmit({ + formId: ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM, + onNext, + fieldIds, + shouldSaveDraft, + }); +} From 187760e6764eb6b3a293d3454c64640b91aa1246 Mon Sep 17 00:00:00 2001 From: Gandalf Date: Sat, 19 Oct 2024 16:26:00 +0530 Subject: [PATCH 032/289] add ability to save draft values and save screen state for netsuite custom forms --- .../useNetSuiteImportAddCustomListForm.ts | 27 +++ .../NetSuiteImportAddCustomListContent.tsx | 153 ++++++++++++++ .../NetSuiteImportAddCustomListPage.tsx | 194 ++---------------- .../customListUtils.ts | 30 +++ .../substeps/ChooseCustomListStep.tsx | 40 +++- .../substeps/ConfirmCustomListStep.tsx | 56 +++-- .../substeps/MappingStep.tsx | 42 +++- .../substeps/TransactionFieldIDStep.tsx | 54 +++-- .../workspace/accounting/netsuite/types.ts | 4 + src/types/onyx/Policy.ts | 2 +- 10 files changed, 382 insertions(+), 220 deletions(-) create mode 100644 src/hooks/useNetSuiteImportAddCustomListForm.ts create mode 100644 src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomListContent.tsx create mode 100644 src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/customListUtils.ts diff --git a/src/hooks/useNetSuiteImportAddCustomListForm.ts b/src/hooks/useNetSuiteImportAddCustomListForm.ts new file mode 100644 index 000000000000..aeb37d54b3ba --- /dev/null +++ b/src/hooks/useNetSuiteImportAddCustomListForm.ts @@ -0,0 +1,27 @@ +import type {FormOnyxKeys} from '@components/Form/types'; +import type {OnyxFormKey} from '@src/ONYXKEYS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useStepFormSubmit from './useStepFormSubmit'; +import type {SubStepProps} from './useSubStep/types'; + +type UseNetSuiteImportAddCustomListFormSubmitParams = Pick & { + formId?: OnyxFormKey; + fieldIds: Array>; + shouldSaveDraft: boolean; +}; + +/** + * Hook for handling submit method in NetSuite Custom List substeps. + * When user is in editing mode, we should save values only when user confirms the change + * @param onNext - callback + * @param fieldIds - field IDs for particular step + * @param shouldSaveDraft - if we should save draft values + */ +export default function useNetSuiteImportAddCustomListFormSubmit({onNext, fieldIds, shouldSaveDraft}: UseNetSuiteImportAddCustomListFormSubmitParams) { + return useStepFormSubmit({ + formId: ONYXKEYS.FORMS.NETSUITE_CUSTOM_LIST_ADD_FORM, + onNext, + fieldIds, + shouldSaveDraft, + }); +} diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomListContent.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomListContent.tsx new file mode 100644 index 000000000000..65c1b7527921 --- /dev/null +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomListContent.tsx @@ -0,0 +1,153 @@ +import React, {useCallback, useMemo, useRef} from 'react'; +import type {ForwardedRef} from 'react'; +import {InteractionManager, View} from 'react-native'; +import {OnyxEntry} from 'react-native-onyx'; +import ConnectionLayout from '@components/ConnectionLayout'; +import type {FormOnyxValues, FormRef} from '@components/Form/types'; +import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; +import type {InteractiveStepSubHeaderHandle} from '@components/InteractiveStepSubHeader'; +import useSubStep from '@hooks/useSubStep'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as Connections from '@libs/actions/connections/NetSuiteCommands'; +import * as FormActions from '@libs/actions/FormActions'; +import Navigation from '@libs/Navigation/Navigation'; +import type {CustomFieldSubStepWithPolicy} from '@pages/workspace/accounting/netsuite/types'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import INPUT_IDS, {NetSuiteCustomFieldForm} from '@src/types/form/NetSuiteCustomFieldForm'; +import {Policy} from '@src/types/onyx'; +import {getCustomListInitialSubstep, getSubstepValues} from './customListUtils'; +import ChooseCustomListStep from './substeps/ChooseCustomListStep'; +import ConfirmCustomListStep from './substeps/ConfirmCustomListStep'; +import MappingStep from './substeps/MappingStep'; +import TransactionFieldIDStep from './substeps/TransactionFieldIDStep'; + +type NetSuiteImportAddCustomListContentProps = { + policy: OnyxEntry; + draftValues: OnyxEntry; +}; + +const formSteps = [ChooseCustomListStep, TransactionFieldIDStep, MappingStep, ConfirmCustomListStep]; + +function NetSuiteImportAddCustomListContent({policy, draftValues}: NetSuiteImportAddCustomListContentProps) { + const policyID = policy?.id ?? '-1'; + const styles = useThemeStyles(); + const ref: ForwardedRef = useRef(null); + const formRef = useRef(null); + + const values = useMemo(() => getSubstepValues(draftValues), [draftValues]); + const startFrom = useMemo(() => getCustomListInitialSubstep(values), [values]); + + const config = policy?.connections?.netsuite?.options?.config; + const customLists = useMemo(() => config?.syncOptions?.customLists ?? [], [config?.syncOptions]); + + const handleFinishStep = useCallback(() => { + InteractionManager.runAfterInteractions(() => { + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOM_FIELD_MAPPING.getRoute(policyID, CONST.NETSUITE_CONFIG.IMPORT_CUSTOM_FIELDS.CUSTOM_LISTS)); + }); + }, [policyID]); + + const { + componentToRender: SubStep, + isEditing, + nextScreen, + prevScreen, + screenIndex, + moveTo, + goToTheLastStep, + } = useSubStep({ + bodyContent: formSteps, + startFrom, + onFinished: handleFinishStep, + }); + + const handleBackButtonPress = () => { + if (isEditing) { + goToTheLastStep(); + return; + } + + // Clicking back on the first screen should go back to listing + if (screenIndex === CONST.NETSUITE_CUSTOM_FIELD_SUBSTEP_INDEXES.CUSTOM_LISTS.CUSTOM_LIST_PICKER) { + FormActions.clearDraftValues(ONYXKEYS.FORMS.NETSUITE_CUSTOM_LIST_ADD_FORM); + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOM_FIELD_MAPPING.getRoute(policyID, CONST.NETSUITE_CONFIG.IMPORT_CUSTOM_FIELDS.CUSTOM_LISTS)); + return; + } + ref.current?.movePrevious(); + formRef.current?.resetErrors(); + prevScreen(); + }; + + const handleNextScreen = useCallback(() => { + if (isEditing) { + goToTheLastStep(); + return; + } + ref.current?.moveNext(); + nextScreen(); + }, [goToTheLastStep, isEditing, nextScreen]); + + const updateNetSuiteCustomLists = useCallback( + (formValues: FormOnyxValues) => { + const updatedCustomLists = customLists.concat([ + { + listName: formValues[INPUT_IDS.LIST_NAME], + internalID: formValues[INPUT_IDS.INTERNAL_ID], + transactionFieldID: formValues[INPUT_IDS.TRANSACTION_FIELD_ID], + mapping: formValues[INPUT_IDS.MAPPING] ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, + }, + ]); + Connections.updateNetSuiteCustomLists( + policyID, + updatedCustomLists, + customLists, + `${CONST.NETSUITE_CONFIG.IMPORT_CUSTOM_FIELDS.CUSTOM_LISTS}_${customLists.length}`, + CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + ); + FormActions.clearDraftValues(ONYXKEYS.FORMS.NETSUITE_CUSTOM_LIST_ADD_FORM); + nextScreen(); + }, + [customLists, nextScreen, policyID], + ); + + return ( + + + + + + + + + ); +} + +NetSuiteImportAddCustomListContent.displayName = 'NetSuiteImportAddCustomListContent'; + +export default NetSuiteImportAddCustomListContent; diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomListPage.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomListPage.tsx index 84277e182d35..5c4e79e2685b 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomListPage.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteImportAddCustomListPage.tsx @@ -1,188 +1,24 @@ -import React, {useCallback, useMemo, useRef} from 'react'; -import type {ForwardedRef} from 'react'; -import {InteractionManager, View} from 'react-native'; -import ConnectionLayout from '@components/ConnectionLayout'; -import FormProvider from '@components/Form/FormProvider'; -import type {FormInputErrors, FormOnyxValues, FormRef} from '@components/Form/types'; -import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; -import type {InteractiveStepSubHeaderHandle} from '@components/InteractiveStepSubHeader'; -import useLocalize from '@hooks/useLocalize'; -import useSubStep from '@hooks/useSubStep'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as Connections from '@libs/actions/connections/NetSuiteCommands'; -import Navigation from '@libs/Navigation/Navigation'; -import * as ValidationUtils from '@libs/ValidationUtils'; -import type {CustomFieldSubStepWithPolicy} from '@pages/workspace/accounting/netsuite/types'; -import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; -import withPolicyConnections from '@pages/workspace/withPolicyConnections'; -import CONST from '@src/CONST'; +import React from 'react'; +import {useOnyx} from 'react-native-onyx'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import withPolicyConnections, {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import INPUT_IDS from '@src/types/form/NetSuiteCustomFieldForm'; -import ChooseCustomListStep from './substeps/ChooseCustomListStep'; -import ConfirmCustomListStep from './substeps/ConfirmCustomListStep'; -import MappingStep from './substeps/MappingStep'; -import TransactionFieldIDStep from './substeps/TransactionFieldIDStep'; - -const formSteps = [ChooseCustomListStep, TransactionFieldIDStep, MappingStep, ConfirmCustomListStep]; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import NetSuiteImportAddCustomListContent from './NetSuiteImportAddCustomListContent'; function NetSuiteImportAddCustomListPage({policy}: WithPolicyConnectionsProps) { - const policyID = policy?.id ?? '-1'; - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const ref: ForwardedRef = useRef(null); - const formRef = useRef(null); - - const config = policy?.connections?.netsuite?.options?.config; - const customLists = useMemo(() => config?.syncOptions?.customLists ?? [], [config?.syncOptions]); - - const handleFinishStep = useCallback(() => { - InteractionManager.runAfterInteractions(() => { - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOM_FIELD_MAPPING.getRoute(policyID, CONST.NETSUITE_CONFIG.IMPORT_CUSTOM_FIELDS.CUSTOM_LISTS)); - }); - }, [policyID]); - - const { - componentToRender: SubStep, - isEditing, - nextScreen, - prevScreen, - screenIndex, - moveTo, - goToTheLastStep, - } = useSubStep({ - bodyContent: formSteps, - startFrom: CONST.NETSUITE_CUSTOM_FIELD_SUBSTEP_INDEXES.CUSTOM_LISTS.CUSTOM_LIST_PICKER, - onFinished: handleFinishStep, - }); - - const handleBackButtonPress = () => { - if (isEditing) { - goToTheLastStep(); - return; - } - - // Clicking back on the first screen should go back to listing - if (screenIndex === CONST.NETSUITE_CUSTOM_FIELD_SUBSTEP_INDEXES.CUSTOM_LISTS.CUSTOM_LIST_PICKER) { - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NETSUITE_IMPORT_CUSTOM_FIELD_MAPPING.getRoute(policyID, CONST.NETSUITE_CONFIG.IMPORT_CUSTOM_FIELDS.CUSTOM_LISTS)); - return; - } - ref.current?.movePrevious(); - formRef.current?.resetErrors(); - prevScreen(); - }; - - const handleNextScreen = useCallback(() => { - if (isEditing) { - goToTheLastStep(); - return; - } - ref.current?.moveNext(); - nextScreen(); - }, [goToTheLastStep, isEditing, nextScreen]); - - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; - switch (screenIndex) { - case CONST.NETSUITE_CUSTOM_FIELD_SUBSTEP_INDEXES.CUSTOM_LISTS.CUSTOM_LIST_PICKER: - return ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.LIST_NAME]); - case CONST.NETSUITE_CUSTOM_FIELD_SUBSTEP_INDEXES.CUSTOM_LISTS.TRANSACTION_FIELD_ID: - if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.TRANSACTION_FIELD_ID])) { - const fieldLabel = translate(`workspace.netsuite.import.importCustomFields.customLists.fields.transactionFieldID`); - errors[INPUT_IDS.TRANSACTION_FIELD_ID] = translate('workspace.netsuite.import.importCustomFields.requiredFieldError', {fieldName: fieldLabel}); - } else if (customLists.find((customList) => customList.transactionFieldID.toLowerCase() === values[INPUT_IDS.TRANSACTION_FIELD_ID].toLowerCase())) { - errors[INPUT_IDS.TRANSACTION_FIELD_ID] = translate('workspace.netsuite.import.importCustomFields.customLists.errors.uniqueTransactionFieldIDError'); - } - return errors; - case CONST.NETSUITE_CUSTOM_FIELD_SUBSTEP_INDEXES.CUSTOM_LISTS.MAPPING: - if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.MAPPING])) { - errors[INPUT_IDS.MAPPING] = translate('common.error.pleaseSelectOne'); - } - return errors; - default: - return errors; - } - }, - [customLists, screenIndex, translate], - ); - - const updateNetSuiteCustomLists = useCallback( - (formValues: FormOnyxValues) => { - const updatedCustomLists = customLists.concat([ - { - listName: formValues[INPUT_IDS.LIST_NAME], - internalID: formValues[INPUT_IDS.INTERNAL_ID], - transactionFieldID: formValues[INPUT_IDS.TRANSACTION_FIELD_ID], - mapping: formValues[INPUT_IDS.MAPPING] ?? CONST.INTEGRATION_ENTITY_MAP_TYPES.TAG, - }, - ]); - Connections.updateNetSuiteCustomLists( - policyID, - updatedCustomLists, - customLists, - `${CONST.NETSUITE_CONFIG.IMPORT_CUSTOM_FIELDS.CUSTOM_LISTS}_${customLists.length}`, - CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - ); - nextScreen(); - }, - [customLists, nextScreen, policyID], - ); + const [draftValues, draftValuesMetadata] = useOnyx(ONYXKEYS.FORMS.NETSUITE_CUSTOM_LIST_ADD_FORM_DRAFT); + const isLoading = isLoadingOnyxValue(draftValuesMetadata); - const selectionListForm = [CONST.NETSUITE_CUSTOM_FIELD_SUBSTEP_INDEXES.CUSTOM_LISTS.MAPPING as number].includes(screenIndex); - const submitFlexAllowed = [ - CONST.NETSUITE_CUSTOM_FIELD_SUBSTEP_INDEXES.CUSTOM_LISTS.CUSTOM_LIST_PICKER as number, - CONST.NETSUITE_CUSTOM_FIELD_SUBSTEP_INDEXES.CUSTOM_LISTS.TRANSACTION_FIELD_ID as number, - ].includes(screenIndex); + if (isLoading) { + return ; + } return ( - - - - - - - - - - + ); } diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/customListUtils.ts b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/customListUtils.ts new file mode 100644 index 000000000000..480b96b0093d --- /dev/null +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/customListUtils.ts @@ -0,0 +1,30 @@ +import {OnyxEntry} from 'react-native-onyx'; +import CONST from '@src/CONST'; +import type {NetSuiteCustomFieldForm} from '@src/types/form'; +import INPUT_IDS from '@src/types/form/NetSuiteCustomFieldForm'; + +function getCustomListInitialSubstep(values: NetSuiteCustomFieldForm) { + if (!values[INPUT_IDS.LIST_NAME]) { + return CONST.NETSUITE_CUSTOM_FIELD_SUBSTEP_INDEXES.CUSTOM_LISTS.CUSTOM_LIST_PICKER; + } + if (!values[INPUT_IDS.TRANSACTION_FIELD_ID]) { + return CONST.NETSUITE_CUSTOM_FIELD_SUBSTEP_INDEXES.CUSTOM_LISTS.TRANSACTION_FIELD_ID; + } + if (!values[INPUT_IDS.MAPPING]) { + return CONST.NETSUITE_CUSTOM_FIELD_SUBSTEP_INDEXES.CUSTOM_LISTS.MAPPING; + } + return CONST.NETSUITE_CUSTOM_FIELD_SUBSTEP_INDEXES.CUSTOM_LISTS.CONFIRM; +} + +function getSubstepValues(NetSuitCustomFieldDraft: OnyxEntry): NetSuiteCustomFieldForm { + return { + [INPUT_IDS.LIST_NAME]: NetSuitCustomFieldDraft?.[INPUT_IDS.LIST_NAME] ?? '', + [INPUT_IDS.TRANSACTION_FIELD_ID]: NetSuitCustomFieldDraft?.[INPUT_IDS.TRANSACTION_FIELD_ID] ?? '', + [INPUT_IDS.MAPPING]: NetSuitCustomFieldDraft?.[INPUT_IDS.MAPPING] ?? '', + [INPUT_IDS.INTERNAL_ID]: NetSuitCustomFieldDraft?.[INPUT_IDS.INTERNAL_ID] ?? '', + [INPUT_IDS.SCRIPT_ID]: NetSuitCustomFieldDraft?.[INPUT_IDS.SCRIPT_ID] ?? '', + [INPUT_IDS.SEGMENT_NAME]: NetSuitCustomFieldDraft?.[INPUT_IDS.SEGMENT_NAME] ?? '', + }; +} + +export {getSubstepValues, getCustomListInitialSubstep}; diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/substeps/ChooseCustomListStep.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/substeps/ChooseCustomListStep.tsx index 473a01d5e7ce..721b14a0c6c3 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/substeps/ChooseCustomListStep.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/substeps/ChooseCustomListStep.tsx @@ -1,26 +1,58 @@ -import React from 'react'; +import React, {useCallback} from 'react'; +import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; +import {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; +import useNetSuiteImportAddCustomListFormSubmit from '@hooks/useNetSuiteImportAddCustomListForm'; +import usePersonalDetailsFormSubmit from '@hooks/usePersonalDetailsFormSubmit'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as ValidationUtils from '@libs/ValidationUtils'; import NetSuiteCustomListPicker from '@pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomListPicker'; import type {CustomFieldSubStepWithPolicy} from '@pages/workspace/accounting/netsuite/types'; +import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/NetSuiteCustomFieldForm'; -function ChooseCustomListStep({policy}: CustomFieldSubStepWithPolicy) { +const STEP_FIELDS = [INPUT_IDS.LIST_NAME, INPUT_IDS.INTERNAL_ID]; + +function ChooseCustomListStep({policy, onNext, isEditing, netSuiteCustomFieldFormValues}: CustomFieldSubStepWithPolicy) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const validate = useCallback( + (values: FormOnyxValues): FormInputErrors => { + return ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.LIST_NAME]); + }, + [translate], + ); + + const handleSubmit = useNetSuiteImportAddCustomListFormSubmit({ + fieldIds: STEP_FIELDS, + onNext, + shouldSaveDraft: true, + }); + return ( - <> + {translate(`workspace.netsuite.import.importCustomFields.customLists.addForm.listNameTitle`)} - + ); } diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/substeps/ConfirmCustomListStep.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/substeps/ConfirmCustomListStep.tsx index e72aa8710753..7000f50c09a4 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/substeps/ConfirmCustomListStep.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/substeps/ConfirmCustomListStep.tsx @@ -1,36 +1,58 @@ import React from 'react'; import {View} from 'react-native'; -import InputWrapper from '@components/Form/InputWrapper'; +import Button from '@components/Button'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; -import NetSuiteMenuWithTopDescriptionForm from '@pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteMenuWithTopDescriptionForm'; import type {CustomFieldSubStepWithPolicy} from '@pages/workspace/accounting/netsuite/types'; +import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import INPUT_IDS from '@src/types/form/NetSuiteCustomFieldForm'; -function ConfirmCustomListStep({onMove}: CustomFieldSubStepWithPolicy) { +function ConfirmCustomListStep({onMove, netSuiteCustomFieldFormValues: values, onNext}: CustomFieldSubStepWithPolicy) { const styles = useThemeStyles(); const {translate} = useLocalize(); - - const fieldNames = [INPUT_IDS.LIST_NAME, INPUT_IDS.TRANSACTION_FIELD_ID, INPUT_IDS.MAPPING]; + const {isOffline} = useNetwork(); return ( {translate('workspace.common.letsDoubleCheck')} - {fieldNames.map((fieldName, index) => ( - { - onMove(index); - }} - valueRenderer={(value) => (fieldName === INPUT_IDS.MAPPING && value ? translate(`workspace.netsuite.import.importTypes.${value}.label` as TranslationPaths) : value)} + { + onMove(CONST.NETSUITE_CUSTOM_FIELD_SUBSTEP_INDEXES.CUSTOM_LISTS.CUSTOM_LIST_PICKER); + }} + /> + { + onMove(CONST.NETSUITE_CUSTOM_FIELD_SUBSTEP_INDEXES.CUSTOM_LISTS.TRANSACTION_FIELD_ID); + }} + /> + { + onMove(CONST.NETSUITE_CUSTOM_FIELD_SUBSTEP_INDEXES.CUSTOM_LISTS.MAPPING); + }} + /> + +