row.tsx 13 KB

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