From fccfd3ea335d1b56d8b9acf695da2d58ae3d5824 Mon Sep 17 00:00:00 2001 From: Olgierd Date: Wed, 21 Oct 2026 18:21:48 +0200 Subject: [PATCH 1/7] Add Compliance dashboard navigation and access rules --- src/CONST/index.ts | 4 + src/languages/en.ts | 1 + src/pages/Insights/InsightsDashboard.tsx | 58 ++++++++-- src/pages/Insights/InsightsTabSelector.tsx | 45 ++++++++ .../controls/InsightsPageControls.tsx | 6 +- src/pages/Insights/dashboardSpecs.ts | 56 +++++++-- src/pages/Insights/insightsAccess.ts | 13 +++ tests/unit/insightsAccessTest.ts | 107 ++++++++++++++++++ tests/unit/insightsQueriesTest.ts | 3 +- 9 files changed, 270 insertions(+), 23 deletions(-) create mode 100644 src/pages/Insights/InsightsTabSelector.tsx create mode 100644 src/pages/Insights/insightsAccess.ts create mode 100644 tests/unit/insightsAccessTest.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index cf311ef2f5ca..cb90afab3d3d 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1415,6 +1415,7 @@ const CONST = { REPORT_MERGE: 'reportMerge', INSIGHTS_PAGE: 'insightsPage', INSIGHTS_COMPARE: 'insightsCompare', + INSIGHTS_COMPLIANCE: 'insightsCompliance', PAYMENT_HISTORY: 'paymentHistory', UNIFIED_CONNECTIONS: 'unifiedConnections', SPEND_FOOTER_SELECTORS: 'spendFooterSelectors', @@ -10031,6 +10032,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', @@ -10888,9 +10890,11 @@ const CONST = { INSIGHTS: { DASHBOARD: { SPEND: 'spend', + COMPLIANCE: 'compliance', }, SEARCH_KEY: { SPEND: 'insightsSpend', + COMPLIANCE: 'insightsCompliance', }, GRAPH: { SPEND_OVER_TIME: 'spendOverTime', diff --git a/src/languages/en.ts b/src/languages/en.ts index 55a23bbfbd3d..b5a362a68a13 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1310,6 +1310,7 @@ const translations = { }, insightsPage: { viewOnSpend: 'View on Spend', + compliance: 'Compliance', compare: { label: 'Compare', previousPeriod: 'Previous period', diff --git a/src/pages/Insights/InsightsDashboard.tsx b/src/pages/Insights/InsightsDashboard.tsx index 17328cadf28c..a658362bcd9f 100644 --- a/src/pages/Insights/InsightsDashboard.tsx +++ b/src/pages/Insights/InsightsDashboard.tsx @@ -22,11 +22,14 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getInsights} from '@libs/actions/Insights'; +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; + import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {InsightsDashboardID} from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {useIsFocused} from '@react-navigation/native'; import React, {useEffect, useEffectEvent} from 'react'; @@ -38,8 +41,9 @@ import type {InsightsDashboardChart, InsightsDashboardState} from './resolveDash import InsightsChartWidget from './charts/InsightsChartWidget'; import InsightsPageControls from './controls/InsightsPageControls'; -import INSIGHTS_DASHBOARD_SPECS, {getVisibleCharts} from './dashboardSpecs'; +import INSIGHTS_DASHBOARD_SPECS, {getAccessibleDashboards, 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'; @@ -181,21 +185,26 @@ function InsightsDashboardContent({state, headlineChart, supportingCharts, filte } function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { + const styles = useThemeStyles(); + const {pageGutter} = useLayoutSpacing(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const {translate} = useLocalize(); const tabRootScreenWrapperProps = useTabRootScreenWrapperProps(NAVIGATION_TABS.INSIGHTS); const {isOffline} = useNetwork(); const isFocused = useIsFocused(); const {login} = useCurrentUserPersonalDetails(); - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [policies, policiesMetadata] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const {filters, defaultFilters, isResolved, setFilters} = useInsightsFilters(dashboardID); const {isBetaEnabled} = usePermissions(); + const accessibleDashboards = getAccessibleDashboards(policies, login, isBetaEnabled); + const isAccessible = accessibleDashboards.includes(dashboardID); const query = isResolved ? buildInsightsJsonQuery(dashboardID, filters, isBetaEnabled(CONST.BETAS.INSIGHTS_COMPARE)) : undefined; const jsonQuery = query?.jsonQuery; const hash = query?.hash; const requestDashboard = () => { - if (!query || !jsonQuery || hash === undefined || isOffline) { + if (!query || !jsonQuery || hash === undefined || isOffline || !isAccessible) { return; } getInsights(dashboardID, hash, jsonQuery, query.snapshotHashes); @@ -210,7 +219,7 @@ function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { return; } onRequestConditionsChanged(); - }, [dashboardID, jsonQuery, hash, isFocused, isOffline]); + }, [dashboardID, jsonQuery, hash, isFocused, isOffline, isAccessible]); const [dashboard] = useOnyx(`${ONYXKEYS.COLLECTION.INSIGHTS}${dashboardID}_${hash}`); const {headlineChart: headlineSpec, supportingCharts: supportingSpecs} = INSIGHTS_DASHBOARD_SPECS[dashboardID]; @@ -233,6 +242,27 @@ function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { const [headlineChart, ...supportingCharts] = charts; const state = getDashboardState(dashboard, isOffline, charts); + if (isResolved && !isLoadingOnyxValue(policiesMetadata) && !isAccessible) { + return ; + } + + const tabSelector = + accessibleDashboards.length > 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} + + + ) )} ({ + 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..7808649fbabd 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; @@ -37,9 +48,10 @@ type InsightsDashboardSpec = { supportingCharts: InsightsChartSpec[]; }; -const INSIGHTS_DASHBOARD_SPECS: Record = { +const INSIGHTS_DASHBOARD_SPECS = { [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,14 +89,44 @@ const INSIGHTS_DASHBOARD_SPECS: Record; -/** Returns the charts that at least one workspace in scope is eligible for. No selected workspaces means every workspace is in scope. */ +/** Returns the workspaces the dashboard is scoped to. No selected workspaces means every workspace is in scope. */ +function getPoliciesInScope(policies: OnyxCollection, policyIDs: string[]): Policy[] { + return Object.values(policies ?? {}).filter((policy): policy is Policy => !!policy && (policyIDs.length === 0 || policyIDs.includes(policy.id))); +} + +/** Returns the charts that at least one workspace in scope is eligible for. */ function getVisibleCharts(charts: InsightsChartSpec[], policies: OnyxCollection, policyIDs: string[], login: string | undefined): InsightsChartSpec[] { - const policiesInScope = Object.values(policies ?? {}).filter((policy): policy is Policy => !!policy && (policyIDs.length === 0 || policyIDs.includes(policy.id))); + const policiesInScope = getPoliciesInScope(policies, policyIDs); return charts.filter(({isPolicyEligible}) => !isPolicyEligible || policiesInScope.some((policy) => isPolicyEligible(policy, login))); } -export {getVisibleCharts}; -export type {InsightsChartSpec}; +/** + * Returns the dashboards whose beta is enabled and that at least one of the user's workspaces is eligible for, in the order their tabs are shown. + * The workspace filter only scopes a dashboard's data, so it doesn't decide which dashboards the user can open. + */ +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}: InsightsDashboardSpec = INSIGHTS_DASHBOARD_SPECS[dashboardID]; + return (!beta || isBetaEnabled(beta)) && (!isPolicyEligible || allPolicies.some((policy) => !!policy && isPolicyEligible(policy, login))); + }); +} + +export {getAccessibleDashboards, getVisibleCharts}; +export type {InsightsChartSpec, InsightsDashboardSpec}; 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..50c9edf8c7d4 --- /dev/null +++ b/src/pages/Insights/insightsAccess.ts @@ -0,0 +1,13 @@ +/** Predicates that decide which Insights dashboards and charts a workspace makes available to the current user. */ + +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, which admins and auditors can. */ +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/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]); + }); + }); +}); diff --git a/tests/unit/insightsQueriesTest.ts b/tests/unit/insightsQueriesTest.ts index 57a4696723cd..a17420e56e99 100644 --- a/tests/unit/insightsQueriesTest.ts +++ b/tests/unit/insightsQueriesTest.ts @@ -2,6 +2,7 @@ import type {SearchCompareMode} from '@components/Search/types'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import type {InsightsChartSpec} from '@pages/Insights/dashboardSpecs'; import INSIGHTS_DASHBOARD_SPECS from '@pages/Insights/dashboardSpecs'; import type {InsightsFilters} from '@pages/Insights/insightsFilters'; import type {InsightsQuery} from '@pages/Insights/insightsQueries'; @@ -223,7 +224,7 @@ describe('insightsQueries', () => { it('groups a chart that declares no group-by the way the page filters do', () => { // Given the headline chart, which declares no group-by of its own - const {headlineChart} = SPEND_SPEC; + const headlineChart: InsightsChartSpec = SPEND_SPEC.headlineChart; expect(headlineChart.groupBy).toBeUndefined(); // When the page filters group by quarter From 0c8a243123d497d87465cd3d16eca9f7351a25fb Mon Sep 17 00:00:00 2001 From: Olgierd Date: Thu, 22 Oct 2026 17:56:01 +0200 Subject: [PATCH 2/7] Fix knip check --- src/pages/Insights/dashboardSpecs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/Insights/dashboardSpecs.ts b/src/pages/Insights/dashboardSpecs.ts index 7808649fbabd..fe0eb69cacae 100644 --- a/src/pages/Insights/dashboardSpecs.ts +++ b/src/pages/Insights/dashboardSpecs.ts @@ -128,5 +128,5 @@ function getAccessibleDashboards(policies: OnyxCollection, login: string } export {getAccessibleDashboards, getVisibleCharts}; -export type {InsightsChartSpec, InsightsDashboardSpec}; +export type {InsightsChartSpec}; export default INSIGHTS_DASHBOARD_SPECS; From d29d916e429ab936c396376b20c3dfd7946fdbd1 Mon Sep 17 00:00:00 2001 From: Olgierd Date: Thu, 22 Oct 2026 20:28:52 +0200 Subject: [PATCH 3/7] Add some polish --- src/pages/Insights/dashboardSpecs.ts | 11 ++++------- src/pages/Insights/insightsAccess.ts | 4 +--- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/pages/Insights/dashboardSpecs.ts b/src/pages/Insights/dashboardSpecs.ts index fe0eb69cacae..932772003463 100644 --- a/src/pages/Insights/dashboardSpecs.ts +++ b/src/pages/Insights/dashboardSpecs.ts @@ -48,7 +48,7 @@ type InsightsDashboardSpec = { supportingCharts: InsightsChartSpec[]; }; -const INSIGHTS_DASHBOARD_SPECS = { +const INSIGHTS_DASHBOARD_SPECS: Record = { [CONST.INSIGHTS.DASHBOARD.SPEND]: { searchKey: CONST.INSIGHTS.SEARCH_KEY.SPEND, labelKey: 'common.spend', @@ -102,7 +102,7 @@ const INSIGHTS_DASHBOARD_SPECS = { }, supportingCharts: [], }, -} satisfies Record; +}; /** Returns the workspaces the dashboard is scoped to. No selected workspaces means every workspace is in scope. */ function getPoliciesInScope(policies: OnyxCollection, policyIDs: string[]): Policy[] { @@ -115,14 +115,11 @@ function getVisibleCharts(charts: InsightsChartSpec[], policies: OnyxCollection< return charts.filter(({isPolicyEligible}) => !isPolicyEligible || policiesInScope.some((policy) => isPolicyEligible(policy, login))); } -/** - * Returns the dashboards whose beta is enabled and that at least one of the user's workspaces is eligible for, in the order their tabs are shown. - * The workspace filter only scopes a dashboard's data, so it doesn't decide which dashboards the user can open. - */ +/** 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}: InsightsDashboardSpec = INSIGHTS_DASHBOARD_SPECS[dashboardID]; + const {beta, isPolicyEligible} = INSIGHTS_DASHBOARD_SPECS[dashboardID]; return (!beta || isBetaEnabled(beta)) && (!isPolicyEligible || allPolicies.some((policy) => !!policy && isPolicyEligible(policy, login))); }); } diff --git a/src/pages/Insights/insightsAccess.ts b/src/pages/Insights/insightsAccess.ts index 50c9edf8c7d4..dc63410e0b10 100644 --- a/src/pages/Insights/insightsAccess.ts +++ b/src/pages/Insights/insightsAccess.ts @@ -1,10 +1,8 @@ -/** Predicates that decide which Insights dashboards and charts a workspace makes available to the current user. */ - 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, which admins and auditors can. */ +/** 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)); } From e48f65992a575daf7d6dad067567b4cb4221b7ec Mon Sep 17 00:00:00 2001 From: Olgierd Date: Thu, 22 Oct 2026 21:08:54 +0200 Subject: [PATCH 4/7] Fix controls styling --- src/pages/Insights/InsightsTabSelector.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/Insights/InsightsTabSelector.tsx b/src/pages/Insights/InsightsTabSelector.tsx index 7a1424c86d72..11b013cb315f 100644 --- a/src/pages/Insights/InsightsTabSelector.tsx +++ b/src/pages/Insights/InsightsTabSelector.tsx @@ -34,7 +34,8 @@ function InsightsTabSelector({activeDashboardID, dashboardIDs}: InsightsTabSelec return ( Navigation.navigate(ROUTES.INSIGHTS.getRoute(dashboardID))} From 5b46bc0b05f8d6e17e8480508249ef0a4e2aeb77 Mon Sep 17 00:00:00 2001 From: Olgierd Date: Fri, 23 Oct 2026 12:23:21 +0200 Subject: [PATCH 5/7] Insights tab button restores the last dashboard --- .../NavigationTabBar/InsightsTabButton.tsx | 16 ++++++++++++-- .../NavigationTabBar/getInsightsTabRoute.ts | 22 +++++++++++++++++++ .../Navigators/TabNavigator.native.tsx | 2 +- 3 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 src/components/Navigation/NavigationTabBar/getInsightsTabRoute.ts diff --git a/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx b/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx index 0042091fab77..12780786ad57 100644 --- a/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx @@ -1,20 +1,28 @@ import {PressableWithFeedback} from '@components/Pressable'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import clearSelectedText from '@libs/clearSelectedText/clearSelectedText'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import Navigation from '@libs/Navigation/Navigation'; +import navigationRef from '@navigation/navigationRef'; + +import {getAccessibleDashboards} from '@pages/Insights/dashboardSpecs'; + import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; +import ONYXKEYS from '@src/ONYXKEYS'; 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 +35,9 @@ function InsightsTabButton({selectedTab, isWideLayout}: InsightsTabButtonProps) const styles = useThemeStyles(); const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['PieChart']); + const {login} = useCurrentUserPersonalDetails(); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const {isBetaEnabled} = usePermissions(); const isSelected = selectedTab === NAVIGATION_TABS.INSIGHTS; const navigateToInsights = () => { @@ -35,7 +46,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, getAccessibleDashboards(policies, login, isBetaEnabled))); }); }; 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/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() { Date: Fri, 23 Oct 2026 16:42:40 +0200 Subject: [PATCH 6/7] Read accessible Insights dashboards through a policy selector and other small changes --- .../NavigationTabBar/InsightsTabButton.tsx | 14 +++++--------- src/pages/Insights/InsightsDashboard.tsx | 16 ++++------------ src/pages/Insights/InsightsPage.tsx | 14 ++++++++++++-- src/pages/Insights/dashboardSpecs.ts | 9 ++------- src/pages/Insights/useAccessibleDashboards.ts | 16 ++++++++++++++++ tests/unit/insightsQueriesTest.ts | 3 +-- 6 files changed, 40 insertions(+), 32 deletions(-) create mode 100644 src/pages/Insights/useAccessibleDashboards.ts diff --git a/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx b/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx index 12780786ad57..32aed6b33fe2 100644 --- a/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/InsightsTabButton.tsx @@ -1,10 +1,7 @@ import {PressableWithFeedback} from '@components/Pressable'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import clearSelectedText from '@libs/clearSelectedText/clearSelectedText'; @@ -13,10 +10,11 @@ import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@navigation/navigationRef'; -import {getAccessibleDashboards} from '@pages/Insights/dashboardSpecs'; +import useAccessibleDashboards from '@pages/Insights/useAccessibleDashboards'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; +import type {InsightsDashboardID} from '@src/types/onyx'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; import type {ValueOf} from 'type-fest'; @@ -35,9 +33,7 @@ function InsightsTabButton({selectedTab, isWideLayout}: InsightsTabButtonProps) const styles = useThemeStyles(); const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['PieChart']); - const {login} = useCurrentUserPersonalDetails(); - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const {isBetaEnabled} = usePermissions(); + const [accessibleDashboards = getEmptyArray()] = useAccessibleDashboards(); const isSelected = selectedTab === NAVIGATION_TABS.INSIGHTS; const navigateToInsights = () => { @@ -47,7 +43,7 @@ function InsightsTabButton({selectedTab, isWideLayout}: InsightsTabButtonProps) clearSelectedText(); interceptAnonymousUser(() => { const rootState = navigationRef.isReady() ? navigationRef.getRootState() : undefined; - Navigation.navigate(getInsightsTabRoute(rootState, getAccessibleDashboards(policies, login, isBetaEnabled))); + Navigation.navigate(getInsightsTabRoute(rootState, accessibleDashboards)); }); }; diff --git a/src/pages/Insights/InsightsDashboard.tsx b/src/pages/Insights/InsightsDashboard.tsx index 3cfbaccf9222..c71737f2512d 100644 --- a/src/pages/Insights/InsightsDashboard.tsx +++ b/src/pages/Insights/InsightsDashboard.tsx @@ -23,8 +23,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getInsights} from '@libs/actions/Insights'; -import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; - import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -42,7 +40,7 @@ import type {InsightsDashboardChart, InsightsDashboardState} from './resolveDash import InsightsChartWidget from './charts/InsightsChartWidget'; import InsightsPageControls from './controls/InsightsPageControls'; -import INSIGHTS_DASHBOARD_SPECS, {getAccessibleDashboards, getVisibleCharts} from './dashboardSpecs'; +import INSIGHTS_DASHBOARD_SPECS, {getVisibleCharts} from './dashboardSpecs'; import buildInsightsJsonQuery from './insightsQueries'; import InsightsTabSelector from './InsightsTabSelector'; import {getDashboardState, INSIGHTS_DASHBOARD_STATE} from './resolveDashboardState'; @@ -189,7 +187,7 @@ 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(); @@ -201,8 +199,6 @@ function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { const [policies, policiesMetadata] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const {filters, defaultFilters, isResolved, setFilters} = useInsightsFilters(dashboardID); const {isBetaEnabled} = usePermissions(); - const accessibleDashboards = getAccessibleDashboards(policies, login, isBetaEnabled); - const isAccessible = accessibleDashboards.includes(dashboardID); const isCompareEnabled = isBetaEnabled(CONST.BETAS.INSIGHTS_COMPARE); const {headlineChart: headlineSpec, supportingCharts: supportingSpecs} = INSIGHTS_DASHBOARD_SPECS[dashboardID]; @@ -212,7 +208,7 @@ function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { const hash = query?.hash; const requestDashboard = () => { - if (!query || hash === undefined || isOffline || !isAccessible) { + if (!query || hash === undefined || isOffline) { return; } getInsights(dashboardID, hash, query.jsonQuery, query.snapshotHashes); @@ -228,7 +224,7 @@ function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { } onRequestConditionsChanged(); // Leaves out jsonQuery so toggling Compare mode doesn't refetch, since every response covers it. Group by and the eligible charts are listed because the hash leaves them out - }, [dashboardID, hash, filters.groupBy, eligibleGraphKeys, isCompareEnabled, isFocused, isOffline, isAccessible]); + }, [dashboardID, hash, filters.groupBy, eligibleGraphKeys, isCompareEnabled, isFocused, isOffline]); const [dashboard] = useOnyx(`${ONYXKEYS.COLLECTION.INSIGHTS}${dashboardID}_${hash}`); // Until the filters and workspaces load, the charts have no query to read data for, so they show their loading state @@ -249,10 +245,6 @@ function InsightsDashboard({dashboardID}: {dashboardID: InsightsDashboardID}) { const [headlineChart, ...supportingCharts] = charts; const state = getDashboardState(dashboard, isOffline, charts); - if (isResolved && !isLoadingOnyxValue(policiesMetadata) && !isAccessible) { - return ; - } - const tabSelector = accessibleDashboards.length > 1 ? ( ; 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/dashboardSpecs.ts b/src/pages/Insights/dashboardSpecs.ts index 932772003463..b2a61b0f93ae 100644 --- a/src/pages/Insights/dashboardSpecs.ts +++ b/src/pages/Insights/dashboardSpecs.ts @@ -104,14 +104,9 @@ const INSIGHTS_DASHBOARD_SPECS: Record, policyIDs: string[]): Policy[] { - return Object.values(policies ?? {}).filter((policy): policy is Policy => !!policy && (policyIDs.length === 0 || policyIDs.includes(policy.id))); -} - -/** Returns the charts that at least one workspace in scope is eligible for. */ +/** Returns the charts that at least one workspace in scope is eligible for. No selected workspaces means every workspace is in scope. */ function getVisibleCharts(charts: InsightsChartSpec[], policies: OnyxCollection, policyIDs: string[], login: string | undefined): InsightsChartSpec[] { - const policiesInScope = getPoliciesInScope(policies, policyIDs); + const policiesInScope = Object.values(policies ?? {}).filter((policy): policy is Policy => !!policy && (policyIDs.length === 0 || policyIDs.includes(policy.id))); return charts.filter(({isPolicyEligible}) => !isPolicyEligible || policiesInScope.some((policy) => isPolicyEligible(policy, login))); } 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/insightsQueriesTest.ts b/tests/unit/insightsQueriesTest.ts index 16c6c7aaf5d3..7328c6749099 100644 --- a/tests/unit/insightsQueriesTest.ts +++ b/tests/unit/insightsQueriesTest.ts @@ -2,7 +2,6 @@ import type {SearchCompareMode} from '@components/Search/types'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; -import type {InsightsChartSpec} from '@pages/Insights/dashboardSpecs'; import INSIGHTS_DASHBOARD_SPECS from '@pages/Insights/dashboardSpecs'; import type {InsightsFilters} from '@pages/Insights/insightsFilters'; import type {InsightsQuery} from '@pages/Insights/insightsQueries'; @@ -246,7 +245,7 @@ describe('insightsQueries', () => { it('groups a chart that declares no group-by the way the page filters do', () => { // Given the headline chart, which declares no group-by of its own - const headlineChart: InsightsChartSpec = SPEND_SPEC.headlineChart; + const {headlineChart} = SPEND_SPEC; expect(headlineChart.groupBy).toBeUndefined(); // When the page filters group by quarter From 07d8b37d4f1669e4392bb26787be206e1caad44c Mon Sep 17 00:00:00 2001 From: Olgierd Date: Tue, 27 Oct 2026 04:24:25 +0100 Subject: [PATCH 7/7] Request Compliance violations by action dates and keep its snapshots apart from Spend --- src/CONST/index.ts | 1 + .../NativeTabNavigator/TabPressListeners.tsx | 23 ++++- src/pages/Insights/dashboardSpecs.ts | 5 +- src/pages/Insights/insightsQueries.ts | 52 ++++++++-- .../InsightsSection/useHomeInsightConfigs.ts | 18 +++- tests/unit/insightsQueriesTest.ts | 98 +++++++++++++++++++ 6 files changed, 182 insertions(+), 15 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index a28018a46b38..03c7aabb090b 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -10937,6 +10937,7 @@ const CONST = { }, GRAPH: { SPEND_OVER_TIME: 'spendOverTime', + VIOLATIONS_OVER_TIME: 'violationsOverTime', TOP_MERCHANTS: 'topMerchants', TOP_CATEGORIES: 'topCategories', TOP_SPENDERS: 'topSpenders', diff --git a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx index 7e61362931a5..513f8a0b2c1d 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/TabPressListeners.tsx @@ -6,9 +6,14 @@ import useRestoreWorkspacesTabOnNavigate from '@hooks/useRestoreWorkspacesTabOnN import navigationRef from '@libs/Navigation/navigationRef'; +import useAccessibleDashboards from '@pages/Insights/useAccessibleDashboards'; + +import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; import {lastExpensesSearchQuerySelector} from '@src/selectors/SearchFilters'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {CommonActions} from '@react-navigation/native'; import {useEffect} from 'react'; @@ -16,16 +21,18 @@ import {useEffect} from 'react'; import type {NativeTabLayoutProps} from './NativeTabLayout'; /** - * The native bar's Spend and Workspaces handling that needs Onyx data, so the data re-renders only this component. + * The native bar's Spend, Insights and Workspaces handling that needs Onyx data, so the data re-renders only this component. * Spend's first visit opens the latest search kept in Onyx, so the tab mounted at startup with the default search gets * it before it is first shown, both for the native bar and for the side bar of a wide layout. Workspaces restores the - * last workspace, or the list for one that is gone, whenever the tab does not already show it. + * last workspace, or the list for one that is gone, whenever the tab does not already show it. Insights falls back to + * Spend when the dashboard it kept is no longer accessible. */ function TabPressListeners({state, descriptors}: Pick) { const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); const [lastExpensesSearchQuery] = useOnyx(ONYXKEYS.SEARCH_FILTERS, {selector: lastExpensesSearchQuerySelector}); const hasSpendBeenShown = useHasTabBeenShown(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); const navigateToWorkspaces = useRestoreWorkspacesTabOnNavigate(); + const [accessibleDashboards, accessibleDashboardsMetadata] = useAccessibleDashboards(); const getTabRoute = (routeName: string) => state.routes.find((tabRoute) => tabRoute.name === routeName); const spendNavigatorState = getTabRoute(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR)?.state; const spendNavigatorKey = spendNavigatorState?.key; @@ -36,6 +43,11 @@ function TabPressListeners({state, descriptors}: Pick id === insightsDashboardID); useEffect(() => { if (hasSpendBeenShown || !spendNavigatorKey || !spendRootRouteKey || spendRootQuery === storedQuery) { @@ -44,6 +56,13 @@ function TabPressListeners({state, descriptors}: Pick { + if (isInsightsFocused || isInsightsDashboardAccessible || !insightsRouteKey) { + return; + } + navigationRef.dispatch({...CommonActions.setParams({dashboardID: CONST.INSIGHTS.DASHBOARD.SPEND}), source: insightsRouteKey, target: state.key}); + }, [isInsightsFocused, isInsightsDashboardAccessible, insightsRouteKey, state.key]); + // Runs on the focused tab too, where it returns from a workspace to the list. useEffect( () => diff --git a/src/pages/Insights/dashboardSpecs.ts b/src/pages/Insights/dashboardSpecs.ts index b2a61b0f93ae..c9140878e184 100644 --- a/src/pages/Insights/dashboardSpecs.ts +++ b/src/pages/Insights/dashboardSpecs.ts @@ -94,10 +94,9 @@ const INSIGHTS_DASHBOARD_SPECS: Record { +function buildFilterFormValues(filters: InsightsFilters, shouldIncludeDate = true): Partial { return { - ...buildDateFormValues(filters.date), + ...(shouldIncludeDate && buildDateFormValues(filters.date)), groupCurrency: filters.groupCurrency, ...(filters.policyIDs.length > 0 && {policyID: filters.policyIDs}), }; } +/** Compliance periods follow actions, while saved picker selections keep the shared date format. */ +function buildComplianceFilters(filters: InsightsFilters) { + const buildStageFilter = (stage: 'anySubmission' | 'anyApproval') => { + if ('from' in filters.date) { + return { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: {operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, left: stage, right: filters.date.from}, + right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO, left: stage, right: filters.date.to}, + }; + } + + return { + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + left: stage, + right: 'preset' in filters.date ? filters.date.preset : filters.date.on, + }; + }; + const actionDates = { + operator: CONST.SEARCH.SYNTAX_OPERATORS.OR, + left: buildStageFilter('anySubmission'), + right: buildStageFilter('anyApproval'), + }; + const commonFilters = buildSearchQueryJSON(buildQueryStringFromFilterFormValues(buildFilterFormValues(filters, false)))?.filters; + + return commonFilters ? {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: commonFilters, right: actionDates} : actionDates; +} + +/** Charts retain picker dates for labels, but Compliance snapshots must not share Spend's cache keys. */ +function buildDashboardQuery(query: SearchQueryString, dashboard: InsightsDashboardID): Readonly | undefined { + const queryJSON = buildSearchQueryJSON(query); + if (!queryJSON || dashboard === CONST.INSIGHTS.DASHBOARD.SPEND) { + return queryJSON; + } + + return {...queryJSON, hash: hashText(`${dashboard}:${queryJSON.hash}`, 2 ** 32)}; +} + /** Builds the dashboard-wide query the whole page is narrowed by. */ function buildInsightsQueryString(filters: InsightsFilters): SearchQueryString { return buildQueryStringFromFilterFormValues({...buildFilterFormValues(filters), groupBy: filters.groupBy, compare: filters.compare}); @@ -104,7 +142,7 @@ function buildInsightsJsonQuery( ): InsightsQuery | undefined { // The backend saves inputQuery as the dashboard's filters, so it keeps the Group by the user picked, not the one fitted to the date range const inputQuery = buildInsightsQueryString(filters); - const queryJSON = buildSearchQueryJSON(buildInsightsQueryString({...filters, groupBy: getQueryGroupBy(filters), compare: undefined})); + const queryJSON = buildDashboardQuery(buildInsightsQueryString({...filters, groupBy: getQueryGroupBy(filters), compare: undefined}), dashboard); if (!queryJSON) { return undefined; } @@ -112,10 +150,10 @@ function buildInsightsJsonQuery( const spec = INSIGHTS_DASHBOARD_SPECS[dashboard]; const buildChartQuery = (chart: InsightsChartSpec): InsightsChartQuery => ({ chart, - queryJSON: buildSearchQueryJSON(applyInsightsFilters(chart, filters)), + queryJSON: buildDashboardQuery(applyInsightsFilters(chart, filters), dashboard), ...(shouldIncludeComparisons && { - previousPeriodQueryJSON: buildSearchQueryJSON(applyInsightsFilters(chart, filters, CONST.SEARCH.COMPARE.PREVIOUS_PERIOD)), - averageQueryJSON: buildSearchQueryJSON(applyInsightsFilters(chart, filters, CONST.SEARCH.COMPARE.AVERAGE)), + previousPeriodQueryJSON: buildDashboardQuery(applyInsightsFilters(chart, filters, CONST.SEARCH.COMPARE.PREVIOUS_PERIOD), dashboard), + averageQueryJSON: buildDashboardQuery(applyInsightsFilters(chart, filters, CONST.SEARCH.COMPARE.AVERAGE), dashboard), }), }); const headlineChart = buildChartQuery(spec.headlineChart); @@ -129,7 +167,7 @@ function buildInsightsJsonQuery( jsonQuery: JSON.stringify({ hash: queryJSON.hash, groupBy: queryJSON.groupBy, - filters: queryJSON.filters, + filters: dashboard === CONST.INSIGHTS.DASHBOARD.COMPLIANCE ? buildComplianceFilters(filters) : queryJSON.filters, inputQuery, searchKey: spec.searchKey, insightsHashes, diff --git a/src/pages/home/InsightsSection/useHomeInsightConfigs.ts b/src/pages/home/InsightsSection/useHomeInsightConfigs.ts index 43045f86e6e4..9158390183da 100644 --- a/src/pages/home/InsightsSection/useHomeInsightConfigs.ts +++ b/src/pages/home/InsightsSection/useHomeInsightConfigs.ts @@ -19,15 +19,25 @@ import type {InsightsGraphKey} from '@src/types/onyx'; import {defaultExpensifyCardSelector} from '@selectors/Card'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -const HOME_INSIGHT_ICONS: Record = { +// Home only shows Spend charts, which also exist as Search menu items +type HomeInsightGraphKey = Exclude; + +type HomeInsightChartSpec = InsightsChartSpec & {graphKey: HomeInsightGraphKey}; + +const HOME_INSIGHT_ICONS: Record = { [CONST.INSIGHTS.GRAPH.SPEND_OVER_TIME]: 'CalendarSolid', [CONST.INSIGHTS.GRAPH.TOP_SPENDERS]: 'User', [CONST.INSIGHTS.GRAPH.TOP_MERCHANTS]: 'Basket', [CONST.INSIGHTS.GRAPH.TOP_CATEGORIES]: 'Folder', }; +/** Whether the chart is one Home can show. */ +function isHomeInsightChart(chart: InsightsChartSpec): chart is HomeInsightChartSpec { + return chart.graphKey in HOME_INSIGHT_ICONS; +} + /** Builds a Home insight that queries the same chart as the Insights page. */ -function buildInsightConfigFromChart(chart: InsightsChartSpec, filters: InsightsFilters): SearchTypeMenuItem { +function buildInsightConfigFromChart(chart: HomeInsightChartSpec, filters: InsightsFilters): SearchTypeMenuItem { const searchQuery = applyInsightsFilters(chart, filters); const searchQueryJSON = buildSearchQueryJSON(searchQuery); @@ -64,7 +74,9 @@ function useHomeInsightConfigs(): {configs: SearchTypeMenuItem[]; isResolved: bo } const {headlineChart, supportingCharts} = INSIGHTS_DASHBOARD_SPECS[CONST.INSIGHTS.DASHBOARD.SPEND]; - const configs = getVisibleCharts([headlineChart, ...supportingCharts], policies, filters.policyIDs, session?.email).map((chart) => buildInsightConfigFromChart(chart, filters)); + const configs = getVisibleCharts([headlineChart, ...supportingCharts], policies, filters.policyIDs, session?.email) + .filter(isHomeInsightChart) + .map((chart) => buildInsightConfigFromChart(chart, filters)); return {configs, isResolved: areFiltersResolved}; } diff --git a/tests/unit/insightsQueriesTest.ts b/tests/unit/insightsQueriesTest.ts index 7328c6749099..dc0346990291 100644 --- a/tests/unit/insightsQueriesTest.ts +++ b/tests/unit/insightsQueriesTest.ts @@ -17,6 +17,7 @@ const FILTERS: InsightsFilters = { }; const SPEND_SPEC = INSIGHTS_DASHBOARD_SPECS[CONST.INSIGHTS.DASHBOARD.SPEND]; +const COMPLIANCE_SPEC = INSIGHTS_DASHBOARD_SPECS[CONST.INSIGHTS.DASHBOARD.COMPLIANCE]; type InsightsPayload = { hash: number; @@ -72,6 +73,103 @@ describe('insightsQueries', () => { }); }); + it('requests Compliance by submission or approval dates without sharing Spend snapshots', () => { + // Given Spend and Compliance using the same year-to-date picker selections + const spend = buildInsightsJsonQuery(CONST.INSIGHTS.DASHBOARD.SPEND, FILTERS, [], true); + + // When Compliance requests its current, previous, and average periods + const request = buildInsightsJsonQuery(CONST.INSIGHTS.DASHBOARD.COMPLIANCE, FILTERS, [], true); + const compared = buildInsightsJsonQuery(CONST.INSIGHTS.DASHBOARD.COMPLIANCE, {...FILTERS, compare: CONST.SEARCH.COMPARE.PREVIOUS_PERIOD}, [], true); + + // Then both action stages use the same bounded period and all snapshots belong to the Compliance graph + expect(parsePayload(request)).toMatchObject({ + searchKey: CONST.INSIGHTS.SEARCH_KEY.COMPLIANCE, + filters: { + operator: 'and', + left: {operator: 'eq', left: 'groupCurrency', right: 'USD'}, + right: { + operator: 'or', + left: {operator: 'eq', left: 'anySubmission', right: 'year-to-date'}, + right: {operator: 'eq', left: 'anyApproval', right: 'year-to-date'}, + }, + }, + insightsHashes: { + violationsOverTime: { + snapshotHash: request?.headlineChart.queryJSON?.hash, + previousPeriodSnapshotHash: request?.headlineChart.previousPeriodQueryJSON?.hash, + averageSnapshotHash: request?.headlineChart.averageQueryJSON?.hash, + }, + }, + numberOfPeriods: 1, + }); + expect(request?.headlineChart.chart.graphKey).toBe(COMPLIANCE_SPEC.headlineChart.graphKey); + expect(Object.keys(parsePayload(request)?.insightsHashes ?? {})).toEqual(['violationsOverTime']); + expect(new Set(request?.snapshotHashes).size).toBe(3); + expect(request?.hash).not.toBe(spend?.hash); + for (const hash of request?.snapshotHashes ?? []) { + expect(spend?.snapshotHashes).not.toContain(hash); + } + expect(compared?.hash).toBe(request?.hash); + expect(compared?.snapshotHashes).toEqual(request?.snapshotHashes); + expect(parsePayload(request)?.inputQuery).toBe(buildInsightsQueryString(FILTERS)); + }); + + it('keeps each Compliance action range bounded and workspace filters outside the OR', () => { + // Given a custom Compliance period scoped to one workspace, without comparisons + const filters: InsightsFilters = {...FILTERS, policyIDs: ['A1'], date: {from: '2026-01-01', to: '2026-03-31'}}; + + // When its request is built + const request = buildInsightsJsonQuery(CONST.INSIGHTS.DASHBOARD.COMPLIANCE, filters, [], false); + + // Then either stage must satisfy both bounds, with no expense-date constraint or comparison snapshots + expect(parsePayload(request)).toMatchObject({ + filters: { + operator: 'and', + left: { + operator: 'and', + left: {operator: 'eq', left: 'groupCurrency', right: 'USD'}, + right: {operator: 'eq', left: 'policyID', right: 'A1'}, + }, + right: { + operator: 'or', + left: { + operator: 'and', + left: {operator: 'gte', left: 'anySubmission', right: '2026-01-01'}, + right: {operator: 'lte', left: 'anySubmission', right: '2026-03-31'}, + }, + right: { + operator: 'and', + left: {operator: 'gte', left: 'anyApproval', right: '2026-01-01'}, + right: {operator: 'lte', left: 'anyApproval', right: '2026-03-31'}, + }, + }, + }, + insightsHashes: {violationsOverTime: {snapshotHash: request?.headlineChart.queryJSON?.hash}}, + }); + expect(parsePayload(request)).not.toHaveProperty('numberOfPeriods'); + expect(request?.snapshotHashes).toEqual([request?.headlineChart.queryJSON?.hash]); + }); + + it('uses a single selected day for both Compliance action stages', () => { + // Given Compliance narrowed to one day + const filters: InsightsFilters = {...FILTERS, date: {on: '2026-03-04'}}; + + // When its request is built + const request = buildInsightsJsonQuery(CONST.INSIGHTS.DASHBOARD.COMPLIANCE, filters, [], false); + + // Then both stages match that day and the headline uses daily buckets + expect(parsePayload(request)).toMatchObject({ + groupBy: CONST.SEARCH.GROUP_BY.DAY, + filters: { + right: { + operator: 'or', + left: {operator: 'eq', left: 'anySubmission', right: '2026-03-04'}, + right: {operator: 'eq', left: 'anyApproval', right: '2026-03-04'}, + }, + }, + }); + }); + it('requests the same data whether or not the dashboard is compared, saving the Compare mode along', () => { // Given the spend dashboard shown on its own const standalone = buildInsightsJsonQuery(CONST.INSIGHTS.DASHBOARD.SPEND, FILTERS, SPEND_SPEC.supportingCharts, true);