diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 2600eb78d58e..a28018a46b38 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1427,6 +1427,7 @@ const CONST = { REPORT_MERGE: 'reportMerge', INSIGHTS_PAGE: 'insightsPage', INSIGHTS_COMPARE: 'insightsCompare', + INSIGHTS_COMPLIANCE: 'insightsCompliance', NL_FILTERS: 'nlFilters', PAYMENT_HISTORY: 'paymentHistory', UNIFIED_CONNECTIONS: 'unifiedConnections', @@ -10069,6 +10070,7 @@ const CONST = { CONTROL_GROUP_CURRENCY: 'Insights-ControlGroupCurrency', CONTROL_GROUP_BY: 'Insights-ControlGroupBy', CONTROL_COMPARE: 'Insights-ControlCompare', + DASHBOARD_TAB: 'Insights-DashboardTab', }, EXPENSE_RULES: { TABLE_ROW: 'ExpenseRules-TableRow', @@ -10927,9 +10929,11 @@ const CONST = { INSIGHTS: { DASHBOARD: { SPEND: 'spend', + COMPLIANCE: 'compliance', }, SEARCH_KEY: { SPEND: 'insightsSpend', + COMPLIANCE: 'insightsCompliance', }, GRAPH: { SPEND_OVER_TIME: 'spendOverTime', diff --git a/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx b/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx index 0042091fab77..32aed6b33fe2 100644 --- a/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx @@ -8,13 +8,19 @@ import clearSelectedText from '@libs/clearSelectedText/clearSelectedText'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import Navigation from '@libs/Navigation/Navigation'; +import navigationRef from '@navigation/navigationRef'; + +import useAccessibleDashboards from '@pages/Insights/useAccessibleDashboards'; + import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; +import type {InsightsDashboardID} from '@src/types/onyx'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; import type {ValueOf} from 'type-fest'; import React from 'react'; +import getInsightsTabRoute from './getInsightsTabRoute'; import NAVIGATION_TABS from './NAVIGATION_TABS'; import TabBarItem from './TabBarItem'; @@ -27,6 +33,7 @@ function InsightsTabButton({selectedTab, isWideLayout}: InsightsTabButtonProps) const styles = useThemeStyles(); const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['PieChart']); + const [accessibleDashboards = getEmptyArray()] = useAccessibleDashboards(); const isSelected = selectedTab === NAVIGATION_TABS.INSIGHTS; const navigateToInsights = () => { @@ -35,7 +42,8 @@ function InsightsTabButton({selectedTab, isWideLayout}: InsightsTabButtonProps) } clearSelectedText(); interceptAnonymousUser(() => { - Navigation.navigate(ROUTES.INSIGHTS.getRoute(CONST.INSIGHTS.DASHBOARD.SPEND)); + const rootState = navigationRef.isReady() ? navigationRef.getRootState() : undefined; + Navigation.navigate(getInsightsTabRoute(rootState, accessibleDashboards)); }); }; diff --git a/src/components/Navigation/NavigationTabBar/getInsightsTabRoute.ts b/src/components/Navigation/NavigationTabBar/getInsightsTabRoute.ts new file mode 100644 index 000000000000..bc3975fbfd7a --- /dev/null +++ b/src/components/Navigation/NavigationTabBar/getInsightsTabRoute.ts @@ -0,0 +1,22 @@ +import {getTabNavigatorRoute} from '@libs/Navigation/helpers/tabNavigatorUtils'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import type {InsightsDashboardID} from '@src/types/onyx'; + +import type {NavigationState} from '@react-navigation/native'; + +/** Returns the route of the dashboard the Insights tab last showed, or Spend when there is none or it is no longer accessible. */ +function getInsightsTabRoute(rootState: NavigationState | undefined, accessibleDashboards: InsightsDashboardID[]) { + const insightsParams = getTabNavigatorRoute(rootState)?.state?.routes.find((route) => route.name === SCREENS.INSIGHTS)?.params; + const lastDashboardID = insightsParams && 'dashboardID' in insightsParams ? Object.values(CONST.INSIGHTS.DASHBOARD).find((id) => id === insightsParams.dashboardID) : undefined; + + if (lastDashboardID && accessibleDashboards.includes(lastDashboardID)) { + return ROUTES.INSIGHTS.getRoute(lastDashboardID); + } + + return ROUTES.INSIGHTS.getRoute(CONST.INSIGHTS.DASHBOARD.SPEND); +} + +export default getInsightsTabRoute; diff --git a/src/languages/en.ts b/src/languages/en.ts index d113227dc1ad..e79c949f6f18 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1314,6 +1314,7 @@ const translations = { }, insightsPage: { viewOnSpend: 'View on Spend', + compliance: 'Compliance', compare: { label: 'Compare', previousPeriod: 'Previous period', diff --git a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx index 6c4b97d1fedc..d3c119494ccd 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx @@ -71,7 +71,7 @@ function TabNavigator() { 1 ? ( + + ) : null; + + const pageControls = + state !== INSIGHTS_DASHBOARD_STATE.NO_EXPENSES ? ( + + ) : null; + return ( - {state !== INSIGHTS_DASHBOARD_STATE.NO_EXPENSES && ( - + {shouldUseNarrowLayout ? ( + <> + {!!tabSelector && {tabSelector}} + {pageControls} + + ) : ( + (!!tabSelector || !!pageControls) && ( + + + {tabSelector} + {pageControls} + + + ) )} ; function InsightsPage({route}: InsightsPageProps) { const {translate} = useLocalize(); const {isBetaEnabled} = usePermissions(); + const [accessibleDashboards = getEmptyArray(), accessibleDashboardsMetadata] = useAccessibleDashboards(); useDocumentTitle(translate('common.insights')); const dashboardID = Object.values(CONST.INSIGHTS.DASHBOARD).find((id) => id === route.params?.dashboardID); - if (!isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE) || !dashboardID) { + if (!isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE) || !dashboardID || (!isLoadingOnyxValue(accessibleDashboardsMetadata) && !accessibleDashboards.includes(dashboardID))) { return ; } - return ; + return ( + + ); } export default InsightsPage; diff --git a/src/pages/Insights/InsightsTabSelector.tsx b/src/pages/Insights/InsightsTabSelector.tsx new file mode 100644 index 000000000000..11b013cb315f --- /dev/null +++ b/src/pages/Insights/InsightsTabSelector.tsx @@ -0,0 +1,46 @@ +import TabSelectorBase from '@components/TabSelector/TabSelectorBase'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {InsightsDashboardID} from '@src/types/onyx'; + +import React from 'react'; + +import INSIGHTS_DASHBOARD_SPECS from './dashboardSpecs'; + +type InsightsTabSelectorProps = { + /** Dashboard the route currently displays */ + activeDashboardID: InsightsDashboardID; + + /** Dashboards the user can switch between, in the order their tabs are shown */ + dashboardIDs: InsightsDashboardID[]; +}; + +function InsightsTabSelector({activeDashboardID, dashboardIDs}: InsightsTabSelectorProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const tabs = dashboardIDs.map((dashboardID) => ({ + key: dashboardID, + title: translate(INSIGHTS_DASHBOARD_SPECS[dashboardID].labelKey), + sentryLabel: CONST.SENTRY_LABEL.INSIGHTS.DASHBOARD_TAB, + testID: `InsightsTab-${dashboardID}`, + })); + + return ( + Navigation.navigate(ROUTES.INSIGHTS.getRoute(dashboardID))} + /> + ); +} + +export default InsightsTabSelector; diff --git a/src/pages/Insights/controls/InsightsPageControls.tsx b/src/pages/Insights/controls/InsightsPageControls.tsx index a1eec0004a01..0c9057a235b9 100644 --- a/src/pages/Insights/controls/InsightsPageControls.tsx +++ b/src/pages/Insights/controls/InsightsPageControls.tsx @@ -71,11 +71,7 @@ function InsightsPageControls({filters, defaultFilters, onChange}: InsightsPageC ); } - return ( - - {controls} - - ); + return {controls}; } export default InsightsPageControls; diff --git a/src/pages/Insights/dashboardSpecs.ts b/src/pages/Insights/dashboardSpecs.ts index 46b649d7ef09..b2a61b0f93ae 100644 --- a/src/pages/Insights/dashboardSpecs.ts +++ b/src/pages/Insights/dashboardSpecs.ts @@ -6,10 +6,12 @@ import {isPolicyEligibleForTopCategories, isPolicyEligibleForTopSpenders} from ' import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import type {InsightsDashboardID, InsightsGraphKey, InsightsSearchKey, Policy} from '@src/types/onyx'; +import type {Beta, InsightsDashboardID, InsightsGraphKey, InsightsSearchKey, Policy} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; +import {canViewAllPolicyData} from './insightsAccess'; + type InsightsChartSpec = { /** Slot the request names the chart's snapshot under, and the response's `graphs` confirms it in */ graphKey: InsightsGraphKey; @@ -30,6 +32,15 @@ type InsightsDashboardSpec = { /** Identifies the dashboard to the backend. */ searchKey: InsightsSearchKey; + /** Label of the dashboard's tab in the segmented control */ + labelKey: TranslationPaths; + + /** The dashboard is hidden unless this beta is enabled */ + beta?: Beta; + + /** The dashboard is shown when any workspace in scope passes this. A dashboard that declares none is always shown. */ + isPolicyEligible?: (policy: Policy, login: string | undefined) => boolean; + /** Chart across the top of the page, the only one the group-by filter applies to */ headlineChart: InsightsChartSpec; @@ -40,6 +51,7 @@ type InsightsDashboardSpec = { const INSIGHTS_DASHBOARD_SPECS: Record = { [CONST.INSIGHTS.DASHBOARD.SPEND]: { searchKey: CONST.INSIGHTS.SEARCH_KEY.SPEND, + labelKey: 'common.spend', headlineChart: { graphKey: CONST.INSIGHTS.GRAPH.SPEND_OVER_TIME, titleKey: 'search.spendOverTime', @@ -77,6 +89,19 @@ const INSIGHTS_DASHBOARD_SPECS: Record !isPolicyEligible || policiesInScope.some((policy) => isPolicyEligible(policy, login))); } -export {getVisibleCharts}; +/** Returns the dashboards the user can open, ignoring the workspace filter. */ +function getAccessibleDashboards(policies: OnyxCollection, login: string | undefined, isBetaEnabled: (beta: Beta) => boolean): InsightsDashboardID[] { + const allPolicies = Object.values(policies ?? {}); + return Object.values(CONST.INSIGHTS.DASHBOARD).filter((dashboardID) => { + const {beta, isPolicyEligible} = INSIGHTS_DASHBOARD_SPECS[dashboardID]; + return (!beta || isBetaEnabled(beta)) && (!isPolicyEligible || allPolicies.some((policy) => !!policy && isPolicyEligible(policy, login))); + }); +} + +export {getAccessibleDashboards, getVisibleCharts}; export type {InsightsChartSpec}; export default INSIGHTS_DASHBOARD_SPECS; diff --git a/src/pages/Insights/insightsAccess.ts b/src/pages/Insights/insightsAccess.ts new file mode 100644 index 000000000000..dc63410e0b10 --- /dev/null +++ b/src/pages/Insights/insightsAccess.ts @@ -0,0 +1,11 @@ +import {isGroupPolicy, isPolicyAdmin, isPolicyAuditor} from '@libs/PolicyUtils'; + +import type {Policy} from '@src/types/onyx'; + +/** Whether the user can see every member's data in the workspace. */ +function canViewAllPolicyData(policy: Policy, login: string | undefined): boolean { + return isGroupPolicy(policy) && (isPolicyAdmin(policy, login) || isPolicyAuditor(policy, login)); +} + +// eslint-disable-next-line import/prefer-default-export -- more predicates are added here as the dashboards grow +export {canViewAllPolicyData}; diff --git a/src/pages/Insights/useAccessibleDashboards.ts b/src/pages/Insights/useAccessibleDashboards.ts new file mode 100644 index 000000000000..3f707f6eda3a --- /dev/null +++ b/src/pages/Insights/useAccessibleDashboards.ts @@ -0,0 +1,16 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import {getAccessibleDashboards} from './dashboardSpecs'; + +/** Returns the dashboards the user can open */ +function useAccessibleDashboards() { + const {login} = useCurrentUserPersonalDetails(); + const {isBetaEnabled} = usePermissions(); + return useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: (policies) => getAccessibleDashboards(policies, login, isBetaEnabled)}); +} + +export default useAccessibleDashboards; diff --git a/tests/unit/insightsAccessTest.ts b/tests/unit/insightsAccessTest.ts new file mode 100644 index 000000000000..b1776686485f --- /dev/null +++ b/tests/unit/insightsAccessTest.ts @@ -0,0 +1,107 @@ +import {getAccessibleDashboards} from '@pages/Insights/dashboardSpecs'; +import {canViewAllPolicyData} from '@pages/Insights/insightsAccess'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Beta, Policy} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import createRandomPolicy from '../utils/collections/policies'; + +const LOGIN = 'user@example.com'; +const SPEND = CONST.INSIGHTS.DASHBOARD.SPEND; +const COMPLIANCE = CONST.INSIGHTS.DASHBOARD.COMPLIANCE; + +/** Builds a workspace the current user holds the given role on. */ +function buildPolicy(index: number, role: ValueOf, type: ValueOf = CONST.POLICY.TYPE.TEAM): Policy { + return {...createRandomPolicy(index, type), id: String(index), role}; +} + +/** Keys the workspaces the way the policy collection stores them. */ +function toCollection(policies: Policy[]): OnyxCollection { + return Object.fromEntries(policies.map((policy) => [`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy])); +} + +const isComplianceBetaEnabled = (beta: Beta) => beta === CONST.BETAS.INSIGHTS_COMPLIANCE; +const isNoBetaEnabled = () => false; + +describe('insightsAccess', () => { + describe('canViewAllPolicyData', () => { + it('lets admins and auditors view all of the workspace data', () => { + // Given a workspace the user administers and one they audit + const adminPolicy = buildPolicy(1, CONST.POLICY.ROLE.ADMIN); + const auditorPolicy = buildPolicy(2, CONST.POLICY.ROLE.AUDITOR); + + // When their access is checked + // Then both roles can see every member's data + expect(canViewAllPolicyData(adminPolicy, LOGIN)).toBe(true); + expect(canViewAllPolicyData(auditorPolicy, LOGIN)).toBe(true); + }); + + it('keeps other members from viewing all of the workspace data', () => { + // Given a workspace the user is a regular member of + const memberPolicy = buildPolicy(1, CONST.POLICY.ROLE.USER); + + // When their access is checked + // Then they can only see their own data, so the workspace doesn't qualify + expect(canViewAllPolicyData(memberPolicy, LOGIN)).toBe(false); + }); + + it('leaves out personal workspaces', () => { + // Given a personal workspace, which the user always administers + const personalPolicy = buildPolicy(1, CONST.POLICY.ROLE.ADMIN, CONST.POLICY.TYPE.PERSONAL); + + // When their access is checked + // Then it doesn't qualify, since it has no other members' data to review + expect(canViewAllPolicyData(personalPolicy, LOGIN)).toBe(false); + }); + }); + + describe('getAccessibleDashboards', () => { + it('hides Compliance without the beta, even from admins', () => { + // Given an admin of a workspace who doesn't have the compliance beta + const policies = toCollection([buildPolicy(1, CONST.POLICY.ROLE.ADMIN)]); + + // When the dashboards they can open are resolved + const dashboards = getAccessibleDashboards(policies, LOGIN, isNoBetaEnabled); + + // Then only Spend is offered, so the beta keeps Compliance out of production + expect(dashboards).toEqual([SPEND]); + }); + + it('offers Compliance to admins and auditors with the beta', () => { + // Given one user who administers a workspace and another who audits one, both with the beta + const adminPolicies = toCollection([buildPolicy(1, CONST.POLICY.ROLE.ADMIN)]); + const auditorPolicies = toCollection([buildPolicy(1, CONST.POLICY.ROLE.AUDITOR)]); + + // When the dashboards they can open are resolved + // Then both get Spend followed by Compliance, in the order the tabs are shown + expect(getAccessibleDashboards(adminPolicies, LOGIN, isComplianceBetaEnabled)).toEqual([SPEND, COMPLIANCE]); + expect(getAccessibleDashboards(auditorPolicies, LOGIN, isComplianceBetaEnabled)).toEqual([SPEND, COMPLIANCE]); + }); + + it('hides Compliance from members who are neither admin nor auditor', () => { + // Given a user with the beta who is only a regular member of their workspaces + const policies = toCollection([buildPolicy(1, CONST.POLICY.ROLE.USER), buildPolicy(2, CONST.POLICY.ROLE.USER)]); + + // When the dashboards they can open are resolved + const dashboards = getAccessibleDashboards(policies, LOGIN, isComplianceBetaEnabled); + + // Then only Spend is offered, since they can't review other members' violations + expect(dashboards).toEqual([SPEND]); + }); + + it('offers Compliance when any of the workspaces qualifies, whatever the roles on the others', () => { + // Given a user with the beta who administers one workspace and is a regular member of another + const policies = toCollection([buildPolicy(1, CONST.POLICY.ROLE.ADMIN), buildPolicy(2, CONST.POLICY.ROLE.USER)]); + + // When the dashboards they can open are resolved + const dashboards = getAccessibleDashboards(policies, LOGIN, isComplianceBetaEnabled); + + // Then Compliance is offered, so narrowing the workspace filter to the member one can't hide its tab + expect(dashboards).toEqual([SPEND, COMPLIANCE]); + }); + }); +});