Skip to content

Commit

Permalink
Merge pull request Expensify#33552 from rayane-djouah/Migrate-ReportA…
Browse files Browse the repository at this point in the history
…ctionItemSingle.js-component-to-TypeScript
  • Loading branch information
mountiny authored Dec 31, 2023
2 parents 7433cdb + 91352ea commit af69ba7
Show file tree
Hide file tree
Showing 3 changed files with 114 additions and 84 deletions.
3 changes: 2 additions & 1 deletion src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import ROUTES from '@src/ROUTES';
import {Beta, Login, PersonalDetails, PersonalDetailsList, Policy, Report, ReportAction, ReportMetadata, Session, Transaction} from '@src/types/onyx';
import {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon';
import {IOUMessage, OriginalMessageActionName, OriginalMessageCreated} from '@src/types/onyx/OriginalMessage';
import {Status} from '@src/types/onyx/PersonalDetails';
import {NotificationPreference} from '@src/types/onyx/Report';
import {Message, ReportActionBase, ReportActions} from '@src/types/onyx/ReportAction';
import {Receipt, WaypointCollection} from '@src/types/onyx/Transaction';
Expand Down Expand Up @@ -329,7 +330,7 @@ type OptionData = {
login?: string | null;
accountID?: number | null;
pronouns?: string;
status?: string | null;
status?: Status | null;
phoneNumber?: string | null;
isUnread?: boolean | null;
isUnreadWithMention?: boolean | null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React, {useCallback, useMemo} from 'react';
import {View} from 'react-native';
import _ from 'underscore';
import {StyleProp, View, ViewStyle} from 'react-native';
import Avatar from '@components/Avatar';
import MultipleAvatars from '@components/MultipleAvatars';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
Expand All @@ -12,7 +9,7 @@ import SubscriptAvatar from '@components/SubscriptAvatar';
import Text from '@components/Text';
import Tooltip from '@components/Tooltip';
import UserDetailsTooltip from '@components/UserDetailsTooltip';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
import useLocalize from '@hooks/useLocalize';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
Expand All @@ -21,112 +18,136 @@ import DateUtils from '@libs/DateUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as ReportUtils from '@libs/ReportUtils';
import * as UserUtils from '@libs/UserUtils';
import reportPropTypes from '@pages/reportPropTypes';
import stylePropTypes from '@styles/stylePropTypes';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import type {Report, ReportAction} from '@src/types/onyx';
import {AvatarType} from '@src/types/onyx/OnyxCommon';
import ReportActionItemDate from './ReportActionItemDate';
import ReportActionItemFragment from './ReportActionItemFragment';
import reportActionPropTypes from './reportActionPropTypes';

const propTypes = {
type ReportActionItemSingleProps = {
/** All the data of the action */
action: PropTypes.shape(reportActionPropTypes).isRequired,
action: ReportAction;

/** Styles for the outermost View */
wrapperStyle: stylePropTypes,
wrapperStyle?: StyleProp<ViewStyle>;

/** Children view component for this action item */
children: PropTypes.node.isRequired,
children: React.ReactNode;

/** Report for this action */
report: reportPropTypes,
report: Report;

/** IOU Report for this action, if any */
iouReport: reportPropTypes,
iouReport?: Report;

/** Show header for action */
showHeader: PropTypes.bool,
showHeader?: boolean;

/** Determines if the avatar is displayed as a subscript (positioned lower than normal) */
shouldShowSubscriptAvatar: PropTypes.bool,
shouldShowSubscriptAvatar?: boolean;

/** If the message has been flagged for moderation */
hasBeenFlagged: PropTypes.bool,
hasBeenFlagged?: boolean;

/** If the action is being hovered */
isHovered: PropTypes.bool,

...withLocalizePropTypes,
isHovered?: boolean;
};

const defaultProps = {
wrapperStyle: undefined,
showHeader: true,
shouldShowSubscriptAvatar: false,
hasBeenFlagged: false,
report: undefined,
iouReport: undefined,
isHovered: false,
type SubAvatar = {
/** Avatar source to display */
source: UserUtils.AvatarSource;

/** Denotes whether it is a user avatar or a workspace avatar */
type: AvatarType;

/** Owner of the avatar. If user, displayName. If workspace, policy name */
name: string;

/** Avatar id */
id?: number | string;

/** A fallback avatar icon to display when there is an error on loading avatar from remote URL */
fallbackIcon?: UserUtils.AvatarSource;
};

const showUserDetails = (accountID) => {
const showUserDetails = (accountID: string) => {
Navigation.navigate(ROUTES.PROFILE.getRoute(accountID));
};

const showWorkspaceDetails = (reportID) => {
const showWorkspaceDetails = (reportID: string) => {
Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(reportID));
};

function ReportActionItemSingle(props) {
function ReportActionItemSingle({
action,
children,
wrapperStyle,
showHeader = true,
shouldShowSubscriptAvatar = false,
hasBeenFlagged = false,
report,
iouReport,
isHovered = false,
}: ReportActionItemSingleProps) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
const actorAccountID = props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && props.iouReport ? props.iouReport.managerID : props.action.actorAccountID;
const {translate} = useLocalize();
const personalDetails = usePersonalDetails() ?? CONST.EMPTY_OBJECT;
const actorAccountID = action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && iouReport ? iouReport.managerID : action.actorAccountID;
let displayName = ReportUtils.getDisplayNameForParticipant(actorAccountID);
const {avatar, login, pendingFields, status, fallbackIcon} = personalDetails[actorAccountID] || {};
let actorHint = (login || displayName || '').replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, '');
const displayAllActors = useMemo(() => props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && props.iouReport, [props.action.actionName, props.iouReport]);
const isWorkspaceActor = ReportUtils.isPolicyExpenseChat(props.report) && (!actorAccountID || displayAllActors);
let avatarSource = UserUtils.getAvatar(avatar, actorAccountID);
const {avatar, login, pendingFields, status, fallbackIcon} = personalDetails[actorAccountID ?? -1] ?? {};
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
let actorHint = (login || (displayName ?? '')).replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, '');
const displayAllActors = useMemo(() => action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && iouReport, [action.actionName, iouReport]);
const isWorkspaceActor = ReportUtils.isPolicyExpenseChat(report) && (!actorAccountID || displayAllActors);
let avatarSource = UserUtils.getAvatar(avatar ?? '', actorAccountID);

if (isWorkspaceActor) {
displayName = ReportUtils.getPolicyName(props.report);
displayName = ReportUtils.getPolicyName(report);
actorHint = displayName;
avatarSource = ReportUtils.getWorkspaceAvatar(props.report);
} else if (props.action.delegateAccountID && personalDetails[props.action.delegateAccountID]) {
avatarSource = ReportUtils.getWorkspaceAvatar(report);
} else if (action.delegateAccountID && personalDetails[action.delegateAccountID]) {
// We replace the actor's email, name, and avatar with the Copilot manually for now. And only if we have their
// details. This will be improved upon when the Copilot feature is implemented.
const delegateDetails = personalDetails[props.action.delegateAccountID];
const delegateDisplayName = delegateDetails.displayName;
actorHint = `${delegateDisplayName} (${props.translate('reportAction.asCopilot')} ${displayName})`;
const delegateDetails = personalDetails[action.delegateAccountID];
const delegateDisplayName = delegateDetails?.displayName;
actorHint = `${delegateDisplayName} (${translate('reportAction.asCopilot')} ${displayName})`;
displayName = actorHint;
avatarSource = UserUtils.getAvatar(delegateDetails.avatar, props.action.delegateAccountID);
avatarSource = UserUtils.getAvatar(delegateDetails?.avatar ?? '', Number(action.delegateAccountID));
}

// If this is a report preview, display names and avatars of both people involved
let secondaryAvatar = {};
let secondaryAvatar: SubAvatar;
const primaryDisplayName = displayName;
if (displayAllActors) {
// The ownerAccountID and actorAccountID can be the same if the a user requests money back from the IOU's original creator, in that case we need to use managerID to avoid displaying the same user twice
const secondaryAccountId = props.iouReport.ownerAccountID === actorAccountID ? props.iouReport.managerID : props.iouReport.ownerAccountID;
const secondaryUserDetails = personalDetails[secondaryAccountId] || {};
const secondaryAccountId = iouReport?.ownerAccountID === actorAccountID ? iouReport?.managerID : iouReport?.ownerAccountID;
const secondaryUserAvatar = personalDetails?.[secondaryAccountId ?? -1]?.avatar ?? '';
const secondaryDisplayName = ReportUtils.getDisplayNameForParticipant(secondaryAccountId);
displayName = `${primaryDisplayName} & ${secondaryDisplayName}`;
secondaryAvatar = {
source: UserUtils.getAvatar(secondaryUserDetails.avatar, secondaryAccountId),
source: UserUtils.getAvatar(secondaryUserAvatar, secondaryAccountId),
type: CONST.ICON_TYPE_AVATAR,
name: secondaryDisplayName,
name: secondaryDisplayName ?? '',
id: secondaryAccountId,
};
} else if (!isWorkspaceActor) {
const avatarIconIndex = props.report.isOwnPolicyExpenseChat || ReportUtils.isPolicyExpenseChat(props.report) ? 0 : 1;
const reportIcons = ReportUtils.getIcons(props.report, {});
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const avatarIconIndex = report.isOwnPolicyExpenseChat || ReportUtils.isPolicyExpenseChat(report) ? 0 : 1;
const reportIcons = ReportUtils.getIcons(report, {});

secondaryAvatar = reportIcons[avatarIconIndex];
} else {
secondaryAvatar = {name: '', source: '', type: 'avatar'};
}
const icon = {source: avatarSource, type: isWorkspaceActor ? CONST.ICON_TYPE_WORKSPACE : CONST.ICON_TYPE_AVATAR, name: primaryDisplayName, id: isWorkspaceActor ? '' : actorAccountID};
const icon = {
source: avatarSource,
type: isWorkspaceActor ? CONST.ICON_TYPE_WORKSPACE : CONST.ICON_TYPE_AVATAR,
name: primaryDisplayName ?? '',
id: isWorkspaceActor ? '' : actorAccountID,
};

// Since the display name for a report action message is delivered with the report history as an array of fragments
// we'll need to take the displayName from personal details and have it be in the same format for now. Eventually,
Expand All @@ -138,29 +159,29 @@ function ReportActionItemSingle(props) {
text: displayName,
},
]
: props.action.person;
: action.person;

const reportID = props.report && props.report.reportID;
const iouReportID = props.iouReport && props.iouReport.reportID;
const reportID = report?.reportID;
const iouReportID = iouReport?.reportID;

const showActorDetails = useCallback(() => {
if (isWorkspaceActor) {
showWorkspaceDetails(reportID);
} else {
// Show participants page IOU report preview
if (displayAllActors) {
if (iouReportID && displayAllActors) {
Navigation.navigate(ROUTES.REPORT_PARTICIPANTS.getRoute(iouReportID));
return;
}
showUserDetails(props.action.delegateAccountID ? props.action.delegateAccountID : actorAccountID);
showUserDetails(action.delegateAccountID ? action.delegateAccountID : String(actorAccountID));
}
}, [isWorkspaceActor, reportID, actorAccountID, props.action.delegateAccountID, iouReportID, displayAllActors]);
}, [isWorkspaceActor, reportID, actorAccountID, action.delegateAccountID, iouReportID, displayAllActors]);

const shouldDisableDetailPage = useMemo(
() =>
actorAccountID === CONST.ACCOUNT_ID.NOTIFICATIONS ||
(!isWorkspaceActor && ReportUtils.isOptimisticPersonalDetail(props.action.delegateAccountID ? props.action.delegateAccountID : actorAccountID)),
[props.action, isWorkspaceActor, actorAccountID],
(!isWorkspaceActor && ReportUtils.isOptimisticPersonalDetail(action.delegateAccountID ? Number(action.delegateAccountID) : actorAccountID ?? -1)),
[action, isWorkspaceActor, actorAccountID],
);

const getAvatar = () => {
Expand All @@ -170,25 +191,23 @@ function ReportActionItemSingle(props) {
icons={[icon, secondaryAvatar]}
isInReportAction
shouldShowTooltip
secondAvatarStyle={[StyleUtils.getBackgroundAndBorderStyle(theme.appBG), props.isHovered ? StyleUtils.getBackgroundAndBorderStyle(theme.hoverComponentBG) : undefined]}
secondAvatarStyle={[StyleUtils.getBackgroundAndBorderStyle(theme.appBG), isHovered ? StyleUtils.getBackgroundAndBorderStyle(theme.hoverComponentBG) : undefined]}
/>
);
}
if (props.shouldShowSubscriptAvatar) {
if (shouldShowSubscriptAvatar) {
return (
<SubscriptAvatar
mainAvatar={icon}
secondaryAvatar={secondaryAvatar}
mainTooltip={actorHint}
secondaryTooltip={ReportUtils.getPolicyName(props.report)}
noMargin
/>
);
}
return (
<UserDetailsTooltip
accountID={actorAccountID}
delegateAccountID={props.action.delegateAccountID}
delegateAccountID={action.delegateAccountID}
icon={icon}
>
<View>
Expand All @@ -203,13 +222,13 @@ function ReportActionItemSingle(props) {
</UserDetailsTooltip>
);
};
const hasEmojiStatus = !displayAllActors && status && status.emojiCode;
const formattedDate = DateUtils.getStatusUntilDate(lodashGet(status, 'clearAfter'));
const statusText = lodashGet(status, 'text', '');
const hasEmojiStatus = !displayAllActors && status?.emojiCode;
const formattedDate = DateUtils.getStatusUntilDate(status?.clearAfter ?? '');
const statusText = status?.text ?? '';
const statusTooltipText = formattedDate ? `${statusText ? `${statusText} ` : ''}(${formattedDate})` : statusText;

return (
<View style={[styles.chatItem, props.wrapperStyle]}>
<View style={[styles.chatItem, wrapperStyle]}>
<PressableWithoutFeedback
style={[styles.alignSelfStart, styles.mr3]}
onPressIn={ControlSelection.block}
Expand All @@ -219,10 +238,10 @@ function ReportActionItemSingle(props) {
accessibilityLabel={actorHint}
role={CONST.ROLE.BUTTON}
>
<OfflineWithFeedback pendingAction={lodashGet(pendingFields, 'avatar', null)}>{getAvatar()}</OfflineWithFeedback>
<OfflineWithFeedback pendingAction={pendingFields?.avatar ?? undefined}>{getAvatar()}</OfflineWithFeedback>
</PressableWithoutFeedback>
<View style={[styles.chatItemRight]}>
{props.showHeader ? (
{showHeader ? (
<View style={[styles.chatItemMessageHeader]}>
<PressableWithoutFeedback
style={[styles.flexShrink1, styles.mr1]}
Expand All @@ -233,12 +252,13 @@ function ReportActionItemSingle(props) {
accessibilityLabel={actorHint}
role={CONST.ROLE.BUTTON}
>
{_.map(personArray, (fragment, index) => (
{personArray?.map((fragment, index) => (
<ReportActionItemFragment
key={`person-${props.action.reportActionID}-${index}`}
// eslint-disable-next-line react/no-array-index-key
key={`person-${action.reportActionID}-${index}`}
accountID={actorAccountID}
fragment={fragment}
delegateAccountID={props.action.delegateAccountID}
delegateAccountID={action.delegateAccountID}
isSingleLine
actorIcon={icon}
/>
Expand All @@ -249,20 +269,18 @@ function ReportActionItemSingle(props) {
<Text
style={styles.userReportStatusEmoji}
numberOfLines={1}
>{`${status.emojiCode}`}</Text>
>{`${status?.emojiCode}`}</Text>
</Tooltip>
)}
<ReportActionItemDate created={props.action.created} />
<ReportActionItemDate created={action.created} />
</View>
) : null}
<View style={props.hasBeenFlagged ? styles.blockquote : {}}>{props.children}</View>
<View style={hasBeenFlagged ? styles.blockquote : {}}>{children}</View>
</View>
</View>
);
}

ReportActionItemSingle.propTypes = propTypes;
ReportActionItemSingle.defaultProps = defaultProps;
ReportActionItemSingle.displayName = 'ReportActionItemSingle';

export default withLocalize(ReportActionItemSingle);
export default ReportActionItemSingle;
15 changes: 13 additions & 2 deletions src/types/onyx/PersonalDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ type Timezone = {
automatic?: boolean;
};

type Status = {
/** The emoji code of the status */
emojiCode: string;

/** The text of the draft status */
text?: string;

/** The timestamp of when the status should be cleared */
clearAfter: string; // ISO 8601 format;
};

type PersonalDetails = {
/** ID of the current user from their personal details */
accountID: number;
Expand Down Expand Up @@ -70,11 +81,11 @@ type PersonalDetails = {
fallbackIcon?: string;

/** Status of the current user from their personal details */
status?: string;
status?: Status;
};

type PersonalDetailsList = Record<string, PersonalDetails | null>;

export default PersonalDetails;

export type {Timezone, SelectedTimezone, PersonalDetailsList};
export type {Timezone, Status, SelectedTimezone, PersonalDetailsList};

0 comments on commit af69ba7

Please sign in to comment.