diff --git a/src/CONST.ts b/src/CONST.ts index 745236dc52ec..8fd13e858b35 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1321,6 +1321,11 @@ const CONST = { }, THREAD_DISABLED: ['CREATED'], }, + TRANSACTION_LIST: { + COLUMNS: { + COMMENTS: 'comments', + }, + }, CANCEL_PAYMENT_REASONS: { ADMIN: 'CANCEL_REASON_ADMIN', }, diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index a085385589a3..43f64670ca99 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -100,11 +100,14 @@ type MoneyReportHeaderProps = { // eslint-disable-next-line react/no-unused-prop-types transactionThreadReportID: string | undefined; + /** Whether back button should be displayed in header */ + shouldDisplayBackButton?: boolean; + /** Method to trigger when pressing close button of the header */ onBackButtonPress: () => void; }; -function MoneyReportHeader({policy, report: moneyRequestReport, transactionThreadReportID, reportActions, onBackButtonPress}: MoneyReportHeaderProps) { +function MoneyReportHeader({policy, report: moneyRequestReport, transactionThreadReportID, reportActions, shouldDisplayBackButton = false, onBackButtonPress}: MoneyReportHeaderProps) { // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to use a correct layout for the hold expense modal https://github.com/Expensify/App/pull/47990#issuecomment-2362382026 // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); @@ -377,6 +380,8 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea setIsDeleteRequestModalVisible(false); }, [canDeleteRequest]); + const shouldShowBackButton = shouldDisplayBackButton || shouldUseNarrowLayout; + return ( , parentReportActionID: string | undefined): OnyxEntry { + if (!parentReportActions || !parentReportActionID) { + return; + } + return parentReportActions[parentReportActionID]; +} + +function isChatOnlyReportAction(action: OnyxTypes.ReportAction) { + return action.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU && action.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED; +} + +function getTransactionsForReportID(transactions: OnyxCollection, reportID: string) { + return Object.values(transactions ?? {}).filter((transaction): transaction is Transaction => { + return transaction?.reportID === reportID; + }); +} + +/** + * TODO make this component have the same functionalities as `ReportActionsList` + * - onLayout + * - onScroll + * - onScrollToIndexFailed + * - shouldEnableAutoScrollToTopThreshold + * - shouldDisplayNewMarker + * - shouldHideThreadDividerLine + */ +function MoneyRequestReportActionsList({report, reportActions = [], hasNewerActions, hasOlderActions}: MoneyRequestReportListProps) { + const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + + const reportID = report?.reportID; + + const [parentReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(report?.parentReportID)}`, { + canEvict: false, + selector: (parentReportActions) => getParentReportAction(parentReportActions, report?.parentReportActionID), + }); + + const mostRecentIOUReportActionID = useMemo(() => getMostRecentIOURequestActionID(reportActions), [reportActions]); + const transactionThreadReportID = getOneTransactionThreadReportID(reportID, reportActions ?? [], false); + const firstVisibleReportActionID = useMemo(() => getFirstVisibleReportActionID(reportActions, isOffline), [reportActions, isOffline]); + const [transactions = []] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { + selector: (allTransactions): OnyxTypes.Transaction[] => getTransactionsForReportID(allTransactions, reportID), + }); + const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID ?? CONST.DEFAULT_NUMBER_ID}`); + + const canPerformWriteAction = canUserPerformWriteAction(report); + + // We are reversing actions because in this View we are starting at the top and don't use Inverted list + const visibleReportActions = useMemo(() => { + const filteredActions = reportActions.filter((reportAction) => { + const isChatAction = isChatOnlyReportAction(reportAction); + + return ( + isChatAction && + (isOffline || isDeletedParentAction(reportAction) || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors) && + shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canPerformWriteAction) + ); + }); + + return filteredActions.toReversed(); + }, [reportActions, isOffline, canPerformWriteAction]); + + const reportActionIDs = useMemo(() => { + return reportActions?.map((action) => action.reportActionID) ?? []; + }, [reportActions]); + + const {loadOlderChats, loadNewerChats} = useLoadReportActions({ + reportID, + reportActions, + allReportActionIDs: reportActionIDs, + transactionThreadReport, + hasOlderActions, + hasNewerActions, + }); + + const onStartReached = useCallback(() => { + if (!isSearchTopmostFullScreenRoute()) { + loadNewerChats(false); + return; + } + + InteractionManager.runAfterInteractions(() => requestAnimationFrame(() => loadNewerChats(false))); + }, [loadNewerChats]); + + const onEndReached = useCallback(() => { + loadOlderChats(false); + }, [loadOlderChats]); + + const shouldUseThreadDividerLine = useMemo(() => { + const topReport = reportActions.length > 0 ? reportActions.at(reportActions.length - 1) : null; + + if (topReport && topReport.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) { + return false; + } + + if (isTransactionThread(parentReportAction)) { + return !isDeletedParentAction(parentReportAction) && !isReversedTransaction(parentReportAction); + } + + if (isTaskReport(report)) { + return !isCanceledTaskReport(report, parentReportAction); + } + + return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); + }, [parentReportAction, report, reportActions]); + + const renderItem = useCallback( + ({item: reportAction, index}: ListRenderItemInfo) => { + const displayAsGroup = + !isConsecutiveChronosAutomaticTimerAction(visibleReportActions, index, chatIncludesChronosWithID(reportAction?.reportID)) && + hasNextActionMadeBySameActor(visibleReportActions, index); + + return ( + 1} + isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} + /> + ); + }, + [visibleReportActions, reportActions, parentReportAction, report, transactionThreadReport, mostRecentIOUReportActionID, shouldUseThreadDividerLine, firstVisibleReportActionID], + ); + + return ( + + {report ? ( + item.reportActionID} + initialNumToRender={10} + onEndReached={onEndReached} + onEndReachedThreshold={0.75} + onStartReached={onStartReached} + onStartReachedThreshold={0.75} + ListHeaderComponent={} + keyboardShouldPersistTaps="handled" + /> + ) : ( + + )} + + ); +} + +MoneyRequestReportActionsList.displayName = 'MoneyRequestReportActionsList'; + +export default MoneyRequestReportActionsList; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTableHeader.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTableHeader.tsx new file mode 100644 index 000000000000..af970a2b92f5 --- /dev/null +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTableHeader.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {SearchColumnType, SortOrder} from '@components/Search/types'; +import SortableTableHeader from '@components/SelectionList/SortableTableHeader'; +import type {SortableColumnName} from '@components/SelectionList/types'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; + +type ColumnConfig = { + columnName: SortableColumnName; + translationKey: TranslationPaths | undefined; + isColumnSortable?: boolean; +}; + +const columnConfig: ColumnConfig[] = [ + { + columnName: CONST.SEARCH.TABLE_COLUMNS.RECEIPT, + translationKey: 'common.receipt', + isColumnSortable: false, + }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.TYPE, + translationKey: 'common.type', + isColumnSortable: false, + }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.DATE, + translationKey: 'common.date', + }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.MERCHANT, + translationKey: 'common.merchant', + }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.CATEGORY, + translationKey: 'common.category', + }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.TAG, + translationKey: 'common.tag', + }, + { + columnName: CONST.REPORT.TRANSACTION_LIST.COLUMNS.COMMENTS, + translationKey: undefined, // comments have no title displayed + isColumnSortable: false, + }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, + translationKey: 'common.total', + }, +]; + +type SearchTableHeaderProps = { + sortBy?: SearchColumnType; + sortOrder?: SortOrder; + onSortPress: (column: SortableColumnName, order: SortOrder) => void; + shouldShowSorting: boolean; +}; + +// At this moment with new Report View we have no extra logic for displaying columns +const shouldShowColumn = () => true; + +function MoneyRequestReportTableHeader({sortBy, sortOrder, onSortPress, shouldShowSorting}: SearchTableHeaderProps) { + const styles = useThemeStyles(); + + return ( + + + + ); +} + +MoneyRequestReportTableHeader.displayName = 'MoneyRequestReportTableHeader'; + +export default MoneyRequestReportTableHeader; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx new file mode 100644 index 000000000000..a1fa22a7863c --- /dev/null +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import {View} from 'react-native'; +import TransactionItemRow from '@components/TransactionItemRow'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type * as OnyxTypes from '@src/types/onyx'; +import MoneyRequestReportTableHeader from './MoneyRequestReportTableHeader'; + +type MoneyRequestReportTransactionListProps = { + /** List of transactions belonging to one report */ + transactions: OnyxTypes.Transaction[]; +}; + +function MoneyRequestReportTransactionList({transactions}: MoneyRequestReportTransactionListProps) { + const styles = useThemeStyles(); + const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); + + const displayNarrowVersion = isMediumScreenWidth || shouldUseNarrowLayout; + + return ( + <> + {!displayNarrowVersion && ( + {}} + /> + )} + + {transactions.map((transaction) => { + return ( + + + + ); + })} + + + ); +} + +MoneyRequestReportTransactionList.displayName = 'MoneyRequestReportTransactionList'; + +export default MoneyRequestReportTransactionList; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx new file mode 100644 index 000000000000..dcf75ce9ea1d --- /dev/null +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; +import HeaderGap from '@components/HeaderGap'; +import MoneyReportHeader from '@components/MoneyReportHeader'; +import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; +import useThemeStyles from '@hooks/useThemeStyles'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {canEditReportAction, getReportOfflinePendingActionAndErrors} from '@libs/ReportUtils'; +import Navigation from '@navigation/Navigation'; +import ReportFooter from '@pages/home/report/ReportFooter'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import MoneyRequestReportActionsList from './MoneyRequestReportActionsList'; + +type MoneyRequestReportViewProps = { + /** The report */ + report: OnyxEntry; + + /** Metadata for report */ + reportMetadata: OnyxEntry; + + /** Current policy */ + policy: OnyxEntry; + + /** Whether Report footer (that includes Composer) should be displayed */ + shouldDisplayReportFooter: boolean; +}; + +function getParentReportAction(parentReportActions: OnyxEntry, parentReportActionID: string | undefined): OnyxEntry { + if (!parentReportActions || !parentReportActionID) { + return; + } + return parentReportActions[parentReportActionID]; +} + +const noOp = () => {}; + +function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayReportFooter}: MoneyRequestReportViewProps) { + const styles = useThemeStyles(); + + const reportID = report?.reportID; + const [isComposerFullSize] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`, {initialValue: false}); + const {reportPendingAction} = getReportOfflinePendingActionAndErrors(report); + + const { + reportActions, + hasNewerActions, + hasOlderActions, + // sortedAllReportActions, + } = usePaginatedReportActions(reportID); + + const [parentReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(report?.parentReportID)}`, { + canEvict: false, + selector: (parentReportActions) => getParentReportAction(parentReportActions, report?.parentReportActionID), + }); + + const lastReportAction = [...reportActions, parentReportAction].find((action) => canEditReportAction(action) && !isMoneyRequestAction(action)); + + if (!report) { + return; + } + + return ( + + + { + Navigation.goBack(); + }} + /> + + {shouldDisplayReportFooter ? ( + + ) : null} + + ); +} + +MoneyRequestReportView.displayName = 'MoneyRequestReportView'; + +export default MoneyRequestReportView; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index ebeb5b8170cf..c5a10c43e76c 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -10,6 +10,7 @@ import SelectionListWithModal from '@components/SelectionListWithModal'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; +import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; @@ -150,6 +151,7 @@ function Search({queryJSON, currentSearchResults, lastNonEmptySearchResults, onS const previousReportActions = usePrevious(reportActions); const shouldGroupByReports = groupBy === CONST.SEARCH.GROUP_BY.REPORTS; + const {canUseTableReportView} = usePermissions(); const canSelectMultiple = isSmallScreenWidth ? !!selectionMode?.isEnabled : true; useEffect(() => { @@ -421,6 +423,11 @@ function Search({queryJSON, currentSearchResults, lastNonEmptySearchResults, onS const backTo = Navigation.getActiveRoute(); + if (canUseTableReportView && isReportListItemType(item)) { + Navigation.navigate(ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID, backTo})); + return; + } + if (isReportActionListItemType(item)) { const reportActionID = item.reportActionID; Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID, reportActionID, backTo})); diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index 7a573412871b..05cc2f280ef9 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -196,7 +196,7 @@ function ReportListItem({ {isLargeScreenWidth && ( - + )} - + - + - + - + - + - + {item.shouldShowCategory && ( - + )} {item.shouldShowTag && ( - + )} {item.shouldShowTax && ( - + )} - + - + boolean; type SearchColumnConfig = { columnName: SearchColumnType; translationKey: TranslationPaths; isColumnSortable?: boolean; - shouldShow: (data: OnyxTypes.SearchResults['data'], metadata: OnyxTypes.SearchResults['search']) => boolean; +}; + +const shouldShowColumnConfig: Record = { + [CONST.SEARCH.TABLE_COLUMNS.RECEIPT]: () => true, + [CONST.SEARCH.TABLE_COLUMNS.TYPE]: () => true, + [CONST.SEARCH.TABLE_COLUMNS.DATE]: () => true, + [CONST.SEARCH.TABLE_COLUMNS.MERCHANT]: (data: OnyxTypes.SearchResults['data']) => getShouldShowMerchant(data), + [CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION]: (data: OnyxTypes.SearchResults['data']) => !getShouldShowMerchant(data), + [CONST.SEARCH.TABLE_COLUMNS.FROM]: () => true, + [CONST.SEARCH.TABLE_COLUMNS.TO]: () => true, + [CONST.SEARCH.TABLE_COLUMNS.CATEGORY]: (data, metadata) => metadata?.columnsToShow?.shouldShowCategoryColumn ?? false, + [CONST.SEARCH.TABLE_COLUMNS.TAG]: (data, metadata) => metadata?.columnsToShow?.shouldShowTagColumn ?? false, + [CONST.SEARCH.TABLE_COLUMNS.TAX_AMOUNT]: (data, metadata) => metadata?.columnsToShow?.shouldShowTaxColumn ?? false, + [CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]: () => true, + [CONST.SEARCH.TABLE_COLUMNS.ACTION]: () => true, + // This column is never displayed on Search + [CONST.REPORT.TRANSACTION_LIST.COLUMNS.COMMENTS]: () => false, }; const expenseHeaders: SearchColumnConfig[] = [ { columnName: CONST.SEARCH.TABLE_COLUMNS.RECEIPT, translationKey: 'common.receipt', - shouldShow: () => true, isColumnSortable: false, }, { columnName: CONST.SEARCH.TABLE_COLUMNS.TYPE, translationKey: 'common.type', - shouldShow: () => true, isColumnSortable: false, }, { columnName: CONST.SEARCH.TABLE_COLUMNS.DATE, translationKey: 'common.date', - shouldShow: () => true, }, { columnName: CONST.SEARCH.TABLE_COLUMNS.MERCHANT, translationKey: 'common.merchant', - shouldShow: (data: OnyxTypes.SearchResults['data']) => getShouldShowMerchant(data), }, { columnName: CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION, translationKey: 'common.description', - shouldShow: (data: OnyxTypes.SearchResults['data']) => !getShouldShowMerchant(data), }, { columnName: CONST.SEARCH.TABLE_COLUMNS.FROM, translationKey: 'common.from', - shouldShow: () => true, }, { columnName: CONST.SEARCH.TABLE_COLUMNS.TO, translationKey: 'common.to', - shouldShow: () => true, }, { columnName: CONST.SEARCH.TABLE_COLUMNS.CATEGORY, translationKey: 'common.category', - shouldShow: (data, metadata) => metadata?.columnsToShow?.shouldShowCategoryColumn ?? false, }, { columnName: CONST.SEARCH.TABLE_COLUMNS.TAG, translationKey: 'common.tag', - shouldShow: (data, metadata) => metadata?.columnsToShow?.shouldShowTagColumn ?? false, }, { columnName: CONST.SEARCH.TABLE_COLUMNS.TAX_AMOUNT, translationKey: 'common.tax', - shouldShow: (data, metadata) => metadata?.columnsToShow?.shouldShowTaxColumn ?? false, isColumnSortable: false, }, { columnName: CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, translationKey: 'common.total', - shouldShow: () => true, }, { columnName: CONST.SEARCH.TABLE_COLUMNS.ACTION, translationKey: 'common.action', - shouldShow: () => true, isColumnSortable: false, }, ]; @@ -104,51 +108,47 @@ type SearchTableHeaderProps = { function SearchTableHeader({data, metadata, sortBy, sortOrder, onSortPress, shouldShowYear, shouldShowSorting}: SearchTableHeaderProps) { const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, isMediumScreenWidth} = useResponsiveLayout(); - const {translate} = useLocalize(); const displayNarrowVersion = isMediumScreenWidth || isSmallScreenWidth; - if (SearchColumns[metadata.type] === null) { - return; - } + const shouldShowColumn = useCallback( + (columnName: SortableColumnName) => { + const shouldShowFun = shouldShowColumnConfig[columnName]; + return shouldShowFun(data, metadata); + }, + [data, metadata], + ); if (displayNarrowVersion) { return; } - return ( - - - {SearchColumns[metadata.type]?.map(({columnName, translationKey, shouldShow, isColumnSortable}) => { - if (!shouldShow(data, metadata)) { - return null; - } + const columnConfig = SearchColumns[metadata.type]; - const isSortable = shouldShowSorting && isColumnSortable; - const isActive = sortBy === columnName; - const textStyle = columnName === CONST.SEARCH.TABLE_COLUMNS.RECEIPT ? StyleUtils.getTextOverflowStyle('clip') : null; + if (!columnConfig) { + return; + } - return ( - onSortPress(columnName, order)} - /> - ); - })} - - + return ( + { + if (columnName === CONST.REPORT.TRANSACTION_LIST.COLUMNS.COMMENTS) { + return; + } + onSortPress(columnName, order); + }} + /> ); } SearchTableHeader.displayName = 'SearchTableHeader'; export default SearchTableHeader; -export {SearchColumns}; diff --git a/src/components/SelectionList/SortableTableHeader.tsx b/src/components/SelectionList/SortableTableHeader.tsx new file mode 100644 index 000000000000..ff6009bb1224 --- /dev/null +++ b/src/components/SelectionList/SortableTableHeader.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; +import type {SortOrder} from '@components/Search/types'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import SortableHeaderText from './SortableHeaderText'; +import type {SortableColumnName} from './types'; + +type ColumnConfig = { + columnName: SortableColumnName; + translationKey: TranslationPaths | undefined; + isColumnSortable?: boolean; +}; + +type SearchTableHeaderProps = { + columns: ColumnConfig[]; + sortBy?: SortableColumnName; + sortOrder?: SortOrder; + shouldShowSorting: boolean; + dateColumnSize: 'normal' | 'wide'; + containerStyles?: StyleProp; + shouldShowColumn: (columnName: SortableColumnName) => boolean; + onSortPress: (column: SortableColumnName, order: SortOrder) => void; +}; + +function SortableTableHeader({columns, sortBy, sortOrder, shouldShowColumn, dateColumnSize, containerStyles, shouldShowSorting, onSortPress}: SearchTableHeaderProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + + return ( + + + {columns.map(({columnName, translationKey, isColumnSortable}) => { + if (!shouldShowColumn(columnName)) { + return null; + } + + const isSortable = shouldShowSorting && isColumnSortable; + const isActive = sortBy === columnName; + const textStyle = columnName === CONST.SEARCH.TABLE_COLUMNS.RECEIPT ? StyleUtils.getTextOverflowStyle('clip') : null; + + return ( + onSortPress(columnName, order)} + /> + ); + })} + + + ); +} + +SortableTableHeader.displayName = 'SortableTableHeader'; + +export default SortableTableHeader; diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index c409c6a61d38..82226fe1bc51 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -14,6 +14,7 @@ import type { } from 'react-native'; import type {AnimatedStyle} from 'react-native-reanimated'; import type {SearchRouterItem} from '@components/Search/SearchAutocompleteList'; +import type {SearchColumnType} from '@components/Search/types'; import type {BrickRoad} from '@libs/WorkspacesSettingsUtils'; // eslint-disable-next-line no-restricted-imports import type CursorStyles from '@styles/utils/cursor/types'; @@ -705,6 +706,8 @@ type ExtendedSectionListData = ExtendedSectionListData>; +type SortableColumnName = SearchColumnType | typeof CONST.REPORT.TRANSACTION_LIST.COLUMNS.COMMENTS; + export type { BaseListItemProps, SelectionListProps, @@ -732,4 +735,5 @@ export type { ValidListItem, ReportActionListItemType, ChatListItemProps, + SortableColumnName, }; diff --git a/src/components/TransactionItemRow/TransactionItemRowRBR.tsx b/src/components/TransactionItemRow/TransactionItemRowRBR.tsx new file mode 100644 index 000000000000..e9c1629e87d4 --- /dev/null +++ b/src/components/TransactionItemRow/TransactionItemRowRBR.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import type {ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import Icon from '@components/Icon'; +import {DotIndicator} from '@components/Icon/Expensicons'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useTransactionViolations from '@hooks/useTransactionViolations'; +import ViolationsUtils from '@libs/Violations/ViolationsUtils'; +import variables from '@styles/variables'; +import type Transaction from '@src/types/onyx/Transaction'; + +function TransactionItemRowRBR({transaction, containerStyles}: {transaction: Transaction; containerStyles?: ViewStyle[]}) { + const styles = useThemeStyles(); + const transactionViolations = useTransactionViolations(transaction?.transactionID); + const {translate} = useLocalize(); + const theme = useTheme(); + + const RBRmessages = transactionViolations + .map((violation, index) => { + const translation = ViolationsUtils.getViolationTranslation(violation, translate); + return index > 0 ? translation.charAt(0).toLowerCase() + translation.slice(1) : translation; + }) + .join(', '); + + return ( + transactionViolations.length > 0 && ( + + + + {RBRmessages} + + + ) + ); +} + +export default TransactionItemRowRBR; diff --git a/src/components/TransactionItemRow/index.tsx b/src/components/TransactionItemRow/index.tsx index 14428f934dde..ebce7c6eccfb 100644 --- a/src/components/TransactionItemRow/index.tsx +++ b/src/components/TransactionItemRow/index.tsx @@ -13,6 +13,7 @@ import ReceiptCell from './DataCells/ReceiptCell'; import TagCell from './DataCells/TagCell'; import TotalCell from './DataCells/TotalCell'; import TypeCell from './DataCells/TypeCell'; +import TransactionItemRowRBR from './TransactionItemRowRBR'; function TransactionItemRow({ transactionItem, @@ -29,6 +30,7 @@ function TransactionItemRow({ const StyleUtils = useStyleUtils(); const backgroundColor = isSelected ? styles.buttonDefaultBG : styles.highlightBG; + const hasCategoryOrTag = !!transactionItem.category || !!transactionItem.tag; return ( @@ -71,7 +73,7 @@ function TransactionItemRow({ - + + )} ) : ( {(hovered) => ( - + - + - + - + - + - + - + - + + )} diff --git a/src/hooks/useIsReportReadyToDisplay.ts b/src/hooks/useIsReportReadyToDisplay.ts new file mode 100644 index 000000000000..15cdc5190bb3 --- /dev/null +++ b/src/hooks/useIsReportReadyToDisplay.ts @@ -0,0 +1,24 @@ +import {useMemo} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {canUserPerformWriteAction} from '@libs/ReportUtils'; +import type {Report} from '@src/types/onyx'; + +function useIsReportReadyToDisplay(report: OnyxEntry, reportIDFromRoute: string | undefined) { + /** + * When false the report is not ready to be fully displayed + */ + const isCurrentReportLoadedFromOnyx = useMemo((): boolean => { + // This is necessary so that when we are retrieving the next report data from Onyx the ReportActionsView will remount completely + const isTransitioning = report && report?.reportID !== reportIDFromRoute; + return reportIDFromRoute !== '' && !!report?.reportID && !isTransitioning; + }, [report, reportIDFromRoute]); + + const isEditingDisabled = useMemo(() => !isCurrentReportLoadedFromOnyx || !canUserPerformWriteAction(report), [isCurrentReportLoadedFromOnyx, report]); + + return { + isCurrentReportLoadedFromOnyx, + isEditingDisabled, + }; +} + +export default useIsReportReadyToDisplay; diff --git a/src/hooks/useLoadReportActions.ts b/src/hooks/useLoadReportActions.ts new file mode 100644 index 000000000000..e4af06fd3332 --- /dev/null +++ b/src/hooks/useLoadReportActions.ts @@ -0,0 +1,129 @@ +import {useIsFocused} from '@react-navigation/native'; +import {useCallback, useMemo, useRef} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {getNewerActions, getOlderActions} from '@userActions/Report'; +import CONST from '@src/CONST'; +import type {Report, ReportAction} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import useNetwork from './useNetwork'; + +type UseLoadReportActionsArguments = { + /** The id of the current report */ + reportID: string; + + /** The id of the reportAction (if specific action was linked to */ + reportActionID?: string; + + /** The list of reportActions linked to the current report */ + reportActions: ReportAction[]; + + /** The IDs of all reportActions linked to the current report (may contain some extra actions) */ + allReportActionIDs: string[]; + + /** The transaction thread report associated with the current transaction, if any */ + transactionThreadReport: OnyxEntry; + + /** If the report has newer actions to load */ + hasNewerActions: boolean; + + /** If the report has older actions to load */ + hasOlderActions: boolean; +}; + +/** + * Provides reusable logic to get the functions for loading older/newer reportActions. + * Used in the report displaying components + */ +function useLoadReportActions({reportID, reportActionID, reportActions, allReportActionIDs, transactionThreadReport, hasOlderActions, hasNewerActions}: UseLoadReportActionsArguments) { + const didLoadOlderChats = useRef(false); + const didLoadNewerChats = useRef(false); + + const {isOffline} = useNetwork(); + const isFocused = useIsFocused(); + + const newestReportAction = useMemo(() => reportActions?.at(0), [reportActions]); + const oldestReportAction = useMemo(() => reportActions?.at(-1), [reportActions]); + + const reportActionIDMap = useMemo(() => { + return reportActions.map((action) => ({ + reportActionID: action.reportActionID, + reportID: allReportActionIDs?.includes(action.reportActionID) ? reportID : transactionThreadReport?.reportID, + })); + }, [reportActions, allReportActionIDs, reportID, transactionThreadReport?.reportID]); + + /** + * Retrieves the next set of reportActions for the chat once we are nearing the end of what we are currently + * displaying. + */ + const loadOlderChats = useCallback( + (force = false) => { + // Only fetch more if we are neither already fetching (so that we don't initiate duplicate requests) nor offline. + if (!force && isOffline) { + return; + } + + // Don't load more reportActions if we're already at the beginning of the chat history + if (!oldestReportAction || !hasOlderActions) { + return; + } + + didLoadOlderChats.current = true; + + if (!isEmptyObject(transactionThreadReport)) { + // Get older actions based on the oldest reportAction for the current report + const oldestActionCurrentReport = reportActionIDMap.findLast((item) => item.reportID === reportID); + getOlderActions(oldestActionCurrentReport?.reportID, oldestActionCurrentReport?.reportActionID); + + // Get older actions based on the oldest reportAction for the transaction thread report + const oldestActionTransactionThreadReport = reportActionIDMap.findLast((item) => item.reportID === transactionThreadReport.reportID); + getOlderActions(oldestActionTransactionThreadReport?.reportID, oldestActionTransactionThreadReport?.reportActionID); + } else { + // Retrieve the next REPORT.ACTIONS.LIMIT sized page of comments + getOlderActions(reportID, oldestReportAction.reportActionID); + } + }, + [isOffline, oldestReportAction, reportID, reportActionIDMap, transactionThreadReport, hasOlderActions], + ); + + const loadNewerChats = useCallback( + (force = false) => { + if ( + !force && + (!reportActionID || + !isFocused || + !newestReportAction || + !hasNewerActions || + isOffline || + // If there was an error only try again once on initial mount. We should also still load + // more in case we have cached messages. + didLoadNewerChats.current || + newestReportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + ) { + return; + } + + didLoadNewerChats.current = true; + + // If this is a one transaction report, ensure we load newer actions for both this report and the report associated with the transaction + if (!isEmptyObject(transactionThreadReport)) { + // Get newer actions based on the newest reportAction for the current report + const newestActionCurrentReport = reportActionIDMap.find((item) => item.reportID === reportID); + getNewerActions(newestActionCurrentReport?.reportID, newestActionCurrentReport?.reportActionID); + + // Get newer actions based on the newest reportAction for the transaction thread report + const newestActionTransactionThreadReport = reportActionIDMap.find((item) => item.reportID === transactionThreadReport.reportID); + getNewerActions(newestActionTransactionThreadReport?.reportID, newestActionTransactionThreadReport?.reportActionID); + } else if (newestReportAction) { + getNewerActions(reportID, newestReportAction.reportActionID); + } + }, + [reportActionID, isFocused, newestReportAction, hasNewerActions, isOffline, transactionThreadReport, reportActionIDMap, reportID], + ); + + return { + loadOlderChats, + loadNewerChats, + }; +} + +export default useLoadReportActions; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 12517dedb5ea..d884b38b8fbd 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -542,12 +542,25 @@ function extractLinksFromMessageHtml(reportAction: OnyxEntry): str * @param reportActions - all actions * @param actionIndex - index of the action */ -function findPreviousAction(reportActions: ReportAction[] | undefined, actionIndex: number): OnyxEntry { - if (!reportActions) { - return undefined; +function findPreviousAction(reportActions: ReportAction[], actionIndex: number): OnyxEntry { + for (let i = actionIndex + 1; i < reportActions.length; i++) { + // Find the next non-pending deletion report action, as the pending delete action means that it is not displayed in the UI, but still is in the report actions list. + // If we are offline, all actions are pending but shown in the UI, so we take the previous action, even if it is a delete. + if (isNetworkOffline || reportActions.at(i)?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return reportActions.at(i); + } } - for (let i = actionIndex + 1; i < reportActions.length; i++) { + return undefined; +} + +/** + * Returns the report action immediately after the specified index. + * @param reportActions - all actions + * @param actionIndex - index of the action + */ +function findNextAction(reportActions: ReportAction[], actionIndex: number): OnyxEntry { + for (let i = actionIndex - 1; i > 0; i--) { // Find the next non-pending deletion report action, as the pending delete action means that it is not displayed in the UI, but still is in the report actions list. // If we are offline, all actions are pending but shown in the UI, so we take the previous action, even if it is a delete. if (isNetworkOffline || reportActions.at(i)?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { @@ -564,9 +577,9 @@ function findPreviousAction(reportActions: ReportAction[] | undefined, actionInd * * @param actionIndex - index of the comment item in state to check */ -function isConsecutiveActionMadeByPreviousActor(reportActions: ReportAction[] | undefined, actionIndex: number): boolean { +function isConsecutiveActionMadeByPreviousActor(reportActions: ReportAction[], actionIndex: number): boolean { const previousAction = findPreviousAction(reportActions, actionIndex); - const currentAction = reportActions?.[actionIndex]; + const currentAction = reportActions.at(actionIndex); // It's OK for there to be no previous action, and in that case, false will be returned // so that the comment isn't grouped @@ -614,6 +627,60 @@ function isConsecutiveActionMadeByPreviousActor(reportActions: ReportAction[] | return currentAction.actorAccountID === previousAction.actorAccountID; } +// Todo combine with `isConsecutiveActionMadeByPreviousActor` so as to not duplicate logic (issue: https://github.com/Expensify/App/issues/58625) +function hasNextActionMadeBySameActor(reportActions: ReportAction[], actionIndex: number) { + const currentAction = reportActions.at(actionIndex); + const nextAction = findNextAction(reportActions, actionIndex); + + // Todo first should have avatar - verify that this works with long chats (issue: https://github.com/Expensify/App/issues/58625) + if (actionIndex === 0) { + return false; + } + + // It's OK for there to be no previous action, and in that case, false will be returned + // so that the comment isn't grouped + if (!currentAction || !nextAction) { + return true; + } + + // Comments are only grouped if they happen within 5 minutes of each other + if (new Date(currentAction.created).getTime() - new Date(nextAction.created).getTime() > 300000) { + return false; + } + + // Do not group if previous action was a created action + if (nextAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { + return false; + } + + // Do not group if previous or current action was a renamed action + if (nextAction.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED || currentAction.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) { + return false; + } + + // Do not group if the delegate account ID is different + if (nextAction.delegateAccountID !== currentAction.delegateAccountID) { + return false; + } + + // Do not group if one of previous / current action is report preview and another one is not report preview + if ((isReportPreviewAction(nextAction) && !isReportPreviewAction(currentAction)) || (isReportPreviewAction(currentAction) && !isReportPreviewAction(nextAction))) { + return false; + } + + if (isSubmittedAction(currentAction)) { + const currentActionAdminAccountID = currentAction.adminAccountID; + + return currentActionAdminAccountID === nextAction.actorAccountID || currentActionAdminAccountID === nextAction.adminAccountID; + } + + if (isSubmittedAction(nextAction)) { + return typeof nextAction.adminAccountID === 'number' ? currentAction.actorAccountID === nextAction.adminAccountID : currentAction.actorAccountID === nextAction.actorAccountID; + } + + return currentAction.actorAccountID === nextAction.actorAccountID; +} + function isChronosAutomaticTimerAction(reportAction: OnyxInputOrEntry, isChronosReport: boolean): boolean { const isAutomaticStartTimerAction = () => /start(?:ed|ing)?(?:\snow)?/i.test(getReportActionText(reportAction)); const isAutomaticStopTimerAction = () => /stop(?:ped|ping)?(?:\snow)?/i.test(getReportActionText(reportAction)); @@ -2269,6 +2336,7 @@ export { isClosedAction, isConsecutiveActionMadeByPreviousActor, isConsecutiveChronosAutomaticTimerAction, + hasNextActionMadeBySameActor, isCreatedAction, isCreatedTaskReportAction, isCurrentActionUnread, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index e381fba1a0de..ade03988382f 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -7,7 +7,6 @@ import type {SearchColumnType, SearchStatus, SortOrder} from '@components/Search import ChatListItem from '@components/SelectionList/ChatListItem'; import ReportListItem from '@components/SelectionList/Search/ReportListItem'; import TransactionListItem from '@components/SelectionList/Search/TransactionListItem'; -import {SearchColumns} from '@components/SelectionList/SearchTableHeader'; import type {ListItem, ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import * as Expensicons from '@src/components/Icon/Expensicons'; import CONST from '@src/CONST'; @@ -794,7 +793,7 @@ function createBaseSavedSearchMenuItem(item: SaveSearchItem, key: string, index: * Whether to show the empty state or not */ function shouldShowEmptyState(isDataLoaded: boolean, dataLength: number, type: SearchDataTypes) { - return !isDataLoaded || dataLength === 0 || !Object.hasOwn(SearchColumns, type); + return !isDataLoaded || dataLength === 0 || !Object.values(CONST.SEARCH.DATA_TYPES).includes(type); } export { diff --git a/src/pages/Search/SearchMoneyRequestReportPage.tsx b/src/pages/Search/SearchMoneyRequestReportPage.tsx index a2af16a67a6e..57ff2a38dcff 100644 --- a/src/pages/Search/SearchMoneyRequestReportPage.tsx +++ b/src/pages/Search/SearchMoneyRequestReportPage.tsx @@ -1,76 +1,59 @@ -import React from 'react'; +import {PortalHost} from '@gorhom/portal'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; +import DragAndDropProvider from '@components/DragAndDrop/Provider'; import HeaderGap from '@components/HeaderGap'; -import MoneyReportHeader from '@components/MoneyReportHeader'; +import MoneyRequestReportView from '@components/MoneyRequestReportView/MoneyRequestReportView'; import BottomTabBar from '@components/Navigation/BottomTabBar'; import BOTTOM_TABS from '@components/Navigation/BottomTabBar/BOTTOM_TABS'; import TopBar from '@components/Navigation/TopBar'; import ScreenWrapper from '@components/ScreenWrapper'; import type {SearchQueryJSON} from '@components/Search/types'; +import useIsReportReadyToDisplay from '@hooks/useIsReportReadyToDisplay'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {openReport} from '@userActions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; -import type * as OnyxTypes from '@src/types/onyx'; import SearchTypeMenu from './SearchTypeMenu'; -type SearchPageProps = PlatformStackScreenProps; - // We will figure out later, when this view is actually being finalized, how to pass down an actual query const tempJSONQuery = buildSearchQueryJSON('') as unknown as SearchQueryJSON; -type TemporaryMoneyRequestReportViewProps = { - /** The report */ - report: OnyxEntry; +type SearchPageProps = PlatformStackScreenProps; - /** The policy tied to the expense report */ - policy: OnyxEntry; +const defaultReportMetadata = { + isLoadingInitialReportActions: true, + isLoadingOlderReportActions: false, + hasLoadingOlderReportActionsError: false, + isLoadingNewerReportActions: false, + hasLoadingNewerReportActionsError: false, + isOptimisticReport: false, }; -/** - * TODO - * This is a completely temporary component, displayed to: - * - show other devs that SearchMoneyRequestReportPage works - * - unblock work for other devs for Report Creation (https://github.com/Expensify/App/issues/57654) - * - * This component is not displayed to any users. - * It will be removed once we fully implement SearchMoneyRequestReportPage (https://github.com/Expensify/App/issues/57508) - */ -function TemporaryMoneyRequestReportView({report, policy}: TemporaryMoneyRequestReportViewProps) { - const styles = useThemeStyles(); - return ( - - - { - Navigation.goBack(); - }} - /> - - ); -} - function SearchMoneyRequestReportPage({route}: SearchPageProps) { const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const styles = useThemeStyles(); const {reportID} = route.params; + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {allowStaleData: true}); + const [reportMetadata = defaultReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {allowStaleData: true, initialValue: {}}); const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; + const {isEditingDisabled, isCurrentReportLoadedFromOnyx} = useIsReportReadyToDisplay(report, reportID); + + useEffect(() => { + openReport(reportID); + }, [reportID]); + if (shouldUseNarrowLayout) { return ( - ); @@ -106,10 +91,19 @@ function SearchMoneyRequestReportPage({route}: SearchPageProps) { - + + + + + + + + ); diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index f29a4d85fc56..1e859776d93f 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -20,6 +20,7 @@ import useAppFocusEvent from '@hooks/useAppFocusEvent'; import type {CurrentReportIDContextValue} from '@hooks/useCurrentReportID'; import useCurrentReportID from '@hooks/useCurrentReportID'; import useDeepCompareRef from '@hooks/useDeepCompareRef'; +import useIsReportReadyToDisplay from '@hooks/useIsReportReadyToDisplay'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; @@ -148,7 +149,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const [userLeavingStatus] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${reportIDFromRoute}`, {initialValue: false}); const [reportOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`, {allowStaleData: true}); const [reportNameValuePairsOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportIDFromRoute}`, {allowStaleData: true}); - const [reportMetadata = defaultReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportIDFromRoute}`, {initialValue: defaultReportMetadata}); + const [reportMetadata = defaultReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportIDFromRoute}`); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {allowStaleData: true, initialValue: {}}); const [parentReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(reportOnyx?.parentReportID)}`, { canEvict: false, @@ -358,14 +359,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { ); } - /** - * When false the ReportActionsView will completely unmount and we will show a loader until it returns true. - */ - const isCurrentReportLoadedFromOnyx = useMemo((): boolean => { - // This is necessary so that when we are retrieving the next report data from Onyx the ReportActionsView will remount completely - const isTransitioning = report && report?.reportID !== reportIDFromRoute; - return reportIDFromRoute !== '' && !!report?.reportID && !isTransitioning; - }, [report, reportIDFromRoute]); + const {isEditingDisabled, isCurrentReportLoadedFromOnyx} = useIsReportReadyToDisplay(report, reportIDFromRoute); const isLinkedActionDeleted = useMemo( () => !!linkedAction && !shouldReportActionBeVisible(linkedAction, linkedAction.reportActionID, canUserPerformWriteAction(report)), @@ -722,7 +716,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { shouldShowButton /> )} - + )} - {isCurrentReportLoadedFromOnyx ? ( void; - /** Should enable auto scroll to top threshold */ shouldEnableAutoScrollToTopThreshold?: boolean; }; @@ -154,7 +151,6 @@ function ReportActionsList({ onLayout, isComposerFullSize, listID, - onContentSizeChange, shouldEnableAutoScrollToTopThreshold, parentReportActionForTransactionThread, }: ReportActionsListProps) { @@ -678,12 +674,6 @@ function ReportActionsList({ }, [isScrollToBottomEnabled, onLayout, reportScrollManager], ); - const onContentSizeChangeInner = useCallback( - (w: number, h: number) => { - onContentSizeChange(w, h); - }, - [onContentSizeChange], - ); const retryLoadNewerChatsError = useCallback(() => { loadNewerChats(true); @@ -768,7 +758,6 @@ function ReportActionsList({ ListFooterComponent={listFooterComponent} keyboardShouldPersistTaps="handled" onLayout={onLayoutInner} - onContentSizeChange={onContentSizeChangeInner} onScroll={trackVerticalScrolling} onScrollToIndexFailed={onScrollToIndexFailed} extraData={extraData} diff --git a/src/pages/home/report/ReportActionsListItemRenderer.tsx b/src/pages/home/report/ReportActionsListItemRenderer.tsx index 1a0d8db13661..eaceaf1df35e 100644 --- a/src/pages/home/report/ReportActionsListItemRenderer.tsx +++ b/src/pages/home/report/ReportActionsListItemRenderer.tsx @@ -41,7 +41,7 @@ type ReportActionsListItemRendererProps = { /** Should we display the new marker on top of the comment? */ shouldDisplayNewMarker: boolean; - /** Linked report action ID */ + /** Report action ID that was referenced in the deeplink to report */ linkedReportActionID?: string; /** Whether we should display "Replies" divider */ diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index 70f953f49d91..f5b6f04b5a2d 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -5,10 +5,11 @@ import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; +import useLoadReportActions from '@hooks/useLoadReportActions'; import useNetwork from '@hooks/useNetwork'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import {getNewerActions, getOlderActions, updateLoadingInitialReportAction} from '@libs/actions/Report'; +import {updateLoadingInitialReportAction} from '@libs/actions/Report'; import Timing from '@libs/actions/Timing'; import DateUtils from '@libs/DateUtils'; import getIsReportFullyVisible from '@libs/getIsReportFullyVisible'; @@ -84,12 +85,9 @@ function ReportActionsView({ const reportActionID = route?.params?.reportActionID; const prevReportActionID = usePrevious(reportActionID); const didLayout = useRef(false); - const didLoadOlderChats = useRef(false); - const didLoadNewerChats = useRef(false); const {isOffline} = useNetwork(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const contentListHeight = useRef(0); const isFocused = useIsFocused(); const [isNavigatingToLinkedMessage, setNavigatingToLinkedMessage] = useState(!!reportActionID); const prevShouldUseNarrowLayoutRef = useRef(shouldUseNarrowLayout); @@ -200,21 +198,12 @@ function ReportActionsView({ [reportActions, isOffline, canPerformWriteAction], ); - const reportActionIDMap = useMemo(() => { - const reportActionIDs = allReportActions?.map((action) => action.reportActionID); - return reportActions.map((action) => ({ - reportActionID: action.reportActionID, - reportID: reportActionIDs?.includes(action.reportActionID) ? reportID : transactionThreadReport?.reportID, - })); - }, [allReportActions, reportID, transactionThreadReport, reportActions]); - const newestReportAction = useMemo(() => reportActions?.at(0), [reportActions]); const mostRecentIOUReportActionID = useMemo(() => getMostRecentIOURequestActionID(reportActions), [reportActions]); const lastActionCreated = visibleReportActions.at(0)?.created; const isNewestAction = (actionCreated: string | undefined, lastVisibleActionCreated: string | undefined) => actionCreated && lastVisibleActionCreated ? actionCreated >= lastVisibleActionCreated : actionCreated === lastVisibleActionCreated; const hasNewestReportAction = isNewestAction(lastActionCreated, report.lastVisibleActionCreated) || isNewestAction(lastActionCreated, transactionThreadReport?.lastVisibleActionCreated); - const oldestReportAction = useMemo(() => reportActions?.at(-1), [reportActions]); useEffect(() => { // update ref with current state @@ -222,78 +211,19 @@ function ReportActionsView({ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [shouldUseNarrowLayout, reportActions, isReportFullyVisible]); - const onContentSizeChange = useCallback((w: number, h: number) => { - contentListHeight.current = h; - }, []); - - /** - * Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently - * displaying. - */ - const loadOlderChats = useCallback( - (force = false) => { - // Only fetch more if we are neither already fetching (so that we don't initiate duplicate requests) nor offline. - if (!force && isOffline) { - return; - } - - // Don't load more chats if we're already at the beginning of the chat history - if (!oldestReportAction || !hasOlderActions) { - return; - } - - didLoadOlderChats.current = true; - - if (!isEmptyObject(transactionThreadReport)) { - // Get older actions based on the oldest reportAction for the current report - const oldestActionCurrentReport = reportActionIDMap.findLast((item) => item.reportID === reportID); - getOlderActions(oldestActionCurrentReport?.reportID, oldestActionCurrentReport?.reportActionID); - - // Get older actions based on the oldest reportAction for the transaction thread report - const oldestActionTransactionThreadReport = reportActionIDMap.findLast((item) => item.reportID === transactionThreadReport.reportID); - getOlderActions(oldestActionTransactionThreadReport?.reportID, oldestActionTransactionThreadReport?.reportActionID); - } else { - // Retrieve the next REPORT.ACTIONS.LIMIT sized page of comments - getOlderActions(reportID, oldestReportAction.reportActionID); - } - }, - [isOffline, oldestReportAction, reportID, reportActionIDMap, transactionThreadReport, hasOlderActions], - ); - - const loadNewerChats = useCallback( - (force = false) => { - if ( - !force && - (!reportActionID || - !isFocused || - !newestReportAction || - !hasNewerActions || - isOffline || - // If there was an error only try again once on initial mount. We should also still load - // more in case we have cached messages. - didLoadNewerChats.current || - newestReportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) - ) { - return; - } - - didLoadNewerChats.current = true; - - // If this is a one transaction report, ensure we load newer actions for both this report and the report associated with the transaction - if (!isEmptyObject(transactionThreadReport)) { - // Get newer actions based on the newest reportAction for the current report - const newestActionCurrentReport = reportActionIDMap.find((item) => item.reportID === reportID); - getNewerActions(newestActionCurrentReport?.reportID, newestActionCurrentReport?.reportActionID); - - // Get newer actions based on the newest reportAction for the transaction thread report - const newestActionTransactionThreadReport = reportActionIDMap.find((item) => item.reportID === transactionThreadReport.reportID); - getNewerActions(newestActionTransactionThreadReport?.reportID, newestActionTransactionThreadReport?.reportActionID); - } else if (newestReportAction) { - getNewerActions(reportID, newestReportAction.reportActionID); - } - }, - [reportActionID, isFocused, newestReportAction, hasNewerActions, isOffline, transactionThreadReport, reportActionIDMap, reportID], - ); + const allReportActionIDs = useMemo(() => { + return allReportActions?.map((action) => action.reportActionID) ?? []; + }, [allReportActions]); + + const {loadOlderChats, loadNewerChats} = useLoadReportActions({ + reportID, + reportActionID, + reportActions, + allReportActionIDs, + transactionThreadReport, + hasOlderActions, + hasNewerActions, + }); /** * Runs when the FlatList finishes laying out @@ -364,7 +294,6 @@ function ReportActionsView({ loadOlderChats={loadOlderChats} loadNewerChats={loadNewerChats} listID={listID} - onContentSizeChange={onContentSizeChange} shouldEnableAutoScrollToTopThreshold={shouldEnableAutoScroll} /> diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index b6ee3085d981..a7f7b537e359 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -1617,14 +1617,14 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ return isDragging ? styles.cursorGrabbing : styles.cursorZoomOut; }, - getSearchTableColumnStyles: (columnName: string, shouldExtendDateColumn = false): ViewStyle => { + getReportTableColumnStyles: (columnName: string, isDateColumnWide = false): ViewStyle => { let columnWidth; switch (columnName) { case CONST.SEARCH.TABLE_COLUMNS.RECEIPT: columnWidth = {...getWidthStyle(variables.w36), ...styles.alignItemsCenter}; break; case CONST.SEARCH.TABLE_COLUMNS.DATE: - columnWidth = getWidthStyle(shouldExtendDateColumn ? variables.w92 : variables.w52); + columnWidth = getWidthStyle(isDateColumnWide ? variables.w92 : variables.w52); break; case CONST.SEARCH.TABLE_COLUMNS.MERCHANT: case CONST.SEARCH.TABLE_COLUMNS.FROM: diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx index dff140a3e63a..2dd7ed401fc2 100644 --- a/tests/perf-test/ReportActionsList.perf-test.tsx +++ b/tests/perf-test/ReportActionsList.perf-test.tsx @@ -117,7 +117,6 @@ function ReportActionsListWrapper() { report={report} onLayout={mockOnLayout} onScroll={mockOnScroll} - onContentSizeChange={() => {}} listID={1} loadOlderChats={mockLoadChats} loadNewerChats={mockLoadChats}