Skip to content

Commit c05478c

Browse files
authored
Merge pull request Expensify#61371 from nkdengineer/fix/60906
Make expense report rows collapsable/expandable on reports page
2 parents cde16e2 + 50d38af commit c05478c

15 files changed

Lines changed: 563 additions & 183 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import React, {useEffect, useRef} from 'react';
2+
import type {ReactNode} from 'react';
3+
import {View} from 'react-native';
4+
import type {StyleProp, ViewStyle} from 'react-native';
5+
import Animated, {useAnimatedStyle, useDerivedValue, useSharedValue, withTiming} from 'react-native-reanimated';
6+
import Icon from '@components/Icon';
7+
import * as Expensicons from '@components/Icon/Expensicons';
8+
import {easing} from '@components/Modal/ReanimatedModal/utils';
9+
import {PressableWithFeedback} from '@components/Pressable';
10+
import useTheme from '@hooks/useTheme';
11+
import useThemeStyles from '@hooks/useThemeStyles';
12+
import CONST from '@src/CONST';
13+
14+
type AnimatedCollapsibleProps = {
15+
/** Whether the component is expanded */
16+
isExpanded: boolean;
17+
18+
/** Element that is inside the collapsible area */
19+
children: ReactNode;
20+
21+
/** Header content to display above the collapsible content */
22+
header: ReactNode;
23+
24+
/** Duration of expansion animation */
25+
duration?: number;
26+
27+
/** Additional external style for the container */
28+
style?: StyleProp<ViewStyle>;
29+
30+
/** Style for the header container */
31+
headerStyle?: StyleProp<ViewStyle>;
32+
33+
/** Style for the content container */
34+
contentStyle?: StyleProp<ViewStyle>;
35+
36+
/** Style for the toggle button */
37+
expandButtonStyle?: StyleProp<ViewStyle>;
38+
39+
/** Whether the toggle button is disabled */
40+
disabled?: boolean;
41+
42+
/** Callback for when the toggle button is pressed */
43+
onPress: () => void;
44+
};
45+
46+
function AnimatedCollapsible({isExpanded, children, header, duration = 300, style, headerStyle, contentStyle, expandButtonStyle, onPress, disabled = false}: AnimatedCollapsibleProps) {
47+
const theme = useTheme();
48+
const styles = useThemeStyles();
49+
const contentHeight = useSharedValue(0);
50+
const isAnimating = useSharedValue(false);
51+
const hasExpanded = useSharedValue(false);
52+
const isExpandedFirstTime = useRef(false);
53+
54+
useEffect(() => {
55+
if (!isExpanded && !isExpandedFirstTime.current) {
56+
return;
57+
}
58+
if (isExpandedFirstTime.current) {
59+
hasExpanded.set(true);
60+
} else {
61+
isExpandedFirstTime.current = true;
62+
}
63+
}, [hasExpanded, isExpanded]);
64+
65+
// Animation for content height and opacity
66+
const derivedHeight = useDerivedValue(() => {
67+
const targetHeight = isExpanded ? contentHeight.get() : 0;
68+
return withTiming(
69+
targetHeight,
70+
{
71+
duration,
72+
easing,
73+
},
74+
(finished) => {
75+
if (!finished) {
76+
return;
77+
}
78+
isAnimating.set(false);
79+
},
80+
);
81+
});
82+
83+
const derivedOpacity = useDerivedValue(() => {
84+
const targetOpacity = isExpanded ? 1 : 0;
85+
isAnimating.set(true);
86+
return withTiming(targetOpacity, {
87+
duration,
88+
easing,
89+
});
90+
});
91+
92+
const contentAnimatedStyle = useAnimatedStyle(() => {
93+
if (!isExpanded && !hasExpanded.get()) {
94+
return {
95+
height: 0,
96+
opacity: 0,
97+
overflow: 'hidden',
98+
};
99+
}
100+
101+
return {
102+
height: !hasExpanded.get() ? undefined : derivedHeight.get(),
103+
opacity: derivedOpacity.get(),
104+
overflow: isAnimating.get() ? 'hidden' : 'visible',
105+
};
106+
});
107+
108+
return (
109+
<View style={style}>
110+
<View style={[headerStyle, styles.flexRow, styles.alignItemsCenter]}>
111+
<View style={[styles.flex1]}>{header}</View>
112+
<PressableWithFeedback
113+
onPress={onPress}
114+
disabled={disabled}
115+
style={[styles.p3, styles.justifyContentCenter, styles.alignItemsCenter, styles.pl0, expandButtonStyle]}
116+
accessibilityRole={CONST.ROLE.BUTTON}
117+
accessibilityLabel={isExpanded ? 'Collapse' : 'Expand'}
118+
>
119+
{({hovered}) => (
120+
<Icon
121+
src={isExpanded ? Expensicons.UpArrow : Expensicons.DownArrow}
122+
fill={hovered ? theme.textSupporting : theme.icon}
123+
small
124+
/>
125+
)}
126+
</PressableWithFeedback>
127+
</View>
128+
<Animated.View style={[contentAnimatedStyle, contentStyle]}>
129+
<View
130+
onLayout={(e) => {
131+
if (!e.nativeEvent.layout.height) {
132+
return;
133+
}
134+
if (!isExpanded) {
135+
hasExpanded.set(true);
136+
}
137+
contentHeight.set(e.nativeEvent.layout.height);
138+
}}
139+
>
140+
<View style={[styles.pv2, styles.ph3]}>
141+
<View style={[styles.borderBottom]} />
142+
</View>
143+
{children}
144+
</View>
145+
</Animated.View>
146+
</View>
147+
);
148+
}
149+
150+
AnimatedCollapsible.displayName = 'AnimatedCollapsible';
151+
152+
export default AnimatedCollapsible;

src/components/Search/SearchList/index.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import Text from '@components/Text';
2424
import useInitialWindowDimensions from '@hooks/useInitialWindowDimensions';
2525
import useKeyboardState from '@hooks/useKeyboardState';
2626
import useLocalize from '@hooks/useLocalize';
27+
import useNetwork from '@hooks/useNetwork';
2728
import useOnyx from '@hooks/useOnyx';
2829
import usePrevious from '@hooks/usePrevious';
2930
import useResponsiveLayout from '@hooks/useResponsiveLayout';
@@ -60,7 +61,7 @@ type SearchListProps = Pick<FlashListProps<SearchListItem>, 'onScroll' | 'conten
6061
canSelectMultiple: boolean;
6162

6263
/** Callback to fire when a checkbox is pressed */
63-
onCheckboxPress: (item: SearchListItem) => void;
64+
onCheckboxPress: (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => void;
6465

6566
/** Callback to fire when "Select All" checkbox is pressed. Only use along with `canSelectMultiple` */
6667
onAllCheckboxPress: () => void;
@@ -167,6 +168,7 @@ function SearchList(
167168
);
168169

169170
const {translate} = useLocalize();
171+
const {isOffline} = useNetwork();
170172
const listRef = useRef<FlashList<SearchListItem>>(null);
171173
const {isKeyboardShown} = useKeyboardState();
172174
const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings();
@@ -184,6 +186,7 @@ function SearchList(
184186
});
185187

186188
const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false});
189+
const [accountID] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: false, selector: (s) => s?.accountID});
187190

188191
const hasItemsBeingRemoved = prevDataLength && prevDataLength > data.length;
189192
const personalDetails = usePersonalDetails();
@@ -294,6 +297,8 @@ function SearchList(
294297
isUserValidated={isUserValidated}
295298
personalDetails={personalDetails}
296299
userBillingFundID={userBillingFundID}
300+
accountID={accountID}
301+
isOffline={isOffline}
297302
onFocus={onFocus}
298303
/>
299304
</Animated.View>
@@ -319,11 +324,13 @@ function SearchList(
319324
isUserValidated,
320325
personalDetails,
321326
userBillingFundID,
327+
accountID,
328+
isOffline,
322329
areAllOptionalColumnsHidden,
323330
],
324331
);
325332

326-
const tableHeaderVisible = canSelectMultiple || !!SearchTableHeader;
333+
const tableHeaderVisible = (canSelectMultiple || !!SearchTableHeader) && (!groupBy || groupBy === CONST.SEARCH.GROUP_BY.REPORTS);
327334
const selectAllButtonVisible = canSelectMultiple && !SearchTableHeader;
328335
const isSelectAllChecked = selectedItemsLength > 0 && selectedItemsLength === flattenedItemsWithoutPendingDelete.length;
329336

src/components/Search/index.tsx

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -406,8 +406,8 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
406406
return [];
407407
}
408408

409-
return getSections(type, searchResults.data, accountID, formatPhoneNumber, groupBy, exportReportActions, searchKey, archivedReportsIdSet);
410-
}, [searchKey, exportReportActions, groupBy, isDataLoaded, searchResults, type, archivedReportsIdSet, formatPhoneNumber, accountID]);
409+
return getSections(type, searchResults.data, accountID, formatPhoneNumber, groupBy, exportReportActions, searchKey, archivedReportsIdSet, queryJSON);
410+
}, [searchKey, exportReportActions, groupBy, isDataLoaded, searchResults, type, archivedReportsIdSet, formatPhoneNumber, accountID, queryJSON]);
411411

412412
useEffect(() => {
413413
/** We only want to display the skeleton for the status filters the first time we load them for a specific data type */
@@ -543,7 +543,7 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
543543
}, [isFocused, data, searchResults?.search?.hasMoreResults, selectedTransactions, selectAllMatchingItems, shouldShowSelectAllMatchingItems, groupBy]);
544544

545545
const toggleTransaction = useCallback(
546-
(item: SearchListItem) => {
546+
(item: SearchListItem, itemTransactions?: TransactionListItemType[]) => {
547547
if (isReportActionListItemType(item)) {
548548
return;
549549
}
@@ -561,10 +561,11 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
561561
return;
562562
}
563563

564-
if (item.transactions.some((transaction) => selectedTransactions[transaction.keyForList]?.isSelected)) {
564+
const currentTransactions = itemTransactions ?? item.transactions;
565+
if (currentTransactions.some((transaction) => selectedTransactions[transaction.keyForList]?.isSelected)) {
565566
const reducedSelectedTransactions: SelectedTransactions = {...selectedTransactions};
566567

567-
item.transactions.forEach((transaction) => {
568+
currentTransactions.forEach((transaction) => {
568569
delete reducedSelectedTransactions[transaction.keyForList];
569570
});
570571

@@ -576,7 +577,7 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
576577
{
577578
...selectedTransactions,
578579
...Object.fromEntries(
579-
item.transactions
580+
currentTransactions
580581
.filter((t) => !isTransactionPendingDelete(t))
581582
.map((transactionItem) => mapTransactionItemToSelectedEntry(transactionItem, reportActionsArray, outstandingReportsByPolicyID)),
582583
),
@@ -587,7 +588,7 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
587588
[data, reportActionsArray, selectedTransactions, outstandingReportsByPolicyID, setSelectedTransactions],
588589
);
589590

590-
const openReport = useCallback(
591+
const onSelectRow = useCallback(
591592
(item: SearchListItem) => {
592593
if (isMobileSelectionModeEnabled) {
593594
toggleTransaction(item);
@@ -599,7 +600,11 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
599600
newFlatFilters.push({key: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: item.accountID}]});
600601
const newQueryJSON: SearchQueryJSON = {...queryJSON, groupBy: undefined, flatFilters: newFlatFilters};
601602
const newQuery = buildSearchQueryString(newQueryJSON);
602-
Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: newQuery}));
603+
const newQueryJSONWithHash = buildSearchQueryJSON(newQuery);
604+
if (!newQueryJSONWithHash) {
605+
return;
606+
}
607+
handleSearch({queryJSON: newQueryJSONWithHash, searchKey, offset: 0, shouldCalculateTotals: false});
603608
return;
604609
}
605610

@@ -608,7 +613,11 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
608613
newFlatFilters.push({key: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: item.cardID}]});
609614
const newQueryJSON: SearchQueryJSON = {...queryJSON, groupBy: undefined, flatFilters: newFlatFilters};
610615
const newQuery = buildSearchQueryString(newQueryJSON);
611-
Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: newQuery}));
616+
const newQueryJSONWithHash = buildSearchQueryJSON(newQuery);
617+
if (!newQueryJSONWithHash) {
618+
return;
619+
}
620+
handleSearch({queryJSON: newQueryJSONWithHash, searchKey, offset: 0, shouldCalculateTotals: false});
612621
return;
613622
}
614623

@@ -617,7 +626,11 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
617626
newFlatFilters.push({key: CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_ID, filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: item.entryID}]});
618627
const newQueryJSON: SearchQueryJSON = {...queryJSON, groupBy: undefined, flatFilters: newFlatFilters};
619628
const newQuery = buildSearchQueryString(newQueryJSON);
620-
Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: newQuery}));
629+
const newQueryJSONWithHash = buildSearchQueryJSON(newQuery);
630+
if (!newQueryJSONWithHash) {
631+
return;
632+
}
633+
handleSearch({queryJSON: newQueryJSONWithHash, searchKey, offset: 0, shouldCalculateTotals: false});
621634
return;
622635
}
623636

@@ -662,7 +675,7 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
662675

663676
Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID, backTo}));
664677
},
665-
[hash, isMobileSelectionModeEnabled, toggleTransaction, queryJSON, reportActionsArray],
678+
[isMobileSelectionModeEnabled, toggleTransaction, queryJSON, handleSearch, searchKey, reportActionsArray, hash],
666679
);
667680

668681
const currentColumns = useMemo(() => {
@@ -700,13 +713,7 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
700713

701714
const isChat = type === CONST.SEARCH.DATA_TYPES.CHAT;
702715
const isTask = type === CONST.SEARCH.DATA_TYPES.TASK;
703-
const canSelectMultiple =
704-
!isChat &&
705-
!isTask &&
706-
(!isSmallScreenWidth || isMobileSelectionModeEnabled) &&
707-
groupBy !== CONST.SEARCH.GROUP_BY.FROM &&
708-
groupBy !== CONST.SEARCH.GROUP_BY.CARD &&
709-
groupBy !== CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID;
716+
const canSelectMultiple = !isChat && !isTask && (!isSmallScreenWidth || isMobileSelectionModeEnabled) && groupBy !== CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID;
710717
const ListItem = getListItem(type, status, groupBy);
711718

712719
const sortedSelectedData = useMemo(
@@ -853,7 +860,8 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
853860
const shouldShowYear = shouldShowYearUtil(searchResults?.data);
854861
const {shouldShowAmountInWideColumn, shouldShowTaxAmountInWideColumn} = getWideAmountIndicators(searchResults?.data);
855862
const shouldShowSorting = !groupBy;
856-
const shouldShowTableHeader = isLargeScreenWidth && !isChat;
863+
const shouldShowTableHeader = isLargeScreenWidth && !isChat && !groupBy;
864+
const tableHeaderVisible = (canSelectMultiple || shouldShowTableHeader) && (!groupBy || groupBy === CONST.SEARCH.GROUP_BY.REPORTS);
857865

858866
return (
859867
<SearchScopeProvider isOnSearch>
@@ -862,7 +870,7 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
862870
ref={searchListRef}
863871
data={sortedSelectedData}
864872
ListItem={ListItem}
865-
onSelectRow={openReport}
873+
onSelectRow={onSelectRow}
866874
onCheckboxPress={toggleTransaction}
867875
onAllCheckboxPress={toggleAllTransactions}
868876
canSelectMultiple={canSelectMultiple}
@@ -873,21 +881,21 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
873881
<SearchTableHeader
874882
canSelectMultiple={canSelectMultiple}
875883
columns={columnsToShow}
876-
metadata={searchResults?.search}
884+
type={searchResults?.search.type}
877885
onSortPress={onSortPress}
878886
sortOrder={sortOrder}
879887
sortBy={sortBy}
880888
shouldShowYear={shouldShowYear}
881889
isAmountColumnWide={shouldShowAmountInWideColumn}
882890
isTaxAmountColumnWide={shouldShowTaxAmountInWideColumn}
883891
shouldShowSorting={shouldShowSorting}
884-
groupBy={groupBy}
885892
areAllOptionalColumnsHidden={areAllOptionalColumnsHidden}
893+
groupBy={groupBy}
886894
/>
887895
)
888896
}
889897
contentContainerStyle={{...contentContainerStyle, ...styles.pb3}}
890-
containerStyle={[styles.pv0, type === CONST.SEARCH.DATA_TYPES.CHAT && !isSmallScreenWidth && styles.pt3]}
898+
containerStyle={[styles.pv0, !tableHeaderVisible && !isSmallScreenWidth && styles.pt3]}
891899
shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()}
892900
onScroll={onSearchListScroll}
893901
onEndReachedThreshold={0.75}

0 commit comments

Comments
 (0)