diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 238b3417fba58..1fef34206be74 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3309,6 +3309,7 @@ const CONST = { CARD_NAME: 'CardName', TRANSACTION_START_DATE: 'TransactionStartDate', CONFIRMATION: 'Confirmation', + INVITE_NEW_MEMBER: 'InviteNewMember', }, TRANSACTION_START_DATE_OPTIONS: { FROM_BEGINNING: 'fromBeginning', @@ -3354,6 +3355,7 @@ 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 bf4ed40ec6f39..44ff6908fd2fc 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1268,8 +1268,14 @@ const ROUTES = { WORKSPACE_INVITE_MESSAGE_ROLE: { route: 'workspaces/:policyID/invite-message/role', - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (policyID: string, backTo?: string) => `${getUrlWithBackToParam(`workspaces/${policyID}/invite-message/role`, backTo)}` as const, + 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); + }, }, WORKSPACE_OVERVIEW: { route: 'workspaces/:policyID/overview', diff --git a/src/components/InteractiveStepWrapper.tsx b/src/components/InteractiveStepWrapper.tsx index d9f6709c47ec8..60961b7996915 100644 --- a/src/components/InteractiveStepWrapper.tsx +++ b/src/components/InteractiveStepWrapper.tsx @@ -56,6 +56,11 @@ 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( @@ -74,6 +79,7 @@ function InteractiveStepWrapper( offlineIndicatorStyle, shouldKeyboardOffsetBottomSafeAreaPadding, enableEdgeToEdgeBottomSafeAreaPadding, + onEntryTransitionEnd, }: InteractiveStepWrapperProps, ref: React.ForwardedRef, ) { @@ -91,6 +97,7 @@ function InteractiveStepWrapper( shouldShowOfflineIndicatorInWideScreen={shouldShowOfflineIndicatorInWideScreen} offlineIndicatorStyle={offlineIndicatorStyle} shouldKeyboardOffsetBottomSafeAreaPadding={shouldKeyboardOffsetBottomSafeAreaPadding} + onEntryTransitionEnd={onEntryTransitionEnd} > ): boolean { ); } -function getRoom(type: ValueOf, policyID: string): OnyxEntry { +function getRoom(type: ValueOf, policyID: string | undefined): 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 ef06d8dc30557..1e3456a8d6060 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -410,6 +410,15 @@ 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, @@ -1059,6 +1068,7 @@ 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 8983082efcf18..f0a5bdb1d41f0 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, + policyID: string | undefined, accountIDs: number[], ): OnyxDataReturnType { const report = ReportUtils.getRoom(roomType, policyID); @@ -875,7 +875,7 @@ function clearWorkspaceOwnerChangeFlow(policyID: string | undefined) { function buildAddMembersToWorkspaceOnyxData( invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, - policyID: string, + policyID: string | undefined, policyMemberAccountIDs: number[], role: string, formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], @@ -977,7 +977,7 @@ function buildAddMembersToWorkspaceOnyxData( function addMembersToWorkspace( invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, welcomeNote: string, - policyID: string, + policyID: string | undefined, 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 ( - - - 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')} - - - - - - + backTo={route.params.backTo} + currentUserPersonalDetails={currentUserPersonalDetails} + /> ); } diff --git a/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx b/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx index 6c3e0845a66a1..d3084f9324a72 100644 --- a/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx @@ -19,6 +19,7 @@ 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; @@ -99,6 +100,13 @@ function AssignCardFeedPage({route, policy}: AssignCardFeedPageProps) { backTo={shouldUseBackToParam ? backTo : undefined} /> ); + case CONST.COMPANY_CARD.STEP.INVITE_NEW_MEMBER: + return ( + + ); default: return ( ; @@ -47,22 +46,42 @@ 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 isEditing = assignCard?.isEditing; + const excludedUsers = useMemo(() => { + const ineligibleInvites = getIneligibleInvitees(policy?.employeeList); + return ineligibleInvites.reduce( + (acc, login) => { + acc[login] = true; + return acc; + }, + {} as Record, + ); + }, [policy?.employeeList]); - const [selectedMember, setSelectedMember] = useState(assignCard?.data?.email ?? ''); - const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); - const [shouldShowError, setShouldShowError] = useState(false); + 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 selectMember = (assignee: ListItem) => { - Keyboard.dismiss(); - setSelectedMember(assignee.login ?? ''); - setShouldShowError(false); - }; + const isEditing = assignCard?.isEditing; - const submit = () => { + const submit = (assignee: ListItem) => { let nextStep: AssignCardStep = CONST.COMPANY_CARD.STEP.CARD; - if (selectedMember === assignCard?.data?.email) { + const personalDetail = getPersonalDetailByEmail(assignee?.login ?? ''); + const memberName = personalDetail?.firstName ? personalDetail.firstName : personalDetail?.login; + const data: Partial = { + email: assignee?.login ?? '', + cardName: getDefaultCardName(memberName), + }; + + Keyboard.dismiss(); + if (assignee?.login === assignCard?.data?.email) { setAssignCardStepAndData({ currentStep: isEditing ? CONST.COMPANY_CARD.STEP.CONFIRMATION : nextStep, isEditing: false, @@ -70,18 +89,18 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { return; } - if (!selectedMember) { - setShouldShowError(true); + 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); 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); @@ -106,9 +125,6 @@ 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) { @@ -127,7 +143,7 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { alternateText: email, login: email, accountID: personalDetail?.accountID, - isSelected: selectedMember === email, + isSelected: assignCard?.data?.email === email, icons: [ { source: personalDetail?.avatar ?? Expensicons.FallbackAvatar, @@ -142,7 +158,7 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { membersList = sortAlphabetically(membersList, 'text', localeCompare); return membersList; - }, [isOffline, policy?.employeeList, selectedMember, formatPhoneNumber, localeCompare]); + }, [isOffline, policy?.employeeList, assignCard?.data?.email, formatPhoneNumber, localeCompare]); const sections = useMemo(() => { if (!debouncedSearchTerm) { @@ -154,23 +170,82 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { ]; } - const searchValue = getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode).toLowerCase(); - const filteredOptions = tokenizedSearch(membersDetails, searchValue, (option) => [option.text ?? '', option.alternateText ?? '']); + const sectionsArr = []; + + if (!areOptionsInitialized) { + return []; + } - return [ - { + 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: filteredOptions, - shouldShow: true, - }, - ]; - }, [membersDetails, debouncedSearchTerm, countryCode]); + data: selectedOptionsForDisplay, + }); + } - const headerMessage = useMemo(() => { - const searchValue = debouncedSearchTerm.trim().toLowerCase(); + // 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, + }); + } - return getHeaderMessage(sections[0].data.length !== 0, false, searchValue, countryCode, false); - }, [debouncedSearchTerm, sections, countryCode]); + // 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]); + + 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]); return ( setDidScreenTransitionEnd(true)} > {translate('workspace.companyCards.whoNeedsCardAssigned')} - } + showLoadingPlaceholder={!areOptionsInitialized} + isLoadingNewOptions={!!isSearchingForReports} /> ); diff --git a/src/pages/workspace/companyCards/assignCard/InviteNewMemberStep.tsx b/src/pages/workspace/companyCards/assignCard/InviteNewMemberStep.tsx new file mode 100644 index 0000000000000..492c766612f82 --- /dev/null +++ b/src/pages/workspace/companyCards/assignCard/InviteNewMemberStep.tsx @@ -0,0 +1,98 @@ +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 416b0e0a9cb91..c6fc058f29586 100644 --- a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react'; +import React, {useEffect, useMemo, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import * as Expensicons from '@components/Icon/Expensicons'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; @@ -7,24 +7,23 @@ 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 {isDeletedPolicyEmployee} from '@libs/PolicyUtils'; +import {getIneligibleInvitees, isDeletedPolicyEmployee} from '@libs/PolicyUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; import Navigation from '@navigation/Navigation'; -import {clearIssueNewCardFlow, getCardDefaultName, setIssueNewCardStepAndData} from '@userActions/Card'; +import {clearIssueNewCardFlow, getCardDefaultName, setDraftInviteAccountID, 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; @@ -42,13 +41,32 @@ 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 ?? '', @@ -60,6 +78,17 @@ 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, @@ -77,9 +106,6 @@ 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) { @@ -125,23 +151,82 @@ function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { ]; } - const searchValue = getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode).toLowerCase(); - const filteredOptions = tokenizedSearch(membersDetails, searchValue, (option) => [option.text ?? '', option.alternateText ?? '']); + const sectionsArr = []; + + if (!areOptionsInitialized) { + return []; + } + + const searchValueForOptions = getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode).toLowerCase(); + const filteredOptions = tokenizedSearch(membersDetails, searchValueForOptions, (option) => [option.text ?? '', option.alternateText ?? '']); - return [ - { + sectionsArr.push({ + title: undefined, + data: filteredOptions, + shouldShow: true, + }); + + // Selected options section + if (selectedOptionsForDisplay.length > 0) { + sectionsArr.push({ title: undefined, - data: filteredOptions, - shouldShow: true, - }, - ]; - }, [debouncedSearchTerm, countryCode, membersDetails]); + data: selectedOptionsForDisplay, + }); + } - const headerMessage = useMemo(() => { - const searchValue = debouncedSearchTerm.trim().toLowerCase(); + // 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, + }); + } - return getHeaderMessage(sections[0].data.length !== 0, false, searchValue, countryCode, false); - }, [debouncedSearchTerm, sections, countryCode]); + // 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]); + + 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]); return ( setDidScreenTransitionEnd(true)} > {translate('workspace.card.issueNewCard.whoNeedsCard')} mode.isSelected)?.keyForList} + isLoadingNewOptions={!!isSearchingForReports} /> ); diff --git a/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx b/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx new file mode 100644 index 0000000000000..20bd2a2d0013a --- /dev/null +++ b/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx @@ -0,0 +1,72 @@ +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 7bf754dc5c394..6098d40022fa8 100644 --- a/src/pages/workspace/expensifyCard/issueNew/IssueNewCardPage.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/IssueNewCardPage.tsx @@ -20,6 +20,7 @@ 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'; @@ -32,6 +33,7 @@ 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, @@ -109,6 +111,8 @@ 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 dd0ba64f86831..9e3df19aa898c 100644 --- a/src/types/onyx/AssignCard.ts +++ b/src/types/onyx/AssignCard.ts @@ -40,6 +40,9 @@ 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 136b5fc1c226c..16e1c07e3ddff 100644 --- a/src/types/onyx/Card.ts +++ b/src/types/onyx/Card.ts @@ -215,6 +215,9 @@ 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 9ea1d8ae61516..c7f8134874e8d 100644 --- a/tests/ui/AssignCardFeedPage.tsx +++ b/tests/ui/AssignCardFeedPage.tsx @@ -30,6 +30,26 @@ 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(),