Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -10927,9 +10929,11 @@ const CONST = {
INSIGHTS: {
DASHBOARD: {
SPEND: 'spend',
COMPLIANCE: 'compliance',
},
SEARCH_KEY: {
SPEND: 'insightsSpend',
COMPLIANCE: 'insightsCompliance',
},
GRAPH: {
SPEND_OVER_TIME: 'spendOverTime',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -27,6 +33,7 @@ function InsightsTabButton({selectedTab, isWideLayout}: InsightsTabButtonProps)
const styles = useThemeStyles();
const {translate} = useLocalize();
const expensifyIcons = useMemoizedLazyExpensifyIcons(['PieChart']);
const [accessibleDashboards = getEmptyArray<InsightsDashboardID>()] = useAccessibleDashboards();
const isSelected = selectedTab === NAVIGATION_TABS.INSIGHTS;

const navigateToInsights = () => {
Expand All @@ -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));
});
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1314,6 +1314,7 @@ const translations = {
},
insightsPage: {
viewOnSpend: 'View on Spend',
compliance: 'Compliance',
compare: {
label: 'Compare',
previousPeriod: 'Previous period',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ function TabNavigator() {
<Tab.Screen
name={SCREENS.INSIGHTS}
component={InsightsPage}
// The Insights tab button opens the Spend dashboard, so a tap on the native tab lands on it too.
// The tab opens on Spend dashboard once, then keeps the last dashboard in navigation state until the app restarts.
initialParams={{dashboardID: CONST.INSIGHTS.DASHBOARD.SPEND}}
Comment on lines +74 to 75

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate retained Compliance before native tab restore

When a narrow native user selects Compliance, leaves the tab, and then loses the compliance beta or their last admin/auditor workspace, the retained dashboardID: compliance here is still restored on the next Insights tab press. I checked NativeTabNavigator/tabScreenListeners.ts: the Insights handler only performs anonymous interception and never runs getInsightsTabRoute, so InsightsPage renders NotFound and the user cannot recover via the native tab even though Spend remains accessible. Validate the retained dashboard on native tab presses and fall back to Spend when access was revoked.

Useful? React with 👍 / 👎.

/>
<Tab.Screen
Expand Down
43 changes: 36 additions & 7 deletions src/pages/Insights/InsightsDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import InsightsChartWidget from './charts/InsightsChartWidget';
import InsightsPageControls from './controls/InsightsPageControls';
import INSIGHTS_DASHBOARD_SPECS, {getVisibleCharts} from './dashboardSpecs';
import buildInsightsJsonQuery from './insightsQueries';
import InsightsTabSelector from './InsightsTabSelector';
import {getDashboardState, INSIGHTS_DASHBOARD_STATE} from './resolveDashboardState';
import InsightsEmptyState from './states/InsightsEmptyState';
import InsightsNoExpensesState from './states/InsightsNoExpensesState';
Expand Down Expand Up @@ -186,7 +187,10 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte
);
}

function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) {
function InsightsDashboard({dashboardID, accessibleDashboards}: {dashboardID: InsightsDashboardID; accessibleDashboards: InsightsDashboardID[]}) {
const styles = useThemeStyles();
const {pageGutter} = useLayoutSpacing();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const {translate} = useLocalize();
const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.INSIGHTS);
const {isOffline} = useNetwork();
Expand Down Expand Up @@ -241,6 +245,23 @@ function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) {
const [headlineChart, ...supportingCharts] = charts;
const state = getDashboardState(dashboard, isOffline, charts);

const tabSelector =
accessibleDashboards.length > 1 ? (
<InsightsTabSelector
activeDashboardID={dashboardID}
dashboardIDs={accessibleDashboards}
/>
) : null;

const pageControls =
state !== INSIGHTS_DASHBOARD_STATE.NO_EXPENSES ? (
<InsightsPageControls
filters={filters}
defaultFilters={defaultFilters}
onChange={setFilters}
/>
) : null;

return (
<ScreenWrapper
shouldShowOfflineIndicatorInWideScreen
Expand All @@ -252,12 +273,20 @@ function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) {
breadcrumbLabel={translate('common.insights')}
shouldDisplayHelpButton
/>
{state !== INSIGHTS_DASHBOARD_STATE.NO_EXPENSES && (
<InsightsPageControls
filters={filters}
defaultFilters={defaultFilters}
onChange={setFilters}
/>
{shouldUseNarrowLayout ? (
<>
{!!tabSelector && <View style={[pageGutter, styles.pb3]}>{tabSelector}</View>}
{pageControls}
</>
) : (
(!!tabSelector || !!pageControls) && (
<View style={[pageGutter, styles.pb5, styles.insightsPageControlsContainer]}>
<View style={[styles.centeredContentWidthLimiter, styles.flexRow, styles.alignItemsCenter, styles.gap2]}>
{tabSelector}
{pageControls}
</View>
</View>
)
)}
<InsightsDashboardContent
state={state}
Expand Down
14 changes: 12 additions & 2 deletions src/pages/Insights/InsightsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,37 @@ import NotFoundPage from '@pages/ErrorPage/NotFoundPage';

import CONST from '@src/CONST';
import type SCREENS from '@src/SCREENS';
import type {InsightsDashboardID} from '@src/types/onyx';
import getEmptyArray from '@src/types/utils/getEmptyArray';
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';

import type {BottomTabScreenProps} from '@react-navigation/bottom-tabs';

import React from 'react';

import InsightsDashboard from './InsightsDashboard';
import useAccessibleDashboards from './useAccessibleDashboards';

type InsightsPageProps = BottomTabScreenProps<TabNavigatorParamList, typeof SCREENS.INSIGHTS>;

function InsightsPage({route}: InsightsPageProps) {
const {translate} = useLocalize();
const {isBetaEnabled} = usePermissions();
const [accessibleDashboards = getEmptyArray<InsightsDashboardID>(), 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 <NotFoundPage />;
}

return <InsightsDashboard dashboardID={dashboardID} />;
return (
<InsightsDashboard
dashboardID={dashboardID}
accessibleDashboards={accessibleDashboards}
/>
);
}

export default InsightsPage;
46 changes: 46 additions & 0 deletions src/pages/Insights/InsightsTabSelector.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<TabSelectorBase
contentContainerStyles={[styles.ph0, styles.pb0, styles.flexGrow0]}
tabButtonStyles={styles.ph6}
tabs={tabs}
activeTabKey={activeDashboardID}
onTabPress={(dashboardID) => Navigation.navigate(ROUTES.INSIGHTS.getRoute(dashboardID))}
/>
);
}

export default InsightsTabSelector;
6 changes: 1 addition & 5 deletions src/pages/Insights/controls/InsightsPageControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,7 @@ function InsightsPageControls({filters, defaultFilters, onChange}: InsightsPageC
);
}

return (
<View style={[pageGutter, styles.pb5, styles.insightsPageControlsContainer]}>
<View style={[styles.centeredContentWidthLimiter, styles.flexRow, styles.flexWrap, styles.alignItemsCenter, styles.justifyContentEnd, styles.gap2]}>{controls}</View>
</View>
);
return <View style={[styles.flex1, styles.flexRow, styles.flexWrap, styles.alignItemsCenter, styles.justifyContentEnd, styles.gap2]}>{controls}</View>;
}

export default InsightsPageControls;
38 changes: 36 additions & 2 deletions src/pages/Insights/dashboardSpecs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -40,6 +51,7 @@ type InsightsDashboardSpec = {
const INSIGHTS_DASHBOARD_SPECS: Record<InsightsDashboardID, InsightsDashboardSpec> = {
[CONST.INSIGHTS.DASHBOARD.SPEND]: {
searchKey: CONST.INSIGHTS.SEARCH_KEY.SPEND,
labelKey: 'common.spend',
headlineChart: {
graphKey: CONST.INSIGHTS.GRAPH.SPEND_OVER_TIME,
titleKey: 'search.spendOverTime',
Expand Down Expand Up @@ -77,6 +89,19 @@ const INSIGHTS_DASHBOARD_SPECS: Record<InsightsDashboardID, InsightsDashboardSpe
},
],
},
[CONST.INSIGHTS.DASHBOARD.COMPLIANCE]: {
searchKey: CONST.INSIGHTS.SEARCH_KEY.COMPLIANCE,
labelKey: 'insightsPage.compliance',
beta: CONST.BETAS.INSIGHTS_COMPLIANCE,
isPolicyEligible: canViewAllPolicyData,
// Placeholder until the Compliance charts land, when violationsOverTime replaces it (added for type compatibility)
headlineChart: {
graphKey: CONST.INSIGHTS.GRAPH.SPEND_OVER_TIME,
titleKey: 'search.spendOverTime',
view: CONST.SEARCH.VIEW.LINE,
Comment on lines +99 to +101

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not render the Spend chart on Compliance

When an eligible admin or auditor opens Compliance after Spend has loaded for the same default filters, this placeholder is treated as a real chart: buildInsightsJsonQuery produces the same chart hash as Spend and useMultipleSnapshots reads the globally keyed snapshot_<hash>. Consequently getDashboardState sees the cached Spend snapshot as loaded and the Compliance page immediately renders “Spend over time” and its data instead of a chartless Compliance page; later responses can also overwrite the snapshot used by Spend. Represent the empty dashboard without a queryable Spend spec, or namespace its snapshots by dashboard/search key.

Useful? React with 👍 / 👎.

Comment on lines +99 to +101

@luacmartins luacmartins Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compliance still sends the Spend payload (date:year-to-date and spendOverTime), so GetInsights returns 400. Could we use anySubmission OR anyApproval with the same bounded range and request violationsOverTime instead? The chart and comparison queries need matching filters and regenerated hashes too.

You can apply this patch to fix the issue

insights-compliance-local-fix.patch

},
supportingCharts: [],
},
};

/** Returns the charts that at least one workspace in scope is eligible for. No selected workspaces means every workspace is in scope. */
Expand All @@ -85,6 +110,15 @@ function getVisibleCharts(charts: InsightsChartSpec[], policies: OnyxCollection<
return charts.filter(({isPolicyEligible}) => !isPolicyEligible || policiesInScope.some((policy) => isPolicyEligible(policy, login)));
}

export {getVisibleCharts};
/** Returns the dashboards the user can open, ignoring the workspace filter. */
function getAccessibleDashboards(policies: OnyxCollection<Policy>, 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;
11 changes: 11 additions & 0 deletions src/pages/Insights/insightsAccess.ts
Original file line number Diff line number Diff line change
@@ -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};
16 changes: 16 additions & 0 deletions src/pages/Insights/useAccessibleDashboards.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading