Skip to content

Commit f6085d2

Browse files
authored
Merge pull request #38808 from software-mansion-labs/travel/trip-room-preview
[VIP-Travel] Create Trip Room Preview
2 parents 2995925 + 46f7bbe commit f6085d2

File tree

8 files changed

+243
-13
lines changed

8 files changed

+243
-13
lines changed

src/components/ReportActionItem/TripDetailsView.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import SpacerView from '@components/SpacerView';
77
import Text from '@components/Text';
88
import useLocalize from '@hooks/useLocalize';
99
import useResponsiveLayout from '@hooks/useResponsiveLayout';
10+
import useStyleUtils from '@hooks/useStyleUtils';
1011
import useTheme from '@hooks/useTheme';
1112
import useThemeStyles from '@hooks/useThemeStyles';
1213
import DateUtils from '@libs/DateUtils';
@@ -32,6 +33,7 @@ type ReservationViewProps = {
3233
function ReservationView({reservation}: ReservationViewProps) {
3334
const theme = useTheme();
3435
const styles = useThemeStyles();
36+
const StyleUtils = useStyleUtils();
3537
const {shouldUseNarrowLayout} = useResponsiveLayout();
3638

3739
const reservationIcon = TripReservationUtils.getTripReservationIcon(reservation.type);
@@ -125,7 +127,7 @@ function ReservationView({reservation}: ReservationViewProps) {
125127
onSecondaryInteraction={() => {}}
126128
iconHeight={20}
127129
iconWidth={20}
128-
iconStyles={[styles.tripReservationIconContainer, styles.mr3]}
130+
iconStyles={[StyleUtils.getTripReservationIconContainer(false), styles.mr3]}
129131
secondaryIconFill={theme.icon}
130132
hoverAndPressStyle={styles.hoveredComponentBG}
131133
/>
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import React, {useMemo} from 'react';
2+
import type {StyleProp, ViewStyle} from 'react-native';
3+
import {FlatList, View} from 'react-native';
4+
import {useOnyx} from 'react-native-onyx';
5+
import Button from '@components/Button';
6+
import Icon from '@components/Icon';
7+
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
8+
import OfflineWithFeedback from '@components/OfflineWithFeedback';
9+
import {PressableWithoutFeedback} from '@components/Pressable';
10+
import {showContextMenuForReport} from '@components/ShowContextMenuContext';
11+
import Text from '@components/Text';
12+
import useLocalize from '@hooks/useLocalize';
13+
import useStyleUtils from '@hooks/useStyleUtils';
14+
import useTheme from '@hooks/useTheme';
15+
import useThemeStyles from '@hooks/useThemeStyles';
16+
import ControlSelection from '@libs/ControlSelection';
17+
import * as CurrencyUtils from '@libs/CurrencyUtils';
18+
import DateUtils from '@libs/DateUtils';
19+
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
20+
import Navigation from '@libs/Navigation/Navigation';
21+
import * as ReportUtils from '@libs/ReportUtils';
22+
import * as TripReservationUtils from '@libs/TripReservationUtils';
23+
import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu';
24+
import variables from '@styles/variables';
25+
import * as Expensicons from '@src/components/Icon/Expensicons';
26+
import CONST from '@src/CONST';
27+
import ONYXKEYS from '@src/ONYXKEYS';
28+
import ROUTES from '@src/ROUTES';
29+
import type {ReportAction} from '@src/types/onyx';
30+
import type {Reservation} from '@src/types/onyx/Transaction';
31+
32+
type TripRoomPreviewProps = {
33+
/** All the data of the action */
34+
action: ReportAction;
35+
36+
/** The associated chatReport */
37+
chatReportID: string;
38+
39+
/** Extra styles to pass to View wrapper */
40+
containerStyles?: StyleProp<ViewStyle>;
41+
42+
/** Popover context menu anchor, used for showing context menu */
43+
contextMenuAnchor?: ContextMenuAnchor;
44+
45+
/** Callback for updating context menu active state, used for showing context menu */
46+
checkIfContextMenuActive?: () => void;
47+
48+
/** Whether the corresponding report action item is hovered */
49+
isHovered?: boolean;
50+
};
51+
52+
type ReservationViewProps = {
53+
reservation: Reservation;
54+
};
55+
56+
function ReservationView({reservation}: ReservationViewProps) {
57+
const theme = useTheme();
58+
const styles = useThemeStyles();
59+
const StyleUtils = useStyleUtils();
60+
const {translate} = useLocalize();
61+
62+
const reservationIcon = TripReservationUtils.getTripReservationIcon(reservation.type);
63+
const title = reservation.type === CONST.RESERVATION_TYPE.CAR ? reservation.carInfo?.name : reservation.start.longName;
64+
65+
const titleComponent =
66+
reservation.type === CONST.RESERVATION_TYPE.FLIGHT ? (
67+
<View style={[styles.flexRow, styles.alignItemsCenter, styles.gap2]}>
68+
<Text style={styles.labelStrong}>{reservation.start.shortName}</Text>
69+
<Icon
70+
src={Expensicons.ArrowRightLong}
71+
width={variables.iconSizeSmall}
72+
height={variables.iconSizeSmall}
73+
fill={theme.icon}
74+
/>
75+
<Text style={styles.labelStrong}>{reservation.end.shortName}</Text>
76+
</View>
77+
) : (
78+
<Text
79+
numberOfLines={1}
80+
style={styles.labelStrong}
81+
>
82+
{title}
83+
</Text>
84+
);
85+
86+
return (
87+
<MenuItemWithTopDescription
88+
description={translate(`travel.${reservation.type}`)}
89+
descriptionTextStyle={styles.textMicro}
90+
titleComponent={titleComponent}
91+
titleContainerStyle={styles.gap1}
92+
secondaryIcon={reservationIcon}
93+
secondaryIconFill={theme.icon}
94+
wrapperStyle={[styles.taskDescriptionMenuItem, styles.p0]}
95+
shouldGreyOutWhenDisabled={false}
96+
numberOfLinesTitle={0}
97+
interactive={false}
98+
iconHeight={variables.iconSizeSmall}
99+
iconWidth={variables.iconSizeSmall}
100+
iconStyles={[StyleUtils.getTripReservationIconContainer(true), styles.mr3]}
101+
isSmallAvatarSubscriptMenu
102+
/>
103+
);
104+
}
105+
106+
const renderItem = ({item}: {item: Reservation}) => <ReservationView reservation={item} />;
107+
108+
function TripRoomPreview({action, chatReportID, containerStyles, contextMenuAnchor, isHovered = false, checkIfContextMenuActive = () => {}}: TripRoomPreviewProps) {
109+
const styles = useThemeStyles();
110+
const {translate} = useLocalize();
111+
const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`);
112+
const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReport?.iouReportID}`);
113+
114+
const tripTransactions = ReportUtils.getTripTransactions(chatReport?.iouReportID, 'reportID');
115+
const reservations: Reservation[] = TripReservationUtils.getReservationsFromTripTransactions(tripTransactions);
116+
const dateInfo = chatReport?.tripData ? DateUtils.getFormattedDateRange(new Date(chatReport.tripData.startDate), new Date(chatReport.tripData.endDate)) : '';
117+
const {totalDisplaySpend} = ReportUtils.getMoneyRequestSpendBreakdown(chatReport);
118+
119+
const displayAmount = useMemo(() => {
120+
if (totalDisplaySpend) {
121+
return CurrencyUtils.convertToDisplayString(totalDisplaySpend, iouReport?.currency);
122+
}
123+
124+
// If iouReport is not available, get amount from the action message (Ex: "Domain20821's Workspace owes $33.00" or "paid ₫60" or "paid -₫60 elsewhere")
125+
let displayAmountValue = '';
126+
const actionMessage = action.message?.[0]?.text ?? '';
127+
const splits = actionMessage.split(' ');
128+
129+
splits.forEach((split) => {
130+
if (!/\d/.test(split)) {
131+
return;
132+
}
133+
134+
displayAmountValue = split;
135+
});
136+
137+
return displayAmountValue;
138+
}, [action.message, iouReport?.currency, totalDisplaySpend]);
139+
140+
return (
141+
<OfflineWithFeedback
142+
pendingAction={action?.pendingAction}
143+
shouldDisableOpacity={!!(action.pendingAction ?? action.isOptimisticAction)}
144+
needsOffscreenAlphaCompositing
145+
>
146+
<View style={[styles.chatItemMessage, containerStyles]}>
147+
<PressableWithoutFeedback
148+
onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()}
149+
onPressOut={() => ControlSelection.unblock()}
150+
onLongPress={(event) => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive)}
151+
shouldUseHapticsOnLongPress
152+
style={[styles.flexRow, styles.justifyContentBetween, styles.reportPreviewBox, styles.cursorDefault]}
153+
role={CONST.ROLE.BUTTON}
154+
accessibilityLabel={translate('iou.viewDetails')}
155+
>
156+
<View style={[styles.moneyRequestPreviewBox, styles.p4, styles.gap5, isHovered ? styles.reportPreviewBoxHoverBorder : undefined]}>
157+
<View style={styles.expenseAndReportPreviewTextContainer}>
158+
<View style={styles.reportPreviewAmountSubtitleContainer}>
159+
<View style={styles.flexRow}>
160+
<View style={[styles.flex1, styles.flexRow, styles.alignItemsCenter]}>
161+
<Text style={[styles.textLabelSupporting, styles.lh16]}>
162+
{translate('travel.trip')}{dateInfo}
163+
</Text>
164+
</View>
165+
</View>
166+
</View>
167+
<View style={styles.reportPreviewAmountSubtitleContainer}>
168+
<View style={styles.flexRow}>
169+
<View style={[styles.flex1, styles.flexRow, styles.alignItemsCenter]}>
170+
<Text style={styles.textHeadlineH2}>{displayAmount}</Text>
171+
</View>
172+
</View>
173+
<View style={styles.flexRow}>
174+
<View style={[styles.flex1, styles.flexRow, styles.alignItemsCenter]}>
175+
<Text style={[styles.textLabelSupporting, styles.textNormal, styles.lh20]}>{chatReport?.reportName}</Text>
176+
</View>
177+
</View>
178+
</View>
179+
</View>
180+
<FlatList
181+
data={reservations}
182+
style={styles.gap3}
183+
renderItem={renderItem}
184+
/>
185+
<Button
186+
medium
187+
success
188+
text={translate('travel.viewTrip')}
189+
onPress={() => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(chatReportID))}
190+
/>
191+
</View>
192+
</PressableWithoutFeedback>
193+
</View>
194+
</OfflineWithFeedback>
195+
);
196+
}
197+
198+
TripRoomPreview.displayName = 'TripRoomPreview';
199+
200+
export default TripRoomPreview;

src/libs/ReportActionsUtils.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,9 +562,14 @@ function shouldReportActionBeVisible(reportAction: OnyxEntry<ReportAction>, key:
562562
return false;
563563
}
564564

565+
if (isTripPreview(reportAction)) {
566+
return true;
567+
}
568+
565569
// All other actions are displayed except thread parents, deleted, or non-pending actions
566570
const isDeleted = isDeletedAction(reportAction);
567571
const isPending = !!reportAction.pendingAction;
572+
568573
return !isDeleted || isPending || isDeletedParentAction(reportAction) || isReversedTransaction(reportAction);
569574
}
570575

@@ -1239,6 +1244,13 @@ function wasActionTakenByCurrentUser(reportAction: OnyxInputOrEntry<ReportAction
12391244
return currentUserAccountID === reportAction?.actorAccountID;
12401245
}
12411246

1247+
/**
1248+
* Check if the report action is the trip preview
1249+
*/
1250+
function isTripPreview(reportAction: OnyxEntry<ReportAction>): boolean {
1251+
return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.TRIPPREVIEW;
1252+
}
1253+
12421254
export {
12431255
extractLinksFromMessageHtml,
12441256
getDismissedViolationMessageText,
@@ -1309,6 +1321,7 @@ export {
13091321
isLinkedTransactionHeld,
13101322
wasActionTakenByCurrentUser,
13111323
isResolvedActionTrackExpense,
1324+
isTripPreview,
13121325
};
13131326

13141327
export type {LastVisibleMessage};

src/libs/ReportUtils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6772,9 +6772,9 @@ function shouldCreateNewMoneyRequestReport(existingIOUReport: OnyxInputOrEntry<R
67726772
return !existingIOUReport || hasIOUWaitingOnCurrentUserBankAccount(chatReport) || !canAddOrDeleteTransactions(existingIOUReport);
67736773
}
67746774

6775-
function getTripTransactions(tripRoomReportID: string | undefined): Transaction[] {
6775+
function getTripTransactions(tripRoomReportID: string | undefined, reportFieldToCompare: 'parentReportID' | 'reportID' = 'parentReportID'): Transaction[] {
67766776
const tripTransactionReportIDs = Object.values(allReports ?? {})
6777-
.filter((report) => report && report?.parentReportID === tripRoomReportID)
6777+
.filter((report) => report && report?.[reportFieldToCompare] === tripRoomReportID)
67786778
.map((report) => report?.reportID);
67796779
return tripTransactionReportIDs.flatMap((reportID) => TransactionUtils.getAllReportTransactions(reportID));
67806780
}

src/pages/home/report/ContextMenu/ContextMenuActions.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,10 @@ const ContextMenuActions: ContextMenuAction[] = [
350350
successTextTranslateKey: 'reportActionContextMenu.copied',
351351
successIcon: Expensicons.Checkmark,
352352
shouldShow: (type, reportAction) =>
353-
type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && !ReportActionsUtils.isReportActionAttachment(reportAction) && !ReportActionsUtils.isMessageDeleted(reportAction),
353+
type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION &&
354+
!ReportActionsUtils.isReportActionAttachment(reportAction) &&
355+
!ReportActionsUtils.isMessageDeleted(reportAction) &&
356+
!ReportActionsUtils.isTripPreview(reportAction),
354357

355358
// If return value is true, we switch the `text` and `icon` on
356359
// `ContextMenuItem` with `successText` and `successIcon` which will fall back to

src/pages/home/report/ReportActionItem.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import TaskAction from '@components/ReportActionItem/TaskAction';
3030
import TaskPreview from '@components/ReportActionItem/TaskPreview';
3131
import TaskView from '@components/ReportActionItem/TaskView';
3232
import TripDetailsView from '@components/ReportActionItem/TripDetailsView';
33+
import TripRoomPreview from '@components/ReportActionItem/TripRoomPreview';
3334
import {ShowContextMenuContext} from '@components/ShowContextMenuContext';
3435
import SpacerView from '@components/SpacerView';
3536
import Text from '@components/Text';
@@ -541,6 +542,17 @@ function ReportActionItem({
541542
isWhisper={isWhisper}
542543
/>
543544
);
545+
} else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.TRIPPREVIEW) {
546+
children = (
547+
<TripRoomPreview
548+
action={action}
549+
chatReportID={action.originalMessage.linkedReportID}
550+
isHovered={hovered}
551+
contextMenuAnchor={popoverAnchorRef.current}
552+
containerStyles={displayAsGroup ? [] : [styles.mt2]}
553+
checkIfContextMenuActive={toggleContextMenuFromActiveReportAction}
554+
/>
555+
);
544556
} else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) {
545557
children = ReportUtils.isClosedExpenseReportWithNoExpenses(iouReport) ? (
546558
<RenderHTML html={`<comment>${translate('parentReportAction.deletedReport')}</comment>`} />

src/styles/index.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4993,15 +4993,6 @@ const styles = (theme: ThemeColors) =>
49934993
flex: 1,
49944994
},
49954995

4996-
tripReservationIconContainer: {
4997-
width: variables.avatarSizeNormal,
4998-
height: variables.avatarSizeNormal,
4999-
backgroundColor: theme.border,
5000-
borderRadius: variables.componentBorderRadiusXLarge,
5001-
alignItems: 'center',
5002-
justifyContent: 'center',
5003-
},
5004-
50054996
textLineThrough: {
50064997
textDecorationLine: 'line-through',
50074998
},

src/styles/utils/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1680,6 +1680,15 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
16801680
...StyleSheet.flatten(descriptionTextStyle),
16811681
opacity: styles.opacitySemiTransparent.opacity,
16821682
}),
1683+
1684+
getTripReservationIconContainer: (isSmallIcon: boolean): StyleProp<ViewStyle> => ({
1685+
width: isSmallIcon ? variables.avatarSizeSmallNormal : variables.avatarSizeNormal,
1686+
height: isSmallIcon ? variables.avatarSizeSmallNormal : variables.avatarSizeNormal,
1687+
borderRadius: isSmallIcon ? variables.avatarSizeSmallNormal : variables.componentBorderRadiusXLarge,
1688+
backgroundColor: theme.border,
1689+
alignItems: 'center',
1690+
justifyContent: 'center',
1691+
}),
16831692
});
16841693

16851694
type StyleUtilsType = ReturnType<typeof createStyleUtils>;

0 commit comments

Comments
 (0)