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 1 commit
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
2 changes: 1 addition & 1 deletion src/components/OfflineWithFeedback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import MessagesRow from './MessagesRow';

type OfflineWithFeedbackProps = ChildrenProps & {
/** The type of action that's pending */
pendingAction?: OnyxCommon.PendingAction;
pendingAction?: OnyxCommon.PendingAction | null;

/** Determine whether to hide the component's children if deletion is pending */
shouldHideOnDelete?: boolean;
Expand Down
1 change: 1 addition & 0 deletions src/libs/Navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ type ProfileNavigatorParamList = {
[SCREENS.PROFILE_ROOT]: {
accountID: string;
reportID: string;
backTo: Routes;
};
};

Expand Down
2 changes: 1 addition & 1 deletion src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4646,7 +4646,7 @@ function shouldAutoFocusOnKeyPress(event: KeyboardEvent): boolean {
/**
* Navigates to the appropriate screen based on the presence of a private note for the current user.
*/
function navigateToPrivateNotes(report: Report, session: Session) {
function navigateToPrivateNotes(report: OnyxEntry<Report>, session: OnyxEntry<Session>) {
if (isEmpty(report) || isEmpty(session) || !session.accountID) {
return;
}
Expand Down
197 changes: 87 additions & 110 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 {OnyxEntry} from 'react-native-onyx';
import AutoUpdateTime from '@components/AutoUpdateTime';
import Avatar from '@components/Avatar';
import BlockingView from '@components/BlockingViews/BlockingView';
Expand All @@ -20,132 +19,113 @@ 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 type {ProfileNavigatorParamList} from '@navigation/types';
import variables from '@styles/variables';
import * as PersonalDetails from '@userActions/PersonalDetails';
import * as Report from '@userActions/Report';
import * as Session from '@userActions/Session';
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,
}),
});

const propTypes = {
/* Onyx Props */

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

/** Route params */
route: matchType.isRequired,
/** The report currently being looked at */
report: OnyxEntry<Report>;
pasyukevich marked this conversation as resolved.
Show resolved Hide resolved

/** 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) {
pasyukevich marked this conversation as resolved.
Show resolved Hide resolved
const styles = useThemeStyles();
const accountID = Number(lodashGet(props.route.params, 'accountID', 0));
const details = lodashGet(props.personalDetails, accountID, ValidationUtils.isValidAccountRoute(accountID) ? {} : {isloading: false});
const {translate, formatPhoneNumber} = useLocalize();
const accountID = Number(route.params?.accountID ?? 0);
const details: PersonalDetails | EmptyObject = personalDetails?.[accountID] ?? (ValidationUtils.isValidAccountRoute(accountID) ? {} : {isLoading: false, accountID: 0, avatar: ''});

const displayName = PersonalDetailsUtils.getDisplayNameOrDefault(details);
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();
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 use ?? instead of ||

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We need to use ||, since 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 isCurrentUser = props.session.accountID === accountID;
const hasMinimumDetails = !_.isEmpty(details.avatar);
const isLoading = lodashGet(details, 'isLoading', false) || _.isEmpty(details);
const isCurrentUser = session?.accountID === accountID;
const hasMinimumDetails = !isEmptyObject(details.avatar);
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const isLoading = details?.isLoading || false || isEmptyObject(details);
pasyukevich marked this conversation as resolved.
Show resolved Hide resolved

// 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) && props.report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN;
const notificationPreference = shouldShowNotificationPreference ? props.translate(`notificationPreferencesPage.notificationPreferences.${props.report.notificationPreference}`) : '';
const shouldShowNotificationPreference = !isEmptyObject(report) && 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.

report could be null/undefined. Even though we have checked !isEmptyObject(report) I think we still should update to report?.notificationPreference for safer

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We will read report.notificationPreference only in the case when the object is not empty

!isEmptyObject(report) will guarantee that we are dealing with an object

Copy link
Contributor

Choose a reason for hiding this comment

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

Yepp this is NAB

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) && !hasMinimumDetails) {
PersonalDetails.openPublicProfilePage(accountID);
PersonalDetailsActions.openPublicProfilePage(accountID);
}
}, [accountID, hasMinimumDetails]);

return (
<ScreenWrapper testID={ProfilePage.displayName}>
<HeaderWithBackButton
title={props.translate('common.profile')}
title={translate('common.profile')}
onBackButtonPress={() => Navigation.goBack(navigateBackTo)}
/>
<View style={[styles.containerWithSpaceBetween, styles.pointerEventsBoxNone]}>
Expand All @@ -155,10 +135,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 @@ -182,7 +162,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 @@ -194,11 +174,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 @@ -209,7 +189,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 @@ -220,30 +200,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) && (
{!isEmptyObject(report) && (
<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}
/>
)}
</ScrollView>
Expand All @@ -254,38 +234,35 @@ function ProfilePage(props) {
icon={Illustrations.ToddBehindCloud}
iconWidth={variables.modalTopIconWidth}
iconHeight={variables.modalTopIconHeight}
title={props.translate('notFound.notHere')}
title={translate('notFound.notHere')}
shouldShowLink
link={props.translate('notFound.goBackHome')}
/>
)}
</View>
</ScreenWrapper>
);
}

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

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

// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
if ((session && Number(session.accountID) === accountID) || SessionActions.isAnonymousUser() || !reportID) {
pasyukevich marked this conversation as resolved.
Show resolved Hide resolved
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