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
30 changes: 8 additions & 22 deletions static/app/components/breadcrumbs.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,34 +15,19 @@ export default Storybook.story('Breadcrumbs', story => {
<Breadcrumbs
crumbs={[
{label: 'Organization', to: '/organizations/sentry/'},
{label: 'Projects', to: '/organizations/sentry/projects/'},
{label: 'Project Settings', to: '/settings/projects/javascript/'},
{label: 'Projects'},
{
label: 'Project Settings',
to: '/settings/projects/javascript/',
preservePageFilters: true,
},
{label: 'General', to: null},
]}
/>
</Storybook.SizingWindow>
</Fragment>
));

story('With Last Item Linked', () => (
<Fragment>
<p>
Set <Storybook.JSXProperty name="linkLastItem" value /> to make the last
breadcrumb clickable.
</p>
<Storybook.SizingWindow display="block">
<Breadcrumbs
linkLastItem
crumbs={[
{label: 'Organization', to: '/organizations/sentry/'},
{label: 'Projects', to: '/organizations/sentry/projects/'},
{label: 'All Projects', to: '/organizations/sentry/projects/all/'},
]}
/>
</Storybook.SizingWindow>
</Fragment>
));

story('Page Filter Preservation', () => (
<Fragment>
<p>
Expand Down Expand Up @@ -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/'},
Expand Down
162 changes: 75 additions & 87 deletions static/app/components/breadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -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<React.ReactNode>;

/**
* It will keep the page filter values (projects, environments, time) in the
Expand All @@ -39,103 +27,103 @@ export interface Crumb {
to?: LinkProps['to'] | null;
}

interface Props extends React.HTMLAttributes<HTMLDivElement> {
/**
* Array of crumbs that will be rendered
*/
interface BreadcrumbsProps extends React.HTMLAttributes<HTMLDivElement> {
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) {
if (crumbs[crumbs.length - 1]?.to) {
// We should not be mutating the crumbs
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: Immutability Broken: Props Mutated Directly

The code directly mutates the crumbs prop by setting crumbs[crumbs.length - 1]!.to = null, despite the comment explicitly stating "We should not be mutating the crumbs". This violates React's principle of immutability and can cause bugs if the same crumbs array is reused elsewhere or if the parent component re-renders with the same reference.

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't make the rules here buddy, this existed before

crumbs[crumbs.length - 1]!.to = null;
}

return (
<BreadcrumbList {...props} data-test-id="breadcrumb-list">
<Flex
as="nav"
gap="xs"
align="center"
padding="md 0"
data-test-id="breadcrumb-list"
{...props}
>
{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 (
<Fragment key={mapKey}>
{to ? (
<BreadcrumbLink
to={to}
preservePageFilters={preservePageFilters}
data-test-id="breadcrumb-link"
>
{label}
</BreadcrumbLink>
) : (
<BreadcrumbItem data-test-id="breadcrumb-item">{label}</BreadcrumbItem>
)}

{index < crumbs.length - 1 && <BreadcrumbDividerIcon direction="right" />}
<Fragment key={index}>
<BreadCrumbItem
crumb={crumb}
variant={index === crumbs.length - 1 ? 'primary' : 'muted'}
/>
{index < crumbs.length - 1 ? (
<Flex align="center" justify="center" flexShrink={0}>
<IconChevron size="xs" direction="right" color="subText" />
</Flex>
) : null}
</Fragment>
);
})}
</BreadcrumbList>
</Flex>
);
}

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 (
<Container maxWidth="400px" width="auto">
{styleProps => {
return props.crumb.to ? (
<BreadcrumbLink
to={props.crumb.to}
preservePageFilters={props.crumb.preservePageFilters}
data-test-id="breadcrumb-link"
onClick={onBreadcrumbLinkClick}
{...styleProps}
>
<Text ellipsis variant={props.variant}>
{props.crumb.label}
</Text>
</BreadcrumbLink>
) : (
<Text
ellipsis
variant={props.variant}
data-test-id="breadcrumb-item"
{...styleProps}
>
{props.crumb.label}
</Text>
);
}}
</Container>
);
}

interface BreadcrumbLinkProps extends LinkProps {
children?: React.ReactNode;
preservePageFilters?: boolean;
}

const BreadcrumbLink = styled(
({preservePageFilters, to, ...props}: BreadcrumbLinkProps) =>
preservePageFilters ? (
<GlobalSelectionLink to={to} {...props} />
) : (
<Link to={to} {...props} />
)
)`
${getBreadcrumbListItemStyles}
max-width: 400px;

&:hover,
&:active {
color: ${p => p.theme.subText};
function BreadcrumbLink(props: BreadcrumbLinkProps) {
const {preservePageFilters, ...rest} = props;
if (preservePageFilters) {
return <GlobalSelectionLink {...rest} />;
}
`;

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 <Link {...rest} />;
}
90 changes: 0 additions & 90 deletions static/app/components/chevron.tsx

This file was deleted.

4 changes: 4 additions & 0 deletions static/app/utils/analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -98,6 +100,7 @@ interface EventParameters
extends GrowthEventParameters,
AgentMonitoringEventParameters,
AlertsEventParameters,
BreadcrumbsAnalyticsEventParameters,
CoreUIEventParameters,
DashboardsEventParameters,
DiscoverEventParameters,
Expand Down Expand Up @@ -135,6 +138,7 @@ interface EventParameters
const allEventMap: Record<string, string | null> = {
...agentMonitoringEventMap,
...alertsEventMap,
...breadcrumbsAnalyticsEventMap,
...coreUIEventMap,
...dashboardsEventMap,
...discoverEventMap,
Expand Down
12 changes: 12 additions & 0 deletions static/app/utils/analytics/breadcrumbsAnalyticsEvents.tsx
Original file line number Diff line number Diff line change
@@ -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',
};
2 changes: 1 addition & 1 deletion static/app/views/discover/landing.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe('Discover > Landing', () => {
</OrganizationContext>
);

expect(await screen.findByText('Discover')).toHaveAttribute(
expect(await screen.findByRole('link', {name: 'Discover'})).toHaveAttribute(
'href',
'/organizations/org-slug/explore/discover/homepage/'
);
Expand Down
Loading
Loading