From a3cf66c31c98811bb6a193d47c3ceda5b97c7179 Mon Sep 17 00:00:00 2001 From: Rutika Pawar <183392827+twilight2294@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:50:02 +0000 Subject: [PATCH 01/23] introduce invitation for cards flow --- src/CONST/index.ts | 2 + src/languages/de.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/zh-hans.ts | 1 + src/libs/actions/Card.ts | 10 + .../workspace/WorkspaceInviteMessagePage.tsx | 239 +--------------- .../assignCard/AssignCardFeedPage.tsx | 8 + .../companyCards/assignCard/AssigneeStep.tsx | 122 ++++++-- .../assignCard/AssigneeStep.tsx.rej | 14 + .../assignCard/InviteNewMemberStep.tsx | 94 +++++++ .../expensifyCard/issueNew/AssigneeStep.tsx | 131 +++++++-- .../issueNew/InviteNewMemberStep.tsx | 70 +++++ .../issueNew/IssueNewCardPage.tsx | 4 + .../WorkspaceInviteMessageComponent.tsx | 263 ++++++++++++++++++ src/types/onyx/AssignCard.ts | 3 + src/types/onyx/Card.ts | 3 + 22 files changed, 703 insertions(+), 269 deletions(-) create mode 100644 src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx.rej create mode 100644 src/pages/workspace/companyCards/assignCard/InviteNewMemberStep.tsx create mode 100644 src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx create mode 100644 src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ea6ae81763033..e86264a203889 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3201,6 +3201,7 @@ const CONST = { CARD_NAME: 'CardName', TRANSACTION_START_DATE: 'TransactionStartDate', CONFIRMATION: 'Confirmation', + INVITE_NEW_MEMBER: 'InviteNewMember', }, TRANSACTION_START_DATE_OPTIONS: { FROM_BEGINNING: 'fromBeginning', @@ -3245,6 +3246,7 @@ const CONST = { LIMIT: 'Limit', CARD_NAME: 'CardName', CONFIRMATION: 'Confirmation', + INVITE_NEW_MEMBER: 'InviteNewMember', }, CARD_TYPE: { PHYSICAL: 'physical', diff --git a/src/languages/de.ts b/src/languages/de.ts index 13825e67e931f..a80348e482d4d 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -4916,6 +4916,7 @@ const translations = { issueCard: 'Karte ausstellen', issueNewCard: { whoNeedsCard: 'Wer braucht eine Karte?', + inviteNewMember: 'Neues Mitglied einladen', findMember: 'Mitglied finden', chooseCardType: 'Wählen Sie einen Kartentyp aus', physicalCard: 'Physische Karte', diff --git a/src/languages/en.ts b/src/languages/en.ts index b19319cb1c94f..af0e44c037c8e 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4897,6 +4897,7 @@ const translations = { issueCard: 'Issue card', issueNewCard: { whoNeedsCard: 'Who needs a card?', + inviteNewMember: 'Invite new member', findMember: 'Find member', chooseCardType: 'Choose a card type', physicalCard: 'Physical card', diff --git a/src/languages/es.ts b/src/languages/es.ts index 725d200359f06..861659305d678 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5156,6 +5156,7 @@ const translations = { getStartedIssuing: 'Empieza emitiendo tu primera tarjeta virtual o física.', issueNewCard: { whoNeedsCard: '¿Quién necesita una tarjeta?', + inviteNewMember: 'Invitar nuevo miembro', findMember: 'Buscar miembro', chooseCardType: 'Elegir un tipo de tarjeta', physicalCard: 'Tarjeta física', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index cf19c6444c162..62aedabd62288 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -4930,6 +4930,7 @@ const translations = { issueCard: 'Émettre une carte', issueNewCard: { whoNeedsCard: "Qui a besoin d'une carte ?", + inviteNewMember: 'Inviter un nouveau membre', findMember: 'Trouver un membre', chooseCardType: 'Choisissez un type de carte', physicalCard: 'Carte physique', diff --git a/src/languages/it.ts b/src/languages/it.ts index d0328b32ed17e..451102434bb3e 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -4929,6 +4929,7 @@ const translations = { issueCard: 'Emetti carta', issueNewCard: { whoNeedsCard: 'Chi ha bisogno di una carta?', + inviteNewMember: 'Invita nuovo membro', findMember: 'Trova membro', chooseCardType: 'Scegli un tipo di carta', physicalCard: 'Carta fisica', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 2ba80810ca82a..a380115c712a0 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -4908,6 +4908,7 @@ const translations = { issueCard: 'カードを発行', issueNewCard: { whoNeedsCard: '誰がカードを必要としていますか?', + inviteNewMember: '新しいメンバーを招待', findMember: 'メンバーを探す', chooseCardType: 'カードタイプを選択', physicalCard: '物理カード', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 6cc8727e4481f..1eb09d995f065 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -4931,6 +4931,7 @@ const translations = { issueCard: 'Kaart uitgeven', issueNewCard: { whoNeedsCard: 'Wie heeft een kaart nodig?', + inviteNewMember: 'Nieuw lid uitnodigen', findMember: 'Lid zoeken', chooseCardType: 'Kies een kaarttype', physicalCard: 'Fysieke kaart', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index c690d69d9c462..ce6a9307d36e8 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -4920,6 +4920,7 @@ const translations = { issueCard: 'Wydaj kartę', issueNewCard: { whoNeedsCard: 'Kto potrzebuje karty?', + inviteNewMember: 'Zaproś nowego członka', findMember: 'Znajdź członka', chooseCardType: 'Wybierz typ karty', physicalCard: 'Fizyczna karta', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 8193bb5dd0e4f..0d45c81c7b83d 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4844,6 +4844,7 @@ const translations = { issueCard: '发卡', issueNewCard: { whoNeedsCard: '谁需要一张卡?', + inviteNewMember: '邀请新成员', findMember: '查找成员', chooseCardType: '选择卡类型', physicalCard: '实体卡', diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 758b05e08ebfd..771807e416268 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -376,6 +376,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, @@ -976,5 +985,6 @@ export { getCardDefaultName, queueExpensifyCardForBilling, clearIssueNewCardFormData, + setDraftInviteAccountID, }; export type {ReplacementReason}; diff --git a/src/pages/workspace/WorkspaceInviteMessagePage.tsx b/src/pages/workspace/WorkspaceInviteMessagePage.tsx index 83a144684149a..03494bf308ffc 100644 --- a/src/pages/workspace/WorkspaceInviteMessagePage.tsx +++ b/src/pages/workspace/WorkspaceInviteMessagePage.tsx @@ -1,43 +1,10 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; -import {InteractionManager, Keyboard, View} from 'react-native'; -import type {GestureResponderEvent} from 'react-native/Libraries/Types/CoreEventTypes'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import type {FormInputErrors} from '@components/Form/types'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; -import ReportActionAvatars from '@components/ReportActionAvatars'; -import type {AnimatedTextInputRef} from '@components/RNTextInput'; -import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; -import TextInput from '@components/TextInput'; +import React from 'react'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; -import useAutoFocusInput from '@hooks/useAutoFocusInput'; -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useViewportOffsetTop from '@hooks/useViewportOffsetTop'; -import {clearDraftValues} from '@libs/actions/FormActions'; -import {openExternalLink} from '@libs/actions/Link'; -import {addMembersToWorkspace, clearWorkspaceInviteRoleDraft} from '@libs/actions/Policy/Member'; -import {setWorkspaceInviteMessageDraft} from '@libs/actions/Policy/Policy'; -import getIsNarrowLayout from '@libs/getIsNarrowLayout'; -import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {getMemberAccountIDsForWorkspace, goBackFromInvalidPolicy} from '@libs/PolicyUtils'; -import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import type {SettingsNavigatorParamList} from '@navigation/types'; -import variables from '@styles/variables'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import INPUT_IDS from '@src/types/form/WorkspaceInviteMessageForm'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import AccessOrNotFoundWrapper from './AccessOrNotFoundWrapper'; +import WorkspaceInviteMessageComponent from './members/WorkspaceInviteMessageComponent'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; @@ -46,205 +13,13 @@ type WorkspaceInviteMessagePageProps = WithPolicyAndFullscreenLoadingProps & PlatformStackScreenProps; 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 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 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(); - 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)} - /> - - - {translate('common.privacy')} - - - } - > - - - - - {translate('workspace.inviteMessage.inviteMessagePrompt')} - - - - { - Navigation.navigate(ROUTES.WORKSPACE_INVITE_MESSAGE_ROLE.getRoute(route.params.policyID, Navigation.getActiveRoute())); - }} - /> - - { - setWelcomeNote(text); - }} - ref={(element: AnimatedTextInputRef) => { - if (!element) { - return; - } - if (!inputRef.current) { - updateMultilineInputRange(element); - } - inputCallbackRef(element); - }} - shouldSaveDraft - /> - - - - + 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 35e59f32522a8..c6c4d71ac6f09 100644 --- a/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssignCardFeedPage.tsx @@ -18,6 +18,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; @@ -98,6 +99,13 @@ function AssignCardFeedPage({route, policy}: AssignCardFeedPageProps) { backTo={shouldUseBackToParam ? backTo : undefined} /> ); + case CONST.COMPANY_CARD.STEP.INVITE_NEW_MEMBER: + return ( + + ); default: return ( ; @@ -37,6 +38,75 @@ type AssigneeStepProps = { feed: OnyxTypes.CompanyCardFeed; }; +const memoizedGetValidOptions = memoize(getValidOptions, {maxSize: 5, monitoringName: 'AssigneeStep.getValidOptions'}); + +function useOptions() { + const betas = useBetas(); + const [isLoading, setIsLoading] = useState(true); + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + const {options: optionsList, areOptionsInitialized} = useOptionsList(); + const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true}); + const existingDelegates = useMemo( + () => + account?.delegatedAccess?.delegates?.reduce( + (prev, {email}) => { + // eslint-disable-next-line no-param-reassign + prev[email] = true; + return prev; + }, + {} as Record, + ), + [account?.delegatedAccess?.delegates], + ); + + const defaultOptions = useMemo(() => { + const {recentReports, personalDetails, userToInvite, currentUserOption} = memoizedGetValidOptions( + { + reports: optionsList.reports, + personalDetails: optionsList.personalDetails, + }, + { + betas, + excludeLogins: {...CONST.EXPENSIFY_EMAILS_OBJECT, ...existingDelegates}, + }, + ); + + const headerMessage = getHeaderMessage((recentReports?.length || 0) + (personalDetails?.length || 0) !== 0, !!userToInvite, ''); + + if (isLoading) { + // eslint-disable-next-line react-compiler/react-compiler + setIsLoading(false); + } + + return { + userToInvite, + recentReports, + personalDetails, + currentUserOption, + headerMessage, + }; + }, [optionsList.reports, optionsList.personalDetails, betas, existingDelegates, isLoading]); + + const options = useMemo(() => { + const filteredOptions = filterAndOrderOptions(defaultOptions, debouncedSearchValue.trim(), { + excludeLogins: {...CONST.EXPENSIFY_EMAILS_OBJECT, ...existingDelegates}, + maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, + }); + const headerMessage = getHeaderMessage( + (filteredOptions.recentReports?.length || 0) + (filteredOptions.personalDetails?.length || 0) !== 0, + !!filteredOptions.userToInvite, + debouncedSearchValue, + ); + + return { + ...filteredOptions, + headerMessage, + }; + }, [debouncedSearchValue, defaultOptions, existingDelegates]); + + return {...options, searchValue, debouncedSearchValue, setSearchValue, areOptionsInitialized}; +} + function AssigneeStep({policy, feed}: AssigneeStepProps) { const {translate, formatPhoneNumber, localeCompare} = useLocalize(); const styles = useThemeStyles(); @@ -74,6 +144,17 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { return; } + if (userToInvite?.login === selectedMember) { + setAssignCardStepAndData({ + currentStep: CONST.COMPANY_CARD.STEP.INVITE_NEW_MEMBER, + data: { + email: selectedMember, + assigneeAccountID: userToInvite?.accountID ?? CONST.DEFAULT_NUMBER_ID, + }, + }); + return; + } + const personalDetail = getPersonalDetailByEmail(selectedMember); const memberName = personalDetail?.firstName ? personalDetail.firstName : personalDetail?.login; const data: Partial = { @@ -105,7 +186,7 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { Navigation.goBack(); }; - const shouldShowSearchInput = policy?.employeeList && Object.keys(policy.employeeList).length >= MINIMUM_MEMBER_TO_SHOW_SEARCH; + const shouldShowSearchInput = policy?.employeeList; const textInputLabel = shouldShowSearchInput ? translate('workspace.card.issueNewCard.findMember') : undefined; const membersDetails = useMemo(() => { @@ -144,7 +225,7 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { }, [isOffline, policy?.employeeList, selectedMember, formatPhoneNumber, localeCompare]); const sections = useMemo(() => { - if (!debouncedSearchTerm) { + if (!debouncedSearchValue) { return [ { data: membersDetails, @@ -153,8 +234,8 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { ]; } - const searchValue = getSearchValueForPhoneOrEmail(debouncedSearchTerm).toLowerCase(); - const filteredOptions = tokenizedSearch(membersDetails, searchValue, (option) => [option.text ?? '', option.alternateText ?? '']); + const searchValueForPhoneOrEmail = getSearchValueForPhoneOrEmail(debouncedSearchValue).toLowerCase(); + const filteredOptions = tokenizedSearch(membersDetails, searchValueForPhoneOrEmail, (option) => [option.text ?? '', option.alternateText ?? '']); return [ { @@ -162,14 +243,22 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { data: filteredOptions, shouldShow: true, }, + { + title: undefined, + data: userToInvite ? [userToInvite] : [], + shouldShow: !!userToInvite, + }, + ...(personalDetails + ? [ + { + title: undefined, + data: personalDetails, + shouldShow: !!personalDetails, + }, + ] + : []), ]; - }, [membersDetails, debouncedSearchTerm]); - - const headerMessage = useMemo(() => { - const searchValue = debouncedSearchTerm.trim().toLowerCase(); - - return getHeaderMessage(sections[0].data.length !== 0, false, searchValue); - }, [debouncedSearchTerm, sections]); + }, [debouncedSearchValue, membersDetails, userToInvite, personalDetails]); return ( {translate('workspace.companyCards.whoNeedsCardAssigned')} } + showLoadingPlaceholder={!areOptionsInitialized} /> ); diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx.rej b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx.rej new file mode 100644 index 0000000000000..0cd9222bed92a --- /dev/null +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx.rej @@ -0,0 +1,14 @@ +diff a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx (rejected hunks) +@@ -45,11 +115,11 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { + const [list] = useCardsList(policy?.id, feed); + const [cardFeeds] = useCardFeeds(policy?.id); + const filteredCardList = getFilteredCardList(list, cardFeeds?.settings?.oAuthAccountDetails?.[feed]); ++ const {userToInvite, searchValue, personalDetails, debouncedSearchValue, setSearchValue, areOptionsInitialized, headerMessage} = useOptions(); + + const isEditing = assignCard?.isEditing; + + const [selectedMember, setSelectedMember] = useState(assignCard?.data?.email ?? ''); +- const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const [shouldShowError, setShouldShowError] = useState(false); + + const selectMember = (assignee: ListItem) => { diff --git a/src/pages/workspace/companyCards/assignCard/InviteNewMemberStep.tsx b/src/pages/workspace/companyCards/assignCard/InviteNewMemberStep.tsx new file mode 100644 index 0000000000000..b11af64a1986c --- /dev/null +++ b/src/pages/workspace/companyCards/assignCard/InviteNewMemberStep.tsx @@ -0,0 +1,94 @@ +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 isEditing = assignCard?.isEditing; + const [list] = useCardsList(policy?.id, feed); + const [cardFeeds] = useCardFeeds(policy?.id); + const filteredCardList = getFilteredCardList(list, cardFeeds?.settings?.oAuthAccountDetails?.[feed]); + + 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 8e8a9874b14da..bc4b0dc4d3ac5 100644 --- a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx @@ -1,7 +1,9 @@ -import React, {useMemo} from 'react'; +import React, {useMemo, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import * as Expensicons from '@components/Icon/Expensicons'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; +import {useBetas} from '@components/OnyxListItemProvider'; +import {useOptionsList} from '@components/OptionListContextProvider'; import SelectionList from '@components/SelectionList'; import type {ListItem} from '@components/SelectionList/types'; import UserListItem from '@components/SelectionList/UserListItem'; @@ -11,19 +13,18 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getHeaderMessage, getSearchValueForPhoneOrEmail, sortAlphabetically} from '@libs/OptionsListUtils'; +import memoize from '@libs/memoize'; +import {filterAndOrderOptions, getHeaderMessage, getSearchValueForPhoneOrEmail, getValidOptions, sortAlphabetically} from '@libs/OptionsListUtils'; import {getPersonalDetailByEmail, getUserNameByEmail} from '@libs/PersonalDetailsUtils'; import {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; @@ -35,17 +36,85 @@ type AssigneeStepProps = { startStepIndex: number; }; +const memoizedGetValidOptions = memoize(getValidOptions, {maxSize: 5, monitoringName: 'AssigneeStep.getValidOptions'}); + +function useOptions() { + const betas = useBetas(); + const [isLoading, setIsLoading] = useState(true); + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + const {options: optionsList, areOptionsInitialized} = useOptionsList(); + const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true}); + const existingDelegates = useMemo( + () => + account?.delegatedAccess?.delegates?.reduce( + (prev, {email}) => { + // eslint-disable-next-line no-param-reassign + prev[email] = true; + return prev; + }, + {} as Record, + ), + [account?.delegatedAccess?.delegates], + ); + + const defaultOptions = useMemo(() => { + const {recentReports, personalDetails, userToInvite, currentUserOption} = memoizedGetValidOptions( + { + reports: optionsList.reports, + personalDetails: optionsList.personalDetails, + }, + { + betas, + excludeLogins: {...CONST.EXPENSIFY_EMAILS_OBJECT, ...existingDelegates}, + }, + ); + + const headerMessage = getHeaderMessage((recentReports?.length || 0) + (personalDetails?.length || 0) !== 0, !!userToInvite, ''); + + if (isLoading) { + // eslint-disable-next-line react-compiler/react-compiler + setIsLoading(false); + } + + return { + userToInvite, + recentReports, + personalDetails, + currentUserOption, + headerMessage, + }; + }, [optionsList.reports, optionsList.personalDetails, betas, existingDelegates, isLoading]); + + const options = useMemo(() => { + const filteredOptions = filterAndOrderOptions(defaultOptions, debouncedSearchValue.trim(), { + excludeLogins: {...CONST.EXPENSIFY_EMAILS_OBJECT, ...existingDelegates}, + maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, + }); + const headerMessage = getHeaderMessage( + (filteredOptions.recentReports?.length || 0) + (filteredOptions.personalDetails?.length || 0) !== 0, + !!filteredOptions.userToInvite, + debouncedSearchValue, + ); + + return { + ...filteredOptions, + headerMessage, + }; + }, [debouncedSearchValue, defaultOptions, existingDelegates]); + + return {...options, searchValue, debouncedSearchValue, setSearchValue, areOptionsInitialized}; +} + function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { const {translate, formatPhoneNumber, localeCompare} = useLocalize(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); const policyID = policy?.id; const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, {canBeMissing: true}); + const {userToInvite, searchValue, personalDetails, debouncedSearchValue, setSearchValue, areOptionsInitialized, headerMessage} = useOptions(); const isEditing = issueNewCard?.isEditing; - const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); - const submit = (assignee: ListItem) => { const data: Partial = { assigneeEmail: assignee?.login ?? '', @@ -56,6 +125,17 @@ function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { data.cardTitle = getCardDefaultName(getUserNameByEmail(assignee?.login ?? '', 'firstName')); } + if (userToInvite?.accountID === assignee?.accountID) { + data.assigneeAccountID = assignee?.accountID; + setIssueNewCardStepAndData({ + step: CONST.EXPENSIFY_CARD.STEP.INVITE_NEW_MEMBER, + data, + policyID, + }); + setDraftInviteAccountID(data.assigneeEmail, assignee?.accountID, policyID); + return; + } + setIssueNewCardStepAndData({ step: isEditing ? CONST.EXPENSIFY_CARD.STEP.CONFIRMATION : CONST.EXPENSIFY_CARD.STEP.CARD_TYPE, data, @@ -73,7 +153,7 @@ function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { clearIssueNewCardFlow(policyID); }; - const shouldShowSearchInput = policy?.employeeList && Object.keys(policy.employeeList).length >= MINIMUM_MEMBER_TO_SHOW_SEARCH; + const shouldShowSearchInput = policy?.employeeList; const textInputLabel = shouldShowSearchInput ? translate('workspace.card.issueNewCard.findMember') : undefined; const membersDetails = useMemo(() => { @@ -111,7 +191,7 @@ function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { }, [isOffline, policy?.employeeList, formatPhoneNumber, localeCompare]); const sections = useMemo(() => { - if (!debouncedSearchTerm) { + if (!debouncedSearchValue) { return [ { data: membersDetails, @@ -120,8 +200,8 @@ function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { ]; } - const searchValue = getSearchValueForPhoneOrEmail(debouncedSearchTerm).toLowerCase(); - const filteredOptions = tokenizedSearch(membersDetails, searchValue, (option) => [option.text ?? '', option.alternateText ?? '']); + const searchValueForOptions = getSearchValueForPhoneOrEmail(debouncedSearchValue).toLowerCase(); + const filteredOptions = tokenizedSearch(membersDetails, searchValueForOptions, (option) => [option.text ?? '', option.alternateText ?? '']); return [ { @@ -129,14 +209,22 @@ function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { data: filteredOptions, shouldShow: true, }, + { + title: undefined, + data: userToInvite ? [userToInvite] : [], + shouldShow: !!userToInvite, + }, + ...(personalDetails + ? [ + { + title: undefined, + data: personalDetails, + shouldShow: !!personalDetails, + }, + ] + : []), ]; - }, [membersDetails, debouncedSearchTerm]); - - const headerMessage = useMemo(() => { - const searchValue = debouncedSearchTerm.trim().toLowerCase(); - - return getHeaderMessage(sections[0].data.length !== 0, false, searchValue); - }, [debouncedSearchTerm, sections]); + }, [debouncedSearchValue, membersDetails, userToInvite, personalDetails]); return ( {translate('workspace.card.issueNewCard.whoNeedsCard')} ); diff --git a/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx b/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx new file mode 100644 index 0000000000000..679adf3ce71db --- /dev/null +++ b/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx @@ -0,0 +1,70 @@ +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 9aa83113150c1..a3c7e808bb486 100644 --- a/src/pages/workspace/expensifyCard/issueNew/IssueNewCardPage.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/IssueNewCardPage.tsx @@ -19,6 +19,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'; @@ -31,6 +32,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, @@ -108,6 +110,8 @@ function IssueNewCardPage({policy, route}: IssueNewCardPageProps) { startStepIndex={startStepIndex} /> ); + case CONST.EXPENSIFY_CARD.STEP.INVITE_NEW_MEMBER: + return ; default: return ( ; + policyID: string; + backTo: Routes | undefined; + currentUserPersonalDetails: OnyxEntry; + shouldShowBackButton?: boolean; + isInviteNewMemberStep?: boolean; + goToNextStep?: () => void; +}; + +function WorkspaceInviteMessageComponent({ + policy, + policyID, + backTo, + currentUserPersonalDetails, + shouldShowBackButton = 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 viewportOffsetTop = useViewportOffsetTop(); + const [welcomeNote, setWelcomeNote] = useState(); + + const {inputCallbackRef, inputRef} = useAutoFocusInput(); + + const [invitedEmailsToAccountIDsDraft, invitedEmailsToAccountIDsDraftResult] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID.toString()}`, { + canBeMissing: true, + }); + const [workspaceInviteMessageDraft, workspaceInviteMessageDraftResult] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT}${policyID.toString()}`, { + canBeMissing: true, + }); + const [workspaceInviteRoleDraft = CONST.POLICY.ROLE.USER] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_ROLE_DRAFT}${policyID.toString()}`, {canBeMissing: true}); + const isOnyxLoading = isLoadingOnyxValue(workspaceInviteMessageDraftResult, invitedEmailsToAccountIDsDraftResult, formDataResult); + + 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(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}`, 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(); + 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)} + /> + )} + + + {translate('common.privacy')} + + + } + > + {isInviteNewMemberStep && {translate('workspace.card.issueNewCard.inviteNewMember')}} + + + + + {translate('workspace.inviteMessage.inviteMessagePrompt')} + + + + { + Navigation.navigate(ROUTES.WORKSPACE_INVITE_MESSAGE_ROLE.getRoute(policyID, Navigation.getActiveRoute())); + }} + /> + + { + setWelcomeNote(text); + }} + ref={(element: AnimatedTextInputRef) => { + if (!element) { + return; + } + if (!inputRef.current) { + updateMultilineInputRange(element); + } + inputCallbackRef(element); + }} + shouldSaveDraft + /> + + + + + ); +} + +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 55308ad81fb96..6e3f6e003e998 100644 --- a/src/types/onyx/Card.ts +++ b/src/types/onyx/Card.ts @@ -212,6 +212,9 @@ type IssueNewCardData = { /** The email address of the cardholder */ assigneeEmail: string; + /** The account ID of the cardholder */ + assigneeAccountID?: number; + /** Card type */ cardType: ValueOf; From 07ca138fa1dada58ed728e83acaf1ac58440446e Mon Sep 17 00:00:00 2001 From: Rutika Pawar <183392827+twilight2294@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:01:00 +0000 Subject: [PATCH 02/23] fix patch failure --- .../companyCards/assignCard/AssigneeStep.tsx | 2 +- .../companyCards/assignCard/AssigneeStep.tsx.rej | 14 -------------- 2 files changed, 1 insertion(+), 15 deletions(-) delete mode 100644 src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx.rej diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index ffde9071aa4f1..e43f88d84e3d5 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -117,10 +117,10 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { const [cardFeeds] = useCardFeeds(policy?.id); const filteredCardList = getFilteredCardList(list, cardFeeds?.settings?.oAuthAccountDetails?.[feed], workspaceCardFeeds); + const {userToInvite, searchValue, personalDetails, debouncedSearchValue, setSearchValue, areOptionsInitialized, headerMessage} = useOptions(); const isEditing = assignCard?.isEditing; const [selectedMember, setSelectedMember] = useState(assignCard?.data?.email ?? ''); - const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const [shouldShowError, setShouldShowError] = useState(false); const selectMember = (assignee: ListItem) => { diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx.rej b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx.rej deleted file mode 100644 index 0cd9222bed92a..0000000000000 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx.rej +++ /dev/null @@ -1,14 +0,0 @@ -diff a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx (rejected hunks) -@@ -45,11 +115,11 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { - const [list] = useCardsList(policy?.id, feed); - const [cardFeeds] = useCardFeeds(policy?.id); - const filteredCardList = getFilteredCardList(list, cardFeeds?.settings?.oAuthAccountDetails?.[feed]); -+ const {userToInvite, searchValue, personalDetails, debouncedSearchValue, setSearchValue, areOptionsInitialized, headerMessage} = useOptions(); - - const isEditing = assignCard?.isEditing; - - const [selectedMember, setSelectedMember] = useState(assignCard?.data?.email ?? ''); -- const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); - const [shouldShowError, setShouldShowError] = useState(false); - - const selectMember = (assignee: ListItem) => { From 5009d0a9962d2cce73cafb508256f73125d23872 Mon Sep 17 00:00:00 2001 From: Rutika Pawar <183392827+twilight2294@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:07:44 +0000 Subject: [PATCH 03/23] fix lint --- src/ROUTES.ts | 2 +- src/libs/API/parameters/AddMembersToWorkspaceParams.ts | 2 +- src/libs/ReportUtils.ts | 4 ++-- src/libs/actions/Policy/Member.ts | 8 ++++---- src/libs/actions/Policy/Policy.ts | 4 ++-- .../companyCards/assignCard/InviteNewMemberStep.tsx | 5 +++-- .../expensifyCard/issueNew/InviteNewMemberStep.tsx | 2 +- .../members/WorkspaceInviteMessageComponent.tsx | 10 +++++----- 8 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 43797bd8322f6..5ca6004567ef5 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1007,7 +1007,7 @@ const ROUTES = { }, WORKSPACE_INVITE_MESSAGE_ROLE: { route: 'workspaces/:policyID/invite-message/role', - getRoute: (policyID: string, backTo?: string) => `${getUrlWithBackToParam(`workspaces/${policyID}/invite-message/role`, backTo)}` as const, + getRoute: (policyID: string | undefined, backTo?: string) => `${getUrlWithBackToParam(`workspaces/${policyID}/invite-message/role`, backTo)}` as const, }, WORKSPACE_OVERVIEW: { route: 'workspaces/:policyID/overview', diff --git a/src/libs/API/parameters/AddMembersToWorkspaceParams.ts b/src/libs/API/parameters/AddMembersToWorkspaceParams.ts index abfed55e2df3a..d8927ad12a03b 100644 --- a/src/libs/API/parameters/AddMembersToWorkspaceParams.ts +++ b/src/libs/API/parameters/AddMembersToWorkspaceParams.ts @@ -1,7 +1,7 @@ type AddMembersToWorkspaceParams = { employees: string; welcomeNote: string; - policyID: string; + policyID: string | undefined; reportCreationData?: string; announceChatReportID?: string; announceCreatedReportActionID?: string; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 73be6c9783866..c443edc19d915 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -7575,7 +7575,7 @@ function buildOptimisticResolvedDuplicatesReportAction(): OptimisticDismissedVio }; } -function buildOptimisticAnnounceChat(policyID: string, accountIDs: number[]): OptimisticAnnounceChat { +function buildOptimisticAnnounceChat(policyID: string | undefined, accountIDs: number[]): OptimisticAnnounceChat { const announceReport = getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID); // This will be fixed as part of https://github.com/Expensify/Expensify/issues/507850 // eslint-disable-next-line deprecation/deprecation @@ -9441,7 +9441,7 @@ function shouldUseFullTitleToDisplay(report: OnyxEntry): 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/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index ea1f2108696ca..d0c1f66aed38a 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -140,7 +140,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); @@ -876,7 +876,7 @@ function clearWorkspaceOwnerChangeFlow(policyID: string) { function buildAddMembersToWorkspaceOnyxData( invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, - policyID: string, + policyID: string | undefined, policyMemberAccountIDs: number[], role: string, formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], @@ -978,7 +978,7 @@ function buildAddMembersToWorkspaceOnyxData( function addMembersToWorkspace( invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, welcomeNote: string, - policyID: string, + policyID: string | undefined, policyMemberAccountIDs: number[], role: string, formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], @@ -1181,7 +1181,7 @@ function setWorkspaceInviteRoleDraft(policyID: string, role: ValueOf { if (isEditing) { @@ -78,7 +79,7 @@ function InviteNewMemberStep({policy, route, currentUserPersonalDetails, feed}: > ; - policyID: string; + policyID: string | undefined; backTo: Routes | undefined; currentUserPersonalDetails: OnyxEntry; shouldShowBackButton?: boolean; @@ -66,13 +66,13 @@ function WorkspaceInviteMessageComponent({ const {inputCallbackRef, inputRef} = useAutoFocusInput(); - const [invitedEmailsToAccountIDsDraft, invitedEmailsToAccountIDsDraftResult] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID.toString()}`, { + const [invitedEmailsToAccountIDsDraft, invitedEmailsToAccountIDsDraftResult] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID}`, { canBeMissing: true, }); - const [workspaceInviteMessageDraft, workspaceInviteMessageDraftResult] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT}${policyID.toString()}`, { + 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.toString()}`, {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 welcomeNoteSubject = useMemo( @@ -110,7 +110,7 @@ function WorkspaceInviteMessageComponent({ 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}`, policyID, policyMemberAccountIDs, workspaceInviteRoleDraft, formatPhoneNumber); + addMembersToWorkspace(invitedEmailsToAccountIDsDraft ?? {}, `${welcomeNoteSubject}\n\n${welcomeNote}`, policyID , policyMemberAccountIDs, workspaceInviteRoleDraft, formatPhoneNumber); setWorkspaceInviteMessageDraft(policyID, welcomeNote ?? null); clearDraftValues(ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM); if (goToNextStep) { From c714574f867a486cbe34afdb50533486d3726ba3 Mon Sep 17 00:00:00 2001 From: Rutika Pawar <183392827+twilight2294@users.noreply.github.com> Date: Wed, 13 Aug 2025 22:43:03 +0530 Subject: [PATCH 04/23] fix prettier --- src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx index 1b9a3adeb603e..abc31bbf7f90a 100644 --- a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx +++ b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx @@ -110,7 +110,7 @@ function WorkspaceInviteMessageComponent({ 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}`, policyID , policyMemberAccountIDs, workspaceInviteRoleDraft, formatPhoneNumber); + addMembersToWorkspace(invitedEmailsToAccountIDsDraft ?? {}, `${welcomeNoteSubject}\n\n${welcomeNote}`, policyID, policyMemberAccountIDs, workspaceInviteRoleDraft, formatPhoneNumber); setWorkspaceInviteMessageDraft(policyID, welcomeNote ?? null); clearDraftValues(ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM); if (goToNextStep) { From c85e35e895279fa981d5e2f28d48ca31c6c84fc1 Mon Sep 17 00:00:00 2001 From: Rutika Pawar <183392827+twilight2294@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:18:44 +0000 Subject: [PATCH 05/23] fix translations --- src/languages/pt-BR.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 50ae38dc8b302..40928b05e15c3 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -4926,6 +4926,7 @@ const translations = { issueCard: 'Emitir cartão', issueNewCard: { whoNeedsCard: 'Quem precisa de um cartão?', + inviteNewMember: 'Convide um novo membro', findMember: 'Encontrar membro', chooseCardType: 'Escolha um tipo de cartão', physicalCard: 'Cartão físico', From fc2260162d41fddb20b0597d896096dacc5d283f Mon Sep 17 00:00:00 2001 From: Rutika Pawar <183392827+twilight2294@users.noreply.github.com> Date: Thu, 14 Aug 2025 06:07:24 +0000 Subject: [PATCH 06/23] apply fixes --- .../companyCards/assignCard/AssigneeStep.tsx | 27 ++++++++++++++++--- .../assignCard/InviteNewMemberStep.tsx | 2 ++ .../issueNew/InviteNewMemberStep.tsx | 1 + .../WorkspaceInviteMessageComponent.tsx | 3 +++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index e43f88d84e3d5..7796baedc35fd 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -17,6 +17,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import {setDraftInviteAccountID} from '@libs/actions/Card'; import {getDefaultCardName, getFilteredCardList, hasOnlyOneCardToAssign} from '@libs/CardUtils'; import memoize from '@libs/memoize'; import {filterAndOrderOptions, getHeaderMessage, getSearchValueForPhoneOrEmail, getValidOptions, sortAlphabetically} from '@libs/OptionsListUtils'; @@ -139,7 +140,7 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { return; } - if (!selectedMember) { + if (!selectedMember || (!searchValue && selectedMember !== policy?.employeeList?.[selectedMember]?.email)) { setShouldShowError(true); return; } @@ -149,9 +150,10 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { currentStep: CONST.COMPANY_CARD.STEP.INVITE_NEW_MEMBER, data: { email: selectedMember, - assigneeAccountID: userToInvite?.accountID ?? CONST.DEFAULT_NUMBER_ID, + assigneeAccountID: userToInvite?.accountID, }, }); + setDraftInviteAccountID(selectedMember, userToInvite?.accountID, policy?.id); return; } @@ -224,6 +226,23 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { return membersList; }, [isOffline, policy?.employeeList, selectedMember, formatPhoneNumber, localeCompare]); + const membersDetailsWithInviteNewMember = useMemo(() => { + if (!userToInvite) { + return {}; + } + + const newMember: ListItem = { + keyForList: userToInvite?.login, + text: userToInvite?.login, + alternateText: userToInvite?.login, + login: userToInvite?.login, + isSelected: selectedMember === userToInvite?.login, + accountID: userToInvite?.accountID, + }; + + return newMember; + }, [selectedMember, userToInvite]); + const sections = useMemo(() => { if (!debouncedSearchValue) { return [ @@ -245,7 +264,7 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { }, { title: undefined, - data: userToInvite ? [userToInvite] : [], + data: userToInvite ? [membersDetailsWithInviteNewMember] : [], shouldShow: !!userToInvite, }, ...(personalDetails @@ -258,7 +277,7 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { ] : []), ]; - }, [debouncedSearchValue, membersDetails, userToInvite, personalDetails]); + }, [debouncedSearchValue, membersDetails, userToInvite, membersDetailsWithInviteNewMember, personalDetails]); return ( ); diff --git a/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx b/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx index b4ead3eec244b..710b6fcafb556 100644 --- a/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx @@ -60,6 +60,7 @@ function InviteNewMemberStep({policy, route, currentUserPersonalDetails}: Invite shouldShowBackButton={false} isInviteNewMemberStep goToNextStep={goToNextStep} + shouldShowTooltip={false} /> ); diff --git a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx index abc31bbf7f90a..225aa311d53c6 100644 --- a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx +++ b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx @@ -43,6 +43,7 @@ type WorkspaceInviteMessageComponentProps = { policyID: string | undefined; backTo: Routes | undefined; currentUserPersonalDetails: OnyxEntry; + shouldShowTooltip?: boolean; shouldShowBackButton?: boolean; isInviteNewMemberStep?: boolean; goToNextStep?: () => void; @@ -53,6 +54,7 @@ function WorkspaceInviteMessageComponent({ policyID, backTo, currentUserPersonalDetails, + shouldShowTooltip = true, shouldShowBackButton = true, isInviteNewMemberStep = false, goToNextStep, @@ -209,6 +211,7 @@ function WorkspaceInviteMessageComponent({ displayInRows: true, }} secondaryAvatarContainerStyle={styles.secondAvatarInline} + shouldShowTooltip={shouldShowTooltip} /> From 628089d7a96f4edfb75ae373704646eee6d42037 Mon Sep 17 00:00:00 2001 From: Rutika Pawar <183392827+twilight2294@users.noreply.github.com> Date: Thu, 28 Aug 2025 16:25:10 +0530 Subject: [PATCH 07/23] fix typefailure --- .../workspace/members/WorkspaceInviteMessageComponent.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx index 225aa311d53c6..f6fedf696bb9b 100644 --- a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx +++ b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx @@ -212,6 +212,7 @@ function WorkspaceInviteMessageComponent({ }} secondaryAvatarContainerStyle={styles.secondAvatarInline} shouldShowTooltip={shouldShowTooltip} + invitedEmailsToAccountIDs={invitedEmailsToAccountIDsDraft} /> @@ -243,7 +244,7 @@ function WorkspaceInviteMessageComponent({ onChangeText={(text: string) => { setWelcomeNote(text); }} - ref={(element: AnimatedTextInputRef) => { + ref={(element: AnimatedTextInputRef | null) => { if (!element) { return; } From 251280f6bef50224f36ae0b31382341a1e5474da Mon Sep 17 00:00:00 2001 From: Rutika Pawar <183392827+twilight2294@users.noreply.github.com> Date: Mon, 8 Sep 2025 19:24:51 +0000 Subject: [PATCH 08/23] part 1: apply reviewers suggestions --- .../members/WorkspaceInviteMessageComponent.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx index f6fedf696bb9b..626c22d4b1cbd 100644 --- a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx +++ b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx @@ -83,14 +83,7 @@ function WorkspaceInviteMessageComponent({ ); 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') - ); + return formData?.[INPUT_IDS.WELCOME_MESSAGE] ?? workspaceInviteMessageDraft ?? translate('workspace.common.welcomeNote'); }, [workspaceInviteMessageDraft, translate, formData]); useEffect(() => { @@ -105,6 +98,10 @@ function WorkspaceInviteMessageComponent({ 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]); @@ -112,6 +109,7 @@ function WorkspaceInviteMessageComponent({ 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); @@ -225,7 +223,7 @@ function WorkspaceInviteMessageComponent({ description={translate('common.role')} shouldShowRightIcon onPress={() => { - Navigation.navigate(ROUTES.WORKSPACE_INVITE_MESSAGE_ROLE.getRoute(policyID, Navigation.getActiveRoute())); + Navigation.navigate(ROUTES.WORKSPACE_INVITE_MESSAGE_ROLE.getRoute(policyID ?? '', Navigation.getActiveRoute())); }} /> From 90d612ca4f62e8e881599827f8ff1ea3adca6137 Mon Sep 17 00:00:00 2001 From: Rutika Pawar <183392827+twilight2294@users.noreply.github.com> Date: Mon, 8 Sep 2025 19:40:32 +0000 Subject: [PATCH 09/23] apply reviewers comments --- src/ROUTES.ts | 10 ++++++-- .../companyCards/assignCard/AssigneeStep.tsx | 22 +++++------------ .../expensifyCard/issueNew/AssigneeStep.tsx | 24 ++++++------------- .../WorkspaceInviteMessageComponent.tsx | 2 +- 4 files changed, 22 insertions(+), 36 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index b8ebefadc40ef..ebf5544cc42a9 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1191,8 +1191,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/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index ad5824ffd9ed3..211c152b59b8b 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -47,18 +47,8 @@ function useOptions() { const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const {options: optionsList, areOptionsInitialized} = useOptionsList(); const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true}); - const existingDelegates = useMemo( - () => - account?.delegatedAccess?.delegates?.reduce( - (prev, {email}) => { - // eslint-disable-next-line no-param-reassign - prev[email] = true; - return prev; - }, - {} as Record, - ), - [account?.delegatedAccess?.delegates], - ); + const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); + const existingDelegates = useMemo(() => Object.fromEntries((account?.delegatedAccess?.delegates ?? []).map(({email}) => [email, true])), [account?.delegatedAccess?.delegates]); const defaultOptions = useMemo(() => { const {recentReports, personalDetails, userToInvite, currentUserOption} = memoizedGetValidOptions( @@ -89,7 +79,7 @@ function useOptions() { }, [optionsList.reports, optionsList.personalDetails, betas, existingDelegates, isLoading]); const options = useMemo(() => { - const filteredOptions = filterAndOrderOptions(defaultOptions, debouncedSearchValue.trim(), { + const filteredOptions = filterAndOrderOptions(defaultOptions, debouncedSearchValue.trim(), countryCode, { excludeLogins: {...CONST.EXPENSIFY_EMAILS_OBJECT, ...existingDelegates}, maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, }); @@ -103,7 +93,7 @@ function useOptions() { ...filteredOptions, headerMessage, }; - }, [debouncedSearchValue, defaultOptions, existingDelegates]); + }, [debouncedSearchValue, defaultOptions, existingDelegates, countryCode]); return {...options, searchValue, debouncedSearchValue, setSearchValue, areOptionsInitialized}; } @@ -254,7 +244,7 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { ]; } - const searchValueForPhoneOrEmail = getSearchValueForPhoneOrEmail(debouncedSearchValue).toLowerCase(); + const searchValueForPhoneOrEmail = getSearchValueForPhoneOrEmail(debouncedSearchValue, countryCode).toLowerCase(); const filteredOptions = tokenizedSearch(membersDetails, searchValueForPhoneOrEmail, (option) => [option.text ?? '', option.alternateText ?? '']); return [ @@ -278,7 +268,7 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { ] : []), ]; - }, [debouncedSearchValue, membersDetails, userToInvite, membersDetailsWithInviteNewMember, personalDetails]); + }, [debouncedSearchValue, membersDetails, userToInvite, membersDetailsWithInviteNewMember, personalDetails, countryCode]); return ( - account?.delegatedAccess?.delegates?.reduce( - (prev, {email}) => { - // eslint-disable-next-line no-param-reassign - prev[email] = true; - return prev; - }, - {} as Record, - ), - [account?.delegatedAccess?.delegates], - ); + const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); + const existingDelegates = useMemo(() => Object.fromEntries((account?.delegatedAccess?.delegates ?? []).map(({email}) => [email, true])), [account?.delegatedAccess?.delegates]); const defaultOptions = useMemo(() => { const {recentReports, personalDetails, userToInvite, currentUserOption} = memoizedGetValidOptions( @@ -87,7 +77,7 @@ function useOptions() { }, [optionsList.reports, optionsList.personalDetails, betas, existingDelegates, isLoading]); const options = useMemo(() => { - const filteredOptions = filterAndOrderOptions(defaultOptions, debouncedSearchValue.trim(), { + const filteredOptions = filterAndOrderOptions(defaultOptions, debouncedSearchValue.trim(), countryCode, { excludeLogins: {...CONST.EXPENSIFY_EMAILS_OBJECT, ...existingDelegates}, maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, }); @@ -101,7 +91,7 @@ function useOptions() { ...filteredOptions, headerMessage, }; - }, [debouncedSearchValue, defaultOptions, existingDelegates]); + }, [debouncedSearchValue, defaultOptions, existingDelegates, countryCode]); return {...options, searchValue, debouncedSearchValue, setSearchValue, areOptionsInitialized}; } @@ -112,8 +102,8 @@ 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 {userToInvite, searchValue, personalDetails, debouncedSearchValue, setSearchValue, areOptionsInitialized, headerMessage} = useOptions(); const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); + const {userToInvite, searchValue, personalDetails, debouncedSearchValue, setSearchValue, areOptionsInitialized, headerMessage} = useOptions(); const currency = useCurrencyForExpensifyCard({policyID}); const isEditing = issueNewCard?.isEditing; @@ -204,7 +194,7 @@ function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { ]; } - const searchValueForOptions = getSearchValueForPhoneOrEmail(debouncedSearchValue).toLowerCase(); + const searchValueForOptions = getSearchValueForPhoneOrEmail(debouncedSearchValue, countryCode).toLowerCase(); const filteredOptions = tokenizedSearch(membersDetails, searchValueForOptions, (option) => [option.text ?? '', option.alternateText ?? '']); return [ @@ -228,7 +218,7 @@ function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { ] : []), ]; - }, [debouncedSearchValue, membersDetails, userToInvite, personalDetails]); + }, [debouncedSearchValue, membersDetails, userToInvite, personalDetails, countryCode]); return ( { - Navigation.navigate(ROUTES.WORKSPACE_INVITE_MESSAGE_ROLE.getRoute(policyID ?? '', Navigation.getActiveRoute())); + Navigation.navigate(ROUTES.WORKSPACE_INVITE_MESSAGE_ROLE.getRoute(policyID, Navigation.getActiveRoute())); }} /> From efca7cd8a18b581f35362938a5cec6aacbdf3aa9 Mon Sep 17 00:00:00 2001 From: Rutika Pawar <183392827+twilight2294@users.noreply.github.com> Date: Mon, 8 Sep 2025 20:06:45 +0000 Subject: [PATCH 10/23] extract useOptions --- src/libs/UseOptionsUtils.ts | 70 +++++++++++++++++++ .../companyCards/assignCard/AssigneeStep.tsx | 66 +---------------- .../expensifyCard/issueNew/AssigneeStep.tsx | 68 +----------------- 3 files changed, 75 insertions(+), 129 deletions(-) create mode 100644 src/libs/UseOptionsUtils.ts diff --git a/src/libs/UseOptionsUtils.ts b/src/libs/UseOptionsUtils.ts new file mode 100644 index 0000000000000..04610d67d99f4 --- /dev/null +++ b/src/libs/UseOptionsUtils.ts @@ -0,0 +1,70 @@ +import {useMemo, useState} from 'react'; +import {useBetas} from '@components/OnyxListItemProvider'; +import {useOptionsList} from '@components/OptionListContextProvider'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useOnyx from '@hooks/useOnyx'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import memoize from './memoize'; +import {filterAndOrderOptions, getHeaderMessage, getValidOptions} from './OptionsListUtils'; + +const memoizedGetValidOptions = memoize(getValidOptions, {maxSize: 5, monitoringName: 'AssigneeStep.getValidOptions'}); + +function useOptions() { + const betas = useBetas(); + const [isLoading, setIsLoading] = useState(true); + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + const {options: optionsList, areOptionsInitialized} = useOptionsList(); + const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true}); + const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); + const existingDelegates = useMemo(() => Object.fromEntries((account?.delegatedAccess?.delegates ?? []).map(({email}) => [email, true])), [account?.delegatedAccess?.delegates]); + + const defaultOptions = useMemo(() => { + const {recentReports, personalDetails, userToInvite, currentUserOption} = memoizedGetValidOptions( + { + reports: optionsList.reports, + personalDetails: optionsList.personalDetails, + }, + { + betas, + excludeLogins: {...CONST.EXPENSIFY_EMAILS_OBJECT, ...existingDelegates}, + }, + ); + + const headerMessage = getHeaderMessage((recentReports?.length || 0) + (personalDetails?.length || 0) !== 0, !!userToInvite, ''); + + if (isLoading) { + // eslint-disable-next-line react-compiler/react-compiler + setIsLoading(false); + } + + return { + userToInvite, + recentReports, + personalDetails, + currentUserOption, + headerMessage, + }; + }, [optionsList.reports, optionsList.personalDetails, betas, existingDelegates, isLoading]); + + const options = useMemo(() => { + const filteredOptions = filterAndOrderOptions(defaultOptions, debouncedSearchValue.trim(), countryCode, { + excludeLogins: {...CONST.EXPENSIFY_EMAILS_OBJECT, ...existingDelegates}, + maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, + }); + const headerMessage = getHeaderMessage( + (filteredOptions.recentReports?.length || 0) + (filteredOptions.personalDetails?.length || 0) !== 0, + !!filteredOptions.userToInvite, + debouncedSearchValue, + ); + + return { + ...filteredOptions, + headerMessage, + }; + }, [debouncedSearchValue, defaultOptions, existingDelegates, countryCode]); + + return {...options, searchValue, debouncedSearchValue, setSearchValue, areOptionsInitialized}; +} + +export default useOptions; diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index 211c152b59b8b..400de659641e9 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -4,26 +4,23 @@ import type {OnyxEntry} from 'react-native-onyx'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import * as Expensicons from '@components/Icon/Expensicons'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; -import {useBetas} from '@components/OnyxListItemProvider'; -import {useOptionsList} from '@components/OptionListContextProvider'; import SelectionList from '@components/SelectionList'; import type {ListItem} from '@components/SelectionList/types'; import UserListItem from '@components/SelectionList/UserListItem'; import Text from '@components/Text'; import useCardFeeds from '@hooks/useCardFeeds'; import useCardsList from '@hooks/useCardsList'; -import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {setDraftInviteAccountID} from '@libs/actions/Card'; import {getDefaultCardName, getFilteredCardList, hasOnlyOneCardToAssign} from '@libs/CardUtils'; -import memoize from '@libs/memoize'; -import {filterAndOrderOptions, getHeaderMessage, getSearchValueForPhoneOrEmail, getValidOptions, sortAlphabetically} from '@libs/OptionsListUtils'; +import {getSearchValueForPhoneOrEmail, sortAlphabetically} from '@libs/OptionsListUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {isDeletedPolicyEmployee} from '@libs/PolicyUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; +import useOptions from '@libs/UseOptionsUtils'; import Navigation from '@navigation/Navigation'; import {setAssignCardStepAndData} from '@userActions/CompanyCards'; import CONST from '@src/CONST'; @@ -39,65 +36,6 @@ type AssigneeStepProps = { feed: OnyxTypes.CompanyCardFeed; }; -const memoizedGetValidOptions = memoize(getValidOptions, {maxSize: 5, monitoringName: 'AssigneeStep.getValidOptions'}); - -function useOptions() { - const betas = useBetas(); - const [isLoading, setIsLoading] = useState(true); - const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); - const {options: optionsList, areOptionsInitialized} = useOptionsList(); - const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true}); - const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); - const existingDelegates = useMemo(() => Object.fromEntries((account?.delegatedAccess?.delegates ?? []).map(({email}) => [email, true])), [account?.delegatedAccess?.delegates]); - - const defaultOptions = useMemo(() => { - const {recentReports, personalDetails, userToInvite, currentUserOption} = memoizedGetValidOptions( - { - reports: optionsList.reports, - personalDetails: optionsList.personalDetails, - }, - { - betas, - excludeLogins: {...CONST.EXPENSIFY_EMAILS_OBJECT, ...existingDelegates}, - }, - ); - - const headerMessage = getHeaderMessage((recentReports?.length || 0) + (personalDetails?.length || 0) !== 0, !!userToInvite, ''); - - if (isLoading) { - // eslint-disable-next-line react-compiler/react-compiler - setIsLoading(false); - } - - return { - userToInvite, - recentReports, - personalDetails, - currentUserOption, - headerMessage, - }; - }, [optionsList.reports, optionsList.personalDetails, betas, existingDelegates, isLoading]); - - const options = useMemo(() => { - const filteredOptions = filterAndOrderOptions(defaultOptions, debouncedSearchValue.trim(), countryCode, { - excludeLogins: {...CONST.EXPENSIFY_EMAILS_OBJECT, ...existingDelegates}, - maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, - }); - const headerMessage = getHeaderMessage( - (filteredOptions.recentReports?.length || 0) + (filteredOptions.personalDetails?.length || 0) !== 0, - !!filteredOptions.userToInvite, - debouncedSearchValue, - ); - - return { - ...filteredOptions, - headerMessage, - }; - }, [debouncedSearchValue, defaultOptions, existingDelegates, countryCode]); - - return {...options, searchValue, debouncedSearchValue, setSearchValue, areOptionsInitialized}; -} - function AssigneeStep({policy, feed}: AssigneeStepProps) { const {translate, formatPhoneNumber, localeCompare} = useLocalize(); const styles = useThemeStyles(); diff --git a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx index 5cb42a8016f47..945058aa1102d 100644 --- a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx @@ -1,24 +1,21 @@ -import React, {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'; -import {useBetas} from '@components/OnyxListItemProvider'; -import {useOptionsList} from '@components/OptionListContextProvider'; import SelectionList from '@components/SelectionList'; import type {ListItem} from '@components/SelectionList/types'; import UserListItem from '@components/SelectionList/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 useThemeStyles from '@hooks/useThemeStyles'; -import memoize from '@libs/memoize'; -import {filterAndOrderOptions, getHeaderMessage, getSearchValueForPhoneOrEmail, getValidOptions, sortAlphabetically} from '@libs/OptionsListUtils'; +import {getSearchValueForPhoneOrEmail, sortAlphabetically} from '@libs/OptionsListUtils'; import {getPersonalDetailByEmail, getUserNameByEmail} from '@libs/PersonalDetailsUtils'; import {isDeletedPolicyEmployee} from '@libs/PolicyUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; +import useOptions from '@libs/UseOptionsUtils'; import Navigation from '@navigation/Navigation'; import {clearIssueNewCardFlow, getCardDefaultName, setDraftInviteAccountID, setIssueNewCardStepAndData} from '@userActions/Card'; import CONST from '@src/CONST'; @@ -37,65 +34,6 @@ type AssigneeStepProps = { startStepIndex: number; }; -const memoizedGetValidOptions = memoize(getValidOptions, {maxSize: 5, monitoringName: 'AssigneeStep.getValidOptions'}); - -function useOptions() { - const betas = useBetas(); - const [isLoading, setIsLoading] = useState(true); - const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); - const {options: optionsList, areOptionsInitialized} = useOptionsList(); - const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true}); - const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); - const existingDelegates = useMemo(() => Object.fromEntries((account?.delegatedAccess?.delegates ?? []).map(({email}) => [email, true])), [account?.delegatedAccess?.delegates]); - - const defaultOptions = useMemo(() => { - const {recentReports, personalDetails, userToInvite, currentUserOption} = memoizedGetValidOptions( - { - reports: optionsList.reports, - personalDetails: optionsList.personalDetails, - }, - { - betas, - excludeLogins: {...CONST.EXPENSIFY_EMAILS_OBJECT, ...existingDelegates}, - }, - ); - - const headerMessage = getHeaderMessage((recentReports?.length || 0) + (personalDetails?.length || 0) !== 0, !!userToInvite, ''); - - if (isLoading) { - // eslint-disable-next-line react-compiler/react-compiler - setIsLoading(false); - } - - return { - userToInvite, - recentReports, - personalDetails, - currentUserOption, - headerMessage, - }; - }, [optionsList.reports, optionsList.personalDetails, betas, existingDelegates, isLoading]); - - const options = useMemo(() => { - const filteredOptions = filterAndOrderOptions(defaultOptions, debouncedSearchValue.trim(), countryCode, { - excludeLogins: {...CONST.EXPENSIFY_EMAILS_OBJECT, ...existingDelegates}, - maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, - }); - const headerMessage = getHeaderMessage( - (filteredOptions.recentReports?.length || 0) + (filteredOptions.personalDetails?.length || 0) !== 0, - !!filteredOptions.userToInvite, - debouncedSearchValue, - ); - - return { - ...filteredOptions, - headerMessage, - }; - }, [debouncedSearchValue, defaultOptions, existingDelegates, countryCode]); - - return {...options, searchValue, debouncedSearchValue, setSearchValue, areOptionsInitialized}; -} - function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { const {translate, formatPhoneNumber, localeCompare} = useLocalize(); const styles = useThemeStyles(); From 7ccd78186a360e33ff5b7b4f91d14607cf3d1e9e Mon Sep 17 00:00:00 2001 From: Rutika Pawar <183392827+twilight2294@users.noreply.github.com> Date: Mon, 8 Sep 2025 20:16:40 +0000 Subject: [PATCH 11/23] move isLoading in useEffect --- src/libs/UseOptionsUtils.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/libs/UseOptionsUtils.ts b/src/libs/UseOptionsUtils.ts index 04610d67d99f4..234d0c40fbb4a 100644 --- a/src/libs/UseOptionsUtils.ts +++ b/src/libs/UseOptionsUtils.ts @@ -1,4 +1,4 @@ -import {useMemo, useState} from 'react'; +import {useEffect, useMemo, useState} from 'react'; import {useBetas} from '@components/OnyxListItemProvider'; import {useOptionsList} from '@components/OptionListContextProvider'; import useDebouncedState from '@hooks/useDebouncedState'; @@ -19,6 +19,14 @@ function useOptions() { const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); const existingDelegates = useMemo(() => Object.fromEntries((account?.delegatedAccess?.delegates ?? []).map(({email}) => [email, true])), [account?.delegatedAccess?.delegates]); + useEffect(() => { + if (!isLoading || !optionsList.reports || !optionsList.personalDetails) { + return; + } + + setIsLoading(false); + }, [isLoading, optionsList.reports, optionsList.personalDetails]); + const defaultOptions = useMemo(() => { const {recentReports, personalDetails, userToInvite, currentUserOption} = memoizedGetValidOptions( { @@ -33,11 +41,6 @@ function useOptions() { const headerMessage = getHeaderMessage((recentReports?.length || 0) + (personalDetails?.length || 0) !== 0, !!userToInvite, ''); - if (isLoading) { - // eslint-disable-next-line react-compiler/react-compiler - setIsLoading(false); - } - return { userToInvite, recentReports, @@ -45,7 +48,7 @@ function useOptions() { currentUserOption, headerMessage, }; - }, [optionsList.reports, optionsList.personalDetails, betas, existingDelegates, isLoading]); + }, [optionsList.reports, optionsList.personalDetails, betas, existingDelegates]); const options = useMemo(() => { const filteredOptions = filterAndOrderOptions(defaultOptions, debouncedSearchValue.trim(), countryCode, { From c7f7e6ad7cde94ef32b43cf393e94179957b0c4e Mon Sep 17 00:00:00 2001 From: Rutika Pawar <183392827+twilight2294@users.noreply.github.com> Date: Mon, 29 Sep 2025 05:57:41 +0000 Subject: [PATCH 12/23] Fix wrong input label --- src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx | 5 +---- src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx | 5 +---- .../workspace/members/WorkspaceInviteMessageComponent.tsx | 2 +- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index 537dd3026e46a..cf356dc3c8171 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -117,9 +117,6 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { Navigation.goBack(); }; - const shouldShowSearchInput = policy?.employeeList; - const textInputLabel = shouldShowSearchInput ? translate('workspace.card.issueNewCard.findMember') : undefined; - const membersDetails = useMemo(() => { let membersList: ListItem[] = []; if (!policy?.employeeList) { @@ -219,7 +216,7 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { > {translate('workspace.companyCards.whoNeedsCardAssigned')} { let membersList: ListItem[] = []; if (!policy?.employeeList) { @@ -171,7 +168,7 @@ function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { > {translate('workspace.card.issueNewCard.whoNeedsCard')} Navigation.dismissModal()); + Navigation.setNavigationActionToMicrotaskQueue(Navigation.dismissModal); return; } From dbbe9e851bd00856a55c88aeb5e09a021ca6e514 Mon Sep 17 00:00:00 2001 From: Rutika Pawar <183392827+twilight2294@users.noreply.github.com> Date: Mon, 29 Sep 2025 07:27:37 +0000 Subject: [PATCH 13/23] fix selection list in company cards page --- .../companyCards/assignCard/AssigneeStep.tsx | 28 ++----------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index cf356dc3c8171..1e6e47ce287d3 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -1,7 +1,6 @@ import React, {useMemo, useState} from 'react'; import {Keyboard} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import * as Expensicons from '@components/Icon/Expensicons'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import SelectionList from '@components/SelectionListWithSections'; @@ -51,16 +50,11 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { const isEditing = assignCard?.isEditing; const [selectedMember, setSelectedMember] = useState(assignCard?.data?.email ?? ''); - const [shouldShowError, setShouldShowError] = useState(false); - const selectMember = (assignee: ListItem) => { + const submit = (assignee: ListItem) => { + let nextStep: AssignCardStep = CONST.COMPANY_CARD.STEP.CARD; Keyboard.dismiss(); 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, @@ -69,11 +63,6 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { return; } - if (!selectedMember || (!searchValue && selectedMember !== policy?.employeeList?.[selectedMember]?.email)) { - setShouldShowError(true); - return; - } - if (userToInvite?.login === selectedMember) { setAssignCardStepAndData({ currentStep: CONST.COMPANY_CARD.STEP.INVITE_NEW_MEMBER, @@ -222,20 +211,9 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { sections={sections} headerMessage={headerMessage} ListItem={UserListItem} - onSelectRow={selectMember} - initiallyFocusedOptionKey={selectedMember} + onSelectRow={submit} shouldUpdateFocusedIndex addBottomSafeAreaPadding - footerContent={ - - } showLoadingPlaceholder={!areOptionsInitialized} /> From 18f38aebec6cd6734f92346d484650529432d303 Mon Sep 17 00:00:00 2001 From: Rutika Pawar <183392827+twilight2294@users.noreply.github.com> Date: Mon, 29 Sep 2025 07:46:42 +0000 Subject: [PATCH 14/23] fix existing users results --- src/libs/UseOptionsUtils.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/libs/UseOptionsUtils.ts b/src/libs/UseOptionsUtils.ts index 234d0c40fbb4a..8ea255dd61379 100644 --- a/src/libs/UseOptionsUtils.ts +++ b/src/libs/UseOptionsUtils.ts @@ -5,6 +5,7 @@ import useDebouncedState from '@hooks/useDebouncedState'; import useOnyx from '@hooks/useOnyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {searchInServer} from './actions/Report'; import memoize from './memoize'; import {filterAndOrderOptions, getHeaderMessage, getValidOptions} from './OptionsListUtils'; @@ -27,6 +28,14 @@ function useOptions() { setIsLoading(false); }, [isLoading, optionsList.reports, optionsList.personalDetails]); + useEffect(() => { + if (!debouncedSearchValue.length) { + return; + } + + searchInServer(debouncedSearchValue); + }, [debouncedSearchValue]); + const defaultOptions = useMemo(() => { const {recentReports, personalDetails, userToInvite, currentUserOption} = memoizedGetValidOptions( { From a6462c3e62b643a15e69ecd82b5718fef819c61e Mon Sep 17 00:00:00 2001 From: Rutika Pawar <183392827+twilight2294@users.noreply.github.com> Date: Sun, 19 Oct 2025 10:26:59 +0000 Subject: [PATCH 15/23] fix loading indicator issue on assignee step --- src/libs/UseOptionsUtils.ts | 10 ++++++---- .../workspace/companyCards/assignCard/AssigneeStep.tsx | 3 ++- .../workspace/expensifyCard/issueNew/AssigneeStep.tsx | 3 ++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/libs/UseOptionsUtils.ts b/src/libs/UseOptionsUtils.ts index 8ea255dd61379..b1638d9fd5f6a 100644 --- a/src/libs/UseOptionsUtils.ts +++ b/src/libs/UseOptionsUtils.ts @@ -1,5 +1,4 @@ import {useEffect, useMemo, useState} from 'react'; -import {useBetas} from '@components/OnyxListItemProvider'; import {useOptionsList} from '@components/OptionListContextProvider'; import useDebouncedState from '@hooks/useDebouncedState'; import useOnyx from '@hooks/useOnyx'; @@ -12,13 +11,15 @@ import {filterAndOrderOptions, getHeaderMessage, getValidOptions} from './Option const memoizedGetValidOptions = memoize(getValidOptions, {maxSize: 5, monitoringName: 'AssigneeStep.getValidOptions'}); function useOptions() { - const betas = useBetas(); + const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); const [isLoading, setIsLoading] = useState(true); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const {options: optionsList, areOptionsInitialized} = useOptionsList(); const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true}); const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); const existingDelegates = useMemo(() => Object.fromEntries((account?.delegatedAccess?.delegates ?? []).map(({email}) => [email, true])), [account?.delegatedAccess?.delegates]); + const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false, canBeMissing: true}); + const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, {canBeMissing: true}); useEffect(() => { if (!isLoading || !optionsList.reports || !optionsList.personalDetails) { @@ -42,6 +43,7 @@ function useOptions() { reports: optionsList.reports, personalDetails: optionsList.personalDetails, }, + draftComments, { betas, excludeLogins: {...CONST.EXPENSIFY_EMAILS_OBJECT, ...existingDelegates}, @@ -57,7 +59,7 @@ function useOptions() { currentUserOption, headerMessage, }; - }, [optionsList.reports, optionsList.personalDetails, betas, existingDelegates]); + }, [optionsList.reports, optionsList.personalDetails, betas, existingDelegates, draftComments]); const options = useMemo(() => { const filteredOptions = filterAndOrderOptions(defaultOptions, debouncedSearchValue.trim(), countryCode, { @@ -76,7 +78,7 @@ function useOptions() { }; }, [debouncedSearchValue, defaultOptions, existingDelegates, countryCode]); - return {...options, searchValue, debouncedSearchValue, setSearchValue, areOptionsInitialized}; + return {...options, searchValue, debouncedSearchValue, setSearchValue, areOptionsInitialized, isSearchingForReports}; } export default useOptions; diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index 1e6e47ce287d3..c6b3d99938a8b 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -46,7 +46,7 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { const [cardFeeds] = useCardFeeds(policy?.id); const filteredCardList = getFilteredCardList(list, cardFeeds?.settings?.oAuthAccountDetails?.[feed], workspaceCardFeeds); - const {userToInvite, searchValue, personalDetails, debouncedSearchValue, setSearchValue, areOptionsInitialized, headerMessage} = useOptions(); + const {userToInvite, searchValue, personalDetails, debouncedSearchValue, setSearchValue, areOptionsInitialized, headerMessage, isSearchingForReports} = useOptions(); const isEditing = assignCard?.isEditing; const [selectedMember, setSelectedMember] = useState(assignCard?.data?.email ?? ''); @@ -215,6 +215,7 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { shouldUpdateFocusedIndex addBottomSafeAreaPadding showLoadingPlaceholder={!areOptionsInitialized} + isLoadingNewOptions={!!isSearchingForReports} /> ); diff --git a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx index eda7f17cefd24..cc6553e16fd9f 100644 --- a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx @@ -41,7 +41,7 @@ function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { const policyID = policy?.id; const [issueNewCard] = useOnyx(`${ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD}${policyID}`, {canBeMissing: true}); const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); - const {userToInvite, searchValue, personalDetails, debouncedSearchValue, setSearchValue, areOptionsInitialized, headerMessage} = useOptions(); + const {userToInvite, searchValue, personalDetails, debouncedSearchValue, setSearchValue, areOptionsInitialized, headerMessage, isSearchingForReports} = useOptions(); const currency = useCurrencyForExpensifyCard({policyID}); const isEditing = issueNewCard?.isEditing; @@ -177,6 +177,7 @@ function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { ListItem={UserListItem} onSelectRow={submit} addBottomSafeAreaPadding + isLoadingNewOptions={!!isSearchingForReports} /> ); From 1aef630cd537e8509022b519233c0ea92cd239a1 Mon Sep 17 00:00:00 2001 From: Rutika Pawar <183392827+twilight2294@users.noreply.github.com> Date: Sun, 19 Oct 2025 11:01:21 +0000 Subject: [PATCH 16/23] update component functions --- .../WorkspaceInviteMessageComponent.tsx | 68 ++++++++++++++----- 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx index 802fd76f73bfc..0dca96d7429b7 100644 --- a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx +++ b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx @@ -24,6 +24,8 @@ import {addMembersToWorkspace, clearWorkspaceInviteRoleDraft} from '@libs/action import {setWorkspaceInviteMessageDraft} from '@libs/actions/Policy/Policy'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Navigation from '@libs/Navigation/Navigation'; +import {getPersonalDetailsForAccountIDs} from '@libs/OptionsListUtils'; +import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {getMemberAccountIDsForWorkspace, goBackFromInvalidPolicy} from '@libs/PolicyUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import variables from '@styles/variables'; @@ -62,6 +64,7 @@ function WorkspaceInviteMessageComponent({ 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(); @@ -76,6 +79,22 @@ function WorkspaceInviteMessageComponent({ }); 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'}`, @@ -113,12 +132,14 @@ function WorkspaceInviteMessageComponent({ 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); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.dismissModal()); return; } @@ -129,6 +150,7 @@ function WorkspaceInviteMessageComponent({ Navigation.setNavigationActionToMicrotaskQueue(() => { Navigation.dismissModal(); + // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID)); }); @@ -173,6 +195,7 @@ function WorkspaceInviteMessageComponent({ Navigation.dismissModal()} onBackButtonPress={() => Navigation.goBack(backTo)} /> @@ -186,19 +209,6 @@ function WorkspaceInviteMessageComponent({ enabledWhenOffline shouldHideFixErrorsAlert addBottomSafeAreaPadding - footerContent={ - - - {translate('common.privacy')} - - - } > {isInviteNewMemberStep && {translate('workspace.card.issueNewCard.inviteNewMember')}} @@ -209,15 +219,23 @@ function WorkspaceInviteMessageComponent({ displayInRows: true, }} secondaryAvatarContainerStyle={styles.secondAvatarInline} - shouldShowTooltip={shouldShowTooltip} invitedEmailsToAccountIDs={invitedEmailsToAccountIDsDraft} + shouldUseCustomFallbackAvatar + shouldShowTooltip={shouldShowTooltip} /> - - {translate('workspace.inviteMessage.inviteMessagePrompt')} - + { + Navigation.goBack(backTo); + }} + /> + + + {translate('workspace.inviteMessage.inviteMessagePrompt')} + + + + {translate('common.privacy')} + + From 54771ac35788a177668051052291ed2db62eeec7 Mon Sep 17 00:00:00 2001 From: Rutika Pawar <183392827+twilight2294@users.noreply.github.com> Date: Sun, 19 Oct 2025 11:45:15 +0000 Subject: [PATCH 17/23] fix company cards --- .../companyCards/assignCard/AssigneeStep.tsx | 54 ++++++------------- .../assignCard/InviteNewMemberStep.tsx | 1 + .../issueNew/InviteNewMemberStep.tsx | 1 + .../WorkspaceInviteMessageComponent.tsx | 23 ++++---- 4 files changed, 32 insertions(+), 47 deletions(-) diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index c6b3d99938a8b..3c925a6d22ac3 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -1,4 +1,4 @@ -import React, {useMemo, useState} from 'react'; +import React, {useMemo} from 'react'; import {Keyboard} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -49,13 +49,17 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { const {userToInvite, searchValue, personalDetails, debouncedSearchValue, setSearchValue, areOptionsInitialized, headerMessage, isSearchingForReports} = useOptions(); const isEditing = assignCard?.isEditing; - const [selectedMember, setSelectedMember] = useState(assignCard?.data?.email ?? ''); - 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), + }; + Keyboard.dismiss(); - setSelectedMember(assignee.login ?? ''); - if (selectedMember === assignCard?.data?.email) { + if (assignee?.login === assignCard?.data?.email) { setAssignCardStepAndData({ currentStep: isEditing ? CONST.COMPANY_CARD.STEP.CONFIRMATION : nextStep, isEditing: false, @@ -63,25 +67,18 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { return; } - if (userToInvite?.login === selectedMember) { + if (userToInvite?.accountID === assignee?.accountID) { setAssignCardStepAndData({ currentStep: CONST.COMPANY_CARD.STEP.INVITE_NEW_MEMBER, data: { - email: selectedMember, - assigneeAccountID: userToInvite?.accountID, + email: assignee?.login ?? '', + assigneeAccountID: assignee?.accountID, }, }); - setDraftInviteAccountID(selectedMember, userToInvite?.accountID, policy?.id); + setDraftInviteAccountID(assignee?.login ?? '', assignee?.accountID, 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); @@ -124,7 +121,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, @@ -139,24 +136,7 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { membersList = sortAlphabetically(membersList, 'text', localeCompare); return membersList; - }, [isOffline, policy?.employeeList, selectedMember, formatPhoneNumber, localeCompare]); - - const membersDetailsWithInviteNewMember = useMemo(() => { - if (!userToInvite) { - return {}; - } - - const newMember: ListItem = { - keyForList: userToInvite?.login, - text: userToInvite?.login, - alternateText: userToInvite?.login, - login: userToInvite?.login, - isSelected: selectedMember === userToInvite?.login, - accountID: userToInvite?.accountID, - }; - - return newMember; - }, [selectedMember, userToInvite]); + }, [isOffline, policy?.employeeList, assignCard?.data?.email, formatPhoneNumber, localeCompare]); const sections = useMemo(() => { if (!debouncedSearchValue) { @@ -179,7 +159,7 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { }, { title: undefined, - data: userToInvite ? [membersDetailsWithInviteNewMember] : [], + data: userToInvite ? [userToInvite] : [], shouldShow: !!userToInvite, }, ...(personalDetails @@ -192,7 +172,7 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { ] : []), ]; - }, [debouncedSearchValue, membersDetails, userToInvite, membersDetailsWithInviteNewMember, personalDetails, countryCode]); + }, [debouncedSearchValue, membersDetails, userToInvite, personalDetails, countryCode]); return ( ); diff --git a/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx b/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx index 710b6fcafb556..20bd2a2d0013a 100644 --- a/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/InviteNewMemberStep.tsx @@ -61,6 +61,7 @@ function InviteNewMemberStep({policy, route, currentUserPersonalDetails}: Invite isInviteNewMemberStep goToNextStep={goToNextStep} shouldShowTooltip={false} + shouldShowMemberNames={false} /> ); diff --git a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx index 0dca96d7429b7..24d77758ec882 100644 --- a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx +++ b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx @@ -47,6 +47,7 @@ type WorkspaceInviteMessageComponentProps = { currentUserPersonalDetails: OnyxEntry; shouldShowTooltip?: boolean; shouldShowBackButton?: boolean; + shouldShowMemberNames?: boolean; isInviteNewMemberStep?: boolean; goToNextStep?: () => void; }; @@ -58,6 +59,7 @@ function WorkspaceInviteMessageComponent({ currentUserPersonalDetails, shouldShowTooltip = true, shouldShowBackButton = true, + shouldShowMemberNames = true, isInviteNewMemberStep = false, goToNextStep, }: WorkspaceInviteMessageComponentProps) { @@ -226,16 +228,17 @@ function WorkspaceInviteMessageComponent({ - { - Navigation.goBack(backTo); - }} - /> - + {shouldShowMemberNames && ( + { + Navigation.goBack(backTo); + }} + /> + )} Date: Fri, 24 Oct 2025 08:34:02 +0000 Subject: [PATCH 18/23] remove redundant results --- .../companyCards/assignCard/AssigneeStep.tsx | 13 ++----------- .../expensifyCard/issueNew/AssigneeStep.tsx | 13 ++----------- 2 files changed, 4 insertions(+), 22 deletions(-) diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index 3c925a6d22ac3..3e2a48e3dad0d 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -46,7 +46,7 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { const [cardFeeds] = useCardFeeds(policy?.id); const filteredCardList = getFilteredCardList(list, cardFeeds?.settings?.oAuthAccountDetails?.[feed], workspaceCardFeeds); - const {userToInvite, searchValue, personalDetails, debouncedSearchValue, setSearchValue, areOptionsInitialized, headerMessage, isSearchingForReports} = useOptions(); + const {userToInvite, searchValue, debouncedSearchValue, setSearchValue, areOptionsInitialized, headerMessage, isSearchingForReports} = useOptions(); const isEditing = assignCard?.isEditing; const submit = (assignee: ListItem) => { @@ -162,17 +162,8 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { data: userToInvite ? [userToInvite] : [], shouldShow: !!userToInvite, }, - ...(personalDetails - ? [ - { - title: undefined, - data: personalDetails, - shouldShow: !!personalDetails, - }, - ] - : []), ]; - }, [debouncedSearchValue, membersDetails, userToInvite, personalDetails, countryCode]); + }, [debouncedSearchValue, membersDetails, userToInvite, countryCode]); return ( Date: Fri, 24 Oct 2025 09:59:43 +0000 Subject: [PATCH 19/23] fix result not found text --- .../companyCards/assignCard/AssigneeStep.tsx | 10 ++++++++-- .../workspace/expensifyCard/issueNew/AssigneeStep.tsx | 11 ++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index 3e2a48e3dad0d..317b49d579c1d 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -15,7 +15,7 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {setDraftInviteAccountID} from '@libs/actions/Card'; import {getDefaultCardName, getFilteredCardList, hasOnlyOneCardToAssign} from '@libs/CardUtils'; -import {getSearchValueForPhoneOrEmail, sortAlphabetically} from '@libs/OptionsListUtils'; +import {getHeaderMessage, getSearchValueForPhoneOrEmail, sortAlphabetically} from '@libs/OptionsListUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {isDeletedPolicyEmployee} from '@libs/PolicyUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; @@ -46,7 +46,7 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { const [cardFeeds] = useCardFeeds(policy?.id); const filteredCardList = getFilteredCardList(list, cardFeeds?.settings?.oAuthAccountDetails?.[feed], workspaceCardFeeds); - const {userToInvite, searchValue, debouncedSearchValue, setSearchValue, areOptionsInitialized, headerMessage, isSearchingForReports} = useOptions(); + const {userToInvite, searchValue, debouncedSearchValue, setSearchValue, areOptionsInitialized, isSearchingForReports} = useOptions(); const isEditing = assignCard?.isEditing; const submit = (assignee: ListItem) => { @@ -165,6 +165,12 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { ]; }, [debouncedSearchValue, membersDetails, userToInvite, countryCode]); + const headerMessage = useMemo(() => { + const searchInputValue = debouncedSearchValue.trim().toLowerCase(); + + return getHeaderMessage(sections[0].data.length !== 0, !!userToInvite, searchInputValue, false, countryCode); + }, [debouncedSearchValue, sections, userToInvite, countryCode]); + return ( { @@ -147,6 +146,12 @@ function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { ]; }, [debouncedSearchValue, membersDetails, userToInvite, countryCode]); + const headerMessage = useMemo(() => { + const searchInputValue = debouncedSearchValue.trim().toLowerCase(); + + return getHeaderMessage(sections[0].data.length !== 0, !!userToInvite, searchInputValue, false, countryCode); + }, [debouncedSearchValue, sections, userToInvite, countryCode]); + return ( Date: Mon, 27 Oct 2025 18:29:18 +0530 Subject: [PATCH 20/23] Update UseOptionsUtils.ts --- src/libs/UseOptionsUtils.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/libs/UseOptionsUtils.ts b/src/libs/UseOptionsUtils.ts index b1638d9fd5f6a..55bdecf2c0cbb 100644 --- a/src/libs/UseOptionsUtils.ts +++ b/src/libs/UseOptionsUtils.ts @@ -6,7 +6,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {searchInServer} from './actions/Report'; import memoize from './memoize'; -import {filterAndOrderOptions, getHeaderMessage, getValidOptions} from './OptionsListUtils'; +import {filterAndOrderOptions, getValidOptions} from './OptionsListUtils'; const memoizedGetValidOptions = memoize(getValidOptions, {maxSize: 5, monitoringName: 'AssigneeStep.getValidOptions'}); @@ -16,7 +16,7 @@ function useOptions() { const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const {options: optionsList, areOptionsInitialized} = useOptionsList(); const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true}); - const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); + const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); const existingDelegates = useMemo(() => Object.fromEntries((account?.delegatedAccess?.delegates ?? []).map(({email}) => [email, true])), [account?.delegatedAccess?.delegates]); const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false, canBeMissing: true}); const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, {canBeMissing: true}); @@ -50,14 +50,11 @@ function useOptions() { }, ); - const headerMessage = getHeaderMessage((recentReports?.length || 0) + (personalDetails?.length || 0) !== 0, !!userToInvite, ''); - return { userToInvite, recentReports, personalDetails, currentUserOption, - headerMessage, }; }, [optionsList.reports, optionsList.personalDetails, betas, existingDelegates, draftComments]); @@ -66,15 +63,9 @@ function useOptions() { excludeLogins: {...CONST.EXPENSIFY_EMAILS_OBJECT, ...existingDelegates}, maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, }); - const headerMessage = getHeaderMessage( - (filteredOptions.recentReports?.length || 0) + (filteredOptions.personalDetails?.length || 0) !== 0, - !!filteredOptions.userToInvite, - debouncedSearchValue, - ); return { ...filteredOptions, - headerMessage, }; }, [debouncedSearchValue, defaultOptions, existingDelegates, countryCode]); From a446ba1f5b0d82e6be1643dab1008a34e30f2823 Mon Sep 17 00:00:00 2001 From: Rutika Pawar <183392827+twilight2294@users.noreply.github.com> Date: Thu, 30 Oct 2025 05:15:48 +0000 Subject: [PATCH 21/23] fix errors on search --- src/components/InteractiveStepWrapper.tsx | 7 + src/libs/UseOptionsUtils.ts | 75 ---------- .../companyCards/assignCard/AssigneeStep.tsx | 132 ++++++++++++++---- .../expensifyCard/issueNew/AssigneeStep.tsx | 130 +++++++++++++---- 4 files changed, 216 insertions(+), 128 deletions(-) delete mode 100644 src/libs/UseOptionsUtils.ts 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} > Object.fromEntries((account?.delegatedAccess?.delegates ?? []).map(({email}) => [email, true])), [account?.delegatedAccess?.delegates]); - const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false, canBeMissing: true}); - const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, {canBeMissing: true}); - - useEffect(() => { - if (!isLoading || !optionsList.reports || !optionsList.personalDetails) { - return; - } - - setIsLoading(false); - }, [isLoading, optionsList.reports, optionsList.personalDetails]); - - useEffect(() => { - if (!debouncedSearchValue.length) { - return; - } - - searchInServer(debouncedSearchValue); - }, [debouncedSearchValue]); - - const defaultOptions = useMemo(() => { - const {recentReports, personalDetails, userToInvite, currentUserOption} = memoizedGetValidOptions( - { - reports: optionsList.reports, - personalDetails: optionsList.personalDetails, - }, - draftComments, - { - betas, - excludeLogins: {...CONST.EXPENSIFY_EMAILS_OBJECT, ...existingDelegates}, - }, - ); - - return { - userToInvite, - recentReports, - personalDetails, - currentUserOption, - }; - }, [optionsList.reports, optionsList.personalDetails, betas, existingDelegates, draftComments]); - - const options = useMemo(() => { - const filteredOptions = filterAndOrderOptions(defaultOptions, debouncedSearchValue.trim(), countryCode, { - excludeLogins: {...CONST.EXPENSIFY_EMAILS_OBJECT, ...existingDelegates}, - maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, - }); - - return { - ...filteredOptions, - }; - }, [debouncedSearchValue, defaultOptions, existingDelegates, countryCode]); - - return {...options, searchValue, debouncedSearchValue, setSearchValue, areOptionsInitialized, isSearchingForReports}; -} - -export default useOptions; diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index fe8e5db2efe99..6d5670a52b17c 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react'; +import React, {useEffect, useMemo, useState} from 'react'; import {Keyboard} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -12,14 +12,15 @@ import useCardsList from '@hooks/useCardsList'; 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 {setDraftInviteAccountID} from '@libs/actions/Card'; +import {searchInServer} from '@libs/actions/Report'; import {getDefaultCardName, getFilteredCardList, hasOnlyOneCardToAssign} from '@libs/CardUtils'; import {getHeaderMessage, getSearchValueForPhoneOrEmail, sortAlphabetically} from '@libs/OptionsListUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; -import {isDeletedPolicyEmployee} from '@libs/PolicyUtils'; +import {getIneligibleInvitees, isDeletedPolicyEmployee} from '@libs/PolicyUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; -import useOptions from '@libs/UseOptionsUtils'; import Navigation from '@navigation/Navigation'; import {setAssignCardStepAndData} from '@userActions/CompanyCards'; import CONST from '@src/CONST'; @@ -45,8 +46,29 @@ 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 {userToInvite, searchValue, debouncedSearchValue, setSearchValue, areOptionsInitialized, isSearchingForReports} = useOptions(); const isEditing = assignCard?.isEditing; const submit = (assignee: ListItem) => { @@ -67,15 +89,16 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { return; } - if (userToInvite?.accountID === assignee?.accountID) { + // check if the assinee accountID is not in the employeeList + if (!policy?.employeeList?.[assignee?.login ?? '']) { setAssignCardStepAndData({ currentStep: CONST.COMPANY_CARD.STEP.INVITE_NEW_MEMBER, data: { email: assignee?.login ?? '', - assigneeAccountID: assignee?.accountID, + assigneeAccountID: assignee?.accountID ?? undefined, }, }); - setDraftInviteAccountID(assignee?.login ?? '', assignee?.accountID, policy?.id); + setDraftInviteAccountID(assignee?.login ?? '', assignee?.accountID ?? undefined, policy?.id); return; } @@ -139,7 +162,7 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { }, [isOffline, policy?.employeeList, assignCard?.data?.email, formatPhoneNumber, localeCompare]); const sections = useMemo(() => { - if (!debouncedSearchValue) { + if (!debouncedSearchTerm) { return [ { data: membersDetails, @@ -148,28 +171,82 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { ]; } - const searchValueForPhoneOrEmail = getSearchValueForPhoneOrEmail(debouncedSearchValue, countryCode).toLowerCase(); - const filteredOptions = tokenizedSearch(membersDetails, searchValueForPhoneOrEmail, (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 ?? '']); + + sectionsArr.push({ + title: undefined, + data: filteredOptions, + shouldShow: true, + }); - return [ - { + // Selected options section + if (selectedOptionsForDisplay.length > 0) { + sectionsArr.push({ title: undefined, - data: filteredOptions, - shouldShow: true, - }, - { + data: selectedOptionsForDisplay, + }); + } + + // Recent reports section + if (availableOptions.recentReports.length > 0) { + sectionsArr.push({ title: undefined, - data: userToInvite ? [userToInvite] : [], - shouldShow: !!userToInvite, - }, - ]; - }, [debouncedSearchValue, membersDetails, userToInvite, countryCode]); + data: availableOptions.recentReports, + }); + } - const headerMessage = useMemo(() => { - const searchInputValue = debouncedSearchValue.trim().toLowerCase(); + // Contacts section + if (availableOptions.personalDetails.length > 0) { + sectionsArr.push({ + title: undefined, + data: availableOptions.personalDetails, + }); + } - return getHeaderMessage(sections[0].data.length !== 0, !!userToInvite, searchInputValue, countryCode, false); - }, [debouncedSearchValue, sections, userToInvite, 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')} { + 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; @@ -56,14 +78,15 @@ function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { data.cardTitle = getCardDefaultName(getUserNameByEmail(assignee?.login ?? '', 'firstName')); } - if (userToInvite?.accountID === assignee?.accountID) { - data.assigneeAccountID = assignee?.accountID; + // check if the assinee accountID is not in the employeeList + 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, policyID); + setDraftInviteAccountID(data.assigneeEmail, assignee?.accountID ?? undefined, policyID); return; } @@ -120,7 +143,7 @@ function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { }, [policy?.employeeList, localeCompare, isOffline, issueNewCard?.data?.assigneeEmail, formatPhoneNumber]); const sections = useMemo(() => { - if (!debouncedSearchValue) { + if (!debouncedSearchTerm) { return [ { data: membersDetails, @@ -129,28 +152,82 @@ function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { ]; } - const searchValueForOptions = getSearchValueForPhoneOrEmail(debouncedSearchValue, countryCode).toLowerCase(); + 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, - }, - { + data: selectedOptionsForDisplay, + }); + } + + // Recent reports section + if (availableOptions.recentReports.length > 0) { + sectionsArr.push({ title: undefined, - data: userToInvite ? [userToInvite] : [], - shouldShow: !!userToInvite, - }, - ]; - }, [debouncedSearchValue, membersDetails, userToInvite, countryCode]); + data: availableOptions.recentReports, + }); + } - const headerMessage = useMemo(() => { - const searchInputValue = debouncedSearchValue.trim().toLowerCase(); + // Contacts section + if (availableOptions.personalDetails.length > 0) { + sectionsArr.push({ + title: undefined, + data: availableOptions.personalDetails, + }); + } - return getHeaderMessage(sections[0].data.length !== 0, !!userToInvite, searchInputValue, countryCode, false); - }, [debouncedSearchValue, sections, userToInvite, 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')} Date: Thu, 30 Oct 2025 05:23:08 +0000 Subject: [PATCH 22/23] fix spellcheck --- src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx | 1 - src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index 6d5670a52b17c..b8b474f788eac 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -89,7 +89,6 @@ function AssigneeStep({policy, feed}: AssigneeStepProps) { return; } - // check if the assinee accountID is not in the employeeList if (!policy?.employeeList?.[assignee?.login ?? '']) { setAssignCardStepAndData({ currentStep: CONST.COMPANY_CARD.STEP.INVITE_NEW_MEMBER, diff --git a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx index 0a6ce1c0483fe..c6fc058f29586 100644 --- a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx @@ -78,7 +78,6 @@ function AssigneeStep({policy, stepNames, startStepIndex}: AssigneeStepProps) { data.cardTitle = getCardDefaultName(getUserNameByEmail(assignee?.login ?? '', 'firstName')); } - // check if the assinee accountID is not in the employeeList if (!policy?.employeeList?.[assignee?.login ?? '']) { data.assigneeAccountID = assignee?.accountID ?? undefined; setIssueNewCardStepAndData({ From 7dbbcf3c0897ab3023c63f85f0cebaab449c4b0a Mon Sep 17 00:00:00 2001 From: Rutika Pawar <183392827+twilight2294@users.noreply.github.com> Date: Thu, 30 Oct 2025 12:16:04 +0530 Subject: [PATCH 23/23] Update AssignCardFeedPage.tsx --- tests/ui/AssignCardFeedPage.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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(),