diff --git a/src/CONST/index.ts b/src/CONST/index.ts index f5b654206dce..1d52615201d4 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -202,6 +202,7 @@ const CONST = { CUSTOM_FIELD_KEYS: {customField1: 'employeeUserID', customField2: 'employeePayrollID'}, ANDROID_PACKAGE_NAME, WORKSPACE_ENABLE_FEATURE_REDIRECT_DELAY: 100, + WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM_ROUTE: 'expenses-from', ANIMATED_HIGHLIGHT_ENTRY_DELAY: 50, ANIMATED_HIGHLIGHT_ENTRY_DURATION: 300, ANIMATED_HIGHLIGHT_START_DELAY: 10, diff --git a/src/ROUTES.ts b/src/ROUTES.ts index a1111f4f1419..ef2b0413529e 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -6,7 +6,7 @@ import type {TupleToUnion, ValueOf} from 'type-fest'; import type {UpperCaseCharacters} from 'type-fest/source/internal'; import type {SearchFilterKey, SearchQueryString, UserFriendlyKey} from './components/Search/types'; -import type CONST from './CONST'; +import CONST from './CONST'; import type {IOUAction, IOUType} from './CONST'; import type {ReplacementReason} from './libs/actions/Card'; import type {IOURequestType} from './libs/actions/IOU'; @@ -1733,10 +1733,11 @@ const ROUTES = { getRoute: (policyID: string, firstApproverEmail: string) => `workspaces/${policyID}/workflows/approvals/${encodeURIComponent(firstApproverEmail)}/edit` as const, }, WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM: { - route: 'workspaces/:policyID/workflows/approvals/expenses-from', + route: `workspaces/:policyID/workflows/approvals/${CONST.WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM_ROUTE}`, - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/workflows/approvals/expenses-from` as const, backTo), + getRoute: (policyID: string, backTo?: string) => + // eslint-disable-next-line no-restricted-syntax -- Legacy route generation + getUrlWithBackToParam(`workspaces/${policyID}/workflows/approvals/${CONST.WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM_ROUTE}` as const, backTo), }, WORKSPACE_WORKFLOWS_APPROVALS_APPROVER: { route: 'workspaces/:policyID/workflows/approvals/approver', diff --git a/src/components/ApproverSelectionList.tsx b/src/components/ApproverSelectionList.tsx index 1f8d09e8bbcc..ae6e44a40ef1 100644 --- a/src/components/ApproverSelectionList.tsx +++ b/src/components/ApproverSelectionList.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react'; +import React, {useCallback, useMemo} from 'react'; import useDebouncedState from '@hooks/useDebouncedState'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -40,6 +40,7 @@ type ApproverSelectionListPageProps = { onSelectApprover?: (approvers: SelectionListApprover[]) => void; shouldShowLoadingPlaceholder?: boolean; shouldEnableHeaderMaxHeight?: boolean; + onSearchChange?: (searchTerm: string) => void; shouldUpdateFocusedIndex?: boolean; }; @@ -66,6 +67,7 @@ function ApproverSelectionList({ onSelectApprover, shouldShowLoadingPlaceholder, shouldEnableHeaderMaxHeight, + onSearchChange, shouldUpdateFocusedIndex = true, }: ApproverSelectionListPageProps) { const styles = useThemeStyles(); @@ -75,6 +77,16 @@ function ApproverSelectionList({ const shouldShowTextInput = shouldShowTextInputProp ?? allApprovers?.length >= CONST.STANDARD_LIST_ITEM_LIMIT; const lazyIllustrations = useMemoizedLazyIllustrations(['TurtleInShell']); + const handleSearchChange = useCallback( + (term: string) => { + setSearchTerm(term); + if (onSearchChange) { + onSearchChange(term); + } + }, + [onSearchChange, setSearchTerm], + ); + const selectedMembers = useMemo(() => allApprovers.filter((approver) => approver.isSelected), [allApprovers]); // eslint-disable-next-line rulesdir/no-negated-variables @@ -126,10 +138,10 @@ function ApproverSelectionList({ () => ({ label: shouldShowListEmptyContent ? undefined : translate('selectionList.findMember'), value: searchTerm, - onChangeText: setSearchTerm, + onChangeText: handleSearchChange, headerMessage: searchTerm && !data?.length ? translate('common.noResultsFound') : '', }), - [shouldShowListEmptyContent, translate, searchTerm, setSearchTerm, data?.length], + [shouldShowListEmptyContent, translate, searchTerm, handleSearchChange, data?.length], ); return ( @@ -153,9 +165,9 @@ function ApproverSelectionList({ {subtitle} { + const ineligibleInvitees = getIneligibleInvitees(employeeList); + const result: Record = {}; + for (const login of ineligibleInvitees) { + result[login] = true; + } + return result; +} + function getSortedTagKeys(policyTagList: OnyxEntry): Array { if (isEmptyObject(policyTagList)) { return []; @@ -1697,6 +1709,7 @@ export { getValidConnectedIntegration, getCountOfEnabledTagsOfList, getIneligibleInvitees, + getExcludedUsers, getMemberAccountIDsForWorkspace, getNumericValue, isMultiLevelTags, diff --git a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx index 125b06ec5372..3f44087204bf 100644 --- a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx +++ b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx @@ -66,6 +66,30 @@ function WorkspaceInviteMessageComponent({ }: WorkspaceInviteMessageComponentProps) { const styles = useThemeStyles(); const {translate, formatPhoneNumber} = useLocalize(); + const policyName = policy?.name; + + const isWorkflowApprovalExpensesFromRoute = useMemo(() => { + if (!backTo || typeof backTo !== 'string') { + return false; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + return (backTo as string).includes(CONST.WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM_ROUTE); + }, [backTo]); + + const headerTitle = useMemo(() => { + if (isWorkflowApprovalExpensesFromRoute) { + return translate('workflowsExpensesFromPage.title'); + } + return translate('workspace.inviteMessage.confirmDetails'); + }, [isWorkflowApprovalExpensesFromRoute, translate]); + + const subtitle = useMemo(() => { + if (isWorkflowApprovalExpensesFromRoute) { + return undefined; + } + return policyName; + }, [isWorkflowApprovalExpensesFromRoute, policyName]); + const [formData, formDataResult] = useOnyx(ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM_DRAFT, {canBeMissing: true}); const [allPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); @@ -82,22 +106,25 @@ 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]; + const memberNames = useMemo(() => { + const personalDetailsOfInvitedEmails = getPersonalDetailsForAccountIDs(Object.values(invitedEmailsToAccountIDsDraft ?? {}), allPersonalDetails ?? {}); + return 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(', '); + return loginFromInviteMap; + }) + .join(', '); + }, [invitedEmailsToAccountIDsDraft, allPersonalDetails]); const welcomeNoteSubject = useMemo( () => `# ${currentUserPersonalDetails?.displayName ?? ''} invited you to ${policy?.name ?? 'a workspace'}`, @@ -147,6 +174,21 @@ function WorkspaceInviteMessageComponent({ return; } + // If backTo is provided and it's the expenses-from route, navigate to approver screen + if (isWorkflowApprovalExpensesFromRoute) { + // Check if it's initial creation flow (backTo doesn't have a nested backTo param) + const backToStr = typeof backTo === 'string' ? backTo : ''; + const isInitialCreationFlow = !backToStr.includes('/expenses-from/'); + if (isInitialCreationFlow) { + // Navigate to approver screen for initial creation flow + Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVER.getRoute(policyID, 0)); + } else { + // For edit flow, just go back + Navigation.goBack(); + } + return; + } + if ((backTo as string)?.endsWith('members')) { Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.dismissModal()); return; @@ -176,7 +218,6 @@ function WorkspaceInviteMessageComponent({ return errorFields; }; - const policyName = policy?.name; const invitingMemberEmail = Object.keys(invitedEmailsToAccountIDsDraft ?? {}).at(0) ?? ''; const invitingMemberDetails = getPersonalDetailByEmail(invitingMemberEmail); const invitingMemberName = Str.removeSMSDomain(invitingMemberDetails?.displayName ?? ''); @@ -201,8 +242,8 @@ function WorkspaceInviteMessageComponent({ > {shouldShowBackButton && ( Navigation.dismissModal()} onBackButtonPress={() => Navigation.goBack(backTo)} @@ -218,7 +259,9 @@ function WorkspaceInviteMessageComponent({ shouldHideFixErrorsAlert addBottomSafeAreaPadding > - {isInviteNewMemberStep && {translate('workspace.card.issueNewCard.inviteNewMember')}} + {(isInviteNewMemberStep || isWorkflowApprovalExpensesFromRoute) && ( + {translate('workspace.card.issueNewCard.inviteNewMember')} + )} ([]); + const excludedUsers = useMemo(() => { + return getExcludedUsers(policy?.employeeList); + }, [policy?.employeeList]); + + const { + searchTerm: searchSelectorTerm, + setSearchTerm: setSearchSelectorTerm, + debouncedSearchTerm, + availableOptions, + areOptionsInitialized, + } = useSearchSelector({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, + includeUserToInvite: true, + excludeLogins: excludedUsers, + includeRecentReports: true, + shouldInitialize: true, + }); + + useEffect(() => { + searchInServer(searchSelectorTerm); + }, [searchSelectorTerm]); + // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !isPolicyAdmin(policy) || isPendingDeletePolicy(policy); const isInitialCreationFlow = approvalWorkflow?.action === CONST.APPROVAL_WORKFLOW.ACTION.CREATE && !route.params.backTo; @@ -48,38 +76,53 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat const personalDetailLogins = useDeepCompareRef(Object.fromEntries(Object.entries(personalDetails ?? {}).map(([id, details]) => [id, details?.login]))); useEffect(() => { - if (!approvalWorkflow?.members) { + if (!approvalWorkflow?.members || selectedMembers.length > 0) { return; } setSelectedMembers( approvalWorkflow.members.map((member) => { const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList); - const accountID = Number(policyMemberEmailsToAccountIDs[member.email] ?? ''); - const login = personalDetailLogins?.[accountID]; + let accountID = Number(policyMemberEmailsToAccountIDs[member.email] ?? ''); + const isPolicyMember = !!policy?.employeeList?.[member.email]; + + const personalDetail = getPersonalDetailByEmail(member.email); + + if (!isPolicyMember) { + const draftAccountID = invitedEmailsToAccountIDsDraft?.[member.email]; + if (draftAccountID) { + accountID = draftAccountID; + } else if (personalDetail?.accountID) { + accountID = personalDetail.accountID; + } + } + + const login = personalDetailLogins?.[accountID] ?? member.email; + const displayName = member.displayName ?? personalDetail?.displayName ?? member.email; + const avatar = member.avatar ?? personalDetail?.avatar; return { - text: Str.removeSMSDomain(member.displayName), + text: Str.removeSMSDomain(displayName), alternateText: member.email, keyForList: member.email, isSelected: true, login: member.email, - icons: [{source: member.avatar ?? icons.FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, name: Str.removeSMSDomain(member.displayName), id: accountID}], - rightElement: ( + icons: [{source: avatar ?? icons.FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, name: Str.removeSMSDomain(displayName), id: accountID}], + rightElement: isPolicyMember ? ( - ), + ) : undefined, }; }), ); - }, [approvalWorkflow?.members, policy?.employeeList, policy?.owner, personalDetailLogins, translate, icons.FallbackAvatar]); + }, [approvalWorkflow?.members, icons.FallbackAvatar, invitedEmailsToAccountIDsDraft, personalDetailLogins, policy?.employeeList, policy?.owner, selectedMembers.length]); - const approversEmail = useMemo(() => approvalWorkflow?.approvers.map((member) => member?.email), [approvalWorkflow?.approvers]); const allApprovers = useMemo(() => { const members: SelectionListApprover[] = [...selectedMembers]; + const approversEmail = approvalWorkflow?.approvers.map((member) => member?.email); if (!approvalWorkflow?.availableMembers) { return members; @@ -112,8 +155,56 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat members.push(...availableMembers); + if (debouncedSearchTerm && areOptionsInitialized) { + if (availableOptions.userToInvite) { + const userToInvite = availableOptions.userToInvite; + const isAlreadySelected = selectedMembers.some((selectedOption) => selectedOption.login === userToInvite.login); + if (!isAlreadySelected) { + members.push({ + text: userToInvite.text ?? userToInvite.login ?? '', + alternateText: userToInvite.login ?? '', + keyForList: userToInvite.login ?? '', + isSelected: false, + login: userToInvite.login ?? '', + icons: userToInvite.icons ?? [], + }); + } + } + + const searchResults = [...availableOptions.recentReports, ...availableOptions.personalDetails].filter((option) => { + const isMember = policy?.employeeList?.[option.login ?? '']; + const isAlreadyInList = members.some((m) => m.login === option.login); + return !isMember && !isAlreadyInList; + }); + + for (const option of searchResults) { + members.push({ + text: option.text ?? option.login ?? '', + alternateText: option.login ?? '', + keyForList: option.login ?? '', + isSelected: false, + login: option.login ?? '', + icons: option.icons ?? [], + }); + } + } + return members; - }, [selectedMembers, approvalWorkflow?.availableMembers, policy?.employeeList, policy?.owner, policy?.preventSelfApproval, personalDetailLogins, icons.FallbackAvatar, approversEmail]); + }, [ + selectedMembers, + approvalWorkflow?.availableMembers, + approvalWorkflow?.approvers, + policy?.employeeList, + policy?.owner, + policy?.preventSelfApproval, + personalDetailLogins, + debouncedSearchTerm, + areOptionsInitialized, + availableOptions.userToInvite, + availableOptions.recentReports, + availableOptions.personalDetails, + icons.FallbackAvatar, + ]); const goBack = useCallback(() => { let backTo; @@ -126,15 +217,71 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat }, [isInitialCreationFlow, route.params.policyID, firstApprover, approvalWorkflow?.action]); const nextStep = useCallback(() => { - const members: Member[] = selectedMembers.map((member) => ({displayName: member.text ?? '', avatar: member.icons?.at(0)?.source, email: member.login ?? ''})); - setApprovalWorkflowMembers(members); + const existingMembers: Member[] = []; + const usersToInvite: Array<{email: string; accountID?: number}> = []; + + for (const member of selectedMembers) { + if (!member.login) { + continue; + } + const isPolicyMember = policy?.employeeList?.[member.login]; + if (isPolicyMember) { + existingMembers.push({ + displayName: member.text ?? '', + avatar: member.icons?.at(0)?.source, + email: member.login, + }); + } else { + const iconId = member.icons?.at(0)?.id; + const accountID = typeof iconId === 'number' ? iconId : undefined; + usersToInvite.push({ + email: member.login, + accountID, + }); + } + } + + const normalizedExistingMembers: Member[] = existingMembers.map((member) => ({ + ...member, + avatar: typeof member.avatar === 'string' ? member.avatar : undefined, + })); + + const allMembers: Member[] = [ + ...normalizedExistingMembers, + ...usersToInvite.map((user) => ({ + displayName: user.email, + email: user.email, + avatar: undefined, + })), + ]; + setApprovalWorkflowMembers(allMembers); + + if (usersToInvite.length > 0) { + const invitedEmailsToAccountIDs: Record = {}; + for (const user of usersToInvite) { + if (!user.email) { + continue; + } + if (user.accountID) { + invitedEmailsToAccountIDs[user.email] = user.accountID; + } + } + + setWorkspaceInviteMembersDraft(route.params.policyID, invitedEmailsToAccountIDs); + + const backToRoute = isInitialCreationFlow + ? ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM.getRoute(route.params.policyID) + : ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM.getRoute(route.params.policyID, route.params.backTo); + Navigation.navigate(ROUTES.WORKSPACE_INVITE_MESSAGE.getRoute(route.params.policyID, backToRoute)); + return; + } if (isInitialCreationFlow) { Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVER.getRoute(route.params.policyID, 0)); } else { goBack(); } - }, [route.params.policyID, selectedMembers, isInitialCreationFlow, goBack]); + }, [route.params.policyID, route.params.backTo, selectedMembers, isInitialCreationFlow, goBack, policy?.employeeList]); const button = useMemo(() => { let buttonText = isInitialCreationFlow ? translate('common.next') : translate('common.save'); @@ -177,10 +324,12 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat allApprovers={allApprovers} listEmptyContentSubtitle={translate('workflowsPage.emptyContent.expensesFromSubtitle')} allowMultipleSelection + shouldShowTextInput onSelectApprover={toggleMember} footerContent={button} shouldShowLoadingPlaceholder={isLoadingApprovalWorkflow} shouldEnableHeaderMaxHeight + onSearchChange={setSearchSelectorTerm} shouldUpdateFocusedIndex={false} />