diff --git a/static/app/components/nav/config.tsx b/static/app/components/nav/config.tsx new file mode 100644 index 0000000000000..27504b725927b --- /dev/null +++ b/static/app/components/nav/config.tsx @@ -0,0 +1,174 @@ +import type {NavConfig} from 'sentry/components/nav/utils'; +import { + IconDashboard, + IconGraph, + IconIssues, + IconLightning, + IconProject, + IconSearch, + IconSettings, + IconSiren, +} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import type {Organization} from 'sentry/types/organization'; +import {getDiscoverLandingUrl} from 'sentry/utils/discover/urls'; +import {MODULE_BASE_URLS} from 'sentry/views/insights/common/utils/useModuleURL'; +import {MODULE_SIDEBAR_TITLE as MODULE_TITLE_HTTP} from 'sentry/views/insights/http/settings'; +import {INSIGHTS_BASE_URL, MODULE_TITLES} from 'sentry/views/insights/settings'; +import {getSearchForIssueGroup, IssueGroup} from 'sentry/views/issueList/utils'; + +/** + * Global nav settings for all Sentry users. + * Links are generated per-organization with the proper `/organization/:slug/` prefix. + * + * To permission-gate certain items, include props to be passed to the `` component + */ +export function createNavConfig({organization}: {organization: Organization}): NavConfig { + const prefix = `organizations/${organization.slug}`; + const insightsPrefix = `${prefix}/${INSIGHTS_BASE_URL}`; + + return { + main: [ + { + label: t('Issues'), + icon: , + submenu: [ + { + label: t('All'), + to: `/${prefix}/issues/?query=is:unresolved`, + }, + { + label: t('Error & Outage'), + to: `/${prefix}/issues/${getSearchForIssueGroup(IssueGroup.ERROR_OUTAGE)}`, + }, + { + label: t('Trend'), + to: `/${prefix}/issues/${getSearchForIssueGroup(IssueGroup.TREND)}`, + }, + { + label: t('Craftsmanship'), + to: `/${prefix}/issues/${getSearchForIssueGroup(IssueGroup.CRAFTSMANSHIP)}`, + }, + { + label: t('Security'), + to: `/${prefix}/issues/${getSearchForIssueGroup(IssueGroup.SECURITY)}`, + }, + {label: t('Feedback'), to: `/${prefix}/feedback/`}, + ], + }, + {label: t('Projects'), to: `/${prefix}/projects/`, icon: }, + { + label: t('Explore'), + icon: , + submenu: [ + { + label: t('Traces'), + to: `/${prefix}/traces/`, + feature: {features: 'performance-trace-explorer'}, + }, + { + label: t('Metrics'), + to: `/${prefix}/metrics/`, + feature: {features: 'custom-metrics'}, + }, + { + label: t('Profiles'), + to: `/${prefix}/profiling/`, + feature: { + features: 'profiling', + hookName: 'feature-disabled:profiling-sidebar-item', + requireAll: false, + }, + }, + { + label: t('Replays'), + to: `/${prefix}/replays/`, + feature: { + features: 'session-replay-ui', + hookName: 'feature-disabled:replay-sidebar-item', + }, + }, + { + label: t('Discover'), + to: getDiscoverLandingUrl(organization), + feature: { + features: 'discover-basic', + hookName: 'feature-disabled:discover2-sidebar-item', + }, + }, + {label: t('Releases'), to: `/${prefix}/releases/`}, + {label: t('Crons'), to: `/${prefix}/crons/`}, + ], + }, + { + label: t('Insights'), + icon: , + feature: {features: 'insights-entry-points'}, + submenu: [ + { + label: MODULE_TITLE_HTTP, + to: `/${insightsPrefix}/${MODULE_BASE_URLS.http}/`, + }, + {label: MODULE_TITLES.db, to: `/${insightsPrefix}/${MODULE_BASE_URLS.db}/`}, + { + label: MODULE_TITLES.resource, + to: `/${insightsPrefix}/${MODULE_BASE_URLS.resource}/`, + }, + { + label: MODULE_TITLES.app_start, + to: `/${insightsPrefix}/${MODULE_BASE_URLS.app_start}/`, + }, + { + label: MODULE_TITLES['mobile-screens'], + to: `/${insightsPrefix}/${MODULE_BASE_URLS['mobile-screens']}/`, + feature: {features: 'insights-mobile-screens-module'}, + }, + { + label: MODULE_TITLES.vital, + to: `/${insightsPrefix}/${MODULE_BASE_URLS.vital}/`, + }, + { + label: MODULE_TITLES.cache, + to: `/${insightsPrefix}/${MODULE_BASE_URLS.cache}/`, + }, + { + label: MODULE_TITLES.queue, + to: `/${insightsPrefix}/${MODULE_BASE_URLS.queue}/`, + }, + { + label: MODULE_TITLES.ai, + to: `/${insightsPrefix}/${MODULE_BASE_URLS.ai}/`, + feature: {features: 'insights-entry-points'}, + }, + ], + }, + { + label: t('Perf.'), + to: '/performance/', + icon: , + feature: { + features: 'performance-view', + hookName: 'feature-disabled:performance-sidebar-item', + }, + }, + { + label: t('Boards'), + to: '/dashboards/', + icon: , + feature: { + features: ['discover', 'discover-query', 'dashboards-basic', 'dashboards-edit'], + hookName: 'feature-disabled:dashboards-sidebar-item', + requireAll: false, + }, + }, + {label: t('Alerts'), to: `/${prefix}/alerts/rules/`, icon: }, + ], + footer: [ + { + label: t('Settings'), + to: `/settings/${organization.slug}/`, + icon: , + }, + ], + }; +} diff --git a/static/app/components/nav/context.tsx b/static/app/components/nav/context.tsx new file mode 100644 index 0000000000000..33a99ecf0d979 --- /dev/null +++ b/static/app/components/nav/context.tsx @@ -0,0 +1,61 @@ +import {createContext, useContext, useMemo} from 'react'; + +import {createNavConfig} from 'sentry/components/nav/config'; +import type { + NavConfig, + NavItemLayout, + NavSidebarItem, + NavSubmenuItem, +} from 'sentry/components/nav/utils'; +import {isNavItemActive, isSubmenuItemActive} from 'sentry/components/nav/utils'; +import {useLocation} from 'sentry/utils/useLocation'; +import useOrganization from 'sentry/utils/useOrganization'; + +export interface NavContext { + /** Raw config for entire nav items */ + config: Readonly; + /** Currently active submenu items, if any */ + submenu?: Readonly>; +} + +const NavContext = createContext({config: {main: []}}); + +export function useNavContext(): NavContext { + const navContext = useContext(NavContext); + return navContext; +} + +export function NavContextProvider({children}) { + const organization = useOrganization(); + const location = useLocation(); + /** Raw nav configuration values */ + const config = useMemo(() => createNavConfig({organization}), [organization]); + /** + * Active submenu items derived from the nav config and current `location`. + * These are returned in a normalized layout format for ease of use. + */ + const submenu = useMemo(() => { + for (const item of config.main) { + if (isNavItemActive(item, location) || isSubmenuItemActive(item, location)) { + return normalizeSubmenu(item.submenu); + } + } + if (config.footer) { + for (const item of config.footer) { + if (isNavItemActive(item, location) || isSubmenuItemActive(item, location)) { + return normalizeSubmenu(item.submenu); + } + } + } + return undefined; + }, [config, location]); + + return {children}; +} + +const normalizeSubmenu = (submenu: NavSidebarItem['submenu']): NavContext['submenu'] => { + if (Array.isArray(submenu)) { + return {main: submenu}; + } + return submenu; +}; diff --git a/static/app/components/nav/index.spec.tsx b/static/app/components/nav/index.spec.tsx new file mode 100644 index 0000000000000..0ca7e1efb43bd --- /dev/null +++ b/static/app/components/nav/index.spec.tsx @@ -0,0 +1,168 @@ +import {LocationFixture} from 'sentry-fixture/locationFixture'; +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {RouterFixture} from 'sentry-fixture/routerFixture'; + +import {getAllByRole, render, screen} from 'sentry-test/reactTestingLibrary'; + +import Nav from 'sentry/components/nav'; + +const ALL_AVAILABLE_FEATURES = [ + 'insights-entry-points', + 'discover', + 'discover-basic', + 'discover-query', + 'dashboards-basic', + 'dashboards-edit', + 'custom-metrics', + 'user-feedback-ui', + 'session-replay-ui', + 'performance-view', + 'performance-trace-explorer', + 'starfish-mobile-ui-module', + 'profiling', +]; + +describe('Nav', function () { + describe('default', function () { + beforeEach(() => { + render(