Skip to content

Commit

Permalink
Migrate WorkspacesListPage to TS
Browse files Browse the repository at this point in the history
  • Loading branch information
filip-solecki committed Jan 30, 2024
1 parent 24c8909 commit 84071e8
Show file tree
Hide file tree
Showing 2 changed files with 118 additions and 114 deletions.
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import PropTypes from 'prop-types';
import React, {useCallback, useMemo, useState} from 'react';
import {FlatList, ScrollView, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import Button from '@components/Button';
import ConfirmModal from '@components/ConfirmModal';
import FeatureList from '@components/FeatureList';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Expensicons from '@components/Icon/Expensicons';
import * as Illustrations from '@components/Icon/Illustrations';
import LottieAnimations from '@components/LottieAnimations';
import type {MenuItemProps} from '@components/MenuItem';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import type {OfflineWithFeedbackProps} from '@components/OfflineWithFeedback';
import {PressableWithoutFeedback} from '@components/Pressable';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
Expand All @@ -19,59 +21,59 @@ import useNetwork from '@hooks/useNetwork';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
import policyMemberPropType from '@pages/policyMemberPropType';
import * as ReimbursementAccountProps from '@pages/ReimbursementAccount/reimbursementAccountPropTypes';
import reportPropTypes from '@pages/reportPropTypes';
import * as App from '@userActions/App';
import * as Policy from '@userActions/Policy';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
import type {PolicyMembers, Policy as PolicyType, ReimbursementAccount, Report} from '@src/types/onyx';
import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading';
import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading';
import WorkspacesListRow from './WorkspacesListRow';

const propTypes = {
/** The list of this user's policies */
policies: PropTypes.objectOf(
PropTypes.shape({
/** The ID of the policy */
ID: PropTypes.string,
type WorkspaceItem = Required<Pick<MenuItemProps, 'title' | 'icon' | 'disabled'>> &
Pick<MenuItemProps, 'brickRoadIndicator' | 'iconFill' | 'fallbackIcon'> &
Pick<OfflineWithFeedbackProps, 'errors' | 'pendingAction'> &
Pick<PolicyType, 'role' | 'type' | 'ownerAccountID'> & {
action: () => void;
dismissError: () => void;
iconType?: ValueOf<typeof CONST.ICON_TYPE_AVATAR | typeof CONST.ICON_TYPE_ICON>;
policyID?: string;
adminRoom?: string | null;
announceRoom?: string | null;
};

/** The name of the policy */
name: PropTypes.string,
// eslint-disable-next-line react/no-unused-prop-types
type GetMenuItem = {item: WorkspaceItem; index: number};

/** The type of the policy */
type: PropTypes.string,
type ChatType = {
adminRoom?: string | null;
announceRoom?: string | null;
};

/** The user's role in the policy */
role: PropTypes.string,
type ChatPolicyType = Record<string, ChatType>;

/** The current action that is waiting to happen on the policy */
pendingAction: PropTypes.oneOf(_.values(CONST.RED_BRICK_ROAD_PENDING_ACTION)),
}),
),
type WorkspaceListPageOnyxProps = {
/** The list of this user's policies */
policies: OnyxCollection<PolicyType>;

/** Bank account attached to free plan */
reimbursementAccount: ReimbursementAccountProps.reimbursementAccountPropTypes,
reimbursementAccount: OnyxEntry<ReimbursementAccount>;

/** A collection of objects for all policies which key policy member objects by accountIDs */
allPolicyMembers: PropTypes.objectOf(PropTypes.objectOf(policyMemberPropType)),
allPolicyMembers: OnyxCollection<PolicyMembers>;

/** All reports shared with the user (coming from Onyx) */
reports: PropTypes.objectOf(reportPropTypes),
reports: OnyxCollection<Report>;
};

const defaultProps = {
policies: {},
allPolicyMembers: {},
reimbursementAccount: {},
reports: {},
};
type WorkspaceListPageProps = WithPolicyAndFullscreenLoadingProps & WorkspaceListPageOnyxProps;

const workspaceFeatures = [
{
Expand All @@ -90,11 +92,8 @@ const workspaceFeatures = [

/**
* Dismisses the errors on one item
*
* @param {string} policyID
* @param {string} pendingAction
*/
function dismissWorkspaceError(policyID, pendingAction) {
function dismissWorkspaceError(policyID: string, pendingAction: OnyxCommon.PendingAction | undefined) {
if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
Policy.clearDeleteWorkspaceError(policyID);
return;
Expand All @@ -107,30 +106,27 @@ function dismissWorkspaceError(policyID, pendingAction) {
throw new Error('Not implemented');
}

// TODO: Rewrite this component to TS according to existing migration on the main branch
function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, reports}) {
function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, reports}: WorkspaceListPageProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {translate} = useLocalize();
const {isOffline} = useNetwork();
const {isSmallScreenWidth} = useWindowDimensions();

const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [policyIDToDelete, setPolicyIDToDelete] = useState(null);
const [policyNameToDelete, setPolicyNameToDelete] = useState(null);
const [policyIDToDelete, setPolicyIDToDelete] = useState('');
const [policyNameToDelete, setPolicyNameToDelete] = useState('');

const confirmDeleteAndHideModal = () => {
Policy.deleteWorkspace(policyIDToDelete, [], policyNameToDelete);
setIsDeleteModalOpen(false);
};

/**
* Gets the menu item for each workspace
*
* @param {Object} item
* @returns {JSX}
*/
const getMenuItem = useCallback(
({item}) => {
({item, index}: GetMenuItem) => {
const threeDotsMenuItems = [
// Check if the user is an admin of the workspace
...(item.role === CONST.POLICY.ROLE.ADMIN
Expand All @@ -139,38 +135,39 @@ function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, r
icon: Expensicons.Trashcan,
text: translate('workspace.common.delete'),
onSelected: () => {
setPolicyIDToDelete(item.policyID);
setPolicyIDToDelete(item.policyID ?? '');
setPolicyNameToDelete(item.title);
setIsDeleteModalOpen(true);
},
},
{
icon: Expensicons.Hashtag,
text: translate('workspace.common.goToRoom', {roomName: CONST.REPORT.WORKSPACE_CHAT_ROOMS.ADMINS}),
onSelected: () => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(item.adminRoom)),
onSelected: () => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(item.adminRoom ?? '')),
},
]
: []),
{
icon: Expensicons.Hashtag,
text: translate('workspace.common.goToRoom', {roomName: CONST.REPORT.WORKSPACE_CHAT_ROOMS.ANNOUNCE}),
onSelected: () => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(item.announceRoom)),
onSelected: () => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(item.announceRoom ?? '')),
},
];

return (
<OfflineWithFeedback
key={`${item.policyID}`}
key={`${item.title}_${index}`}
pendingAction={item.pendingAction}
errorRowStyles={styles.ph5}
onClose={item.dismissError}
errors={item.errors}
>
<PressableWithoutFeedback
accessibilityRole="button"
role={CONST.ROLE.BUTTON}
accessibilityLabel="row"
style={[styles.mh5, styles.mb3]}
disabled={item.disabled}
onPress={() => item.action()}
onPress={item.action}
>
{({hovered}) => (
<WorkspacesListRow
Expand Down Expand Up @@ -226,71 +223,79 @@ function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, r
);
}, [isSmallScreenWidth, styles, translate]);

const policyRooms = useMemo(
() =>
_.reduce(
reports,
(result, report) => {
if (!report || !report.reportID || !report.policyID) {
return result;
}
const policyRooms = useMemo(() => {
if (!reports || isEmptyObject(reports)) {
return;
}

if (!result[report.policyID]) {
// eslint-disable-next-line no-param-reassign
result[report.policyID] = {};
}
return Object.values(reports).reduce<ChatPolicyType>((result, report) => {
if (!report?.reportID || !report.policyID) {
return result;
}

switch (report.chatType) {
case CONST.REPORT.CHAT_TYPE.POLICY_ADMINS:
// eslint-disable-next-line no-param-reassign
result[report.policyID].adminRoom = report.reportID;
break;
case CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE:
// eslint-disable-next-line no-param-reassign
result[report.policyID].announceRoom = report.reportID;
break;
default:
break;
}
if (!result[report.policyID]) {
// eslint-disable-next-line no-param-reassign
result[report.policyID] = {};
}

return result;
},
{},
),
[reports],
);
switch (report.chatType) {
case CONST.REPORT.CHAT_TYPE.POLICY_ADMINS:
// eslint-disable-next-line no-param-reassign
result[report.policyID].adminRoom = report.reportID;
break;
case CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE:
// eslint-disable-next-line no-param-reassign
result[report.policyID].announceRoom = report.reportID;
break;
default:
break;
}

return result;
}, {});
}, [reports]);

/**
* Add free policies (workspaces) to the list of menu items and returns the list of menu items
* @returns {Array} the menu item list
*/
const workspaces = useMemo(() => {
const reimbursementAccountBrickRoadIndicator = !_.isEmpty(reimbursementAccount.errors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '';
return _.chain(policies)
.filter((policy) => PolicyUtils.shouldShowPolicy(policy, isOffline))
.map((policy) => ({
title: policy.name,
icon: policy.avatar ? policy.avatar : ReportUtils.getDefaultWorkspaceAvatar(policy.name),
iconType: policy.avatar ? CONST.ICON_TYPE_AVATAR : CONST.ICON_TYPE_ICON,
action: () => Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(policy.id)),
iconFill: theme.textLight,
fallbackIcon: Expensicons.FallbackWorkspaceAvatar,
brickRoadIndicator: reimbursementAccountBrickRoadIndicator || PolicyUtils.getPolicyBrickRoadIndicatorStatus(policy, allPolicyMembers),
pendingAction: policy.pendingAction,
errors: policy.errors,
dismissError: () => dismissWorkspaceError(policy.id, policy.pendingAction),
disabled: policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
policyID: policy.id,
adminRoom: policyRooms[policy.id] ? policyRooms[policy.id].adminRoom : null,
announceRoom: policyRooms[policy.id] ? policyRooms[policy.id].announceRoom : null,
ownerAccountID: policy.ownerAccountID,
role: policy.role,
}))
.sortBy((policy) => policy.title.toLowerCase())
.value();
}, [reimbursementAccount.errors, policies, isOffline, theme.textLight, allPolicyMembers, policyRooms]);
const reimbursementAccountBrickRoadIndicator = reimbursementAccount?.errors ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined;

This comment has been minimized.

Copy link
@paultsimura

paultsimura Sep 7, 2024

Contributor

This caused #48167 – we should have checked for errors to not be an empty object as it's set to {} by default.

if (isEmptyObject(policies)) {
return [];
}

return Object.values(policies)
.filter((policy): policy is PolicyType => PolicyUtils.shouldShowPolicy(policy, !!isOffline))
.map(
(policy): WorkspaceItem => ({
title: policy.name,
icon: policy.avatar ? policy.avatar : ReportUtils.getDefaultWorkspaceAvatar(policy.name),
action: () => Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(policy.id)),
brickRoadIndicator: reimbursementAccountBrickRoadIndicator ?? PolicyUtils.getPolicyBrickRoadIndicatorStatus(policy, allPolicyMembers),
pendingAction: policy.pendingAction,
errors: policy.errors,
dismissError: () => {
if (!policy.pendingAction) {
return;
}
dismissWorkspaceError(policy.id, policy.pendingAction);
},
disabled: policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
iconType: policy.avatar ? CONST.ICON_TYPE_AVATAR : CONST.ICON_TYPE_ICON,
iconFill: theme.textLight,
fallbackIcon: Expensicons.FallbackWorkspaceAvatar,
policyID: policy.id,
adminRoom: policyRooms?.[policy.id] ? policyRooms[policy.id].adminRoom : null,
announceRoom: policyRooms?.[policy.id] ? policyRooms[policy.id].announceRoom : null,
ownerAccountID: policy.ownerAccountID,
role: policy.role,
type: policy.type,
}),
)
.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()));
}, [reimbursementAccount?.errors, policies, isOffline, theme.textLight, allPolicyMembers, policyRooms]);

if (_.isEmpty(workspaces)) {
if (isEmptyObject(workspaces)) {
return (
<ScreenWrapper
includeSafeAreaPaddingBottom={false}
Expand Down Expand Up @@ -321,6 +326,7 @@ function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, r
ctaText={translate('workspace.new.newWorkspace')}
ctaAccessibilityLabel={translate('workspace.new.newWorkspace')}
onCtaPress={() => App.createWorkspaceWithPolicyDraftAndNavigateToIt()}
// @ts-expect-error TODO: Remove once FeatureList (https://github.com/Expensify/App/issues/25039) is migrated to TS
illustration={LottieAnimations.WorkspacePlanet}
illustrationBackgroundColor={theme.PAGE_THEMES[SCREENS.SETTINGS.WORKSPACES].backgroundColor}
// We use this style to vertically center the illustration, as the original illustration is not centered
Expand All @@ -336,6 +342,7 @@ function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, r
<ScreenWrapper
shouldEnablePickerAvoiding={false}
shouldShowOfflineIndicatorInWideScreen
testID={WorkspacesListPage.displayName}
>
<View style={styles.flex1}>
<HeaderWithBackButton
Expand Down Expand Up @@ -370,13 +377,10 @@ function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, r
);
}

WorkspacesListPage.propTypes = propTypes;
WorkspacesListPage.defaultProps = defaultProps;
WorkspacesListPage.displayName = 'WorkspacesListPage';

export default compose(
withPolicyAndFullscreenLoading,
withOnyx({
export default withPolicyAndFullscreenLoading(
withOnyx<WorkspaceListPageProps, WorkspaceListPageOnyxProps>({
policies: {
key: ONYXKEYS.COLLECTION.POLICY,
},
Expand All @@ -389,5 +393,5 @@ export default compose(
reports: {
key: ONYXKEYS.COLLECTION.REPORT,
},
}),
)(WorkspacesListPage);
})(WorkspacesListPage),
);
4 changes: 2 additions & 2 deletions src/pages/workspace/WorkspacesListRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ type WorkspacesListRowProps = WithCurrentUserPersonalDetailsProps & {
/** Account ID of the workspace's owner */
ownerAccountID?: number;

/** Type of workspace. Type personal is not valid in this context so it's omitted */
workspaceType: typeof CONST.POLICY.TYPE.FREE | typeof CONST.POLICY.TYPE.CORPORATE | typeof CONST.POLICY.TYPE.TEAM;
/** Type of workspace */
workspaceType?: ValueOf<typeof CONST.POLICY.TYPE>;

/** Icon to show next to the workspace name */
workspaceIcon?: AvatarSource;
Expand Down

0 comments on commit 84071e8

Please sign in to comment.