From e3d26d7271a665baf88ea61b9b76ce2fd868da85 Mon Sep 17 00:00:00 2001 From: KJ21-ENG Date: Thu, 18 Jun 2026 01:45:24 +0530 Subject: [PATCH 01/30] Fix table page header scrolling --- src/components/Table/Table.tsx | 15 ++++- src/components/Table/TableBody.tsx | 54 ++++++++++++++++- src/components/Table/TableContext.tsx | 7 +++ src/components/Table/types.ts | 5 +- .../Tables/DomainListTable/index.tsx | 14 ++++- .../Tables/WorkspaceCategoriesTable/index.tsx | 18 +++--- .../WorkspaceCompanyCardsTable/index.tsx | 11 +--- .../WorkspaceDistanceRatesTable/index.tsx | 18 +++--- .../Tables/WorkspaceListTable/index.tsx | 14 ++++- .../Tables/WorkspaceRoomsTable/index.tsx | 15 ++++- src/components/WorkspaceListLayout.tsx | 47 ++++++++++----- src/pages/domain/DomainsListPage.tsx | 32 ++++++---- src/pages/workspace/WorkspacesListPage.tsx | 58 +++++++++++-------- .../categories/WorkspaceCategoriesPage.tsx | 36 ++++-------- .../distanceRates/PolicyDistanceRatesPage.tsx | 26 ++++----- .../workspace/rooms/WorkspaceRoomsPage.tsx | 25 ++++---- tests/ui/TableTest.tsx | 33 +++++++++++ 17 files changed, 288 insertions(+), 140 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 6ffc8ff304d6..98c7fb454903 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -150,6 +150,7 @@ function Table({data: sortedData, originalSelectableCount, selectedKeys, onRowSelectionChange}); const processedData = selectionMiddleware(sortedData); + const originalDataLength = data?.length ?? 0; + const isEmptyResult = processedData.length === 0 && originalDataLength > 0 && (hasActiveSearchString || hasAppliedFilters); + const shouldRenderStickyHeader = !!headerComponent && processedData.length > 0 && !isEmptyResult && !(shouldUseNarrowTableLayout && !title); const listRef = useRef>(null); @@ -225,14 +229,17 @@ function Table processedData; } + if (property === 'scrollToIndex') { + return (params: Parameters['scrollToIndex']>[0]) => { + listRef.current?.scrollToIndex({...params, index: params.index + (shouldRenderStickyHeader ? 1 : 0)}); + }; + } + return listRef.current?.[property as keyof FlashListRef]; }, }) as TableHandle; }); - const originalDataLength = data?.length ?? 0; - const isEmptyResult = processedData.length === 0 && originalDataLength > 0 && (hasActiveSearchString || hasAppliedFilters); - const handleMobileSelectionPress = () => { turnOnMobileSelectionMode(); @@ -245,6 +252,7 @@ function Table = { title, + headerComponent, listRef, listProps, processedData, @@ -257,6 +265,7 @@ function Table({contentContainerStyle, style, .. shouldUseNarrowTableLayout, hasActiveFilters, hasSearchString, + headerComponent, isEmptyResult, + shouldRenderStickyHeader, } = useTableContext(); - const {ListEmptyComponent, contentContainerStyle: listContentContainerStyle, ...restListProps} = listProps ?? {}; + const { + ListEmptyComponent, + ListHeaderComponent, + contentContainerStyle: listContentContainerStyle, + getItemType, + keyExtractor, + renderItem, + stickyHeaderIndices, + ...restListProps + } = listProps ?? {}; const tableBodyContentContainerStyle = useBottomSafeSafeAreaPaddingStyle({ addBottomSafeAreaPadding: true, @@ -82,6 +97,36 @@ function TableBody({contentContainerStyle, style, .. useDebouncedAccessibilityAnnouncement(message, isEmptyResult, activeSearchString); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const tableHeaderItem = {keyForList: TABLE_HEADER_KEY} as DataType; + const listData = shouldRenderStickyHeader ? [tableHeaderItem, ...filteredAndSortedData] : filteredAndSortedData; + const getDataIndex = (index: number) => (shouldRenderStickyHeader ? index - 1 : index); + const isTableHeaderItem = (index: number) => shouldRenderStickyHeader && index === 0; + + const renderListItem = (info: ListRenderItemInfo) => { + if (isTableHeaderItem(info.index)) { + return ; + } + + return renderItem?.({...info, index: getDataIndex(info.index)}) ?? null; + }; + + const keyExtractorForList = (item: DataType, index: number) => { + if (isTableHeaderItem(index)) { + return TABLE_HEADER_KEY; + } + + return keyExtractor?.(item, getDataIndex(index)) ?? item.keyForList; + }; + + const getItemTypeForList = (item: DataType, index: number, extraData: unknown) => { + if (isTableHeaderItem(index)) { + return TABLE_HEADER_KEY; + } + + return getItemType?.(item, getDataIndex(index), extraData); + }; + const EmptyResultComponent = ( ({contentContainerStyle, style, .. > ref={listRef} - data={filteredAndSortedData} + data={listData} style={[styles.flex1, styles.mnh0]} showsVerticalScrollIndicator={false} maintainVisibleContentPosition={{disabled: true}} + ListHeaderComponent={headerComponent ?? ListHeaderComponent} ListEmptyComponent={isEmptyResult ? EmptyResultComponent : ListEmptyComponent} + stickyHeaderIndices={shouldRenderStickyHeader ? [0] : stickyHeaderIndices} contentContainerStyle={[filteredAndSortedData.length === 0 && styles.flexGrow1, listContentContainerStyle, tableBodyContentContainerStyle, contentContainerStyle]} keyboardShouldPersistTaps="handled" + renderItem={renderListItem} + keyExtractor={keyExtractorForList} + getItemType={getItemTypeForList} {...restListProps} /> diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx index 61ce34cbc338..cf8caba44fd2 100644 --- a/src/components/Table/TableContext.tsx +++ b/src/components/Table/TableContext.tsx @@ -15,6 +15,9 @@ type TableContextValue | null>; @@ -54,6 +57,9 @@ type TableContextValue = { listProps: {} as SharedListProps, hasActiveFilters: false, hasSearchString: false, + shouldRenderStickyHeader: false, isEmptyResult: false, shouldUseNarrowTableLayout: false, isMobileSelectionEnabled: false, diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index bb1168abf553..57758b930deb 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -1,5 +1,5 @@ import type {FlashListProps, FlashListRef} from '@shopify/flash-list'; -import type {PropsWithChildren} from 'react'; +import type {PropsWithChildren, ReactElement} from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import type {FilterConfig, FilteringMethods, IsItemInFilterCallback} from './middlewares/filtering'; import type {IsItemInSearchCallback, SearchingMethods} from './middlewares/searching'; @@ -120,6 +120,9 @@ type TableProps= CONST.STANDARD_LIST_ITEM_LIMIT; + const tableHeaderComponent = ( + <> + {headerComponent} + {shouldShowSearchBar && } + + ); + const renderTableItem = ({item, index}: ListRenderItemInfo) => { return ( `${row.domainAccountID}-${index}`} > - {domains.length >= CONST.STANDARD_LIST_ITEM_LIMIT && } - ); diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index 8309fcda77f2..a11e1f7b2003 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -39,6 +39,7 @@ type WorkspaceCategoriesTableProps = { selectedKeys: string[]; onRowSelectionChange: (selectedRowKeys: string[]) => void; EmptyStateComponent: React.ReactElement; + headerComponent?: React.ReactElement; }; export default function WorkspaceCategoriesTable({ @@ -50,6 +51,7 @@ export default function WorkspaceCategoriesTable({ shouldShowApproverColumn, onRowSelectionChange, EmptyStateComponent, + headerComponent, }: WorkspaceCategoriesTableProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); @@ -137,6 +139,13 @@ export default function WorkspaceCategoriesTable({ ); const isEmpty = categories.length === 0; + const shouldShowSearchBar = categories.length >= CONST.STANDARD_LIST_ITEM_LIMIT; + const tableHeaderComponent = ( + <> + {headerComponent} + {shouldShowSearchBar && } + + ); return ( category.keyForList} onRowSelectionChange={onRowSelectionChange} + headerComponent={tableHeaderComponent} > {isEmpty && EmptyStateComponent} - {!isEmpty && ( - <> - {categories.length >= CONST.STANDARD_LIST_ITEM_LIMIT && } - - - - )} + {!isEmpty && }
); } diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx index 1679b0f7154d..df412069db42 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx @@ -362,13 +362,6 @@ function WorkspaceCompanyCardsTable({ /> ); - const ListHeader = ( - <> - {headerButtonsComponent} - {!isLoadingFeed && !isFeedPending && showCards && } - - ); - return ( ) : undefined} ListEmptyComponent={isLoadingCards ? LoadingComponent : } > - {!shouldUseNarrowTableLayout && ListHeader} + {!showCards && !shouldUseNarrowTableLayout && headerButtonsComponent} {(isLoading || isFeedPending || isNoFeed) && !feedErrorKey && ( diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx index 9f978e980a97..1e2bf95d649f 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx @@ -19,6 +19,7 @@ type WorkspaceDistanceRatesTableProps = { selectionEnabled: boolean; selectedKeys: string[]; onRowSelectionChange: (selectedRowKeys: string[]) => void; + headerComponent?: React.ReactElement; }; const STATUS_ORDER: Record = { @@ -28,7 +29,7 @@ const STATUS_ORDER: Record = { [CONST.CUSTOM_UNITS.RATE_STATUS.INACTIVE]: 3, }; -function WorkspaceDistanceRatesTable({ratesData, selectionEnabled, selectedKeys, onRowSelectionChange}: WorkspaceDistanceRatesTableProps) { +function WorkspaceDistanceRatesTable({ratesData, selectionEnabled, selectedKeys, onRowSelectionChange, headerComponent}: WorkspaceDistanceRatesTableProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); @@ -120,6 +121,12 @@ function WorkspaceDistanceRatesTable({ratesData, selectionEnabled, selectedKeys, const isEmpty = ratesData.length === 0; const shouldShowSearchBar = ratesData.length >= CONST.STANDARD_LIST_ITEM_LIMIT; + const tableHeaderComponent = ( + <> + {headerComponent} + {shouldShowSearchBar && } + + ); return (
- {!isEmpty && ( - <> - {shouldShowSearchBar && } - - - - )} + {!isEmpty && }
); } diff --git a/src/components/Tables/WorkspaceListTable/index.tsx b/src/components/Tables/WorkspaceListTable/index.tsx index 6bd55f1c460b..af3d0e4bb417 100644 --- a/src/components/Tables/WorkspaceListTable/index.tsx +++ b/src/components/Tables/WorkspaceListTable/index.tsx @@ -45,9 +45,10 @@ type WorkspaceRowData = TableData & { type WorkspaceListTableProps = { ref?: React.Ref> | undefined; workspaces: WorkspaceRowData[]; + headerComponent?: React.ReactElement; }; -export default function WorkspaceListTable({ref, workspaces}: WorkspaceListTableProps) { +export default function WorkspaceListTable({ref, workspaces, headerComponent}: WorkspaceListTableProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); @@ -100,6 +101,14 @@ export default function WorkspaceListTable({ref, workspaces}: WorkspaceListTable return item.title.toLowerCase().includes(searchValue.toLowerCase()); }; + const shouldShowSearchBar = workspaces.length >= CONST.STANDARD_LIST_ITEM_LIMIT; + const tableHeaderComponent = ( + <> + {headerComponent} + {shouldShowSearchBar && } + + ); + const renderTableItem = ({item, index}: ListRenderItemInfo) => { return ( `${row.policyID}-${index}`} > - {workspaces.length >= CONST.STANDARD_LIST_ITEM_LIMIT && } - ); diff --git a/src/components/Tables/WorkspaceRoomsTable/index.tsx b/src/components/Tables/WorkspaceRoomsTable/index.tsx index 2030d43de85e..e0b8c0945a8b 100644 --- a/src/components/Tables/WorkspaceRoomsTable/index.tsx +++ b/src/components/Tables/WorkspaceRoomsTable/index.tsx @@ -18,9 +18,12 @@ type WorkspaceRoomsTableProps = { /** The reportID of the room that should play the highlight animation (e.g. when it was just created) */ highlightedReportID?: string; + + /** Content rendered above the table header inside the scrollable list */ + headerComponent?: React.ReactElement; }; -function WorkspaceRoomsTable({rooms, highlightedReportID}: WorkspaceRoomsTableProps) { +function WorkspaceRoomsTable({rooms, highlightedReportID, headerComponent}: WorkspaceRoomsTableProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); @@ -71,6 +74,13 @@ function WorkspaceRoomsTable({rooms, highlightedReportID}: WorkspaceRoomsTablePr /> ); + const tableHeaderComponent = ( + <> + {headerComponent} + + + ); + return ( `${row.reportID}-${index}`} + headerComponent={tableHeaderComponent} > - -
); diff --git a/src/components/WorkspaceListLayout.tsx b/src/components/WorkspaceListLayout.tsx index d3f1d45de44c..f41ebadd08b6 100644 --- a/src/components/WorkspaceListLayout.tsx +++ b/src/components/WorkspaceListLayout.tsx @@ -1,4 +1,3 @@ -import type {PropsWithChildren} from 'react'; import React from 'react'; import {View} from 'react-native'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -16,10 +15,11 @@ import OfflineIndicator from './OfflineIndicator'; import ScreenWrapper from './ScreenWrapper'; import TabSelectorBase from './TabSelector/TabSelectorBase'; -type WorkspaceListLayoutProps = PropsWithChildren<{ +type WorkspaceListLayoutProps = { + children: React.ReactNode | ((headerComponent: React.ReactElement) => React.ReactNode); headerButton?: React.ReactNode; activeTabKey: 'workspaces' | 'domains'; -}>; +}; export default function WorkspaceListLayout({children, activeTabKey, headerButton}: WorkspaceListLayoutProps) { const styles = useThemeStyles(); @@ -60,6 +60,34 @@ export default function WorkspaceListLayout({children, activeTabKey, headerButto Navigation.navigate(matchingNavigationOption.route); }; + const headerComponent = ( + + + {headerButton} + + ); + const shouldRenderHeaderInTable = typeof children === 'function'; + const content = shouldRenderHeaderInTable ? ( + (children as (headerComponent: React.ReactElement) => React.ReactNode)(headerComponent) + ) : ( + <> + + + {shouldDisplayButtonsInSeparateLine && headerButton} + + + {children} + + ); + return ( - {!shouldDisplayButtonsInSeparateLine && headerButton} + {!shouldRenderHeaderInTable && {!shouldDisplayButtonsInSeparateLine && headerButton}} - - - {shouldDisplayButtonsInSeparateLine && headerButton} - - - {children} + {content} {!shouldUseNarrowLayout && } diff --git a/src/pages/domain/DomainsListPage.tsx b/src/pages/domain/DomainsListPage.tsx index 8277dc1963c4..82b447ac1df3 100644 --- a/src/pages/domain/DomainsListPage.tsx +++ b/src/pages/domain/DomainsListPage.tsx @@ -92,18 +92,28 @@ function DomainsListPage() { activeTabKey="domains" headerButton={headerButton} > - - {shouldShowLoadingIndicator && ( - - - - )} + {(headerComponent) => ( + + {shouldShowLoadingIndicator && ( + <> + {headerComponent} + + + + + )} - {!shouldShowLoadingIndicator && } - + {!shouldShowLoadingIndicator && ( + + )} + + )} ); } diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 31eee871a769..02afb6093d80 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -480,32 +480,40 @@ function WorkspacesListPage() { activeTabKey="workspaces" headerButton={headerButton} > - {shouldShowLoadingIndicator ? ( - - - - ) : ( - - )} - {!!policyIDToDelete && ( - setPolicyIDToDelete(undefined)} - /> + {(headerComponent) => ( + <> + {shouldShowLoadingIndicator ? ( + <> + {headerComponent} + + + + + ) : ( + + )} + {!!policyIDToDelete && ( + setPolicyIDToDelete(undefined)} + /> + )} + + )} - ); } diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index c0a611a302f3..bbf9e6bce953 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -684,32 +684,16 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { )} {!isLoading && ( - <> - {hasVisibleCategories && ( - - {!hasSyncError && isConnectionVerified && currentConnectionName ? ( - - ) : ( - {translate('workspace.categories.subtitle')} - )} - - )} - - setSelectedCategoryKeys(selectedRowKeys)} - EmptyStateComponent={emptyStateContent} - /> - + setSelectedCategoryKeys(selectedRowKeys)} + EmptyStateComponent={emptyStateContent} + headerComponent={hasVisibleCategories ? headerContent : undefined} + /> )} )} {!isLoading && ( - <> - {ratesData.length > 0 && ( - - {translate('workspace.distanceRates.centrallyManage')} - - )} - - + 0 ? ( + + {translate('workspace.distanceRates.centrallyManage')} + + ) : undefined + } + /> )} diff --git a/src/pages/workspace/rooms/WorkspaceRoomsPage.tsx b/src/pages/workspace/rooms/WorkspaceRoomsPage.tsx index d1d38618e0aa..777511167ce3 100644 --- a/src/pages/workspace/rooms/WorkspaceRoomsPage.tsx +++ b/src/pages/workspace/rooms/WorkspaceRoomsPage.tsx @@ -108,21 +108,22 @@ function WorkspaceRoomsPage({route}: WorkspaceRoomsPageProps) { )} - {shouldUseNarrowLayout && ( - -