diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 6e2e666d88255..cf5ab8c4b16ef 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3315,7 +3315,6 @@ const CONST = { CARD_NAME: 'CardName', TRANSACTION_START_DATE: 'TransactionStartDate', CONFIRMATION: 'Confirmation', - INVITE_NEW_MEMBER: 'InviteNewMember', }, TRANSACTION_START_DATE_OPTIONS: { FROM_BEGINNING: 'fromBeginning', @@ -3361,7 +3360,6 @@ const CONST = { LIMIT: 'Limit', CARD_NAME: 'CardName', CONFIRMATION: 'Confirmation', - INVITE_NEW_MEMBER: 'InviteNewMember', }, CARD_TYPE: { PHYSICAL: 'physical', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 6ac6a5b89e3e5..36cdd042c048d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1295,14 +1295,8 @@ const ROUTES = { WORKSPACE_INVITE_MESSAGE_ROLE: { route: 'workspaces/:policyID/invite-message/role', - getRoute: (policyID: string | undefined, backTo?: string) => { - if (!policyID) { - Log.warn('Invalid policyID is used to build the WORKSPACE_INVITE_MESSAGE_ROLE route'); - } - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - return getUrlWithBackToParam(`workspaces/${policyID}/invite-message/role` as const, backTo); - }, + // eslint-disable-next-line no-restricted-syntax -- Legacy route generation + getRoute: (policyID: string, backTo?: string) => `${getUrlWithBackToParam(`workspaces/${policyID}/invite-message/role`, backTo)}` as const, }, WORKSPACE_OVERVIEW: { route: 'workspaces/:policyID/overview', diff --git a/src/components/InteractiveStepWrapper.tsx b/src/components/InteractiveStepWrapper.tsx index 60961b7996915..d9f6709c47ec8 100644 --- a/src/components/InteractiveStepWrapper.tsx +++ b/src/components/InteractiveStepWrapper.tsx @@ -56,11 +56,6 @@ type InteractiveStepWrapperProps = { * This flag can be removed, once all components/screens have switched to edge-to-edge safe area handling. */ enableEdgeToEdgeBottomSafeAreaPadding?: boolean; - - /** - * Callback to be called when the screen entry transition ends. - */ - onEntryTransitionEnd?: () => void; }; function InteractiveStepWrapper( @@ -79,7 +74,6 @@ function InteractiveStepWrapper( offlineIndicatorStyle, shouldKeyboardOffsetBottomSafeAreaPadding, enableEdgeToEdgeBottomSafeAreaPadding, - onEntryTransitionEnd, }: InteractiveStepWrapperProps, ref: React.ForwardedRef, ) { @@ -97,7 +91,6 @@ function InteractiveStepWrapper( shouldShowOfflineIndicatorInWideScreen={shouldShowOfflineIndicatorInWideScreen} offlineIndicatorStyle={offlineIndicatorStyle} shouldKeyboardOffsetBottomSafeAreaPadding={shouldKeyboardOffsetBottomSafeAreaPadding} - onEntryTransitionEnd={onEntryTransitionEnd} > ): boolean { ); } -function getRoom(type: ValueOf, policyID: string | undefined): OnyxEntry { +function getRoom(type: ValueOf, policyID: string): OnyxEntry { const room = Object.values(allReports ?? {}).find((report) => report?.policyID === policyID && report?.chatType === type && !isThread(report)); return room; } diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 1e3456a8d6060..ef06d8dc30557 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -410,15 +410,6 @@ function setIssueNewCardStepAndData({data, isEditing, step, policyID, isChangeAs }); } -function setDraftInviteAccountID(assigneeEmail: string | undefined, assigneeAccountID: number | undefined, policyID: string | undefined) { - if (!policyID) { - return; - } - Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID}`, { - [assigneeEmail ?? '']: assigneeAccountID, - }); -} - function clearIssueNewCardFlow(policyID: string | undefined) { Onyx.set(`${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, { currentStep: null, @@ -1068,7 +1059,6 @@ export { getCardDefaultName, queueExpensifyCardForBilling, clearIssueNewCardFormData, - setDraftInviteAccountID, resolveFraudAlert, }; export type {ReplacementReason}; diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index f0a5bdb1d41f0..8983082efcf18 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -131,7 +131,7 @@ function getPolicy(policyID: string | undefined): OnyxEntry { */ function buildRoomMembersOnyxData( roomType: typeof CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE | typeof CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, - policyID: string | undefined, + policyID: string, accountIDs: number[], ): OnyxDataReturnType { const report = ReportUtils.getRoom(roomType, policyID); @@ -875,7 +875,7 @@ function clearWorkspaceOwnerChangeFlow(policyID: string | undefined) { function buildAddMembersToWorkspaceOnyxData( invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, - policyID: string | undefined, + policyID: string, policyMemberAccountIDs: number[], role: string, formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], @@ -977,7 +977,7 @@ function buildAddMembersToWorkspaceOnyxData( function addMembersToWorkspace( invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, welcomeNote: string, - policyID: string | undefined, + policyID: string, policyMemberAccountIDs: number[], role: string, formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], @@ -1180,7 +1180,7 @@ function setWorkspaceInviteRoleDraft(policyID: string, role: ValueOf; function WorkspaceInviteMessagePage({policy, route, currentUserPersonalDetails}: WorkspaceInviteMessagePageProps) { + const styles = useThemeStyles(); + const {translate, formatPhoneNumber} = useLocalize(); + const [formData, formDataResult] = useOnyx(ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM_DRAFT, {canBeMissing: true}); + const [allPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); + + const viewportOffsetTop = useViewportOffsetTop(); + const [welcomeNote, setWelcomeNote] = useState(); + + const {inputCallbackRef, inputRef} = useAutoFocusInput(); + + const [invitedEmailsToAccountIDsDraft, invitedEmailsToAccountIDsDraftResult] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`, { + canBeMissing: true, + }); + const [workspaceInviteMessageDraft, workspaceInviteMessageDraftResult] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT}${route.params.policyID.toString()}`, { + canBeMissing: true, + }); + const [workspaceInviteRoleDraft = CONST.POLICY.ROLE.USER] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_ROLE_DRAFT}${route.params.policyID.toString()}`, {canBeMissing: true}); + const isOnyxLoading = isLoadingOnyxValue(workspaceInviteMessageDraftResult, invitedEmailsToAccountIDsDraftResult, formDataResult); + const personalDetailsOfInvitedEmails = getPersonalDetailsForAccountIDs(Object.values(invitedEmailsToAccountIDsDraft ?? {}), allPersonalDetails ?? {}); + const memberNames = Object.values(personalDetailsOfInvitedEmails) + .map((personalDetail) => { + const displayName = getDisplayNameOrDefault(personalDetail, '', false); + if (displayName) { + return displayName; + } + + // We don't have login details for users who are not in the database yet + // So we need to fallback to their login from the invitedEmailsToAccountIDsDraft + const accountID = personalDetail.accountID; + const loginFromInviteMap = Object.entries(invitedEmailsToAccountIDsDraft ?? {}).find(([, id]) => id === accountID)?.[0]; + + return loginFromInviteMap; + }) + .join(', '); + + const welcomeNoteSubject = useMemo( + () => `# ${currentUserPersonalDetails?.displayName ?? ''} invited you to ${policy?.name ?? 'a workspace'}`, + [policy?.name, currentUserPersonalDetails?.displayName], + ); + + const getDefaultWelcomeNote = useCallback(() => { + return ( + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + formData?.[INPUT_IDS.WELCOME_MESSAGE] ?? + // workspaceInviteMessageDraft can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + workspaceInviteMessageDraft ?? + translate('workspace.common.welcomeNote') + ); + }, [workspaceInviteMessageDraft, translate, formData]); + + useEffect(() => { + if (isOnyxLoading) { + return; + } + if (!isEmptyObject(invitedEmailsToAccountIDsDraft)) { + setWelcomeNote(getDefaultWelcomeNote()); + return; + } + if (isEmptyObject(policy)) { + return; + } + Navigation.goBack(route.params.backTo); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [isOnyxLoading]); + + const sendInvitation = () => { + Keyboard.dismiss(); + const policyMemberAccountIDs = Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList, false, false)); + // Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details + addMembersToWorkspace( + invitedEmailsToAccountIDsDraft ?? {}, + `${welcomeNoteSubject}\n\n${welcomeNote}`, + route.params.policyID, + policyMemberAccountIDs, + workspaceInviteRoleDraft, + formatPhoneNumber, + ); + setWorkspaceInviteMessageDraft(route.params.policyID, welcomeNote ?? null); + clearDraftValues(ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM); + if ((route.params?.backTo as string)?.endsWith('members')) { + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.dismissModal()); + return; + } + + if (getIsNarrowLayout()) { + Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(route.params.policyID), {forceReplace: true}); + return; + } + + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.dismissModal(); + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(route.params.policyID)); + }); + }); + }; + + /** Opens privacy url as an external link */ + const openPrivacyURL = (event: GestureResponderEvent | KeyboardEvent | undefined) => { + event?.preventDefault(); + openExternalLink(CONST.OLD_DOT_PUBLIC_URLS.PRIVACY_URL); + }; + + const validate = (): FormInputErrors => { + const errorFields: FormInputErrors = {}; + if (isEmptyObject(invitedEmailsToAccountIDsDraft) && !isOnyxLoading) { + errorFields.welcomeMessage = translate('workspace.inviteMessage.inviteNoMembersError'); + } + return errorFields; + }; + + const policyName = policy?.name; + + useEffect(() => { + return () => { + clearWorkspaceInviteRoleDraft(route.params.policyID); + }; + }, [route.params.policyID]); + return ( - + accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]} + fullPageNotFoundViewProps={{subtitleKey: isEmptyObject(policy) ? undefined : 'workspace.common.notAuthorized', onLinkPress: goBackFromInvalidPolicy}} + > + + Navigation.dismissModal()} + onBackButtonPress={() => Navigation.goBack(route.params.backTo)} + /> + + + + + + + { + Navigation.goBack(route.params.backTo); + }} + /> + + { + Navigation.navigate(ROUTES.WORKSPACE_INVITE_MESSAGE_ROLE.getRoute(route.params.policyID, Navigation.getActiveRoute())); + }} + /> + + + {translate('workspace.inviteMessage.inviteMessagePrompt')} + + { + setWelcomeNote(text); + }} + ref={(element: AnimatedTextInputRef | null) => { + if (!element) { + return; + } + if (!inputRef.current) { + updateMultilineInputRange(element); + } + inputCallbackRef(element); + }} + shouldSaveDraft + /> + + + {translate('common.privacy')} + + + + + + ); } diff --git a/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx b/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx index c06a80c44f924..2d0f4dabe3739 100644 --- a/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx @@ -19,7 +19,6 @@ import AssigneeStep from './AssigneeStep'; import CardNameStep from './CardNameStep'; import CardSelectionStep from './CardSelectionStep'; import ConfirmationStep from './ConfirmationStep'; -import InviteNewMemberStep from './InviteNewMemberStep'; import TransactionStartDateStep from './TransactionStartDateStep'; type AssignCardFeedPageProps = PlatformStackScreenProps & WithPolicyAndFullscreenLoadingProps; @@ -94,13 +93,6 @@ function AssignCardFeedPage({route, policy}: AssignCardFeedPageProps) { backTo={shouldUseBackToParam ? backTo : undefined} /> ); - case CONST.COMPANY_CARD.STEP.INVITE_NEW_MEMBER: - return ( - - ); default: return ( ; @@ -46,42 +47,22 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { const [list] = useCardsList(policy?.id, feed); const [cardFeeds] = useCardFeeds(policy?.id); const filteredCardList = getFilteredCardList(list, cardFeeds?.settings?.oAuthAccountDetails?.[feed], workspaceCardFeeds); - const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); - const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false, canBeMissing: true}); - - const excludedUsers = useMemo(() => { - const ineligibleInvites = getIneligibleInvitees(policy?.employeeList); - return ineligibleInvites.reduce( - (acc, login) => { - acc[login] = true; - return acc; - }, - {} as Record, - ); - }, [policy?.employeeList]); - - const {searchTerm, setSearchTerm, debouncedSearchTerm, availableOptions, selectedOptionsForDisplay, areOptionsInitialized} = useSearchSelector({ - selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, - searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, - includeUserToInvite: true, - excludeLogins: excludedUsers, - includeRecentReports: true, - shouldInitialize: didScreenTransitionEnd, - }); const isEditing = assignCard?.isEditing; - const submit = (assignee: ListItem) => { - let nextStep: AssignCardStep = CONST.COMPANY_CARD.STEP.CARD; - const personalDetail = getPersonalDetailByEmail(assignee?.login ?? ''); - const memberName = personalDetail?.firstName ? personalDetail.firstName : personalDetail?.login; - const data: Partial = { - email: assignee?.login ?? '', - cardName: getDefaultCardName(memberName), - }; + const [selectedMember, setSelectedMember] = useState(assignCard?.data?.email ?? ''); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const [shouldShowError, setShouldShowError] = useState(false); + const selectMember = (assignee: ListItem) => { Keyboard.dismiss(); - if (assignee?.login === assignCard?.data?.email) { + setSelectedMember(assignee.login ?? ''); + setShouldShowError(false); + }; + + const submit = () => { + let nextStep: AssignCardStep = CONST.COMPANY_CARD.STEP.CARD; + if (selectedMember === assignCard?.data?.email) { setAssignCardStepAndData({ currentStep: isEditing ? CONST.COMPANY_CARD.STEP.CONFIRMATION : nextStep, isEditing: false, @@ -89,18 +70,18 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { return; } - if (!policy?.employeeList?.[assignee?.login ?? '']) { - setAssignCardStepAndData({ - currentStep: CONST.COMPANY_CARD.STEP.INVITE_NEW_MEMBER, - data: { - email: assignee?.login ?? '', - assigneeAccountID: assignee?.accountID ?? undefined, - }, - }); - setDraftInviteAccountID(assignee?.login ?? '', assignee?.accountID ?? undefined, policy?.id); + if (!selectedMember) { + setShouldShowError(true); return; } + const personalDetail = getPersonalDetailByEmail(selectedMember); + const memberName = personalDetail?.firstName ? personalDetail.firstName : personalDetail?.login; + const data: Partial = { + email: selectedMember, + cardName: getDefaultCardName(memberName), + }; + if (hasOnlyOneCardToAssign(filteredCardList)) { nextStep = CONST.COMPANY_CARD.STEP.TRANSACTION_START_DATE; data.cardNumber = Object.keys(filteredCardList).at(0); @@ -125,6 +106,9 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { Navigation.goBack(); }; + const shouldShowSearchInput = policy?.employeeList && Object.keys(policy.employeeList).length >= MINIMUM_MEMBER_TO_SHOW_SEARCH; + const textInputLabel = shouldShowSearchInput ? translate('workspace.card.issueNewCard.findMember') : undefined; + const membersDetails = useMemo(() => { let membersList: ListItem[] = []; if (!policy?.employeeList) { @@ -143,7 +127,7 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { alternateText: email, login: email, accountID: personalDetail?.accountID, - isSelected: assignCard?.data?.email === email, + isSelected: selectedMember === email, icons: [ { source: personalDetail?.avatar ?? Expensicons.FallbackAvatar, @@ -158,7 +142,7 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { membersList = sortAlphabetically(membersList, 'text', localeCompare); return membersList; - }, [isOffline, policy?.employeeList, assignCard?.data?.email, formatPhoneNumber, localeCompare]); + }, [isOffline, policy?.employeeList, selectedMember, formatPhoneNumber, localeCompare]); const sections = useMemo(() => { if (!debouncedSearchTerm) { @@ -170,82 +154,23 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { ]; } - const sectionsArr = []; - - if (!areOptionsInitialized) { - return []; - } + const searchValue = getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode).toLowerCase(); + const filteredOptions = tokenizedSearch(membersDetails, searchValue, (option) => [option.text ?? '', option.alternateText ?? '']); - const searchValueForOptions = getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode).toLowerCase(); - const filteredOptions = tokenizedSearch(membersDetails, searchValueForOptions, (option) => [option.text ?? '', option.alternateText ?? '']); - - sectionsArr.push({ - title: undefined, - data: filteredOptions, - shouldShow: true, - }); - - // Selected options section - if (selectedOptionsForDisplay.length > 0) { - sectionsArr.push({ - title: undefined, - data: selectedOptionsForDisplay, - }); - } - - // Recent reports section - if (availableOptions.recentReports.length > 0) { - sectionsArr.push({ - title: undefined, - data: availableOptions.recentReports, - }); - } - - // Contacts section - if (availableOptions.personalDetails.length > 0) { - sectionsArr.push({ + return [ + { title: undefined, - data: availableOptions.personalDetails, - }); - } - - // User to invite section - if (availableOptions.userToInvite) { - sectionsArr.push({ - title: undefined, - data: [availableOptions.userToInvite], - }); - } - - return sectionsArr; - }, [ - debouncedSearchTerm, - areOptionsInitialized, - countryCode, - membersDetails, - selectedOptionsForDisplay, - availableOptions.recentReports, - availableOptions.personalDetails, - availableOptions.userToInvite, - ]); - - useEffect(() => { - searchInServer(searchTerm); - }, [searchTerm]); + data: filteredOptions, + shouldShow: true, + }, + ]; + }, [membersDetails, debouncedSearchTerm, countryCode]); const headerMessage = useMemo(() => { - const searchValue = searchTerm.trim().toLowerCase(); - if (!availableOptions.userToInvite && CONST.EXPENSIFY_EMAILS_OBJECT[searchValue]) { - return translate('messages.errorMessageInvalidEmail'); - } - return getHeaderMessage( - sections.some((section) => section.data.length > 0), - !!availableOptions.userToInvite, - searchValue, - countryCode, - false, - ); - }, [searchTerm, availableOptions.userToInvite, sections, countryCode, translate]); + const searchValue = debouncedSearchTerm.trim().toLowerCase(); + + return getHeaderMessage(sections[0].data.length !== 0, false, searchValue, countryCode, false); + }, [debouncedSearchTerm, sections, countryCode]); return ( setDidScreenTransitionEnd(true)} > {translate('workspace.companyCards.whoNeedsCardAssigned')} + } /> ); diff --git a/src/pages/workspace/companyCards/assignCard/InviteNewMemberStep.tsx b/src/pages/workspace/companyCards/assignCard/InviteNewMemberStep.tsx deleted file mode 100644 index 492c766612f82..0000000000000 --- a/src/pages/workspace/companyCards/assignCard/InviteNewMemberStep.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react'; -import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; -import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; -import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; -import useCardFeeds from '@hooks/useCardFeeds'; -import useCardsList from '@hooks/useCardsList'; -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import {getDefaultCardName, getFilteredCardList, hasOnlyOneCardToAssign} from '@libs/CardUtils'; -import WorkspaceInviteMessageComponent from '@pages/workspace/members/WorkspaceInviteMessageComponent'; -import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; -import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; -import {setAssignCardStepAndData} from '@userActions/CompanyCards'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {AssignCardData, AssignCardStep} from '@src/types/onyx/AssignCard'; -import type {CompanyCardFeed} from '@src/types/onyx/CardFeeds'; - -type InviteeNewMemberStepProps = WithPolicyAndFullscreenLoadingProps & - WithCurrentUserPersonalDetailsProps & { - /** Selected feed */ - feed: CompanyCardFeed; - }; - -function InviteNewMemberStep({policy, route, currentUserPersonalDetails, feed}: InviteeNewMemberStepProps) { - const {translate} = useLocalize(); - const [assignCard] = useOnyx(ONYXKEYS.ASSIGN_CARD, {canBeMissing: true}); - const [workspaceCardFeeds] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, {canBeMissing: false}); - const isEditing = assignCard?.isEditing; - const [list] = useCardsList(policy?.id, feed); - const [cardFeeds] = useCardFeeds(policy?.id); - const filteredCardList = getFilteredCardList(list, cardFeeds?.settings?.oAuthAccountDetails?.[feed], workspaceCardFeeds); - - const handleBackButtonPress = () => { - if (isEditing) { - setAssignCardStepAndData({currentStep: CONST.COMPANY_CARD.STEP.CONFIRMATION, isEditing: false}); - } else { - setAssignCardStepAndData({ - currentStep: CONST.COMPANY_CARD.STEP.ASSIGNEE, - data: { - ...assignCard?.data, - assigneeAccountID: undefined, - email: undefined, - }, - isEditing: false, - }); - } - }; - - const goToNextStep = () => { - let nextStep: AssignCardStep = CONST.COMPANY_CARD.STEP.CARD; - const data: Partial = { - email: assignCard?.data?.email, - cardName: getDefaultCardName(assignCard?.data?.email), - }; - - if (hasOnlyOneCardToAssign(filteredCardList)) { - nextStep = CONST.COMPANY_CARD.STEP.TRANSACTION_START_DATE; - data.cardNumber = Object.keys(filteredCardList).at(0); - data.encryptedCardNumber = Object.values(filteredCardList).at(0); - } - - setAssignCardStepAndData({ - currentStep: isEditing ? CONST.COMPANY_CARD.STEP.CONFIRMATION : nextStep, - data, - isEditing: false, - }); - }; - - return ( - - - - ); -} - -InviteNewMemberStep.displayName = 'InviteNewMemberStep'; - -export default withPolicyAndFullscreenLoading(withCurrentUserPersonalDetails(InviteNewMemberStep)); diff --git a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx index c6fc058f29586..416b0e0a9cb91 100644 --- a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useMemo} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import * as Expensicons from '@components/Icon/Expensicons'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; @@ -7,23 +7,24 @@ import type {ListItem} from '@components/SelectionListWithSections/types'; import UserListItem from '@components/SelectionListWithSections/UserListItem'; import Text from '@components/Text'; import useCurrencyForExpensifyCard from '@hooks/useCurrencyForExpensifyCard'; +import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import useSearchSelector from '@hooks/useSearchSelector'; import useThemeStyles from '@hooks/useThemeStyles'; -import {searchInServer} from '@libs/actions/Report'; import {getHeaderMessage, getSearchValueForPhoneOrEmail, sortAlphabetically} from '@libs/OptionsListUtils'; import {getPersonalDetailByEmail, getUserNameByEmail} from '@libs/PersonalDetailsUtils'; -import {getIneligibleInvitees, isDeletedPolicyEmployee} from '@libs/PolicyUtils'; +import {isDeletedPolicyEmployee} from '@libs/PolicyUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; import Navigation from '@navigation/Navigation'; -import {clearIssueNewCardFlow, getCardDefaultName, setDraftInviteAccountID, setIssueNewCardStepAndData} from '@userActions/Card'; +import {clearIssueNewCardFlow, getCardDefaultName, setIssueNewCardStepAndData} from '@userActions/Card'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type {IssueNewCardData} from '@src/types/onyx/Card'; +const MINIMUM_MEMBER_TO_SHOW_SEARCH = 8; + type AssigneeStepProps = { // The policy that the card will be issued under policy: OnyxEntry; @@ -41,32 +42,13 @@ function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { const {isOffline} = useNetwork(); const policyID = policy?.id; const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, {canBeMissing: true}); - const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); - const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false, canBeMissing: true}); - - const excludedUsers = useMemo(() => { - const ineligibleInvites = getIneligibleInvitees(policy?.employeeList); - return ineligibleInvites.reduce( - (acc, login) => { - acc[login] = true; - return acc; - }, - {} as Record, - ); - }, [policy?.employeeList]); - - const {searchTerm, setSearchTerm, debouncedSearchTerm, availableOptions, selectedOptionsForDisplay, areOptionsInitialized} = useSearchSelector({ - selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, - searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, - includeUserToInvite: true, - excludeLogins: excludedUsers, - includeRecentReports: true, - shouldInitialize: didScreenTransitionEnd, - }); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); const currency = useCurrencyForExpensifyCard({policyID}); + const isEditing = issueNewCard?.isEditing; + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const submit = (assignee: ListItem) => { const data: Partial = { assigneeEmail: assignee?.login ?? '', @@ -78,17 +60,6 @@ function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { data.cardTitle = getCardDefaultName(getUserNameByEmail(assignee?.login ?? '', 'firstName')); } - if (!policy?.employeeList?.[assignee?.login ?? '']) { - data.assigneeAccountID = assignee?.accountID ?? undefined; - setIssueNewCardStepAndData({ - step: CONST.EXPENSIFY_CARD.STEP.INVITE_NEW_MEMBER, - data, - policyID, - }); - setDraftInviteAccountID(data.assigneeEmail, assignee?.accountID ?? undefined, policyID); - return; - } - setIssueNewCardStepAndData({ step: isEditing ? CONST.EXPENSIFY_CARD.STEP.CONFIRMATION : CONST.EXPENSIFY_CARD.STEP.CARD_TYPE, data, @@ -106,6 +77,9 @@ function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { clearIssueNewCardFlow(policyID); }; + const shouldShowSearchInput = policy?.employeeList && Object.keys(policy.employeeList).length >= MINIMUM_MEMBER_TO_SHOW_SEARCH; + const textInputLabel = shouldShowSearchInput ? translate('workspace.card.issueNewCard.findMember') : undefined; + const membersDetails = useMemo(() => { let membersList: ListItem[] = []; if (!policy?.employeeList) { @@ -151,82 +125,23 @@ function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { ]; } - const sectionsArr = []; - - if (!areOptionsInitialized) { - return []; - } - - const searchValueForOptions = getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode).toLowerCase(); - const filteredOptions = tokenizedSearch(membersDetails, searchValueForOptions, (option) => [option.text ?? '', option.alternateText ?? '']); + const searchValue = getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode).toLowerCase(); + const filteredOptions = tokenizedSearch(membersDetails, searchValue, (option) => [option.text ?? '', option.alternateText ?? '']); - sectionsArr.push({ - title: undefined, - data: filteredOptions, - shouldShow: true, - }); - - // Selected options section - if (selectedOptionsForDisplay.length > 0) { - sectionsArr.push({ + return [ + { title: undefined, - data: selectedOptionsForDisplay, - }); - } - - // Recent reports section - if (availableOptions.recentReports.length > 0) { - sectionsArr.push({ - title: undefined, - data: availableOptions.recentReports, - }); - } - - // Contacts section - if (availableOptions.personalDetails.length > 0) { - sectionsArr.push({ - title: undefined, - data: availableOptions.personalDetails, - }); - } - - // User to invite section - if (availableOptions.userToInvite) { - sectionsArr.push({ - title: undefined, - data: [availableOptions.userToInvite], - }); - } - - return sectionsArr; - }, [ - debouncedSearchTerm, - areOptionsInitialized, - countryCode, - membersDetails, - selectedOptionsForDisplay, - availableOptions.recentReports, - availableOptions.personalDetails, - availableOptions.userToInvite, - ]); - - useEffect(() => { - searchInServer(searchTerm); - }, [searchTerm]); + data: filteredOptions, + shouldShow: true, + }, + ]; + }, [debouncedSearchTerm, countryCode, membersDetails]); const headerMessage = useMemo(() => { - const searchValue = searchTerm.trim().toLowerCase(); - if (!availableOptions.userToInvite && CONST.EXPENSIFY_EMAILS_OBJECT[searchValue]) { - return translate('messages.errorMessageInvalidEmail'); - } - return getHeaderMessage( - sections.some((section) => section.data.length > 0), - !!availableOptions.userToInvite, - searchValue, - countryCode, - false, - ); - }, [searchTerm, availableOptions.userToInvite, sections, countryCode, translate]); + const searchValue = debouncedSearchTerm.trim().toLowerCase(); + + return getHeaderMessage(sections[0].data.length !== 0, false, searchValue, countryCode, false); + }, [debouncedSearchTerm, sections, countryCode]); return ( setDidScreenTransitionEnd(true)} > {translate('workspace.card.issueNewCard.whoNeedsCard')} mode.isSelected)?.keyForList} /> ); diff --git a/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx b/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx deleted file mode 100644 index 20bd2a2d0013a..0000000000000 --- a/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React from 'react'; -import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; -import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; -import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import {setIssueNewCardStepAndData} from '@libs/actions/Card'; -import WorkspaceInviteMessageComponent from '@pages/workspace/members/WorkspaceInviteMessageComponent'; -import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; -import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -type InviteeNewMemberStepProps = WithPolicyAndFullscreenLoadingProps & WithCurrentUserPersonalDetailsProps; - -function InviteNewMemberStep({policy, route, currentUserPersonalDetails}: InviteeNewMemberStepProps) { - const {translate} = useLocalize(); - const policyID = route.params.policyID; - const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, {canBeMissing: true}); - - const isEditing = issueNewCard?.isEditing; - const handleBackButtonPress = () => { - if (isEditing) { - setIssueNewCardStepAndData({step: CONST.EXPENSIFY_CARD.STEP.CONFIRMATION, isEditing: false, policyID}); - return; - } - - setIssueNewCardStepAndData({ - step: CONST.EXPENSIFY_CARD.STEP.ASSIGNEE, - data: {...issueNewCard?.data, assigneeAccountID: undefined, assigneeEmail: undefined}, - isEditing: false, - policyID, - }); - }; - - const goToNextStep = () => { - if (isEditing) { - setIssueNewCardStepAndData({step: CONST.EXPENSIFY_CARD.STEP.CONFIRMATION, isEditing: false, policyID}); - } else { - setIssueNewCardStepAndData({step: CONST.EXPENSIFY_CARD.STEP.CARD_TYPE, isEditing: false, policyID}); - } - }; - - return ( - - - - ); -} - -InviteNewMemberStep.displayName = 'InviteNewMemberStep'; - -export default withPolicyAndFullscreenLoading(withCurrentUserPersonalDetails(InviteNewMemberStep)); diff --git a/src/pages/workspace/expensifyCard/issueNew/IssueNewCardPage.tsx b/src/pages/workspace/expensifyCard/issueNew/IssueNewCardPage.tsx index 6098d40022fa8..7bf754dc5c394 100644 --- a/src/pages/workspace/expensifyCard/issueNew/IssueNewCardPage.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/IssueNewCardPage.tsx @@ -20,7 +20,6 @@ import AssigneeStep from './AssigneeStep'; import CardNameStep from './CardNameStep'; import CardTypeStep from './CardTypeStep'; import ConfirmationStep from './ConfirmationStep'; -import InviteNewMemberStep from './InviteNewMemberStep'; import LimitStep from './LimitStep'; import LimitTypeStep from './LimitTypeStep'; @@ -33,7 +32,6 @@ function getStartStepIndex(issueNewCard: OnyxEntry): number { const STEP_INDEXES: Record = { [CONST.EXPENSIFY_CARD.STEP.ASSIGNEE]: 0, - [CONST.EXPENSIFY_CARD.STEP.INVITE_NEW_MEMBER]: 0, [CONST.EXPENSIFY_CARD.STEP.CARD_TYPE]: 1, [CONST.EXPENSIFY_CARD.STEP.LIMIT_TYPE]: 2, [CONST.EXPENSIFY_CARD.STEP.LIMIT]: 3, @@ -111,8 +109,6 @@ function IssueNewCardPage({policy, route}: IssueNewCardPageProps) { startStepIndex={startStepIndex} /> ); - case CONST.EXPENSIFY_CARD.STEP.INVITE_NEW_MEMBER: - return ; default: return ( ; - policyID: string | undefined; - backTo: Routes | undefined; - currentUserPersonalDetails: OnyxEntry; - shouldShowTooltip?: boolean; - shouldShowBackButton?: boolean; - shouldShowMemberNames?: boolean; - isInviteNewMemberStep?: boolean; - goToNextStep?: () => void; -}; - -function WorkspaceInviteMessageComponent({ - policy, - policyID, - backTo, - currentUserPersonalDetails, - shouldShowTooltip = true, - shouldShowBackButton = true, - shouldShowMemberNames = true, - isInviteNewMemberStep = false, - goToNextStep, -}: WorkspaceInviteMessageComponentProps) { - const styles = useThemeStyles(); - const {translate, formatPhoneNumber} = useLocalize(); - const [formData, formDataResult] = useOnyx(ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM_DRAFT, {canBeMissing: true}); - const [allPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); - - const viewportOffsetTop = useViewportOffsetTop(); - const [welcomeNote, setWelcomeNote] = useState(); - - const {inputCallbackRef, inputRef} = useAutoFocusInput(); - - const [invitedEmailsToAccountIDsDraft, invitedEmailsToAccountIDsDraftResult] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID}`, { - canBeMissing: true, - }); - const [workspaceInviteMessageDraft, workspaceInviteMessageDraftResult] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT}${policyID}`, { - canBeMissing: true, - }); - const [workspaceInviteRoleDraft = CONST.POLICY.ROLE.USER] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_ROLE_DRAFT}${policyID}`, {canBeMissing: true}); - const isOnyxLoading = isLoadingOnyxValue(workspaceInviteMessageDraftResult, invitedEmailsToAccountIDsDraftResult, formDataResult); - const personalDetailsOfInvitedEmails = getPersonalDetailsForAccountIDs(Object.values(invitedEmailsToAccountIDsDraft ?? {}), allPersonalDetails ?? {}); - const memberNames = Object.values(personalDetailsOfInvitedEmails) - .map((personalDetail) => { - const displayName = getDisplayNameOrDefault(personalDetail, '', false); - if (displayName) { - return displayName; - } - - // We don't have login details for users who are not in the database yet - // So we need to fallback to their login from the invitedEmailsToAccountIDsDraft - const accountID = personalDetail.accountID; - const loginFromInviteMap = Object.entries(invitedEmailsToAccountIDsDraft ?? {}).find(([, id]) => id === accountID)?.[0]; - - return loginFromInviteMap; - }) - .join(', '); - - const welcomeNoteSubject = useMemo( - () => `# ${currentUserPersonalDetails?.displayName ?? ''} invited you to ${policy?.name ?? 'a workspace'}`, - [policy?.name, currentUserPersonalDetails?.displayName], - ); - - const getDefaultWelcomeNote = useCallback(() => { - return formData?.[INPUT_IDS.WELCOME_MESSAGE] ?? workspaceInviteMessageDraft ?? translate('workspace.common.welcomeNote'); - }, [workspaceInviteMessageDraft, translate, formData]); - - useEffect(() => { - if (isOnyxLoading) { - return; - } - if (!isEmptyObject(invitedEmailsToAccountIDsDraft)) { - setWelcomeNote(getDefaultWelcomeNote()); - return; - } - if (isEmptyObject(policy)) { - return; - } - Navigation.goBack(backTo); - - // We only want to run this useEffect when the onyx values have loaded - // We navigate back to the main members screen when the invitation has been sent - // This is decided when onyx values have loaded and if `invitedEmailsToAccountIDsDraft` is empty - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [isOnyxLoading]); - - const sendInvitation = () => { - Keyboard.dismiss(); - const policyMemberAccountIDs = Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList, false, false)); - // Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details - // See https://github.com/Expensify/App/blob/main/README.md#workspace, we set conditions about who can leave the workspace - addMembersToWorkspace(invitedEmailsToAccountIDsDraft ?? {}, `${welcomeNoteSubject}\n\n${welcomeNote}`, policyID, policyMemberAccountIDs, workspaceInviteRoleDraft, formatPhoneNumber); - setWorkspaceInviteMessageDraft(policyID, welcomeNote ?? null); - clearDraftValues(ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM); - - if (goToNextStep) { - goToNextStep(); - return; - } - - if ((backTo as string)?.endsWith('members')) { - Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.dismissModal()); - return; - } - - if (getIsNarrowLayout()) { - Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID), {forceReplace: true}); - return; - } - - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.dismissModal(); - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID)); - }); - }); - }; - - /** Opens privacy url as an external link */ - const openPrivacyURL = (event: GestureResponderEvent | KeyboardEvent | undefined) => { - event?.preventDefault(); - openExternalLink(CONST.OLD_DOT_PUBLIC_URLS.PRIVACY_URL); - }; - - const validate = (): FormInputErrors => { - const errorFields: FormInputErrors = {}; - if (isEmptyObject(invitedEmailsToAccountIDsDraft) && !isOnyxLoading) { - errorFields.welcomeMessage = translate('workspace.inviteMessage.inviteNoMembersError'); - } - return errorFields; - }; - - const policyName = policy?.name; - - useEffect(() => { - return () => { - clearWorkspaceInviteRoleDraft(policyID); - }; - }, [policyID]); - - return ( - - - {shouldShowBackButton && ( - Navigation.dismissModal()} - onBackButtonPress={() => Navigation.goBack(backTo)} - /> - )} - - {isInviteNewMemberStep && {translate('workspace.card.issueNewCard.inviteNewMember')}} - - - - - - {shouldShowMemberNames && ( - { - Navigation.goBack(backTo); - }} - /> - )} - { - Navigation.navigate(ROUTES.WORKSPACE_INVITE_MESSAGE_ROLE.getRoute(policyID, Navigation.getActiveRoute())); - }} - /> - - - {translate('workspace.inviteMessage.inviteMessagePrompt')} - - { - setWelcomeNote(text); - }} - ref={(element: AnimatedTextInputRef | null) => { - if (!element) { - return; - } - if (!inputRef.current) { - updateMultilineInputRange(element); - } - inputCallbackRef(element); - }} - shouldSaveDraft - /> - - - {translate('common.privacy')} - - - - - - - ); -} - -WorkspaceInviteMessageComponent.displayName = 'WorkspaceInviteMessageComponent'; - -export default WorkspaceInviteMessageComponent; diff --git a/src/types/onyx/AssignCard.ts b/src/types/onyx/AssignCard.ts index 9e3df19aa898c..dd0ba64f86831 100644 --- a/src/types/onyx/AssignCard.ts +++ b/src/types/onyx/AssignCard.ts @@ -40,9 +40,6 @@ type AssignCardData = { /** Plaid accounts */ plaidAccounts?: LinkAccount[] | PlaidAccount[]; - - /** The account ID of the cardholder */ - assigneeAccountID?: number; }; /** Model of assign card flow */ diff --git a/src/types/onyx/Card.ts b/src/types/onyx/Card.ts index 9f90d8333d381..54743c55bfedc 100644 --- a/src/types/onyx/Card.ts +++ b/src/types/onyx/Card.ts @@ -218,9 +218,6 @@ type IssueNewCardData = { /** The email address of the cardholder */ assigneeEmail: string; - /** The account ID of the cardholder */ - assigneeAccountID?: number; - /** Card type */ cardType: ValueOf; diff --git a/tests/ui/AssignCardFeedPage.tsx b/tests/ui/AssignCardFeedPage.tsx index c7f8134874e8d..9ea1d8ae61516 100644 --- a/tests/ui/AssignCardFeedPage.tsx +++ b/tests/ui/AssignCardFeedPage.tsx @@ -30,26 +30,6 @@ jest.mock('@hooks/useNetwork', () => })), ); -jest.mock('react-native-permissions', () => ({ - RESULTS: { - UNAVAILABLE: 'unavailable', - BLOCKED: 'blocked', - DENIED: 'denied', - GRANTED: 'granted', - LIMITED: 'limited', - }, - check: jest.fn(() => Promise.resolve('granted')), - request: jest.fn(() => Promise.resolve('granted')), - PERMISSIONS: { - IOS: { - CONTACTS: 'ios.permission.CONTACTS', - }, - ANDROID: { - READ_CONTACTS: 'android.permission.READ_CONTACTS', - }, - }, -})); - jest.mock('@rnmapbox/maps', () => { return { default: jest.fn(),