row.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. import {useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import Access from 'sentry/components/acl/access';
  4. import ActorAvatar from 'sentry/components/avatar/actorAvatar';
  5. import TeamAvatar from 'sentry/components/avatar/teamAvatar';
  6. import Tag from 'sentry/components/badge/tag';
  7. import {openConfirmModal} from 'sentry/components/confirm';
  8. import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
  9. import type {ItemsBeforeFilter} from 'sentry/components/dropdownAutoComplete/types';
  10. import DropdownBubble from 'sentry/components/dropdownBubble';
  11. import type {MenuItemProps} from 'sentry/components/dropdownMenu';
  12. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  13. import ErrorBoundary from 'sentry/components/errorBoundary';
  14. import IdBadge from 'sentry/components/idBadge';
  15. import ExternalLink from 'sentry/components/links/externalLink';
  16. import Link from 'sentry/components/links/link';
  17. import LoadingIndicator from 'sentry/components/loadingIndicator';
  18. import TextOverflow from 'sentry/components/textOverflow';
  19. import {Tooltip} from 'sentry/components/tooltip';
  20. import {IconChevron, IconEllipsis, IconUser} from 'sentry/icons';
  21. import {t, tct} from 'sentry/locale';
  22. import {space} from 'sentry/styles/space';
  23. import type {Actor} from 'sentry/types/core';
  24. import type {Project} from 'sentry/types/project';
  25. import {useUserTeams} from 'sentry/utils/useUserTeams';
  26. import AlertLastIncidentActivationInfo from 'sentry/views/alerts/list/rules/alertLastIncidentActivationInfo';
  27. import AlertRuleStatus from 'sentry/views/alerts/list/rules/alertRuleStatus';
  28. import CombinedAlertBadge from 'sentry/views/alerts/list/rules/combinedAlertBadge';
  29. import {getActor} from 'sentry/views/alerts/list/rules/utils';
  30. import {UptimeMonitorMode} from 'sentry/views/alerts/rules/uptime/types';
  31. import type {CombinedAlerts} from '../../types';
  32. import {CombinedAlertType} from '../../types';
  33. import {isIssueAlert} from '../../utils';
  34. type Props = {
  35. hasEditAccess: boolean;
  36. onDelete: (projectId: string, rule: CombinedAlerts) => void;
  37. onOwnerChange: (projectId: string, rule: CombinedAlerts, ownerValue: string) => void;
  38. orgId: string;
  39. projects: Project[];
  40. projectsLoaded: boolean;
  41. rule: CombinedAlerts;
  42. };
  43. function RuleListRow({
  44. rule,
  45. projectsLoaded,
  46. projects,
  47. orgId,
  48. onDelete,
  49. onOwnerChange,
  50. hasEditAccess,
  51. }: Props) {
  52. const {teams: userTeams} = useUserTeams();
  53. const [assignee, setAssignee] = useState<string>('');
  54. const isUptime = rule.type === CombinedAlertType.UPTIME;
  55. const isCron = rule.type === CombinedAlertType.CRONS;
  56. const slug = isUptime
  57. ? rule.projectSlug
  58. : isCron
  59. ? rule.project.slug
  60. : rule.projects[0]!;
  61. const editKey = {
  62. [CombinedAlertType.ISSUE]: 'rules',
  63. [CombinedAlertType.METRIC]: 'metric-rules',
  64. [CombinedAlertType.UPTIME]: 'uptime-rules',
  65. [CombinedAlertType.CRONS]: 'crons-rules',
  66. } satisfies Record<CombinedAlertType, string>;
  67. const editLink = `/organizations/${orgId}/alerts/${editKey[rule.type]}/${slug}/${rule.id}/`;
  68. const mutateKey = {
  69. [CombinedAlertType.ISSUE]: 'issue',
  70. [CombinedAlertType.METRIC]: 'metric',
  71. [CombinedAlertType.UPTIME]: 'uptime',
  72. [CombinedAlertType.CRONS]: 'crons',
  73. } satisfies Record<CombinedAlertType, string>;
  74. const duplicateLink = {
  75. pathname: `/organizations/${orgId}/alerts/new/${mutateKey[rule.type]}/`,
  76. query: {
  77. project: slug,
  78. duplicateRuleId: rule.id,
  79. createFromDuplicate: 'true',
  80. referrer: 'alert_stream',
  81. },
  82. };
  83. const ownerActor = getActor(rule);
  84. const canEdit = ownerActor?.id
  85. ? userTeams.some(team => team.id === ownerActor.id)
  86. : true;
  87. const activeActions = {
  88. [CombinedAlertType.ISSUE]: ['edit', 'duplicate', 'delete'],
  89. [CombinedAlertType.METRIC]: ['edit', 'duplicate', 'delete'],
  90. [CombinedAlertType.UPTIME]: ['edit', 'delete'],
  91. [CombinedAlertType.CRONS]: ['edit', 'delete'],
  92. };
  93. const actions: MenuItemProps[] = [
  94. {
  95. key: 'edit',
  96. label: t('Edit'),
  97. to: editLink,
  98. hidden: !activeActions[rule.type].includes('edit'),
  99. },
  100. {
  101. key: 'duplicate',
  102. label: t('Duplicate'),
  103. to: duplicateLink,
  104. hidden: !activeActions[rule.type].includes('duplicate'),
  105. },
  106. {
  107. key: 'delete',
  108. label: t('Delete'),
  109. hidden: !activeActions[rule.type].includes('delete'),
  110. priority: 'danger',
  111. onAction: () => {
  112. openConfirmModal({
  113. onConfirm: () => onDelete(slug, rule),
  114. header: <h5>{t('Delete Alert Rule?')}</h5>,
  115. message: t(
  116. 'Are you sure you want to delete "%s"? You won\'t be able to view the history of this alert once it\'s deleted.',
  117. rule.name
  118. ),
  119. confirmText: t('Delete Rule'),
  120. priority: 'danger',
  121. });
  122. },
  123. },
  124. ];
  125. function handleOwnerChange({value}: {value: string}) {
  126. const ownerValue = value && `team:${value}`;
  127. setAssignee(ownerValue);
  128. onOwnerChange(slug, rule, ownerValue);
  129. }
  130. const unassignedOption: ItemsBeforeFilter[number] = {
  131. value: '',
  132. label: (
  133. <MenuItemWrapper>
  134. <PaddedIconUser size="lg" />
  135. <Label>{t('Unassigned')}</Label>
  136. </MenuItemWrapper>
  137. ),
  138. searchKey: 'unassigned',
  139. actor: '',
  140. disabled: false,
  141. };
  142. const project = projects.find(p => p.slug === slug);
  143. const filteredProjectTeams = (project?.teams ?? []).filter(projTeam => {
  144. return userTeams.some(team => team.id === projTeam.id);
  145. });
  146. const dropdownTeams = filteredProjectTeams
  147. .map<ItemsBeforeFilter[number]>((team, idx) => ({
  148. value: team.id,
  149. searchKey: team.slug,
  150. label: (
  151. <MenuItemWrapper data-test-id="assignee-option" key={idx}>
  152. <IconContainer>
  153. <TeamAvatar team={team} size={24} />
  154. </IconContainer>
  155. <Label>#{team.slug}</Label>
  156. </MenuItemWrapper>
  157. ),
  158. }))
  159. .concat(unassignedOption);
  160. const teamId = assignee?.split(':')[1]!;
  161. const teamName = filteredProjectTeams.find(team => team.id === teamId);
  162. const assigneeTeamActor = assignee && {
  163. type: 'team' as Actor['type'],
  164. id: teamId,
  165. name: '',
  166. };
  167. const avatarElement = assigneeTeamActor ? (
  168. <ActorAvatar
  169. actor={assigneeTeamActor}
  170. className="avatar"
  171. size={24}
  172. tooltipOptions={{overlayStyle: {textAlign: 'left'}}}
  173. tooltip={tct('Assigned to [name]', {name: teamName && `#${teamName.name}`})}
  174. />
  175. ) : (
  176. <Tooltip isHoverable skipWrapper title={t('Unassigned')}>
  177. <PaddedIconUser size="lg" color="gray400" />
  178. </Tooltip>
  179. );
  180. const hasUptimeAutoconfigureBadge =
  181. rule.type === CombinedAlertType.UPTIME &&
  182. rule.mode === UptimeMonitorMode.AUTO_DETECTED_ACTIVE;
  183. const titleBadge = hasUptimeAutoconfigureBadge ? (
  184. <Tag
  185. type="info"
  186. tooltipProps={{isHoverable: true}}
  187. tooltipText={tct(
  188. 'This Uptime Monitoring alert was auto-detected. [learnMore: Learn more].',
  189. {
  190. learnMore: (
  191. <ExternalLink href="https://docs.sentry.io/product/alerts/uptime-monitoring/" />
  192. ),
  193. }
  194. )}
  195. >
  196. {t('Auto Detected')}
  197. </Tag>
  198. ) : null;
  199. function ruleUrl() {
  200. switch (rule.type) {
  201. case CombinedAlertType.METRIC:
  202. return `/organizations/${orgId}/alerts/rules/details/${rule.id}/`;
  203. case CombinedAlertType.CRONS:
  204. return `/organizations/${orgId}/alerts/rules/crons/${rule.project.slug}/${rule.id}/details/`;
  205. case CombinedAlertType.UPTIME:
  206. return `/organizations/${orgId}/alerts/rules/uptime/${rule.projectSlug}/${rule.id}/details/`;
  207. default:
  208. return `/organizations/${orgId}/alerts/rules/${rule.projects[0]}/${rule.id}/details/`;
  209. }
  210. }
  211. return (
  212. <ErrorBoundary>
  213. <AlertNameWrapper isIssueAlert={isIssueAlert(rule)}>
  214. <AlertNameAndStatus>
  215. <AlertName>
  216. <Link to={ruleUrl()}>
  217. {rule.name} {titleBadge}
  218. </Link>
  219. </AlertName>
  220. <AlertIncidentDate>
  221. <AlertLastIncidentActivationInfo rule={rule} />
  222. </AlertIncidentDate>
  223. </AlertNameAndStatus>
  224. </AlertNameWrapper>
  225. <FlexCenter>
  226. <FlexCenter>
  227. <CombinedAlertBadge rule={rule} />
  228. </FlexCenter>
  229. {!isUptime && !isCron && (
  230. <MarginLeft>
  231. <AlertRuleStatus rule={rule} />
  232. </MarginLeft>
  233. )}
  234. </FlexCenter>
  235. <FlexCenter>
  236. <ProjectBadgeContainer>
  237. <ProjectBadge
  238. avatarSize={18}
  239. project={projectsLoaded && project ? project : {slug}}
  240. />
  241. </ProjectBadgeContainer>
  242. </FlexCenter>
  243. <FlexCenter>
  244. {ownerActor ? (
  245. <ActorAvatar actor={ownerActor} size={24} />
  246. ) : (
  247. <AssigneeWrapper>
  248. {!projectsLoaded && <StyledLoadingIndicator mini />}
  249. {projectsLoaded && (
  250. <DropdownAutoComplete
  251. data-test-id="alert-row-assignee"
  252. maxHeight={400}
  253. onOpen={e => {
  254. e?.stopPropagation();
  255. }}
  256. items={dropdownTeams}
  257. alignMenu="right"
  258. onSelect={handleOwnerChange}
  259. itemSize="small"
  260. searchPlaceholder={t('Filter teams')}
  261. disableLabelPadding
  262. emptyHidesInput
  263. disabled={!hasEditAccess}
  264. >
  265. {({getActorProps, isOpen}) => (
  266. <DropdownButton {...getActorProps({})}>
  267. {avatarElement}
  268. {hasEditAccess && (
  269. <StyledChevron direction={isOpen ? 'up' : 'down'} size="xs" />
  270. )}
  271. </DropdownButton>
  272. )}
  273. </DropdownAutoComplete>
  274. )}
  275. </AssigneeWrapper>
  276. )}
  277. </FlexCenter>
  278. <ActionsColumn>
  279. <Access access={['alerts:write']}>
  280. {({hasAccess}) => (
  281. <DropdownMenu
  282. items={actions}
  283. position="bottom-end"
  284. triggerProps={{
  285. 'aria-label': t('Actions'),
  286. size: 'xs',
  287. icon: <IconEllipsis />,
  288. showChevron: false,
  289. }}
  290. disabledKeys={hasAccess && canEdit ? [] : ['delete']}
  291. />
  292. )}
  293. </Access>
  294. </ActionsColumn>
  295. </ErrorBoundary>
  296. );
  297. }
  298. // TODO: see static/app/components/profiling/flex.tsx and utilize the FlexContainer styled component
  299. const FlexCenter = styled('div')`
  300. display: flex;
  301. align-items: center;
  302. `;
  303. const AlertNameWrapper = styled('div')<{isIssueAlert?: boolean}>`
  304. ${p => p.theme.overflowEllipsis}
  305. display: flex;
  306. align-items: center;
  307. gap: ${space(2)};
  308. ${p => p.isIssueAlert && `padding: ${space(3)} ${space(2)}; line-height: 2.4;`}
  309. `;
  310. const AlertNameAndStatus = styled('div')`
  311. ${p => p.theme.overflowEllipsis}
  312. line-height: 1.35;
  313. `;
  314. const AlertName = styled('div')`
  315. ${p => p.theme.overflowEllipsis}
  316. font-size: ${p => p.theme.fontSizeLarge};
  317. `;
  318. const AlertIncidentDate = styled('div')`
  319. color: ${p => p.theme.gray300};
  320. `;
  321. const ProjectBadgeContainer = styled('div')`
  322. width: 100%;
  323. `;
  324. const ProjectBadge = styled(IdBadge)`
  325. flex-shrink: 0;
  326. `;
  327. const ActionsColumn = styled('div')`
  328. display: flex;
  329. align-items: center;
  330. justify-content: center;
  331. padding: ${space(1)};
  332. `;
  333. const AssigneeWrapper = styled('div')`
  334. display: flex;
  335. justify-content: flex-end;
  336. /* manually align menu underneath dropdown caret */
  337. ${DropdownBubble} {
  338. right: -14px;
  339. }
  340. `;
  341. const DropdownButton = styled('div')`
  342. display: flex;
  343. align-items: center;
  344. font-size: 20px;
  345. `;
  346. const StyledChevron = styled(IconChevron)`
  347. margin-left: ${space(1)};
  348. `;
  349. const PaddedIconUser = styled(IconUser)`
  350. padding: ${space(0.25)};
  351. `;
  352. const IconContainer = styled('div')`
  353. display: flex;
  354. align-items: center;
  355. justify-content: center;
  356. width: ${p => p.theme.iconSizes.lg};
  357. height: ${p => p.theme.iconSizes.lg};
  358. flex-shrink: 0;
  359. `;
  360. const MenuItemWrapper = styled('div')`
  361. display: flex;
  362. align-items: center;
  363. font-size: ${p => p.theme.fontSizeSmall};
  364. `;
  365. const Label = styled(TextOverflow)`
  366. margin-left: ${space(0.75)};
  367. `;
  368. const MarginLeft = styled('div')`
  369. margin-left: ${space(1)};
  370. `;
  371. const StyledLoadingIndicator = styled(LoadingIndicator)`
  372. height: 24px;
  373. margin: 0;
  374. margin-right: ${space(1.5)};
  375. `;
  376. export default RuleListRow;