Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
113 changes: 113 additions & 0 deletions src/components/Tables/WorkspaceListTable/LeaveWorkspaceAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import {useEffect, useRef} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import {ModalActions} from '@components/Modal/Global/ModalContext';
import useConfirmModal from '@hooks/useConfirmModal';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import {close} from '@libs/actions/Modal';
import {leaveWorkspace} from '@libs/actions/Policy/Policy';
import {getConnectionExporters, isPolicyAdmin, isPolicyApprover, isPolicyAuditor} from '@libs/PolicyUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PersonalDetailsList, Policy} from '@src/types/onyx';
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';

type LeaveWorkspaceActionProps = {
/** ID of the workspace being left */
policyID: string;

/** Called when the flow is finished or abandoned, so the parent can unmount this component */
onDismiss: () => void;
};

const ownerDisplayNameSelector = (ownerAccountID: number) => (personalDetailsList: OnyxEntry<PersonalDetailsList>) => personalDetailsList?.[ownerAccountID]?.displayName ?? '';

function isUserReimburserForPolicy(policy: OnyxEntry<Policy>, userEmail: string | undefined): boolean {
return !!userEmail && policy?.achAccount?.reimburser === userEmail;
}

/**
* Self-contained "leave workspace" flow, mounted only after the user picks Leave in the row menu.
* The full policy entry needed to build the confirmation prompt is subscribed to only for the
* lifetime of the flow, so the workspaces list rows don't re-render on every policy change.
*/
function LeaveWorkspaceAction({policyID, onDismiss}: LeaveWorkspaceActionProps) {
const {translate} = useLocalize();
const {showConfirmModal} = useConfirmModal();
const [session, sessionResult] = useOnyx(ONYXKEYS.SESSION);
const [policy, policyResult] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
const ownerAccountID = policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID;
const [policyOwnerDisplayName] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: ownerDisplayNameSelector(ownerAccountID)}, [ownerAccountID]);

const isLoadingData = isLoadingOnyxValue(sessionResult, policyResult);

const confirmModalPrompt = () => {
const userEmail = session?.email ?? '';
const exporters = getConnectionExporters(policy);

if (isUserReimburserForPolicy(policy, userEmail)) {
return translate('common.leaveWorkspaceReimburser');
}

if (policy?.technicalContact === userEmail) {
return translate('common.leaveWorkspaceConfirmationTechContact', policyOwnerDisplayName ?? '');
}

if (exporters.some((exporter) => exporter === userEmail)) {
return translate('common.leaveWorkspaceConfirmationExporter', policyOwnerDisplayName ?? '');
}

if (isPolicyApprover(policy, userEmail)) {
return translate('common.leaveWorkspaceConfirmationApprover', policyOwnerDisplayName ?? '');
}

if (isPolicyAdmin(policy)) {
return translate('common.leaveWorkspaceConfirmationAdmin');
}

if (isPolicyAuditor(policy)) {
return translate('common.leaveWorkspaceConfirmationAuditor');
}

return translate('common.leaveWorkspaceConfirmation');
};

// Closes the row popover (if still open) and shows the confirmation modal once the policy entry has loaded.
const hasStartedRef = useRef(false);
useEffect(() => {
if (hasStartedRef.current || isLoadingData) {
return;
}
hasStartedRef.current = true;

close(() => {
if (isUserReimburserForPolicy(policy, session?.email)) {
showConfirmModal({
title: translate('common.leaveWorkspace'),
prompt: confirmModalPrompt(),
confirmText: translate('common.buttonConfirm'),
success: true,
shouldShowCancelButton: false,
}).then(() => onDismiss());
return;
}

showConfirmModal({
title: translate('common.leaveWorkspace'),
prompt: confirmModalPrompt(),
confirmText: translate('common.leaveWorkspace'),
cancelText: translate('common.cancel'),
danger: true,
}).then((result) => {
if (result.action === ModalActions.CONFIRM && policy) {
leaveWorkspace(session?.accountID ?? CONST.DEFAULT_NUMBER_ID, session?.email ?? '', policy);
}
onDismiss();
});
});
});

return null;
}

export default LeaveWorkspaceAction;
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {useEffect, useRef} from 'react';
import type {ValueOf} from 'type-fest';
import useOnyx from '@hooks/useOnyx';
import {clearWorkspaceOwnerChangeFlow, requestWorkspaceOwnerChange} from '@libs/actions/Policy/Member';
import Navigation from '@libs/Navigation/Navigation';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';

type TransferOwnershipActionProps = {
/** ID of the workspace whose ownership is being transferred */
policyID: string;

/** Called once the ownership change flow has started, so the parent can unmount this component */
onDismiss: () => void;
};

/**
* Kicks off the "transfer owner" flow, mounted only after the user picks Transfer owner in the row menu.
* The full policy entry needed by requestWorkspaceOwnerChange is subscribed to only for the moment
* the flow starts, so the workspaces list rows don't re-render on every policy change.
*/
function TransferOwnershipAction({policyID, onDismiss}: TransferOwnershipActionProps) {
const [session, sessionResult] = useOnyx(ONYXKEYS.SESSION);
const [policy, policyResult] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);

const isLoadingData = isLoadingOnyxValue(sessionResult, policyResult);

const hasStartedRef = useRef(false);
useEffect(() => {
if (hasStartedRef.current || isLoadingData) {
return;
}
hasStartedRef.current = true;

clearWorkspaceOwnerChangeFlow(policyID);
requestWorkspaceOwnerChange(policy, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, session?.email ?? '');
Navigation.navigate(
ROUTES.WORKSPACE_OWNER_CHANGE_CHECK.getRoute(
policyID,
session?.accountID ?? CONST.DEFAULT_NUMBER_ID,
'amountOwed' as ValueOf<typeof CONST.POLICY.OWNERSHIP_ERRORS>,
Navigation.getActiveRoute(),
),
);
onDismiss();
});

return null;
}

export default TransferOwnershipAction;
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React from 'react';
import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import Icon from '@components/Icon';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useOnyx from '@hooks/useOnyx';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID';
import {isConnectionInProgress} from '@libs/actions/connections';
import {getPolicyBrickRoadIndicatorStatus, getUberConnectionErrorDirectlyFromPolicy, shouldShowEmployeeListError} from '@libs/PolicyUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {hasReimbursementAccountErrorsSelector} from '@src/selectors/ReimbursementAccount';
import type {Policy, PolicyConnectionSyncProgress} from '@src/types/onyx';
import type {CardFeedErrors} from '@src/types/onyx/DerivedValues';

type WorkspaceRowBrickRoadIndicatorProps = {
/** ID of the policy the row represents */
policyID: string;
};

const createCardFeedErrorsSelector = (workspaceAccountID: number) => (cardFeedErrors: OnyxEntry<CardFeedErrors>) =>
!!cardFeedErrors?.shouldShowRbrForWorkspaceAccountID?.[workspaceAccountID];

const createPolicyErrorsSelector = (connectionSyncProgress: OnyxEntry<PolicyConnectionSyncProgress>) => (policy: OnyxEntry<Policy>) =>
getUberConnectionErrorDirectlyFromPolicy(policy) ||
shouldShowEmployeeListError(policy) ||
getPolicyBrickRoadIndicatorStatus(policy, isConnectionInProgress(connectionSyncProgress, policy)) === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;

/**
* Error indicator of a workspaces list row. All of its subscriptions are narrow (booleans or a single
* small entry), so a policy write re-evaluates only this row's selectors and re-renders at most this
* one component instead of committing the whole workspaces list page.
*/
function WorkspaceRowBrickRoadIndicator({policyID}: WorkspaceRowBrickRoadIndicatorProps) {
const theme = useTheme();
const styles = useThemeStyles();
const icons = useMemoizedLazyExpensifyIcons(['DotIndicator']);
const workspaceAccountID = useWorkspaceAccountID(policyID);
const [hasReimbursementAccountErrors] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {selector: hasReimbursementAccountErrorsSelector});
const [hasCardFeedErrors] = useOnyx(ONYXKEYS.DERIVED.CARD_FEED_ERRORS, {selector: createCardFeedErrorsSelector(workspaceAccountID)}, [workspaceAccountID]);
const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`);
const [hasPolicyErrors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: createPolicyErrorsSelector(connectionSyncProgress)}, [connectionSyncProgress]);

// Every branch of the page-level brick road logic this replaces resolved to ERROR or undefined,
// so a boolean OR preserves its semantics.
const hasError = !!hasReimbursementAccountErrors || !!hasCardFeedErrors || !!hasPolicyErrors;

if (!hasError) {
return null;
}

return (
<View style={[styles.flexRow, styles.alignItemsCenter, styles.gap2]}>
<Icon
src={icons.DotIndicator}
fill={theme.danger}
/>
</View>
);
}

export default WorkspaceRowBrickRoadIndicator;
Loading
Loading