diff --git a/static/app/components/breadcrumbs.stories.tsx b/static/app/components/breadcrumbs.stories.tsx index b2516449ddd364..a85acb0dfb2b89 100644 --- a/static/app/components/breadcrumbs.stories.tsx +++ b/static/app/components/breadcrumbs.stories.tsx @@ -15,8 +15,12 @@ export default Storybook.story('Breadcrumbs', story => { @@ -24,25 +28,6 @@ export default Storybook.story('Breadcrumbs', story => { )); - story('With Last Item Linked', () => ( - -

- Set to make the last - breadcrumb clickable. -

- - - -
- )); - story('Page Filter Preservation', () => (

@@ -87,7 +72,8 @@ export default Storybook.story('Breadcrumbs', story => { ], [ { - label: 'longlonglonglonglonglonglonglonglonglonglonglonglonglonglong', + label: + 'A Very Long Project Name Here That Will Be Truncated Because It Is Too Long', to: '/org/', }, {label: 'Very Long Project Name Here', to: '/project/'}, diff --git a/static/app/components/breadcrumbs.tsx b/static/app/components/breadcrumbs.tsx index 1f31d0f087eb84..8458571338a012 100644 --- a/static/app/components/breadcrumbs.tsx +++ b/static/app/components/breadcrumbs.tsx @@ -1,31 +1,19 @@ import {Fragment} from 'react'; -import type {Theme} from '@emotion/react'; -import {css} from '@emotion/react'; -import styled from '@emotion/styled'; -import {Chevron} from 'sentry/components/chevron'; +import {Container, Flex} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + import type {LinkProps} from 'sentry/components/core/link'; import {Link} from 'sentry/components/core/link'; import GlobalSelectionLink from 'sentry/components/globalSelectionLink'; -import {space} from 'sentry/styles/space'; - -const BreadcrumbList = styled('nav')` - display: flex; - align-items: center; - padding: ${space(1)} 0; -`; +import {IconChevron} from 'sentry/icons'; +import {trackAnalytics} from 'sentry/utils/analytics'; export interface Crumb { /** * Label of the crumb */ - label: React.ReactNode; - - /** - * Component will try to come up with unique key, but you can provide your own - * (used when mapping over crumbs) - */ - key?: string; + label: NonNullable; /** * It will keep the page filter values (projects, environments, time) in the @@ -39,103 +27,98 @@ export interface Crumb { to?: LinkProps['to'] | null; } -interface Props extends React.HTMLAttributes { - /** - * Array of crumbs that will be rendered - */ +interface BreadcrumbsProps extends React.HTMLAttributes { crumbs: Crumb[]; - - /** - * As a general rule of thumb we don't want the last item to be link as it most likely - * points to the same page we are currently on. This is by default false, so that - * people don't have to check if crumb is last in the array and then manually - * assign `to: null/undefined` when passing props to this component. - */ - linkLastItem?: boolean; } /** * Page breadcrumbs used for navigation, not to be confused with sentry's event breadcrumbs */ -export function Breadcrumbs({crumbs, linkLastItem = false, ...props}: Props) { +export function Breadcrumbs({crumbs, ...props}: BreadcrumbsProps) { if (crumbs.length === 0) { return null; } - if (!linkLastItem) { - crumbs[crumbs.length - 1]!.to = null; - } - return ( - + {crumbs.map((crumb, index) => { - const {label, to, preservePageFilters, key} = crumb; - const labelKey = typeof label === 'string' ? label : ''; - const mapKey = - key ?? (typeof to === 'string' ? `${labelKey}${to}` : `${labelKey}${index}`); - return ( - - {to ? ( - - {label} - - ) : ( - {label} - )} - - {index < crumbs.length - 1 && } + + + {index < crumbs.length - 1 ? ( + + + + ) : null} ); })} - + ); } -const getBreadcrumbListItemStyles = (p: {theme: Theme}) => css` - ${p.theme.overflowEllipsis} - color: ${p.theme.subText}; - width: auto; +interface BreadCrumbItemProps { + crumb: Crumb; + variant: 'primary' | 'muted'; +} - &:last-child { - color: ${p.theme.textColor}; +function BreadCrumbItem(props: BreadCrumbItemProps) { + function onBreadcrumbLinkClick() { + if (props.crumb.to) { + trackAnalytics('breadcrumbs.link.clicked', {organization: null}); + } } -`; -interface BreadcrumbLinkProps { - to: LinkProps['to']; + return ( + + {styleProps => { + return props.crumb.to ? ( + + + {props.crumb.label} + + + ) : ( + + {props.crumb.label} + + ); + }} + + ); +} + +interface BreadcrumbLinkProps extends LinkProps { children?: React.ReactNode; preservePageFilters?: boolean; } -const BreadcrumbLink = styled( - ({preservePageFilters, to, ...props}: BreadcrumbLinkProps) => - preservePageFilters ? ( - - ) : ( - - ) -)` - ${getBreadcrumbListItemStyles} - max-width: 400px; - - &:hover, - &:active { - color: ${p => p.theme.subText}; +function BreadcrumbLink(props: BreadcrumbLinkProps) { + const {preservePageFilters, ...rest} = props; + if (preservePageFilters) { + return ; } -`; -const BreadcrumbItem = styled('span')` - ${getBreadcrumbListItemStyles} - max-width: 400px; -`; - -const BreadcrumbDividerIcon = styled(Chevron)` - color: ${p => p.theme.subText}; - margin: 0 ${space(0.5)}; - flex-shrink: 0; -`; + return ; +} diff --git a/static/app/components/chevron.tsx b/static/app/components/chevron.tsx deleted file mode 100644 index 57f5c0afe940d5..00000000000000 --- a/static/app/components/chevron.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import {useTheme} from '@emotion/react'; -import styled from '@emotion/styled'; -import {motion} from 'framer-motion'; - -interface ChevronProps extends React.SVGAttributes { - direction?: 'up' | 'right' | 'down' | 'left'; - /** - * Whether to lighten (by lowering the opacity) the chevron. Useful if the chevron is - * inside a dropdown trigger button. - */ - light?: boolean; - /** - * The size of the checkbox. Defaults to 'sm'. - */ - size?: 'large' | 'medium' | 'small'; - weight?: 'regular' | 'medium'; -} - -const rubikWeightFactor: Record, number> = { - regular: 1, - medium: 1.4, -}; - -function getPath(direction: NonNullable) { - // Base values for a downward chevron - const base = [ - [3.5, 5.5], - [7, 9], - [10.5, 5.5], - ] as const; - - switch (direction) { - case 'right': - // Switch X and Y axis (so `base[0][1]` goes before `base[0][1]`) - return `M${base[0][1]} ${base[0][0]}L${base[1][1]} ${base[1][0]}L${base[2][1]} ${base[2][0]}`; - case 'left': - // Switch X and Y axis, then flip X axis (so 14 - …) - return `M${14 - base[0][1]} ${base[0][0]}L${14 - base[1][1]} ${base[1][0]}L${14 - base[2][1]} ${base[2][0]}`; - case 'up': - // Flip Y axis (so 14 - …) - return `M${base[0][0]} ${14 - base[0][1]}L${base[1][0]} ${14 - base[1][1]}L${base[2][0]} ${14 - base[2][1]}`; - case 'down': - default: - return `M${base[0][0]} ${base[0][1]}L${base[1][0]} ${base[1][1]}L${base[2][0]} ${base[2][1]}`; - } -} - -function Chevron({ - size = 'medium', - weight = 'regular', - direction = 'down', - light = false, - ...props -}: ChevronProps) { - const theme = useTheme(); - return ( - - - - ); -} - -const VariableWeightIcon = styled('svg')<{size: string; weightFactor: number}>` - width: ${p => p.size}; - height: ${p => p.size}; - - fill: none; - stroke: currentColor; - stroke-linecap: round; - stroke-linejoin: round; - stroke-width: calc(${p => p.size} * 0.0875 * ${p => p.weightFactor}); -`; - -export {Chevron}; diff --git a/static/app/utils/analytics.tsx b/static/app/utils/analytics.tsx index f81190492e8e4c..9cf3b07d6c5fa6 100644 --- a/static/app/utils/analytics.tsx +++ b/static/app/utils/analytics.tsx @@ -40,6 +40,8 @@ import { import type {AgentMonitoringEventParameters} from './analytics/agentMonitoringAnalyticsEvents'; import {agentMonitoringEventMap} from './analytics/agentMonitoringAnalyticsEvents'; +import type {BreadcrumbsAnalyticsEventParameters} from './analytics/breadcrumbsAnalyticsEvents'; +import {breadcrumbsAnalyticsEventMap} from './analytics/breadcrumbsAnalyticsEvents'; import type {CoreUIEventParameters} from './analytics/coreuiAnalyticsEvents'; import {coreUIEventMap} from './analytics/coreuiAnalyticsEvents'; import type {DashboardsEventParameters} from './analytics/dashboardsAnalyticsEvents'; @@ -98,6 +100,7 @@ interface EventParameters extends GrowthEventParameters, AgentMonitoringEventParameters, AlertsEventParameters, + BreadcrumbsAnalyticsEventParameters, CoreUIEventParameters, DashboardsEventParameters, DiscoverEventParameters, @@ -135,6 +138,7 @@ interface EventParameters const allEventMap: Record = { ...agentMonitoringEventMap, ...alertsEventMap, + ...breadcrumbsAnalyticsEventMap, ...coreUIEventMap, ...dashboardsEventMap, ...discoverEventMap, diff --git a/static/app/utils/analytics/breadcrumbsAnalyticsEvents.tsx b/static/app/utils/analytics/breadcrumbsAnalyticsEvents.tsx new file mode 100644 index 00000000000000..2c271357ac326d --- /dev/null +++ b/static/app/utils/analytics/breadcrumbsAnalyticsEvents.tsx @@ -0,0 +1,12 @@ +export type BreadcrumbsAnalyticsEventParameters = { + 'breadcrumbs.link.clicked': {organization: null}; + 'breadcrumbs.menu.opened': {organization: null}; +}; + +export const breadcrumbsAnalyticsEventMap: Record< + keyof BreadcrumbsAnalyticsEventParameters, + string | null +> = { + 'breadcrumbs.link.clicked': 'Breadcrumbs: Link Clicked', + 'breadcrumbs.menu.opened': 'Breadcrumbs: Menu Opened', +}; diff --git a/static/app/views/discover/landing.spec.tsx b/static/app/views/discover/landing.spec.tsx index 74562fcf2def8b..c05ee114b0b567 100644 --- a/static/app/views/discover/landing.spec.tsx +++ b/static/app/views/discover/landing.spec.tsx @@ -116,7 +116,7 @@ describe('Discover > Landing', () => { ); - expect(await screen.findByText('Discover')).toHaveAttribute( + expect(await screen.findByRole('link', {name: 'Discover'})).toHaveAttribute( 'href', '/organizations/org-slug/explore/discover/homepage/' ); diff --git a/static/app/views/discover/landing.tsx b/static/app/views/discover/landing.tsx index ad11f8a7e8e4b4..d7a1c713bdef90 100644 --- a/static/app/views/discover/landing.tsx +++ b/static/app/views/discover/landing.tsx @@ -187,12 +187,10 @@ function DiscoverLanding() { { expect(measurementsMetaMock).toHaveBeenCalled(); }); - expect(screen.getByText('Discover')).toHaveAttribute( + expect(screen.getByRole('link', {name: 'Discover'})).toHaveAttribute( 'href', expect.stringMatching( new RegExp('^/organizations/org-slug/explore/discover/homepage/') diff --git a/static/app/views/preprod/buildComparison/header/buildCompareHeaderContent.tsx b/static/app/views/preprod/buildComparison/header/buildCompareHeaderContent.tsx index 92813e99c71226..21bea258819e36 100644 --- a/static/app/views/preprod/buildComparison/header/buildCompareHeaderContent.tsx +++ b/static/app/views/preprod/buildComparison/header/buildCompareHeaderContent.tsx @@ -37,14 +37,14 @@ export function BuildCompareHeaderContent(props: BuildCompareHeaderContentProps) const breadcrumbs: Crumb[] = [ { to: '#', - label: 'Releases', + label: t('Releases'), }, { to: `/organizations/${organization.slug}/preprod/${projectId}/${buildDetails.id}/`, - label: buildDetails.app_info.version, + label: buildDetails.app_info.version ?? t('Build Version'), }, { - label: 'Compare', + label: t('Compare'), }, ]; diff --git a/static/app/views/replays/detail/header/replayDetailsPageBreadcrumbs.tsx b/static/app/views/replays/detail/header/replayDetailsPageBreadcrumbs.tsx index 626e803804b67f..242e1eec305f24 100644 --- a/static/app/views/replays/detail/header/replayDetailsPageBreadcrumbs.tsx +++ b/static/app/views/replays/detail/header/replayDetailsPageBreadcrumbs.tsx @@ -69,7 +69,9 @@ export default function ReplayDetailsPageBreadcrumbs({readerResult}: Props) { }, label: project ? ( - ) : null, + ) : ( + t('Project') + ), }; const replayCrumb = { diff --git a/static/app/views/settings/components/settingsBreadcrumb/index.tsx b/static/app/views/settings/components/settingsBreadcrumb/index.tsx index 47a39973f26cc5..e7a01d373aea67 100644 --- a/static/app/views/settings/components/settingsBreadcrumb/index.tsx +++ b/static/app/views/settings/components/settingsBreadcrumb/index.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; +import {trackAnalytics} from 'sentry/utils/analytics'; import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes'; import recreateRoute from 'sentry/utils/recreateRoute'; @@ -31,6 +32,10 @@ function SettingsBreadcrumb({className, routes, params}: Props) { const lastRouteIndex = routes.map(r => !!r.name).lastIndexOf(true); + function onSettingsBreadcrumbLinkClick() { + trackAnalytics('breadcrumbs.link.clicked', {organization: null}); + } + return ( {routes.map((route, i) => { @@ -54,7 +59,10 @@ function SettingsBreadcrumb({className, routes, params}: Props) { } return ( - + {pathTitle || route.name} {isLast ? null : } diff --git a/static/app/views/settings/components/settingsBreadcrumb/organizationCrumb.tsx b/static/app/views/settings/components/settingsBreadcrumb/organizationCrumb.tsx index 2eb46fefef64dc..e96c20cb3e6ecd 100644 --- a/static/app/views/settings/components/settingsBreadcrumb/organizationCrumb.tsx +++ b/static/app/views/settings/components/settingsBreadcrumb/organizationCrumb.tsx @@ -6,6 +6,7 @@ import IdBadge from 'sentry/components/idBadge'; import {t} from 'sentry/locale'; import OrganizationsStore from 'sentry/stores/organizationsStore'; import {useLegacyStore} from 'sentry/stores/useLegacyStore'; +import {trackAnalytics} from 'sentry/utils/analytics'; import recreateRoute from 'sentry/utils/recreateRoute'; import {resolveRoute} from 'sentry/utils/resolveRoute'; import {testableWindowLocation} from 'sentry/utils/testableWindowLocation'; @@ -76,6 +77,11 @@ function OrganizationCrumb({routes, route, ...props}: SettingsBreadcrumbProps) { } onCrumbSelect={handleSelect} + onOpenChange={open => { + if (open) { + trackAnalytics('breadcrumbs.menu.opened', {organization: null}); + } + }} hasMenu={hasMenu} route={route} value={organization.slug} diff --git a/static/app/views/settings/components/settingsBreadcrumb/projectCrumb.tsx b/static/app/views/settings/components/settingsBreadcrumb/projectCrumb.tsx index 0f7689eb1ebe55..6f10e1638e6ab6 100644 --- a/static/app/views/settings/components/settingsBreadcrumb/projectCrumb.tsx +++ b/static/app/views/settings/components/settingsBreadcrumb/projectCrumb.tsx @@ -4,6 +4,7 @@ import {ProjectAvatar} from 'sentry/components/core/avatar/projectAvatar'; import IdBadge from 'sentry/components/idBadge'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import {space} from 'sentry/styles/space'; +import {trackAnalytics} from 'sentry/utils/analytics'; import recreateRoute from 'sentry/utils/recreateRoute'; import replaceRouterParams from 'sentry/utils/replaceRouterParams'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -65,6 +66,11 @@ function ProjectCrumb({routes, route, ...props}: SettingsBreadcrumbProps) { } value={activeProject?.slug ?? ''} onCrumbSelect={handleSelect} + onOpenChange={open => { + if (open) { + trackAnalytics('breadcrumbs.menu.opened', {organization: null}); + } + }} onSearch={onSearch} options={projects.map(project => ({ value: project.slug, diff --git a/static/app/views/settings/components/settingsBreadcrumb/teamCrumb.tsx b/static/app/views/settings/components/settingsBreadcrumb/teamCrumb.tsx index 6c210a254a90eb..81b6f011443b55 100644 --- a/static/app/views/settings/components/settingsBreadcrumb/teamCrumb.tsx +++ b/static/app/views/settings/components/settingsBreadcrumb/teamCrumb.tsx @@ -1,6 +1,7 @@ import {TeamAvatar} from 'sentry/components/core/avatar/teamAvatar'; import IdBadge from 'sentry/components/idBadge'; import {t} from 'sentry/locale'; +import {trackAnalytics} from 'sentry/utils/analytics'; import recreateRoute from 'sentry/utils/recreateRoute'; import {useNavigate} from 'sentry/utils/useNavigate'; import {useParams} from 'sentry/utils/useParams'; @@ -38,6 +39,11 @@ function TeamCrumb({routes, route, ...props}: SettingsBreadcrumbProps) { }) ); }} + onOpenChange={open => { + if (open) { + trackAnalytics('breadcrumbs.menu.opened', {organization: null}); + } + }} hasMenu={hasMenu} route={route} value={team.slug}