diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 1942b24fdff7..10eb9020369f 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1168,6 +1168,7 @@ const CONST = { INSIGHTS_PAGE: 'insightsPage', INSIGHTS_COMPARE: 'insightsCompare', PAYMENT_HISTORY: 'paymentHistory', + UNIFIED_CONNECTIONS: 'unifiedConnections', SUPPORT_TICKET: 'supportTicket', ANCHORED_FIELD_DROPDOWNS: 'anchoredFieldDropdowns', RESIZABLE_TABLE_COLUMNS: 'resizableTableColumns', @@ -6938,6 +6939,14 @@ const CONST = { FLAG_FOR_REVIEW: 'flagForReview', AGENTS: 'agents', }, + CONNECTIONS: { + ALL: 'all', + ACCOUNTING: 'accounting', + PEOPLE: 'people', + RECRUITING: 'recruiting', + RECEIPTS: 'receipts', + AI: 'ai', + }, WORKFLOWS_TAB_TYPE: 'workflowsTabType', WORKFLOWS: { SUBMISSIONS: 'submissions', @@ -9259,12 +9268,14 @@ const CONST = { MILEAGE_RATE_AUTO_UPDATED: 'mileageRateAutoUpdated', MARK_ALL_AS_READ: 'markAllAsRead', ACCOUNT_MOVED_TO_TOP_BAR: 'accountMovedToTopBar', + CONNECTIONS_MOVED: 'connectionsMoved', REQUIRE_FIELDS_RULE_RECEIPT_COUPLING_TOOLTIP: 'requireFieldsRuleReceiptCouplingTooltip', REQUIRE_FIELDS_RULE_ITEMIZED_RECEIPT_COUPLING_TOOLTIP: 'requireFieldsRuleItemizedReceiptCouplingTooltip', }, PRODUCT_TRAINING_TOOLTIP_REAPPEAR_WINDOW: { SEVEN_DAYS: 7 * 24 * 60 * 60 * 1000, }, + INBOX_TAB_STALE_UNREAD_MONTHS: 3, CHANGE_POLICY_TRAINING_MODAL: 'changePolicyModal', AGENTS_RULES_BANNER: 'agentsRulesBanner', @@ -10037,6 +10048,7 @@ const CONST = { HR: 'WorkspaceInitial-HR', RECRUITING: 'WorkspaceInitial-Recruiting', RECEIPT_PARTNERS: 'WorkspaceInitial-ReceiptPartners', + CONNECTIONS: 'WorkspaceInitial-Connections', CATEGORIES: 'WorkspaceInitial-Categories', TAGS: 'WorkspaceInitial-Tags', TAXES: 'WorkspaceInitial-Taxes', @@ -10122,6 +10134,11 @@ const CONST = { CARD_SECTION_ADD_BUTTON: 'WorkspaceAccounting-CardSectionAddButton', THREE_DOT_MENU: 'WorkspaceAccounting-ThreeDotMenu', }, + CONNECTIONS: { + CARD: 'WorkspaceConnections-Card', + CONFIGURE_BUTTON: 'WorkspaceConnections-ConfigureButton', + SUGGEST_INTEGRATION: 'WorkspaceConnections-SuggestIntegration', + }, RULES: { ADD_SPEND_RULE: 'WorkspaceRules-AddSpendRule', INDIVIDUAL_EXPENSES_MENU_ITEM: 'WorkspaceRules-IndividualExpensesMenuItem', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 3f7300a0957d..51eeeccac2f7 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -714,11 +714,16 @@ const DYNAMIC_ROUTES = { }, SAGE_INTACCT_PREREQUISITES: { path: 'sage-intacct/prerequisites', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.ACCOUNTING.EXISTING_SAGE_INTACCT_CONNECTIONS], + entryScreens: [ + SCREENS.WORKSPACE.ACCOUNTING.ROOT, + SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING, + SCREENS.WORKSPACE.CONNECTIONS, + SCREENS.WORKSPACE.ACCOUNTING.EXISTING_SAGE_INTACCT_CONNECTIONS, + ], }, POLICY_ACCOUNTING_CERTINIA_EXPORT: { path: 'certinia/export', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING], }, POLICY_ACCOUNTING_CERTINIA_PREFERRED_EXPORTER: { path: 'certinia-preferred-exporter/select', @@ -738,7 +743,7 @@ const DYNAMIC_ROUTES = { }, POLICY_ACCOUNTING_CERTINIA_ADVANCED: { path: 'certinia/advanced', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING], }, POLICY_ACCOUNTING_CERTINIA_FX_EXPENSE_ACCOUNT: { path: 'certinia-fx-expense-account/select', @@ -750,7 +755,7 @@ const DYNAMIC_ROUTES = { }, POLICY_ACCOUNTING_CERTINIA_COMPANY_SELECTOR: { path: 'certinia/company', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING], }, POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_VENDOR_SELECT: { path: 'vendor/select', @@ -766,7 +771,7 @@ const DYNAMIC_ROUTES = { }, POLICY_ACCOUNTING_NETSUITE_EXPORT: { path: 'connections/netsuite/export', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING, SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT], }, POLICY_ACCOUNTING_NETSUITE_RECEIVABLE_ACCOUNT_SELECT: { path: 'receivable-account/select', @@ -819,7 +824,7 @@ const DYNAMIC_ROUTES = { }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT: { path: 'quickbooks-online/export', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING, SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT], }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT: { path: 'company-card-expense-account', @@ -835,7 +840,7 @@ const DYNAMIC_ROUTES = { }, POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT: { path: 'quickbooks-desktop/export', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING, SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT], }, POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT: { path: 'qbd-company-card-expense-account', @@ -925,7 +930,7 @@ const DYNAMIC_ROUTES = { }, POLICY_ACCOUNTING_XERO_EXPORT: { path: 'xero/export', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING, SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT], }, POLICY_ACCOUNTING_XERO_PREFERRED_EXPORTER_SELECT: { path: 'xero-preferred-exporter/select', @@ -961,7 +966,7 @@ const DYNAMIC_ROUTES = { }, WORKSPACE_ACCOUNTING_QUICKBOOKS_DESKTOP_ADVANCED: { path: 'quickbooks-desktop/advanced', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING], }, POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_FX_EXPENSE_ACCOUNT_SELECT: { path: 'qbd-fx-expense-account-select', @@ -1181,7 +1186,7 @@ const DYNAMIC_ROUTES = { }, POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT: { path: 'sage-intacct/export', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING, SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT], }, WORKSPACE_OVERVIEW_PLAN: { path: 'plan', @@ -1306,14 +1311,34 @@ const DYNAMIC_ROUTES = { // suffix inherits their `:policyID` (same as WORKSPACE_INVITE above). path: 'hr-sync-results', // The results screen opens automatically when an HR sync finishes, and a sync can complete - // while the user is on either the HR page or the members list, so both are entry screens. - entryScreens: [SCREENS.WORKSPACE.HR, SCREENS.WORKSPACE.MEMBERS], + // while the user is on the HR page, the Connections page or its HR panel, or the members list. + // With the unified connections beta, the Connections page also renders on the old integration routes. + entryScreens: [ + SCREENS.WORKSPACE.HR, + SCREENS.WORKSPACE.CONNECTIONS, + SCREENS.WORKSPACE.CONNECTIONS_HR, + SCREENS.WORKSPACE.MEMBERS, + SCREENS.WORKSPACE.ACCOUNTING.ROOT, + SCREENS.WORKSPACE.RECRUITING, + SCREENS.WORKSPACE.RECEIPT_PARTNERS, + SCREENS.WORKSPACE.MCP, + ], }, WORKSPACE_RECRUITING_SYNC_RESULTS: { // The results screen opens automatically when a recruiting sync finishes, and a sync can complete - // while the user is on either the recruiting page or the members list, so both are entry screens. + // while the user is on the recruiting page, the Connections page or its recruiting panel, or the members list. + // With the unified connections beta, the Connections page also renders on the old integration routes. path: 'recruiting-sync-results', - entryScreens: [SCREENS.WORKSPACE.RECRUITING, SCREENS.WORKSPACE.MEMBERS], + entryScreens: [ + SCREENS.WORKSPACE.RECRUITING, + SCREENS.WORKSPACE.CONNECTIONS, + SCREENS.WORKSPACE.CONNECTIONS_RECRUITING, + SCREENS.WORKSPACE.MEMBERS, + SCREENS.WORKSPACE.ACCOUNTING.ROOT, + SCREENS.WORKSPACE.HR, + SCREENS.WORKSPACE.RECEIPT_PARTNERS, + SCREENS.WORKSPACE.MCP, + ], }, WORKSPACE_OWNER_CHANGE_CHECK: { path: 'change-owner/:policyID/:accountID/:error', @@ -1336,12 +1361,12 @@ const DYNAMIC_ROUTES = { }, WORKSPACE_RECEIPT_PARTNERS_INVITE_EDIT: { path: ':integration/invite/edit', - entryScreens: [SCREENS.WORKSPACE.RECEIPT_PARTNERS], + entryScreens: [SCREENS.WORKSPACE.RECEIPT_PARTNERS, SCREENS.WORKSPACE.CONNECTIONS_RECEIPT_PARTNERS], getRoute: (integration: string) => `${integration}/invite/edit` as const, }, WORKSPACE_RECEIPT_PARTNERS_INVITE: { path: ':integration/invite', - entryScreens: [SCREENS.WORKSPACE.RECEIPT_PARTNERS], + entryScreens: [SCREENS.WORKSPACE.RECEIPT_PARTNERS, SCREENS.WORKSPACE.CONNECTIONS_RECEIPT_PARTNERS], getRoute: (integration: string) => `${integration}/invite` as const, }, EXPENSIFY_CARD_DETAILS: { @@ -3522,6 +3547,71 @@ const ROUTES = { return `workspaces/${policyID}/accounting${queryParams}` as const; }, }, + WORKSPACE_CONNECTIONS: { + route: 'workspaces/:policyID/connections', + getRoute: ( + policyID: string | undefined, + newConnectionName?: ConnectionName, + integrationToDisconnect?: ConnectionName, + shouldDisconnectIntegrationBeforeConnecting?: boolean, + isIntuitEnterpriseSuite?: boolean, + ) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the WORKSPACE_CONNECTIONS route'); + } + + let queryParams = ''; + if (newConnectionName) { + queryParams += `?newConnectionName=${newConnectionName}`; + if (integrationToDisconnect) { + queryParams += `&integrationToDisconnect=${integrationToDisconnect}`; + } + if (shouldDisconnectIntegrationBeforeConnecting !== undefined) { + queryParams += `&shouldDisconnectIntegrationBeforeConnecting=${shouldDisconnectIntegrationBeforeConnecting}`; + } + if (isIntuitEnterpriseSuite !== undefined) { + queryParams += `&isIntuitEnterpriseSuite=${isIntuitEnterpriseSuite}`; + } + } + return `workspaces/${policyID}/connections${queryParams}` as const; + }, + }, + WORKSPACE_CONNECTIONS_ACCOUNTING: { + route: 'workspaces/:policyID/connections/accounting', + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the WORKSPACE_CONNECTIONS_ACCOUNTING route'); + } + return `workspaces/${policyID}/connections/accounting` as const; + }, + }, + WORKSPACE_CONNECTIONS_HR: { + route: 'workspaces/:policyID/connections/hr', + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the WORKSPACE_CONNECTIONS_HR route'); + } + return `workspaces/${policyID}/connections/hr` as const; + }, + }, + WORKSPACE_CONNECTIONS_RECRUITING: { + route: 'workspaces/:policyID/connections/recruiting', + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the WORKSPACE_CONNECTIONS_RECRUITING route'); + } + return `workspaces/${policyID}/connections/recruiting` as const; + }, + }, + WORKSPACE_CONNECTIONS_RECEIPT_PARTNERS: { + route: 'workspaces/:policyID/connections/receipt-partners', + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the WORKSPACE_CONNECTIONS_RECEIPT_PARTNERS route'); + } + return `workspaces/${policyID}/connections/receipt-partners` as const; + }, + }, WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED: { route: 'workspaces/:policyID/accounting/quickbooks-online/advanced', getRoute: (policyID: string | undefined) => { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 1871d9a129b5..fbdaa24eedc0 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -985,6 +985,11 @@ const SCREENS = { DYNAMIC_WORKSPACE_DOWNGRADE: 'Dynamic_Workspace_Downgrade', DYNAMIC_PAY_AND_DOWNGRADE: 'Dynamic_Workspace_Pay_And_Downgrade', MCP: 'Workspace_MCP', + CONNECTIONS: 'Workspace_Connections', + CONNECTIONS_ACCOUNTING: 'Workspace_Connections_Accounting', + CONNECTIONS_HR: 'Workspace_Connections_HR', + CONNECTIONS_RECRUITING: 'Workspace_Connections_Recruiting', + CONNECTIONS_RECEIPT_PARTNERS: 'Workspace_Connections_Receipt_Partners', RECEIPT_PARTNERS: 'Workspace_Receipt_Partners', DYNAMIC_RECEIPT_PARTNERS_INVITE: 'Dynamic_Workspace_Receipt_Partners_Invite', DYNAMIC_RECEIPT_PARTNERS_INVITE_EDIT: 'Dynamic_Workspace_Receipt_Partners_Invite_Edit', diff --git a/src/components/ConnectToSageIntacctFlow/index.tsx b/src/components/ConnectToSageIntacctFlow/index.tsx index 9cd1d1434f12..73ff7e5d307e 100644 --- a/src/components/ConnectToSageIntacctFlow/index.tsx +++ b/src/components/ConnectToSageIntacctFlow/index.tsx @@ -1,9 +1,12 @@ import useHasReusablePoliciesConnectedTo from '@hooks/useHasReusablePoliciesConnectedTo'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import useOnyx from '@hooks/useOnyx'; import {isAuthenticationError} from '@libs/actions/connections'; import Navigation from '@libs/Navigation/Navigation'; +import {getAccountingConnectionsRoute} from '@pages/workspace/connections/utils'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -18,6 +21,7 @@ function ConnectToSageIntacctFlow({policyID}: ConnectToSageIntacctFlowProps) { const hasReusablePoliciesConnectedToSageIntacct = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, policyID); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const shouldGoToEnterCredentials = isAuthenticationError(policy, CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); useEffect(() => { if (shouldGoToEnterCredentials) { @@ -25,7 +29,7 @@ function ConnectToSageIntacctFlow({policyID}: ConnectToSageIntacctFlowProps) { return; } if (!hasReusablePoliciesConnectedToSageIntacct) { - Navigation.navigate(`${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.SAGE_INTACCT_PREREQUISITES.path}`); + Navigation.navigate(`${getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID)}/${DYNAMIC_ROUTES.SAGE_INTACCT_PREREQUISITES.path}`); return; } Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXISTING_CONNECTIONS.getRoute(policyID)); diff --git a/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts index 9d62d4bc70d3..a080dbe0b495 100644 --- a/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts +++ b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts @@ -19,6 +19,7 @@ const WIDE_LAYOUT_INACTIVE_SCREENS: string[] = [ SCREENS.WORKSPACES_LIST, SCREENS.SETTINGS.SUBSCRIPTION.ROOT, SCREENS.WORKSPACE.ACCOUNTING.ROOT, + SCREENS.WORKSPACE.CONNECTIONS, SCREENS.WORKSPACE.INITIAL, SCREENS.WORKSPACE.PROFILE, SCREENS.WORKSPACE.WORKFLOWS, diff --git a/src/components/MenuItem/MenuItem.tsx b/src/components/MenuItem/MenuItem.tsx index 526699a121ff..21dc75bd995f 100644 --- a/src/components/MenuItem/MenuItem.tsx +++ b/src/components/MenuItem/MenuItem.tsx @@ -58,7 +58,7 @@ import type WithSentryLabel from '@src/types/utils/SentryLabel'; import type {ImageContentFit} from 'expo-image'; import type {ComponentRef, ReactElement, ReactNode, Ref} from 'react'; -import type {GestureResponderEvent, Role, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {GestureResponderEvent, ImageStyle, Role, StyleProp, TextStyle, ViewStyle} from 'react-native'; import type {AnimatedStyle} from 'react-native-reanimated'; import type {ValueOf} from 'type-fest'; @@ -175,6 +175,9 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Optional component to render before the description text (e.g. a badge pill) */ descriptionAddon?: ReactNode; + /** Content shown right after the title, on the same line */ + titleAddon?: ReactNode; + /** Text to show below menu item. This text is not interactive */ helperText?: string; @@ -238,6 +241,9 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Prop to represent the size of the avatar images to be shown */ avatarSize?: ValueOf; + /** Additional styles for the avatar image when the icon type is an avatar */ + avatarImageStyles?: StyleProp; + /** Affects avatar size */ viewMode?: ValueOf; @@ -355,6 +361,12 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Whether the tooltip should hide on scroll */ shouldHideOnScroll?: boolean; + /** Whether the tooltip should stay hidden on first display when the item starts off screen */ + shouldCheckTooltipVisibilityOnFirstDisplay?: boolean; + + /** Whether the tooltip should hide when the screen loses navigation focus */ + shouldHideTooltipOnNavigate?: boolean; + shouldShowLoadingSpinnerIcon?: boolean; shouldShowRadioButton?: boolean; @@ -464,6 +476,7 @@ function MenuItem({ furtherDetailsComponent, description, descriptionAddon, + titleAddon, helperText, helperTextStyle, errorText, @@ -494,6 +507,7 @@ function MenuItem({ leftComponent, rightIconReportID, avatarSize = CONST.AVATAR_SIZE.DEFAULT, + avatarImageStyles, isSmallAvatarSubscriptMenu = false, brickRoadIndicator, shouldRenderAsHTML = false, @@ -525,6 +539,8 @@ function MenuItem({ shouldRenderTooltip = false, shouldDisplayEducationalTooltip, shouldHideOnScroll = false, + shouldCheckTooltipVisibilityOnFirstDisplay = false, + shouldHideTooltipOnNavigate = true, tooltipAnchorAlignment, tooltipWrapperStyle = {}, tooltipShiftHorizontal = 0, @@ -785,6 +801,8 @@ function MenuItem({ shouldTeleportPortalToModalLayer={shouldTeleportPortalToModalLayer} onTooltipPress={onEducationTooltipPress} shouldHideOnScroll={shouldHideOnScroll} + shouldCheckVisibilityOnFirstDisplay={shouldCheckTooltipVisibilityOnFirstDisplay} + shouldHideOnNavigate={shouldHideTooltipOnNavigate} > )} + {titleAddon} )} {!shouldShowDescriptionOnTop && renderDescriptionView()} diff --git a/src/components/ProductTrainingContext/TOOLTIPS.ts b/src/components/ProductTrainingContext/TOOLTIPS.ts index 84c533803c63..275ceb642b5d 100644 --- a/src/components/ProductTrainingContext/TOOLTIPS.ts +++ b/src/components/ProductTrainingContext/TOOLTIPS.ts @@ -16,6 +16,7 @@ const { MILEAGE_RATE_AUTO_UPDATED, MARK_ALL_AS_READ, ACCOUNT_MOVED_TO_TOP_BAR, + CONNECTIONS_MOVED, } = CONST.PRODUCT_TRAINING_TOOLTIP_NAMES; type ProductTrainingTooltipName = Exclude< @@ -114,6 +115,14 @@ const TOOLTIPS: Record = { priority: 1700, shouldShow: () => true, }, + [CONNECTIONS_MOVED]: { + content: 'productTrainingTooltip.connectionsMoved', + onHideTooltip: (isDismissedUsingCloseButton = false) => dismissProductTraining(CONNECTIONS_MOVED, isDismissedUsingCloseButton), + name: CONNECTIONS_MOVED, + priority: 1650, + // The workspace menu limits it to members who can read Connections + shouldShow: () => true, + }, }; export default TOOLTIPS; diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index 17f778062bce..219e9cb181b5 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -10,6 +10,7 @@ import TextWithTooltip from '@components/TextWithTooltip'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -78,6 +79,7 @@ const SEARCH_ROUTER_ICON_NAMES = [ 'Users', 'Hashtag', 'Sync', + 'Connect', 'Briefcase', 'Tag', 'Coins', @@ -143,6 +145,9 @@ type BuildWorkspaceNavigationItemsParams = { /** Whether the Merge ATS beta gating the Recruiting feature is enabled. */ isRecruitingBetaEnabled: boolean; + /** Whether the unified Connections beta replaces the separate connection pages. */ + isUnifiedConnectionsBetaEnabled: boolean; + /** Whether navigation should use the narrow-layout Workspace flow. */ shouldUseNarrowLayout: boolean; @@ -291,6 +296,7 @@ function buildWorkspaceNavigationItems({ isOffline, isVendorMatchingBetaEnabled, isRecruitingBetaEnabled, + isUnifiedConnectionsBetaEnabled, shouldUseNarrowLayout, convertToDisplayString, getItemText, @@ -310,6 +316,7 @@ function buildWorkspaceNavigationItems({ policyCategories: policyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy.id}`], isVendorMatchingBetaEnabled, isRecruitingBetaEnabled, + isUnifiedConnectionsBetaEnabled, convertToDisplayString, }); @@ -321,7 +328,11 @@ function buildWorkspaceNavigationItems({ action: () => navigateToWorkspaceSettingsRoute(item.getRoute(), policy.id, shouldUseNarrowLayout, item.screenName), keyForList: `workspace_${policy.id}_${item.screenName}`, rightElement: , - matchTerms: item.screenName === SCREENS.WORKSPACE.PROFILE ? [itemText, policy.name] : [itemText], + matchTerms: [ + itemText, + ...(item.screenName === SCREENS.WORKSPACE.PROFILE ? [policy.name] : []), + ...(item.searchAliasKeys ?? []).map((translationKey) => getItemText({...item, translationKey})), + ], sortText: policy.name, }; }); @@ -386,6 +397,7 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) const {convertToDisplayString} = useCurrencyListActions(); const {isOffline} = useNetwork(); const {isBetaEnabled} = usePermissions(); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(SEARCH_ROUTER_ICON_NAMES); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -441,6 +453,7 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) isOffline: !!isOffline, isVendorMatchingBetaEnabled: isBetaEnabled(CONST.BETAS.VENDOR_MATCHING), isRecruitingBetaEnabled: isBetaEnabled(CONST.BETAS.MERGE_ATS), + isUnifiedConnectionsBetaEnabled, shouldUseNarrowLayout, convertToDisplayString, getItemText: (item) => translate(item.translationKey), diff --git a/src/components/TabSelector/TabSelectorBase.tsx b/src/components/TabSelector/TabSelectorBase.tsx index a0aa2bb97d5c..273dee29700c 100644 --- a/src/components/TabSelector/TabSelectorBase.tsx +++ b/src/components/TabSelector/TabSelectorBase.tsx @@ -6,7 +6,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; import type {TabSelectorBaseProps} from './types'; @@ -14,6 +14,7 @@ import getBackgroundColor from './getBackground'; import getOpacity from './getOpacity'; import {useTabSelectorActions, useTabSelectorState} from './TabSelectorContext'; import TabSelectorItem from './TabSelectorItem'; +import TabSelectorScrollFade from './TabSelectorScrollFade'; /** * Navigation-agnostic tab selector UI that renders a row of TabSelectorItem components. @@ -32,6 +33,8 @@ function TabSelectorBase({ shouldShowLabelWhenInactive = true, equalWidth = false, contentContainerStyles, + tabButtonStyles, + shouldShowScrollFade = false, }: TabSelectorBaseProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -47,6 +50,21 @@ function TabSelectorBase({ const activeIndex = tabs.findIndex((tab) => tab.key === activeTabKey); + // The measurements change on every scroll frame, so they live in a ref and only the fade visibility is state + const scrollMetricsRef = useRef({offset: 0, viewportWidth: 0, contentWidth: 0}); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + const updateScrollFade = (metrics: Partial) => { + if (!shouldShowScrollFade) { + return; + } + const {offset, viewportWidth, contentWidth} = {...scrollMetricsRef.current, ...metrics}; + scrollMetricsRef.current = {offset, viewportWidth, contentWidth}; + setCanScrollLeft(offset > 1); + setCanScrollRight(offset + viewportWidth < contentWidth - 1); + }; + // After a tab change, reset affectedAnimatedTabs once the transition is done so // tabs settle back into the default animated state. useEffect(() => { @@ -58,95 +76,105 @@ function TabSelectorBase({ }, [defaultAffectedAnimatedTabs, activeIndex]); return ( - { - onContainerScroll(e); - triggerScrollEvent(); - }} - ref={containerRef} - style={styles.scrollableTabSelector} - // On iOS a horizontal ScrollView lays out its content along an unbounded main axis, so flex-1 tabs - // (equalWidth) divide their intrinsic content width instead of the viewport. Giving the content - // container a definite width lets the flex children split it evenly. Scoped to equalWidth so normal - // overflowing/scrollable tab rows are not constrained. - contentContainerStyle={[styles.tabSelectorContentContainer, equalWidth && styles.w100, contentContainerStyles]} - horizontal - showsHorizontalScrollIndicator={false} - keyboardShouldPersistTaps="handled" - > - {tabs.map((tab, index) => { - const isActive = index === activeIndex; - const activeOpacity = getOpacity({ - routesLength, - tabIndex: index, - active: true, - affectedTabs: affectedAnimatedTabs, - position, - isActive, - }); - const inactiveOpacity = getOpacity({ - routesLength, - tabIndex: index, - active: false, - affectedTabs: affectedAnimatedTabs, - position, - isActive, - }); - const backgroundColor = getBackgroundColor({ - routesLength, - tabIndex: index, - affectedTabs: affectedAnimatedTabs, - theme, - position, - isActive, - }); - - const handlePress = () => { - if (tab.isDisabled) { - tab.disabledAction?.(); - return; - } - if (isActive) { - onActiveTabPress(tab.key); - return; - } - setAffectedAnimatedTabs([activeIndex, index]); - onTabPress(tab.key); - }; - - return ( - onLongTabPress(tab.key) : undefined} - activeOpacity={activeOpacity} - inactiveOpacity={inactiveOpacity} - backgroundColor={backgroundColor} - isActive={isActive} - testID={tab.testID} - sentryLabel={tab.sentryLabel} - shouldShowLabelWhenInactive={shouldShowLabelWhenInactive} - equalWidth={equalWidth} - badgeText={tab.badgeText} - isBadgeCondensed={tab.isBadgeCondensed} - badgeStyles={tab.badgeStyles} - isBadgeError={tab.isBadgeError} - pendingAction={tab.pendingAction} - isDisabled={tab.isDisabled} - disabledAction={tab.disabledAction} - badgeEducationalTooltipProps={tab.badgeEducationalTooltipProps} - /> - ); - })} - + <> + { + onContainerLayout(e); + updateScrollFade({viewportWidth: e.nativeEvent.layout.width}); + }} + onContentSizeChange={(contentWidth) => updateScrollFade({contentWidth})} + onScroll={(e) => { + onContainerScroll(e); + triggerScrollEvent(); + updateScrollFade({offset: e.nativeEvent.contentOffset.x}); + }} + ref={containerRef} + style={styles.scrollableTabSelector} + // On iOS a horizontal ScrollView lays out its content along an unbounded main axis, so flex-1 tabs + // (equalWidth) divide their intrinsic content width instead of the viewport. Giving the content + // container a definite width lets the flex children split it evenly. Scoped to equalWidth so normal + // overflowing/scrollable tab rows are not constrained. + contentContainerStyle={[styles.tabSelectorContentContainer, equalWidth && styles.w100, contentContainerStyles]} + horizontal + showsHorizontalScrollIndicator={false} + keyboardShouldPersistTaps="handled" + > + {tabs.map((tab, index) => { + const isActive = index === activeIndex; + const activeOpacity = getOpacity({ + routesLength, + tabIndex: index, + active: true, + affectedTabs: affectedAnimatedTabs, + position, + isActive, + }); + const inactiveOpacity = getOpacity({ + routesLength, + tabIndex: index, + active: false, + affectedTabs: affectedAnimatedTabs, + position, + isActive, + }); + const backgroundColor = getBackgroundColor({ + routesLength, + tabIndex: index, + affectedTabs: affectedAnimatedTabs, + theme, + position, + isActive, + }); + + const handlePress = () => { + if (tab.isDisabled) { + tab.disabledAction?.(); + return; + } + if (isActive) { + onActiveTabPress(tab.key); + return; + } + setAffectedAnimatedTabs([activeIndex, index]); + onTabPress(tab.key); + }; + + return ( + onLongTabPress(tab.key) : undefined} + activeOpacity={activeOpacity} + inactiveOpacity={inactiveOpacity} + backgroundColor={backgroundColor} + isActive={isActive} + testID={tab.testID} + sentryLabel={tab.sentryLabel} + shouldShowLabelWhenInactive={shouldShowLabelWhenInactive} + equalWidth={equalWidth} + tabButtonStyles={tabButtonStyles} + badgeText={tab.badgeText} + isBadgeCondensed={tab.isBadgeCondensed} + badgeStyles={tab.badgeStyles} + isBadgeError={tab.isBadgeError} + pendingAction={tab.pendingAction} + isDisabled={tab.isDisabled} + disabledAction={tab.disabledAction} + badgeEducationalTooltipProps={tab.badgeEducationalTooltipProps} + /> + ); + })} + + {shouldShowScrollFade && canScrollLeft && } + {shouldShowScrollFade && canScrollRight && } + ); } diff --git a/src/components/TabSelector/TabSelectorItem.tsx b/src/components/TabSelector/TabSelectorItem.tsx index 3c424c580709..4916ee62b4e7 100644 --- a/src/components/TabSelector/TabSelectorItem.tsx +++ b/src/components/TabSelector/TabSelectorItem.tsx @@ -48,6 +48,7 @@ function TabSelectorItem({ disabledAction, pendingAction, badgeEducationalTooltipProps, + tabButtonStyles, }: TabSelectorItemProps) { const {isOffline} = useNetwork(); @@ -76,6 +77,7 @@ function TabSelectorItem({ styles.tabBackground(isHovered, isActive, isDisabled, backgroundColor), styles.userSelectNone, isOfflineWithPendingAction ? styles.offlineFeedbackPending : undefined, + tabButtonStyles, ]} wrapperStyle={equalWidth ? styles.flex1 : styles.flexGrow1} onSecondaryInteraction={isPressableDisabled ? undefined : onLongPress} diff --git a/src/components/TabSelector/TabSelectorScrollFade.tsx b/src/components/TabSelector/TabSelectorScrollFade.tsx new file mode 100644 index 000000000000..fb7455a8c124 --- /dev/null +++ b/src/components/TabSelector/TabSelectorScrollFade.tsx @@ -0,0 +1,62 @@ +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import React, {useId} from 'react'; +import {View} from 'react-native'; +import Svg, {Defs, LinearGradient, Rect, Stop} from 'react-native-svg'; + +type TabSelectorScrollFadeProps = { + /** Which edge of the tab row the fade covers */ + side: 'left' | 'right'; +}; + +/** Fades the tabs out toward an edge so a cut-off tab reads as scrollable */ +function TabSelectorScrollFade({side}: TabSelectorScrollFadeProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const gradientID = useId(); + + return ( + + + + + + + + + + + + ); +} + +export default TabSelectorScrollFade; diff --git a/src/components/TabSelector/types.ts b/src/components/TabSelector/types.ts index 5b0ed3fdf780..02fa391df9fe 100644 --- a/src/components/TabSelector/types.ts +++ b/src/components/TabSelector/types.ts @@ -91,6 +91,12 @@ type TabSelectorBaseProps = { /** Additional styles for the tabs' scroll content container. */ contentContainerStyles?: StyleProp; + + /** Additional styles for each tab button. */ + tabButtonStyles?: StyleProp; + + /** Whether to fade the edges that have more tabs to scroll to. The fades are positioned against the parent view. */ + shouldShowScrollFade?: boolean; }; type TabSelectorItemProps = WithSentryLabel & { @@ -124,6 +130,9 @@ type TabSelectorItemProps = WithSentryLabel & { /** Whether tabs should have equal width */ equalWidth?: boolean; + /** Additional styles for the tab button. */ + tabButtonStyles?: StyleProp; + /** Text to display on the badge on the tab. */ badgeText?: string; diff --git a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx index b19a42c8dbff..2e7d4b889bce 100644 --- a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx +++ b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import type {HostInstance, LayoutRectangle, NativeSyntheticEvent} from 'react-native'; import {NavigationContext, useIsFocused} from '@react-navigation/native'; -import React, {memo, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState} from 'react'; +import React, {memo, useCallback, useContext, useEffect, useEffectEvent, useLayoutEffect, useRef, useState} from 'react'; import {DeviceEventEmitter, Dimensions} from 'react-native'; import measureTooltipCoordinate, {getTooltipCoordinates} from './measureTooltipCoordinate'; @@ -29,6 +29,7 @@ function BaseEducationalTooltip({ shouldDisplayTooltip, shouldHideOnNavigate = true, shouldHideOnScroll = false, + shouldCheckVisibilityOnFirstDisplay = false, uniqueID, ...props }: EducationalTooltipProps) { @@ -151,6 +152,14 @@ function BaseEducationalTooltip({ }; }, []); + const showOnFirstDisplay = useEffectEvent(() => { + if (shouldCheckVisibilityOnFirstDisplay) { + renderTooltip(); + return; + } + show.current?.(); + }); + useEffect(() => { if (!shouldMeasure || shouldSuppressTooltip || !shouldShowTooltip) { return; @@ -161,7 +170,7 @@ function BaseEducationalTooltip({ } // When tooltip is used inside an animated view (e.g. popover), we need to wait for the animation to finish before measuring content. const timerID = setTimeout(() => { - show.current?.(); + showOnFirstDisplay(); // Mark the first display as done only once it has actually happened, so paths that re-measure // on a later layout (e.g. rotation) don't fire against a still-animating layout beforehand. hasDisplayedTooltipRef.current = true; diff --git a/src/components/Tooltip/types.ts b/src/components/Tooltip/types.ts index b7a758d207ff..a0eeceae473c 100644 --- a/src/components/Tooltip/types.ts +++ b/src/components/Tooltip/types.ts @@ -95,6 +95,9 @@ type EducationalTooltipProps = ChildrenProps & /** Whether the tooltip should hide during scrolling */ shouldHideOnScroll?: boolean; + /** Whether the first display should stay hidden when the anchor starts off screen. Only applies with `shouldHideOnScroll`. */ + shouldCheckVisibilityOnFirstDisplay?: boolean; + uniqueID?: string; }; diff --git a/src/hooks/useDynamicForwardPath.ts b/src/hooks/useDynamicForwardPath.ts index 21438548c21c..50e5a2f70291 100644 --- a/src/hooks/useDynamicForwardPath.ts +++ b/src/hooks/useDynamicForwardPath.ts @@ -36,6 +36,7 @@ const FORWARD_TO_MAPPINGS: Record(data: TValue[]) => data; * @param filterData - Predicate that decides whether a datum matches the current search input. * @param sortData - Optional comparator used to sort the filtered results. Defaults to identity (no sort). * @param preFilter - Optional filter applied before text search (e.g. group/category filter). + * @returns The search input, its setter, the results, and the search text the results were filtered by. That last one lags the input by the debounce window. */ function useSearchResults( data: TValue[], @@ -40,7 +41,7 @@ function useSearchResults( setInputValue(''); }, [data.length, prevData.length, setInputValue]); - return [inputValue, setInputValue, result] as const; + return [inputValue, setInputValue, result, searchQuery] as const; } export default useSearchResults; diff --git a/src/languages/de.ts b/src/languages/de.ts index 6cb0afe249fc..4e146fc297c1 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -4988,6 +4988,7 @@ ${amount} für ${merchant} – ${date}`, readOnlyActionTitle: 'Nicht so schnell …', readOnlyActionPrompt: 'Ihre Arbeitsbereichsrolle kann diese Einstellungen anzeigen, aber nicht bearbeiten.', noAccessActionPrompt: 'Ihre Arbeitsbereichsrolle hat keinen Zugriff auf diese Einstellungen. Bitten Sie eine/n Admin, falls Sie Zugriff benötigen.', + connections: 'Verknüpfungen', }, createdForClient: { title: 'Du hast einen Workspace für deinen Kunden erstellt!', @@ -5012,6 +5013,31 @@ ${amount} für ${merchant} – ${date}`, subtitle: 'von OpenAI', }, }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Alle', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Buchhaltung', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'Personalwesen & Mitarbeitende', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Personalbeschaffung', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Belege', + [CONST.TAB.CONNECTIONS.AI]: 'KI & MCP', + }, + findConnections: 'Verbindungen finden', + configure: 'Konfigurieren', + fix: 'Beheben', + active: 'Aktiv', + broken: 'Defekt', + brokenConnection: 'Kann aufgrund einer unterbrochenen Verbindung nicht synchronisieren', + replaceConnectionTitle: 'Verbindung ersetzen?', + offer: 'Angebot', + replaceConnectionPrompt: (connectionName: string) => `Dadurch wird Ihre aktuelle ${connectionName}-Verbindung entfernt.`, + hrisListing: (providerName: string) => `${providerName} (HRIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: 'Sie sehen Ihre nicht? Schlagen Sie eine Integration vor, wir schauen sie uns an.', + noResultsPrompt: 'Bitte passen Sie Ihre Suche an oder schlagen Sie eine Integration vor.', + allConnectedTitle: 'Sie sind vollständig verbunden', + allConnectedPrompt: 'Hier ist bereits alles verbunden. Sie können auch eine Integration vorschlagen.', + }, receiptPartners: { uber: { subtitle: (organizationName: string) => @@ -11609,6 +11635,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc markAllAsRead: 'Rechtsklick, um alles als gelesen zu markieren.', markAllAsReadTouchScreen: 'Lange drücken, um alles als gelesen zu markieren.', accountMovedToTopBar: 'Greifen Sie auf Ihr Konto und Ihre persönlichen Einstellungen zu.', + connectionsMoved: 'Alle Ihre Verbindungen an einem Ort', }, discardChangesConfirmation: { title: 'Änderungen verwerfen?', diff --git a/src/languages/el.ts b/src/languages/el.ts index cb4d7a657d82..f546f0994228 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -5060,6 +5060,7 @@ ${amount} για ${merchant} - ${date}`, deleteTravelInvoicingError: 'Η εταιρεία σας έχει ακόμα ενεργοποιημένη τη συγκεντρωτική χρέωση ταξιδιών.', vendors: 'Προμηθευτές', noAccessActionPrompt: 'Ο ρόλος σας σε αυτόν τον χώρο εργασίας δεν έχει πρόσβαση σε αυτές τις ρυθμίσεις. Ζητήστε από έναν διαχειριστή να σας δώσει πρόσβαση, αν τη χρειάζεστε.', + connections: 'Συνδέσεις', }, createdForClient: { title: 'Δημιουργήσατε έναν χώρο εργασίας για τον πελάτη σας!', @@ -5084,6 +5085,31 @@ ${amount} για ${merchant} - ${date}`, subtitle: 'από την OpenAI', }, }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Όλα', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Λογιστική', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'ΔΑΔ & ανθρώπινο δυναμικό', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Προσλήψεις', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Αποδείξεις', + [CONST.TAB.CONNECTIONS.AI]: 'ΤΝ & MCP', + }, + findConnections: 'Εύρεση συνδέσεων', + configure: 'Ρυθμίστε', + fix: 'Διόρθωση', + active: 'Ενεργό', + broken: 'Χαλασμένο', + brokenConnection: 'Δεν είναι δυνατός ο συγχρονισμός λόγω κατεστραμμένης σύνδεσης', + replaceConnectionTitle: 'Αντικατάσταση σύνδεσης;', + offer: 'Προσφορά', + replaceConnectionPrompt: (connectionName: string) => `Αυτό θα καταργήσει την τρέχουσα σύνδεση ${connectionName}.`, + hrisListing: (providerName: string) => `${providerName} (HRIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: 'Δεν βλέπετε τη δική σας; Προτείνετε μια ενσωμάτωση και θα το εξετάσουμε.', + noResultsPrompt: 'Παρακαλούμε προσαρμόστε την αναζήτησή σας ή προτείνετε μια ενσωμάτωση.', + allConnectedTitle: 'Έχετε συνδεθεί πλήρως', + allConnectedPrompt: 'Όλα εδώ είναι ήδη συνδεδεμένα. Μπορείτε επίσης να προτείνετε μια ενσωμάτωση.', + }, receiptPartners: { uber: { subtitle: (organizationName: string) => @@ -11807,6 +11833,7 @@ ${reportName}`, markAllAsRead: 'Δεξί κλικ για επισήμανση όλων ως αναγνωσμένων.', markAllAsReadTouchScreen: 'Παρατεταμένο πάτημα για επισήμανση όλων ως αναγνωσμένων.', accountMovedToTopBar: 'Αποκτήστε πρόσβαση στον λογαριασμό και τις προσωπικές σας ρυθμίσεις.', + connectionsMoved: 'Όλες οι συνδέσεις σας σε ένα σημείο', }, discardChangesConfirmation: { title: 'Απόρριψη αλλαγών;', diff --git a/src/languages/en.ts b/src/languages/en.ts index 5bb2666b9706..0978f1a82756 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5014,6 +5014,7 @@ const translations = { recruiting: 'Recruiting', mcp: 'MCP', receiptPartners: 'Receipt partners', + connections: 'Connections', rules: 'Rules', displayedAs: 'Displayed as', plan: 'Plan', @@ -5188,6 +5189,31 @@ const translations = { subtitle: 'by OpenAI', }, }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'All', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Accounting', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'HR & People', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Recruiting', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Receipts', + [CONST.TAB.CONNECTIONS.AI]: 'AI & MCP', + }, + findConnections: 'Find connections', + configure: 'Configure', + fix: 'Fix', + active: 'Active', + broken: 'Broken', + brokenConnection: 'Cannot sync due to broken connection', + replaceConnectionTitle: 'Replace connection?', + offer: 'Offer', + replaceConnectionPrompt: (connectionName: string) => `This will remove your current ${connectionName} connection.`, + hrisListing: (providerName: string) => `${providerName} (HRIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: "Don't see yours? Suggest an integration, we'll look into it.", + noResultsPrompt: 'Please adjust your search or suggest an integration.', + allConnectedTitle: "You're all connected", + allConnectedPrompt: 'Everything here is already connected. You can also suggest an integration.', + }, receiptPartners: { uber: { subtitle: (organizationName: string) => (organizationName ? `Connected to ${organizationName}` : 'Automate travel and meal delivery expenses across your organization.'), @@ -11874,6 +11900,7 @@ const translations = { markAllAsRead: 'Right-click to mark all as read.', markAllAsReadTouchScreen: 'Long-press to mark all as read.', accountMovedToTopBar: 'Access your account and personal settings.', + connectionsMoved: 'All your connections in one place', }, discardChangesConfirmation: { title: 'Discard changes?', diff --git a/src/languages/es.ts b/src/languages/es.ts index 940fe1d21d22..34f9b109e192 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4912,6 +4912,7 @@ ${amount} para ${merchant} - ${date}`, peopleAdminAlternateText: 'Gestiona miembros y flujos de aprobación.', paymentsAdminAlternateText: 'Gestiona los pagos del flujo de trabajo.', noAccessActionPrompt: 'Tu rol en el espacio de trabajo no tiene acceso a estos ajustes. Pide a un administrador que te lo habilite si lo necesitas.', + connections: 'Conexiones', }, createdForClient: { title: '¡Has creado un espacio de trabajo para tu cliente!', @@ -4936,6 +4937,31 @@ ${amount} para ${merchant} - ${date}`, subtitle: 'de OpenAI', }, }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Todo', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Contabilidad', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'RR. HH. y Personas', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Reclutamiento', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Recibos', + [CONST.TAB.CONNECTIONS.AI]: 'IA y MCP', + }, + findConnections: 'Buscar conexiones', + configure: 'Configurar', + fix: 'Corregir', + active: 'Activo', + broken: 'Roto', + brokenConnection: 'No se puede sincronizar debido a una conexión interrumpida', + replaceConnectionTitle: '¿Reemplazar la conexión?', + offer: 'Oferta', + replaceConnectionPrompt: (connectionName: string) => `Esto eliminará tu conexión actual de ${connectionName}.`, + hrisListing: (providerName: string) => `${providerName} (HRIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: '¿No ves la tuya? Sugiere una integración y lo estudiaremos.', + noResultsPrompt: 'Ajusta tu búsqueda o sugiere una integración.', + allConnectedTitle: 'Ya está todo conectado', + allConnectedPrompt: 'Todo aquí ya está conectado. También puedes sugerir una integración.', + }, receiptPartners: { uber: { subtitle: (organizationName) => (organizationName ? `Conectado a ${organizationName}` : 'Automatice los gastos de viajes y entrega de comidas en toda su organización.'), @@ -11795,6 +11821,7 @@ ${reportName}`, markAllAsRead: 'Haz clic derecho para marcar todo como leído.', markAllAsReadTouchScreen: 'Mantén pulsado para marcar todo como leído.', accountMovedToTopBar: 'Accede a tu cuenta y a tu configuración personal.', + connectionsMoved: 'Todas tus conexiones en un solo lugar', }, discardChangesConfirmation: { title: '¿Descartar cambios?', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 13a28c3febf8..4a7ab2d177c3 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -4994,6 +4994,7 @@ ${amount} pour ${merchant} - ${date}`, readOnlyActionTitle: 'Pas si vite...', readOnlyActionPrompt: 'Votre rôle dans cet espace de travail peut afficher ces paramètres, mais ne peut pas les modifier.', noAccessActionPrompt: 'Votre rôle dans l’espace de travail n’a pas accès à ces paramètres. Demandez à un administrateur si vous en avez besoin.', + connections: 'Connexions', }, createdForClient: { title: 'Vous avez créé un espace de travail pour votre client !', @@ -5018,6 +5019,31 @@ ${amount} pour ${merchant} - ${date}`, subtitle: 'par OpenAI', }, }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Tous', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Comptabilité', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'RH & Personnel', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Recrutement', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Reçus', + [CONST.TAB.CONNECTIONS.AI]: 'IA et MCP', + }, + findConnections: 'Trouver des connexions', + configure: 'Configurer', + fix: 'Corriger', + active: 'Actif', + broken: 'Cassé', + brokenConnection: 'Synchronisation impossible en raison d’une connexion interrompue', + replaceConnectionTitle: 'Remplacer la connexion ?', + offer: 'Offre', + replaceConnectionPrompt: (connectionName: string) => `Cela supprimera votre connexion ${connectionName} actuelle.`, + hrisListing: (providerName: string) => `${providerName} (SIRH)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: 'Vous ne voyez pas la vôtre ? Suggérez une intégration, nous allons l’examiner.', + noResultsPrompt: 'Veuillez modifier votre recherche ou suggérer une intégration.', + allConnectedTitle: 'Vous êtes entièrement connecté', + allConnectedPrompt: 'Tout est déjà connecté ici. Vous pouvez aussi suggérer une intégration.', + }, receiptPartners: { uber: { subtitle: (organizationName: string) => @@ -11633,6 +11659,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e markAllAsRead: 'Faites un clic droit pour tout marquer comme lu.', markAllAsReadTouchScreen: 'Faites un appui long pour tout marquer comme lu.', accountMovedToTopBar: 'Accédez à votre compte et à vos paramètres personnels.', + connectionsMoved: 'Toutes vos connexions au même endroit', }, discardChangesConfirmation: { title: 'Ignorer les modifications ?', diff --git a/src/languages/it.ts b/src/languages/it.ts index 2d02fdae45ed..1a16867ebe5a 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -4956,6 +4956,7 @@ ${amount} per ${merchant} - ${date}`, readOnlyActionTitle: 'Non così in fretta...', readOnlyActionPrompt: 'Il tuo ruolo nello spazio di lavoro può visualizzare queste impostazioni, ma non può modificarle.', noAccessActionPrompt: 'Il tuo ruolo nello spazio di lavoro non ha accesso a queste impostazioni. Chiedi a un amministratore se ne hai bisogno.', + connections: 'Connessioni', }, createdForClient: { title: 'Hai creato uno spazio di lavoro per il tuo cliente!', @@ -4980,6 +4981,31 @@ ${amount} per ${merchant} - ${date}`, subtitle: 'di OpenAI', }, }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Tutto', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Contabilità', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'Risorse umane e persone', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Assunzioni', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Ricevute', + [CONST.TAB.CONNECTIONS.AI]: 'IA e MCP', + }, + findConnections: 'Trova connessioni', + configure: 'Configura', + fix: 'Correggi', + active: 'Attivo', + broken: 'Guasto', + brokenConnection: 'Impossibile sincronizzare a causa di una connessione interrotta', + replaceConnectionTitle: 'Sostituire la connessione?', + offer: 'Offerta', + replaceConnectionPrompt: (connectionName: string) => `Questo rimuoverà la tua connessione ${connectionName} attuale.`, + hrisListing: (providerName: string) => `${providerName} (HRIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: "Non vedi la tua? Suggerisci un'integrazione, la valuteremo.", + noResultsPrompt: `Modifica la tua ricerca oppure suggerisci un'integrazione.`, + allConnectedTitle: 'Sei tutto connesso', + allConnectedPrompt: `Qui è già tutto collegato. Puoi anche suggerire un'integrazione.`, + }, receiptPartners: { uber: { subtitle: (organizationName: string) => @@ -11559,6 +11585,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, markAllAsRead: 'Fai clic destro per segnare tutto come letto.', markAllAsReadTouchScreen: 'Tieni premuto per segnare tutto come letto.', accountMovedToTopBar: 'Accedi al tuo account e alle impostazioni personali.', + connectionsMoved: 'Tutte le tue connessioni in un unico posto', }, discardChangesConfirmation: { title: 'Scartare le modifiche?', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 0a9d10675c07..4d71a7c595cd 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -4921,6 +4921,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' readOnlyActionTitle: 'ちょっと待ってください…', readOnlyActionPrompt: 'このワークスペースでのあなたのロールは、これらの設定を表示できますが、編集することはできません。', noAccessActionPrompt: 'このワークスペースでのあなたのロールでは、これらの設定にアクセスできません。必要な場合は管理者に依頼してください。', + connections: '連携', }, createdForClient: { title: 'クライアントのワークスペースを作成しました!', @@ -4945,6 +4946,31 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' subtitle: 'OpenAI 提供', }, }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'すべて', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: '会計', + [CONST.TAB.CONNECTIONS.PEOPLE]: '人事・人材', + [CONST.TAB.CONNECTIONS.RECRUITING]: '採用', + [CONST.TAB.CONNECTIONS.RECEIPTS]: '領収書', + [CONST.TAB.CONNECTIONS.AI]: 'AI と MCP', + }, + findConnections: '連携先を見つける', + configure: '設定', + fix: '修正', + active: '有効', + broken: '壊れています', + brokenConnection: '接続が切断されているため同期できません', + replaceConnectionTitle: '接続を置き換えますか?', + offer: 'オファー', + replaceConnectionPrompt: (connectionName: string) => `現在の ${connectionName} 接続が削除されます。`, + hrisListing: (providerName: string) => `${providerName}(HRIS)`, + atsListing: (providerName: string) => `${providerName}(ATS)`, + suggestIntegration: 'お探しのものが見つかりませんか?連携を提案してください、こちらで検討します。', + noResultsPrompt: '検索条件を調整するか、連携を提案してください。', + allConnectedTitle: 'すべて接続されました', + allConnectedPrompt: 'ここにあるものはすべてすでに連携されています。連携を提案することもできます。', + }, receiptPartners: { uber: { subtitle: (organizationName: string) => (organizationName ? `${organizationName} に接続しました` : '組織全体の出張費や飲食デリバリー経費を自動化しましょう。'), @@ -11392,6 +11418,7 @@ ${reportName}`, markAllAsRead: '右クリックですべて既読にすることができます。', markAllAsReadTouchScreen: '長押しですべて既読にすることができます。', accountMovedToTopBar: 'アカウントと個人設定にアクセスします。', + connectionsMoved: 'すべての連携をひとまとめに', }, discardChangesConfirmation: { title: '変更を破棄しますか?', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 074b9356ffbb..90b99b7dadec 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -4958,6 +4958,7 @@ ${amount} voor ${merchant} - ${date}`, readOnlyActionTitle: 'Niet zo snel...', readOnlyActionPrompt: 'Je rol in de workspace kan deze instellingen bekijken, maar niet bewerken.', noAccessActionPrompt: 'Je rol in deze workspace heeft geen toegang tot deze instellingen. Vraag een admin als je die nodig hebt.', + connections: 'Verbindingen', }, createdForClient: { title: 'Je hebt een werkruimte voor je klant aangemaakt!', @@ -4982,6 +4983,31 @@ ${amount} voor ${merchant} - ${date}`, subtitle: 'van OpenAI', }, }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Alles', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Boekhouding', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'HR & People', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Werving', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Bonnetjes', + [CONST.TAB.CONNECTIONS.AI]: 'AI & MCP', + }, + findConnections: 'Verbindingen zoeken', + configure: 'Configureren', + fix: 'Oplossen', + active: 'Actief', + broken: 'Defect', + brokenConnection: 'Kan niet synchroniseren vanwege een verbroken verbinding', + replaceConnectionTitle: 'Verbinding vervangen?', + offer: 'Aanbieding', + replaceConnectionPrompt: (connectionName: string) => `Hiermee verwijder je je huidige ${connectionName}-verbinding.`, + hrisListing: (providerName: string) => `${providerName} (HRIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: 'Zie je de jouwe niet? Stel een integratie voor, dan kijken we ernaar.', + noResultsPrompt: 'Pas je zoekopdracht aan of stel een integratie voor.', + allConnectedTitle: 'Je bent helemaal verbonden', + allConnectedPrompt: 'Alles hier is al verbonden. Je kunt ook een integratie voorstellen.', + }, receiptPartners: { uber: { subtitle: (organizationName: string) => (organizationName ? `Verbonden met ${organizationName}` : 'Automatiseer reis- en maaltijdbezorgingskosten in uw hele organisatie.'), @@ -11521,6 +11547,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, markAllAsRead: 'Klik met de rechtermuisknop om alles als gelezen te markeren.', markAllAsReadTouchScreen: 'Houd ingedrukt om alles als gelezen te markeren.', accountMovedToTopBar: 'Ga naar je account en persoonlijke instellingen.', + connectionsMoved: 'Al je connecties op één plek', }, discardChangesConfirmation: { title: 'Wijzigingen negeren?', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 3fd0bc63d861..10312dce26aa 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -4986,6 +4986,7 @@ ${amount} dla ${merchant} - ${date}`, readOnlyActionTitle: 'Nie tak szybko…', readOnlyActionPrompt: 'Twoja rola w przestrzeni roboczej może wyświetlać te ustawienia, ale nie może ich edytować.', noAccessActionPrompt: 'Twoja rola w tym zespole nie ma dostępu do tych ustawień. Jeśli go potrzebujesz, poproś administratora.', + connections: 'Połączenia', }, createdForClient: { title: 'Utworzyłeś przestrzeń roboczą dla swojego klienta!', @@ -5010,6 +5011,31 @@ ${amount} dla ${merchant} - ${date}`, subtitle: 'od OpenAI', }, }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Wszystko', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Księgowość', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'HR i ludzie', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Rekrutacja', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Paragony', + [CONST.TAB.CONNECTIONS.AI]: 'AI i MCP', + }, + findConnections: 'Znajdź połączenia', + configure: 'Skonfiguruj', + fix: 'Napraw', + active: 'Aktywne', + broken: 'Uszkodzone', + brokenConnection: 'Nie można zsynchronizować z powodu przerwanego połączenia', + replaceConnectionTitle: 'Zastąpić połączenie?', + offer: 'Oferta', + replaceConnectionPrompt: (connectionName: string) => `To usunie twoje bieżące połączenie ${connectionName}.`, + hrisListing: (providerName: string) => `${providerName} (HRIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: 'Nie widzisz swojej? Zaproponuj integrację, przyjrzymy się temu.', + noResultsPrompt: 'Dostosuj wyszukiwanie lub zaproponuj integrację.', + allConnectedTitle: 'Wszystko połączone', + allConnectedPrompt: 'Wszystko tutaj jest już połączone. Możesz też zaproponować integrację.', + }, receiptPartners: { uber: { subtitle: (organizationName: string) => @@ -11539,6 +11565,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, markAllAsRead: 'Kliknij prawym przyciskiem myszy, aby oznaczyć wszystko jako przeczytane.', markAllAsReadTouchScreen: 'Przytrzymaj, aby oznaczyć wszystko jako przeczytane.', accountMovedToTopBar: 'Uzyskaj dostęp do swojego konta i ustawień osobistych.', + connectionsMoved: 'Wszystkie twoje połączenia w jednym miejscu', }, discardChangesConfirmation: { title: 'Odrzucić zmiany?', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 7f4783027dc9..e94c09b5795d 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -4949,6 +4949,7 @@ ${amount} para ${merchant} - ${date}`, readOnlyActionTitle: 'Calma aí...', readOnlyActionPrompt: 'Sua função no workspace pode ver essas configurações, mas não pode editá-las.', noAccessActionPrompt: 'Sua função no espaço de trabalho não tem acesso a essas configurações. Peça a um administrador se você precisar disso.', + connections: 'Conexões', }, createdForClient: { title: 'Você criou um espaço de trabalho para seu cliente!', @@ -4973,6 +4974,31 @@ ${amount} para ${merchant} - ${date}`, subtitle: 'da OpenAI', }, }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Tudo', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Contabilidade', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'RH e Pessoas', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Recrutamento', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Recibos', + [CONST.TAB.CONNECTIONS.AI]: 'IA e MCP', + }, + findConnections: 'Encontrar conexões', + configure: 'Configurar', + fix: 'Corrigir', + active: 'Ativo', + broken: 'Quebrado', + brokenConnection: 'Não é possível sincronizar devido à conexão interrompida', + replaceConnectionTitle: 'Substituir conexão?', + offer: 'Oferta', + replaceConnectionPrompt: (connectionName: string) => `Isso vai remover sua conexão atual com ${connectionName}.`, + hrisListing: (providerName: string) => `${providerName} (RHIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: 'Não encontrou a sua? Sugira uma integração e nós vamos analisar.', + noResultsPrompt: 'Ajuste sua pesquisa ou sugira uma integração.', + allConnectedTitle: 'Tudo conectado', + allConnectedPrompt: 'Tudo aqui já está conectado. Você também pode sugerir uma integração.', + }, receiptPartners: { uber: { subtitle: (organizationName: string) => @@ -11514,6 +11540,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, markAllAsRead: 'Clique com o botão direito para marcar tudo como lido.', markAllAsReadTouchScreen: 'Mantenha pressionado para marcar tudo como lido.', accountMovedToTopBar: 'Acesse sua conta e configurações pessoais.', + connectionsMoved: 'Todas as suas conexões em um só lugar', }, discardChangesConfirmation: { title: 'Descartar alterações?', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 411f1d1b94d4..d13c9ab8b7b7 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4806,6 +4806,7 @@ ${amount},商户:${merchant} - 日期:${date}`, readOnlyActionTitle: '别急……', readOnlyActionPrompt: '你的工作区角色可以查看这些设置,但不能编辑。', noAccessActionPrompt: '您的工作区角色无权访问这些设置。如需访问,请联系管理员。', + connections: '连接', }, createdForClient: { title: '您已为客户创建了工作区!', @@ -4830,6 +4831,31 @@ ${amount},商户:${merchant} - 日期:${date}`, subtitle: '由 OpenAI 提供', }, }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: '全部', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: '会计', + [CONST.TAB.CONNECTIONS.PEOPLE]: '人力资源与员工', + [CONST.TAB.CONNECTIONS.RECRUITING]: '招聘', + [CONST.TAB.CONNECTIONS.RECEIPTS]: '收据', + [CONST.TAB.CONNECTIONS.AI]: 'AI 和 MCP', + }, + findConnections: '查找关联', + configure: '配置', + fix: '修复', + active: '有效', + broken: '损坏', + brokenConnection: '由于连接中断,无法同步', + replaceConnectionTitle: '替换连接?', + offer: '优惠', + replaceConnectionPrompt: (connectionName: string) => `这将移除你当前的 ${connectionName} 连接。`, + hrisListing: (providerName: string) => `${providerName}(HRIS)`, + atsListing: (providerName: string) => `${providerName}(ATS)`, + suggestIntegration: '没找到适合你的?建议一个集成,我们会进行评估。', + noResultsPrompt: '请调整您的搜索条件或建议一个集成。', + allConnectedTitle: '全部已连接', + allConnectedPrompt: '这里的一切都已连接完成。您也可以建议一个集成。', + }, receiptPartners: { uber: { subtitle: (organizationName: string) => (organizationName ? `已连接到 ${organizationName}` : '在整个组织内自动化管理差旅和外卖餐饮报销。'), @@ -11097,6 +11123,7 @@ ${reportName}`, markAllAsRead: '右键点击即可将所有内容标记为已读。', markAllAsReadTouchScreen: '长按即可将所有内容标记为已读。', accountMovedToTopBar: '访问你的账户和个人设置。', + connectionsMoved: '所有连接尽在一处', }, discardChangesConfirmation: { title: '放弃更改?', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 23752a46e5e1..6ab024222ae1 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -443,6 +443,10 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/settings/Profile/Contacts/SetDefaultContactMethodConfirmValidateCodePage').default, [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: () => require('../../../../pages/settings/Preferences/PriorityModePage').default, [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: () => require('../../../../pages/workspace/accounting/PolicyAccountingPage').default, + [SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING]: () => require('../../../../pages/workspace/connections/ConnectionsAccountingPage').default, + [SCREENS.WORKSPACE.CONNECTIONS_HR]: () => require('../../../../pages/workspace/connections/ConnectionsHRPage').default, + [SCREENS.WORKSPACE.CONNECTIONS_RECRUITING]: () => require('../../../../pages/workspace/connections/ConnectionsRecruitingPage').default, + [SCREENS.WORKSPACE.CONNECTIONS_RECEIPT_PARTNERS]: () => require('../../../../pages/workspace/connections/ConnectionsReceiptPartnersPage').default, [SCREENS.SETTINGS.PREFERENCES.LANGUAGE]: () => require('../../../../pages/settings/Preferences/LanguagePage').default, [SCREENS.SETTINGS.PREFERENCES.THEME]: () => require('../../../../pages/settings/Preferences/ThemePage').default, [SCREENS.SETTINGS.TROUBLESHOOT_SERVER]: () => require('../../../../pages/settings/Troubleshoot/ServerPage').default, diff --git a/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx index b016c03f4ffa..c2cc3fe466ff 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx @@ -27,6 +27,7 @@ const CENTRAL_PANE_WORKSPACE_SCREENS = { [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: () => require('../../../../pages/workspace/accounting/PolicyAccountingPage').default, [SCREENS.WORKSPACE.HR]: () => require('../../../../pages/workspace/hr/WorkspaceHRPage').default, [SCREENS.WORKSPACE.RECRUITING]: () => require('../../../../pages/workspace/recruiting/WorkspaceRecruitingPage').default, + [SCREENS.WORKSPACE.CONNECTIONS]: () => require('../../../../pages/workspace/connections/WorkspaceConnectionsPage').default, [SCREENS.WORKSPACE.CATEGORIES]: () => require('../../../../pages/workspace/categories/WorkspaceCategoriesPage').default, [SCREENS.WORKSPACE.VENDORS]: () => require('../../../../pages/workspace/vendors/WorkspaceVendorsPage').default, [SCREENS.WORKSPACE.MORE_FEATURES]: () => require('../../../../pages/workspace/WorkspaceMoreFeaturesPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 6a3b34be3edd..f7d44c43df7b 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -524,6 +524,12 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.ACCOUNTING.INTUIT_ENTERPRISE_SUITE_ENTITY_SELECTOR]: { path: ROUTES.POLICY_ACCOUNTING_INTUIT_ENTERPRISE_SUITE_ENTITY_SELECTOR.route, }, + [SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING]: {path: ROUTES.WORKSPACE_CONNECTIONS_ACCOUNTING.route}, + [SCREENS.WORKSPACE.CONNECTIONS_HR]: {path: ROUTES.WORKSPACE_CONNECTIONS_HR.route}, + [SCREENS.WORKSPACE.CONNECTIONS_RECRUITING]: {path: ROUTES.WORKSPACE_CONNECTIONS_RECRUITING.route}, + [SCREENS.WORKSPACE.CONNECTIONS_RECEIPT_PARTNERS]: {path: ROUTES.WORKSPACE_CONNECTIONS_RECEIPT_PARTNERS.route}, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_IMPORT]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_IMPORT.route}, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_CHART_OF_ACCOUNTS]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CHART_OF_ACCOUNTS.route}, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_CLASSES]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CLASSES.route}, @@ -2491,6 +2495,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.RECEIPT_PARTNERS]: { path: ROUTES.WORKSPACE_RECEIPT_PARTNERS.route, }, + [SCREENS.WORKSPACE.CONNECTIONS]: { + path: ROUTES.WORKSPACE_CONNECTIONS.route, + }, [SCREENS.WORKSPACE.WORKFLOWS]: { path: ROUTES.WORKSPACE_WORKFLOWS.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 29ef730b798e..39219bfb94a8 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -835,6 +835,18 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.DISTANCE_RATES_COMMUTER_EXCLUSIONS]: { policyID: string; }; + [SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING]: { + policyID: string; + }; + [SCREENS.WORKSPACE.CONNECTIONS_HR]: { + policyID: string; + }; + [SCREENS.WORKSPACE.CONNECTIONS_RECRUITING]: { + policyID: string; + }; + [SCREENS.WORKSPACE.CONNECTIONS_RECEIPT_PARTNERS]: { + policyID: string; + }; [SCREENS.WORKSPACE.DISTANCE_RATES_WORK_ARRANGEMENT]: { policyID: string; }; @@ -3225,6 +3237,13 @@ type WorkspaceSplitNavigatorParamList = { [SCREENS.WORKSPACE.RECEIPT_PARTNERS]: { policyID: string; }; + [SCREENS.WORKSPACE.CONNECTIONS]: { + policyID: string; + newConnectionName?: ConnectionName; + integrationToDisconnect?: ConnectionName; + shouldDisconnectIntegrationBeforeConnecting?: boolean; + isIntuitEnterpriseSuite?: string; + }; [SCREENS.WORKSPACE.DYNAMIC_RECEIPT_PARTNERS_INVITE]: { policyID: string; integration: string; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 864ad4b60304..2ff1d98b9991 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -4941,7 +4941,7 @@ function enablePolicyConnections(policyID: string, enabled: boolean, shouldGoBac } } -function enablePolicyHR(policyID: string, enabled: boolean) { +function enablePolicyHR(policyID: string, enabled: boolean, shouldGoBack = true) { const onyxData: OnyxData = { optimisticData: [ { @@ -4984,12 +4984,12 @@ function enablePolicyHR(policyID: string, enabled: boolean) { API.writeWithNoDuplicatesEnableFeatureConflicts(WRITE_COMMANDS.ENABLE_POLICY_HR, parameters, onyxData); - if (enabled && getIsNarrowLayout()) { + if (enabled && getIsNarrowLayout() && shouldGoBack) { goBackWhenEnableFeature(); } } -function enablePolicyRecruiting(policyID: string, enabled: boolean) { +function enablePolicyRecruiting(policyID: string, enabled: boolean, shouldGoBack = true) { const onyxData: OnyxData = { optimisticData: [ { @@ -5032,12 +5032,12 @@ function enablePolicyRecruiting(policyID: string, enabled: boolean) { API.writeWithNoDuplicatesEnableFeatureConflicts(WRITE_COMMANDS.ENABLE_POLICY_RECRUITING, parameters, onyxData); - if (enabled && getIsNarrowLayout()) { + if (enabled && getIsNarrowLayout() && shouldGoBack) { goBackWhenEnableFeature(); } } -function enablePolicyMCP(policyID: string, enabled: boolean) { +function enablePolicyMCP(policyID: string, enabled: boolean, shouldGoBack = true) { const onyxData: OnyxData = { optimisticData: [ { @@ -5080,12 +5080,12 @@ function enablePolicyMCP(policyID: string, enabled: boolean) { API.writeWithNoDuplicatesEnableFeatureConflicts(WRITE_COMMANDS.ENABLE_POLICY_MCP, parameters, onyxData); - if (enabled && getIsNarrowLayout()) { + if (enabled && getIsNarrowLayout() && shouldGoBack) { goBackWhenEnableFeature(); } } -function enablePolicyReceiptPartners(policyID: string, enabled: boolean) { +function enablePolicyReceiptPartners(policyID: string, enabled: boolean, shouldGoBack = true) { const onyxData: OnyxData = { optimisticData: [ { @@ -5128,11 +5128,37 @@ function enablePolicyReceiptPartners(policyID: string, enabled: boolean) { API.write(WRITE_COMMANDS.TOGGLE_RECEIPT_PARTNERS, parameters, onyxData); - if (enabled && getIsNarrowLayout()) { + if (enabled && getIsNarrowLayout() && shouldGoBack) { goBackWhenEnableFeature(); } } +type ConnectionFeatureName = + | typeof CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED + | typeof CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED + | typeof CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED + | typeof CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED + | typeof CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED; + +const ENABLE_CONNECTION_FEATURE = { + [CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED]: enablePolicyConnections, + [CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED]: enablePolicyHR, + [CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED]: enablePolicyRecruiting, + [CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED]: enablePolicyReceiptPartners, + [CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED]: enablePolicyMCP, +} as const; + +/** + * The Connections page has no feature toggles, so connecting an integration from it turns the integration's feature on. + * The user stays on the page they connected from. + */ +function enablePolicyFeatureForConnection(policy: OnyxEntry, featureName: ConnectionFeatureName) { + if (!policy?.id || PolicyUtils.isPolicyFeatureEnabled(policy, featureName)) { + return; + } + ENABLE_CONNECTION_FEATURE[featureName](policy.id, true, false); +} + /** Save the preferred export method for a policy */ function savePreferredExportMethod(policyID: string, exportMethod: ReportExportType) { Onyx.merge(`${ONYXKEYS.LAST_EXPORT_METHOD}`, {[policyID]: exportMethod}); @@ -8145,6 +8171,7 @@ export { enablePolicyRecruiting, enablePolicyMCP, enablePolicyReceiptPartners, + enablePolicyFeatureForConnection, enablePolicyReportFields, enablePolicyInvoiceFields, enablePolicyTaxes, diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index fbd5a0c4c18d..689a4465ce7f 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -11,6 +11,7 @@ import useCardFeedErrors from '@hooks/useCardFeedErrors'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; import useHasApprovalWorkflowWithNonMemberApprover from '@hooks/useHasApprovalWorkflowWithNonMemberApprover'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import useIsWorkspacesTabFocused from '@hooks/useIsWorkspacesTabFocused'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -20,6 +21,7 @@ import usePermissions from '@hooks/usePermissions'; import usePolicyConnectionsPrefetch from '@hooks/usePolicyConnectionsPrefetch'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useScrollEventEmitter from '@hooks/useScrollEventEmitter'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; @@ -44,6 +46,7 @@ import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {LayoutChangeEvent} from 'react-native'; +import type {TupleToUnion} from 'type-fest'; import {findFocusedRoute, useFocusEffect, useIsFocused, useNavigationState} from '@react-navigation/native'; import {createHasExpenseDefaultRuleErrorsSelector} from '@selectors/Rule'; @@ -53,6 +56,7 @@ import {View} from 'react-native'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; +import useConnectionsMovedTooltip from './connections/useConnectionsMovedTooltip'; import getWorkspaceMenuItems from './getWorkspaceMenuItems'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; @@ -75,6 +79,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const {translate} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); const {isBetaEnabled} = usePermissions(); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); const isFocused = useIsFocused(); const isWorkspacesTabFocused = useIsWorkspacesTabFocused(); @@ -111,6 +116,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac 'Receipt', 'Briefcase', 'Sync', + 'Connect', 'Tag', 'Users', 'Workflows', @@ -195,13 +201,30 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac hasApprovalWorkflowWithNonMemberApprover, isVendorMatchingBetaEnabled: isBetaEnabled(CONST.BETAS.VENDOR_MATCHING), isRecruitingBetaEnabled: isBetaEnabled(CONST.BETAS.MERGE_ATS), + isUnifiedConnectionsBetaEnabled, convertToDisplayString, }).map((item) => ({ ...item, action: singleExecution(waitForNavigate(() => Navigation.navigate(item.getRoute()))), })); + const triggerScrollEvent = useScrollEventEmitter(); + const { + shouldShowTooltip: shouldShowConnectionsTooltip, + hideTooltip: hideConnectionsTooltip, + renderTooltipContent: renderConnectionsTooltip, + } = useConnectionsMovedTooltip( + // New workspaces never used the old integration pages, so only point this out where something is already connected. + isWorkspacesTabFocused && + !hasPolicyCreationError && + (!isEmptyObject(policy?.connections) || !!policy?.receiptPartners?.uber?.enabled) && + workspaceMenuItems.some((item) => item.screenName === SCREENS.WORKSPACE.CONNECTIONS), + activeRoute, + isWorkspacesTabFocused, + ); // Close RHP if we land on a route that no longer exists in the menu - const canAccessRoute = activeRoute && (workspaceMenuItems.some((item) => item.screenName === activeRoute) || activeRoute === SCREENS.WORKSPACE.INITIAL); + const getItemScreenNames = (item: TupleToUnion) => [item.screenName, ...(item.aliasScreenNames ?? [])]; + const canAccessRoute = + activeRoute && (workspaceMenuItems.some((item) => getItemScreenNames(item).some((screenName) => screenName === activeRoute)) || activeRoute === SCREENS.WORKSPACE.INITIAL); useEffect(() => { if (!shouldShowNotFoundPage && canAccessRoute) { return; @@ -257,7 +280,11 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac shouldDisplayAccountButton /> - + dismissError(policyID, policy?.pendingAction)} @@ -275,25 +302,43 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac Ideally we should use MenuList component for MenuItems with singleExecution/Navigation actions. In this case where user can click on workspace avatar or menu items, we need to have a check for `isExecuting`. So, we are directly mapping menuItems. */} - {workspaceMenuItems.map((item) => ( - - ))} + {workspaceMenuItems.map((item) => { + const isConnectionsItem = item.screenName === SCREENS.WORKSPACE.CONNECTIONS; + const onPress = () => { + if (isConnectionsItem) { + hideConnectionsTooltip(); + } + item.action(); + }; + return ( + activeRoute?.startsWith(screenName)))} + interactive={!hasPolicyCreationError} + title={translate(item.translationKey)} + icon={item.icon} + onPress={onPress} + brickRoadIndicator={item.brickRoadIndicator} + wrapperStyle={styles.sectionMenuItem(shouldUseNarrowLayout)} + highlighted={!!item?.highlighted} + focused={getItemScreenNames(item).some((screenName) => activeRoute?.startsWith(screenName))} + role={CONST.ROLE.TAB} + badgeText={item.badgeText} + shouldIconUseAutoWidthStyle + sentryLabel={item.sentryLabel} + shouldGreyOutWhenDisabled={hasPolicyCreationError} + shouldRenderTooltip={isConnectionsItem && shouldShowConnectionsTooltip} + renderTooltipContent={renderConnectionsTooltip} + tooltipWrapperStyle={styles.productTrainingTooltipWrapper} + tooltipAnchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP}} + onEducationTooltipPress={onPress} + // The workspace menu is the sidebar, which isn't the navigation-focused screen on wide layouts. + shouldHideTooltipOnNavigate={false} + shouldHideOnScroll={shouldUseNarrowLayout} + shouldCheckTooltipVisibilityOnFirstDisplay={shouldUseNarrowLayout} + /> + ); + })} diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx index 188e1f26b461..ff1098f77ce4 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx @@ -9,6 +9,7 @@ import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDefaultFundID from '@hooks/useDefaultFundID'; import useIsPolicyConnectedToUberReceiptPartner from '@hooks/useIsPolicyConnectedToUberReceiptPartner'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -102,6 +103,8 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {showConfirmModal} = useConfirmModal(); const isRecruitingBetaEnabled = isBetaEnabled(CONST.BETAS.MERGE_ATS); + // Connections replaces this section's pages, and turns each feature on as its first integration connects + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); const illustrations = useMemoizedLazyIllustrations([ 'FolderOpen', 'Accounting', @@ -367,151 +370,155 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro {translate('workspace.moreFeatures.subtitle')} - - { - if (!policyID) { - return; - } - if (tryNavigateToSubmitWorkspaceUpgrade(policy, isEnabled, CONST.UPGRADE_FEATURE_INTRO_MAPPING.accounting.alias)) { - return; - } - enablePolicyConnections(policyID, isEnabled); - }} - errors={getLatestErrorField(policy ?? {}, CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED)} - onCloseError={() => { - if (!policyID) { - return; - } - clearPolicyErrorField(policyID, CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED); - }} - onPress={() => { - if (!policyID) { - return; - } - Navigation.navigate(ROUTES.POLICY_ACCOUNTING.getRoute(policyID)); - }} - /> - { - if (!policyID) { - return; - } - if (isEnabled && !isControlPolicy(policy)) { - Navigation.navigate( - ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.hr.alias, ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)), - ); - return; - } - enablePolicyHR(policyID, isEnabled); - }} - onPress={() => { - if (!policyID) { - return; - } - Navigation.navigate(ROUTES.WORKSPACE_HR.getRoute(policyID)); - }} - /> - { - if (!policyID) { - return; - } - enablePolicyReceiptPartners(policyID, isEnabled); - }} - errors={getLatestErrorField(policy ?? {}, CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED)} - onCloseError={() => { - if (!policyID) { - return; - } - clearPolicyErrorField(policyID, CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED); - }} - onPress={() => { - if (!policyID) { - return; - } - Navigation.navigate(ROUTES.WORKSPACE_RECEIPT_PARTNERS.getRoute(policyID)); - }} - /> - {isRecruitingBetaEnabled && ( + {!isUnifiedConnectionsBetaEnabled && ( + { + if (!policyID) { + return; + } + if (tryNavigateToSubmitWorkspaceUpgrade(policy, isEnabled, CONST.UPGRADE_FEATURE_INTRO_MAPPING.accounting.alias)) { + return; + } + enablePolicyConnections(policyID, isEnabled); + }} + errors={getLatestErrorField(policy ?? {}, CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED)} + onCloseError={() => { + if (!policyID) { + return; + } + clearPolicyErrorField(policyID, CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED); + }} + onPress={() => { + if (!policyID) { + return; + } + Navigation.navigate(ROUTES.POLICY_ACCOUNTING.getRoute(policyID)); + }} + /> + { if (!policyID) { return; } if (isEnabled && !isControlPolicy(policy)) { Navigation.navigate( - ROUTES.WORKSPACE_UPGRADE.getRoute( - policyID, - CONST.UPGRADE_FEATURE_INTRO_MAPPING.recruiting.alias, - ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID), - ), + ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.hr.alias, ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)), ); return; } - enablePolicyRecruiting(policyID, isEnabled); + enablePolicyHR(policyID, isEnabled); }} onPress={() => { if (!policyID) { return; } - Navigation.navigate(ROUTES.WORKSPACE_RECRUITING.getRoute(policyID)); + Navigation.navigate(ROUTES.WORKSPACE_HR.getRoute(policyID)); }} /> - )} - { - if (!policyID) { - return; - } - enablePolicyMCP(policyID, isEnabled); - }} - onPress={() => { - if (!policyID) { - return; - } - Navigation.navigate(ROUTES.WORKSPACE_MCP.getRoute(policyID)); - }} - /> - + { + if (!policyID) { + return; + } + enablePolicyReceiptPartners(policyID, isEnabled); + }} + errors={getLatestErrorField(policy ?? {}, CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED)} + onCloseError={() => { + if (!policyID) { + return; + } + clearPolicyErrorField(policyID, CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED); + }} + onPress={() => { + if (!policyID) { + return; + } + Navigation.navigate(ROUTES.WORKSPACE_RECEIPT_PARTNERS.getRoute(policyID)); + }} + /> + {isRecruitingBetaEnabled && ( + { + if (!policyID) { + return; + } + if (isEnabled && !isControlPolicy(policy)) { + Navigation.navigate( + ROUTES.WORKSPACE_UPGRADE.getRoute( + policyID, + CONST.UPGRADE_FEATURE_INTRO_MAPPING.recruiting.alias, + ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID), + ), + ); + return; + } + enablePolicyRecruiting(policyID, isEnabled); + }} + onPress={() => { + if (!policyID) { + return; + } + Navigation.navigate(ROUTES.WORKSPACE_RECRUITING.getRoute(policyID)); + }} + /> + )} + { + if (!policyID) { + return; + } + enablePolicyMCP(policyID, isEnabled); + }} + onPress={() => { + if (!policyID) { + return; + } + Navigation.navigate(ROUTES.WORKSPACE_MCP.getRoute(policyID)); + }} + /> + + )} (); const {translate} = useLocalize(); const {showConfirmModal, closeModalByID} = useConfirmModal(); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); const policyID = policy?.id; // `removePolicyConnection` only runs once the user confirms, which can be a while after the flow started, so the @@ -117,7 +122,14 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider return; } - if (tryNavigateToSubmitWorkspaceUpgrade(policy, true, CONST.UPGRADE_FEATURE_INTRO_MAPPING.accounting.alias)) { + if ( + tryNavigateToSubmitWorkspaceUpgrade( + policy, + true, + CONST.UPGRADE_FEATURE_INTRO_MAPPING.accounting.alias, + isUnifiedConnectionsBetaEnabled ? ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID) : undefined, + ) + ) { return; } @@ -133,17 +145,22 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider dualEntry: hasReusablePoliciesConnectedToDualEntry, campfire: hasReusablePoliciesConnectedToCampfire, }, - undefined, - undefined, - newActiveIntegration.integrationToDisconnect, - newActiveIntegration.shouldDisconnectIntegrationBeforeConnecting, - undefined, - accountingIcons, - cardFeeds, - cardLists, - newActiveIntegration.isIntuitEnterpriseSuite, + { + integrationToDisconnect: newActiveIntegration.integrationToDisconnect, + shouldDisconnectIntegrationBeforeConnecting: newActiveIntegration.shouldDisconnectIntegrationBeforeConnecting, + expensifyIcons: accountingIcons, + cardFeeds, + cardList: cardLists, + isIntuitEnterpriseSuiteOverride: newActiveIntegration.isIntuitEnterpriseSuite, + isUnifiedConnectionsBetaEnabled, + }, ); + // Connections has no feature toggle, so turn it on here. The Control upgrade only enables the integration's own feature. + if (isUnifiedConnectionsBetaEnabled) { + enablePolicyFeatureForConnection(policy, CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED); + } + const workspaceUpgradeNavigationDetails = accountingIntegrationData?.workspaceUpgradeNavigationDetails; if (workspaceUpgradeNavigationDetails && !isControlPolicy(policy)) { Navigation.navigate( @@ -166,19 +183,30 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider const connectionName = newActiveIntegration.isIntuitEnterpriseSuite ? translate('workspace.accounting.intuitEnterpriseSuite') : (CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[newActiveIntegration.name] ?? newActiveIntegration.name); + const modalCopy = isUnifiedConnectionsBetaEnabled + ? { + title: translate('workspace.connections.replaceConnectionTitle'), + prompt: translate('workspace.connections.replaceConnectionPrompt', getAccountingIntegrationDisplayName(policy, integrationToDisconnect, translate)), + confirmText: translate('common.replace'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + } + : { + title: translate('workspace.accounting.connectTitle', connectionName), + prompt: translate('workspace.accounting.connectPrompt', connectionName), + confirmText: translate('workspace.accounting.setup'), + buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, + }; isDisconnectConfirmationPendingRef.current = true; showConfirmModal({ // `startIntegrationFlow` can run more than once for the same flow (the `useFocusEffect` in - // `PolicyAccountingPage` re-fires whenever `startIntegrationFlow` is re-created). A stable id keeps the - // repeat call updating this prompt in place instead of stacking a second copy behind it. + // `PolicyAccountingPage` and `WorkspaceConnectionsPage` re-fires whenever `startIntegrationFlow` is + // re-created). A stable id keeps the repeat call updating this prompt in place instead of stacking a + // second copy behind it. id: ACCOUNTING_CONNECTION_CONFIRMATION_MODAL_ID, - title: translate('workspace.accounting.connectTitle', connectionName), - prompt: translate('workspace.accounting.connectPrompt', connectionName), - confirmText: translate('workspace.accounting.setup'), + ...modalCopy, cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, }).then((result) => { // A repeat call for the same id is handed back the promise the first call got, so every call's handler // runs on a single user answer. Only the first one may act, or the disconnect would be requested twice. @@ -220,6 +248,7 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider accountingIcons, cardFeeds, cardLists, + isUnifiedConnectionsBetaEnabled, ], ); @@ -255,15 +284,15 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider dualEntry: hasReusablePoliciesConnectedToDualEntry, campfire: hasReusablePoliciesConnectedToCampfire, }, - policy, - activeIntegration.key, - undefined, - undefined, - undefined, - accountingIcons, - cardFeeds, - cardLists, - activeIntegration.isIntuitEnterpriseSuite, + { + policy, + key: activeIntegration.key, + expensifyIcons: accountingIcons, + cardFeeds, + cardList: cardLists, + isIntuitEnterpriseSuiteOverride: activeIntegration.isIntuitEnterpriseSuite, + isUnifiedConnectionsBetaEnabled, + }, )?.setupConnectionFlow; }; diff --git a/src/pages/workspace/accounting/ClaimOfferPage.tsx b/src/pages/workspace/accounting/ClaimOfferPage.tsx index d6c959e56334..624d931c4e0b 100644 --- a/src/pages/workspace/accounting/ClaimOfferPage.tsx +++ b/src/pages/workspace/accounting/ClaimOfferPage.tsx @@ -9,6 +9,7 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -25,6 +26,8 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import {enablePolicyFeatureForConnection} from '@userActions/Policy/Policy'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -58,6 +61,7 @@ function ClaimOfferPage({route, policy}: ClaimOfferPageProps) { const expensifyIcons = useMemoizedLazyExpensifyIcons(['TreasureChestGreenWithSparkle']); const integrations = policy?.receiptPartners; const {isUberConnected} = useGetReceiptPartnersIntegrationData(policyID); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); const accountingIntegrations = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES; @@ -95,6 +99,10 @@ function ClaimOfferPage({route, policy}: ClaimOfferPageProps) { connectionName: CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER, featureName: CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED, onConnect: () => { + // Connections offers Uber before Receipt partners is on, so the feature is turned on as Uber connects + if (isUnifiedConnectionsBetaEnabled) { + enablePolicyFeatureForConnection(policy, CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED); + } openExternalLink(`${CONST.UBER_CONNECT_URL}?${integrations?.uber?.connectFormData}`); }, }, @@ -144,7 +152,10 @@ function ClaimOfferPage({route, policy}: ClaimOfferPageProps) { [ + {name, isIntuitEnterpriseSuite: name === CONST.POLICY.CONNECTIONS.NAME.QBO ? false : undefined}, + ...(name === CONST.POLICY.CONNECTIONS.NAME.QBO ? [{name, isIntuitEnterpriseSuite: true}] : []), ]); - const [cardFeeds] = useCardFeeds(policyID); - const [cardLists] = useCardsLists(); - const connectionSyncStage = connectionSyncProgress?.stageInProgress; - - const canUseBusinessCentralIntegration = isBetaEnabled(CONST.BETAS.BUSINESS_CENTRAL) || !!policy?.connections?.businessCentral; - const accountingIntegrations = useMemo( - () => - CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.filter((name) => { - if (name === CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL) { - return canUseBusinessCentralIntegration; - } - return true; - }), - [canUseBusinessCentralIntegration], - ); - const accountingIntegrationOptions = useMemo( - () => - accountingIntegrations.flatMap((name) => [ - {name, isIntuitEnterpriseSuite: name === CONST.POLICY.CONNECTIONS.NAME.QBO ? false : undefined}, - ...(name === CONST.POLICY.CONNECTIONS.NAME.QBO ? [{name, isIntuitEnterpriseSuite: true}] : []), - ]), - [accountingIntegrations], - ); - const syncingAccountingIntegration = accountingIntegrations.find((integration) => integration === connectionSyncProgress?.connectionName); - const connectedIntegration = getConnectedIntegration(policy, accountingIntegrations) ?? syncingAccountingIntegration; - const isIntuitEnterpriseSuiteSyncInProgress = isSyncInProgress && activeIntegration?.name === CONST.POLICY.CONNECTIONS.NAME.QBO && activeIntegration.isIntuitEnterpriseSuite === true; - const isConnectedToIntuitEnterpriseSuite = - connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBO && (isIntuitEnterpriseSuiteConnection(policy) || isIntuitEnterpriseSuiteSyncInProgress); - const connectedIntegrationDisplayName = connectedIntegration ? getAccountingIntegrationDisplayName(policy, connectedIntegration, translate) : undefined; - const hasAccountingConnection = hasAccountingConnections(policy); - const {canWrite: canWriteAccounting, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); - const synchronizationError = connectedIntegration && getSynchronizationErrorMessage(policy, connectedIntegration, isSyncInProgress, translate, styles); - - const isSageIntacct = connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT; - const hasAuthError = !!connectedIntegration && !!synchronizationError && isAuthenticationError(policy, connectedIntegration); - // A QBO refresh token that is about to expire (or already has, without a sync failing yet) is warned about while the connection still looks healthy - const qboTokenExpiryStatus = - connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBO && !synchronizationError && !isSyncInProgress ? getQBORefreshTokenExpiryStatus(policy) : undefined; - const isQBOTokenExpiringSoon = !!qboTokenExpiryStatus; - const qboTokenExpiryDate = isQBOTokenExpiringSoon ? getQBORefreshTokenExpiryDate(policy) : undefined; - const shouldShowEnterCredentials = !!connectedIntegration && (hasAuthError || isSageIntacct || isQBOTokenExpiringSoon); - - // Get the last successful date of the integration. Then, if `connectionSyncProgress` is the same integration displayed and the state is 'jobDone', get the more recent update time of the two. - const successfulDate = getIntegrationLastSuccessfulDate( - getLocalDateFromDatetime, - connectedIntegration ? policy?.connections?.[connectedIntegration] : undefined, - connectedIntegration === connectionSyncProgress?.connectionName ? connectionSyncProgress : undefined, - ); - - const hasSyncError = shouldShowSyncError(policy, isSyncInProgress, accountingIntegrations); - const hasUnsupportedNDIntegration = !isEmptyObject(policy?.connections) && hasSupportedOnlyOnOldDotIntegration(policy); - - const tenants = useMemo(() => getXeroTenants(policy), [policy]); - const currentXeroOrganization = findCurrentXeroOrganization(tenants, policy?.connections?.xero?.config?.tenantID); const shouldShowSynchronizationError = !!synchronizationError; - const shouldShowReinstallConnectorMenuItem = shouldShowSynchronizationError && connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBD; - const shouldShowCardReconciliationOption = Object.values(allCardSettings ?? {})?.some((cardSetting) => isExpensifyCardFullySetUp(policy, cardSetting)); - const shouldShowReconnect = hasAuthError && connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.CERTINIA; - let credentialsMenuTextKey: Parameters[0] = 'workspace.accounting.enterCredentials'; - if (shouldShowReconnect || isQBOTokenExpiringSoon) { - credentialsMenuTextKey = 'workspace.accounting.reconnect'; - } else if (isSageIntacct && !hasAuthError) { - credentialsMenuTextKey = 'workspace.accounting.updateCredentials'; - } - - const overflowMenu: ThreeDotsMenuProps['menuItems'] = useMemo( - () => [ - ...(shouldShowReinstallConnectorMenuItem - ? [ - { - icon: icons.CircularArrowBackwards, - text: translate('workspace.accounting.reinstall'), - onSelected: () => startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBD}), - shouldCallAfterModalHide: true, - disabled: isOffline, - iconRight: icons.NewWindow, - }, - ] - : []), - ...(shouldShowEnterCredentials - ? [ - { - icon: icons.Key, - text: translate(credentialsMenuTextKey), - onSelected: () => { - if (isSageIntacct && policyID) { - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTER_CREDENTIALS.getRoute(policyID)); - return; - } - startIntegrationFlow({name: connectedIntegration, isIntuitEnterpriseSuite: isConnectedToIntuitEnterpriseSuite}); - }, - shouldCallAfterModalHide: true, - disabled: isOffline, - iconRight: icons.NewWindow, - }, - ] - : []), - ...(!hasAuthError - ? [ - { - icon: icons.Sync, - text: translate('workspace.accounting.syncNow'), - onSelected: () => syncConnection(policy, connectedIntegration), - disabled: isOffline, - }, - ] - : []), - { - icon: icons.Trashcan, - text: translate('workspace.accounting.disconnect'), - onSelected: () => { - showConfirmModal({ - title: translate('workspace.accounting.disconnectTitle', connectedIntegrationDisplayName), - prompt: translate('workspace.accounting.disconnectPrompt', connectedIntegrationDisplayName), - confirmText: translate('workspace.accounting.disconnect'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }).then(({action}) => { - if (action !== ModalActions.CONFIRM || !connectedIntegration || !policyID) { - return; - } - removePolicyConnection(policy, connectedIntegration); - }); - }, - shouldCallAfterModalHide: true, - }, - ], - [ - icons.NewWindow, - icons.CircularArrowBackwards, - icons.Key, - icons.Sync, - icons.Trashcan, - shouldShowEnterCredentials, - shouldShowReinstallConnectorMenuItem, - translate, - isOffline, - policy, - connectedIntegration, - connectedIntegrationDisplayName, - isConnectedToIntuitEnterpriseSuite, - startIntegrationFlow, - isSageIntacct, - hasAuthError, - credentialsMenuTextKey, - policyID, - showConfirmModal, - ], - ); // `startIntegrationFlow` changes identity whenever `policy` does, which re-runs this effect. The // Navigation.setParams below clears newConnectionName through a navigation state update that lands in a later @@ -323,249 +134,11 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { }, [newConnectionName, shouldConnectToIntuitEnterpriseSuite, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting, policy, startIntegrationFlow, canWriteAccounting]), ); - useEffect(() => { - if (successfulDate) { - setDateTimeToRelative(getDatetimeToRelative(successfulDate)); - return; - } - setDateTimeToRelative(''); - }, [getDatetimeToRelative, successfulDate]); - - useEffect(() => { - if (!policyID || !policy?.areExpensifyCardsEnabled || !workspaceAccountID) { - return; - } - openPolicyExpensifyCardsPage(policyID, workspaceAccountID); - }, [policyID, policy?.areExpensifyCardsEnabled, workspaceAccountID]); - - const integrationSpecificMenuItems = useMemo(() => { - const sageIntacctEntityList = policy?.connections?.intacct?.data?.entities ?? []; - const netSuiteSubsidiaryList = policy?.connections?.netsuite?.options?.data?.subsidiaryList ?? []; - const rilletSubsidiaryList = policy?.connections?.rillet?.data?.subsidiaries; - const dualEntryCompanyList = policy?.connections?.dualEntry?.data?.companies; - const campfireSubsidiaryList = policy?.connections?.campfire?.data?.subsidiaries; - const businessCentralCompanyList = policy?.connections?.businessCentral?.data?.companies; - const certiniaConfig = policy?.connections?.financialforce?.config; - const certiniaCompanies = policy?.connections?.financialforce?.data?.companies ?? []; - const certiniaCompanyID = getCertiniaSelectedCompanyID(certiniaConfig); - const certiniaCompanyField = certiniaConfig?.hasPSA ? CONST.CERTINIA_CONFIG.COMPANY_ID : CONST.CERTINIA_CONFIG.COMPANY; - const selectedCertiniaCompany = certiniaCompanies.find((company) => company.id === certiniaCompanyID); - switch (connectedIntegration) { - case CONST.POLICY.CONNECTIONS.NAME.XERO: - return !policy?.connections?.xero?.data?.tenants - ? {} - : { - description: translate('workspace.xero.organization'), - iconRight: icons.ArrowRight, - title: getCurrentXeroOrganizationName(policy), - wrapperStyle: [styles.sectionMenuItemTopDescription], - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting && tenants.length > 1, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - onPress: - canWriteAccounting && tenants.length > 1 - ? () => { - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_ORGANIZATION.getRoute(policyID, currentXeroOrganization?.id)); - } - : undefined, - pendingAction: settingsPendingAction([CONST.XERO_CONFIG.TENANT_ID], policy?.connections?.xero?.config?.pendingFields), - brickRoadIndicator: areSettingsInErrorFields([CONST.XERO_CONFIG.TENANT_ID], policy?.connections?.xero?.config?.errorFields) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: - return !policy?.connections?.netsuite?.options?.config?.subsidiary - ? {} - : { - description: translate('workspace.netsuite.subsidiary'), - iconRight: icons.ArrowRight, - title: policy?.connections?.netsuite?.options?.config?.subsidiary ?? '', - wrapperStyle: [styles.sectionMenuItemTopDescription], - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting && netSuiteSubsidiaryList?.length > 1, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: policy?.connections?.netsuite?.options?.config?.pendingFields?.subsidiary, - brickRoadIndicator: policy?.connections?.netsuite?.options?.config?.errorFields?.subsidiary ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: - canWriteAccounting && netSuiteSubsidiaryList?.length > 1 - ? () => { - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR.getRoute(policyID)); - } - : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT: - return !sageIntacctEntityList.length - ? {} - : { - description: translate('workspace.intacct.entity'), - iconRight: icons.ArrowRight, - title: getCurrentSageIntacctEntityName(policy, translate('workspace.common.topLevel')), - wrapperStyle: [styles.sectionMenuItemTopDescription], - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: policy?.connections?.intacct?.config?.pendingFields?.entity, - brickRoadIndicator: policy?.connections?.intacct?.config?.errorFields?.entity ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: canWriteAccounting ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTITY.getRoute(policyID)) : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.QBO: - return !policy?.connections?.quickbooksOnline?.config?.companyName - ? {} - : { - description: translate(isConnectedToIntuitEnterpriseSuite ? 'workspace.qbo.entity' : 'workspace.qbo.connectedTo'), - iconRight: isConnectedToIntuitEnterpriseSuite ? icons.ArrowRight : undefined, - title: policy?.connections?.quickbooksOnline?.config?.companyName, - wrapperStyle: [styles.sectionMenuItemTopDescription], - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: isConnectedToIntuitEnterpriseSuite && canWriteAccounting, - shouldShowDescriptionOnTop: true, - interactive: isConnectedToIntuitEnterpriseSuite && canWriteAccounting, - pendingAction: policy?.connections?.quickbooksOnline?.config?.pendingFields?.realmId, - brickRoadIndicator: policy?.connections?.quickbooksOnline?.config?.errorFields?.realmId ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: - isConnectedToIntuitEnterpriseSuite && canWriteAccounting && policyID - ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_INTUIT_ENTERPRISE_SUITE_ENTITY_SELECTOR.getRoute(policyID)) - : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.CERTINIA: - return !isCertiniaFFAConnection(certiniaConfig) - ? {} - : { - description: translate('workspace.certinia.company'), - iconRight: icons.ArrowRight, - title: selectedCertiniaCompany?.name ?? certiniaCompanyID ?? translate('common.none'), - wrapperStyle: [styles.sectionMenuItemTopDescription], - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: settingsPendingAction([certiniaCompanyField], certiniaConfig?.pendingFields), - brickRoadIndicator: areSettingsInErrorFields([certiniaCompanyField], certiniaConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: canWriteAccounting ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CERTINIA_COMPANY_SELECTOR.getRoute(policyID)) : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.RILLET: - return !rilletSubsidiaryList?.length - ? {} - : { - description: translate('workspace.rillet.subsidiary'), - iconRight: icons.ArrowRight, - title: rilletSubsidiaryList?.find((subsidiary) => subsidiary.id === policy?.connections?.rillet?.config?.subsidiaryID)?.tradeName ?? '', - wrapperStyle: [styles.sectionMenuItemTopDescription], - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting && rilletSubsidiaryList && rilletSubsidiaryList.length > 1, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: policy?.connections?.rillet?.config.pendingFields?.subsidiaryID, - brickRoadIndicator: policy?.connections?.rillet?.config.errorFields?.subsidiaryID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: - policyID && canWriteAccounting && rilletSubsidiaryList && rilletSubsidiaryList.length > 1 - ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_SUBSIDIARY_SELECTOR.getRoute(policyID)) - : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: - return !dualEntryCompanyList?.length - ? {} - : { - description: translate('workspace.dualEntry.subsidiary'), - iconRight: icons.ArrowRight, - title: dualEntryCompanyList?.find((company) => company.id === policy?.connections?.dualEntry?.config?.subsidiaryID)?.name ?? '', - wrapperStyle: [styles.sectionMenuItemTopDescription], - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting && dualEntryCompanyList && dualEntryCompanyList.length > 1, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: policy?.connections?.dualEntry?.config.pendingFields?.subsidiaryID, - brickRoadIndicator: policy?.connections?.dualEntry?.config.errorFields?.subsidiaryID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: - policyID && canWriteAccounting && dualEntryCompanyList && dualEntryCompanyList.length > 1 - ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_DUALENTRY_SUBSIDIARY_SELECTOR.getRoute(policyID)) - : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: - return !campfireSubsidiaryList?.length - ? {} - : { - description: translate('workspace.campfire.subsidiary'), - iconRight: icons.ArrowRight, - title: campfireSubsidiaryList?.find((subsidiary) => subsidiary.id === policy?.connections?.campfire?.config?.subsidiaryID)?.name ?? '', - wrapperStyle: [styles.sectionMenuItemTopDescription], - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting && campfireSubsidiaryList && campfireSubsidiaryList.length > 1, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: policy?.connections?.campfire?.config.pendingFields?.subsidiaryID, - brickRoadIndicator: policy?.connections?.campfire?.config.errorFields?.subsidiaryID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: - policyID && canWriteAccounting && campfireSubsidiaryList && campfireSubsidiaryList.length > 1 - ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CAMPFIRE_SUBSIDIARY_SELECTOR.getRoute(policyID)) - : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL: - return !businessCentralCompanyList?.length - ? {} - : { - description: translate('workspace.businessCentral.subsidiary'), - iconRight: icons.ArrowRight, - title: businessCentralCompanyList.find((company) => company.id === policy?.connections?.businessCentral?.config?.companyID)?.displayName ?? '', - wrapperStyle: [styles.sectionMenuItemTopDescription], - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting && businessCentralCompanyList.length > 1, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: policy?.connections?.businessCentral?.config.pendingFields?.companyID, - brickRoadIndicator: policy?.connections?.businessCentral?.config.errorFields?.companyID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: - policyID && canWriteAccounting && businessCentralCompanyList.length > 1 - ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_COMPANY_SELECTOR.getRoute(policyID)) - : undefined, - }; - - default: - return undefined; - } - }, [ - canWriteAccounting, - connectedIntegration, - currentXeroOrganization?.id, - isConnectedToIntuitEnterpriseSuite, - policy, - policyID, - styles.fontWeightNormal, - styles.sectionMenuItemTopDescription, - tenants.length, - translate, - icons.ArrowRight, - ]); - - const connectionsMenuItems: MenuItemData[] = useMemo(() => { + const getConnectionsMenuItems = (): MenuItemData[] => { if (!hasAccountingConnection && !isSyncInProgress && policyID) { return accountingIntegrationOptions .map(({name: integration, isIntuitEnterpriseSuite}) => { - const integrationData = getAccountingIntegrationData( - integration, - policyID, - translate, - { - sageIntacct: hasReusablePoliciesConnectedToSageIntacct, - qbd: hasReusablePoliciesConnectedToQBD, - certinia: hasReusablePoliciesConnectedToCertinia, - rillet: hasReusablePoliciesConnectedToRillet, - dualEntry: hasReusablePoliciesConnectedToDualEntry, - campfire: hasReusablePoliciesConnectedToCampfire, - }, - undefined, - undefined, - undefined, - undefined, - undefined, - accountingIcons, - cardFeeds, - cardLists, - isIntuitEnterpriseSuite, - ); + const integrationData = getAvailableIntegrationData(integration, isIntuitEnterpriseSuite); if (!integrationData) { return undefined; } @@ -628,99 +201,10 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { .filter(Boolean) as MenuItemData[]; } - if (!connectedIntegration || !policyID) { + if (!connectionDetails) { return []; } - const isConnectionVerified = !isConnectionUnverified(policy, connectedIntegration); - const integrationData = getAccountingIntegrationData( - connectedIntegration, - policyID, - translate, - { - sageIntacct: hasReusablePoliciesConnectedToSageIntacct, - qbd: hasReusablePoliciesConnectedToQBD, - certinia: hasReusablePoliciesConnectedToCertinia, - rillet: hasReusablePoliciesConnectedToRillet, - dualEntry: hasReusablePoliciesConnectedToDualEntry, - campfire: hasReusablePoliciesConnectedToCampfire, - }, - policy, - undefined, - undefined, - undefined, - isBetaEnabled(CONST.BETAS.NETSUITE_USA_TAX), - accountingIcons, - cardFeeds, - cardLists, - isConnectedToIntuitEnterpriseSuite, - ); - const iconProps = integrationData?.icon ? {icon: integrationData.icon, iconType: CONST.ICON_TYPE_AVATAR} : {}; - - let connectionMessage; - if (isSyncInProgress && connectionSyncStage) { - connectionMessage = translate('workspace.accounting.connections.syncStageName', connectionSyncStage, integrationData?.title); - } else if (!isConnectionVerified) { - connectionMessage = translate('workspace.accounting.notSync'); - } else { - connectionMessage = translate('workspace.accounting.lastSync', datetimeToRelative); - } - - const configurationOptions = canWriteAccounting - ? [ - { - icon: icons.Pencil, - iconRight: icons.ArrowRight, - shouldShowRightIcon: true, - title: translate('workspace.accounting.import'), - wrapperStyle: [styles.sectionMenuItemTopDescription], - onPress: integrationData?.onImportPagePress, - brickRoadIndicator: areSettingsInErrorFields(integrationData?.subscribedImportSettings, integrationData?.errorFields) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined, - pendingAction: settingsPendingAction(integrationData?.subscribedImportSettings, integrationData?.pendingFields), - }, - { - icon: icons.Send, - iconRight: icons.ArrowRight, - shouldShowRightIcon: true, - title: translate('workspace.accounting.export'), - wrapperStyle: [styles.sectionMenuItemTopDescription], - onPress: integrationData?.onExportPagePress, - brickRoadIndicator: - areSettingsInErrorFields(integrationData?.subscribedExportSettings, integrationData?.errorFields) || - shouldShowQBOReimbursableExportDestinationAccountError(policy) || - integrationData?.externalSubscribedExportSettingsHasErrorFields - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined, - pendingAction: - settingsPendingAction(integrationData?.subscribedExportSettings, integrationData?.pendingFields) ?? integrationData?.externalSubscribedExportSettingsPendingAction, - }, - ...(shouldShowCardReconciliationOption && integrationData?.onCardReconciliationPagePress - ? [ - { - icon: icons.ExpensifyCard, - iconRight: icons.ArrowRight, - shouldShowRightIcon: true, - title: translate('workspace.accounting.cardReconciliation'), - wrapperStyle: [styles.sectionMenuItemTopDescription], - onPress: integrationData?.onCardReconciliationPagePress, - }, - ] - : []), - { - icon: icons.Gear, - iconRight: icons.ArrowRight, - shouldShowRightIcon: true, - title: translate('workspace.accounting.advanced'), - wrapperStyle: [styles.sectionMenuItemTopDescription], - onPress: integrationData?.onAdvancedPagePress, - brickRoadIndicator: areSettingsInErrorFields(integrationData?.subscribedAdvancedSettings, integrationData?.errorFields) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined, - pendingAction: settingsPendingAction(integrationData?.subscribedAdvancedSettings, integrationData?.pendingFields), - }, - ] - : []; + const iconProps = connectionDetails.icon ? {icon: connectionDetails.icon, iconType: CONST.ICON_TYPE_AVATAR} : {}; let rightComponent; if (isSyncInProgress) { @@ -739,91 +223,26 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { ); } - let qboTokenExpiryHint; - if (qboTokenExpiryDate && canWriteAccounting) { - const formattedExpiryDate = DateUtils.formatWithUTCTimeZone(qboTokenExpiryDate.toISOString(), CONST.DATE.MONTH_DAY_YEAR_FORMAT, dateFnsLocale); - qboTokenExpiryHint = ( - <> - {translate( - qboTokenExpiryStatus === CONST.POLICY.CONNECTIONS.QBO_REFRESH_TOKEN_EXPIRY_STATUS.EXPIRED - ? 'workspace.accounting.qboConnectionExpired' - : 'workspace.accounting.qboConnectionExpiring', - {date: formattedExpiryDate}, - )}{' '} - startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBO, isIntuitEnterpriseSuite: isConnectedToIntuitEnterpriseSuite})}> - {translate('workspace.accounting.reconnect')} - - - ); - } - return [ { ...iconProps, interactive: false, wrapperStyle: [styles.sectionMenuItemTopDescription, shouldShowSynchronizationError && styles.pb0], shouldShowRightComponent: true, - title: integrationData?.title, + title: connectionDetails.title, errorText: synchronizationError, errorTextStyle: [styles.mt5], shouldShowRedDotIndicator: true, - description: connectionMessage, + description: connectionDetails.connectionMessage, hintText: qboTokenExpiryHint, rightComponent, }, - ...(isEmptyObject(integrationSpecificMenuItems) || shouldShowSynchronizationError || !hasAccountingConnection ? [] : [integrationSpecificMenuItems]), - ...(!hasAccountingConnection || !isConnectionVerified ? [] : configurationOptions), + ...connectionDetails.settingsMenuItems, ]; - }, [ - policy, - hasAccountingConnection, - isSyncInProgress, - policyID, - connectedIntegration, - translate, - isBetaEnabled, - accountingIcons, - cardFeeds, - cardLists, - connectionSyncStage, - icons.Pencil, - icons.ArrowRight, - icons.ExpensifyCard, - icons.Gear, - icons.Send, - styles.sectionMenuItemTopDescription, - styles.pb0, - styles.mt5, - styles.popoverMenuIcon, - styles.mr3, - styles.justifyContentCenter, - styles.buttonOpacityDisabled, - styles.buttonDisabled, - shouldShowCardReconciliationOption, - shouldShowSynchronizationError, - synchronizationError, - overflowMenu, - integrationSpecificMenuItems, - accountingIntegrationOptions, - isConnectedToIntuitEnterpriseSuite, - shouldUseNarrowLayout, - startIntegrationFlow, - popoverAnchorRefs, - datetimeToRelative, - qboTokenExpiryDate, - qboTokenExpiryStatus, - dateFnsLocale, - hasReusablePoliciesConnectedToSageIntacct, - hasReusablePoliciesConnectedToCertinia, - hasReusablePoliciesConnectedToRillet, - hasReusablePoliciesConnectedToDualEntry, - hasReusablePoliciesConnectedToCampfire, - hasReusablePoliciesConnectedToQBD, - canWriteAccounting, - showReadOnlyModal, - ]); + }; + const connectionsMenuItems = getConnectionsMenuItems(); - const otherIntegrationsItems = useMemo(() => { + const getOtherIntegrationsItems = () => { if ((!hasAccountingConnection && !isSyncInProgress) || !policyID) { return; } @@ -832,28 +251,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { ); return otherIntegrations .map(({name: integration, isIntuitEnterpriseSuite}) => { - const integrationData = getAccountingIntegrationData( - integration, - policyID, - translate, - { - sageIntacct: hasReusablePoliciesConnectedToSageIntacct, - qbd: hasReusablePoliciesConnectedToQBD, - certinia: hasReusablePoliciesConnectedToCertinia, - rillet: hasReusablePoliciesConnectedToRillet, - dualEntry: hasReusablePoliciesConnectedToDualEntry, - campfire: hasReusablePoliciesConnectedToCampfire, - }, - undefined, - undefined, - undefined, - undefined, - undefined, - accountingIcons, - cardFeeds, - cardLists, - isIntuitEnterpriseSuite, - ); + const integrationData = getAvailableIntegrationData(integration, isIntuitEnterpriseSuite); if (!integrationData) { return undefined; } @@ -903,32 +301,8 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { }; }) .filter(Boolean) as MenuItemWithLink[]; - }, [ - hasAccountingConnection, - isSyncInProgress, - accountingIntegrationOptions, - connectedIntegration, - isConnectedToIntuitEnterpriseSuite, - policyID, - translate, - hasReusablePoliciesConnectedToSageIntacct, - hasReusablePoliciesConnectedToCertinia, - hasReusablePoliciesConnectedToRillet, - hasReusablePoliciesConnectedToDualEntry, - hasReusablePoliciesConnectedToCampfire, - hasReusablePoliciesConnectedToQBD, - styles.justifyContentCenter, - styles.buttonOpacityDisabled, - styles.buttonDisabled, - styles.sectionMenuItemTopDescription, - startIntegrationFlow, - popoverAnchorRefs, - accountingIcons, - cardFeeds, - cardLists, - canWriteAccounting, - showReadOnlyModal, - ]); + }; + const otherIntegrationsItems = getOtherIntegrationsItems(); const [chatTextLink, chatReportID] = useMemo(() => { // If they have an onboarding specialist assigned display the following and link to the #admins room with the account executive. @@ -944,8 +318,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { return [translate('workspace.accounting.talkToConcierge'), conciergeReportID]; }, [account?.accountManagerAccountID, account?.accountManagerReportID, conciergeReportID, policy?.chatReportIDAdmins, translate]); - const oldDotPolicyConnectionsURL = policyID ? `${oldDotEnvironmentURL}/${CONST.OLDDOT_URLS.POLICY_CONNECTIONS_URL_ENCODED(policyID)}` : ''; - return ( Navigation.goBack(policyID ? ROUTES.POLICY_ACCOUNTING.getRoute(policyID) : undefined)} + onBackButtonPress={() => Navigation.goBack()} title="workspace.certinia.company" listEmptyContent={listEmptyContent} connectionName={CONST.POLICY.CONNECTIONS.NAME.CERTINIA} diff --git a/src/pages/workspace/accounting/certinia/advanced/CertiniaAdvancedPage.tsx b/src/pages/workspace/accounting/certinia/advanced/CertiniaAdvancedPage.tsx index 06bf6cbc6cb8..10337baa3a6b 100644 --- a/src/pages/workspace/accounting/certinia/advanced/CertiniaAdvancedPage.tsx +++ b/src/pages/workspace/accounting/certinia/advanced/CertiniaAdvancedPage.tsx @@ -26,7 +26,7 @@ import { } from '@userActions/connections/FinancialForce'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import React from 'react'; @@ -39,7 +39,6 @@ function CertiniaAdvancedPage({policy}: WithPolicyConnectionsProps) { const advancedConfig = config?.advanced; const isPSA = !!config?.hasPSA; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_ADVANCED.path); - const advancedPath = policyID ? `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_ADVANCED.path}` : undefined; const selectedFxExpenseAccountName = data?.expenseAccounts?.find(({id}) => id === config?.fxExpenseAccount)?.name; return ( @@ -134,7 +133,7 @@ function CertiniaAdvancedPage({policy}: WithPolicyConnectionsProps) { title={selectedFxExpenseAccountName} description={translate('workspace.certinia.fxExpenseAccount')} wrapperStyle={[styles.ph5, styles.pv3]} - onPress={!advancedPath ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_FX_EXPENSE_ACCOUNT.path, advancedPath))} + onPress={!policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_FX_EXPENSE_ACCOUNT.path))} brickRoadIndicator={areSettingsInErrorFields([CONST.CERTINIA_CONFIG.FX_EXPENSE_ACCOUNT], config?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} /> diff --git a/src/pages/workspace/accounting/certinia/export/CertiniaExportPage.tsx b/src/pages/workspace/accounting/certinia/export/CertiniaExportPage.tsx index ab2737c99c6a..aca9fef4a834 100644 --- a/src/pages/workspace/accounting/certinia/export/CertiniaExportPage.tsx +++ b/src/pages/workspace/accounting/certinia/export/CertiniaExportPage.tsx @@ -2,6 +2,7 @@ import ConnectionLayout from '@components/ConnectionLayout'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -14,7 +15,7 @@ import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnec import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import React from 'react'; @@ -35,7 +36,7 @@ function CertiniaExportPage({policy}: WithPolicyConnectionsProps) { const {config, data} = policy?.connections?.financialforce ?? {}; const exportConfig = config?.export; const hasPSA = !!config?.hasPSA; - const exportPath = policyID ? `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_EXPORT.path}` : undefined; + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_EXPORT.path); // Skip the lookup when `vendorAccount` is empty (its seeded default) so we don't try to match an empty id against the synced vendor list. const selectedVendor = exportConfig?.vendorAccount ? data?.vendors?.find((vendor) => vendor.id === exportConfig?.vendorAccount) : undefined; // Only show "None" when no vendorAccount is configured. When a vendorAccount id is set but the synced vendor list @@ -51,7 +52,7 @@ function CertiniaExportPage({policy}: WithPolicyConnectionsProps) { // We use the logical OR (||) here instead of ?? because `exporter` could be an empty string on a fresh connection // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing title: exportConfig?.exporter || policyOwner, - onPress: !exportPath ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_PREFERRED_EXPORTER.path, exportPath)), + onPress: !policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_PREFERRED_EXPORTER.path)), subscribedSettings: [CONST.CERTINIA_CONFIG.EXPORTER], }; @@ -60,7 +61,7 @@ function CertiniaExportPage({policy}: WithPolicyConnectionsProps) { { description: translate('workspace.certinia.reportExportStatus.label'), title: normalizedReportExportStatus ? translate(`workspace.certinia.reportExportStatus.values.${normalizedReportExportStatus}`) : exportStatus, - onPress: !exportPath ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_REPORT_EXPORT_STATUS.path, exportPath)), + onPress: !policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_REPORT_EXPORT_STATUS.path)), subscribedSettings: [CONST.CERTINIA_CONFIG.EXPORT_STATUS], }, { @@ -84,13 +85,13 @@ function CertiniaExportPage({policy}: WithPolicyConnectionsProps) { { description: translate('workspace.certinia.exportStatus.label'), title: normalizedFFAExportStatus ? translate(`workspace.certinia.exportStatus.values.${normalizedFFAExportStatus}`) : undefined, - onPress: !exportPath ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_EXPORT_STATUS.path, exportPath)), + onPress: !policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_EXPORT_STATUS.path)), subscribedSettings: [CONST.CERTINIA_CONFIG.EXPORT_STATUS], }, { description: translate('workspace.certinia.exportDate.label'), title: exportDate ? translate(`workspace.certinia.exportDate.values.${exportDate}`) : undefined, - onPress: !exportPath ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_EXPORT_DATE.path, exportPath)), + onPress: !policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_EXPORT_DATE.path)), subscribedSettings: [CONST.CERTINIA_CONFIG.EXPORT_DATE], }, { @@ -109,7 +110,7 @@ function CertiniaExportPage({policy}: WithPolicyConnectionsProps) { { description: translate('workspace.accounting.defaultVendor'), title: defaultVendorTitle, - onPress: !exportPath ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_DEFAULT_VENDOR.path, exportPath)), + onPress: !policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_DEFAULT_VENDOR.path)), subscribedSettings: [CONST.CERTINIA_CONFIG.VENDOR_ACCOUNT], }, ]; @@ -123,7 +124,7 @@ function CertiniaExportPage({policy}: WithPolicyConnectionsProps) { title="workspace.certinia.exportDescription" accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} policyID={policyID} - onBackButtonPress={() => Navigation.goBack(policyID ? ROUTES.POLICY_ACCOUNTING.getRoute(policyID) : undefined)} + onBackButtonPress={() => Navigation.goBack(backPath)} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} contentContainerStyle={styles.pb2} titleStyle={styles.ph5} diff --git a/src/pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage.tsx b/src/pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage.tsx index 85c9bea93c6d..61e9cc8b3939 100644 --- a/src/pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage.tsx +++ b/src/pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage.tsx @@ -108,7 +108,7 @@ function SageIntacctAdvancedPage({policy}: WithPolicyProps) { contentContainerStyle={styles.pb2} titleStyle={styles.ph5} connectionName={CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT} - onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING.getRoute(policyID))} + onBackButtonPress={() => Navigation.goBack()} > Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_AUTO_SYNC.path)))}> diff --git a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctExportPage.tsx b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctExportPage.tsx index db311a9197f4..5a84b0ac259c 100644 --- a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctExportPage.tsx +++ b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctExportPage.tsx @@ -30,7 +30,6 @@ function DynamicSageIntacctExportPage({policy}: WithPolicyProps) { const policyID = policy?.id; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT.path); const {export: exportConfig, pendingFields, errorFields} = policy?.connections?.intacct?.config ?? {}; - const exportPath = policyID ? `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT.path}` : undefined; const travelPayableAccount = policy?.connections?.intacct?.data?.creditCards?.find((account) => account.name === exportConfig?.travelInvoicingPayableAccountID); const workspaceAccountID = useWorkspaceAccountID(policyID); @@ -62,9 +61,7 @@ function DynamicSageIntacctExportPage({policy}: WithPolicyProps) { { title: travelPayableAccount?.name, description: translate('workspace.common.travelInvoicing'), - action: !exportPath - ? undefined - : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_TRAVEL_BILLING_CONFIGURATION.path, exportPath)), + action: !policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_TRAVEL_BILLING_CONFIGURATION.path)), subscribedSettings: [CONST.SAGE_INTACCT_CONFIG.TRAVEL_BILLING_PAYABLE_ACCOUNT], }, ] diff --git a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctTravelBillingConfigurationPage.tsx b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctTravelBillingConfigurationPage.tsx index cc133d878886..ed745bb6cbfb 100644 --- a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctTravelBillingConfigurationPage.tsx +++ b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctTravelBillingConfigurationPage.tsx @@ -16,7 +16,7 @@ import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnec import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import React from 'react'; @@ -30,7 +30,6 @@ function DynamicSageIntacctTravelBillingConfigurationPage({policy}: WithPolicyCo const config = policy?.connections?.intacct?.config; const travelPayableAccount = policy?.connections?.intacct?.data?.creditCards?.find((account) => account.name === config?.export?.travelInvoicingPayableAccountID); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_TRAVEL_BILLING_CONFIGURATION.path); - const travelBillingPath = `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT.path}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_TRAVEL_BILLING_CONFIGURATION.path}`; return ( Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT.path, travelBillingPath))} + onPress={() => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT.path))} value={travelPayableAccount?.name} > {areSettingsInErrorFields(payableAccountSetting, config?.errorFields) && } diff --git a/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.native.tsx b/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.native.tsx index cab7a8add383..2af24d2f033d 100644 --- a/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.native.tsx +++ b/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.native.tsx @@ -1,10 +1,12 @@ +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; + import {getNetSuiteSetupLink} from '@libs/actions/connections/NetSuiteCommands'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import AccountingSetupWebViewPage from '@pages/workspace/accounting/AccountingSetupWebViewPage'; +import {getAccountingConnectionsRoute} from '@pages/workspace/connections/utils'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import React from 'react'; @@ -14,6 +16,7 @@ type NetSuiteSetupPageProps = PlatformStackScreenProps ); } diff --git a/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteExistingConnectionsPage.tsx b/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteExistingConnectionsPage.tsx index 0714a32bff1c..b2a7fc4ceffa 100644 --- a/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteExistingConnectionsPage.tsx +++ b/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteExistingConnectionsPage.tsx @@ -1,6 +1,7 @@ import ConnectionLayout from '@components/ConnectionLayout'; import MenuItemList from '@components/MenuItemList'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import useLocalize from '@hooks/useLocalize'; import useReusablePoliciesConnectedTo from '@hooks/useReusablePoliciesConnectedTo'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -10,8 +11,9 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {getAccountingConnectionsRoute} from '@pages/workspace/connections/utils'; + import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import React from 'react'; @@ -23,6 +25,8 @@ function NetSuiteExistingConnectionsPage({route}: ExistingConnectionsPageProps) const {translate, datetimeToRelative} = useLocalize(); const styles = useThemeStyles(); const policyID: string = route.params.policyID; + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); + const accountingConnectionsRoute = getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID); const {reusablePoliciesConnectedTo: reusablePoliciesConnectedToNetSuite} = useReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.NETSUITE, policyID); const menuItems = reusablePoliciesConnectedToNetSuite.map((policy) => { @@ -37,7 +41,7 @@ function NetSuiteExistingConnectionsPage({route}: ExistingConnectionsPageProps) description: date ? translate('workspace.common.lastSyncDate', CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.netsuite, date) : translate('workspace.accounting.netsuite'), onPress: () => { copyExistingPolicyConnection(policy.id, policyID, CONST.POLICY.CONNECTIONS.NAME.NETSUITE); - Navigation.goBack(ROUTES.WORKSPACE_ACCOUNTING.getRoute(policyID)); + Navigation.goBack(accountingConnectionsRoute); }, }; }); @@ -53,7 +57,7 @@ function NetSuiteExistingConnectionsPage({route}: ExistingConnectionsPageProps) titleStyle={styles.ph5} shouldLoadForEmptyConnection connectionName={CONST.POLICY.CONNECTIONS.NAME.NETSUITE} - onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_ACCOUNTING.getRoute(policyID))} + onBackButtonPress={() => Navigation.goBack(accountingConnectionsRoute)} > { - if (!policyID) { - return ROUTES.HOME; - } - return createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.path, ROUTES.WORKSPACE_ACCOUNTING.getRoute(policyID)); - }, [policyID]); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); + const netSuiteExportBackPath = policyID + ? createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.path, getAccountingSettingsRoute(isUnifiedConnectionsBetaEnabled, policyID)) + : ROUTES.HOME; const config = policy?.connections?.netsuite?.options.config; const {subsidiaryList} = policy?.connections?.netsuite?.options?.data ?? {}; diff --git a/src/pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage.tsx index ed76e9c47c4c..999c690799d6 100644 --- a/src/pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage.tsx @@ -2,6 +2,7 @@ import BlockingView from '@components/BlockingViews/BlockingView'; import type {SelectorType} from '@components/SelectionScreen'; import SelectionScreen from '@components/SelectionScreen'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; @@ -14,6 +15,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {canUseTaxNetSuite, getNetSuiteTaxAccountOptions} from '@libs/NetSuiteUtils'; import {settingsPendingAction} from '@libs/PolicyUtils'; +import {getAccountingSettingsRoute} from '@pages/workspace/connections/utils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; @@ -33,12 +35,10 @@ function NetSuiteTaxPostingAccountSelectPage({policy}: WithPolicyConnectionsProp const {isBetaEnabled} = usePermissions(); const policyID = policy?.id; - const netSuiteExportBackPath = useMemo(() => { - if (!policyID) { - return ROUTES.HOME; - } - return createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.path, ROUTES.WORKSPACE_ACCOUNTING.getRoute(policyID)); - }, [policyID]); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); + const netSuiteExportBackPath = policyID + ? createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.path, getAccountingSettingsRoute(isUnifiedConnectionsBetaEnabled, policyID)) + : ROUTES.HOME; const config = policy?.connections?.netsuite?.options.config; const {subsidiaryList} = policy?.connections?.netsuite?.options?.data ?? {}; diff --git a/src/pages/workspace/accounting/netsuite/export/NetSuiteTravelBillingConfigurationPage.tsx b/src/pages/workspace/accounting/netsuite/export/NetSuiteTravelBillingConfigurationPage.tsx index 9fb6dbc2f079..53465fc131c6 100644 --- a/src/pages/workspace/accounting/netsuite/export/NetSuiteTravelBillingConfigurationPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/NetSuiteTravelBillingConfigurationPage.tsx @@ -3,6 +3,7 @@ import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -13,6 +14,7 @@ import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils import Navigation from '@navigation/Navigation'; import TravelBillingContinuousReconciliationSection from '@pages/workspace/accounting/common/TravelBillingContinuousReconciliationSection'; +import {getAccountingSettingsRoute} from '@pages/workspace/connections/utils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; @@ -22,7 +24,7 @@ import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type {ValueOf} from 'type-fest'; -import React, {useMemo} from 'react'; +import React from 'react'; type NetSuiteSectionType = { title?: string; @@ -41,7 +43,8 @@ function NetSuiteTravelBillingConfigurationPage({policy}: WithPolicyConnectionsP const styles = useThemeStyles(); const policyID = policy?.id ?? String(CONST.DEFAULT_NUMBER_ID); - const netSuiteExportBackPath = useMemo(() => createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.path, ROUTES.WORKSPACE_ACCOUNTING.getRoute(policyID)), [policyID]); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); + const netSuiteExportBackPath = createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.path, getAccountingSettingsRoute(isUnifiedConnectionsBetaEnabled, policyID)); const config = policy?.connections?.netsuite?.options?.config; const {payableList} = policy?.connections?.netsuite?.options?.data ?? {}; diff --git a/src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupFlowSyncPage.tsx b/src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupFlowSyncPage.tsx index c8b0618737c8..9acc7157d51b 100644 --- a/src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupFlowSyncPage.tsx +++ b/src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupFlowSyncPage.tsx @@ -1,3 +1,4 @@ +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import useOnyx from '@hooks/useOnyx'; import {isConnectionInProgress, syncConnection} from '@libs/actions/connections'; @@ -5,9 +6,10 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {getAccountingConnectionsRoute} from '@pages/workspace/connections/utils'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {useEffect} from 'react'; @@ -18,6 +20,7 @@ function QuickBooksDesktopSetupFlowSyncPage({route}: QuickBooksDesktopSetupFlowS const policyID: string = route.params.policyID; const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID ?? '-1'}`); const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID ?? '-1'}`); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); useEffect(() => { if (!policyID) { @@ -29,7 +32,7 @@ function QuickBooksDesktopSetupFlowSyncPage({route}: QuickBooksDesktopSetupFlowS syncConnection(policy, CONST.POLICY.CONNECTIONS.NAME.QBD, true); } - Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING.getRoute(policyID)); + Navigation.navigate(getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID)); // disabling this rule, as we want this to run only on the first render // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/pages/workspace/accounting/qbd/advanced/QuickbooksDesktopAutoSyncPageBase.tsx b/src/pages/workspace/accounting/qbd/advanced/QuickbooksDesktopAutoSyncPageBase.tsx index 9e8c47aabc2d..2a2139bf588b 100644 --- a/src/pages/workspace/accounting/qbd/advanced/QuickbooksDesktopAutoSyncPageBase.tsx +++ b/src/pages/workspace/accounting/qbd/advanced/QuickbooksDesktopAutoSyncPageBase.tsx @@ -2,6 +2,7 @@ import ConnectionLayout from '@components/ConnectionLayout'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -11,6 +12,7 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import Navigation from '@libs/Navigation/Navigation'; import {settingsPendingAction} from '@libs/PolicyUtils'; +import {getAccountingSettingsRoute} from '@pages/workspace/connections/utils'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; import {clearQBDErrorField} from '@userActions/Policy/Policy'; @@ -35,6 +37,7 @@ function QuickbooksDesktopAutoSyncPageBase({policy, navigateBackTo}: QuickbooksD const styles = useThemeStyles(); const {translate} = useLocalize(); const policyID = policy?.id; + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); const config = policy?.connections?.quickbooksDesktop?.config; const {autoSync, pendingFields} = config ?? {}; const accountingMethod = config?.export?.accountingMethod ?? COMMON_CONST.INTEGRATIONS.ACCOUNTING_METHOD.CASH; @@ -42,8 +45,13 @@ function QuickbooksDesktopAutoSyncPageBase({policy, navigateBackTo}: QuickbooksD settingsPendingAction([CONST.QUICKBOOKS_DESKTOP_CONFIG.AUTO_SYNC], pendingFields) ?? settingsPendingAction([CONST.QUICKBOOKS_DESKTOP_CONFIG.ACCOUNTING_METHOD], pendingFields); const goBack = useCallback(() => { - Navigation.goBack(navigateBackTo ?? createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_DESKTOP_ADVANCED.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID))); - }, [policyID, navigateBackTo]); + Navigation.goBack( + navigateBackTo ?? + (policyID + ? createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_DESKTOP_ADVANCED.path, getAccountingSettingsRoute(isUnifiedConnectionsBetaEnabled, policyID)) + : undefined), + ); + }, [isUnifiedConnectionsBetaEnabled, policyID, navigateBackTo]); return ( account.id === qbdConfig?.export?.travelInvoicingPayableAccountID); - const exportPath = policyID ? `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT.path}` : undefined; const workspaceAccountID = useWorkspaceAccountID(policyID); const [cardSettings] = useOnyx(getTravelBillingCardSettingsKey(workspaceAccountID)); @@ -85,15 +84,7 @@ function DynamicQuickbooksDesktopExportPage({policy}: WithPolicyConnectionsProps }, { description: translate('workspace.accounting.exportCompanyCard'), - onPress: !policyID - ? undefined - : () => - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT.path, - `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT.path}`, - ), - ), + onPress: !policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT.path)), brickRoadIndicator: qbdConfig?.errorFields?.exportCompanyCard ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, title: qbdConfig?.export?.nonReimbursable ? translate(`workspace.qbd.accounts.${qbdConfig?.export?.nonReimbursable}`) : undefined, subscribedSettings: [ @@ -107,9 +98,7 @@ function DynamicQuickbooksDesktopExportPage({policy}: WithPolicyConnectionsProps ? [ { description: translate('workspace.common.travelInvoicing'), - onPress: !exportPath - ? undefined - : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_TRAVEL_BILLING_CONFIGURATION.path, exportPath)), + onPress: !policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_TRAVEL_BILLING_CONFIGURATION.path)), title: travelPayableAccount?.name, subscribedSettings: [CONST.QUICKBOOKS_DESKTOP_CONFIG.TRAVEL_BILLING_PAYABLE_ACCOUNT], }, diff --git a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopTravelBillingConfigurationPage.tsx b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopTravelBillingConfigurationPage.tsx index d72a07428655..bdc331d2407e 100644 --- a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopTravelBillingConfigurationPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopTravelBillingConfigurationPage.tsx @@ -16,7 +16,7 @@ import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnec import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {ValueOf} from 'type-fest'; @@ -44,13 +44,12 @@ function QuickbooksDesktopTravelBillingConfigurationPage({policy}: WithPolicyCon const {payableAccounts} = policy?.connections?.quickbooksDesktop?.data ?? {}; const travelPayableAccount = payableAccounts?.find((item) => item.id === qbdConfig?.export?.travelInvoicingPayableAccountID); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_TRAVEL_BILLING_CONFIGURATION.path); - const travelBillingPath = `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT.path}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_TRAVEL_BILLING_CONFIGURATION.path}`; const sections: QBDSection[] = [ { title: travelPayableAccount?.name, description: translate('workspace.common.travelInvoicingPayableAccount'), - onPress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT.path, travelBillingPath)), + onPress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT.path)), subscribedSettings: payableAccount, pendingAction: settingsPendingAction(payableAccount, qbdConfig?.pendingFields), brickRoadIndicator: areSettingsInErrorFields(payableAccount, qbdConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, diff --git a/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopImportPage.tsx b/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopImportPage.tsx index 08eb489effe2..080d2da301b3 100644 --- a/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopImportPage.tsx +++ b/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopImportPage.tsx @@ -68,7 +68,7 @@ function QuickbooksDesktopImportPage({policy}: WithPolicyProps) { contentContainerStyle={styles.pb2} titleStyle={styles.ph5} connectionName={CONST.POLICY.CONNECTIONS.NAME.QBD} - onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING.getRoute(policyID))} + onBackButtonPress={() => Navigation.goBack()} > {sections.map((section) => ( ; + + /** Runs once the user confirms disconnecting, before the connection is removed */ + onDisconnectConfirmed?: () => void; +}; + +/** + * Everything the Accounting page and the Connections accounting panel derive for the workspace's connected accounting + * integration: its status, the overflow menu, and the settings rows. + */ +function useConnectedAccountingIntegration(policy: OnyxEntry, {menuItemWrapperStyle, onDisconnectConfirmed}: UseConnectedAccountingIntegrationOptions = {}) { + const policyID = policy?.id; + const hasReusablePoliciesConnectedToSageIntacct = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, policyID); + const hasReusablePoliciesConnectedToQBD = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.QBD, policyID); + const hasReusablePoliciesConnectedToCertinia = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CERTINIA, policyID); + const hasReusablePoliciesConnectedToRillet = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.RILLET, policyID); + const hasReusablePoliciesConnectedToDualEntry = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, policyID); + const hasReusablePoliciesConnectedToCampfire = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE, policyID); + const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); + const styles = useThemeStyles(); + const {translate, datetimeToRelative: getDatetimeToRelative, getLocalDateFromDatetime, dateFnsLocale} = useLocalize(); + const {environment} = useEnvironment(); + const {isOffline} = useNetwork(); + const {isBetaEnabled} = usePermissions(); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); + const {showConfirmModal} = useConfirmModal(); + const {activeIntegration} = useAccountingState(); + const {startIntegrationFlow} = useAccountingActions(); + const workspaceAccountID = useWorkspaceAccountID(policyID); + const allCardSettings = useExpensifyCardFeeds(policyID); + const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'CircularArrowBackwards', 'ExpensifyCard', 'Gear', 'Key', 'NewWindow', 'Pencil', 'Send', 'Sync', 'Trashcan']); + const accountingIcons = useMemoizedLazyExpensifyIcons([ + 'IntacctSquare', + 'IntuitSquare', + 'QBOSquare', + 'XeroSquare', + 'NetSuiteSquare', + 'QBDSquare', + 'CertiniaSquare', + 'RilletSquare', + 'DualEntrySquare', + 'CampfireSquare', + 'BusinessCentralSquare', + ]); + const [cardFeeds] = useCardFeeds(policyID); + const [cardLists] = useCardsLists(); + const {canWrite: canWriteAccounting, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); + + const existingConnections = { + sageIntacct: hasReusablePoliciesConnectedToSageIntacct, + qbd: hasReusablePoliciesConnectedToQBD, + certinia: hasReusablePoliciesConnectedToCertinia, + rillet: hasReusablePoliciesConnectedToRillet, + dualEntry: hasReusablePoliciesConnectedToDualEntry, + campfire: hasReusablePoliciesConnectedToCampfire, + }; + + /** Data for an integration the workspace could connect, as listed with a Setup button */ + const getAvailableIntegrationData = (integration: ConnectionName, isIntuitEnterpriseSuite?: boolean) => + policyID + ? getAccountingIntegrationData(integration, policyID, translate, existingConnections, { + expensifyIcons: accountingIcons, + cardFeeds, + cardList: cardLists, + isIntuitEnterpriseSuiteOverride: isIntuitEnterpriseSuite, + }) + : undefined; + + const isSyncInProgress = isConnectionInProgress(connectionSyncProgress, policy); + const connectionSyncStage = connectionSyncProgress?.stageInProgress; + const canUseBusinessCentralIntegration = isBetaEnabled(CONST.BETAS.BUSINESS_CENTRAL) || !!policy?.connections?.businessCentral; + const accountingIntegrations = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.filter((name) => { + if (name === CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL) { + return canUseBusinessCentralIntegration; + } + return true; + }); + const syncingAccountingIntegration = accountingIntegrations.find((integration) => integration === connectionSyncProgress?.connectionName); + const connectedIntegration = getConnectedIntegration(policy, accountingIntegrations) ?? syncingAccountingIntegration; + const isIntuitEnterpriseSuiteSyncInProgress = isSyncInProgress && activeIntegration?.name === CONST.POLICY.CONNECTIONS.NAME.QBO && activeIntegration.isIntuitEnterpriseSuite === true; + const isConnectedToIntuitEnterpriseSuite = + connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBO && (isIntuitEnterpriseSuiteConnection(policy) || isIntuitEnterpriseSuiteSyncInProgress); + const connectedIntegrationDisplayName = connectedIntegration ? getAccountingIntegrationDisplayName(policy, connectedIntegration, translate) : undefined; + const hasAccountingConnection = hasAccountingConnections(policy); + const synchronizationError = connectedIntegration && getSynchronizationErrorMessage(policy, connectedIntegration, isSyncInProgress, translate, styles); + + const isSageIntacct = connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT; + const hasAuthError = !!connectedIntegration && !!synchronizationError && isAuthenticationError(policy, connectedIntegration); + // A QBO refresh token that is about to expire (or already has, without a sync failing yet) is warned about while the connection still looks healthy + const qboTokenExpiryStatus = + connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBO && !synchronizationError && !isSyncInProgress ? getQBORefreshTokenExpiryStatus(policy) : undefined; + const isQBOTokenExpiringSoon = !!qboTokenExpiryStatus; + const qboTokenExpiryDate = isQBOTokenExpiringSoon ? getQBORefreshTokenExpiryDate(policy) : undefined; + const shouldShowEnterCredentials = !!connectedIntegration && (hasAuthError || isSageIntacct || isQBOTokenExpiringSoon); + + // Get the last successful date of the integration. Then, if `connectionSyncProgress` is the same integration displayed and the state is 'jobDone', get the more recent update time of the two. + const successfulDate = getIntegrationLastSuccessfulDate( + getLocalDateFromDatetime, + connectedIntegration ? policy?.connections?.[connectedIntegration] : undefined, + connectedIntegration === connectionSyncProgress?.connectionName ? connectionSyncProgress : undefined, + ); + const datetimeToRelative = successfulDate ? getDatetimeToRelative(successfulDate) : ''; + + const hasSyncError = shouldShowSyncError(policy, isSyncInProgress, accountingIntegrations); + const hasUnsupportedNDIntegration = !isEmptyObject(policy?.connections) && hasSupportedOnlyOnOldDotIntegration(policy); + + const tenants = getXeroTenants(policy); + const currentXeroOrganization = findCurrentXeroOrganization(tenants, policy?.connections?.xero?.config?.tenantID); + const shouldShowSynchronizationError = !!synchronizationError; + const shouldShowReinstallConnectorMenuItem = shouldShowSynchronizationError && connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBD; + const shouldShowCardReconciliationOption = Object.values(allCardSettings ?? {})?.some((cardSetting) => isExpensifyCardFullySetUp(policy, cardSetting)); + const shouldShowReconnect = hasAuthError && connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.CERTINIA; + let credentialsMenuTextKey: Parameters[0] = 'workspace.accounting.enterCredentials'; + if (shouldShowReconnect || isQBOTokenExpiringSoon) { + credentialsMenuTextKey = 'workspace.accounting.reconnect'; + } else if (isSageIntacct && !hasAuthError) { + credentialsMenuTextKey = 'workspace.accounting.updateCredentials'; + } + + const overflowMenu: ThreeDotsMenuProps['menuItems'] = [ + ...(shouldShowReinstallConnectorMenuItem + ? [ + { + icon: icons.CircularArrowBackwards, + text: translate('workspace.accounting.reinstall'), + onSelected: () => startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBD}), + shouldCallAfterModalHide: true, + disabled: isOffline, + iconRight: icons.NewWindow, + }, + ] + : []), + ...(shouldShowEnterCredentials + ? [ + { + icon: icons.Key, + text: translate(credentialsMenuTextKey), + onSelected: () => { + if (isSageIntacct && policyID) { + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTER_CREDENTIALS.getRoute(policyID)); + return; + } + startIntegrationFlow({name: connectedIntegration, isIntuitEnterpriseSuite: isConnectedToIntuitEnterpriseSuite}); + }, + shouldCallAfterModalHide: true, + disabled: isOffline, + iconRight: icons.NewWindow, + }, + ] + : []), + ...(!hasAuthError + ? [ + { + icon: icons.Sync, + text: translate('workspace.accounting.syncNow'), + onSelected: () => syncConnection(policy, connectedIntegration), + disabled: isOffline, + }, + ] + : []), + { + icon: icons.Trashcan, + text: translate('workspace.accounting.disconnect'), + onSelected: () => { + showConfirmModal({ + title: translate('workspace.accounting.disconnectTitle', connectedIntegrationDisplayName), + prompt: translate('workspace.accounting.disconnectPrompt', connectedIntegrationDisplayName), + confirmText: translate('workspace.accounting.disconnect'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }).then(({action}) => { + if (action !== ModalActions.CONFIRM || !connectedIntegration || !policyID) { + return; + } + onDisconnectConfirmed?.(); + removePolicyConnection(policy, connectedIntegration); + }); + }, + shouldCallAfterModalHide: true, + }, + ]; + + useEffect(() => { + if (!policyID || !policy?.areExpensifyCardsEnabled || !workspaceAccountID) { + return; + } + openPolicyExpensifyCardsPage(policyID, workspaceAccountID); + }, [policyID, policy?.areExpensifyCardsEnabled, workspaceAccountID]); + + const getIntegrationSpecificMenuItems = () => { + const sageIntacctEntityList = policy?.connections?.intacct?.data?.entities ?? []; + const netSuiteSubsidiaryList = policy?.connections?.netsuite?.options?.data?.subsidiaryList ?? []; + const rilletSubsidiaryList = policy?.connections?.rillet?.data?.subsidiaries; + const dualEntryCompanyList = policy?.connections?.dualEntry?.data?.companies; + const campfireSubsidiaryList = policy?.connections?.campfire?.data?.subsidiaries; + const businessCentralCompanyList = policy?.connections?.businessCentral?.data?.companies; + const certiniaConfig = policy?.connections?.financialforce?.config; + const certiniaCompanies = policy?.connections?.financialforce?.data?.companies ?? []; + const certiniaCompanyID = getCertiniaSelectedCompanyID(certiniaConfig); + const certiniaCompanyField = certiniaConfig?.hasPSA ? CONST.CERTINIA_CONFIG.COMPANY_ID : CONST.CERTINIA_CONFIG.COMPANY; + const selectedCertiniaCompany = certiniaCompanies.find((company) => company.id === certiniaCompanyID); + switch (connectedIntegration) { + case CONST.POLICY.CONNECTIONS.NAME.XERO: + return !policy?.connections?.xero?.data?.tenants + ? {} + : { + description: translate('workspace.xero.organization'), + iconRight: icons.ArrowRight, + title: getCurrentXeroOrganizationName(policy), + wrapperStyle: menuItemWrapperStyle, + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting && tenants.length > 1, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + onPress: + canWriteAccounting && tenants.length > 1 + ? () => { + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_ORGANIZATION.getRoute(policyID, currentXeroOrganization?.id)); + } + : undefined, + pendingAction: settingsPendingAction([CONST.XERO_CONFIG.TENANT_ID], policy?.connections?.xero?.config?.pendingFields), + brickRoadIndicator: areSettingsInErrorFields([CONST.XERO_CONFIG.TENANT_ID], policy?.connections?.xero?.config?.errorFields) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: + return !policy?.connections?.netsuite?.options?.config?.subsidiary + ? {} + : { + description: translate('workspace.netsuite.subsidiary'), + iconRight: icons.ArrowRight, + title: policy?.connections?.netsuite?.options?.config?.subsidiary ?? '', + wrapperStyle: menuItemWrapperStyle, + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting && netSuiteSubsidiaryList?.length > 1, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: policy?.connections?.netsuite?.options?.config?.pendingFields?.subsidiary, + brickRoadIndicator: policy?.connections?.netsuite?.options?.config?.errorFields?.subsidiary ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: + canWriteAccounting && netSuiteSubsidiaryList?.length > 1 + ? () => { + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR.getRoute(policyID)); + } + : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT: + return !sageIntacctEntityList.length + ? {} + : { + description: translate('workspace.intacct.entity'), + iconRight: icons.ArrowRight, + title: getCurrentSageIntacctEntityName(policy, translate('workspace.common.topLevel')), + wrapperStyle: menuItemWrapperStyle, + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: policy?.connections?.intacct?.config?.pendingFields?.entity, + brickRoadIndicator: policy?.connections?.intacct?.config?.errorFields?.entity ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: canWriteAccounting ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTITY.getRoute(policyID)) : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.QBO: + return !policy?.connections?.quickbooksOnline?.config?.companyName + ? {} + : { + description: translate(isConnectedToIntuitEnterpriseSuite ? 'workspace.qbo.entity' : 'workspace.qbo.connectedTo'), + iconRight: isConnectedToIntuitEnterpriseSuite ? icons.ArrowRight : undefined, + title: policy?.connections?.quickbooksOnline?.config?.companyName, + wrapperStyle: menuItemWrapperStyle, + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: isConnectedToIntuitEnterpriseSuite && canWriteAccounting, + shouldShowDescriptionOnTop: true, + interactive: isConnectedToIntuitEnterpriseSuite && canWriteAccounting, + pendingAction: policy?.connections?.quickbooksOnline?.config?.pendingFields?.realmId, + brickRoadIndicator: policy?.connections?.quickbooksOnline?.config?.errorFields?.realmId ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: + isConnectedToIntuitEnterpriseSuite && canWriteAccounting && policyID + ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_INTUIT_ENTERPRISE_SUITE_ENTITY_SELECTOR.getRoute(policyID)) + : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.CERTINIA: + return !isCertiniaFFAConnection(certiniaConfig) + ? {} + : { + description: translate('workspace.certinia.company'), + iconRight: icons.ArrowRight, + title: selectedCertiniaCompany?.name ?? certiniaCompanyID ?? translate('common.none'), + wrapperStyle: menuItemWrapperStyle, + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: settingsPendingAction([certiniaCompanyField], certiniaConfig?.pendingFields), + brickRoadIndicator: areSettingsInErrorFields([certiniaCompanyField], certiniaConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: canWriteAccounting ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CERTINIA_COMPANY_SELECTOR.getRoute(policyID)) : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.RILLET: + return !rilletSubsidiaryList?.length + ? {} + : { + description: translate('workspace.rillet.subsidiary'), + iconRight: icons.ArrowRight, + title: rilletSubsidiaryList?.find((subsidiary) => subsidiary.id === policy?.connections?.rillet?.config?.subsidiaryID)?.tradeName ?? '', + wrapperStyle: menuItemWrapperStyle, + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting && rilletSubsidiaryList && rilletSubsidiaryList.length > 1, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: policy?.connections?.rillet?.config.pendingFields?.subsidiaryID, + brickRoadIndicator: policy?.connections?.rillet?.config.errorFields?.subsidiaryID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: + policyID && canWriteAccounting && rilletSubsidiaryList && rilletSubsidiaryList.length > 1 + ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_SUBSIDIARY_SELECTOR.getRoute(policyID)) + : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: + return !dualEntryCompanyList?.length + ? {} + : { + description: translate('workspace.dualEntry.subsidiary'), + iconRight: icons.ArrowRight, + title: dualEntryCompanyList?.find((company) => company.id === policy?.connections?.dualEntry?.config?.subsidiaryID)?.name ?? '', + wrapperStyle: menuItemWrapperStyle, + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting && dualEntryCompanyList && dualEntryCompanyList.length > 1, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: policy?.connections?.dualEntry?.config.pendingFields?.subsidiaryID, + brickRoadIndicator: policy?.connections?.dualEntry?.config.errorFields?.subsidiaryID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: + policyID && canWriteAccounting && dualEntryCompanyList && dualEntryCompanyList.length > 1 + ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_DUALENTRY_SUBSIDIARY_SELECTOR.getRoute(policyID)) + : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: + return !campfireSubsidiaryList?.length + ? {} + : { + description: translate('workspace.campfire.subsidiary'), + iconRight: icons.ArrowRight, + title: campfireSubsidiaryList?.find((subsidiary) => subsidiary.id === policy?.connections?.campfire?.config?.subsidiaryID)?.name ?? '', + wrapperStyle: menuItemWrapperStyle, + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting && campfireSubsidiaryList && campfireSubsidiaryList.length > 1, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: policy?.connections?.campfire?.config.pendingFields?.subsidiaryID, + brickRoadIndicator: policy?.connections?.campfire?.config.errorFields?.subsidiaryID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: + policyID && canWriteAccounting && campfireSubsidiaryList && campfireSubsidiaryList.length > 1 + ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CAMPFIRE_SUBSIDIARY_SELECTOR.getRoute(policyID)) + : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL: + return !businessCentralCompanyList?.length + ? {} + : { + description: translate('workspace.businessCentral.subsidiary'), + iconRight: icons.ArrowRight, + title: businessCentralCompanyList.find((company) => company.id === policy?.connections?.businessCentral?.config?.companyID)?.displayName ?? '', + wrapperStyle: menuItemWrapperStyle, + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting && businessCentralCompanyList.length > 1, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: policy?.connections?.businessCentral?.config.pendingFields?.companyID, + brickRoadIndicator: policy?.connections?.businessCentral?.config.errorFields?.companyID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: + policyID && canWriteAccounting && businessCentralCompanyList.length > 1 + ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_COMPANY_SELECTOR.getRoute(policyID)) + : undefined, + }; + + default: + return undefined; + } + }; + const integrationSpecificMenuItems = getIntegrationSpecificMenuItems(); + + const getConnectionDetails = () => { + if (!connectedIntegration || !policyID) { + return undefined; + } + const isConnectionVerified = !isConnectionUnverified(policy, connectedIntegration); + const integrationData = getAccountingIntegrationData(connectedIntegration, policyID, translate, existingConnections, { + policy, + canUseNetSuiteUSATax: isBetaEnabled(CONST.BETAS.NETSUITE_USA_TAX), + expensifyIcons: accountingIcons, + cardFeeds, + cardList: cardLists, + isIntuitEnterpriseSuiteOverride: isConnectedToIntuitEnterpriseSuite, + isUnifiedConnectionsBetaEnabled, + }); + let connectionMessage; + if (isSyncInProgress && connectionSyncStage) { + connectionMessage = translate('workspace.accounting.connections.syncStageName', connectionSyncStage, integrationData?.title); + } else if (!isConnectionVerified) { + connectionMessage = translate('workspace.accounting.notSync'); + } else { + connectionMessage = translate('workspace.accounting.lastSync', datetimeToRelative); + } + + const configurationOptions = canWriteAccounting + ? [ + { + icon: icons.Pencil, + iconRight: icons.ArrowRight, + shouldShowRightIcon: true, + title: translate('workspace.accounting.import'), + wrapperStyle: menuItemWrapperStyle, + onPress: integrationData?.onImportPagePress, + brickRoadIndicator: areSettingsInErrorFields(integrationData?.subscribedImportSettings, integrationData?.errorFields) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined, + pendingAction: settingsPendingAction(integrationData?.subscribedImportSettings, integrationData?.pendingFields), + }, + { + icon: icons.Send, + iconRight: icons.ArrowRight, + shouldShowRightIcon: true, + title: translate('workspace.accounting.export'), + wrapperStyle: menuItemWrapperStyle, + onPress: integrationData?.onExportPagePress, + brickRoadIndicator: + areSettingsInErrorFields(integrationData?.subscribedExportSettings, integrationData?.errorFields) || + shouldShowQBOReimbursableExportDestinationAccountError(policy) || + integrationData?.externalSubscribedExportSettingsHasErrorFields + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined, + pendingAction: + settingsPendingAction(integrationData?.subscribedExportSettings, integrationData?.pendingFields) ?? integrationData?.externalSubscribedExportSettingsPendingAction, + }, + ...(shouldShowCardReconciliationOption && integrationData?.onCardReconciliationPagePress + ? [ + { + icon: icons.ExpensifyCard, + iconRight: icons.ArrowRight, + shouldShowRightIcon: true, + title: translate('workspace.accounting.cardReconciliation'), + wrapperStyle: menuItemWrapperStyle, + onPress: integrationData?.onCardReconciliationPagePress, + }, + ] + : []), + { + icon: icons.Gear, + iconRight: icons.ArrowRight, + shouldShowRightIcon: true, + title: translate('workspace.accounting.advanced'), + wrapperStyle: menuItemWrapperStyle, + onPress: integrationData?.onAdvancedPagePress, + brickRoadIndicator: areSettingsInErrorFields(integrationData?.subscribedAdvancedSettings, integrationData?.errorFields) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined, + pendingAction: settingsPendingAction(integrationData?.subscribedAdvancedSettings, integrationData?.pendingFields), + }, + ] + : []; + + const settingsMenuItems: MenuItemData[] = [ + ...(isEmptyObject(integrationSpecificMenuItems) || shouldShowSynchronizationError || !hasAccountingConnection ? [] : [integrationSpecificMenuItems]), + ...(!hasAccountingConnection || !isConnectionVerified ? [] : configurationOptions), + ]; + return {icon: integrationData?.icon, title: integrationData?.title, connectionMessage, settingsMenuItems}; + }; + const connectionDetails = getConnectionDetails(); + + let qboTokenExpiryHint; + if (qboTokenExpiryDate && canWriteAccounting) { + const formattedExpiryDate = DateUtils.formatWithUTCTimeZone(qboTokenExpiryDate.toISOString(), CONST.DATE.MONTH_DAY_YEAR_FORMAT, dateFnsLocale); + qboTokenExpiryHint = ( + <> + {translate( + qboTokenExpiryStatus === CONST.POLICY.CONNECTIONS.QBO_REFRESH_TOKEN_EXPIRY_STATUS.EXPIRED + ? 'workspace.accounting.qboConnectionExpired' + : 'workspace.accounting.qboConnectionExpiring', + {date: formattedExpiryDate}, + )}{' '} + startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBO, isIntuitEnterpriseSuite: isConnectedToIntuitEnterpriseSuite})}> + {translate('workspace.accounting.reconnect')} + + + ); + } + + const oldDotPolicyConnectionsURL = policyID ? `${getOldDotURLFromEnvironment(environment)}/${CONST.OLDDOT_URLS.POLICY_CONNECTIONS_URL_ENCODED(policyID)}` : ''; + + return { + accountingIntegrations, + connectedIntegration, + connectedIntegrationDisplayName, + isConnectedToIntuitEnterpriseSuite, + isSyncInProgress, + hasAccountingConnection, + hasSyncError, + hasUnsupportedNDIntegration, + synchronizationError, + canWriteAccounting, + showReadOnlyModal, + overflowMenu, + connectionDetails, + qboTokenExpiryHint, + oldDotPolicyConnectionsURL, + getAvailableIntegrationData, + }; +} + +export default useConnectedAccountingIntegration; diff --git a/src/pages/workspace/accounting/utils.tsx b/src/pages/workspace/accounting/utils.tsx index 36b3d7b1f032..a40d0b0f9b54 100644 --- a/src/pages/workspace/accounting/utils.tsx +++ b/src/pages/workspace/accounting/utils.tsx @@ -21,6 +21,8 @@ import {getCurrentConnectionName} from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; +import {getAccountingConnectionsRoute} from '@pages/workspace/connections/utils'; + import type {ThemeStyles} from '@styles/index'; import {getTrackingCategories} from '@userActions/connections/Xero'; @@ -63,36 +65,56 @@ function getCurrentAccountingIntegrationName(policy: OnyxEntry, translat return currentConnectionName === CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.quickbooksOnline ? getQuickbooksOnlineIntegrationName(policy, translate) : currentConnectionName; } -// eslint-disable-next-line @typescript-eslint/max-params +type AccountingIntegrationIcons = Record< + | 'IntacctSquare' + | 'IntuitSquare' + | 'QBOSquare' + | 'XeroSquare' + | 'NetSuiteSquare' + | 'QBDSquare' + | 'CertiniaSquare' + | 'RilletSquare' + | 'DualEntrySquare' + | 'CampfireSquare' + | 'BusinessCentralSquare', + IconAsset +>; + +type GetAccountingIntegrationDataOptions = { + policy?: Policy; + key?: number; + integrationToDisconnect?: ConnectionName; + shouldDisconnectIntegrationBeforeConnecting?: boolean; + canUseNetSuiteUSATax?: boolean; + expensifyIcons?: AccountingIntegrationIcons; + cardFeeds?: CombinedCardFeeds; + cardList?: Record; + + /** Picks QBO or Intuit Enterprise Suite explicitly instead of following the existing connection */ + isIntuitEnterpriseSuiteOverride?: boolean; + + /** Routes the connect flows through the Connections page instead of the Accounting page */ + isUnifiedConnectionsBetaEnabled?: boolean; +}; + function getAccountingIntegrationData( connectionName: PolicyConnectionName, policyID: string, translate: LocaleContextProps['translate'], existingConnections: {sageIntacct: boolean; qbd: boolean; certinia: boolean; rillet: boolean; dualEntry: boolean; campfire: boolean}, - policy?: Policy, - key?: number, - integrationToDisconnect?: ConnectionName, - shouldDisconnectIntegrationBeforeConnecting?: boolean, - canUseNetSuiteUSATax?: boolean, - expensifyIcons?: Record< - | 'IntacctSquare' - | 'IntuitSquare' - | 'QBOSquare' - | 'XeroSquare' - | 'NetSuiteSquare' - | 'QBDSquare' - | 'CertiniaSquare' - | 'RilletSquare' - | 'DualEntrySquare' - | 'CampfireSquare' - | 'BusinessCentralSquare', - IconAsset - >, - cardFeeds?: CombinedCardFeeds, - cardList?: Record, - isIntuitEnterpriseSuiteOverride?: boolean, + { + policy, + key, + integrationToDisconnect, + shouldDisconnectIntegrationBeforeConnecting, + canUseNetSuiteUSATax, + expensifyIcons, + cardFeeds, + cardList, + isIntuitEnterpriseSuiteOverride, + isUnifiedConnectionsBetaEnabled = false, + }: GetAccountingIntegrationDataOptions = {}, ): AccountingIntegration | undefined { - const basePath = ROUTES.POLICY_ACCOUNTING.getRoute(policyID); const qboConfig = policy?.connections?.quickbooksOnline?.config; const qbdConfig = policy?.connections?.quickbooksDesktop?.config; // An explicit QBO or IES selection must take precedence over the existing connection identity. @@ -101,16 +123,16 @@ function getAccountingIntegrationData( const netsuiteSelectedSubsidiary = (policy?.connections?.netsuite?.options?.data?.subsidiaryList ?? []).find((subsidiary) => subsidiary.internalID === netsuiteConfig?.subsidiaryID); const getBackToAfterWorkspaceUpgradeRouteForIntacct = () => { if (integrationToDisconnect) { - return ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); + return getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); } if (existingConnections.sageIntacct) { return ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXISTING_CONNECTIONS.getRoute(policyID); } - return createDynamicRoute(DYNAMIC_ROUTES.SAGE_INTACCT_PREREQUISITES.path, basePath); + return createDynamicRoute(DYNAMIC_ROUTES.SAGE_INTACCT_PREREQUISITES.path, getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID)); }; const getBackToAfterWorkspaceUpgradeRouteForQBD = () => { if (integrationToDisconnect) { - return ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); + return getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); } if (existingConnections.qbd) { return ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXISTING_CONNECTIONS.getRoute(policyID); @@ -119,7 +141,7 @@ function getAccountingIntegrationData( }; const getBackToAfterWorkspaceUpgradeRouteForCertinia = () => { if (integrationToDisconnect) { - return ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); + return getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); } if (existingConnections.certinia) { return ROUTES.POLICY_ACCOUNTING_CERTINIA_EXISTING_CONNECTIONS.getRoute(policyID); @@ -128,7 +150,7 @@ function getAccountingIntegrationData( }; const getBackToAfterWorkspaceUpgradeRouteForRillet = () => { if (integrationToDisconnect) { - return ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); + return getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); } if (existingConnections.rillet) { return ROUTES.POLICY_ACCOUNTING_RILLET_EXISTING_CONNECTIONS.getRoute(policyID); @@ -137,7 +159,7 @@ function getAccountingIntegrationData( }; const getBackToAfterWorkspaceUpgradeRouteForDualEntry = () => { if (integrationToDisconnect) { - return ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); + return getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); } if (existingConnections.dualEntry) { return ROUTES.POLICY_ACCOUNTING_DUALENTRY_EXISTING_CONNECTIONS.getRoute(policyID); @@ -146,7 +168,7 @@ function getAccountingIntegrationData( }; const getBackToAfterWorkspaceUpgradeRouteForCampfire = () => { if (integrationToDisconnect) { - return ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); + return getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); } if (existingConnections.campfire) { return ROUTES.POLICY_ACCOUNTING_CAMPFIRE_EXISTING_CONNECTIONS.getRoute(policyID); @@ -175,7 +197,7 @@ function getAccountingIntegrationData( CONST.QUICKBOOKS_CONFIG.SYNC_TAX, CONST.QUICKBOOKS_CONFIG.SYNC_ITEMS, ], - onExportPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT.path, basePath)), + onExportPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT.path)), subscribedExportSettings: [ CONST.QUICKBOOKS_CONFIG.EXPORT, CONST.QUICKBOOKS_CONFIG.EXPORT_DATE, @@ -211,7 +233,8 @@ function getAccountingIntegrationData( ? { workspaceUpgradeNavigationDetails: { integrationAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.intuitEnterpriseSuite.alias, - backToAfterWorkspaceUpgradeRoute: ROUTES.POLICY_ACCOUNTING.getRoute( + backToAfterWorkspaceUpgradeRoute: getAccountingConnectionsRoute( + isUnifiedConnectionsBetaEnabled, policyID, connectionName, integrationToDisconnect, @@ -240,7 +263,7 @@ function getAccountingIntegrationData( CONST.XERO_CONFIG.IMPORT_TAX_RATES, ...getTrackingCategories(policy).map((category) => `${CONST.XERO_CONFIG.TRACKING_CATEGORY_PREFIX}${category.id}`), ], - onExportPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path, basePath)), + onExportPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path)), subscribedExportSettings: [ CONST.XERO_CONFIG.EXPORTER, CONST.XERO_CONFIG.BILL_DATE, @@ -328,7 +351,7 @@ function getAccountingIntegrationData( workspaceUpgradeNavigationDetails: { integrationAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.netsuite.alias, backToAfterWorkspaceUpgradeRoute: integrationToDisconnect - ? ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting) + ? getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting) : ROUTES.POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT.getRoute(policyID, getInitialSubPageForNetsuiteTokenInput(policy)), }, pendingFields: {...netsuiteConfig?.pendingFields, ...policy?.connections?.netsuite?.config?.pendingFields, ...policy?.connections?.netsuite?.options?.config?.pendingFields}, @@ -351,7 +374,7 @@ function getAccountingIntegrationData( CONST.SAGE_INTACCT_CONFIG.TAX, ...(policy?.connections?.intacct?.config?.mappings?.dimensions ?? []).map((dimension) => `${CONST.SAGE_INTACCT_CONFIG.DIMENSION_PREFIX}${dimension.dimension}`), ], - onExportPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID))), + onExportPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT.path)), subscribedExportSettings: [ CONST.SAGE_INTACCT_CONFIG.EXPORTER, CONST.SAGE_INTACCT_CONFIG.EXPORT_DATE, @@ -392,8 +415,7 @@ function getAccountingIntegrationData( ), onImportPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_IMPORT.getRoute(policyID)), onExportPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT.path)), - onAdvancedPagePress: () => - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_DESKTOP_ADVANCED.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID))), + onAdvancedPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_DESKTOP_ADVANCED.path)), subscribedImportSettings: [ CONST.QUICKBOOKS_DESKTOP_CONFIG.ENABLE_NEW_CATEGORIES, CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CLASSES, @@ -475,9 +497,9 @@ function getAccountingIntegrationData( ), onImportPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CERTINIA_IMPORT.getRoute(policyID)), subscribedImportSettings: certiniaSubscribedImportSettings, - onExportPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_EXPORT.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID))), + onExportPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_EXPORT.path)), subscribedExportSettings: certiniaSubscribedExportSettings, - onAdvancedPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_ADVANCED.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID))), + onAdvancedPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_ADVANCED.path)), subscribedAdvancedSettings: certiniaSubscribedAdvancedSettings, onCardReconciliationPagePress: () => Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_CARD_RECONCILIATION.getRoute(policyID, CONST.POLICY.CONNECTIONS.ROUTE.CERTINIA)), pendingFields: certiniaConfig?.pendingFields, @@ -694,7 +716,7 @@ function getAccountingIntegrationData( workspaceUpgradeNavigationDetails: { integrationAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.businessCentral.alias, backToAfterWorkspaceUpgradeRoute: integrationToDisconnect - ? ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting) + ? getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting) : ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_PREREQUISITES.getRoute(policyID), }, pendingFields: policy?.connections?.businessCentral?.config?.pendingFields, diff --git a/src/pages/workspace/accounting/xero/XeroOrganizationConfigurationPage.tsx b/src/pages/workspace/accounting/xero/XeroOrganizationConfigurationPage.tsx index e8882f8f7082..08f598b635b9 100644 --- a/src/pages/workspace/accounting/xero/XeroOrganizationConfigurationPage.tsx +++ b/src/pages/workspace/accounting/xero/XeroOrganizationConfigurationPage.tsx @@ -22,7 +22,6 @@ import variables from '@styles/variables'; import {clearXeroErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import React, {useMemo} from 'react'; @@ -95,7 +94,7 @@ function XeroOrganizationConfigurationPage({ onSelectRow={saveSelection} initiallyFocusedOptionKey={currentXeroOrganization?.id} headerContent={listHeaderComponent} - onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING.getRoute(policyID))} + onBackButtonPress={() => Navigation.goBack()} title="workspace.xero.organization" listEmptyContent={listEmptyContent} pendingAction={xeroConfig?.pendingFields?.tenantID} diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroBankAccountSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroBankAccountSelectPage.tsx index 4a4b68448689..f90d18ff8d0e 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroBankAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroBankAccountSelectPage.tsx @@ -3,13 +3,13 @@ import type {SelectorType} from '@components/SelectionScreen'; import SelectionScreen from '@components/SelectionScreen'; import Text from '@components/Text'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearXeroErrorField} from '@libs/actions/Policy/Policy'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {getXeroBankAccounts, settingsPendingAction} from '@libs/PolicyUtils'; @@ -21,7 +21,7 @@ import variables from '@styles/variables'; import {updateXeroExportNonReimbursableAccount} from '@userActions/connections/Xero'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; @@ -38,9 +38,11 @@ function DynamicXeroBankAccountSelectPage({policy}: WithPolicyConnectionsProps) [config?.export?.nonReimbursableAccount, policy], ); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT_BANK_ACCOUNT_SELECT.path); + const goBack = useCallback(() => { - Navigation.goBack(policyID ? createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID)) : undefined); - }, [policyID]); + Navigation.goBack(backPath); + }, [backPath]); const listHeaderComponent = useMemo( () => ( diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx index 92940342da7c..7b819badc1ee 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx @@ -18,7 +18,7 @@ import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnec import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import React, {useMemo} from 'react'; @@ -41,7 +41,6 @@ function DynamicXeroExportConfigurationPage({policy}: WithPolicyConnectionsProps // invalid the moment the new sync completes. const isVendorFeatureAvailable = isXeroVendorMatchingActive(policy); const defaultVendorName = getXeroSupplierByID(policy, defaultVendor)?.name ?? ''; - const exportPath = policyID ? `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path}` : undefined; const workspaceAccountID = useWorkspaceAccountID(policyID); const [cardSettings] = useOnyx(getTravelBillingCardSettingsKey(workspaceAccountID)); const travelSettings = getCardSettings(cardSettings, CONST.TRAVEL.PROGRAM_TRAVEL_US); @@ -92,7 +91,7 @@ function DynamicXeroExportConfigurationPage({policy}: WithPolicyConnectionsProps { title: translate('workspace.xero.bankTransactions'), description: translate('workspace.common.travelInvoicing'), - onPress: !exportPath ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_TRAVEL_BILLING_CONFIGURATION.path, exportPath)), + onPress: !policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_TRAVEL_BILLING_CONFIGURATION.path)), subscribedSettings: [CONST.XERO_CONFIG.TRAVEL_BILLING_PAYABLE_ACCOUNT], }, ] diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx index bf9a55ee0bc4..6986442cc594 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx @@ -3,6 +3,7 @@ import type {SelectorType} from '@components/SelectionScreen'; import SelectionScreen from '@components/SelectionScreen'; import Text from '@components/Text'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -10,7 +11,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {updateManyPolicyConnectionConfigs} from '@libs/actions/connections'; import {clearXeroErrorField} from '@libs/actions/Policy/Policy'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {getXeroSuppliers, isXeroVendorMatchingActive, settingsPendingAction, sortVendors} from '@libs/PolicyUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; @@ -21,7 +21,7 @@ import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; @@ -96,9 +96,11 @@ function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicy [shouldShowClearOption, clearOption, filteredSupplierOptions], ); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT.path); + const goBack = useCallback(() => { - Navigation.goBack(policyID ? createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID)) : undefined); - }, [policyID]); + Navigation.goBack(backPath); + }, [backPath]); const selectSupplier = useCallback( ({value}: SelectorType) => { diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroPreferredExporterSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroPreferredExporterSelectPage.tsx index 834ad2af7603..2cc45f201673 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroPreferredExporterSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroPreferredExporterSelectPage.tsx @@ -3,12 +3,12 @@ import SelectionScreen from '@components/SelectionScreen'; import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearXeroErrorField} from '@libs/actions/Policy/Policy'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import {getAdminEmployees, isExpensifyTeam, settingsPendingAction} from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; @@ -19,7 +19,7 @@ import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import {updateXeroExportExporter} from '@userActions/connections/Xero'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useMemo} from 'react'; @@ -38,9 +38,10 @@ function DynamicXeroPreferredExporterSelectPage({policy}: WithPolicyConnectionsP const {login: currentUserLogin} = useCurrentUserPersonalDetails(); const policyID = policy?.id; + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_PREFERRED_EXPORTER_SELECT.path); const goBack = useCallback(() => { - Navigation.goBack(policyID ? createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID)) : undefined); - }, [policyID]); + Navigation.goBack(backPath); + }, [backPath]); const data: CardListItem[] = useMemo(() => { if (!isEmpty(policyOwner) && isEmpty(exporters)) { diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillDateSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillDateSelectPage.tsx index 91b75802f8fe..036381960fb0 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillDateSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillDateSelectPage.tsx @@ -3,12 +3,12 @@ import SelectionScreen from '@components/SelectionScreen'; import type {SelectorType} from '@components/SelectionScreen'; import Text from '@components/Text'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearXeroErrorField} from '@libs/actions/Policy/Policy'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import {settingsPendingAction} from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; @@ -19,7 +19,7 @@ import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import {updateXeroExportBillDate} from '@userActions/connections/Xero'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {ValueOf} from 'type-fest'; @@ -43,9 +43,11 @@ function DynamicXeroPurchaseBillDateSelectPage({policy}: WithPolicyConnectionsPr isSelected: config?.export?.billDate === dateType, })); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT_PURCHASE_BILL_DATE_SELECT.path); + const goBack = useCallback(() => { - Navigation.goBack(policyID ? createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID)) : undefined); - }, [policyID]); + Navigation.goBack(backPath); + }, [backPath]); const headerContent = useMemo( () => ( diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillStatusSelectorPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillStatusSelectorPage.tsx index e898ed23f7d9..bdebc13610b9 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillStatusSelectorPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillStatusSelectorPage.tsx @@ -3,12 +3,12 @@ import SelectionScreen from '@components/SelectionScreen'; import type {SelectorType} from '@components/SelectionScreen'; import Text from '@components/Text'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearXeroErrorField} from '@libs/actions/Policy/Policy'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import {settingsPendingAction} from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; @@ -19,7 +19,7 @@ import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import {updateXeroExportBillStatus} from '@userActions/connections/Xero'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {ValueOf} from 'type-fest'; @@ -38,9 +38,11 @@ function DynamicXeroPurchaseBillStatusSelectorPage({policy}: WithPolicyConnectio const {config} = policy?.connections?.xero ?? {}; const invoiceStatus = config?.export?.billStatus?.purchase; + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_BILL_STATUS_SELECTOR.path); + const goBack = useCallback(() => { - Navigation.goBack(policyID ? createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID)) : undefined); - }, [policyID]); + Navigation.goBack(backPath); + }, [backPath]); const data: MenuListItem[] = Object.values(CONST.XERO_CONFIG.INVOICE_STATUS).map((status) => ({ value: status, diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroTravelBillingConfigurationPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroTravelBillingConfigurationPage.tsx index 51185e7a303e..40056392baaf 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroTravelBillingConfigurationPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroTravelBillingConfigurationPage.tsx @@ -18,7 +18,7 @@ import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnec import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import React from 'react'; @@ -31,7 +31,6 @@ function DynamicXeroTravelBillingConfigurationPage({policy}: WithPolicyConnectio const policyID = policy?.id ?? String(CONST.DEFAULT_NUMBER_ID); const config = policy?.connections?.xero?.config; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_TRAVEL_BILLING_CONFIGURATION.path); - const travelBillingPath = `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_TRAVEL_BILLING_CONFIGURATION.path}`; const {bankAccounts} = policy?.connections?.xero?.data ?? {}; const travelPayableAccount = bankAccounts?.find((account) => account.id === config?.export?.travelInvoicingPayableAccountID); @@ -68,7 +67,7 @@ function DynamicXeroTravelBillingConfigurationPage({policy}: WithPolicyConnectio if (!policyID) { return; } - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT.path, travelBillingPath)); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT.path)); }} value={travelPayableAccount?.name} > diff --git a/src/pages/workspace/connections/ConnectionCard.tsx b/src/pages/workspace/connections/ConnectionCard.tsx new file mode 100644 index 000000000000..af12aeef5495 --- /dev/null +++ b/src/pages/workspace/connections/ConnectionCard.tsx @@ -0,0 +1,131 @@ +/** + * A single integration tile on the Connections page. Connected integrations show their status and a Configure or Fix + * button, and the rest show a "+" that starts the connection flow. + */ +import ActivityIndicator from '@components/ActivityIndicator'; +import Badge from '@components/Badge'; +import Button from '@components/Button'; +import Icon from '@components/Icon'; +import MenuItem from '@components/MenuItem'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; + +import React from 'react'; +import {View} from 'react-native'; + +import type {ConnectionListing} from './types'; + +type ConnectionCardProps = { + listing: ConnectionListing; + + /** Stacks the card at full width instead of wrapping it by its minimum width */ + shouldUseFullWidth: boolean; +}; + +function ConnectionCard({listing, shouldUseFullWidth}: ConnectionCardProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const theme = useTheme(); + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const icons = useMemoizedLazyExpensifyIcons(['Building', 'Plus']); + const {title, icon, status, offer, onConnect, onConfigure, registerConnectButton, isLoading} = listing; + + const connectIcon = ( + + {isLoading ? ( + + ) : ( + + )} + + ); + + let statusComponent = ( + + ); + if (status?.isSyncing) { + statusComponent = ( + + + + ); + } + + return ( + + + ) : undefined + } + description={status?.message} + titleAddon={ + offer ? ( + + ) : undefined + } + numberOfLinesDescription={1} + wrapperStyle={[styles.pv4, styles.ph4]} + onPress={status ? onConfigure : onConnect} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.CONNECTIONS.CARD} + disabled={!status && (isOffline || !!isLoading)} + shouldShowRightComponent + rightComponent={status ? statusComponent : connectIcon} + /> + + ); +} + +export default ConnectionCard; diff --git a/src/pages/workspace/connections/ConnectionsAccountingPage.tsx b/src/pages/workspace/connections/ConnectionsAccountingPage.tsx new file mode 100644 index 000000000000..af7513824412 --- /dev/null +++ b/src/pages/workspace/connections/ConnectionsAccountingPage.tsx @@ -0,0 +1,166 @@ +import ActivityIndicator from '@components/ActivityIndicator'; +import UserAvatar from '@components/Avatar/UserAvatar'; +import FormHelpMessage from '@components/FormHelpMessage'; +import Header from '@components/Header'; +import MenuItem from '@components/MenuItem'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; + +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; + +import Navigation from '@navigation/Navigation'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import {AccountingContextProvider} from '@pages/workspace/accounting/AccountingContext'; +import type {PolicyAccountingPageProps} from '@pages/workspace/accounting/types'; +import useConnectedAccountingIntegration from '@pages/workspace/accounting/useConnectedAccountingIntegration'; +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; + +import {openOldDotLink} from '@userActions/Link'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +import React from 'react'; +import {View} from 'react-native'; + +/** Settings for the workspace's connected accounting integration, opened from the Connections page. */ +function ConnectionsAccountingPage({policy}: PolicyAccountingPageProps) { + useWorkspaceDocumentTitle(policy?.name, 'workspace.common.accounting'); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); + const policyID = policy?.id; + const { + connectedIntegration, + connectedIntegrationDisplayName, + isSyncInProgress, + hasSyncError, + hasUnsupportedNDIntegration, + synchronizationError, + canWriteAccounting, + overflowMenu, + connectionDetails, + qboTokenExpiryHint, + oldDotPolicyConnectionsURL, + } = useConnectedAccountingIntegration(policy, { + // These settings have nothing to show once the integration is gone + onDisconnectConfirmed: () => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)), + }); + const connectionsMenuItems = connectionDetails?.settingsMenuItems ?? []; + + return ( + + +
+ Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} /> + {!!connectionDetails?.icon && ( + + )} + + + {!!connectionDetails && isSyncInProgress && } + {!!connectionDetails && canWriteAccounting && !isSyncInProgress && } + +
+ + + {!!synchronizationError && ( + + )} + {!!qboTokenExpiryHint && ( + + )} + {!hasUnsupportedNDIntegration && + connectionsMenuItems.map((menuItem) => ( + + + + ))} + {hasUnsupportedNDIntegration && hasSyncError && !!policyID && ( + + )} + {hasUnsupportedNDIntegration && !hasSyncError && !!policyID && ( + + + { + // Go to Expensify Classic. + openOldDotLink(CONST.OLDDOT_URLS.POLICY_CONNECTIONS_URL(policyID)); + }} + > + {translate('workspace.accounting.goToODToSettings')} + + + + )} + + +
+
+ ); +} + +function ConnectionsAccountingPageWrapper(props: PolicyAccountingPageProps) { + return ( + + + + ); +} + +export default withPolicyConnections(ConnectionsAccountingPageWrapper); diff --git a/src/pages/workspace/connections/ConnectionsGrid.tsx b/src/pages/workspace/connections/ConnectionsGrid.tsx new file mode 100644 index 000000000000..d4a6750e35da --- /dev/null +++ b/src/pages/workspace/connections/ConnectionsGrid.tsx @@ -0,0 +1,49 @@ +/** + * Lays out connection cards in as many columns as fit, up to three, and stacks them on narrow and medium screens. + */ +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; +import {View} from 'react-native'; + +import type {ConnectionListing} from './types'; + +import ConnectionCard from './ConnectionCard'; + +// Invisible fillers take the empty slots of the last row so its cards keep their column width +const FILLER_COUNT = 2; + +type ConnectionsGridProps = { + listings: ConnectionListing[]; +}; + +function ConnectionsGrid({listings}: ConnectionsGridProps) { + const styles = useThemeStyles(); + const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); + // Medium screens can leave less than a card's minimum width once both navigation panes are shown + const shouldStackCards = shouldUseNarrowLayout || isMediumScreenWidth; + + return ( + + {listings.map((listing) => ( + + ))} + {!shouldStackCards && + Array.from({length: FILLER_COUNT}, (_, index) => ( + + ))} + + ); +} + +export default ConnectionsGrid; diff --git a/src/pages/workspace/connections/ConnectionsHRPage.tsx b/src/pages/workspace/connections/ConnectionsHRPage.tsx new file mode 100644 index 000000000000..af88d3fdee96 --- /dev/null +++ b/src/pages/workspace/connections/ConnectionsHRPage.tsx @@ -0,0 +1,54 @@ +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; +import usePolicy from '@hooks/usePolicy'; + +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import {getHRCards} from '@pages/workspace/hr/utils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +import ConnectionsMergePageBase from './ConnectionsMergePageBase'; + +type ConnectionsHRPageProps = PlatformStackScreenProps; + +function ConnectionsHRPage({ + route: { + params: {policyID}, + }, +}: ConnectionsHRPageProps) { + const {translate, getLocalDateFromDatetime, formatPhoneNumber} = useLocalize(); + const policy = usePolicy(policyID); + const policyEmployeePersonalDetails = usePersonalDetailsByLogins([...Object.keys(policy?.employeeList ?? {})]); + const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); + const icons = useMemoizedLazyExpensifyIcons(['GustoSquare', 'TriNetSquare']); + + const cards = getHRCards({ + policy, + policyEmployeePersonalDetails, + connectionSyncProgress, + getLocalDateFromDatetime, + translate, + formatPhoneNumber, + policyID, + gustoIcon: icons.GustoSquare, + trinetIcon: icons.TriNetSquare, + }); + + return ( + + ); +} + +export default ConnectionsHRPage; diff --git a/src/pages/workspace/connections/ConnectionsMergePageBase.tsx b/src/pages/workspace/connections/ConnectionsMergePageBase.tsx new file mode 100644 index 000000000000..c4c050d71b50 --- /dev/null +++ b/src/pages/workspace/connections/ConnectionsMergePageBase.tsx @@ -0,0 +1,152 @@ +import ConnectToMergeFlow from '@components/ConnectToMergeFlow'; +import ScreenWrapper from '@components/ScreenWrapper'; + +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import usePolicy from '@hooks/usePolicy'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; + +import {openPolicyHRPage, openPolicyRecruitingPage} from '@libs/actions/PolicyConnections'; +import {isMergeConnectionName} from '@libs/merge/MergeUtils'; +import Navigation from '@libs/Navigation/Navigation'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import MergeInitialSyncingModalListener from '@pages/workspace/merge/MergeInitialSyncingModalListener'; +import MergeSyncResultsListener from '@pages/workspace/merge/MergeSyncResultsListener'; +import type {MergeProviderCardCategory, MergeProviderCardDescriptor} from '@pages/workspace/merge/types'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +import React, {useEffect, useState} from 'react'; + +import ConnectionsMergeProviderCard from './ConnectionsMergeProviderCard'; + +/** The handful of things that differ between the HR page and the Recruiting page. Everything else is shared. */ +const PAGE_CONFIG = { + [CONST.POLICY.CONNECTIONS.CATEGORY.HR]: { + featureName: CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED, + openPage: openPolicyHRPage, + testID: 'ConnectionsHRPage', + }, + [CONST.POLICY.CONNECTIONS.CATEGORY.RECRUITING]: { + featureName: CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED, + openPage: openPolicyRecruitingPage, + testID: 'ConnectionsRecruitingPage', + }, +} as const; + +type ConnectionsMergePageBaseContentProps = { + /** The workspace whose connections are listed. */ + policyID: string; + + /** Which page this is. Picks the category-specific copy, feature flag, and data-fetching command. */ + category: MergeProviderCardCategory; + + /** Provider cards built by the category's `utils`. Only the connected one is shown. */ + cards: MergeProviderCardDescriptor[]; +}; + +type ConnectionsMergePageBaseProps = ConnectionsMergePageBaseContentProps & { + /** Whether to block access to the page, e.g. when the category is still behind a beta. */ + shouldBeBlocked?: boolean; +}; + +function ConnectionsMergePageBaseContent({policyID, category, cards}: ConnectionsMergePageBaseContentProps) { + const {translate} = useLocalize(); + const policy = usePolicy(policyID); + const [activeSetupFlow, setActiveSetupFlow] = useState<{setupLink: string; key: number} | undefined>(); + + const {testID} = PAGE_CONFIG[category]; + + // At most one provider of a category can be connected to a workspace at a time. + const connectedCard = cards.find((card) => card.isConnected); + + const {canWrite: canWriteMoreFeatures, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); + + const handleReconnect = (card: MergeProviderCardDescriptor) => { + if (!card.setupLink) { + return; + } + + if (!canWriteMoreFeatures) { + showReadOnlyModal(); + return; + } + + // A random key forces a remount on every press, even for the same provider + setActiveSetupFlow({setupLink: card.setupLink, key: Math.random()}); + }; + + return ( + + {!!connectedCard && ( + + )} + {!!connectedCard && isMergeConnectionName(connectedCard.connectionName) && ( + + )} + {!!activeSetupFlow && ( + setActiveSetupFlow(undefined)} + /> + )} + {!!connectedCard && ( + handleReconnect(connectedCard)} + onDisconnect={() => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} + onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} + canWriteMoreFeatures={canWriteMoreFeatures} + showReadOnlyModal={showReadOnlyModal} + /> + )} + + ); +} + +function ConnectionsMergePageBase({policyID, category, cards, shouldBeBlocked}: ConnectionsMergePageBaseProps) { + const {featureName, openPage} = PAGE_CONFIG[category]; + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); + + useWorkspaceDocumentTitle(undefined, `workspace.common.${category}`); + + useNetwork({onReconnect: () => openPage(policyID)}); + + useEffect(() => { + openPage(policyID); + }, [openPage, policyID]); + + return ( + card.isConnected)} + > + + + ); +} + +export default ConnectionsMergePageBase; diff --git a/src/pages/workspace/connections/ConnectionsMergeProviderCard.tsx b/src/pages/workspace/connections/ConnectionsMergeProviderCard.tsx new file mode 100644 index 000000000000..f5c53f726f37 --- /dev/null +++ b/src/pages/workspace/connections/ConnectionsMergeProviderCard.tsx @@ -0,0 +1,131 @@ +import ActivityIndicator from '@components/ActivityIndicator'; +import UserAvatar from '@components/Avatar/UserAvatar'; +import FormHelpMessage from '@components/FormHelpMessage'; +import Header from '@components/Header'; +import MenuItem from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import RenderHTML from '@components/RenderHTML'; +import ScrollView from '@components/ScrollView'; + +import useEnvironment from '@hooks/useEnvironment'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearMergeConnectionErrorField} from '@libs/actions/connections/merge'; +import Navigation from '@libs/Navigation/Navigation'; + +import type {MergeProviderCardDescriptor} from '@pages/workspace/merge/types'; +import useMergeProviderCardDetails from '@pages/workspace/merge/useMergeProviderCardDetails'; + +import CONST from '@src/CONST'; +import type Policy from '@src/types/onyx/Policy'; + +import React from 'react'; +import {View} from 'react-native'; + +type ConnectionsMergeProviderCardProps = { + /** Descriptor object containing the Merge provider's display info, connection state, and sync status. */ + card: MergeProviderCardDescriptor; + + /** The workspace policy that owns this Merge integration. */ + policy: Policy | undefined; + + /** Callback invoked when the user taps the "Connect" or "Reconnect" button. */ + handleConnect: () => void; + + /** Called once the user confirms disconnecting, right before the connection is removed */ + onDisconnect?: () => void; + + /** Called when the header's back button is pressed. */ + onBackButtonPress: () => void; + + /** Whether the current user can edit this Merge connection. */ + canWriteMoreFeatures: boolean; + + /** Shows the read-only action modal. */ + showReadOnlyModal: () => void; +}; + +function ConnectionsMergeProviderCard({card, policy, handleConnect, onDisconnect, onBackButtonPress, canWriteMoreFeatures, showReadOnlyModal}: ConnectionsMergeProviderCardProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {environmentURL} = useEnvironment(); + const {cardIcon, connectionDescription, lastSyncErrorMessage, overflowMenu, visibleConfigRows} = useMergeProviderCardDetails({ + card, + policy, + handleConnect, + canWriteMoreFeatures, + showReadOnlyModal, + onDisconnect, + }); + + return ( + <> +
+ + + + + {card.isSyncInProgress && } + {card.isConnected && canWriteMoreFeatures && !card.isSyncInProgress && } + +
+ + {!!card.completeSetupRoute && ( + + + + )} + {!!lastSyncErrorMessage && ( + + )} + {visibleConfigRows.map((row) => { + const RowMenuItem = row.shouldRenderAsMenuItem ? MenuItem : MenuItemWithTopDescription; + + return ( + clearMergeConnectionErrorField(policy?.id, card.connectionName, row.field)} + > + Navigation.navigate(row.route)} + interactive={canWriteMoreFeatures} + /> + + ); + })} + + + ); +} + +export default ConnectionsMergeProviderCard; diff --git a/src/pages/workspace/connections/ConnectionsReceiptPartnersPage.tsx b/src/pages/workspace/connections/ConnectionsReceiptPartnersPage.tsx new file mode 100644 index 000000000000..5c6ac04a5c5d --- /dev/null +++ b/src/pages/workspace/connections/ConnectionsReceiptPartnersPage.tsx @@ -0,0 +1,180 @@ +import UserAvatar from '@components/Avatar/UserAvatar'; +import FormHelpMessage from '@components/FormHelpMessage'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import Header from '@components/Header'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; + +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useScreenBoundDynamicRoute from '@hooks/useScreenBoundDynamicRoute'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; + +import Navigation from '@navigation/Navigation'; +import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@navigation/types'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import useReceiptPartnersSettings from '@pages/workspace/receiptPartners/useReceiptPartnersSettings'; +import getSynchronizationErrorMessage from '@pages/workspace/receiptPartners/utils'; +import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; + +import CONST from '@src/CONST'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +type ConnectionsReceiptPartnersPageProps = PlatformStackScreenProps; + +function ConnectionsReceiptPartnersPage({route}: ConnectionsReceiptPartnersPageProps) { + const policyID = route.params.policyID; + const icons = useMemoizedLazyExpensifyIcons(['Mail']); + const {translate} = useLocalize(); + const buildDynamicRoute = useScreenBoundDynamicRoute(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); + const { + policy, + integrations, + isAutoInvite, + isAutoRemove, + isUberConnected, + shouldShowEnterCredentialsError, + getReceiptPartnersIntegrationData, + canWriteMoreFeatures, + withReadOnlyFallback, + toggleUberAutoInvite, + toggleUberAutoRemove, + getOverflowMenu, + } = useReceiptPartnersSettings(policyID, { + // These settings have nothing to show once Uber is disconnected + onDisconnectConfirmed: () => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)), + }); + useWorkspaceDocumentTitle(policy?.name, 'workspace.common.receiptPartners'); + const isLoading = policy?.isLoading; + const centralBillingAccountEmail = !!integrations?.uber?.centralBillingAccountEmail; + + const uberData = policyID ? getReceiptPartnersIntegrationData(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER) : undefined; + + return ( + + {isLoading ? ( + + ) : ( + +
+ Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} /> + {!!uberData?.icon && ( + + )} + + {!!uberData && canWriteMoreFeatures && ( + + + + )} +
+ + {!!uberData && shouldShowEnterCredentialsError && ( + + )} + {isUberConnected && ( + <> + + + + + + + {centralBillingAccountEmail && ( + + + Navigation.navigate( + ROUTES.WORKSPACE_RECEIPT_PARTNERS_CHANGE_BILLING_ACCOUNT.getRoute(policyID, CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER), + ) + : undefined + } + /> + + )} + {canWriteMoreFeatures && ( + + Navigation.navigate(buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_RECEIPT_PARTNERS_INVITE_EDIT.getRoute(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER))) + } + /> + )} + + )} + +
+ )} +
+ ); +} + +export default ConnectionsReceiptPartnersPage; diff --git a/src/pages/workspace/connections/ConnectionsRecruitingPage.tsx b/src/pages/workspace/connections/ConnectionsRecruitingPage.tsx new file mode 100644 index 000000000000..d7123e4c65cc --- /dev/null +++ b/src/pages/workspace/connections/ConnectionsRecruitingPage.tsx @@ -0,0 +1,44 @@ +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; +import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; +import usePolicy from '@hooks/usePolicy'; + +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import {getRecruitingCards} from '@pages/workspace/recruiting/utils'; + +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +import ConnectionsMergePageBase from './ConnectionsMergePageBase'; + +type ConnectionsRecruitingPageProps = PlatformStackScreenProps; + +function ConnectionsRecruitingPage({ + route: { + params: {policyID}, + }, +}: ConnectionsRecruitingPageProps) { + const {translate, formatPhoneNumber} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Download']); + const policy = usePolicy(policyID); + const policyEmployeePersonalDetails = usePersonalDetailsByLogins([...Object.keys(policy?.employeeList ?? {})]); + const {isBetaEnabled} = usePermissions(); + + const cards = getRecruitingCards({policy, policyEmployeePersonalDetails, policyID, icons, translate, formatPhoneNumber}); + + return ( + + ); +} + +export default ConnectionsRecruitingPage; diff --git a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx new file mode 100644 index 000000000000..7c1dba68a69d --- /dev/null +++ b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx @@ -0,0 +1,385 @@ +/** + * The single place where a workspace browses, connects, and manages all of its integrations: accounting, HR, + * receipt partners, and AI assistants. + */ +import ConnectToMergeFlow from '@components/ConnectToMergeFlow'; +import GenericEmptyStateComponent from '@components/EmptyStateComponent/GenericEmptyStateComponent'; +import Header from '@components/Header'; +import Icon from '@components/Icon'; +import {PressableWithFeedback} from '@components/Pressable'; +import RenderHTML from '@components/RenderHTML'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import SidePanelButton from '@components/SidePanel/SidePanelButton'; +import TabSelectorBase from '@components/TabSelector/TabSelectorBase'; +import TabSelectorContextProvider from '@components/TabSelector/TabSelectorContext'; +import type {TabSelectorBaseItem} from '@components/TabSelector/types'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; + +import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import useOpenConciergeAnywhere from '@hooks/useOpenConciergeAnywhere'; +import usePermissions from '@hooks/usePermissions'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSearchResults from '@hooks/useSearchResults'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; + +import {openPolicyHRPage, openPolicyRecruitingPage} from '@libs/actions/PolicyConnections'; +import Navigation from '@libs/Navigation/Navigation'; +import {isControlPolicy} from '@libs/PolicyUtils'; +import tokenizedSearch from '@libs/tokenizedSearch'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import {AccountingContextProvider, useAccountingActions} from '@pages/workspace/accounting/AccountingContext'; +import MergeInitialSyncingModalListener from '@pages/workspace/merge/MergeInitialSyncingModalListener'; +import MergeSyncResultsListener from '@pages/workspace/merge/MergeSyncResultsListener'; +import type {MergeProviderCardCategory} from '@pages/workspace/merge/types'; +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; + +import variables from '@styles/variables'; + +import {openPolicyReceiptPartnersPage} from '@userActions/Policy/Policy'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Account} from '@src/types/onyx'; +import type {ConnectionName} from '@src/types/onyx/Policy'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {useFocusEffect, useRoute} from '@react-navigation/native'; +import React, {useEffect, useRef, useState} from 'react'; +import {View} from 'react-native'; + +import type {ConnectionListing, ConnectionsTab} from './types'; + +import ConnectionsGrid from './ConnectionsGrid'; +import useAccountingConnectionListings from './useAccountingConnectionListings'; +import useMCPConnectionListings from './useMCPConnectionListings'; +import useMergeConnectionListings from './useMergeConnectionListings'; +import useReceiptPartnerConnectionListings from './useReceiptPartnerConnectionListings'; +import {getListingsForTab} from './utils'; + +type RouteParams = { + newConnectionName?: ConnectionName; + integrationToDisconnect?: ConnectionName; + shouldDisconnectIntegrationBeforeConnecting?: boolean; + isIntuitEnterpriseSuite?: string; +}; + +type MergeSetupFlow = { + setupLink: string; + category: MergeProviderCardCategory; + key: number; +}; + +/** HR and recruiting providers are Control-only, so their data is only requested for Control workspaces */ +const accountManagerReportIDSelector = (account: OnyxEntry) => (account?.accountManagerAccountID ? account.accountManagerReportID : undefined); + +function fetchConnectionsData(policyID: string | undefined, canUseMergeConnections: boolean, isRecruitingBetaEnabled: boolean) { + if (!policyID) { + return; + } + openPolicyReceiptPartnersPage(policyID); + if (!canUseMergeConnections) { + return; + } + openPolicyHRPage(policyID); + if (isRecruitingBetaEnabled) { + openPolicyRecruitingPage(policyID); + } +} + +function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { + const policyID = policy?.id; + const styles = useThemeStyles(); + const theme = useTheme(); + const icons = useMemoizedLazyExpensifyIcons(['ArrowRight']); + const illustrations = useMemoizedLazyIllustrations(['EmptyShelves']); + const {translate} = useLocalize(); + const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); + const shouldUseSingleColumn = shouldUseNarrowLayout || isMediumScreenWidth; + const [isSearchFocused, setIsSearchFocused] = useState(false); + const {isBetaEnabled} = usePermissions(); + const {openConciergeAnywhere} = useOpenConciergeAnywhere(); + const [accountManagerReportID] = useOnyx(ONYXKEYS.ACCOUNT, {selector: accountManagerReportIDSelector}); + const {startIntegrationFlow} = useAccountingActions(); + const {canWrite: canWriteAccounting} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); + const [lastSelectedTab, setLastSelectedTab] = useState(CONST.TAB.CONNECTIONS.ALL); + const [mergeSetupFlow, setMergeSetupFlow] = useState(); + const route = useRoute(); + const params = route.params as RouteParams | undefined; + useWorkspaceDocumentTitle(policy?.name, 'workspace.common.connections'); + + const isRecruitingBetaEnabled = isBetaEnabled(CONST.BETAS.MERGE_ATS); + const canUseMergeConnections = isControlPolicy(policy); + + useNetwork({onReconnect: () => fetchConnectionsData(policyID, canUseMergeConnections, isRecruitingBetaEnabled)}); + + useEffect(() => { + fetchConnectionsData(policyID, canUseMergeConnections, isRecruitingBetaEnabled); + }, [policyID, canUseMergeConnections, isRecruitingBetaEnabled]); + + const accountingListings = useAccountingConnectionListings(policy); + const mergeListings = useMergeConnectionListings(policy, (setupLink, category) => { + // A random key forces a remount on every press, even for the same provider + setMergeSetupFlow({setupLink, category, key: Math.random()}); + }); + const receiptPartnerListings = useReceiptPartnerConnectionListings(policy); + const mcpListings = useMCPConnectionListings(policy); + const listings: ConnectionListing[] = [...accountingListings, ...mergeListings, ...receiptPartnerListings, ...mcpListings]; + const connectedListings = listings.filter((listing) => !!listing.status); + const availableListings = listings.filter((listing) => !listing.status); + + // Recruiting providers are behind a beta, so the tab only shows once there is something to list in it + const hasRecruitingListings = listings.some((listing) => listing.category === CONST.TAB.CONNECTIONS.RECRUITING); + const visibleTabs = Object.values(CONST.TAB.CONNECTIONS).filter((tab) => tab !== CONST.TAB.CONNECTIONS.RECRUITING || hasRecruitingListings); + const activeTab = visibleTabs.includes(lastSelectedTab) ? lastSelectedTab : CONST.TAB.CONNECTIONS.ALL; + + const [searchValue, setSearchValue, searchResults, appliedSearchValue] = useSearchResults( + availableListings, + (listing, searchInput) => tokenizedSearch([listing], searchInput, (item) => [item.title]).length > 0, + ); + // The results only switch once the debounced search has run, so the tab's full list doesn't flash while typing + const isSearching = !!searchValue.trim() && !!appliedSearchValue.trim(); + const visibleListings = isSearching ? searchResults : getListingsForTab(availableListings, activeTab); + // Search results span every tab, so no tab is highlighted. Clearing the search returns to the last tab. + const selectedTab = isSearching ? undefined : activeTab; + const noResultsMessage = translate('common.noResultsFoundMatching', searchValue); + const shouldShowNoResults = isSearching && !searchResults.length; + useDebouncedAccessibilityAnnouncement(noResultsMessage, shouldShowNoResults, searchValue); + // A tab also ends up empty once everything in it is connected + const shouldShowEmptyState = !visibleListings.length; + + // Suggestions go to the person who looks after this workspace, like the old Accounting page did + const openSuggestIntegration = () => { + if (policy?.chatReportIDAdmins) { + openConciergeAnywhere({reportID: String(policy.chatReportIDAdmins)}); + return; + } + if (accountManagerReportID) { + openConciergeAnywhere({reportID: accountManagerReportID}); + return; + } + openConciergeAnywhere({forceConcierge: true}); + }; + + // Upgrade and setup flows that leave the app come back here with the integration to connect in the route params. + // `startIntegrationFlow` changes identity whenever `policy` does, and the params are only cleared in a later render, + // so the guard is keyed on the value to start each flow once. + const startedIntegrationFlowForRef = useRef(undefined); + const newConnectionName = params?.newConnectionName; + const integrationToDisconnect = params?.integrationToDisconnect; + const shouldDisconnectIntegrationBeforeConnecting = params?.shouldDisconnectIntegrationBeforeConnecting; + const shouldConnectToIntuitEnterpriseSuite = params?.isIntuitEnterpriseSuite === 'true'; + + useFocusEffect(() => { + if (!newConnectionName || !isControlPolicy(policy) || !canWriteAccounting) { + if (!newConnectionName) { + startedIntegrationFlowForRef.current = undefined; + } + return; + } + if (startedIntegrationFlowForRef.current === newConnectionName) { + return; + } + startedIntegrationFlowForRef.current = newConnectionName; + + startIntegrationFlow({ + name: newConnectionName, + isIntuitEnterpriseSuite: shouldConnectToIntuitEnterpriseSuite, + integrationToDisconnect, + shouldDisconnectIntegrationBeforeConnecting, + }); + Navigation.setParams({ + newConnectionName: undefined, + isIntuitEnterpriseSuite: undefined, + integrationToDisconnect: undefined, + shouldDisconnectIntegrationBeforeConnecting: undefined, + }); + }); + + const connectedHRConnectionName = CONST.POLICY.CONNECTIONS.HR_CONNECTION_NAMES.find((name) => !!policy?.connections?.[name]); + const connectedRecruitingConnectionName = isRecruitingBetaEnabled ? CONST.POLICY.CONNECTIONS.RECRUITING_CONNECTION_NAMES.find((name) => !!policy?.connections?.[name]) : undefined; + + const tabs: Array> = visibleTabs.map((tab) => ({ + key: tab, + title: translate(`workspace.connections.tabs.${tab}`), + })); + + const searchLabel = translate('workspace.connections.findConnections'); + const searchBar = ( + setIsSearchFocused(true)} + onBlur={() => setIsSearchFocused(false)} + onChangeText={setSearchValue} + /> + ); + + return ( + + {!!policyID && !!connectedHRConnectionName && ( + + )} + {!!policyID && connectedHRConnectionName === CONST.POLICY.CONNECTIONS.NAME.MERGE_HR && ( + + )} + {!!policyID && !!connectedRecruitingConnectionName && ( + <> + + + + )} + {!!mergeSetupFlow && ( + setMergeSetupFlow(undefined)} + /> + )} +
+ {shouldUseNarrowLayout && Navigation.goBack()} />} + + + + +
+ + {connectedListings.length > 0 && ( + + + + )} + 0 ? styles.mt6 : styles.mt3, !shouldUseSingleColumn && [styles.flexRow, styles.alignItemsCenter, styles.gap5]]}> + + + { + setSearchValue(''); + setLastSelectedTab(tab); + }} + onActiveTabPress={() => setSearchValue('')} + // On mobile the tabs scroll edge to edge, so the page gutter moves inside the scroll content + contentContainerStyles={shouldUseNarrowLayout ? styles.pb0 : [styles.ph0, styles.pb0]} + tabButtonStyles={styles.connectionsTabButton} + // On mobile a hard edge shows more clearly that the tabs scroll + shouldShowScrollFade={!shouldUseNarrowLayout} + /> + + + {searchBar} + + {shouldShowEmptyState ? ( + + +
+ } + /> + ) : ( + + + + )} + {!shouldShowEmptyState && ( + + {translate('workspace.connections.suggestIntegration')} + + + )} +
+ + ); +} + +function WorkspaceConnectionsPageWrapper(props: WithPolicyConnectionsProps) { + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); + + return ( + + + + + + ); +} + +export default withPolicyConnections(WorkspaceConnectionsPageWrapper); diff --git a/src/pages/workspace/connections/types.ts b/src/pages/workspace/connections/types.ts new file mode 100644 index 000000000000..23f2b9381c27 --- /dev/null +++ b/src/pages/workspace/connections/types.ts @@ -0,0 +1,53 @@ +import type CONST from '@src/CONST'; +import type IconAsset from '@src/types/utils/IconAsset'; + +import type {ComponentRef} from 'react'; +import type {View} from 'react-native'; +import type {ValueOf} from 'type-fest'; + +type ConnectionsTab = ValueOf; + +/** The tabs a listing can belong to. All shows every listing. */ +type ConnectionCategory = Exclude; + +type ConnectionStatus = { + /** A broken connection shows a Fix button instead of Configure */ + isBroken: boolean; + + message: string; + + /** Shows a spinner instead of the Configure button while the connection syncs */ + isSyncing?: boolean; +}; + +/** One integration on the Connections page, either available to connect or already connected. */ +type ConnectionListing = { + /** Unique across all categories */ + key: string; + + category: ConnectionCategory; + + title: string; + + /** Local asset, or a remote logo URL for Merge providers */ + icon?: IconAsset | string; + + /** Only set while the integration is connected to the workspace */ + status?: ConnectionStatus; + + onConnect: () => void; + + /** Shows an "Offer" badge for a partner promotion. The badge only opens the offer when `onPress` is set. */ + offer?: {onPress?: () => void}; + + /** Shows a spinner instead of "+" while the data the connection flow needs is still loading */ + isLoading?: boolean; + + /** Opens the connection's settings. Only called for connected listings. */ + onConfigure?: () => void; + + /** Receives the element that starts the connection, since some accounting setup flows anchor a popover to it */ + registerConnectButton?: (button: ComponentRef | null) => void; +}; + +export type {ConnectionsTab, ConnectionStatus, ConnectionListing}; diff --git a/src/pages/workspace/connections/useAccountingConnectionListings.ts b/src/pages/workspace/connections/useAccountingConnectionListings.ts new file mode 100644 index 000000000000..71bd9da67ed4 --- /dev/null +++ b/src/pages/workspace/connections/useAccountingConnectionListings.ts @@ -0,0 +1,161 @@ +/** + * Builds the accounting listings for the Connections page and starts the accounting connect flows from them. + * Must be rendered inside an AccountingContextProvider. + */ +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {isConnectionInProgress, isConnectionUnverified} from '@libs/actions/connections'; +import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; +import Navigation from '@libs/Navigation/Navigation'; +import {getConnectedIntegration, getIntegrationLastSuccessfulDate} from '@libs/PolicyUtils'; + +import {useAccountingActions, useAccountingState} from '@pages/workspace/accounting/AccountingContext'; +import {getAccountingIntegrationData, getSynchronizationErrorMessage, isIntuitEnterpriseSuiteConnection} from '@pages/workspace/accounting/utils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Policy} from '@src/types/onyx'; +import type {ConnectionName} from '@src/types/onyx/Policy'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import type {ConnectionListing, ConnectionStatus} from './types'; + +import {getSyncStatusMessage} from './utils'; + +// Listings only read an integration's title and icon. The setup flow inputs are read by `AccountingContext` when a flow starts. +const NO_REUSABLE_CONNECTIONS = {sageIntacct: false, qbd: false, certinia: false, rillet: false, dualEntry: false, campfire: false}; + +function useAccountingConnectionListings(policy: OnyxEntry): ConnectionListing[] { + const policyID = policy?.id; + const styles = useThemeStyles(); + const {translate, datetimeToRelative, getLocalDateFromDatetime} = useLocalize(); + const {isBetaEnabled} = usePermissions(); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); + const {startIntegrationFlow} = useAccountingActions(); + const {activeIntegration, popoverAnchorRefs} = useAccountingState(); + const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); + const {canWrite, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); + const accountingIcons = useMemoizedLazyExpensifyIcons([ + 'IntacctSquare', + 'IntuitSquare', + 'QBOSquare', + 'XeroSquare', + 'NetSuiteSquare', + 'QBDSquare', + 'CertiniaSquare', + 'RilletSquare', + 'DualEntrySquare', + 'CampfireSquare', + 'BusinessCentralSquare', + ]); + + if (!policyID) { + return []; + } + + const canUseBusinessCentralIntegration = isBetaEnabled(CONST.BETAS.BUSINESS_CENTRAL) || !!policy?.connections?.businessCentral; + const accountingIntegrations = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.filter((name) => { + if (name === CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL) { + return canUseBusinessCentralIntegration; + } + return true; + }); + + const syncingIntegration = accountingIntegrations.find((integration) => integration === connectionSyncProgress?.connectionName); + // HR providers write to the same sync progress key, so only an accounting sync counts here + const accountingSyncProgress = syncingIntegration ? connectionSyncProgress : undefined; + const isSyncInProgress = !!accountingSyncProgress && isConnectionInProgress(accountingSyncProgress, policy); + const connectedIntegration = getConnectedIntegration(policy, accountingIntegrations) ?? syncingIntegration; + const isIntuitEnterpriseSuiteSyncInProgress = isSyncInProgress && activeIntegration?.name === CONST.POLICY.CONNECTIONS.NAME.QBO && activeIntegration.isIntuitEnterpriseSuite === true; + const isConnectedToIntuitEnterpriseSuite = + connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBO && (isIntuitEnterpriseSuiteConnection(policy) || isIntuitEnterpriseSuiteSyncInProgress); + + const connect = (name: ConnectionName, isIntuitEnterpriseSuite?: boolean) => { + if (!canWrite) { + showReadOnlyModal(); + return; + } + startIntegrationFlow( + connectedIntegration + ? {name, isIntuitEnterpriseSuite, integrationToDisconnect: connectedIntegration, shouldDisconnectIntegrationBeforeConnecting: true} + : {name, isIntuitEnterpriseSuite}, + ); + }; + + const getConnectedStatus = (name: ConnectionName, title: string | undefined): ConnectionStatus => { + const hasQBOExportError = name === CONST.POLICY.CONNECTIONS.NAME.QBO && shouldShowQBOReimbursableExportDestinationAccountError(policy); + if (hasQBOExportError || getSynchronizationErrorMessage(policy, name, isSyncInProgress, translate, styles)) { + return {isBroken: true, message: translate('workspace.connections.brokenConnection')}; + } + // A new connection hasn't synced yet while its first sync runs, so the sync is checked first + const syncStage = isSyncInProgress ? accountingSyncProgress?.stageInProgress : undefined; + if (!syncStage && isConnectionUnverified(policy, name)) { + return {isBroken: false, isSyncing: isSyncInProgress, message: translate('workspace.accounting.notSync')}; + } + return { + isBroken: false, + isSyncing: isSyncInProgress, + message: getSyncStatusMessage({ + syncingMessage: syncStage ? translate('workspace.accounting.connections.syncStageName', syncStage, title) : undefined, + successfulDate: getIntegrationLastSuccessfulDate( + getLocalDateFromDatetime, + policy?.connections?.[name], + name === accountingSyncProgress?.connectionName ? accountingSyncProgress : undefined, + ), + translate, + datetimeToRelative, + }), + }; + }; + + const integrationOptions = accountingIntegrations.flatMap((name) => [ + {name, isIntuitEnterpriseSuite: name === CONST.POLICY.CONNECTIONS.NAME.QBO ? false : undefined}, + ...(name === CONST.POLICY.CONNECTIONS.NAME.QBO ? [{name, isIntuitEnterpriseSuite: true}] : []), + ]); + + return integrationOptions.flatMap(({name, isIntuitEnterpriseSuite}) => { + const integrationData = getAccountingIntegrationData(name, policyID, translate, NO_REUSABLE_CONNECTIONS, { + expensifyIcons: accountingIcons, + isIntuitEnterpriseSuiteOverride: isIntuitEnterpriseSuite, + isUnifiedConnectionsBetaEnabled, + }); + if (!integrationData) { + return []; + } + + const key = isIntuitEnterpriseSuite ? CONST.POLICY.CONNECTIONS.ACCOUNTING_INTEGRATION_ALIASES.INTUIT_ENTERPRISE_SUITE : name; + const isConnected = name === connectedIntegration && !!isIntuitEnterpriseSuite === isConnectedToIntuitEnterpriseSuite; + + return { + key, + category: CONST.TAB.CONNECTIONS.ACCOUNTING, + title: integrationData.title, + icon: integrationData.icon, + status: isConnected ? getConnectedStatus(name, integrationData.title) : undefined, + // The Xero partner offer is for workspaces choosing their first accounting integration + offer: + name === CONST.POLICY.CONNECTIONS.NAME.XERO && !connectedIntegration + ? {onPress: canWrite ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CLAIM_OFFER.getRoute(policyID, CONST.POLICY.CONNECTIONS.NAME.XERO)) : undefined} + : undefined, + onConnect: () => connect(name, isIntuitEnterpriseSuite), + onConfigure: () => Navigation.navigate(ROUTES.WORKSPACE_CONNECTIONS_ACCOUNTING.getRoute(policyID)), + registerConnectButton: (button) => { + const anchorRef = popoverAnchorRefs?.current?.[key]; + if (!anchorRef) { + return; + } + anchorRef.current = button; + }, + }; + }); +} + +export default useAccountingConnectionListings; diff --git a/src/pages/workspace/connections/useConnectionsMovedTooltip.tsx b/src/pages/workspace/connections/useConnectionsMovedTooltip.tsx new file mode 100644 index 000000000000..c4ad2c298fae --- /dev/null +++ b/src/pages/workspace/connections/useConnectionsMovedTooltip.tsx @@ -0,0 +1,60 @@ +import {useProductTrainingContext} from '@components/ProductTrainingContext'; +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {dismissProductTraining} from '@libs/actions/Welcome'; + +import CONST from '@src/CONST'; + +import React, {useEffect, useRef} from 'react'; +import {View} from 'react-native'; + +/** + * The tooltip on the workspace menu's Connections item telling users their integrations moved there. Leaving the page + * it was first shown on counts as dismissing it, so it only shows once. + */ +function useConnectionsMovedTooltip(shouldShow: boolean, activeRoute: string | undefined, isWorkspacesTabFocused: boolean) { + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const {shouldShowProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext(CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.CONNECTIONS_MOVED, shouldShow); + const shownOnRouteRef = useRef(undefined); + + useEffect(() => { + if (shouldShowProductTrainingTooltip && !shownOnRouteRef.current) { + shownOnRouteRef.current = activeRoute; + } + const shownOnRoute = shownOnRouteRef.current; + if (!shownOnRoute || (shownOnRoute === activeRoute && isWorkspacesTabFocused)) { + return; + } + shownOnRouteRef.current = undefined; + dismissProductTraining(CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.CONNECTIONS_MOVED); + }, [activeRoute, isWorkspacesTabFocused, shouldShowProductTrainingTooltip]); + + const hideTooltip = () => { + shownOnRouteRef.current = undefined; + hideProductTrainingTooltip(); + }; + + const renderTooltipContent = () => ( + + + {translate('common.new')}{' '} + {translate('productTrainingTooltip.connectionsMoved')} + + + ); + + return {shouldShowTooltip: shouldShowProductTrainingTooltip, hideTooltip, renderTooltipContent}; +} + +export default useConnectionsMovedTooltip; diff --git a/src/pages/workspace/connections/useMCPConnectionListings.ts b/src/pages/workspace/connections/useMCPConnectionListings.ts new file mode 100644 index 000000000000..2ed5f058de74 --- /dev/null +++ b/src/pages/workspace/connections/useMCPConnectionListings.ts @@ -0,0 +1,62 @@ +/** + * Builds the AI & MCP listings for the Connections page. These connect outside Expensify, so they never show as connected. + */ +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; + +import {openExternalLink} from '@userActions/Link'; +import {enablePolicyFeatureForConnection} from '@userActions/Policy/Policy'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import type {ConnectionListing} from './types'; + +import {MCP_CONNECTOR} from './utils'; + +function useMCPConnectionListings(policy: OnyxEntry): ConnectionListing[] { + const policyID = policy?.id; + const {translate} = useLocalize(); + const {canWrite, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); + const icons = useMemoizedLazyExpensifyIcons(['ChatGPTSquare', 'ClaudeSquare', 'CursorSquare']); + + if (!policyID) { + return []; + } + + const connectors = [ + {key: MCP_CONNECTOR.CLAUDE, icon: icons.ClaudeSquare, title: translate('workspace.mcp.claude.title'), url: CONST.CLAUDE_CONNECT_URL}, + {key: MCP_CONNECTOR.CURSOR, icon: icons.CursorSquare, title: translate('workspace.mcp.cursor.title'), url: CONST.CURSOR_MCP_HELP_URL}, + {key: MCP_CONNECTOR.CHATGPT, icon: icons.ChatGPTSquare, title: translate('workspace.mcp.chatgpt.title'), url: CONST.CHATGPT_CONNECT_URL}, + ]; + + const assistantListings: ConnectionListing[] = connectors.map(({key, icon, title, url}) => ({ + key, + category: CONST.TAB.CONNECTIONS.AI, + title, + icon, + onConnect: () => { + if (!canWrite) { + showReadOnlyModal(); + return; + } + enablePolicyFeatureForConnection(policy, CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED); + openExternalLink(url); + }, + })); + + // Any other MCP client connects by following the guide, so this card opens it without turning anything on + const mcpGuideListing: ConnectionListing = { + key: MCP_CONNECTOR.MCP, + category: CONST.TAB.CONNECTIONS.AI, + title: translate('workspace.moreFeatures.mcp.title'), + onConnect: () => openExternalLink(CONST.MCP_HELP_URL), + }; + + return [...assistantListings, mcpGuideListing]; +} + +export default useMCPConnectionListings; diff --git a/src/pages/workspace/connections/useMergeConnectionListings.ts b/src/pages/workspace/connections/useMergeConnectionListings.ts new file mode 100644 index 000000000000..8249f8c6c243 --- /dev/null +++ b/src/pages/workspace/connections/useMergeConnectionListings.ts @@ -0,0 +1,160 @@ +/** + * Builds the People listings (HR and, behind the Merge ATS beta, recruiting providers) for the Connections page. + */ +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import type {PersonalDetailsByLogin} from '@components/PersonalDetailsByLoginProvider'; + +import useConfirmModal from '@hooks/useConfirmModal'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; + +import {removePolicyConnection} from '@libs/actions/connections'; +import Navigation from '@libs/Navigation/Navigation'; +import {isControlPolicy} from '@libs/PolicyUtils'; + +import {getHRCards} from '@pages/workspace/hr/utils'; +import type {MergeProviderCardCategory, MergeProviderCardDescriptor} from '@pages/workspace/merge/types'; +import {getRecruitingCards} from '@pages/workspace/recruiting/utils'; + +import {enablePolicyFeatureForConnection} from '@userActions/Policy/Policy'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import type {ConnectionListing, ConnectionStatus} from './types'; + +import {getSyncStatusMessage} from './utils'; + +const CATEGORY_CONFIG = { + [CONST.POLICY.CONNECTIONS.CATEGORY.HR]: { + featureName: CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED, + upgradeAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.hr.alias, + getConfigureRoute: ROUTES.WORKSPACE_CONNECTIONS_HR.getRoute, + }, + [CONST.POLICY.CONNECTIONS.CATEGORY.RECRUITING]: { + featureName: CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED, + upgradeAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.recruiting.alias, + getConfigureRoute: ROUTES.WORKSPACE_CONNECTIONS_RECRUITING.getRoute, + }, +} as const; + +// Personal details only feed the settings rows, which the Connections page doesn't render +const NO_PERSONAL_DETAILS: PersonalDetailsByLogin = {}; + +function useMergeConnectionListings(policy: OnyxEntry, onStartSetup: (setupLink: string, category: MergeProviderCardCategory) => void): ConnectionListing[] { + const policyID = policy?.id; + const {translate, getLocalDateFromDatetime, datetimeToRelative, formatPhoneNumber} = useLocalize(); + const {isBetaEnabled} = usePermissions(); + const {showConfirmModal} = useConfirmModal(); + const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); + const {canWrite, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); + const icons = useMemoizedLazyExpensifyIcons(['GustoSquare', 'TriNetSquare', 'Download']); + + if (!policyID) { + return []; + } + + const isRecruitingBetaEnabled = isBetaEnabled(CONST.BETAS.MERGE_ATS); + const hrCards = getHRCards({ + policy, + policyEmployeePersonalDetails: NO_PERSONAL_DETAILS, + connectionSyncProgress, + getLocalDateFromDatetime, + translate, + formatPhoneNumber, + policyID, + gustoIcon: icons.GustoSquare, + trinetIcon: icons.TriNetSquare, + }); + const recruitingCards = isRecruitingBetaEnabled ? getRecruitingCards({policy, policyEmployeePersonalDetails: NO_PERSONAL_DETAILS, policyID, icons, translate, formatPhoneNumber}) : []; + + const connect = (card: MergeProviderCardDescriptor, categoryCards: MergeProviderCardDescriptor[]) => { + if (!card.setupLink) { + return; + } + if (!canWrite) { + showReadOnlyModal(); + return; + } + + const config = CATEGORY_CONFIG[card.category]; + if (!isControlPolicy(policy)) { + Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, config.upgradeAlias, ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))); + return; + } + + const {setupLink} = card; + const startSetup = () => { + enablePolicyFeatureForConnection(policy, config.featureName); + onStartSetup(setupLink, card.category); + }; + + // At most one provider of a category can be connected to a workspace at a time + const connectedCard = categoryCards.find((categoryCard) => categoryCard.isConnected); + if (!connectedCard) { + startSetup(); + return; + } + showConfirmModal({ + title: translate('workspace.connections.replaceConnectionTitle'), + prompt: translate('workspace.connections.replaceConnectionPrompt', connectedCard.displayName), + confirmText: translate('common.replace'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }).then(({action}) => { + if (action !== ModalActions.CONFIRM) { + return; + } + removePolicyConnection(policy, connectedCard.connectionName); + startSetup(); + }); + }; + + const getConnectedStatus = (card: MergeProviderCardDescriptor): ConnectionStatus => { + if (card.needsReconnect || card.hasError) { + return {isBroken: true, message: translate('workspace.connections.brokenConnection')}; + } + if (card.completeSetupRoute) { + return {isBroken: false, message: translate('workspace.merge.completeSetup')}; + } + let syncingMessage; + if (card.isSyncInProgress) { + syncingMessage = card.syncStageInProgress ? translate('workspace.hr.syncStageName', card.syncStageInProgress) : translate(`workspace.${card.category}.syncing`); + } + return { + isBroken: false, + isSyncing: card.isSyncInProgress, + message: getSyncStatusMessage({syncingMessage, successfulDate: card.successfulDate, translate, datetimeToRelative}), + }; + }; + + const getTitle = (card: MergeProviderCardDescriptor) => { + // Some providers offer both an HR and an ATS integration, so both listings get a suffix to tell them apart + if (!isRecruitingBetaEnabled) { + return card.displayName; + } + return translate(card.category === CONST.POLICY.CONNECTIONS.CATEGORY.HR ? 'workspace.connections.hrisListing' : 'workspace.connections.atsListing', card.displayName); + }; + + const toListings = (categoryCards: MergeProviderCardDescriptor[]): ConnectionListing[] => + categoryCards.map((card) => ({ + key: card.key, + category: card.category === CONST.POLICY.CONNECTIONS.CATEGORY.RECRUITING ? CONST.TAB.CONNECTIONS.RECRUITING : CONST.TAB.CONNECTIONS.PEOPLE, + title: getTitle(card), + icon: card.icon, + status: card.isConnected ? getConnectedStatus(card) : undefined, + onConnect: () => connect(card, categoryCards), + onConfigure: () => Navigation.navigate(CATEGORY_CONFIG[card.category].getConfigureRoute(policyID)), + })); + + return [...toListings(hrCards), ...toListings(recruitingCards)]; +} + +export default useMergeConnectionListings; diff --git a/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts new file mode 100644 index 000000000000..19a0748d79d6 --- /dev/null +++ b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts @@ -0,0 +1,102 @@ +/** + * Builds the Receipts listings (receipt partners such as Uber for Business) for the Connections page. + */ +import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import usePrevious from '@hooks/usePrevious'; + +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; + +import {openExternalLink} from '@userActions/Link'; +import {enablePolicyFeatureForConnection} from '@userActions/Policy/Policy'; + +import CONST from '@src/CONST'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {useEffect, useRef} from 'react'; + +import type {ConnectionListing} from './types'; + +function useReceiptPartnerConnectionListings(policy: OnyxEntry): ConnectionListing[] { + const policyID = policy?.id; + const {translate} = useLocalize(); + const {getReceiptPartnersIntegrationData, shouldShowEnterCredentialsError, isUberConnected} = useGetReceiptPartnersIntegrationData(policyID); + const {canWrite, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); + const prevIsUberConnected = usePrevious(isUberConnected); + const {isOffline} = useNetwork(); + const hasStartedUberConnectionRef = useRef(false); + + // The Uber connection finishes outside the app, so the invite flow opens once the connection shows up here. It only + // opens for a connection started from this page, not for one that shows up when the data first loads. + useEffect(() => { + if (!policyID || !isUberConnected || prevIsUberConnected || !canWrite || !hasStartedUberConnectionRef.current) { + return; + } + hasStartedUberConnectionRef.current = false; + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.WORKSPACE_RECEIPT_PARTNERS_INVITE.getRoute(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER), + ROUTES.WORKSPACE_CONNECTIONS_RECEIPT_PARTNERS.getRoute(policyID), + ), + ); + }, [prevIsUberConnected, isUberConnected, policyID, canWrite]); + + const uberData = getReceiptPartnersIntegrationData(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER); + if (!policyID || !uberData) { + return []; + } + + const connectUber = () => { + if (!canWrite) { + showReadOnlyModal(); + return; + } + const connectFormData = policy?.receiptPartners?.uber?.connectFormData; + if (!connectFormData) { + return; + } + enablePolicyFeatureForConnection(policy, CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED); + hasStartedUberConnectionRef.current = true; + openExternalLink(`${CONST.UBER_CONNECT_URL}?${connectFormData}`); + }; + + const isConnected = isUberConnected || shouldShowEnterCredentialsError; + const isBroken = shouldShowEnterCredentialsError || !!uberData.errorFields; + + return [ + { + key: CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER, + category: CONST.TAB.CONNECTIONS.RECEIPTS, + title: uberData.title, + icon: uberData.icon, + status: isConnected + ? { + isBroken, + message: isBroken ? translate('workspace.connections.brokenConnection') : uberData.description, + } + : undefined, + // The Uber partner offer only applies to new connections + offer: isConnected + ? undefined + : { + onPress: canWrite + ? () => { + hasStartedUberConnectionRef.current = true; + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CLAIM_OFFER.getRoute(policyID, CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER)); + } + : undefined, + }, + onConnect: connectUber, + isLoading: !policy?.receiptPartners?.uber && !isOffline && !!policy?.isLoadingReceiptPartners, + onConfigure: () => Navigation.navigate(ROUTES.WORKSPACE_CONNECTIONS_RECEIPT_PARTNERS.getRoute(policyID)), + }, + ]; +} + +export default useReceiptPartnerConnectionListings; diff --git a/src/pages/workspace/connections/utils.ts b/src/pages/workspace/connections/utils.ts new file mode 100644 index 000000000000..225087142eaa --- /dev/null +++ b/src/pages/workspace/connections/utils.ts @@ -0,0 +1,70 @@ +/** + * Helpers shared by the Connections page listing builders. + */ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +import type {ConnectionListing, ConnectionsTab} from './types'; + +const MCP_CONNECTOR = { + CLAUDE: 'claude', + CHATGPT: 'chatgpt', + CURSOR: 'cursor', + MCP: 'mcp', +} as const; + +type GetSyncStatusMessageParams = { + /** Shown instead of the last sync time while a sync is running */ + syncingMessage?: string; + + /** ISO datetime of the last successful sync, undefined if the connection never synced */ + successfulDate?: string; + + translate: LocaleContextProps['translate']; + + datetimeToRelative: LocaleContextProps['datetimeToRelative']; +}; + +function getSyncStatusMessage({syncingMessage, successfulDate, translate, datetimeToRelative}: GetSyncStatusMessageParams): string { + if (syncingMessage) { + return syncingMessage; + } + if (!successfulDate) { + return translate('workspace.accounting.notSync'); + } + return translate('workspace.accounting.lastSync', datetimeToRelative(successfulDate)); +} + +function getListingsForTab(listings: ConnectionListing[], tab: ConnectionsTab): ConnectionListing[] { + if (tab === CONST.TAB.CONNECTIONS.ALL) { + return listings; + } + return listings.filter((listing) => listing.category === tab); +} + +/** The page listing the workspace's accounting integrations, which is Connections while the unified Connections beta is on */ +function getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled: boolean, ...params: Parameters) { + if (isUnifiedConnectionsBetaEnabled) { + return ROUTES.WORKSPACE_CONNECTIONS.getRoute(...params); + } + return ROUTES.POLICY_ACCOUNTING.getRoute(...params); +} + +/** The page an accounting integration's settings open from, which is the Connections accounting panel while the unified Connections beta is on */ +function getAccountingSettingsRoute(isUnifiedConnectionsBetaEnabled: boolean, policyID: string) { + if (isUnifiedConnectionsBetaEnabled) { + return ROUTES.WORKSPACE_CONNECTIONS_ACCOUNTING.getRoute(policyID); + } + return ROUTES.POLICY_ACCOUNTING.getRoute(policyID); +} + +function getHRSettingsRoute(isUnifiedConnectionsBetaEnabled: boolean, policyID: string) { + if (isUnifiedConnectionsBetaEnabled) { + return ROUTES.WORKSPACE_CONNECTIONS_HR.getRoute(policyID); + } + return ROUTES.WORKSPACE_HR.getRoute(policyID); +} + +export {MCP_CONNECTOR, getSyncStatusMessage, getListingsForTab, getAccountingConnectionsRoute, getAccountingSettingsRoute, getHRSettingsRoute}; diff --git a/src/pages/workspace/connections/withUnifiedConnectionsBeta.tsx b/src/pages/workspace/connections/withUnifiedConnectionsBeta.tsx new file mode 100644 index 000000000000..c5f9fe60decd --- /dev/null +++ b/src/pages/workspace/connections/withUnifiedConnectionsBeta.tsx @@ -0,0 +1,28 @@ +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; + +import type {ComponentProps, ComponentType} from 'react'; + +import React from 'react'; + +import WorkspaceConnectionsPage from './WorkspaceConnectionsPage'; + +type WorkspaceConnectionsPageProps = ComponentProps; + +/** + * Renders the Connections page in place of a page it replaces while the unified Connections beta is on. Old links and + * deep links into those pages' settings still resolve to their routes, so they need to land on Connections for beta users. + */ +export default function withUnifiedConnectionsBeta>(WrappedComponent: ComponentType): ComponentType { + function WithUnifiedConnectionsBeta(props: TProps) { + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); + + if (isUnifiedConnectionsBetaEnabled) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- these are the screen props of a page Connections replaced, and it only reads the policy ID and the connection to start from the route + return ; + } + + return ; + } + + return WithUnifiedConnectionsBeta; +} diff --git a/src/pages/workspace/getWorkspaceMenuItems.ts b/src/pages/workspace/getWorkspaceMenuItems.ts index 3ec908c36625..06835db8f58f 100644 --- a/src/pages/workspace/getWorkspaceMenuItems.ts +++ b/src/pages/workspace/getWorkspaceMenuItems.ts @@ -53,6 +53,7 @@ type WorkspaceMenuIconMap = Record< | 'Hashtag' | 'Document' | 'Sync' + | 'Connect' | 'Receipt' | 'Briefcase' | 'Folder' @@ -85,6 +86,12 @@ type WorkspaceMenuItem = WithSentryLabel & { screenName: WorkspaceTopLevelScreens; badgeText?: string; highlighted?: boolean; + + /** Other names the Search router also matches this item by */ + searchAliasKeys?: TranslationPaths[]; + + /** Other screens that render this item's page, so the menu treats them as this item */ + aliasScreenNames?: WorkspaceTopLevelScreens[]; }; /** Inputs used to build the Workspace menu while preserving its visibility and indicator rules. */ @@ -115,6 +122,8 @@ type GetWorkspaceMenuItemsParams = { isVendorMatchingBetaEnabled?: boolean; /** Whether the Merge ATS beta gating the Recruiting feature is enabled. */ isRecruitingBetaEnabled?: boolean; + /** Whether the unified Connections beta replaces the Accounting, HR, Recruiting, Receipt partners, and MCP items. */ + isUnifiedConnectionsBetaEnabled?: boolean; /** Formats the invoice account balance for its menu badge. */ convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; }; @@ -133,6 +142,7 @@ function getWorkspaceMenuItems({ hasApprovalWorkflowWithNonMemberApprover = false, isVendorMatchingBetaEnabled = false, isRecruitingBetaEnabled = false, + isUnifiedConnectionsBetaEnabled = false, convertToDisplayString, }: GetWorkspaceMenuItemsParams): WorkspaceMenuItem[] { const canReadPolicyFeature = (policyFeature: PolicyFeature) => canMemberRead(policy, currentUserLogin ?? '', policyFeature); @@ -163,6 +173,14 @@ function getWorkspaceMenuItems({ return CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; } }; + const getConnectionsBrickRoadIndicator = () => { + if (hasSyncError || shouldShowQBOReimbursableExportDestinationAccountError(policy) || hasHRError || shouldShowEnterCredentialsError) { + return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; + } + if (isMergeHRCompleteSetupNeeded(policy) || isQBORefreshTokenExpiringSoonSelector(policy)) { + return CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; + } + }; const hasMembersError = shouldShowEmployeeListError(policy); const hasPolicyCategoryError = hasPolicyCategoriesError(policyCategories); const hasGeneralSettingsError = @@ -242,7 +260,20 @@ function getWorkspaceMenuItems({ }); } - if (policyFeatureStates[CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED] && canReadPolicyFeature(CONST.POLICY.POLICY_FEATURE.ACCOUNTING)) { + if (isUnifiedConnectionsBetaEnabled && canReadMoreFeatures) { + items.push({ + translationKey: 'workspace.common.connections', + searchAliasKeys: ['workspace.common.accounting', 'workspace.common.hr', 'workspace.common.recruiting', 'workspace.common.receiptPartners', 'workspace.common.mcp'], + aliasScreenNames: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.HR, SCREENS.WORKSPACE.RECRUITING, SCREENS.WORKSPACE.RECEIPT_PARTNERS, SCREENS.WORKSPACE.MCP], + icon: icons.Connect, + getRoute: () => ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID), + brickRoadIndicator: getConnectionsBrickRoadIndicator(), + screenName: SCREENS.WORKSPACE.CONNECTIONS, + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.CONNECTIONS, + }); + } + + if (!isUnifiedConnectionsBetaEnabled && policyFeatureStates[CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED] && canReadPolicyFeature(CONST.POLICY.POLICY_FEATURE.ACCOUNTING)) { let accountingBrickRoadIndicator; if (hasSyncError || shouldShowQBOReimbursableExportDestinationAccountError(policy)) { accountingBrickRoadIndicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; @@ -260,7 +291,7 @@ function getWorkspaceMenuItems({ }); } - if (policyFeatureStates[CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED] && canReadMoreFeatures) { + if (!isUnifiedConnectionsBetaEnabled && policyFeatureStates[CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED] && canReadMoreFeatures) { items.push({ translationKey: 'workspace.common.hr', icon: icons.Users, @@ -272,7 +303,7 @@ function getWorkspaceMenuItems({ }); } - if (policyFeatureStates[CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED] && canReadMoreFeatures) { + if (!isUnifiedConnectionsBetaEnabled && policyFeatureStates[CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED] && canReadMoreFeatures) { items.push({ translationKey: 'workspace.common.recruiting', icon: icons.UserPlus, @@ -283,7 +314,7 @@ function getWorkspaceMenuItems({ }); } - if (policyFeatureStates[CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED] && canReadMoreFeatures) { + if (!isUnifiedConnectionsBetaEnabled && policyFeatureStates[CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED] && canReadMoreFeatures) { items.push({ translationKey: 'workspace.common.receiptPartners', icon: icons.Receipt, @@ -295,7 +326,7 @@ function getWorkspaceMenuItems({ }); } - if (policyFeatureStates[CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED] && canReadMoreFeatures) { + if (!isUnifiedConnectionsBetaEnabled && policyFeatureStates[CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED] && canReadMoreFeatures) { items.push({ translationKey: 'workspace.common.mcp', icon: icons.Bot, diff --git a/src/pages/workspace/hr/WorkspaceHRPage.tsx b/src/pages/workspace/hr/WorkspaceHRPage.tsx index c93994116dec..cd8cb4bc30bc 100644 --- a/src/pages/workspace/hr/WorkspaceHRPage.tsx +++ b/src/pages/workspace/hr/WorkspaceHRPage.tsx @@ -7,6 +7,7 @@ import usePolicy from '@hooks/usePolicy'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; +import withUnifiedConnectionsBeta from '@pages/workspace/connections/withUnifiedConnectionsBeta'; import MergeConnectionsPageBase from '@pages/workspace/merge/MergeConnectionsPageBase'; import CONST from '@src/CONST'; @@ -51,4 +52,4 @@ function WorkspaceHRPage({ ); } -export default WorkspaceHRPage; +export default withUnifiedConnectionsBeta(WorkspaceHRPage); diff --git a/src/pages/workspace/hr/gusto/GustoFinalApproverPage.tsx b/src/pages/workspace/hr/gusto/GustoFinalApproverPage.tsx index 2125a3f61cb6..a35667fb3e89 100644 --- a/src/pages/workspace/hr/gusto/GustoFinalApproverPage.tsx +++ b/src/pages/workspace/hr/gusto/GustoFinalApproverPage.tsx @@ -1,3 +1,4 @@ +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import useLocalize from '@hooks/useLocalize'; import {updateGustoFinalApprover} from '@libs/actions/connections/Gusto'; @@ -5,11 +6,11 @@ import {isGustoConnected} from '@libs/merge/HRUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {getHRSettingsRoute} from '@pages/workspace/connections/utils'; import MergeFinalApproverPageBase from '@pages/workspace/merge/MergeFinalApproverPageBase'; import type {MergeFinalApproverProviderConfig} from '@pages/workspace/merge/MergeFinalApproverPageBase'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import React from 'react'; @@ -22,12 +23,13 @@ function GustoFinalApproverPage({ }, }: GustoFinalApproverPageProps) { const {translate} = useLocalize(); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); const config: MergeFinalApproverProviderConfig = { testID: 'GustoFinalApproverPage', isConnected: isGustoConnected, featureName: CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED, - backRoute: ROUTES.WORKSPACE_HR.getRoute(policyID), + backRoute: getHRSettingsRoute(isUnifiedConnectionsBetaEnabled, policyID), getCurrentFinalApprover: (policy) => policy?.connections?.gusto?.config?.finalApprover ?? null, getProviderName: () => translate('workspace.hr.gusto.title'), getHeaderTitle: () => translate('workspace.merge.finalApprover'), diff --git a/src/pages/workspace/hr/merge/MergeHRFinalApproverPage.tsx b/src/pages/workspace/hr/merge/MergeHRFinalApproverPage.tsx index 86d954f95301..4104798c0406 100644 --- a/src/pages/workspace/hr/merge/MergeHRFinalApproverPage.tsx +++ b/src/pages/workspace/hr/merge/MergeHRFinalApproverPage.tsx @@ -1,3 +1,4 @@ +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import useLocalize from '@hooks/useLocalize'; import {updateMergeFinalApprover} from '@libs/actions/connections/merge'; @@ -6,11 +7,11 @@ import {isMergeConnected} from '@libs/merge/MergeUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {getHRSettingsRoute} from '@pages/workspace/connections/utils'; import MergeFinalApproverPageBase from '@pages/workspace/merge/MergeFinalApproverPageBase'; import type {MergeFinalApproverProviderConfig} from '@pages/workspace/merge/MergeFinalApproverPageBase'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import React from 'react'; @@ -23,12 +24,13 @@ function MergeHRFinalApproverPage({ }, }: MergeHRFinalApproverPageProps) { const {translate} = useLocalize(); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); const config: MergeFinalApproverProviderConfig = { testID: 'MergeHRFinalApproverPage', isConnected: (policy) => isMergeConnected(policy, CONST.POLICY.CONNECTIONS.NAME.MERGE_HR), featureName: CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED, - backRoute: ROUTES.WORKSPACE_HR.getRoute(policyID), + backRoute: getHRSettingsRoute(isUnifiedConnectionsBetaEnabled, policyID), getCurrentFinalApprover: (policy) => policy?.connections?.merge_hris?.config?.finalApprover ?? null, getProviderName: (policy) => getConnectedHRProvider(policy)?.displayName ?? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.merge_hris, getHeaderTitle: (providerName) => translate('workspace.hr.providerFinalApprover', providerName), diff --git a/src/pages/workspace/hr/zenefits/ZenefitsFinalApproverPage.tsx b/src/pages/workspace/hr/zenefits/ZenefitsFinalApproverPage.tsx index 119effb66fff..7241784dacd1 100644 --- a/src/pages/workspace/hr/zenefits/ZenefitsFinalApproverPage.tsx +++ b/src/pages/workspace/hr/zenefits/ZenefitsFinalApproverPage.tsx @@ -1,3 +1,4 @@ +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import useLocalize from '@hooks/useLocalize'; import {updateZenefitsFinalApprover} from '@libs/actions/connections/Zenefits'; @@ -5,11 +6,11 @@ import {isZenefitsConnected} from '@libs/merge/HRUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {getHRSettingsRoute} from '@pages/workspace/connections/utils'; import MergeFinalApproverPageBase from '@pages/workspace/merge/MergeFinalApproverPageBase'; import type {MergeFinalApproverProviderConfig} from '@pages/workspace/merge/MergeFinalApproverPageBase'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import React from 'react'; @@ -22,12 +23,13 @@ function ZenefitsFinalApproverPage({ }, }: ZenefitsFinalApproverPageProps) { const {translate} = useLocalize(); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); const config: MergeFinalApproverProviderConfig = { testID: 'ZenefitsFinalApproverPage', isConnected: isZenefitsConnected, featureName: CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED, - backRoute: ROUTES.WORKSPACE_HR.getRoute(policyID), + backRoute: getHRSettingsRoute(isUnifiedConnectionsBetaEnabled, policyID), getCurrentFinalApprover: (policy) => policy?.connections?.zenefits?.config?.finalApprover ?? null, getProviderName: () => translate('workspace.hr.zenefits.title'), getHeaderTitle: () => translate('workspace.merge.finalApprover'), diff --git a/src/pages/workspace/mcp/WorkspaceMCPPage.tsx b/src/pages/workspace/mcp/WorkspaceMCPPage.tsx index 440b26b752ac..00b74532d621 100644 --- a/src/pages/workspace/mcp/WorkspaceMCPPage.tsx +++ b/src/pages/workspace/mcp/WorkspaceMCPPage.tsx @@ -21,6 +21,7 @@ import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import withUnifiedConnectionsBeta from '@pages/workspace/connections/withUnifiedConnectionsBeta'; import variables from '@styles/variables'; @@ -125,4 +126,4 @@ function WorkspaceMCPPage({route}: WorkspaceMCPPageProps) { ); } -export default WorkspaceMCPPage; +export default withUnifiedConnectionsBeta(WorkspaceMCPPage); diff --git a/src/pages/workspace/merge/MergeProviderCard.tsx b/src/pages/workspace/merge/MergeProviderCard.tsx index 2805e8637ec7..4e6b09867a6e 100644 --- a/src/pages/workspace/merge/MergeProviderCard.tsx +++ b/src/pages/workspace/merge/MergeProviderCard.tsx @@ -3,35 +3,27 @@ import Button from '@components/Button'; import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import RenderHTML from '@components/RenderHTML'; -import TextLink from '@components/TextLink'; import ThreeDotsMenu from '@components/ThreeDotsMenu'; -import type ThreeDotsMenuProps from '@components/ThreeDotsMenu/types'; -import useConfirmModal from '@hooks/useConfirmModal'; import useEnvironment from '@hooks/useEnvironment'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; -import {removePolicyConnection, syncConnection} from '@libs/actions/connections'; import {clearMergeConnectionErrorField} from '@libs/actions/connections/merge'; -import {showMergeManualSyncLimitModalIfReached} from '@libs/merge/MergeUtils'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import type Policy from '@src/types/onyx/Policy'; -import type {ReactNode} from 'react'; - import React from 'react'; import {View} from 'react-native'; import type {MergeProviderCardDescriptor} from './types'; +import useMergeProviderCardDetails from './useMergeProviderCardDetails'; + type MergeProviderCardProps = { /** Descriptor object containing the Merge provider's display info, connection state, and sync status. */ card: MergeProviderCardDescriptor; @@ -50,127 +42,16 @@ type MergeProviderCardProps = { }; function MergeProviderCard({card, policy, handleConnect, canWriteMoreFeatures, showReadOnlyModal}: MergeProviderCardProps) { - const {translate, datetimeToRelative} = useLocalize(); + const {translate} = useLocalize(); const styles = useThemeStyles(); const {environmentURL} = useEnvironment(); - const {isOffline} = useNetwork(); - const icons = useMemoizedLazyExpensifyIcons(['Sync', 'Trashcan', 'Building', 'CheckCircle']); - const {showConfirmModal} = useConfirmModal(); - - const fallbackIcon = icons.Building; - const cardIcon = card.icon || fallbackIcon; - - let connectionDescription: string; - if (card.isSyncInProgress) { - connectionDescription = card.syncStageInProgress ? translate('workspace.hr.syncStageName', card.syncStageInProgress) : translate(`workspace.${card.category}.syncing`); - } else if (!card.successfulDate) { - connectionDescription = translate('workspace.merge.notSync'); - } else { - connectionDescription = translate('workspace.merge.lastSync', datetimeToRelative(card.successfulDate)); - } - - // Checked in this order: reconnect, then a stale selection, then any other sync failure - a stale - // selection is reported here even if the same sync also failed for an unrelated reason. - let lastSyncErrorMessage: ReactNode | undefined; - if (card.needsReconnect) { - lastSyncErrorMessage = ( - <> - {`${translate('workspace.merge.authenticationError', card.displayName)} `} - {!isOffline && ( - - {translate('workspace.merge.reconnectLink')} - - )} - - ); - } else if (card.staleGroupsRoute) { - const staleGroupsRoute = card.staleGroupsRoute; - lastSyncErrorMessage = ( - <> - {`${translate('workspace.hr.mergeHR.groups.staleSelectionError', card.displayName)} `} - { - if (!canWriteMoreFeatures) { - showReadOnlyModal(); - return; - } - Navigation.navigate(staleGroupsRoute); - }} - > - {translate('workspace.hr.mergeHR.groups.updateSelectionLink')} - - - ); - } else if (card.hasError) { - const genericError = translate('workspace.merge.syncError', card.displayName); - lastSyncErrorMessage = card.lastSyncErrorMessage ? `${genericError} ("${card.lastSyncErrorMessage}")` : genericError; - } - - const getPrimaryMenuItem = (): ThreeDotsMenuProps['menuItems'][number] => { - if (card.needsReconnect) { - return { - icon: icons.Sync, - text: translate('workspace.merge.reconnect'), - onSelected: handleConnect, - disabled: isOffline, - }; - } - if (card.completeSetupRoute) { - return { - icon: icons.CheckCircle, - text: translate('workspace.merge.completeSetup'), - onSelected: () => { - if (!canWriteMoreFeatures) { - showReadOnlyModal(); - return; - } - if (card.completeSetupRoute) { - Navigation.navigate(card.completeSetupRoute); - } - }, - disabled: isOffline, - }; - } - return { - icon: icons.Sync, - text: translate('workspace.merge.syncNow'), - onSelected: () => { - if (showMergeManualSyncLimitModalIfReached(policy, card.connectionName, translate, showConfirmModal)) { - return; - } - syncConnection(policy, card.connectionName); - }, - disabled: isOffline, - shouldCallAfterModalHide: true, - }; - }; - - const overflowMenu: ThreeDotsMenuProps['menuItems'] = [ - getPrimaryMenuItem(), - { - icon: icons.Trashcan, - text: translate('workspace.merge.disconnect'), - onSelected: () => { - showConfirmModal({ - title: translate('workspace.merge.disconnectTitle', card.displayName), - prompt: translate('workspace.merge.disconnectPrompt', card.displayName), - confirmText: translate('workspace.merge.disconnect'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }).then((result) => { - if (result?.action !== ModalActions.CONFIRM || !policy) { - return; - } - removePolicyConnection(policy, card.connectionName); - }); - }, - shouldCallAfterModalHide: true, - }, - ]; + const {fallbackIcon, cardIcon, connectionDescription, lastSyncErrorMessage, overflowMenu, visibleConfigRows} = useMergeProviderCardDetails({ + card, + policy, + handleConnect, + canWriteMoreFeatures, + showReadOnlyModal, + }); let rightInset: React.ReactNode; if (!card.isConnected) { @@ -201,9 +82,6 @@ function MergeProviderCard({card, policy, handleConnect, canWriteMoreFeatures, s const rightComponent = {rightInset}; - // While the setup is incomplete only the rows that failed to save are shown, so the admin is steered to the setup flow first. - const visibleConfigRows = card.isConnected && !card.isInitialSyncInProgress ? (card.configRows ?? []).filter((row) => !card.completeSetupRoute || !!row.errors) : []; - return ( <> void; + canWriteMoreFeatures: boolean; + showReadOnlyModal: () => void; + + /** Runs once the user confirms disconnecting, before the connection is removed */ + onDisconnect?: () => void; +}; + +/** The status text, sync error and overflow menu of a Merge provider, shared by the HR and Recruiting cards and their Connections panel */ +function useMergeProviderCardDetails({card, policy, handleConnect, canWriteMoreFeatures, showReadOnlyModal, onDisconnect}: UseMergeProviderCardDetailsParams) { + const {translate, datetimeToRelative} = useLocalize(); + const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + const icons = useMemoizedLazyExpensifyIcons(['Sync', 'Trashcan', 'Building', 'CheckCircle']); + const {showConfirmModal} = useConfirmModal(); + + const fallbackIcon = icons.Building; + const cardIcon = card.icon || fallbackIcon; + + let connectionDescription: string; + if (card.isSyncInProgress) { + connectionDescription = card.syncStageInProgress ? translate('workspace.hr.syncStageName', card.syncStageInProgress) : translate(`workspace.${card.category}.syncing`); + } else if (!card.successfulDate) { + connectionDescription = translate('workspace.merge.notSync'); + } else { + connectionDescription = translate('workspace.merge.lastSync', datetimeToRelative(card.successfulDate)); + } + + // Checked in this order: reconnect, then a stale selection, then any other sync failure. A stale + // selection is reported here even if the same sync also failed for an unrelated reason. + let lastSyncErrorMessage: ReactNode | undefined; + if (card.needsReconnect) { + lastSyncErrorMessage = ( + <> + {`${translate('workspace.merge.authenticationError', card.displayName)} `} + {!isOffline && ( + + {translate('workspace.merge.reconnectLink')} + + )} + + ); + } else if (card.staleGroupsRoute) { + const staleGroupsRoute = card.staleGroupsRoute; + lastSyncErrorMessage = ( + <> + {`${translate('workspace.hr.mergeHR.groups.staleSelectionError', card.displayName)} `} + { + if (!canWriteMoreFeatures) { + showReadOnlyModal(); + return; + } + Navigation.navigate(staleGroupsRoute); + }} + > + {translate('workspace.hr.mergeHR.groups.updateSelectionLink')} + + + ); + } else if (card.hasError) { + const genericError = translate('workspace.merge.syncError', card.displayName); + lastSyncErrorMessage = card.lastSyncErrorMessage ? `${genericError} ("${card.lastSyncErrorMessage}")` : genericError; + } + + const getPrimaryMenuItem = (): ThreeDotsMenuProps['menuItems'][number] => { + if (card.needsReconnect) { + return { + icon: icons.Sync, + text: translate('workspace.merge.reconnect'), + onSelected: handleConnect, + disabled: isOffline, + }; + } + if (card.completeSetupRoute) { + return { + icon: icons.CheckCircle, + text: translate('workspace.merge.completeSetup'), + onSelected: () => { + if (!canWriteMoreFeatures) { + showReadOnlyModal(); + return; + } + if (card.completeSetupRoute) { + Navigation.navigate(card.completeSetupRoute); + } + }, + disabled: isOffline, + }; + } + return { + icon: icons.Sync, + text: translate('workspace.merge.syncNow'), + onSelected: () => { + if (showMergeManualSyncLimitModalIfReached(policy, card.connectionName, translate, showConfirmModal)) { + return; + } + syncConnection(policy, card.connectionName); + }, + disabled: isOffline, + shouldCallAfterModalHide: true, + }; + }; + + const overflowMenu: ThreeDotsMenuProps['menuItems'] = [ + getPrimaryMenuItem(), + { + icon: icons.Trashcan, + text: translate('workspace.merge.disconnect'), + onSelected: () => { + showConfirmModal({ + title: translate('workspace.merge.disconnectTitle', card.displayName), + prompt: translate('workspace.merge.disconnectPrompt', card.displayName), + confirmText: translate('workspace.merge.disconnect'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }).then((result) => { + if (result?.action !== ModalActions.CONFIRM || !policy) { + return; + } + onDisconnect?.(); + removePolicyConnection(policy, card.connectionName); + }); + }, + shouldCallAfterModalHide: true, + }, + ]; + + // While the setup is incomplete only the rows that failed to save are shown, so the admin is steered to the setup flow first. + const visibleConfigRows = card.isConnected && !card.isInitialSyncInProgress ? (card.configRows ?? []).filter((row) => !card.completeSetupRoute || !!row.errors) : []; + + return {fallbackIcon, cardIcon, connectionDescription, lastSyncErrorMessage, overflowMenu, visibleConfigRows}; +} + +export default useMergeProviderCardDetails; diff --git a/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx b/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx index f949fb077165..cae70af7bd10 100644 --- a/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx +++ b/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx @@ -4,20 +4,14 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemSectionRoot from '@components/MenuItem/presets/MenuItemSectionRoot'; -import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Section from '@components/Section'; import ThreeDotsMenu from '@components/ThreeDotsMenu'; -import useConfirmModal from '@hooks/useConfirmModal'; -import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import usePolicy from '@hooks/usePolicy'; -import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScreenBoundDynamicRoute from '@hooks/useScreenBoundDynamicRoute'; @@ -30,11 +24,9 @@ import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {MenuItemData} from '@pages/workspace/accounting/types'; +import withUnifiedConnectionsBeta from '@pages/workspace/connections/withUnifiedConnectionsBeta'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; -import {openExternalLink} from '@userActions/Link'; -import {openPolicyReceiptPartnersPage, removePolicyReceiptPartnersConnection, togglePolicyUberAutoInvite, togglePolicyUberAutoRemove} from '@userActions/Policy/Policy'; - import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -42,61 +34,45 @@ import type {AnchorPosition} from '@src/styles'; import type {ComponentRef} from 'react'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; +import useReceiptPartnersSettings from './useReceiptPartnersSettings'; import getSynchronizationErrorMessage from './utils'; type WorkspaceReceiptPartnersPageProps = PlatformStackScreenProps; function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps) { const policyID = route.params.policyID; - const icons = useMemoizedLazyExpensifyIcons(['Key', 'Mail', 'NewWindow', 'Trashcan']); + const icons = useMemoizedLazyExpensifyIcons(['Mail']); const {translate} = useLocalize(); const buildDynamicRoute = useScreenBoundDynamicRoute(); const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {showConfirmModal} = useConfirmModal(); - const receiptPartnerNames = CONST.POLICY.RECEIPT_PARTNERS.NAME; - const receiptPartnerIntegrations = Object.values(receiptPartnerNames); + const receiptPartnerIntegrations = Object.values(CONST.POLICY.RECEIPT_PARTNERS.NAME); const threeDotsMenuContainerRef = useRef>(null); - const policy = usePolicy(policyID); + const { + policy, + integrations, + isAutoInvite, + isAutoRemove, + isUberConnected, + shouldShowEnterCredentialsError, + getReceiptPartnersIntegrationData, + isOffline, + canWriteMoreFeatures, + showReadOnlyModal, + withReadOnlyFallback, + startIntegrationFlow, + toggleUberAutoInvite, + toggleUberAutoRemove, + getOverflowMenu, + } = useReceiptPartnersSettings(policyID); useWorkspaceDocumentTitle(policy?.name, 'workspace.common.receiptPartners'); - const {getReceiptPartnersIntegrationData, shouldShowEnterCredentialsError, isUberConnected} = useGetReceiptPartnersIntegrationData(policyID); const isLoading = policy?.isLoading; - const integrations = policy?.receiptPartners; - const isAutoRemove = !!integrations?.uber?.autoRemove; - const isAutoInvite = !!integrations?.uber?.autoInvite; const centralBillingAccountEmail = !!integrations?.uber?.centralBillingAccountEmail; // Track focus and connection change to route to the invite flow once after successful connection const prevIsUberConnected = usePrevious(isUberConnected); - const {canWrite: canWriteMoreFeatures, showReadOnlyModal, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); - - const startIntegrationFlow = useCallback( - ({name}: {name: string}) => { - switch (name) { - case CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER: { - openExternalLink(`${CONST.UBER_CONNECT_URL}?${integrations?.uber?.connectFormData}`); - break; - } - default: { - break; - } - } - }, - [integrations?.uber?.connectFormData], - ); - - const fetchReceiptPartners = useCallback(() => { - openPolicyReceiptPartnersPage(policyID); - }, [policyID]); - - const {isOffline} = useNetwork({onReconnect: fetchReceiptPartners}); - - useEffect(() => { - fetchReceiptPartners(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // When Uber connection status flips from false -> true, navigate to the invite flow once useEffect(() => { @@ -106,7 +82,7 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps Navigation.navigate(buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_RECEIPT_PARTNERS_INVITE.getRoute(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER))); }, [prevIsUberConnected, isUberConnected, policyID, canWriteMoreFeatures, buildDynamicRoute]); - const calculateAndSetThreeDotsMenuPosition = useCallback(() => { + const calculateAndSetThreeDotsMenuPosition = () => { if (shouldUseNarrowLayout) { return Promise.resolve({horizontal: 0, vertical: 0}); } @@ -118,76 +94,9 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps }); }); }); - }, [shouldUseNarrowLayout]); - - const toggleWorkspaceUberAutoInvite = useCallback(() => { - togglePolicyUberAutoInvite(policyID, !isAutoInvite); - }, [isAutoInvite, policyID]); - - const toggleWorkspaceUberAutoRemove = useCallback(() => { - togglePolicyUberAutoRemove(policyID, !isAutoRemove); - }, [isAutoRemove, policyID]); + }; - const disconnectPartner = useCallback( - (partner: (typeof receiptPartnerNames)[keyof typeof receiptPartnerNames]) => { - if (!policyID) { - return; - } - removePolicyReceiptPartnersConnection(policyID, partner, integrations?.[partner]); - fetchReceiptPartners(); - }, - [policyID, integrations, fetchReceiptPartners], - ); - - const getOverflowMenu = useCallback( - (integration: string) => { - switch (integration) { - case CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER: - if (shouldShowEnterCredentialsError) { - return [ - { - icon: icons.Key, - text: translate('workspace.accounting.enterCredentials'), - onSelected: () => - startIntegrationFlow({ - name: CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER, - }), - shouldCallAfterModalHide: true, - disabled: isOffline, - iconRight: icons.NewWindow, - }, - ]; - } - - return [ - { - icon: icons.Trashcan, - text: translate('workspace.accounting.disconnect'), - onSelected: () => { - showConfirmModal({ - title: translate('workspace.moreFeatures.receiptPartnersWarningModal.featureEnabledTitle'), - prompt: translate('workspace.moreFeatures.receiptPartnersWarningModal.description'), - confirmText: translate('workspace.accounting.disconnect'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }).then(({action}) => { - if (action !== ModalActions.CONFIRM) { - return; - } - disconnectPartner(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER); - }); - }, - shouldCallAfterModalHide: true, - }, - ]; - default: - return []; - } - }, - [icons.Key, icons.NewWindow, icons.Trashcan, shouldShowEnterCredentialsError, translate, isOffline, startIntegrationFlow, showConfirmModal, disconnectPartner], - ); - - const connectionsMenuItems: MenuItemData[] = useMemo(() => { + const getConnectionsMenuItems = (): MenuItemData[] => { if (policyID) { return receiptPartnerIntegrations .map((integration) => { @@ -270,24 +179,8 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps } return []; - }, [ - policyID, - receiptPartnerIntegrations, - getReceiptPartnersIntegrationData, - getOverflowMenu, - canWriteMoreFeatures, - shouldShowEnterCredentialsError, - translate, - styles, - shouldUseNarrowLayout, - isUberConnected, - calculateAndSetThreeDotsMenuPosition, - policy?.receiptPartners?.uber, - policy?.isLoadingReceiptPartners, - isOffline, - startIntegrationFlow, - showReadOnlyModal, - ]); + }; + const connectionsMenuItems = getConnectionsMenuItems(); return ( void; +}; + +/** State and actions for the workspace's receipt partner connections, shared by the Receipt partners page and its Connections panel */ +function useReceiptPartnersSettings(policyID: string, {onDisconnectConfirmed}: UseReceiptPartnersSettingsOptions = {}) { + const icons = useMemoizedLazyExpensifyIcons(['Key', 'NewWindow', 'Trashcan']); + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + const policy = usePolicy(policyID); + const {getReceiptPartnersIntegrationData, shouldShowEnterCredentialsError, isUberConnected} = useGetReceiptPartnersIntegrationData(policyID); + const integrations = policy?.receiptPartners; + const isAutoRemove = !!integrations?.uber?.autoRemove; + const isAutoInvite = !!integrations?.uber?.autoInvite; + const {canWrite: canWriteMoreFeatures, showReadOnlyModal, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); + + const fetchReceiptPartners = () => { + openPolicyReceiptPartnersPage(policyID); + }; + + const {isOffline} = useNetwork({onReconnect: fetchReceiptPartners}); + + useEffect(() => { + openPolicyReceiptPartnersPage(policyID); + }, [policyID]); + + const startIntegrationFlow = ({name}: {name: string}) => { + switch (name) { + case CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER: { + openExternalLink(`${CONST.UBER_CONNECT_URL}?${integrations?.uber?.connectFormData}`); + break; + } + default: { + break; + } + } + }; + + const toggleUberAutoInvite = () => { + togglePolicyUberAutoInvite(policyID, !isAutoInvite); + }; + + const toggleUberAutoRemove = () => { + togglePolicyUberAutoRemove(policyID, !isAutoRemove); + }; + + const disconnectPartner = (partner: ValueOf) => { + if (!policyID) { + return; + } + removePolicyReceiptPartnersConnection(policyID, partner, integrations?.[partner]); + fetchReceiptPartners(); + }; + + const getOverflowMenu = (integration: string): PopoverMenuItem[] => { + switch (integration) { + case CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER: + if (shouldShowEnterCredentialsError) { + return [ + { + icon: icons.Key, + text: translate('workspace.accounting.enterCredentials'), + onSelected: () => + startIntegrationFlow({ + name: CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER, + }), + shouldCallAfterModalHide: true, + disabled: isOffline, + iconRight: icons.NewWindow, + }, + ]; + } + + return [ + { + icon: icons.Trashcan, + text: translate('workspace.accounting.disconnect'), + onSelected: () => { + showConfirmModal({ + title: translate('workspace.moreFeatures.receiptPartnersWarningModal.featureEnabledTitle'), + prompt: translate('workspace.moreFeatures.receiptPartnersWarningModal.description'), + confirmText: translate('workspace.accounting.disconnect'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }).then(({action}) => { + if (action !== ModalActions.CONFIRM) { + return; + } + onDisconnectConfirmed?.(); + disconnectPartner(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER); + }); + }, + shouldCallAfterModalHide: true, + }, + ]; + default: + return []; + } + }; + + return { + policy, + integrations, + isAutoInvite, + isAutoRemove, + isUberConnected, + shouldShowEnterCredentialsError, + getReceiptPartnersIntegrationData, + isOffline, + canWriteMoreFeatures, + showReadOnlyModal, + withReadOnlyFallback, + startIntegrationFlow, + toggleUberAutoInvite, + toggleUberAutoRemove, + getOverflowMenu, + }; +} + +export default useReceiptPartnersSettings; diff --git a/src/pages/workspace/recruiting/WorkspaceRecruitingPage.tsx b/src/pages/workspace/recruiting/WorkspaceRecruitingPage.tsx index 8e0392cb9da9..33a4f1aeb2ba 100644 --- a/src/pages/workspace/recruiting/WorkspaceRecruitingPage.tsx +++ b/src/pages/workspace/recruiting/WorkspaceRecruitingPage.tsx @@ -11,6 +11,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; +import withUnifiedConnectionsBeta from '@pages/workspace/connections/withUnifiedConnectionsBeta'; import MergeConnectionsPageBase from '@pages/workspace/merge/MergeConnectionsPageBase'; import CONST from '@src/CONST'; @@ -56,4 +57,4 @@ function WorkspaceRecruitingPage({ ); } -export default WorkspaceRecruitingPage; +export default withUnifiedConnectionsBeta(WorkspaceRecruitingPage); diff --git a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx index 66614ccb4345..0d663f74ae2c 100644 --- a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx +++ b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx @@ -3,6 +3,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -128,6 +129,7 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { const {translate} = useLocalize(); const {isBetaEnabledOrUnknown} = usePermissions(); const isVendorMatchingBetaEnabled = isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); const {accountID, email = ''} = useCurrentUserPersonalDetails(); const getReviewWorkspaceSettingsTaskCompletion = useReviewWorkspaceSettingsTaskCompletion(); const [priorFirstDayFreeTrial] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL); @@ -189,8 +191,10 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { case CONST.UPGRADE_FEATURE_INTRO_MAPPING.invoicing.id: case CONST.UPGRADE_FEATURE_INTRO_MAPPING.companyCardSubmit.id: case CONST.UPGRADE_FEATURE_INTRO_MAPPING.travelSubmit.id: - case CONST.UPGRADE_FEATURE_INTRO_MAPPING.hr.id: return Navigation.goBack(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); + case CONST.UPGRADE_FEATURE_INTRO_MAPPING.hr.id: + // Connections also upgrades for HR, and passes itself as the page to return to + return Navigation.goBack((isUnifiedConnectionsBetaEnabled ? route.params.backTo : undefined) ?? ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); default: return route.params.backTo ? Navigation.goBack(route.params.backTo) : Navigation.goBack(); } @@ -331,10 +335,10 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { enablePerDiem(policyID, true, perDiemCustomUnit?.customUnitID, false); break; case CONST.UPGRADE_FEATURE_INTRO_MAPPING.hr.id: - enablePolicyHR(policyID, true); + enablePolicyHR(policyID, true, !isUnifiedConnectionsBetaEnabled); break; case CONST.UPGRADE_FEATURE_INTRO_MAPPING.recruiting.id: - enablePolicyRecruiting(policyID, true); + enablePolicyRecruiting(policyID, true, !isUnifiedConnectionsBetaEnabled); break; case CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id: case CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvalSubmit.id: diff --git a/src/styles/index.ts b/src/styles/index.ts index 18f1d71e7919..da9b22e2c8f7 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -1148,6 +1148,15 @@ const staticStyles = (theme: ThemeColors) => borderColor: theme.badgeDangerBG, }, + badgeOffer: { + backgroundColor: theme.badgeOfferBG, + borderColor: theme.badgeOfferBG, + }, + + badgeOfferText: { + color: theme.badgeOfferText, + }, + badgeDangerStrong: { backgroundColor: theme.danger, borderColor: theme.danger, @@ -5051,6 +5060,26 @@ const staticStyles = (theme: ThemeColors) => scrollMarginInline: variables.tabSelectorScrollMarginInline, }, + // Rows are spaced by each card's top margin so the zero-height fillers add no space. The negative margin cancels the first row's. + connectionsGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + columnGap: 12, + marginTop: -12, + }, + + // Cards wrap by their minimum width. Three 26% bases fit a row with the gaps, while a fourth does not. + connectionCard: { + flexBasis: '26%', + minWidth: variables.connectionCardMinWidth, + }, + + connectionsTabButton: { + height: 'auto', + paddingVertical: 10, + paddingHorizontal: 16, + }, + tabSelector: { flexDirection: 'row', paddingHorizontal: 20, diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index 6fb517723de7..c108dd30b0fa 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -97,6 +97,8 @@ const darkTheme = { badgeDangerText: colors.tangerine200, badgeAdHoc: colors.pink600, badgeAdHocHover: colors.pink700, + badgeOfferBG: colors.pink700, + badgeOfferText: colors.pink200, bordersBold: colors.productDark400, buttonIcon: colors.productDark700, mentionText: colors.blue100, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index b83c5a565946..3a71dfe79c3d 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -97,6 +97,8 @@ const lightTheme = { badgeDangerText: colors.tangerine700, badgeAdHoc: colors.pink600, badgeAdHocHover: colors.pink700, + badgeOfferBG: colors.pink200, + badgeOfferText: colors.pink600, bordersBold: colors.productLight400, buttonIcon: colors.productLight700, mentionText: colors.blue600, diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts index da9f8e06987c..415292a2bbe2 100644 --- a/src/styles/theme/types.ts +++ b/src/styles/theme/types.ts @@ -105,6 +105,8 @@ type ThemeColors = { badgeDangerText: Color; badgeAdHoc: Color; badgeAdHocHover: Color; + badgeOfferBG: Color; + badgeOfferText: Color; bordersBold: Color; buttonIcon: Color; mentionText: Color; diff --git a/src/styles/variables.ts b/src/styles/variables.ts index efba97038492..2179289548c2 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -107,6 +107,7 @@ export default { mobileResponsiveWidthBreakpoint: 800, tabletResponsiveWidthBreakpoint: 1024, sidePanelResponsiveWidthBreakpoint: 1300, + connectionCardMinWidth: 300, iosSafeAreaInsetsPercentage: 0.7, androidSafeAreaInsetsPercentage: 1, sideBarWidth: 375, @@ -252,6 +253,7 @@ export default { signInLogoWidthPill: 132, tabSelectorButtonHeight: 40, tabSelectorButtonPadding: 12, + tabSelectorScrollFadeWidth: 32, tabSelectorScrollMarginInline: 20, tabSelectorMaxTabLabelWidth: 256, filterPillMaxWidth: 256, diff --git a/src/types/onyx/DismissedProductTraining.ts b/src/types/onyx/DismissedProductTraining.ts index 39f9f67a127e..d034afa5776b 100644 --- a/src/types/onyx/DismissedProductTraining.ts +++ b/src/types/onyx/DismissedProductTraining.ts @@ -11,6 +11,7 @@ const { MILEAGE_RATE_AUTO_UPDATED, MARK_ALL_AS_READ, ACCOUNT_MOVED_TO_TOP_BAR, + CONNECTIONS_MOVED, REQUIRE_FIELDS_RULE_RECEIPT_COUPLING_TOOLTIP, REQUIRE_FIELDS_RULE_ITEMIZED_RECEIPT_COUPLING_TOOLTIP, } = CONST.PRODUCT_TRAINING_TOOLTIP_NAMES; @@ -101,6 +102,11 @@ type DismissedProductTraining = { */ [ACCOUNT_MOVED_TO_TOP_BAR]: DismissedProductTrainingElement; + /** + * When user dismisses the tooltip pointing at the Connections item in the workspace menu, we store the timestamp here. + */ + [CONNECTIONS_MOVED]: DismissedProductTrainingElement; + /** * When user dismisses the require fields rule tooltip explaining why Receipt is locked while * Itemized receipt is required, we store the timestamp here. diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index 8b0692260434..fddf8886b1ad 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -5743,6 +5743,38 @@ describe('actions/Policy', () => { }); }); + describe('enablePolicyFeatureForConnection', () => { + it('should turn the feature on when it is off, since the Connections page has no toggle for it', async () => { + // Given a workspace with HR off + const apiWriteSpy = jest.spyOn(APIModule, 'writeWithNoDuplicatesEnableFeatureConflicts').mockImplementation(() => Promise.resolve()); + const fakePolicy: PolicyType = {...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), isHREnabled: false, connections: undefined}; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + await waitForBatchedUpdates(); + + // When an HR provider is connected from the Connections page + Policy.enablePolicyFeatureForConnection(fakePolicy, CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED); + await waitForBatchedUpdates(); + + // Then HR is turned on, so the connection's settings pages are reachable + expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.ENABLE_POLICY_HR, expect.objectContaining({policyID: fakePolicy.id, enabled: true}), expect.anything()); + apiWriteSpy.mockRestore(); + }); + + it('should not call the API when the feature is already on', async () => { + // Given a workspace with HR already on + const apiWriteSpy = jest.spyOn(APIModule, 'writeWithNoDuplicatesEnableFeatureConflicts').mockImplementation(() => Promise.resolve()); + const fakePolicy: PolicyType = {...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), isHREnabled: true}; + + // When an HR provider is connected from the Connections page + Policy.enablePolicyFeatureForConnection(fakePolicy, CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED); + await waitForBatchedUpdates(); + + // Then nothing is sent, since there is nothing to turn on + expect(apiWriteSpy).not.toHaveBeenCalled(); + apiWriteSpy.mockRestore(); + }); + }); + describe('enablePolicyTaxes', () => { it('should enable policy taxes optimistically and succeed', async () => { // Given a policy with taxes disabled diff --git a/tests/ui/WorkspaceMoreFeaturesPageTest.tsx b/tests/ui/WorkspaceMoreFeaturesPageTest.tsx index 04185e1f2a1f..f36cf9bbf3f9 100644 --- a/tests/ui/WorkspaceMoreFeaturesPageTest.tsx +++ b/tests/ui/WorkspaceMoreFeaturesPageTest.tsx @@ -26,6 +26,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import type Beta from '@src/types/onyx/Beta'; import {PortalProvider} from '@gorhom/portal'; import {NavigationContainer} from '@react-navigation/native'; @@ -307,6 +308,8 @@ describe('WorkspaceMoreFeaturesPage', () => { await TestHelper.signInWithTestUser(); hasAccountingConnectionsMock.mockReturnValue(true); await act(async () => { + // The test user is on the 'all' beta, which includes the unified Connections beta that hides this toggle + await Onyx.merge(ONYXKEYS.BETAS, []); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildPolicy({id: POLICY_ID})); }); @@ -320,6 +323,8 @@ describe('WorkspaceMoreFeaturesPage', () => { await TestHelper.signInWithTestUser(); hasAccountingConnectionsMock.mockReturnValue(true); await act(async () => { + // The test user is on the 'all' beta, which includes the unified Connections beta that hides this toggle + await Onyx.merge(ONYXKEYS.BETAS, []); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildPolicy({id: POLICY_ID})); }); @@ -388,6 +393,8 @@ describe('WorkspaceMoreFeaturesPage', () => { await TestHelper.signInWithTestUser(); useIsUberConnectedMock.mockReturnValue(true); await act(async () => { + // The test user is on the 'all' beta, which includes the unified Connections beta that hides this toggle + await Onyx.merge(ONYXKEYS.BETAS, []); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildPolicy({id: POLICY_ID})); }); @@ -408,6 +415,39 @@ describe('WorkspaceMoreFeaturesPage', () => { }); }); + describe('Integrate section', () => { + const renderWithBetas = async (betas: Beta[]) => { + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.merge(ONYXKEYS.BETAS, betas); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildPolicy({id: POLICY_ID})); + }); + renderPage({policyID: POLICY_ID}); + await waitForBatchedUpdatesWithAct(); + }; + + const accountingSwitchQuery = () => + screen.queryByRole(CONST.ROLE.SWITCH, {name: new RegExp(escapeRegExp(TestHelper.translateLocal('workspace.moreFeatures.connections.subtitle')), 'i')}); + + it('shows the Accounting toggle without the unified Connections beta', async () => { + // Given a workspace + // When More Features renders without the beta + await renderWithBetas([]); + + // Then the integration features can still be turned on here, since their pages are only reachable once they are on + expect(accountingSwitchQuery()).toBeOnTheScreen(); + }); + + it('hides the integration toggles with the unified Connections beta', async () => { + // Given a workspace + // When More Features renders with the beta + await renderWithBetas([CONST.BETAS.UNIFIED_CONNECTIONS]); + + // Then the toggles are gone, because Connections turns each feature on as its first integration connects + expect(accountingSwitchQuery()).toBeNull(); + }); + }); + describe('Vendors row (visibility gated on a supported integration)', () => { const renderWithVendorMatching = async (connections: Record, isBetaEnabled = true) => { await TestHelper.signInWithTestUser(); diff --git a/tests/unit/AccountingConnectionConfirmationTest.tsx b/tests/unit/AccountingConnectionConfirmationTest.tsx index 71adc21b49de..eaa05033e0a0 100644 --- a/tests/unit/AccountingConnectionConfirmationTest.tsx +++ b/tests/unit/AccountingConnectionConfirmationTest.tsx @@ -13,6 +13,7 @@ import Onyx from 'react-native-onyx'; import type * as MockUseConfirmModalUtil from '../utils/mockUseConfirmModal'; import createRandomPolicy from '../utils/collections/policies'; +import createMock from '../utils/createMock'; import {getShowConfirmModalOption, MockModalActions, mockShowConfirmModal, resetMockConfirmModal, resolveShowConfirmModal} from '../utils/mockUseConfirmModal'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -86,6 +87,17 @@ jest.mock('@hooks/useHasReusablePoliciesConnectedTo', () => ({ default: () => false, })); +let mockEnabledBetas: string[] = []; +jest.mock('@hooks/usePermissions', () => () => ({isBetaEnabled: (beta: string) => mockEnabledBetas.includes(beta)})); + +const mockEnablePolicyFeatureForConnection = jest.fn(); +jest.mock('@userActions/Policy/Policy', () => ({ + __esModule: true, + enablePolicyFeatureForConnection: (policyToEnable: Policy | undefined, featureName: string) => { + mockEnablePolicyFeatureForConnection(policyToEnable?.id, featureName); + }, +})); + const policy: Policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE, 'Test workspace'), id: POLICY_ID}; const TestHarness = React.forwardRef((_props, ref) => { @@ -96,10 +108,10 @@ const TestHarness = React.forwardRef((_props, ref) => { return null; }); -function renderProvider() { +function renderProvider(providerPolicy: Policy = policy) { const ref = React.createRef(); render( - + , ); @@ -309,4 +321,64 @@ describe('AccountingContextProvider connect-confirmation prompt', () => { expect(mockRemovePolicyConnection).not.toHaveBeenCalled(); expect(screen.queryByTestId(SETUP_FLOW_TEST_ID)).not.toBeOnTheScreen(); }); + + describe('with the unified Connections beta', () => { + beforeEach(() => { + mockEnabledBetas = [CONST.BETAS.UNIFIED_CONNECTIONS]; + }); + + afterEach(() => { + mockEnabledBetas = []; + }); + + it('should ask to replace the connected integration, naming the one that would be removed', async () => { + // Given a workspace with an accounting integration connected + const ref = renderProvider(); + + // When a flow that has to disconnect it first is started + await startFlowNeedingDisconnect(ref); + + // Then the prompt is about replacing that connection, because Connections lists every integration side by side + expect(getShowConfirmModalOption('title')).toBe('workspace.connections.replaceConnectionTitle'); + expect(getShowConfirmModalOption('prompt')).toBe(`workspace.connections.replaceConnectionPrompt:${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.xero}`); + expect(getShowConfirmModalOption('confirmText')).toBe('common.replace'); + expect(getShowConfirmModalOption('buttonVariant')).toBe(CONST.BUTTON_VARIANT.DANGER); + expect(screen.queryByTestId(SETUP_FLOW_TEST_ID)).not.toBeOnTheScreen(); + }); + + it('should name the connected Intuit Enterprise Suite when that is the connection being replaced', async () => { + // Given a workspace whose QuickBooks Online connection is the Intuit Enterprise Suite variant + const ref = renderProvider({ + ...policy, + connections: createMock({quickbooksOnline: {config: {credentials: {scope: CONST.POLICY.CONNECTIONS.INTUIT_ENTERPRISE_SUITE_SCOPE}}}}), + }); + + // When a different integration is started, which means replacing that connection + await act(async () => { + ref.current?.startIntegrationFlow({ + name: CONST.POLICY.CONNECTIONS.NAME.XERO, + shouldDisconnectIntegrationBeforeConnecting: true, + integrationToDisconnect: CONST.POLICY.CONNECTIONS.NAME.QBO, + }); + await waitForBatchedUpdates(); + }); + + // Then the prompt names the suite rather than QuickBooks Online, so the user recognizes what they would lose + expect(getShowConfirmModalOption('prompt')).toBe('workspace.connections.replaceConnectionPrompt:workspace.accounting.intuitEnterpriseSuite'); + }); + + it('should turn the Accounting feature on when a flow starts, since Connections has no toggle for it', async () => { + // Given a workspace with the Accounting feature off, which Connections has no toggle for + const ref = renderProvider({...policy, areConnectionsEnabled: false}); + + // When a connect flow is started + await act(async () => { + ref.current?.startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBO}); + await waitForBatchedUpdates(); + }); + + // Then the feature is turned on without navigating away, so the integration can be used once it connects + expect(mockEnablePolicyFeatureForConnection).toHaveBeenCalledWith(POLICY_ID, CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED); + }); + }); }); diff --git a/tests/unit/ConnectionsPanelGatesTest.tsx b/tests/unit/ConnectionsPanelGatesTest.tsx new file mode 100644 index 000000000000..5e792a19783e --- /dev/null +++ b/tests/unit/ConnectionsPanelGatesTest.tsx @@ -0,0 +1,169 @@ +import {render, screen} from '@testing-library/react-native'; + +import type TextComponent from '@components/Text'; + +import ConnectionsAccountingPage from '@pages/workspace/connections/ConnectionsAccountingPage'; +import ConnectionsMergePageBase from '@pages/workspace/connections/ConnectionsMergePageBase'; +import ConnectionsReceiptPartnersPage from '@pages/workspace/connections/ConnectionsReceiptPartnersPage'; +import type {MergeProviderCardDescriptor} from '@pages/workspace/merge/types'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; + +import React from 'react'; + +import createRandomPolicy from '../utils/collections/policies'; + +const NOT_FOUND = 'Not found'; +const POLICY_ID = 'policy-1'; + +let mockIsUnifiedConnectionsBetaEnabled = true; +let mockConnectedIntegration: string | undefined; +let mockIsUberConnected = false; + +// The gates under test are the props each panel passes here, so the wrapper only shows whether the panel is blocked +jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => { + const {default: MockText} = jest.requireActual<{default: typeof TextComponent}>('@components/Text'); + return { + __esModule: true, + default: ({children, shouldBeBlocked}: {children: React.ReactNode; shouldBeBlocked?: boolean}) => (shouldBeBlocked ? Not found : children), + }; +}); +jest.mock('@components/ScreenWrapper', () => ({__esModule: true, default: () => null})); +jest.mock('@components/FullscreenLoadingIndicator', () => ({__esModule: true, default: () => null})); +jest.mock('@hooks/useIsUnifiedConnectionsBetaEnabled', () => () => mockIsUnifiedConnectionsBetaEnabled); +jest.mock('@hooks/useWorkspaceDocumentTitle', () => () => {}); +jest.mock('@hooks/useScreenBoundDynamicRoute', () => () => jest.fn()); +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); +jest.mock('@hooks/useNetwork', () => () => ({isOffline: false})); +jest.mock('@hooks/usePolicy', () => () => undefined); +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => () => ({canWrite: true, showReadOnlyModal: jest.fn()})); +jest.mock('@libs/actions/PolicyConnections', () => ({openPolicyHRPage: jest.fn(), openPolicyRecruitingPage: jest.fn()})); +jest.mock('@pages/workspace/withPolicyConnections', () => ({ + __esModule: true, + default: (Component: React.ComponentType<{policy: Policy}>) => Component, +})); +jest.mock('@pages/workspace/accounting/AccountingContext', () => ({ + AccountingContextProvider: ({children}: {children: React.ReactNode}) => children, +})); +jest.mock('@pages/workspace/accounting/useConnectedAccountingIntegration', () => ({ + __esModule: true, + default: () => ({connectedIntegration: mockConnectedIntegration, hasUnsupportedNDIntegration: false}), +})); +jest.mock('@pages/workspace/receiptPartners/useReceiptPartnersSettings', () => ({ + __esModule: true, + default: () => ({ + policy: {isLoading: false}, + isUberConnected: mockIsUberConnected, + shouldShowEnterCredentialsError: false, + getReceiptPartnersIntegrationData: () => undefined, + canWriteMoreFeatures: true, + withReadOnlyFallback: () => undefined, + toggleUberAutoInvite: jest.fn(), + toggleUberAutoRemove: jest.fn(), + getOverflowMenu: () => [], + }), +})); + +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only: the HOC that would inject `policy` is mocked out, so it is passed as a prop here +const ConnectionsAccountingPageUnderTest = ConnectionsAccountingPage as unknown as React.ComponentType<{policy: Policy}>; +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only: the panel only reads the policy ID from its route +const ConnectionsReceiptPartnersPageUnderTest = ConnectionsReceiptPartnersPage as unknown as React.ComponentType<{route: {params: {policyID: string}}}>; + +const policy: Policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), id: POLICY_ID}; + +function buildHRCard(isConnected: boolean): MergeProviderCardDescriptor { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only: the gate only reads isConnected + return {connectionName: 'gusto', displayName: 'Gusto', category: CONST.POLICY.CONNECTIONS.CATEGORY.HR, isConnected} as MergeProviderCardDescriptor; +} + +describe('Connections panel gates', () => { + beforeEach(() => { + mockIsUnifiedConnectionsBetaEnabled = true; + mockConnectedIntegration = undefined; + mockIsUberConnected = false; + }); + + describe('ConnectionsAccountingPage', () => { + it('should block the panel for a user without the beta, even with an integration connected', () => { + // Given a connected workspace viewed by a user without the unified Connections beta + mockIsUnifiedConnectionsBetaEnabled = false; + mockConnectedIntegration = CONST.POLICY.CONNECTIONS.NAME.QBO; + + // When the panel opens from a link + render(); + + // Then it shows Not Found, since this user still has the Accounting page + expect(screen.getByText(NOT_FOUND)).toBeOnTheScreen(); + }); + + it('should block the panel when no accounting integration is connected', () => { + // Given a beta user on a workspace with nothing connected + render(); + + // Then the panel shows Not Found, since it only holds a connected integration's settings + expect(screen.getByText(NOT_FOUND)).toBeOnTheScreen(); + }); + + it('should open the panel for a beta user with an integration connected', () => { + // Given a beta user on a workspace connected to QuickBooks Online + mockConnectedIntegration = CONST.POLICY.CONNECTIONS.NAME.QBO; + + // When the panel opens + render(); + + // Then its settings render + expect(screen.queryByText(NOT_FOUND)).not.toBeOnTheScreen(); + }); + }); + + describe('ConnectionsMergePageBase', () => { + it('should block the panel when no provider of the category is connected', () => { + // Given a beta user on a workspace with no HR provider connected + render( + , + ); + + // Then the panel shows Not Found + expect(screen.getByText(NOT_FOUND)).toBeOnTheScreen(); + }); + + it('should open the panel once a provider is connected', () => { + // Given a beta user on a workspace with Gusto connected + render( + , + ); + + // Then its settings render + expect(screen.queryByText(NOT_FOUND)).not.toBeOnTheScreen(); + }); + }); + + describe('ConnectionsReceiptPartnersPage', () => { + it('should block the panel when Uber is not connected', () => { + // Given a beta user on a workspace without Uber + render(); + + // Then the panel shows Not Found + expect(screen.getByText(NOT_FOUND)).toBeOnTheScreen(); + }); + + it('should block the panel for a user without the beta', () => { + // Given Uber is connected but the user doesn't have the beta + mockIsUnifiedConnectionsBetaEnabled = false; + mockIsUberConnected = true; + render(); + + // Then the panel shows Not Found, since this user still has the Receipt partners page + expect(screen.getByText(NOT_FOUND)).toBeOnTheScreen(); + }); + }); +}); diff --git a/tests/unit/PolicyAccountingPageQBOTokenExpiryTest.tsx b/tests/unit/PolicyAccountingPageQBOTokenExpiryTest.tsx index 94038a342332..77b52432932d 100644 --- a/tests/unit/PolicyAccountingPageQBOTokenExpiryTest.tsx +++ b/tests/unit/PolicyAccountingPageQBOTokenExpiryTest.tsx @@ -9,6 +9,7 @@ import type TextComponent from '@components/Text'; import DateUtils from '@libs/DateUtils'; import PolicyAccountingPage from '@pages/workspace/accounting/PolicyAccountingPage'; +import ConnectionsAccountingPage from '@pages/workspace/connections/ConnectionsAccountingPage'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -85,7 +86,7 @@ jest.mock('@components/Section', () => ({__esModule: true, default: ({children}: jest.mock('@components/CollapsibleSection', () => ({__esModule: true, default: () => null})); jest.mock('@components/ActivityIndicator', () => ({__esModule: true, default: () => null})); -// The real menu only renders its entries inside a popover once pressed. Rendering their labels inline lets the tests +// The real menus only render their entries inside a popover once pressed. Rendering their labels inline lets the tests // read which credentials entry the page offers without driving the popover. jest.mock('@components/ThreeDotsMenu', () => { const {default: MockText} = jest.requireActual<{default: typeof TextComponent}>('@components/Text'); @@ -94,6 +95,13 @@ jest.mock('@components/ThreeDotsMenu', () => { default: ({menuItems}: {menuItems: PopoverMenuItem[]}) => menuItems.map((menuItem) => {`${OVERFLOW_MENU_ITEM_PREFIX}${menuItem.text}`}), }; }); +jest.mock('@components/Header/primitives/HeaderThreeDotsMenu', () => { + const {default: MockText} = jest.requireActual<{default: typeof TextComponent}>('@components/Text'); + return { + __esModule: true, + default: ({items}: {items: PopoverMenuItem[]}) => items.map((menuItem) => {`${OVERFLOW_MENU_ITEM_PREFIX}${menuItem.text}`}), + }; +}); jest.mock('@hooks/usePolicyFeatureWriteAccess', () => ({ __esModule: true, @@ -102,8 +110,13 @@ jest.mock('@hooks/usePolicyFeatureWriteAccess', () => ({ // The real `withPolicyConnections` HOC reads `policy` from Onyx and strips it from the component's public props. It is // mocked to an identity wrapper above, so the component under test takes `policy` directly. -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only: the HOC that would inject `policy` is mocked out, so it is passed as a prop here -const PolicyAccountingPageUnderTest = PolicyAccountingPage as unknown as React.ComponentType<{policy: Policy}>; +// The Accounting page and the Connections accounting panel share the connected integration's logic, so both are covered. +const PAGES_UNDER_TEST = [ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only: the HOC that would inject `policy` is mocked out, so it is passed as a prop here + {pageName: 'PolicyAccountingPage', PageUnderTest: PolicyAccountingPage as unknown as React.ComponentType<{policy: Policy}>}, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only: the HOC that would inject `policy` is mocked out, so it is passed as a prop here + {pageName: 'ConnectionsAccountingPage', PageUnderTest: ConnectionsAccountingPage as unknown as React.ComponentType<{policy: Policy}>}, +]; /** * A healthy QBO connection (synced, no error) whose refresh token expires at the given time. `data` is populated so the @@ -138,16 +151,16 @@ function formatExpiryDate(expiryDate: Date): string { return DateUtils.formatWithUTCTimeZone(expiryDate.toISOString(), CONST.DATE.MONTH_DAY_YEAR_FORMAT, undefined); } -async function renderPage(policy: Policy) { +async function renderPage(PageUnderTest: React.ComponentType<{policy: Policy}>, policy: Policy) { render( - + , ); await waitForBatchedUpdates(); } -describe('PolicyAccountingPage QBO refresh token expiry warning', () => { +describe.each(PAGES_UNDER_TEST)('$pageName QBO refresh token expiry warning', ({PageUnderTest}) => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); IntlStore.load(CONST.LOCALES.EN); @@ -162,7 +175,7 @@ describe('PolicyAccountingPage QBO refresh token expiry warning', () => { it('should warn with the expiry date and offer to reconnect when the token expires within the warning window', async () => { // Given a healthy QBO connection whose refresh token expires in a few days const expiryDate = addDays(new Date(), 3); - await renderPage(buildQBOPolicy(expiryDate)); + await renderPage(PageUnderTest, buildQBOPolicy(expiryDate)); // Then the connection row explains when the connection will expire expect(screen.getByText(`Your QuickBooks Online connection expires on ${formatExpiryDate(expiryDate)}.`, {exact: false})).toBeOnTheScreen(); @@ -182,7 +195,7 @@ describe('PolicyAccountingPage QBO refresh token expiry warning', () => { it('should say the connection expired when the token is past its expiry but no sync has failed yet', async () => { // Given a QBO connection whose refresh token lapsed yesterday while the last sync still reads as successful const expiryDate = subDays(new Date(), 1); - await renderPage(buildQBOPolicy(expiryDate)); + await renderPage(PageUnderTest, buildQBOPolicy(expiryDate)); // Then the row says the connection already expired and still offers to reconnect expect(screen.getByText(`Your QuickBooks Online connection expired on ${formatExpiryDate(expiryDate)}.`, {exact: false})).toBeOnTheScreen(); @@ -191,7 +204,7 @@ describe('PolicyAccountingPage QBO refresh token expiry warning', () => { it('should not warn while the token is still far from expiring', async () => { // Given a healthy QBO connection whose refresh token is valid well past the warning window - await renderPage(buildQBOPolicy(addDays(new Date(), CONST.POLICY.CONNECTIONS.QBO_REFRESH_TOKEN_EXPIRY_WARNING_DAYS + 30))); + await renderPage(PageUnderTest, buildQBOPolicy(addDays(new Date(), CONST.POLICY.CONNECTIONS.QBO_REFRESH_TOKEN_EXPIRY_WARNING_DAYS + 30))); // Then no expiry warning is shown and the overflow menu offers no credentials entry, as a healthy QBO connection // has nothing to re-enter @@ -203,7 +216,7 @@ describe('PolicyAccountingPage QBO refresh token expiry warning', () => { it('should leave the authentication error in charge once a sync has already failed on the expired token', async () => { // Given a QBO connection whose token lapsed and whose last sync already failed to authenticate - await renderPage(buildQBOPolicy(subDays(new Date(), 1), true)); + await renderPage(PageUnderTest, buildQBOPolicy(subDays(new Date(), 1), true)); // Then the standard authentication error is shown instead of a second, redundant expiry warning expect(screen.queryByText('Your QuickBooks Online connection', {exact: false})).not.toBeOnTheScreen(); @@ -213,7 +226,7 @@ describe('PolicyAccountingPage QBO refresh token expiry warning', () => { it('should not warn a member who can only read the accounting settings', async () => { // Given a QBO connection whose refresh token expires in a few days, viewed by a member without write access to accounting mockCanWriteAccounting = false; - await renderPage(buildQBOPolicy(addDays(new Date(), 3))); + await renderPage(PageUnderTest, buildQBOPolicy(addDays(new Date(), 3))); // Then no expiry warning or Reconnect link is shown, since reconnecting is a write action this member cannot take expect(screen.queryByText('Your QuickBooks Online connection', {exact: false})).not.toBeOnTheScreen(); diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index 8de59663665f..27f1b64ea78c 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -204,6 +204,7 @@ const workspaceIcons = { Hashtag: mockIcon, Document: mockIcon, Sync: mockIcon, + Connect: mockIcon, Receipt: mockIcon, Briefcase: mockIcon, Folder: mockIcon, @@ -704,7 +705,7 @@ describe('Workspace Search Router navigation source', () => { jest.clearAllMocks(); }); - const buildItems = (policies: Policy[], isOffline = false) => + const buildItems = (policies: Policy[], isOffline = false, isUnifiedConnectionsBetaEnabled = false) => buildWorkspaceNavigationItems({ policies: Object.fromEntries(policies.map((policy) => [`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy])), policyCategories: undefined, @@ -713,6 +714,7 @@ describe('Workspace Search Router navigation source', () => { isOffline, isVendorMatchingBetaEnabled: false, isRecruitingBetaEnabled: false, + isUnifiedConnectionsBetaEnabled, shouldUseNarrowLayout: false, convertToDisplayString: () => '$0.00', getItemText: (item) => { @@ -721,6 +723,7 @@ describe('Workspace Search Router navigation source', () => { ['workspace.common.members', 'Members'], ['workspace.common.rooms', 'Rooms'], ['workspace.common.workflows', 'Workflows'], + ['workspace.common.connections', 'Connections'], ['workspace.common.hr', 'HR'], ]); return labels.get(item.translationKey) ?? item.translationKey; @@ -757,6 +760,17 @@ describe('Workspace Search Router navigation source', () => { ]); }); + it('matches the Connections row by the name of a page it replaced when the unified Connections beta is on', () => { + // Given a workspace with the HR feature on, viewed with the beta on so HR has no row of its own + const items = buildItems([createWorkspacePolicy('1', 'Alpha Workspace', {isHREnabled: true})], false, true); + + // When searching for the old HR page by its short name + const suggestions = buildNavigationSuggestions('hr', [items], localeCompare); + + // Then the Connections row is suggested, because HR moved onto the Connections page + expect(suggestions.map((item) => item.keyForList)).toEqual([`workspace_1_${SCREENS.WORKSPACE.CONNECTIONS}`]); + }); + it('includes workspace identity and navigates through the Workspace synchronization helper', () => { const policy = createWorkspacePolicy('1', 'Alpha Workspace'); const overviewItem = buildItems([policy]).find((item) => item.keyForList === `workspace_1_${SCREENS.WORKSPACE.PROFILE}`); diff --git a/tests/unit/WorkspaceConnectionsPageAutoStartFlowTest.tsx b/tests/unit/WorkspaceConnectionsPageAutoStartFlowTest.tsx new file mode 100644 index 000000000000..c4dec08dde9f --- /dev/null +++ b/tests/unit/WorkspaceConnectionsPageAutoStartFlowTest.tsx @@ -0,0 +1,186 @@ +import {render} from '@testing-library/react-native'; + +import Navigation from '@libs/Navigation/Navigation'; + +import WorkspaceConnectionsPage from '@pages/workspace/connections/WorkspaceConnectionsPage'; + +import CONST from '@src/CONST'; +import type {ConnectionName} from '@src/types/onyx/Policy'; +import type Policy from '@src/types/onyx/Policy'; + +import type * as ReactNavigation from '@react-navigation/native'; + +import React from 'react'; + +import createRandomPolicy from '../utils/collections/policies'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const POLICY_ID = 'policy-1'; + +type RouteParams = { + newConnectionName?: ConnectionName; + integrationToDisconnect?: ConnectionName; + shouldDisconnectIntegrationBeforeConnecting?: boolean; + isIntuitEnterpriseSuite?: string; +}; + +let mockRouteParams: RouteParams = {}; + +const mockStartIntegrationFlow = jest.fn(); + +// The real `useFocusEffect` re-runs its callback whenever the callback's identity changes while the screen is focused. +// `useEffect(cb, [cb])` reproduces exactly that, which is the behaviour the guard under test has to survive. +jest.mock('@react-navigation/native', () => { + const actualNavigation = jest.requireActual('@react-navigation/native'); + const {useEffect: mockUseEffect} = jest.requireActual('react'); + return { + ...actualNavigation, + useFocusEffect: (callback: () => void) => { + mockUseEffect(callback, [callback]); + }, + useRoute: () => ({params: mockRouteParams}), + useNavigation: () => ({}), + useIsFocused: () => true, + }; +}); + +// `Navigation.setParams` clears `newConnectionName` through react-navigation's state update, which lands in a later +// render than the synchronous call. The mock therefore only records the requested params; each test applies them when +// it wants that state update to land, which is what lets the two orderings below be told apart. +let mockPendingParams: RouteParams | undefined; +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + setParams: jest.fn((params: RouteParams) => { + mockPendingParams = params; + }), + navigate: jest.fn(), + goBack: jest.fn(), + isNavigationReady: () => Promise.resolve(), + }, +})); + +/** Lands the pending `Navigation.setParams` update, as a real navigation state change eventually would. */ +function landPendingParamsUpdate() { + mockRouteParams = {...mockRouteParams, ...mockPendingParams}; + mockPendingParams = undefined; +} + +jest.mock('@pages/workspace/accounting/AccountingContext', () => ({ + __esModule: true, + AccountingContextProvider: ({children}: {children: React.ReactNode}) => children, + useAccountingActions: () => ({startIntegrationFlow: mockStartIntegrationFlow}), + useAccountingState: () => ({activeIntegration: undefined, popoverAnchorRefs: {current: {}}}), +})); + +jest.mock('@pages/workspace/withPolicyConnections', () => ({ + __esModule: true, + default: (Component: React.ComponentType<{policy: Policy}>) => Component, +})); + +jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({ + __esModule: true, + default: ({children}: {children: React.ReactNode}) => children, +})); + +jest.mock('@components/ScreenWrapper', () => ({ + __esModule: true, + default: ({children}: {children: React.ReactNode}) => children, +})); + +jest.mock('@components/ScrollView', () => ({ + __esModule: true, + default: ({children}: {children: React.ReactNode}) => children, +})); + +jest.mock('@components/SidePanel/SidePanelButton', () => ({__esModule: true, default: () => null})); +jest.mock('@components/TabSelector/TabSelectorBase', () => ({__esModule: true, default: () => null})); +jest.mock('@components/RenderHTML', () => ({__esModule: true, default: () => null})); +jest.mock('@pages/workspace/connections/ConnectionsGrid', () => ({__esModule: true, default: () => null})); +jest.mock('@pages/workspace/connections/useAccountingConnectionListings', () => ({__esModule: true, default: () => []})); +jest.mock('@pages/workspace/connections/useMergeConnectionListings', () => ({__esModule: true, default: () => []})); +jest.mock('@pages/workspace/connections/useReceiptPartnerConnectionListings', () => ({__esModule: true, default: () => []})); +jest.mock('@pages/workspace/connections/useMCPConnectionListings', () => ({__esModule: true, default: () => []})); +jest.mock('@libs/actions/PolicyConnections', () => ({openPolicyHRPage: jest.fn(), openPolicyRecruitingPage: jest.fn()})); +jest.mock('@userActions/Policy/Policy', () => ({openPolicyReceiptPartnersPage: jest.fn()})); + +jest.mock('@libs/PolicyUtils', () => { + const actual = jest.requireActual>('@libs/PolicyUtils'); + return { + ...actual, + isControlPolicy: () => true, + }; +}); + +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => ({ + __esModule: true, + default: () => ({canWrite: true, showReadOnlyModal: () => {}}), +})); + +// The real `withPolicyConnections` HOC reads `policy` from Onyx and strips it from the component's public props. It is +// mocked to an identity wrapper above, so the component under test takes `policy` directly. +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only: the HOC that would inject `policy` is mocked out, so it is passed as a prop here +const WorkspaceConnectionsPageUnderTest = WorkspaceConnectionsPage as unknown as React.ComponentType<{policy: Policy}>; + +function buildPolicy(overrides: Partial = {}): Policy { + return {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE, 'Test workspace'), id: POLICY_ID, ...overrides}; +} + +describe('WorkspaceConnectionsPage auto-started connect flow', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPendingParams = undefined; + mockRouteParams = { + newConnectionName: CONST.POLICY.CONNECTIONS.NAME.QBO, + integrationToDisconnect: CONST.POLICY.CONNECTIONS.NAME.XERO, + shouldDisconnectIntegrationBeforeConnecting: true, + }; + }); + + it('should start the flow once when the effect re-runs before the cleared param has landed', async () => { + // Given the page opened by a route that asks for a connect flow, which starts it and asks for the param to be + // cleared so it cannot be acted on twice + const {rerender} = render(); + await waitForBatchedUpdates(); + + expect(mockStartIntegrationFlow).toHaveBeenCalledTimes(1); + expect(Navigation.setParams).toHaveBeenCalled(); + + // When anything re-creates `startIntegrationFlow` and re-runs the effect before that clear has landed, which a + // policy update does. This is the window the guard exists for: `newConnectionName` is still set here. + rerender(); + await waitForBatchedUpdates(); + + // Then the flow is not started again, because a second start is what used to stack a second confirmation + // prompt the user had to dismiss twice + expect(mockStartIntegrationFlow).toHaveBeenCalledTimes(1); + }); + + it('should start the flow again for a later round-trip that asks for the same integration', async () => { + // Given a connect flow that was started from the route param and then let the clear land, so nothing is + // pending any more + const {rerender} = render(); + await waitForBatchedUpdates(); + + expect(mockStartIntegrationFlow).toHaveBeenCalledTimes(1); + + landPendingParamsUpdate(); + rerender(); + await waitForBatchedUpdates(); + + expect(mockStartIntegrationFlow).toHaveBeenCalledTimes(1); + + // When the user comes back later and asks for the very same integration again + mockRouteParams = { + newConnectionName: CONST.POLICY.CONNECTIONS.NAME.QBO, + integrationToDisconnect: CONST.POLICY.CONNECTIONS.NAME.XERO, + shouldDisconnectIntegrationBeforeConnecting: true, + }; + rerender(); + await waitForBatchedUpdates(); + + // Then it is honoured rather than swallowed as a repeat of the first run, or the connect flow would silently + // do nothing the second time round + expect(mockStartIntegrationFlow).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/unit/getWorkspaceMenuItemsTest.ts b/tests/unit/getWorkspaceMenuItemsTest.ts index a4b061e9d80e..209529ae1562 100644 --- a/tests/unit/getWorkspaceMenuItemsTest.ts +++ b/tests/unit/getWorkspaceMenuItemsTest.ts @@ -17,6 +17,7 @@ const icons = { Hashtag: mockIcon, Document: mockIcon, Sync: mockIcon, + Connect: mockIcon, Receipt: mockIcon, Briefcase: mockIcon, Folder: mockIcon, @@ -606,3 +607,92 @@ describe('getWorkspaceMenuItems', () => { expect(convertToDisplayString).toHaveBeenCalledWith(123, policy.outputCurrency); }); }); + +describe('getWorkspaceMenuItems with the unified Connections beta', () => { + it('replaces the separate integration rows with a single Connections row', () => { + // Given an admin on a workspace with every integration feature turned on + const policy = createMock({ + ...buildPolicy(CONST.POLICY.ROLE.ADMIN), + areConnectionsEnabled: true, + isHREnabled: true, + isRecruitingEnabled: true, + receiptPartners: {enabled: true}, + isMCPEnabled: true, + }); + + // When the Workspace menu is built with the beta on + const items = getWorkspaceMenuItems({ + policy, + policyID: policy.id, + currentUserLogin, + icons, + isRecruitingBetaEnabled: true, + isUnifiedConnectionsBetaEnabled: true, + convertToDisplayString: () => '', + }); + + // Then the integrations are reached from one Connections row, which also owns the old pages' screens so their links stay in the menu + const connectionsItem = items.find((item) => item.translationKey === 'workspace.common.connections'); + expect(connectionsItem?.getRoute()).toBe(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policy.id)); + expect(connectionsItem?.aliasScreenNames).toEqual([ + SCREENS.WORKSPACE.ACCOUNTING.ROOT, + SCREENS.WORKSPACE.HR, + SCREENS.WORKSPACE.RECRUITING, + SCREENS.WORKSPACE.RECEIPT_PARTNERS, + SCREENS.WORKSPACE.MCP, + ]); + const translationKeys = items.map((item) => item.translationKey); + for (const replacedKey of [ + 'workspace.common.accounting', + 'workspace.common.hr', + 'workspace.common.recruiting', + 'workspace.common.receiptPartners', + 'workspace.common.mcp', + ] as const) { + expect(translationKeys).not.toContain(replacedKey); + } + }); + + it('shows the Connections row even when no integration feature is enabled', () => { + // Given an admin on a workspace that has none of the integration features turned on + const policy = createMock({ + ...buildPolicy(CONST.POLICY.ROLE.ADMIN), + areConnectionsEnabled: false, + isHREnabled: false, + isRecruitingEnabled: false, + receiptPartners: {enabled: false}, + }); + + // When the Workspace menu is built with the beta on + const items = getWorkspaceMenuItems({ + policy, + policyID: policy.id, + currentUserLogin, + icons, + isUnifiedConnectionsBetaEnabled: true, + convertToDisplayString: () => '', + }); + + // Then the row is still there, because connecting an integration turns its feature on + expect(items.find((item) => item.translationKey === 'workspace.common.connections')?.screenName).toBe(SCREENS.WORKSPACE.CONNECTIONS); + }); + + it("surfaces each replaced row's indicator on the Connections row", () => { + // Given a workspace whose receipt partner credentials need attention + const policy = createMock({...buildPolicy(CONST.POLICY.ROLE.ADMIN), receiptPartners: {enabled: true}}); + + // When the Workspace menu is built with the beta on + const items = getWorkspaceMenuItems({ + policy, + policyID: policy.id, + currentUserLogin, + icons, + shouldShowEnterCredentialsError: true, + isUnifiedConnectionsBetaEnabled: true, + convertToDisplayString: () => '', + }); + + // Then the error shows on Connections, since the Receipt partners row it used to show on is gone + expect(items.find((item) => item.translationKey === 'workspace.common.connections')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); + }); +}); diff --git a/tests/unit/pages/workspace/accounting/qbd/subscribedSettingsTest.ts b/tests/unit/pages/workspace/accounting/qbd/subscribedSettingsTest.ts index 60bba39c2b72..7f02485d4e27 100644 --- a/tests/unit/pages/workspace/accounting/qbd/subscribedSettingsTest.ts +++ b/tests/unit/pages/workspace/accounting/qbd/subscribedSettingsTest.ts @@ -43,7 +43,7 @@ function getIntegrationData({ }, }); - return getAccountingIntegrationData(CONST.POLICY.CONNECTIONS.NAME.QBD, POLICY_ID, mockTranslate, EXISTING_CONNECTIONS, policy); + return getAccountingIntegrationData(CONST.POLICY.CONNECTIONS.NAME.QBD, POLICY_ID, mockTranslate, EXISTING_CONNECTIONS, {policy}); } describe('QBD subscribed settings', () => { diff --git a/tests/unit/pages/workspace/accounting/qbo/defaultVendorSubscriptionsTest.ts b/tests/unit/pages/workspace/accounting/qbo/defaultVendorSubscriptionsTest.ts index eb6770c1fe30..c6f78b9c7399 100644 --- a/tests/unit/pages/workspace/accounting/qbo/defaultVendorSubscriptionsTest.ts +++ b/tests/unit/pages/workspace/accounting/qbo/defaultVendorSubscriptionsTest.ts @@ -33,13 +33,9 @@ function buildPolicy(nonReimbursableExpensesExportDestination: NonReimbursableDe } function getIntegrationData(nonReimbursableExpensesExportDestination: NonReimbursableDestination, autoCreateVendor = false) { - return getAccountingIntegrationData( - CONST.POLICY.CONNECTIONS.NAME.QBO, - POLICY_ID, - mockTranslate, - EXISTING_CONNECTIONS, - buildPolicy(nonReimbursableExpensesExportDestination, autoCreateVendor), - ); + return getAccountingIntegrationData(CONST.POLICY.CONNECTIONS.NAME.QBO, POLICY_ID, mockTranslate, EXISTING_CONNECTIONS, { + policy: buildPolicy(nonReimbursableExpensesExportDestination, autoCreateVendor), + }); } describe('QBO default vendor export subscriptions', () => { diff --git a/tests/unit/useAccountingConnectionListingsTest.ts b/tests/unit/useAccountingConnectionListingsTest.ts new file mode 100644 index 000000000000..9e67e7a85e35 --- /dev/null +++ b/tests/unit/useAccountingConnectionListingsTest.ts @@ -0,0 +1,102 @@ +import {renderHook} from '@testing-library/react-native'; + +import useAccountingConnectionListings from '@pages/workspace/connections/useAccountingConnectionListings'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; +import type {ConnectionName} from '@src/types/onyx/Policy'; + +import createRandomPolicy from '../utils/collections/policies'; + +const mockStartIntegrationFlow = jest.fn(); +const mockShowReadOnlyModal = jest.fn(); +let mockCanWrite = true; +let mockConnectedIntegration: ConnectionName | undefined; + +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key, datetimeToRelative: () => '', getLocalDateFromDatetime: jest.fn()})); +jest.mock('@hooks/usePermissions', () => () => ({isBetaEnabled: () => false})); +jest.mock('@hooks/useIsUnifiedConnectionsBetaEnabled', () => () => true); +jest.mock('@hooks/useOnyx', () => () => [undefined]); +jest.mock('@hooks/useThemeStyles', () => () => ({})); +jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: () => ({})})); +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => () => ({canWrite: mockCanWrite, showReadOnlyModal: mockShowReadOnlyModal})); +jest.mock('@libs/actions/connections', () => ({isConnectionInProgress: () => false, isConnectionUnverified: () => false})); +jest.mock('@libs/actions/connections/QuickbooksOnline', () => ({shouldShowQBOReimbursableExportDestinationAccountError: () => false})); +jest.mock('@libs/Navigation/Navigation', () => ({__esModule: true, default: {navigate: jest.fn()}})); +jest.mock('@libs/PolicyUtils', () => ({getConnectedIntegration: () => mockConnectedIntegration, getIntegrationLastSuccessfulDate: () => undefined})); +jest.mock('@pages/workspace/accounting/AccountingContext', () => ({ + useAccountingActions: () => ({startIntegrationFlow: mockStartIntegrationFlow}), + useAccountingState: () => ({activeIntegration: undefined, popoverAnchorRefs: {current: {}}}), +})); +jest.mock('@pages/workspace/accounting/utils', () => ({ + getAccountingIntegrationData: (name: string) => ({title: name}), + getSynchronizationErrorMessage: () => undefined, + isIntuitEnterpriseSuiteConnection: () => false, +})); + +const policy: Policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), id: 'policy-1'}; + +function findListing(key: string) { + const {result} = renderHook(() => useAccountingConnectionListings(policy)); + return result.current.find((listing) => listing.key === key); +} + +describe('useAccountingConnectionListings', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCanWrite = true; + mockConnectedIntegration = undefined; + }); + + it('should start the connect flow for a workspace with no accounting integration', () => { + // Given a workspace with nothing connected + const xero = findListing(CONST.POLICY.CONNECTIONS.NAME.XERO); + + // When the admin connects Xero + xero?.onConnect(); + + // Then Xero's flow starts with nothing to disconnect + expect(mockStartIntegrationFlow).toHaveBeenCalledWith({name: CONST.POLICY.CONNECTIONS.NAME.XERO, isIntuitEnterpriseSuite: undefined}); + }); + + it('should replace the connected integration when the admin connects another one', () => { + // Given a workspace already connected to QuickBooks Online, since only one accounting integration can be connected + mockConnectedIntegration = CONST.POLICY.CONNECTIONS.NAME.QBO; + const xero = findListing(CONST.POLICY.CONNECTIONS.NAME.XERO); + + // When the admin connects Xero + xero?.onConnect(); + + // Then the flow is told to disconnect QuickBooks Online first, which asks the admin before replacing it + expect(mockStartIntegrationFlow).toHaveBeenCalledWith({ + name: CONST.POLICY.CONNECTIONS.NAME.XERO, + isIntuitEnterpriseSuite: undefined, + integrationToDisconnect: CONST.POLICY.CONNECTIONS.NAME.QBO, + shouldDisconnectIntegrationBeforeConnecting: true, + }); + }); + + it('should only show the read-only modal to a member who cannot edit accounting', () => { + // Given a member without write access to accounting + mockCanWrite = false; + const xero = findListing(CONST.POLICY.CONNECTIONS.NAME.XERO); + + // When they try to connect Xero + xero?.onConnect(); + + // Then they are told they can't, and no flow starts + expect(mockShowReadOnlyModal).toHaveBeenCalledTimes(1); + expect(mockStartIntegrationFlow).not.toHaveBeenCalled(); + }); + + it('should show the connected integration with its status', () => { + // Given a workspace connected to QuickBooks Online + mockConnectedIntegration = CONST.POLICY.CONNECTIONS.NAME.QBO; + + // When the listings are built + const qbo = findListing(CONST.POLICY.CONNECTIONS.NAME.QBO); + + // Then QuickBooks Online carries a healthy status, so it shows in the connected section with Configure + expect(qbo?.status?.isBroken).toBe(false); + }); +}); diff --git a/tests/unit/useMCPConnectionListingsTest.ts b/tests/unit/useMCPConnectionListingsTest.ts new file mode 100644 index 000000000000..29c2d0e01c55 --- /dev/null +++ b/tests/unit/useMCPConnectionListingsTest.ts @@ -0,0 +1,69 @@ +import {renderHook} from '@testing-library/react-native'; + +import useMCPConnectionListings from '@pages/workspace/connections/useMCPConnectionListings'; + +import {openExternalLink} from '@userActions/Link'; +import {enablePolicyFeatureForConnection} from '@userActions/Policy/Policy'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; + +import createRandomPolicy from '../utils/collections/policies'; + +const mockShowReadOnlyModal = jest.fn(); +let mockCanWrite = true; + +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); +jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: () => ({})})); +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => () => ({canWrite: mockCanWrite, showReadOnlyModal: mockShowReadOnlyModal})); +jest.mock('@userActions/Link', () => ({openExternalLink: jest.fn()})); +jest.mock('@userActions/Policy/Policy', () => ({enablePolicyFeatureForConnection: jest.fn()})); + +const policy: Policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), id: 'policy-1'}; + +describe('useMCPConnectionListings', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCanWrite = true; + }); + + it('should turn MCP on and open the assistant when an admin connects one', () => { + // Given an admin who can edit the workspace + const {result} = renderHook(() => useMCPConnectionListings(policy)); + const claude = result.current.find((listing) => listing.key === 'claude'); + + // When they connect Claude + claude?.onConnect(); + + // Then MCP is turned on and Claude's connect page opens, since Connections has no MCP toggle + expect(enablePolicyFeatureForConnection).toHaveBeenCalledWith(policy, CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED); + expect(openExternalLink).toHaveBeenCalledWith(CONST.CLAUDE_CONNECT_URL); + }); + + it('should open the MCP guide from the MCP card without turning anything on', () => { + // Given the AI tab, which lists a general MCP card for clients without their own card + const {result} = renderHook(() => useMCPConnectionListings(policy)); + const mcpGuide = result.current.find((listing) => listing.key === 'mcp'); + + // When the admin presses "+" on it + mcpGuide?.onConnect(); + + // Then the guide opens, since the client is set up by following it rather than by connecting from here + expect(openExternalLink).toHaveBeenCalledWith(CONST.MCP_HELP_URL); + expect(enablePolicyFeatureForConnection).not.toHaveBeenCalled(); + }); + + it('should only show the read-only modal to a member who cannot edit the workspace', () => { + // Given a member without write access to the workspace features + mockCanWrite = false; + const {result} = renderHook(() => useMCPConnectionListings(policy)); + + // When they try to connect an assistant + result.current.at(0)?.onConnect(); + + // Then they are told they can't, and nothing is turned on or opened + expect(mockShowReadOnlyModal).toHaveBeenCalledTimes(1); + expect(enablePolicyFeatureForConnection).not.toHaveBeenCalled(); + expect(openExternalLink).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/useMergeConnectionListingsTest.ts b/tests/unit/useMergeConnectionListingsTest.ts new file mode 100644 index 000000000000..653a95419651 --- /dev/null +++ b/tests/unit/useMergeConnectionListingsTest.ts @@ -0,0 +1,114 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import {ModalActions} from '@components/Modal/Global/ModalContext'; + +import {removePolicyConnection} from '@libs/actions/connections'; +import Navigation from '@libs/Navigation/Navigation'; + +import useMergeConnectionListings from '@pages/workspace/connections/useMergeConnectionListings'; +import type {MergeProviderCardDescriptor} from '@pages/workspace/merge/types'; + +import {enablePolicyFeatureForConnection} from '@userActions/Policy/Policy'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; + +import createRandomPolicy from '../utils/collections/policies'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const mockShowConfirmModal = jest.fn(); +let mockIsControlPolicy = true; +let mockHRCards: Array> = []; + +jest.mock('@hooks/useLocalize', () => () => ({ + translate: (key: string) => key, + getLocalDateFromDatetime: jest.fn(), + datetimeToRelative: () => '', + formatPhoneNumber: (phone: string) => phone, +})); +jest.mock('@hooks/usePermissions', () => () => ({isBetaEnabled: () => false})); +jest.mock('@hooks/useConfirmModal', () => () => ({showConfirmModal: mockShowConfirmModal})); +jest.mock('@hooks/useOnyx', () => () => [undefined]); +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => () => ({canWrite: true, showReadOnlyModal: jest.fn()})); +jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: () => ({})})); +jest.mock('@libs/actions/connections', () => ({removePolicyConnection: jest.fn()})); +jest.mock('@libs/Navigation/Navigation', () => ({__esModule: true, default: {navigate: jest.fn()}})); +jest.mock('@libs/PolicyUtils', () => ({isControlPolicy: () => mockIsControlPolicy})); +jest.mock('@pages/workspace/hr/utils', () => ({getHRCards: () => mockHRCards})); +jest.mock('@pages/workspace/recruiting/utils', () => ({getRecruitingCards: () => []})); +jest.mock('@userActions/Policy/Policy', () => ({enablePolicyFeatureForConnection: jest.fn()})); + +const policy: Policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), id: 'policy-1'}; + +function buildCard(connectionName: MergeProviderCardDescriptor['connectionName'], isConnected: boolean): Partial { + return {connectionName, displayName: connectionName, category: CONST.POLICY.CONNECTIONS.CATEGORY.HR, isConnected, setupLink: `https://merge.dev/${connectionName}`}; +} + +function renderListings() { + const onStartSetup = jest.fn(); + const {result} = renderHook(() => useMergeConnectionListings(policy, onStartSetup)); + return {listings: result.current, onStartSetup}; +} + +describe('useMergeConnectionListings', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsControlPolicy = true; + mockHRCards = [buildCard(CONST.POLICY.CONNECTIONS.NAME.GUSTO, false), buildCard(CONST.POLICY.CONNECTIONS.NAME.ZENEFITS, false)]; + }); + + it('should send a workspace that is not on Control to the upgrade page', () => { + // Given a workspace that is not on the Control plan, which HR requires + mockIsControlPolicy = false; + const {listings, onStartSetup} = renderListings(); + + // When the admin connects an HR provider + listings.at(0)?.onConnect(); + + // Then the upgrade page opens instead of the provider's setup + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(onStartSetup).not.toHaveBeenCalled(); + }); + + it('should turn HR on and start the setup when no provider is connected yet', () => { + // Given a Control workspace with no HR provider connected + const {listings, onStartSetup} = renderListings(); + + // When the admin connects Gusto + listings.find((listing) => listing.title === CONST.POLICY.CONNECTIONS.NAME.GUSTO)?.onConnect(); + + // Then HR is turned on and Gusto's setup starts, with nothing to replace + expect(enablePolicyFeatureForConnection).toHaveBeenCalledWith(policy, CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED); + expect(onStartSetup).toHaveBeenCalledWith(`https://merge.dev/${CONST.POLICY.CONNECTIONS.NAME.GUSTO}`, CONST.POLICY.CONNECTIONS.CATEGORY.HR); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + }); + + it('should ask before replacing the connected provider, and leave it alone when the admin cancels', async () => { + // Given a workspace with Gusto connected, since only one HR provider can be connected at a time + mockHRCards = [buildCard(CONST.POLICY.CONNECTIONS.NAME.GUSTO, true), buildCard(CONST.POLICY.CONNECTIONS.NAME.ZENEFITS, false)]; + mockShowConfirmModal.mockResolvedValue({action: ModalActions.CLOSE}); + const {listings, onStartSetup} = renderListings(); + + // When the admin connects another provider and cancels the Replace prompt + await act(async () => { + listings.find((listing) => listing.title === CONST.POLICY.CONNECTIONS.NAME.ZENEFITS)?.onConnect(); + await waitForBatchedUpdates(); + }); + + // Then Gusto stays connected and no setup starts + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + expect(removePolicyConnection).not.toHaveBeenCalled(); + expect(onStartSetup).not.toHaveBeenCalled(); + }); + + it('should mark a provider that needs reconnecting as broken', () => { + // Given a connected provider whose authentication expired + mockHRCards = [{...buildCard(CONST.POLICY.CONNECTIONS.NAME.GUSTO, true), needsReconnect: true}]; + + // When the listings are built + const {listings} = renderListings(); + + // Then the card reads as broken, so the admin sees a Fix button + expect(listings.at(0)?.status?.isBroken).toBe(true); + }); +}); diff --git a/tests/unit/useReceiptPartnerConnectionListingsTest.ts b/tests/unit/useReceiptPartnerConnectionListingsTest.ts new file mode 100644 index 000000000000..b8db5cebbc8e --- /dev/null +++ b/tests/unit/useReceiptPartnerConnectionListingsTest.ts @@ -0,0 +1,87 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import Navigation from '@libs/Navigation/Navigation'; + +import useReceiptPartnerConnectionListings from '@pages/workspace/connections/useReceiptPartnerConnectionListings'; + +import {openExternalLink} from '@userActions/Link'; +import {enablePolicyFeatureForConnection} from '@userActions/Policy/Policy'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; + +import createRandomPolicy from '../utils/collections/policies'; + +const mockUberData = {title: 'Uber for Business', description: 'Connected', icon: undefined, errorFields: undefined as Record | undefined}; +let mockUberState = {isUberConnected: false, shouldShowEnterCredentialsError: false, errorFields: undefined as Record | undefined}; + +jest.mock('@hooks/useGetReceiptPartnersIntegrationData', () => ({ + __esModule: true, + default: () => ({ + getReceiptPartnersIntegrationData: () => ({...mockUberData, errorFields: mockUberState.errorFields}), + shouldShowEnterCredentialsError: mockUberState.shouldShowEnterCredentialsError, + isUberConnected: mockUberState.isUberConnected, + }), +})); +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); +jest.mock('@hooks/useNetwork', () => () => ({isOffline: false})); +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => () => ({canWrite: true, showReadOnlyModal: jest.fn()})); +jest.mock('@libs/Navigation/Navigation', () => ({__esModule: true, default: {navigate: jest.fn()}})); +jest.mock('@userActions/Link', () => ({openExternalLink: jest.fn()})); +jest.mock('@userActions/Policy/Policy', () => ({enablePolicyFeatureForConnection: jest.fn()})); + +const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + id: 'policy-1', + receiptPartners: {enabled: false, uber: {connectFormData: 'form=data'}}, +}; + +describe('useReceiptPartnerConnectionListings', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUberState = {isUberConnected: false, shouldShowEnterCredentialsError: false, errorFields: undefined}; + }); + + it('should mark a connected Uber as broken when its last update failed', () => { + // Given a connected Uber whose disconnect failed, which leaves errorFields on the connection + mockUberState = {isUberConnected: true, shouldShowEnterCredentialsError: false, errorFields: {connection: {error: 'Failed'}}}; + + // When the listing is built + const {result} = renderHook(() => useReceiptPartnerConnectionListings(policy)); + + // Then the card reads as broken, so the admin sees the failure instead of a healthy status + expect(result.current.at(0)?.status?.isBroken).toBe(true); + }); + + it('should turn Receipt partners on and open the Uber connect page when "+" is pressed', () => { + // Given a workspace with Uber not connected + const {result} = renderHook(() => useReceiptPartnerConnectionListings(policy)); + + // When the admin presses "+" on Uber + act(() => result.current.at(0)?.onConnect()); + + // Then the feature is turned on and the Uber connect page opens, since Connections has no toggle for the feature + expect(enablePolicyFeatureForConnection).toHaveBeenCalledWith(policy, CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED); + expect(openExternalLink).toHaveBeenCalledWith(`${CONST.UBER_CONNECT_URL}?form=data`); + }); + + it('should only open the invite flow for a connection started from the page', () => { + // Given Uber's connection only shows up once its data loads, without the admin starting it here + const {rerender, result} = renderHook(() => useReceiptPartnerConnectionListings(policy)); + mockUberState = {...mockUberState, isUberConnected: true}; + rerender({}); + + // Then the invite flow stays closed, since the admin didn't just connect + expect(Navigation.navigate).not.toHaveBeenCalled(); + + // When the admin then connects Uber from the page and the connection shows up + mockUberState = {...mockUberState, isUberConnected: false}; + rerender({}); + act(() => result.current.at(0)?.onConnect()); + mockUberState = {...mockUberState, isUberConnected: true}; + rerender({}); + + // Then the invite flow opens once, so the admin can invite employees to the new connection + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/withUnifiedConnectionsBetaTest.tsx b/tests/unit/withUnifiedConnectionsBetaTest.tsx new file mode 100644 index 000000000000..55082759b98a --- /dev/null +++ b/tests/unit/withUnifiedConnectionsBetaTest.tsx @@ -0,0 +1,53 @@ +import {render, screen} from '@testing-library/react-native'; + +import Text from '@components/Text'; + +import withUnifiedConnectionsBeta from '@pages/workspace/connections/withUnifiedConnectionsBeta'; + +import React from 'react'; + +let mockIsUnifiedConnectionsBetaEnabled = false; +jest.mock('@hooks/useIsUnifiedConnectionsBetaEnabled', () => ({ + __esModule: true, + default: () => mockIsUnifiedConnectionsBetaEnabled, +})); + +jest.mock('@pages/workspace/connections/WorkspaceConnectionsPage', () => { + const {default: MockText} = jest.requireActual<{default: typeof Text}>('@components/Text'); + return { + __esModule: true, + default: () => Connections page, + }; +}); + +function OldIntegrationPage({label}: {label: string}) { + return {label}; +} + +const OldIntegrationPageWithBeta = withUnifiedConnectionsBeta(OldIntegrationPage); + +describe('withUnifiedConnectionsBeta', () => { + it('should render the wrapped page while the beta is off', () => { + // Given a user without the unified Connections beta + mockIsUnifiedConnectionsBetaEnabled = false; + + // When an old integration page renders + render(); + + // Then the old page shows as before, since only beta users moved to Connections + expect(screen.getByText('Accounting page')).toBeOnTheScreen(); + expect(screen.queryByText('Connections page')).not.toBeOnTheScreen(); + }); + + it('should render the Connections page in its place while the beta is on', () => { + // Given a user with the unified Connections beta + mockIsUnifiedConnectionsBetaEnabled = true; + + // When an old link opens an integration page + render(); + + // Then Connections shows instead, since the old page is no longer in the workspace menu for this user + expect(screen.getByText('Connections page')).toBeOnTheScreen(); + expect(screen.queryByText('Accounting page')).not.toBeOnTheScreen(); + }); +});