From 233a37a99097ae593fc69f2eed17ab1e9ee7b19b Mon Sep 17 00:00:00 2001 From: Jakub Kosmydel <104823336+kosmydel@users.noreply.github.com> Date: Thu, 11 Jul 2024 10:39:43 +0200 Subject: [PATCH 01/32] feat: display RBR for auto-sync errors --- src/components/Indicator.tsx | 1 + src/libs/PolicyUtils.ts | 12 +++++++++++- src/libs/WorkspacesSettingsUtils.ts | 3 ++- src/libs/actions/connections/index.ts | 15 +++++++++++---- src/pages/workspace/WorkspaceInitialPage.tsx | 4 ++-- .../workspace/accounting/PolicyAccountingPage.tsx | 7 ++++--- src/types/onyx/Policy.ts | 14 ++++++++++---- 7 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/components/Indicator.tsx b/src/components/Indicator.tsx index 0d269d1ca593..e377d030e93e 100644 --- a/src/components/Indicator.tsx +++ b/src/components/Indicator.tsx @@ -55,6 +55,7 @@ function Indicator({reimbursementAccount, policies, bankAccountList, fundList, u () => Object.values(cleanPolicies).some(PolicyUtils.hasPolicyError), () => Object.values(cleanPolicies).some(PolicyUtils.hasCustomUnitsError), () => Object.values(cleanPolicies).some(PolicyUtils.hasEmployeeListError), + () => Object.values(cleanPolicies).some(PolicyUtils.hasAutoSyncError), () => SubscriptionUtils.hasSubscriptionRedDotError(), () => Object.keys(reimbursementAccount?.errors ?? {}).length > 0, () => !!loginList && UserUtils.hasLoginListError(loginList), diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 140916349c53..6bcfd2e8a539 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -11,6 +11,7 @@ import INPUT_IDS from '@src/types/form/NetSuiteCustomFieldForm'; import type {OnyxInputOrEntry, Policy, PolicyCategories, PolicyEmployeeList, PolicyTagList, PolicyTags, TaxRate} from '@src/types/onyx'; import type { ConnectionLastSync, + ConnectionName, Connections, CustomUnit, NetSuiteAccount, @@ -23,6 +24,7 @@ import type { } from '@src/types/onyx/Policy'; import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {getSynchronizationErrorMessage} from './actions/connections'; import * as Localize from './Localize'; import Navigation from './Navigation/Navigation'; import * as NetworkStore from './Network/NetworkStore'; @@ -79,6 +81,13 @@ function hasPolicyCategoriesError(policyCategories: OnyxEntry) return Object.keys(policyCategories ?? {}).some((categoryName) => Object.keys(policyCategories?.[categoryName]?.errors ?? {}).length > 0); } +/** + * Checks if the policy has auto-sync error. + */ +function hasAutoSyncError(policy: OnyxEntry): boolean { + return (Object.keys(policy?.connections ?? {}) as ConnectionName[]).some((connection) => !!getSynchronizationErrorMessage(policy, connection, false)); +} + /** * Check if the policy has any error fields. */ @@ -139,7 +148,7 @@ function getUnitRateValue(toLocaleDigit: (arg: string) => string, customUnitRate * Get the brick road indicator status for a policy. The policy has an error status if there is a policy member error, a custom unit error or a field error. */ function getPolicyBrickRoadIndicatorStatus(policy: OnyxEntry): ValueOf | undefined { - if (hasEmployeeListError(policy) || hasCustomUnitsError(policy) || hasPolicyErrorFields(policy)) { + if (hasEmployeeListError(policy) || hasCustomUnitsError(policy) || hasPolicyErrorFields(policy) || hasAutoSyncError(policy)) { return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; } return undefined; @@ -757,6 +766,7 @@ export { getUnitRateValue, goBackFromInvalidPolicy, hasAccountingConnections, + hasAutoSyncError, hasCustomUnitsError, hasEmployeeListError, hasPolicyCategoriesError, diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts index d827a6cae000..56685a950aa4 100644 --- a/src/libs/WorkspacesSettingsUtils.ts +++ b/src/libs/WorkspacesSettingsUtils.ts @@ -9,7 +9,7 @@ import type {Unit} from '@src/types/onyx/Policy'; import * as CurrencyUtils from './CurrencyUtils'; import type {Phrase, PhraseParameters} from './Localize'; import * as OptionsListUtils from './OptionsListUtils'; -import {hasCustomUnitsError, hasEmployeeListError, hasPolicyError, hasTaxRateError} from './PolicyUtils'; +import {hasAutoSyncError, hasCustomUnitsError, hasEmployeeListError, hasPolicyError, hasTaxRateError} from './PolicyUtils'; import * as ReportConnection from './ReportConnection'; import * as ReportUtils from './ReportUtils'; @@ -79,6 +79,7 @@ function hasGlobalWorkspaceSettingsRBR(policies: OnyxCollection) { () => Object.values(cleanPolicies).some(hasCustomUnitsError), () => Object.values(cleanPolicies).some(hasTaxRateError), () => Object.values(cleanPolicies).some(hasEmployeeListError), + () => Object.values(cleanPolicies).some(hasAutoSyncError), () => Object.keys(reimbursementAccount?.errors ?? {}).length > 0, ]; diff --git a/src/libs/actions/connections/index.ts b/src/libs/actions/connections/index.ts index fd6440c3a92c..2b1ec0fdf4d2 100644 --- a/src/libs/actions/connections/index.ts +++ b/src/libs/actions/connections/index.ts @@ -4,6 +4,7 @@ import * as API from '@libs/API'; import type {RemovePolicyConnectionParams, UpdateManyPolicyConnectionConfigurationsParams, UpdatePolicyConnectionConfigParams} from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; +import * as Localize from '@libs/Localize'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ConnectionName, Connections, PolicyConnectionName} from '@src/types/onyx/Policy'; @@ -258,12 +259,18 @@ function updateManyPolicyConnectionConfigs, connectionName: PolicyConnectionName, isSyncInProgress: boolean): boolean { +function getSynchronizationErrorMessage(policy: OnyxEntry, connectionName: PolicyConnectionName, isSyncInProgress: boolean): string | undefined { // NetSuite does not use the conventional lastSync object, so we need to check for lastErrorSyncDate if (connectionName === CONST.POLICY.CONNECTIONS.NAME.NETSUITE) { - return !isSyncInProgress && !!policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.NETSUITE].lastErrorSyncDate; + if (!isSyncInProgress && !!policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.NETSUITE].lastErrorSyncDate) { + return Localize.translateLocal('workspace.accounting.syncError', connectionName); + } + return; + } + const connection = policy?.connections?.[connectionName]; + if (!isSyncInProgress && connection?.lastSync?.isSuccessful === false) { + return connection?.lastSync?.errorMessage; } - return !isSyncInProgress && policy?.connections?.[connectionName]?.lastSync?.isSuccessful === false; } -export {removePolicyConnection, updatePolicyConnectionConfig, updateManyPolicyConnectionConfigs, hasSynchronizationError, syncConnection}; +export {removePolicyConnection, updatePolicyConnectionConfig, updateManyPolicyConnectionConfigs, getSynchronizationErrorMessage, syncConnection}; diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index a3785ccde384..62040b6d385a 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -92,6 +92,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAcc const policy = policyDraft?.id ? policyDraft : policyProp; const [isCurrencyModalOpen, setIsCurrencyModalOpen] = useState(false); const hasPolicyCreationError = !!(policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && !isEmptyObject(policy.errors)); + const hasPolicyAccountingError = PolicyUtils.hasAutoSyncError(policy); const waitForNavigate = useWaitForNavigation(); const {singleExecution, isExecuting} = useSingleExecution(); const activeRoute = useNavigationState(getTopmostRouteName); @@ -289,8 +290,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAcc translationKey: 'workspace.common.accounting', icon: Expensicons.Sync, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.POLICY_ACCOUNTING.getRoute(policyID)))), - // brickRoadIndicator should be set when API will be ready - brickRoadIndicator: undefined, + brickRoadIndicator: hasPolicyAccountingError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, routeName: SCREENS.WORKSPACE.ACCOUNTING.ROOT, }); } diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index 400d54d0e005..1c922d4db78a 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -29,7 +29,7 @@ import usePermissions from '@hooks/usePermissions'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import {hasSynchronizationError, removePolicyConnection, syncConnection} from '@libs/actions/connections'; +import {getSynchronizationErrorMessage, removePolicyConnection, syncConnection} from '@libs/actions/connections'; import {findCurrentXeroOrganization, getCurrentXeroOrganizationName, getIntegrationLastSuccessfulDate, getXeroTenants} from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; @@ -219,7 +219,8 @@ function PolicyAccountingPage({policy, connectionSyncProgress}: PolicyAccounting if (!connectedIntegration) { return []; } - const shouldShowSynchronizationError = hasSynchronizationError(policy, connectedIntegration, isSyncInProgress); + const synchronizationError = getSynchronizationErrorMessage(policy, connectedIntegration, isSyncInProgress); + const shouldShowSynchronizationError = !!synchronizationError; const integrationData = accountingIntegrationData(connectedIntegration, policyID, translate); const iconProps = integrationData?.icon ? {icon: integrationData.icon, iconType: CONST.ICON_TYPE_AVATAR} : {}; return [ @@ -229,7 +230,7 @@ function PolicyAccountingPage({policy, connectionSyncProgress}: PolicyAccounting wrapperStyle: [styles.sectionMenuItemTopDescription, shouldShowSynchronizationError && styles.pb0], shouldShowRightComponent: true, title: integrationData?.title, - errorText: shouldShowSynchronizationError ? translate('workspace.accounting.syncError', connectedIntegration) : undefined, + errorText: synchronizationError, errorTextStyle: [styles.mt5], shouldShowRedDotIndicator: true, description: isSyncInProgress diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index e3b7ea301e26..30fcb5413a38 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -175,6 +175,12 @@ type ConnectionLastSync = { /** Date when the connection's last failed sync occurred */ errorDate?: string; + /** Error message when the connection's last sync failed */ + errorMessage?: string; + + /** If the connection's last sync failed due to authentication error */ + isAuthenticationError: boolean; + /** Whether the connection's last sync was successful */ isSuccessful: boolean; @@ -1178,16 +1184,16 @@ type Connection = { /** Available integration connections */ type Connections = { /** QuickBooks integration connection */ - quickbooksOnline: Connection; + [CONST.POLICY.CONNECTIONS.NAME.QBO]: Connection; /** Xero integration connection */ - xero: Connection; + [CONST.POLICY.CONNECTIONS.NAME.XERO]: Connection; /** NetSuite integration connection */ - netsuite: NetSuiteConnection; + [CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: NetSuiteConnection; /** Sage Intacct integration connection */ - intacct: Connection; + [CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: Connection; }; /** Names of integration connections */ From 43e5fe176239c37859cac1a5a9380ec3cb5e00db Mon Sep 17 00:00:00 2001 From: Jakub Kosmydel <104823336+kosmydel@users.noreply.github.com> Date: Thu, 11 Jul 2024 13:18:26 +0200 Subject: [PATCH 02/32] feat: reconnect integrations --- src/libs/actions/connections/index.ts | 10 +++- .../accounting/PolicyAccountingPage.tsx | 51 +++++++++++++++---- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/libs/actions/connections/index.ts b/src/libs/actions/connections/index.ts index 2b1ec0fdf4d2..147ec7aff3f5 100644 --- a/src/libs/actions/connections/index.ts +++ b/src/libs/actions/connections/index.ts @@ -273,4 +273,12 @@ function getSynchronizationErrorMessage(policy: OnyxEntry, connectionNam } } -export {removePolicyConnection, updatePolicyConnectionConfig, updateManyPolicyConnectionConfigs, getSynchronizationErrorMessage, syncConnection}; +function isAuthenticationError(policy: OnyxEntry, connectionName: PolicyConnectionName) { + if (connectionName === CONST.POLICY.CONNECTIONS.NAME.NETSUITE) { + return false; + } + const connection = policy?.connections?.[connectionName]; + return connection?.lastSync?.isAuthenticationError === true; +} + +export {removePolicyConnection, updatePolicyConnectionConfig, updateManyPolicyConnectionConfigs, getSynchronizationErrorMessage, syncConnection, isAuthenticationError}; diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index 1c922d4db78a..74acd938546b 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -23,19 +23,23 @@ import ScrollView from '@components/ScrollView'; import Section from '@components/Section'; import ThreeDotsMenu from '@components/ThreeDotsMenu'; import type ThreeDotsMenuProps from '@components/ThreeDotsMenu/types'; +import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import usePermissions from '@hooks/usePermissions'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import {getSynchronizationErrorMessage, removePolicyConnection, syncConnection} from '@libs/actions/connections'; +import {getSynchronizationErrorMessage, isAuthenticationError, removePolicyConnection, syncConnection} from '@libs/actions/connections'; +import {getXeroSetupLink} from '@libs/actions/connections/ConnectToXero'; +import getQuickBooksOnlineSetupLink from '@libs/actions/connections/QuickBooksOnline'; import {findCurrentXeroOrganization, getCurrentXeroOrganizationName, getIntegrationLastSuccessfulDate, getXeroTenants} from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import type {AnchorPosition} from '@styles/index'; +import * as Link from '@userActions/Link'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -137,11 +141,25 @@ function accountingIntegrationData( } } +function reconnectPolicyAccountingIntegration(policyID: string, environmentURL: string, integration: PolicyConnectionName) { + switch (integration) { + case CONST.POLICY.CONNECTIONS.NAME.QBO: + Link.openLink(getQuickBooksOnlineSetupLink(policyID), environmentURL); + break; + case CONST.POLICY.CONNECTIONS.NAME.XERO: + Link.openLink(getXeroSetupLink(policyID), environmentURL); + break; + default: + break; + } +} + function PolicyAccountingPage({policy, connectionSyncProgress}: PolicyAccountingPageProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate, datetimeToRelative: getDatetimeToRelative} = useLocalize(); const {isOffline} = useNetwork(); + const {environmentURL} = useEnvironment(); const {canUseNetSuiteIntegration, canUseSageIntacctIntegration} = usePermissions(); const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); const [threeDotsMenuPosition, setThreeDotsMenuPosition] = useState({horizontal: 0, vertical: 0}); @@ -162,6 +180,8 @@ function PolicyAccountingPage({policy, connectionSyncProgress}: PolicyAccounting ); const connectedIntegration = accountingIntegrations.find((integration) => !!policy?.connections?.[integration]) ?? connectionSyncProgress?.connectionName; + const synchronizationError = connectedIntegration && getSynchronizationErrorMessage(policy, connectedIntegration, isSyncInProgress); + const shouldShowEnterCredentials = connectedIntegration && !!synchronizationError && isAuthenticationError(policy, connectedIntegration); const policyID = policy?.id ?? '-1'; const successfulDate = getIntegrationLastSuccessfulDate(connectedIntegration ? policy?.connections?.[connectedIntegration] : undefined); @@ -177,19 +197,32 @@ function PolicyAccountingPage({policy, connectionSyncProgress}: PolicyAccounting const overflowMenu: ThreeDotsMenuProps['menuItems'] = useMemo( () => [ - { - icon: Expensicons.Sync, - text: translate('workspace.accounting.syncNow'), - onSelected: () => syncConnection(policyID, connectedIntegration), - disabled: isOffline, - }, + ...(!shouldShowEnterCredentials + ? [ + { + icon: Expensicons.Sync, + text: translate('workspace.accounting.syncNow'), + onSelected: () => syncConnection(policyID, connectedIntegration), + disabled: isOffline, + }, + ] + : [ + { + icon: Expensicons.Key, + text: translate('workspace.accounting.enterCredentials'), + onSelected: () => reconnectPolicyAccountingIntegration(policyID, environmentURL, connectedIntegration), + disabled: isOffline, + iconRight: Expensicons.NewWindow, + shouldShowRightIcon: true, + }, + ]), { icon: Expensicons.Trashcan, text: translate('workspace.accounting.disconnect'), onSelected: () => setIsDisconnectModalOpen(true), }, ], - [translate, policyID, isOffline, connectedIntegration], + [isAuthenticationError, translate, isOffline, policyID, connectedIntegration, environmentURL], ); useEffect(() => { @@ -219,7 +252,6 @@ function PolicyAccountingPage({policy, connectionSyncProgress}: PolicyAccounting if (!connectedIntegration) { return []; } - const synchronizationError = getSynchronizationErrorMessage(policy, connectedIntegration, isSyncInProgress); const shouldShowSynchronizationError = !!synchronizationError; const integrationData = accountingIntegrationData(connectedIntegration, policyID, translate); const iconProps = integrationData?.icon ? {icon: integrationData.icon, iconType: CONST.ICON_TYPE_AVATAR} : {}; @@ -334,6 +366,7 @@ function PolicyAccountingPage({policy, connectionSyncProgress}: PolicyAccounting policy, isSyncInProgress, connectedIntegration, + synchronizationError, policyID, translate, styles.sectionMenuItemTopDescription, From 0fecab4b40c8c7f78a36f0259ac6af375a0506a4 Mon Sep 17 00:00:00 2001 From: Jakub Kosmydel <104823336+kosmydel@users.noreply.github.com> Date: Fri, 12 Jul 2024 12:10:58 +0200 Subject: [PATCH 03/32] refactor: add AccountingContext --- src/App.tsx | 3 + .../ConnectToNetSuiteButton/index.tsx | 57 +++--- .../index.native.tsx | 29 ++- .../ConnectToQuickbooksOnlineButton/index.tsx | 58 +++--- .../ConnectToSageIntacctButton/index.tsx | 55 +++--- .../ConnectToXeroButton/index.native.tsx | 30 ++-- src/components/ConnectToXeroButton/index.tsx | 54 +++--- .../accounting/AccountingContext.tsx | 59 ++++++ .../accounting/PolicyAccountingPage.tsx | 170 ++++-------------- src/pages/workspace/accounting/types.ts | 29 +++ src/pages/workspace/accounting/utils.tsx | 88 +++++++++ 11 files changed, 328 insertions(+), 304 deletions(-) create mode 100644 src/pages/workspace/accounting/AccountingContext.tsx create mode 100644 src/pages/workspace/accounting/types.ts create mode 100644 src/pages/workspace/accounting/utils.tsx diff --git a/src/App.tsx b/src/App.tsx index 98b5d4afeb1d..0402d31745ac 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import {GestureHandlerRootView} from 'react-native-gesture-handler'; import {KeyboardProvider} from 'react-native-keyboard-controller'; import {PickerStateProvider} from 'react-native-picker-select'; import {SafeAreaProvider} from 'react-native-safe-area-context'; +import {AccountingContextProvider} from '@pages/workspace/accounting/AccountingContext'; import '../wdyr'; import ActiveElementRoleProvider from './components/ActiveElementRoleProvider'; import ActiveWorkspaceContextProvider from './components/ActiveWorkspaceProvider'; @@ -93,6 +94,8 @@ function App({url}: AppProps) { VideoPopoverMenuContextProvider, KeyboardProvider, SearchContextProvider, + // TODO: Verify if we can move this provider somewhere else + AccountingContextProvider, ]} > diff --git a/src/components/ConnectToNetSuiteButton/index.tsx b/src/components/ConnectToNetSuiteButton/index.tsx index a0cd36671117..437c5396c3e7 100644 --- a/src/components/ConnectToNetSuiteButton/index.tsx +++ b/src/components/ConnectToNetSuiteButton/index.tsx @@ -1,9 +1,5 @@ -import React, {useState} from 'react'; +import React, {useEffect, useState} from 'react'; import AccountingConnectionConfirmationModal from '@components/AccountingConnectionConfirmationModal'; -import Button from '@components/Button'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import useThemeStyles from '@hooks/useThemeStyles'; import {removePolicyConnection} from '@libs/actions/connections'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; @@ -11,42 +7,33 @@ import ROUTES from '@src/ROUTES'; import type {ConnectToNetSuiteButtonProps} from './types'; function ConnectToNetSuiteButton({policyID, shouldDisconnectIntegrationBeforeConnecting, integrationToDisconnect}: ConnectToNetSuiteButtonProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const {isOffline} = useNetwork(); - const [isDisconnectModalOpen, setIsDisconnectModalOpen] = useState(false); - return ( - <> -