diff --git a/src/components/Switch.tsx b/src/components/Switch.tsx index 1693bafe323d..e6ab9a9f1c1c 100644 --- a/src/components/Switch.tsx +++ b/src/components/Switch.tsx @@ -1,5 +1,5 @@ import React, {useEffect, useRef} from 'react'; -import {Animated} from 'react-native'; +import {Animated, InteractionManager} from 'react-native'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useNativeDriver from '@libs/useNativeDriver'; @@ -32,6 +32,12 @@ function Switch({isOn, onToggle, accessibilityLabel, disabled}: SwitchProps) { const offsetX = useRef(new Animated.Value(isOn ? OFFSET_X.ON : OFFSET_X.OFF)); const theme = useTheme(); + const handleSwitchPress = () => { + InteractionManager.runAfterInteractions(() => { + onToggle(!isOn); + }); + }; + useEffect(() => { Animated.timing(offsetX.current, { toValue: isOn ? OFFSET_X.ON : OFFSET_X.OFF, @@ -44,8 +50,8 @@ function Switch({isOn, onToggle, accessibilityLabel, disabled}: SwitchProps) { onToggle(!isOn)} - onLongPress={() => onToggle(!isOn)} + onPress={handleSwitchPress} + onLongPress={handleSwitchPress} role={CONST.ROLE.SWITCH} aria-checked={isOn} accessibilityLabel={accessibilityLabel} diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index cd375b580d85..0c6c371c0f09 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -3806,7 +3806,9 @@ function navigateWhenEnableFeature(policyID: string, featureRoute: Route) { new Promise((resolve) => { resolve(); }).then(() => { - Navigation.navigate(featureRoute); + requestAnimationFrame(() => { + Navigation.navigate(featureRoute); + }); }); } diff --git a/src/pages/workspace/FeatureEnabledAccessOrNotFoundWrapper.tsx b/src/pages/workspace/FeatureEnabledAccessOrNotFoundWrapper.tsx index 3bcdc1fe3303..f3a1c51238e5 100644 --- a/src/pages/workspace/FeatureEnabledAccessOrNotFoundWrapper.tsx +++ b/src/pages/workspace/FeatureEnabledAccessOrNotFoundWrapper.tsx @@ -1,9 +1,10 @@ /* eslint-disable rulesdir/no-negated-variables */ -import React, {useEffect} from 'react'; +import React, {useEffect, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import useNetwork from '@hooks/useNetwork'; import Navigation from '@libs/Navigation/Navigation'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as Policy from '@userActions/Policy'; @@ -33,9 +34,25 @@ type FeatureEnabledAccessOrNotFoundComponentProps = FeatureEnabledAccessOrNotFou }; function FeatureEnabledAccessOrNotFoundComponent(props: FeatureEnabledAccessOrNotFoundComponentProps) { + const pendingField = props.policy?.pendingFields?.[props.featureName]; const isPolicyIDInRoute = !!props.policyID?.length; + const isFeatureEnabled = PolicyUtils.isPolicyFeatureEnabled(props.policy, props.featureName); + + const [isPolicyFeatureEnabled, setIsPolicyFeatureEnabled] = useState(isFeatureEnabled); + const {isOffline} = useNetwork(); + + const shouldShowNotFoundPage = isEmptyObject(props.policy) || !props.policy?.id || !isPolicyFeatureEnabled; const shouldShowFullScreenLoadingIndicator = props.isLoadingReportData !== false && (!Object.entries(props.policy ?? {}).length || !props.policy?.id); - const shouldShowNotFoundPage = isEmptyObject(props.policy) || !props.policy?.id || !PolicyUtils.isPolicyFeatureEnabled(props.policy, props.featureName); + + // We only update the feature state if it isn't pending. + // This is because the feature state changes several times during the creation of a workspace, while we are waiting for a response from the backend. + // Without this, we can have unexpectedly have 'Not Found' be shown. + useEffect(() => { + if (pendingField && !isOffline && !isFeatureEnabled) { + return; + } + setIsPolicyFeatureEnabled(isFeatureEnabled); + }, [pendingField, isOffline, isFeatureEnabled]); useEffect(() => { if (!isPolicyIDInRoute || !isEmptyObject(props.policy)) { diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 2f32034391a5..eb41089160e3 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -14,6 +14,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; import useSingleExecution from '@hooks/useSingleExecution'; @@ -33,6 +34,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; +import type {PolicyFeatureName} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; @@ -71,6 +73,8 @@ type WorkspaceInitialPageOnyxProps = { type WorkspaceInitialPageProps = WithPolicyAndFullscreenLoadingProps & WorkspaceInitialPageOnyxProps & StackScreenProps; +type PolicyFeatureStates = Record; + function dismissError(policyID: string) { PolicyUtils.goBackFromInvalidPolicy(); Policy.removeWorkspace(policyID); @@ -86,6 +90,20 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAcc const activeRoute = useNavigationState(getTopmostWorkspacesCentralPaneName); const {translate} = useLocalize(); const {canUseAccountingIntegrations} = usePermissions(); + const {isOffline} = useNetwork(); + + const prevPendingFields = usePrevious(policy?.pendingFields); + const policyFeatureStates = useMemo( + () => ({ + [CONST.POLICY.MORE_FEATURES.ARE_DISTANCE_RATES_ENABLED]: policy?.areDistanceRatesEnabled, + [CONST.POLICY.MORE_FEATURES.ARE_WORKFLOWS_ENABLED]: policy?.areWorkflowsEnabled, + [CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED]: policy?.areCategoriesEnabled, + [CONST.POLICY.MORE_FEATURES.ARE_TAGS_ENABLED]: policy?.areTagsEnabled, + [CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED]: policy?.tax?.trackingEnabled, + [CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED]: policy?.areConnectionsEnabled, + }), + [policy], + ) as PolicyFeatureStates; const policyID = policy?.id ?? ''; const policyName = policy?.name ?? ''; @@ -122,6 +140,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAcc const shouldShowProtectedItems = PolicyUtils.isPolicyAdmin(policy); const isPaidGroupPolicy = PolicyUtils.isPaidGroupPolicy(policy); const isFreeGroupPolicy = PolicyUtils.isFreeGroupPolicy(policy); + const [featureStates, setFeatureStates] = useState(policyFeatureStates); const protectedFreePolicyMenuItems: WorkspaceMenuItem[] = [ { @@ -167,7 +186,24 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAcc const protectedCollectPolicyMenuItems: WorkspaceMenuItem[] = []; - if (policy?.areDistanceRatesEnabled) { + // We only update feature states if they aren't pending. + // These changes are made to synchronously change feature states along with FeatureEnabledAccessOrNotFoundComponent. + useEffect(() => { + setFeatureStates((currentFeatureStates) => { + const newFeatureStates = {} as PolicyFeatureStates; + (Object.keys(policy?.pendingFields ?? {}) as PolicyFeatureName[]).forEach((key) => { + const isFeatureEnabled = PolicyUtils.isPolicyFeatureEnabled(policy, key); + newFeatureStates[key] = + prevPendingFields?.[key] !== policy?.pendingFields?.[key] || isOffline || !policy?.pendingFields?.[key] ? isFeatureEnabled : currentFeatureStates[key]; + }); + return { + ...policyFeatureStates, + ...newFeatureStates, + }; + }); + }, [policy, isOffline, policyFeatureStates, prevPendingFields]); + + if (featureStates?.[CONST.POLICY.MORE_FEATURES.ARE_DISTANCE_RATES_ENABLED]) { protectedCollectPolicyMenuItems.push({ translationKey: 'workspace.common.distanceRates', icon: Expensicons.Car, @@ -176,7 +212,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAcc }); } - if (policy?.areWorkflowsEnabled) { + if (featureStates?.[CONST.POLICY.MORE_FEATURES.ARE_WORKFLOWS_ENABLED]) { protectedCollectPolicyMenuItems.push({ translationKey: 'workspace.common.workflows', icon: Expensicons.Workflows, @@ -186,7 +222,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAcc }); } - if (policy?.areCategoriesEnabled) { + if (featureStates?.[CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED]) { protectedCollectPolicyMenuItems.push({ translationKey: 'workspace.common.categories', icon: Expensicons.Folder, @@ -196,7 +232,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAcc }); } - if (policy?.areTagsEnabled) { + if (featureStates?.[CONST.POLICY.MORE_FEATURES.ARE_TAGS_ENABLED]) { protectedCollectPolicyMenuItems.push({ translationKey: 'workspace.common.tags', icon: Expensicons.Tag, @@ -205,7 +241,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAcc }); } - if (policy?.tax?.trackingEnabled) { + if (featureStates?.[CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED]) { protectedCollectPolicyMenuItems.push({ translationKey: 'workspace.common.taxes', icon: Expensicons.Tax, @@ -215,7 +251,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, reimbursementAcc }); } - if (policy?.areConnectionsEnabled && canUseAccountingIntegrations) { + if (featureStates?.[CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED] && canUseAccountingIntegrations) { protectedCollectPolicyMenuItems.push({ translationKey: 'workspace.common.accounting', icon: Expensicons.Sync,