Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TS migration] Migrate 'Profile' page to TypeScript #34974

Merged
merged 17 commits into from
Mar 11, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/libs/Navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ type ProfileNavigatorParamList = {
[SCREENS.PROFILE_ROOT]: {
accountID: string;
reportID: string;
backTo: Routes;
};
};

Expand Down
212 changes: 96 additions & 116 deletions src/pages/ProfilePage.js → src/pages/ProfilePage.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import type {StackScreenProps} from '@react-navigation/stack';
import Str from 'expensify-common/lib/str';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React, {useEffect} from 'react';
import {ScrollView, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import AutoUpdateTime from '@components/AutoUpdateTime';
import Avatar from '@components/Avatar';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
Expand All @@ -19,132 +18,118 @@ import PressableWithoutFocus from '@components/Pressable/PressableWithoutFocus';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
import UserDetailsTooltip from '@components/UserDetailsTooltip';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import {parsePhoneNumber} from '@libs/PhoneNumber';
import * as ReportUtils from '@libs/ReportUtils';
import * as UserUtils from '@libs/UserUtils';
import * as ValidationUtils from '@libs/ValidationUtils';
import * as PersonalDetails from '@userActions/PersonalDetails';
import * as Report from '@userActions/Report';
import * as Session from '@userActions/Session';
import type {ProfileNavigatorParamList} from '@navigation/types';
import * as PersonalDetailsActions from '@userActions/PersonalDetails';
import * as ReportActions from '@userActions/Report';
Copy link
Contributor

@DylanDylann DylanDylann Feb 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit confused. Because we also have '@userActions/ReportActions' import in some other places

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that using a Report is even more confusing since it does not describe what it is (especially when we are using the ts and have types)

import * as SessionActions from '@userActions/Session';
Comment on lines +31 to +33
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's turn this imports back as they was, and update OnyxTypes import to be
import type * as OnyxTypes from '@src/types/onyx';
This way we can use them like OnyxTypes.PersonalDetails, OnyxTypes.Report, etc

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is better to import only the needed types

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pasyukevich I agree with @VickyStash comment in this case, by doing your way you had to change the other imports to avoid conflicts, we don't need that if we use OnyxTypes.* pattern

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me using ReportActions is way better, since we will see that we are dealing with the actions (not with class/ type)

import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import personalDetailsPropType from './personalDetailsPropType';
import type SCREENS from '@src/SCREENS';
import type {PersonalDetails, PersonalDetailsList, Report, Session} from '@src/types/onyx';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type {EmptyObject} from '@src/types/utils/EmptyObject';

const matchType = PropTypes.shape({
params: PropTypes.shape({
/** accountID passed via route /a/:accountID */
accountID: PropTypes.string,
type ProfilePageOnyxProps = {
/** The personal details of the person who is logged in */
personalDetails: OnyxEntry<PersonalDetailsList>;

/** report ID passed */
reportID: PropTypes.string,
}),
});
/** The report currently being looked at */
report: OnyxEntry<Report>;
pasyukevich marked this conversation as resolved.
Show resolved Hide resolved

const propTypes = {
/* Onyx Props */

/** The personal details of all users */
personalDetails: PropTypes.objectOf(personalDetailsPropType),

/** Route params */
route: matchType.isRequired,
/** The list of all reports
* ONYXKEYS.COLLECTION.REPORT is needed for report key function
*/
// eslint-disable-next-line react/no-unused-prop-types
reports: OnyxCollection<Report>;

/** Session info for the currently logged in user. */
session: PropTypes.shape({
/** Currently logged in user accountID */
accountID: PropTypes.number,
}),

...withLocalizePropTypes,
session: OnyxEntry<Session>;
};

const defaultProps = {
// When opening someone else's profile (via deep link) before login, this is empty
personalDetails: {},
session: {
accountID: 0,
},
};
type ProfilePageProps = ProfilePageOnyxProps & StackScreenProps<ProfileNavigatorParamList, typeof SCREENS.PROFILE_ROOT>;

/**
* Gets the phone number to display for SMS logins
*
* @param {Object} details
* @param {String} details.login
* @param {String} details.displayName
* @returns {String}
*/
const getPhoneNumber = (details) => {
// If the user hasn't set a displayName, it is set to their phone number, so use that
const displayName = lodashGet(details, 'displayName', '');
const getPhoneNumber = ({login = '', displayName = ''}: PersonalDetails | EmptyObject): string | undefined => {
// If the user hasn't set a displayName, it is set to their phone number
const parsedPhoneNumber = parsePhoneNumber(displayName);

if (parsedPhoneNumber.possible) {
return parsedPhoneNumber.number.e164;
return parsedPhoneNumber?.number?.e164;
}

// If the user has set a displayName, get the phone number from the SMS login
return details.login ? Str.removeSMSDomain(details.login) : '';
return login ? Str.removeSMSDomain(login) : '';
};

function ProfilePage(props) {
function ProfilePage({personalDetails, route, session, report}: ProfilePageProps) {
const styles = useThemeStyles();
const accountID = Number(lodashGet(props.route.params, 'accountID', 0));
const isCurrentUser = props.session.accountID === accountID;
const {translate, formatPhoneNumber} = useLocalize();
const accountID = Number(route.params?.accountID ?? 0);
const isCurrentUser = session?.accountID === accountID;
const details: PersonalDetails | EmptyObject = personalDetails?.[accountID] ?? (ValidationUtils.isValidAccountRoute(accountID) ? {} : {isLoading: false, accountID: 0, avatar: ''});

const details = lodashGet(props.personalDetails, accountID, ValidationUtils.isValidAccountRoute(accountID) ? {} : {isloading: false});
const displayName = PersonalDetailsUtils.getDisplayNameOrDefault(details, undefined, undefined, isCurrentUser);
const avatar = lodashGet(details, 'avatar', UserUtils.getDefaultAvatar());
const fallbackIcon = lodashGet(details, 'fallbackIcon', '');
const login = lodashGet(details, 'login', '');
const timezone = lodashGet(details, 'timezone', {});
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
pasyukevich marked this conversation as resolved.
Show resolved Hide resolved
const avatar = details?.avatar || UserUtils.getDefaultAvatar(); // we can have an empty string and in this case, we need to show the default avatar
const fallbackIcon = details?.fallbackIcon ?? '';
const login = details?.login ?? '';
const timezone = details?.timezone;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add a fallback value here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so since we have some statements in code that rely on timezone undefined / empty value

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pasyukevich We pass the timezone to AutoUpdateTime Component as an Object. If timezone is undefined, we will get the error "can't read the attribute of undefined"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DylanDylann It is not a problem, since we will render AutoUpdateTime only if shouldShowLocalTime is true that s already checks that the timezone is not empty

Copy link
Contributor

@DylanDylann DylanDylann Feb 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pasyukevich Also please help to resolve conflict. This PR has been delayed for a long time. Let's move forward


// If we have a reportID param this means that we
// arrived here via the ParticipantsPage and should be allowed to navigate back to it
const shouldShowLocalTime = !ReportUtils.hasAutomatedExpensifyAccountIDs([accountID]) && !_.isEmpty(timezone);
let pronouns = lodashGet(details, 'pronouns', '');
if (pronouns && pronouns.startsWith(CONST.PRONOUNS.PREFIX)) {
const shouldShowLocalTime = !ReportUtils.hasAutomatedExpensifyAccountIDs([accountID]) && !isEmptyObject(timezone);
let pronouns = details?.pronouns ?? '';
if (pronouns?.startsWith(CONST.PRONOUNS.PREFIX)) {
const localeKey = pronouns.replace(CONST.PRONOUNS.PREFIX, '');
pronouns = props.translate(`pronouns.${localeKey}`);
pronouns = translate(`pronouns.${localeKey}` as TranslationPaths);
}

const isSMSLogin = Str.isSMSLogin(login);
const phoneNumber = getPhoneNumber(details);
const phoneOrEmail = isSMSLogin ? getPhoneNumber(details) : login;

const hasMinimumDetails = !_.isEmpty(details.avatar);
const isLoading = lodashGet(details, 'isLoading', false) || _.isEmpty(details);
const hasMinimumDetails = !isEmptyObject(details.avatar);
const isLoading = Boolean(details?.isLoading) || isEmptyObject(details);

// If the API returns an error for some reason there won't be any details and isLoading will get set to false, so we want to show a blocking screen
const shouldShowBlockingView = !hasMinimumDetails && !isLoading;

const statusEmojiCode = lodashGet(details, 'status.emojiCode', '');
const statusText = lodashGet(details, 'status.text', '');
const statusEmojiCode = details?.status?.emojiCode ?? '';
const statusText = details?.status?.text ?? '';
const hasStatus = !!statusEmojiCode;
const statusContent = `${statusEmojiCode} ${statusText}`;

const navigateBackTo = lodashGet(props.route, 'params.backTo');
const navigateBackTo = route?.params?.backTo;

const shouldShowNotificationPreference = !_.isEmpty(props.report) && !isCurrentUser && props.report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN;
const notificationPreference = shouldShowNotificationPreference ? props.translate(`notificationPreferencesPage.notificationPreferences.${props.report.notificationPreference}`) : '';
const shouldShowNotificationPreference = !isEmptyObject(report) && !isCurrentUser && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this might have caused #39677

const notificationPreference = shouldShowNotificationPreference
? translate(`notificationPreferencesPage.notificationPreferences.${report.notificationPreference}` as TranslationPaths)
: '';
Comment on lines +118 to +120
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assertion can be escaped if we narrow down the type of notificationPreference reusing condition from shouldShowNotificationPreference const, like

Suggested change
const notificationPreference = shouldShowNotificationPreference
? translate(`notificationPreferencesPage.notificationPreferences.${report.notificationPreference}` as TranslationPaths)
: '';
const notificationPreference = shouldShowNotificationPreference && report.notificationPreference && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN
? translate(`notificationPreferencesPage.notificationPreferences.${report.notificationPreference}`)
: '';

But I'm not sure if it's a better approach

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the current is better cause we have a better visualisation of the logic


// eslint-disable-next-line rulesdir/prefer-early-return
useEffect(() => {
if (ValidationUtils.isValidAccountRoute(accountID)) {
PersonalDetails.openPublicProfilePage(accountID);
PersonalDetailsActions.openPublicProfilePage(accountID);
}
}, [accountID]);

return (
<ScreenWrapper testID={ProfilePage.displayName}>
<FullPageNotFoundView shouldShow={shouldShowBlockingView || CONST.RESTRICTED_ACCOUNT_IDS.includes(accountID)}>
<HeaderWithBackButton
title={props.translate('common.profile')}
title={translate('common.profile')}
onBackButtonPress={() => Navigation.goBack(navigateBackTo)}
/>
<View style={[styles.containerWithSpaceBetween, styles.pointerEventsBoxNone]}>
Expand All @@ -154,10 +139,10 @@ function ProfilePage(props) {
<PressableWithoutFocus
style={[styles.noOutline]}
onPress={() => Navigation.navigate(ROUTES.PROFILE_AVATAR.getRoute(String(accountID)))}
accessibilityLabel={props.translate('common.profile')}
accessibilityLabel={translate('common.profile')}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
>
<OfflineWithFeedback pendingAction={lodashGet(details, 'pendingFields.avatar', null)}>
<OfflineWithFeedback pendingAction={details?.pendingFields?.avatar}>
<Avatar
containerStyles={[styles.avatarXLarge, styles.mb3]}
imageStyles={[styles.avatarXLarge]}
Expand All @@ -181,7 +166,7 @@ function ProfilePage(props) {
style={[styles.textLabelSupporting, styles.mb1]}
numberOfLines={1}
>
{props.translate('statusPage.status')}
{translate('statusPage.status')}
</Text>
<Text>{statusContent}</Text>
</View>
Expand All @@ -193,11 +178,11 @@ function ProfilePage(props) {
style={[styles.textLabelSupporting, styles.mb1]}
numberOfLines={1}
>
{props.translate(isSMSLogin ? 'common.phoneNumber' : 'common.email')}
{translate(isSMSLogin ? 'common.phoneNumber' : 'common.email')}
</Text>
<CommunicationsLink value={phoneOrEmail}>
<CommunicationsLink value={phoneOrEmail ?? ''}>
<UserDetailsTooltip accountID={details.accountID}>
<Text numberOfLines={1}>{isSMSLogin ? props.formatPhoneNumber(phoneNumber) : login}</Text>
<Text numberOfLines={1}>{isSMSLogin ? formatPhoneNumber(phoneNumber ?? '') : login}</Text>
</UserDetailsTooltip>
</CommunicationsLink>
</View>
Expand All @@ -208,7 +193,7 @@ function ProfilePage(props) {
style={[styles.textLabelSupporting, styles.mb1]}
numberOfLines={1}
>
{props.translate('profilePage.preferredPronouns')}
{translate('profilePage.preferredPronouns')}
</Text>
<Text numberOfLines={1}>{pronouns}</Text>
</View>
Expand All @@ -219,30 +204,30 @@ function ProfilePage(props) {
<MenuItemWithTopDescription
shouldShowRightIcon
title={notificationPreference}
description={props.translate('notificationPreferencesPage.label')}
onPress={() => Navigation.navigate(ROUTES.REPORT_SETTINGS_NOTIFICATION_PREFERENCES.getRoute(props.report.reportID))}
description={translate('notificationPreferencesPage.label')}
onPress={() => Navigation.navigate(ROUTES.REPORT_SETTINGS_NOTIFICATION_PREFERENCES.getRoute(report.reportID))}
wrapperStyle={[styles.mtn6, styles.mb5]}
/>
)}
{!isCurrentUser && !Session.isAnonymousUser() && (
{!isCurrentUser && !SessionActions.isAnonymousUser() && (
<MenuItem
title={`${props.translate('common.message')}${displayName}`}
title={`${translate('common.message')}${displayName}`}
titleStyle={styles.flex1}
icon={Expensicons.ChatBubble}
onPress={() => Report.navigateToAndOpenReportWithAccountIDs([accountID])}
onPress={() => ReportActions.navigateToAndOpenReportWithAccountIDs([accountID])}
wrapperStyle={styles.breakAll}
shouldShowRightIcon
/>
)}
{!_.isEmpty(props.report) && !isCurrentUser && (
{!isEmptyObject(report) && !isCurrentUser && (
<MenuItem
title={`${props.translate('privateNotes.title')}`}
title={`${translate('privateNotes.title')}`}
titleStyle={styles.flex1}
icon={Expensicons.Pencil}
onPress={() => ReportUtils.navigateToPrivateNotes(props.report, props.session)}
onPress={() => ReportUtils.navigateToPrivateNotes(report, session)}
wrapperStyle={styles.breakAll}
shouldShowRightIcon
brickRoadIndicator={Report.hasErrorInPrivateNotes(props.report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
brickRoadIndicator={ReportActions.hasErrorInPrivateNotes(report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we fall back to empty string?
Prop definition:

/** The type of brick road indicator to show. */
brickRoadIndicator: PropTypes.oneOf([CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, CONST.BRICK_ROAD_INDICATOR_STATUS.INFO, ''])

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, with TS updates we can have one of the CONST.BRICK_ROAD_INDICATOR_STATUS values or undefined

/>
)}
</ScrollView>
Expand All @@ -254,49 +239,44 @@ function ProfilePage(props) {
);
}

ProfilePage.propTypes = propTypes;
ProfilePage.defaultProps = defaultProps;
ProfilePage.displayName = 'ProfilePage';

/**
* This function narrow down the data from Onyx to just the properties that we want to trigger a re-render of the component. This helps minimize re-rendering
* and makes the entire component more performant because it's not re-rendering when a bunch of properties change which aren't ever used in the UI.
* @param {Object} [report]
* @returns {Object|undefined}
*/
const chatReportSelector = (report) =>
report && {
const chatReportSelector = (report: OnyxEntry<Report>): Report =>
(report && {
reportID: report.reportID,
participantAccountIDs: report.participantAccountIDs,
parentReportID: report.parentReportID,
parentReportActionID: report.parentReportActionID,
type: report.type,
chatType: report.chatType,
isPolicyExpenseChat: report.isPolicyExpenseChat,
};
}) as Report;

export default compose(
withLocalize,
withOnyx({
reports: {
key: ONYXKEYS.COLLECTION.REPORT,
selector: chatReportSelector,
},
personalDetails: {
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
},
session: {
pasyukevich marked this conversation as resolved.
Show resolved Hide resolved
key: ONYXKEYS.SESSION,
},
report: {
key: ({route, session, reports}) => {
const accountID = Number(lodashGet(route.params, 'accountID', 0));
const reportID = lodashGet(ReportUtils.getChatByParticipants([accountID], reports), 'reportID', '');
if ((session && Number(session.accountID) === accountID) || Session.isAnonymousUser() || !reportID) {
return null;
}
return `${ONYXKEYS.COLLECTION.REPORT}${reportID}`;
},
export default withOnyx<ProfilePageProps, ProfilePageOnyxProps>({
reports: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pasyukevich We improved the selector types but we still have a small issue in this case, that we must specify a selector that returns a non-nullable object (otherwise the types starts failing again), to fix chatReportSelector, change it to this:

const chatReportSelector = (report: OnyxEntry<Report>): Report =>
    (report && {
        reportID: report.reportID,
        participantAccountIDs: report.participantAccountIDs,
        parentReportID: report.parentReportID,
        parentReportActionID: report.parentReportActionID,
        type: report.type,
        chatType: report.chatType,
        isPolicyExpenseChat: report.isPolicyExpenseChat,
    }) as Report;

cc @youssef-lr

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, many thanks @fabioh8010!

key: ONYXKEYS.COLLECTION.REPORT,
selector: chatReportSelector,
},
personalDetails: {
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
},
session: {
key: ONYXKEYS.SESSION,
},
report: {
key: ({route, session, reports}) => {
const accountID = Number(route.params?.accountID ?? 0);
const reportID = ReportUtils.getChatByParticipants([accountID], reports)?.reportID ?? '';

if ((Boolean(session) && Number(session?.accountID) === accountID) || SessionActions.isAnonymousUser() || !reportID) {
return `${ONYXKEYS.COLLECTION.REPORT}0`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return ${ONYXKEYS.COLLECTION.REPORT}0; will return undefined. Why do we need to update from return null;
to

return `${ONYXKEYS.COLLECTION.REPORT}0`;` will return undefined

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key should be always a key according to our types

The key was updated from null to string, the actual result didn't changed

Before - OnyxStorage[null] - undefined
Now - OnyxStorage[${ONYXKEYS.COLLECTION.REPORT}0] - undefined

}

pasyukevich marked this conversation as resolved.
Show resolved Hide resolved
return `${ONYXKEYS.COLLECTION.REPORT}${reportID}`;
},
}),
)(ProfilePage);
},
})(ProfilePage);
Loading