From d02a5d5863719f4ccbe60568aca48bda66d1e51e Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 15 Oct 2025 18:24:32 +0200 Subject: [PATCH 01/24] Display domains to members and admins in Workspaces tab --- src/ONYXKEYS.ts | 8 + src/components/Domain/DomainsListRow.tsx | 51 ++++++ src/languages/en.ts | 1 + src/pages/workspace/WorkspacesListPage.tsx | 190 ++++++++++++++------- src/styles/index.ts | 5 + src/types/onyx/Domain.ts | 15 ++ src/types/onyx/index.ts | 2 + 7 files changed, 211 insertions(+), 61 deletions(-) create mode 100644 src/components/Domain/DomainsListRow.tsx create mode 100644 src/types/onyx/Domain.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index ad56a9bc1453..18b392bd4fd9 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -696,6 +696,12 @@ const ONYXKEYS = { /** Stores the information about the state of issuing a new card */ ISSUE_NEW_EXPENSIFY_CARD: 'issueNewExpensifyCard_', + + /** User's domains general information */ + DOMAIN: 'domain_', + + /** Used for identifying user as admin of a domain */ + SHARED_NVP_PRIVATE_DOMAIN_ACCESS: 'sharedNVP_private_admin_access_', }, /** List of Form ids */ @@ -1079,6 +1085,8 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.LAST_SELECTED_EXPENSIFY_CARD_FEED]: OnyxTypes.FundID; [ONYXKEYS.COLLECTION.NVP_EXPENSIFY_ON_CARD_WAITLIST]: OnyxTypes.CardOnWaitlist; [ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD]: OnyxTypes.IssueNewCard; + [ONYXKEYS.COLLECTION.DOMAIN]: OnyxTypes.Domain; + [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_ACCESS]: boolean; }; type OnyxValuesMapping = { diff --git a/src/components/Domain/DomainsListRow.tsx b/src/components/Domain/DomainsListRow.tsx new file mode 100644 index 000000000000..9b1f72b5fe09 --- /dev/null +++ b/src/components/Domain/DomainsListRow.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import {View} from 'react-native'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import TextWithTooltip from '@components/TextWithTooltip'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +type DomainsListRowProps = { + /** Name of the domain */ + title: string; + + /** Whether the row is hovered so we can modify its style */ + isHovered: boolean; +}; + +function DomainsListRow({title, isHovered}: DomainsListRowProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + + return ( + + + + + + + + + + + ); +} + +DomainsListRow.displayName = 'DomainsListRow'; + +export default DomainsListRow; diff --git a/src/languages/en.ts b/src/languages/en.ts index 5dae3eaf34a4..e14652dbdffd 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -667,6 +667,7 @@ const translations = { pinned: 'Pinned', read: 'Read', copyToClipboard: 'Copy to clipboard', + domains: 'Domains', }, supportalNoAccess: { title: 'Not so fast', diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 8b9ec811b53c..d93ab43561d3 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -1,10 +1,12 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; +import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {FlatList, InteractionManager, View} from 'react-native'; +import {InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; +import DomainsListRow from '@components/Domain/DomainsListRow'; import EmptyStateComponent from '@components/EmptyStateComponent'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -20,7 +22,9 @@ import {PressableWithoutFeedback} from '@components/Pressable'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import SearchBar from '@components/SearchBar'; -import type {ListItem} from '@components/SelectionListWithSections/types'; +import SelectionList from '@components/SelectionListWithSections'; +import RadioListItem from '@components/SelectionListWithSections/RadioListItem'; +import type {ListItem, SectionListDataType, SelectionListHandle} from '@components/SelectionListWithSections/types'; import WorkspaceRowSkeleton from '@components/Skeletons/WorkspaceRowSkeleton'; import Text from '@components/Text'; import useCardFeeds from '@hooks/useCardFeeds'; @@ -37,6 +41,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolationOfWorkspace from '@hooks/useTransactionViolationOfWorkspace'; import {isConnectionInProgress} from '@libs/actions/connections'; +import {openOldDotLink} from '@libs/actions/Link'; import {clearWorkspaceOwnerChangeFlow, requestWorkspaceOwnerChange} from '@libs/actions/Policy/Member'; import {calculateBillNewDot, clearDeleteWorkspaceError, clearDuplicateWorkspace, clearErrors, deleteWorkspace, leaveWorkspace, removeWorkspace} from '@libs/actions/Policy/Policy'; import {callFunctionIfActionIsAllowed} from '@libs/actions/Session'; @@ -79,7 +84,12 @@ type WorkspaceItem = ListItem & }; // eslint-disable-next-line react/no-unused-prop-types -type GetMenuItem = {item: WorkspaceItem; index: number}; +type GetWorkspaceMenuItem = {item: WorkspaceItem; index: number}; + +type DomainItem = ListItem & {title: string; action: () => void; disabled: boolean}; + +// eslint-disable-next-line react/no-unused-prop-types +type GetDomainMenuItem = {item: DomainItem; index: number}; /** * Dismisses the errors on one item @@ -120,6 +130,9 @@ function WorkspacesListPage() { const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); const [reimbursementAccountError] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {canBeMissing: true, selector: reimbursementAccountErrorSelector}); + const [allDomains] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN, {canBeMissing: false}); + const [domainAccess] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_ACCESS, {canBeMissing: false}); + // This hook preloads the screens of adjacent tabs to make changing tabs faster. usePreloadFullScreenNavigators(); @@ -142,7 +155,7 @@ function WorkspacesListPage() { selector: filterInactiveCards, canBeMissing: true, }); - const flatlistRef = useRef(null); + const listRef = useRef(null); const [lastAccessedWorkspacePolicyID] = useOnyx(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, {canBeMissing: true}); // This will be fixed as part of https://github.com/Expensify/Expensify/issues/507850 @@ -201,8 +214,8 @@ function WorkspacesListPage() { /** * Gets the menu item for each workspace */ - const getMenuItem = useCallback( - ({item, index}: GetMenuItem) => { + const getWorkspaceMenuItem = useCallback( + ({item, index}: GetWorkspaceMenuItem) => { const isAdmin = isPolicyAdmin(item as unknown as PolicyType, session?.email); const isOwner = item.ownerAccountID === session?.accountID; const isDefault = activePolicyID === item.policyID; @@ -346,6 +359,35 @@ function WorkspacesListPage() { ], ); + /** + * Gets the menu item for each domain + */ + const getDomainMenuItem = useCallback( + ({item, index}: GetDomainMenuItem) => ( + + + {({hovered}) => ( + + )} + + + ), + [styles], + ); + const navigateToWorkspace = useCallback( (policyID: string) => { // On the wide layout, we always want to open the Profile page when opening workspace settings from the list @@ -358,6 +400,8 @@ function WorkspacesListPage() { [shouldUseNarrowLayout], ); + const navigateToDomain = useCallback(() => openOldDotLink(CONST.OLDDOT_URLS.ADMIN_DOMAINS_URL), []); + /** * Add free policies (workspaces) to the list of menu items and returns the list of menu items */ @@ -424,13 +468,26 @@ function WorkspacesListPage() { const sortWorkspace = useCallback((workspaceItems: WorkspaceItem[]) => workspaceItems.sort((a, b) => localeCompare(a.title, b.title)), [localeCompare]); const [inputValue, setInputValue, filteredWorkspaces] = useSearchResults(workspaces, filterWorkspace, sortWorkspace); + const domains = useMemo( + () => + Object.values(allDomains ?? {}) + .filter((domain) => domain !== undefined) + .map((domain) => ({ + title: Str.extractEmailDomain(domain.email), + action: navigateToDomain, + disabled: !domainAccess?.[`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_ACCESS}${domain.accountID}`], + pendingAction: domain.pendingAction, + })) satisfies DomainItem[], + [navigateToDomain, allDomains, domainAccess], + ); + useEffect(() => { if (isEmptyObject(duplicateWorkspace) || !filteredWorkspaces.length || !isFocused) { return; } const duplicateWorkspaceIndex = filteredWorkspaces.findIndex((workspace) => workspace.policyID === duplicateWorkspace.policyID); if (duplicateWorkspaceIndex > 0) { - flatlistRef.current?.scrollToIndex({index: duplicateWorkspaceIndex, animated: false}); + listRef.current?.scrollToIndex(duplicateWorkspaceIndex); // eslint-disable-next-line deprecation/deprecation InteractionManager.runAfterInteractions(() => { clearDuplicateWorkspace(); @@ -438,47 +495,50 @@ function WorkspacesListPage() { } }, [duplicateWorkspace, isFocused, filteredWorkspaces]); - const listHeaderComponent = ( - <> - {isLessThanMediumScreen && } - {workspaces.length > CONST.SEARCH_ITEM_LIMIT && ( - 0} - /> - )} - {!isLessThanMediumScreen && filteredWorkspaces.length > 0 && ( - - - - {translate('workspace.common.workspaceName')} - - - - - {translate('workspace.common.workspaceOwner')} - - - - - {translate('workspace.common.workspaceType')} - + const getListHeaderComponent = useCallback( + () => ( + <> + {isLessThanMediumScreen && } + {workspaces.length > CONST.SEARCH_ITEM_LIMIT && ( + 0} + /> + )} + {!isLessThanMediumScreen && filteredWorkspaces.length > 0 && ( + + + + {translate('workspace.common.workspaceName')} + + + + + {translate('workspace.common.workspaceOwner')} + + + + + {translate('workspace.common.workspaceType')} + + + - - - )} - + )} + + ), + [filteredWorkspaces, inputValue, isLessThanMediumScreen, setInputValue, styles, translate, workspaces], ); const getHeaderButton = () => ( @@ -498,7 +558,20 @@ function WorkspacesListPage() { useHandleBackButton(onBackButtonPress); - if (isEmptyObject(workspaces)) { + const sections = useMemo( + () => + [ + {data: filteredWorkspaces, renderItem: getWorkspaceMenuItem, CustomSectionHeader: getListHeaderComponent}, + { + data: domains, + title: translate('common.domains'), + renderItem: getDomainMenuItem, + }, + ] as Array>, + [domains, filteredWorkspaces, getDomainMenuItem, getListHeaderComponent, getWorkspaceMenuItem, translate], + ); + + if (isEmptyObject(workspaces) && isEmptyObject(domains)) { return ( {!shouldUseNarrowLayout && {getHeaderButton()}} - {shouldUseNarrowLayout && {getHeaderButton()}} - { - flatlistRef.current?.scrollToOffset({ - offset: info.averageItemLength * info.index, - animated: true, - }); - }} - renderItem={getMenuItem} - ListHeaderComponent={listHeaderComponent} - keyboardShouldPersistTaps="handled" + {shouldUseNarrowLayout && {getHeaderButton()}} + {}} + ListItem={RadioListItem} /> marginHorizontal: 20, marginBottom: 20, }, + domainIcon: { + backgroundColor: theme.border, + padding: 10, + borderRadius: 8, + }, }) satisfies StaticStyles; const dynamicStyles = (theme: ThemeColors) => diff --git a/src/types/onyx/Domain.ts b/src/types/onyx/Domain.ts new file mode 100644 index 000000000000..3069c84f9b36 --- /dev/null +++ b/src/types/onyx/Domain.ts @@ -0,0 +1,15 @@ +import type * as OnyxCommon from './OnyxCommon'; + +/** Model of domain data */ +type Domain = OnyxCommon.OnyxValueWithOfflineFeedback<{ + /** The accountID of the account representing the domain, used as a unique identifier for a domain */ + accountID: number; + + /** The name of the domain prefixed with +@ */ + email: string; + + /** Whether the domain has been validated by adding a DNS record */ + validated: boolean; +}>; + +export default Domain; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 49071257e590..74e2a887c421 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -28,6 +28,7 @@ import type CustomStatusDraft from './CustomStatusDraft'; import type {OutstandingReportsByPolicyIDDerivedValue, ReportAttributesDerivedValue, ReportTransactionsAndViolationsDerivedValue} from './DerivedValues'; import type DismissedProductTraining from './DismissedProductTraining'; import type DismissedReferralBanners from './DismissedReferralBanners'; +import type Domain from './Domain'; import type Download from './Download'; import type DuplicateWorkspace from './DuplicateWorkspace'; import type ExpensifyCardBankAccountMetadata from './ExpensifyCardBankAccountMetadata'; @@ -283,4 +284,5 @@ export type { BillingReceiptDetails, ExportTemplate, HybridApp, + Domain, }; From 52b6cd9064db452e833ad05683b71b5fcc1ddd00 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 15 Oct 2025 18:35:42 +0200 Subject: [PATCH 02/24] Tiny fix --- src/components/Domain/DomainsListRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Domain/DomainsListRow.tsx b/src/components/Domain/DomainsListRow.tsx index 9b1f72b5fe09..7f826dc13be2 100644 --- a/src/components/Domain/DomainsListRow.tsx +++ b/src/components/Domain/DomainsListRow.tsx @@ -10,7 +10,7 @@ type DomainsListRowProps = { /** Name of the domain */ title: string; - /** Whether the row is hovered so we can modify its style */ + /** Whether the row is hovered, so we can modify its style */ isHovered: boolean; }; From b25da55abc157a446c113ff7c615215d52d42ef8 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 16 Oct 2025 12:33:20 +0200 Subject: [PATCH 03/24] Update section title spacing --- src/pages/workspace/WorkspacesListPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 152a3d0712ea..96161aa4a21f 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -676,7 +676,7 @@ function WorkspacesListPage() { ref={listRef} sections={sections} shouldShowListEmptyContent - sectionTitleStyles={[styles.ph5, styles.pb3, styles.mb0]} + sectionTitleStyles={[styles.ph5, styles.pb5, styles.mt0, styles.mb0, styles.pt3]} onSelectRow={() => {}} ListItem={RadioListItem} /> From 6cccf3aaa3ebe344d13da999f7f8c13a000460f0 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 16 Oct 2025 13:21:54 +0200 Subject: [PATCH 04/24] Implement workspaces empty state when domains exist --- src/pages/workspace/WorkspacesListPage.tsx | 152 +++++++++++---------- 1 file changed, 79 insertions(+), 73 deletions(-) diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 96161aa4a21f..a054ec4d0f24 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -541,59 +541,78 @@ function WorkspacesListPage() { } }, [duplicateWorkspace, isFocused, filteredWorkspaces]); - const getListHeaderComponent = useCallback( - () => ( - <> - {isLessThanMediumScreen && } - {workspaces.length > CONST.SEARCH_ITEM_LIMIT && ( - 0} - /> - )} - {!isLessThanMediumScreen && filteredWorkspaces.length > 0 && ( - - - - {translate('workspace.common.workspaceName')} - - - - - {translate('workspace.common.workspaceOwner')} - - - - - {translate('workspace.common.workspaceType')} - - - + const getListHeaderComponent = () => ( + <> + {isLessThanMediumScreen && } + {workspaces.length > CONST.SEARCH_ITEM_LIMIT && ( + 0} + /> + )} + {!isLessThanMediumScreen && filteredWorkspaces.length > 0 && ( + + + + {translate('workspace.common.workspaceName')} + - )} - - ), - [filteredWorkspaces, inputValue, isLessThanMediumScreen, setInputValue, styles, translate, workspaces], + + + {translate('workspace.common.workspaceOwner')} + + + + + {translate('workspace.common.workspaceType')} + + + + + )} + ); - const getHeaderButton = () => ( -