Skip to content

Commit

Permalink
feat(assignee-selector): Create storybook component for new assignee …
Browse files Browse the repository at this point in the history
…selector trigger (#70561)

This PR creates a new component `<AssigneeBadge/>` which will eventually
replace the current assignee selector trigger in the issue stream. This
PR does **not** make any changes to the selector, it merely creates the
component and a corresponding storybook entry so it can be iterated on
independent of any changes made to the issue stream.

More details of the project can be found
[here](#69827) (#69827)

At a glance (as of 5/9):
<img width="206" alt="image"
src="https://github.com/getsentry/sentry/assets/55160142/9da5fa59-4cc0-4dcf-be27-b7fcc5c686e3">

Note that unlike the previous assignee selector, there is no longer a
"Suggested Assignee" state for this new assignee selector, there is only
an Assigned and Unassigned state.

TODO:
- [x] Figure out what the loading state looks like
- [x] Chevron weight and style match border
- [x] Add tests
  • Loading branch information
MichaelSun48 committed May 10, 2024
1 parent 5a883ee commit d17225a
Show file tree
Hide file tree
Showing 2 changed files with 262 additions and 0 deletions.
100 changes: 100 additions & 0 deletions static/app/components/assigneeBadge.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import {Fragment, useState} from 'react';

import {AssigneeBadge} from 'sentry/components/assigneeBadge';
import storyBook from 'sentry/stories/storyBook';
import type {Actor} from 'sentry/types';
import {useUser} from 'sentry/utils/useUser';
import {useUserTeams} from 'sentry/utils/useUserTeams';

export default storyBook(AssigneeBadge, story => {
story('User Assignee', () => {
const user = useUser();
const [chevron1Toggle, setChevron1Toggle] = useState<'up' | 'down'>('down');
const [chevron2Toggle, setChevron2Toggle] = useState<'up' | 'down'>('down');
const userActor: Actor = {
type: 'user',
id: user.id,
name: user.name,
email: user.email,
};

return (
<Fragment>
<p onClick={() => setChevron1Toggle(chevron1Toggle === 'up' ? 'down' : 'up')}>
<AssigneeBadge assignedTo={userActor} chevronDirection={chevron1Toggle} />
</p>
<p onClick={() => setChevron2Toggle(chevron2Toggle === 'up' ? 'down' : 'up')}>
<AssigneeBadge
showLabel
assignedTo={userActor}
chevronDirection={chevron2Toggle}
/>
</p>
</Fragment>
);
});

story('Team Assignee', () => {
const {teams} = useUserTeams();
const [chevron1Toggle, setChevron1Toggle] = useState<'up' | 'down'>('down');
const [chevron2Toggle, setChevron2Toggle] = useState<'up' | 'down'>('down');

const teamActor: Actor = {
type: 'team',
id: teams[0].id,
name: teams[0].name,
};

return (
<Fragment>
<p onClick={() => setChevron1Toggle(chevron1Toggle === 'up' ? 'down' : 'up')}>
<AssigneeBadge
assignmentReason="suspectCommit"
assignedTo={teamActor}
chevronDirection={chevron1Toggle}
/>
</p>
<p onClick={() => setChevron2Toggle(chevron2Toggle === 'up' ? 'down' : 'up')}>
<AssigneeBadge
assignedTo={teamActor}
showLabel
chevronDirection={chevron2Toggle}
/>
</p>
</Fragment>
);
});

story('Unassigned', () => {
const [chevron1Toggle, setChevron1Toggle] = useState<'up' | 'down'>('down');
const [chevron2Toggle, setChevron2Toggle] = useState<'up' | 'down'>('down');

return (
<Fragment>
<p onClick={() => setChevron1Toggle(chevron1Toggle === 'up' ? 'down' : 'up')}>
<AssigneeBadge assignedTo={undefined} chevronDirection={chevron1Toggle} />
</p>
<p onClick={() => setChevron2Toggle(chevron2Toggle === 'up' ? 'down' : 'up')}>
<AssigneeBadge
showLabel
assignedTo={undefined}
chevronDirection={chevron2Toggle}
/>
</p>
</Fragment>
);
});

story('Loading', () => {
return (
<Fragment>
<p>
<AssigneeBadge loading />
</p>
<p>
<AssigneeBadge showLabel loading />
</p>
</Fragment>
);
});
});
162 changes: 162 additions & 0 deletions static/app/components/assigneeBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import {Fragment} from 'react';
import styled from '@emotion/styled';

import ActorAvatar from 'sentry/components/avatar/actorAvatar';
import Tag from 'sentry/components/badge/tag';
import {Chevron} from 'sentry/components/chevron';
import ExternalLink from 'sentry/components/links/externalLink';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import Placeholder from 'sentry/components/placeholder';
import {Tooltip} from 'sentry/components/tooltip';
import {t, tct} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Actor, SuggestedOwnerReason} from 'sentry/types';
import {lightTheme as theme} from 'sentry/utils/theme';

type AssigneeBadgeProps = {
assignedTo?: Actor | undefined;
assignmentReason?: SuggestedOwnerReason;
chevronDirection?: 'up' | 'down';
loading?: boolean;
showLabel?: boolean;
};

const AVATAR_SIZE = 16;

export function AssigneeBadge({
assignedTo,
assignmentReason,
showLabel = false,
chevronDirection = 'down',
loading = false,
}: AssigneeBadgeProps) {
const suggestedReasons: Record<SuggestedOwnerReason, React.ReactNode> = {
suspectCommit: tct('Based on [commit:commit data]', {
commit: (
<TooltipSubExternalLink href="https://docs.sentry.io/product/sentry-basics/integrate-frontend/configure-scms/" />
),
}),
ownershipRule: t('Matching Issue Owners Rule'),
projectOwnership: t('Matching Issue Owners Rule'),
codeowners: t('Matching Codeowners Rule'),
};

const makeAssignedIcon = (actor: Actor) => {
return (
<Fragment>
<ActorAvatar
actor={actor}
className="avatar"
size={AVATAR_SIZE}
hasTooltip={false}
data-test-id="assigned-avatar"
// Team avatars need extra left margin since the
// square team avatar is being fit into a rounded borders
style={{
marginLeft: actor.type === 'team' ? space(0.5) : space(0),
}}
/>
{showLabel && (
<div
style={{color: theme.textColor}}
>{`${actor.type === 'team' ? '#' : ''}${actor.name}`}</div>
)}
<Chevron direction={chevronDirection} size="small" />
</Fragment>
);
};

const loadingIcon = (
<Fragment>
<StyledLoadingIndicator mini hideMessage relative size={AVATAR_SIZE} />
{showLabel && 'Loading...'}
<Chevron direction={chevronDirection} size="small" />
</Fragment>
);

const unassignedIcon = (
<Fragment>
<Placeholder
shape="circle"
width={`${AVATAR_SIZE}px`}
height={`${AVATAR_SIZE}px`}
/>
{showLabel && <Fragment>Unassigned</Fragment>}
<Chevron direction={chevronDirection} size="small" />
</Fragment>
);

return loading ? (
<StyledTag icon={loadingIcon} />
) : assignedTo ? (
<Tooltip
title={
<TooltipWrapper>
{t('Assigned to ')}
{assignedTo.type === 'team' ? `#${assignedTo.name}` : assignedTo.name}
{assignmentReason && (
<TooltipSubtext>{suggestedReasons[assignmentReason]}</TooltipSubtext>
)}
</TooltipWrapper>
}
>
<StyledTag icon={makeAssignedIcon(assignedTo)} />
</Tooltip>
) : (
<Tooltip
title={
<TooltipWrapper>
<div>{t('Unassigned')}</div>
<TooltipSubtext>
{tct(
'You can auto-assign issues by adding [issueOwners:Issue Owner rules].',
{
issueOwners: (
<TooltipSubExternalLink href="https://docs.sentry.io/product/error-monitoring/issue-owners/" />
),
}
)}
</TooltipSubtext>
</TooltipWrapper>
}
>
<StyledTag icon={unassignedIcon} borderStyle="dashed" />
</Tooltip>
);
}

const StyledLoadingIndicator = styled(LoadingIndicator)`
display: inline-flex;
align-items: center;
`;

const TooltipWrapper = styled('div')`
text-align: left;
`;

const StyledTag = styled(Tag)`
span {
display: flex;
align-items: center;
gap: ${space(0.5)};
}
& > div {
height: 24px;
padding: ${space(0.5)};
}
color: ${p => p.theme.subText};
cursor: pointer;
`;

const TooltipSubtext = styled('div')`
color: ${p => p.theme.subText};
`;

const TooltipSubExternalLink = styled(ExternalLink)`
color: ${p => p.theme.subText};
text-decoration: underline;
:hover {
color: ${p => p.theme.subText};
}
`;

0 comments on commit d17225a

Please sign in to comment.