Repository navigation
[NoQA][Insights] Add Compliance dashboard navigation and access rules #104590
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
fccfd3e
63eb26e
9700655
0c8a243
d29d916
e48f659
5b46bc0
1dc9d4b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; |
| 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; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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', | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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: Useful? React with 👍 / 👎.
Comment on lines
+99
to
+101
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 You can apply this patch to fix the issue |
||
| }, | ||
| supportingCharts: [], | ||
| }, | ||
| }; | ||
|
|
||
| /** Returns the charts that at least one workspace in scope is eligible for. No selected workspaces means every workspace is in scope. */ | ||
|
|
@@ -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; | ||
| 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}; |
| 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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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: compliancehere is still restored on the next Insights tab press. I checkedNativeTabNavigator/tabScreenListeners.ts: the Insights handler only performs anonymous interception and never runsgetInsightsTabRoute, soInsightsPagerenders 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 👍 / 👎.