group.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665
  1. import {Fragment, useCallback, useMemo} from 'react';
  2. import {css, Theme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import classNames from 'classnames';
  5. import type {LocationDescriptor} from 'history';
  6. import AssigneeSelector from 'sentry/components/assigneeSelector';
  7. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  8. import Count from 'sentry/components/count';
  9. import DeprecatedDropdownMenu from 'sentry/components/deprecatedDropdownMenu';
  10. import EventOrGroupExtraDetails from 'sentry/components/eventOrGroupExtraDetails';
  11. import EventOrGroupHeader from 'sentry/components/eventOrGroupHeader';
  12. import Link from 'sentry/components/links/link';
  13. import MenuItem from 'sentry/components/menuItem';
  14. import {getRelativeSummary} from 'sentry/components/organizations/timeRangeSelector/utils';
  15. import {PanelItem} from 'sentry/components/panels';
  16. import Placeholder from 'sentry/components/placeholder';
  17. import ProgressBar from 'sentry/components/progressBar';
  18. import {joinQuery, parseSearch, Token} from 'sentry/components/searchSyntax/parser';
  19. import GroupChart from 'sentry/components/stream/groupChart';
  20. import GroupCheckBox from 'sentry/components/stream/groupCheckBox';
  21. import TimeSince from 'sentry/components/timeSince';
  22. import {DEFAULT_STATS_PERIOD} from 'sentry/constants';
  23. import {t} from 'sentry/locale';
  24. import GroupStore from 'sentry/stores/groupStore';
  25. import SelectedGroupStore from 'sentry/stores/selectedGroupStore';
  26. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  27. import space from 'sentry/styles/space';
  28. import {Group, GroupReprocessing, NewQuery, Organization, User} from 'sentry/types';
  29. import {defined, percent} from 'sentry/utils';
  30. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  31. import EventView from 'sentry/utils/discover/eventView';
  32. import usePageFilters from 'sentry/utils/usePageFilters';
  33. import usePrevious from 'sentry/utils/usePrevious';
  34. import withOrganization from 'sentry/utils/withOrganization';
  35. import {TimePeriodType} from 'sentry/views/alerts/rules/metric/details/constants';
  36. import {
  37. DISCOVER_EXCLUSION_FIELDS,
  38. getTabs,
  39. isForReviewQuery,
  40. Query,
  41. } from 'sentry/views/issueList/utils';
  42. export const DEFAULT_STREAM_GROUP_STATS_PERIOD = '24h';
  43. type Props = {
  44. id: string;
  45. organization: Organization;
  46. canSelect?: boolean;
  47. customStatsPeriod?: TimePeriodType;
  48. displayReprocessingLayout?: boolean;
  49. hasGuideAnchor?: boolean;
  50. index?: number;
  51. memberList?: User[];
  52. narrowGroups?: boolean;
  53. query?: string;
  54. queryFilterDescription?: string;
  55. showInboxTime?: boolean;
  56. statsPeriod?: string;
  57. useFilteredStats?: boolean;
  58. useTintRow?: boolean;
  59. withChart?: boolean;
  60. };
  61. function BaseGroupRow({
  62. id,
  63. organization,
  64. customStatsPeriod,
  65. displayReprocessingLayout,
  66. hasGuideAnchor,
  67. index,
  68. memberList,
  69. query,
  70. queryFilterDescription,
  71. showInboxTime,
  72. statsPeriod = DEFAULT_STREAM_GROUP_STATS_PERIOD,
  73. canSelect = true,
  74. withChart = true,
  75. useFilteredStats = false,
  76. useTintRow = true,
  77. narrowGroups = false,
  78. }: Props) {
  79. const groups = useLegacyStore(GroupStore);
  80. const group = groups.find(item => item.id === id) as Group;
  81. const {selection} = usePageFilters();
  82. const lastInboxState = usePrevious(group.inbox);
  83. const reviewed =
  84. isForReviewQuery(query) &&
  85. lastInboxState !== false &&
  86. lastInboxState?.reason !== undefined &&
  87. group.inbox === false;
  88. const actionTaken = group.status !== 'unresolved';
  89. const {period, start, end} = selection.datetime || {};
  90. const summary =
  91. customStatsPeriod?.label.toLowerCase() ??
  92. (!!start && !!end
  93. ? 'time range'
  94. : getRelativeSummary(period || DEFAULT_STATS_PERIOD).toLowerCase());
  95. const sharedAnalytics = useMemo(() => {
  96. const tab = getTabs(organization).find(([tabQuery]) => tabQuery === query)?.[1];
  97. const owners = group.owners ?? [];
  98. return {
  99. organization,
  100. group_id: group.id,
  101. tab: tab?.analyticsName || 'other',
  102. was_shown_suggestion: owners.length > 0,
  103. };
  104. }, [organization, group.id, group.owners, query]);
  105. const trackClick = useCallback(() => {
  106. if (query === Query.FOR_REVIEW) {
  107. trackAdvancedAnalyticsEvent('inbox_tab.issue_clicked', {
  108. organization,
  109. group_id: group.id,
  110. });
  111. }
  112. if (query !== undefined) {
  113. trackAdvancedAnalyticsEvent('issues_stream.issue_clicked', sharedAnalytics);
  114. }
  115. }, [organization, group.id, query, sharedAnalytics]);
  116. const trackAssign: React.ComponentProps<typeof AssigneeSelector>['onAssign'] =
  117. useCallback(
  118. (type, _assignee, suggestedAssignee) => {
  119. if (query !== undefined) {
  120. trackAdvancedAnalyticsEvent('issues_stream.issue_assigned', {
  121. ...sharedAnalytics,
  122. did_assign_suggestion: !!suggestedAssignee,
  123. assigned_suggestion_reason: suggestedAssignee?.suggestedReason,
  124. assigned_type: type,
  125. });
  126. }
  127. },
  128. [query, sharedAnalytics]
  129. );
  130. const toggleSelect = useCallback(
  131. (evt: React.MouseEvent<HTMLDivElement>) => {
  132. const targetElement = evt.target as Partial<HTMLElement>;
  133. // Ignore clicks on links
  134. if (targetElement?.tagName?.toLowerCase() === 'a') {
  135. return;
  136. }
  137. // Ignore clicks on the selection checkbox
  138. if (targetElement?.tagName?.toLowerCase() === 'input') {
  139. return;
  140. }
  141. let e = targetElement;
  142. while (e.parentElement) {
  143. if (e?.tagName?.toLowerCase() === 'a') {
  144. return;
  145. }
  146. e = e.parentElement!;
  147. }
  148. if (evt.shiftKey) {
  149. SelectedGroupStore.shiftToggleItems(group.id);
  150. window.getSelection()?.removeAllRanges();
  151. } else {
  152. SelectedGroupStore.toggleSelect(group.id);
  153. }
  154. },
  155. [group.id]
  156. );
  157. const getDiscoverUrl = (isFiltered?: boolean): LocationDescriptor => {
  158. // when there is no discover feature open events page
  159. const hasDiscoverQuery = organization.features.includes('discover-basic');
  160. const parsedResult = parseSearch(
  161. isFiltered && typeof query === 'string' ? query : ''
  162. );
  163. const filteredTerms = parsedResult?.filter(
  164. p => !(p.type === Token.Filter && DISCOVER_EXCLUSION_FIELDS.includes(p.key.text))
  165. );
  166. const filteredQuery = joinQuery(filteredTerms, true);
  167. const commonQuery = {projects: [Number(group.project.id)]};
  168. if (hasDiscoverQuery) {
  169. const stats = customStatsPeriod ?? (selection.datetime || {});
  170. const discoverQuery: NewQuery = {
  171. ...commonQuery,
  172. id: undefined,
  173. name: group.title || group.type,
  174. fields: ['title', 'release', 'environment', 'user', 'timestamp'],
  175. orderby: '-timestamp',
  176. query: `issue.id:${group.id}${filteredQuery}`,
  177. version: 2,
  178. };
  179. if (!!stats.start && !!stats.end) {
  180. discoverQuery.start = new Date(stats.start).toISOString();
  181. discoverQuery.end = new Date(stats.end).toISOString();
  182. if (stats.utc) {
  183. discoverQuery.utc = true;
  184. }
  185. } else {
  186. discoverQuery.range = stats.period || DEFAULT_STATS_PERIOD;
  187. }
  188. const discoverView = EventView.fromSavedQuery(discoverQuery);
  189. return discoverView.getResultsViewUrlTarget(organization.slug);
  190. }
  191. return {
  192. pathname: `/organizations/${organization.slug}/issues/${group.id}/events/`,
  193. query: {
  194. ...commonQuery,
  195. query: filteredQuery,
  196. },
  197. };
  198. };
  199. const renderReprocessingColumns = () => {
  200. const {statusDetails, count} = group as GroupReprocessing;
  201. const {info, pendingEvents} = statusDetails;
  202. if (!info) {
  203. return null;
  204. }
  205. const {totalEvents, dateCreated} = info;
  206. const remainingEventsToReprocess = totalEvents - pendingEvents;
  207. const remainingEventsToReprocessPercent = percent(
  208. remainingEventsToReprocess,
  209. totalEvents
  210. );
  211. return (
  212. <Fragment>
  213. <StartedColumn>
  214. <TimeSince date={dateCreated} />
  215. </StartedColumn>
  216. <EventsReprocessedColumn>
  217. {!defined(count) ? (
  218. <Placeholder height="17px" />
  219. ) : (
  220. <Fragment>
  221. <Count value={remainingEventsToReprocess} />
  222. {'/'}
  223. <Count value={totalEvents} />
  224. </Fragment>
  225. )}
  226. </EventsReprocessedColumn>
  227. <ProgressColumn>
  228. <ProgressBar value={remainingEventsToReprocessPercent} />
  229. </ProgressColumn>
  230. </Fragment>
  231. );
  232. };
  233. // Use data.filtered to decide on which value to use
  234. // In case of the query has filters but we avoid showing both sets of filtered/unfiltered stats
  235. // we use useFilteredStats param passed to Group for deciding
  236. const primaryCount = group.filtered ? group.filtered.count : group.count;
  237. const secondaryCount = group.filtered ? group.count : undefined;
  238. const primaryUserCount = group.filtered ? group.filtered.userCount : group.userCount;
  239. const secondaryUserCount = group.filtered ? group.userCount : undefined;
  240. const showSecondaryPoints = Boolean(
  241. withChart && group && group.filtered && statsPeriod && useFilteredStats
  242. );
  243. const groupCount = !defined(primaryCount) ? (
  244. <Placeholder height="18px" />
  245. ) : (
  246. <DeprecatedDropdownMenu isNestedDropdown>
  247. {({isOpen, getRootProps, getActorProps, getMenuProps}) => {
  248. const topLevelCx = classNames('dropdown', {'anchor-middle': true, open: isOpen});
  249. return (
  250. <GuideAnchor target="dynamic_counts" disabled={!hasGuideAnchor}>
  251. <span {...getRootProps({className: topLevelCx})}>
  252. <span {...getActorProps({})}>
  253. <div className="dropdown-actor-title">
  254. <PrimaryCount value={primaryCount} />
  255. {secondaryCount !== undefined && useFilteredStats && (
  256. <SecondaryCount value={secondaryCount} />
  257. )}
  258. </div>
  259. </span>
  260. {useFilteredStats && (
  261. <StyledDropdownList
  262. {...getMenuProps({className: 'dropdown-menu inverted'})}
  263. >
  264. {group.filtered && (
  265. <Fragment>
  266. <StyledMenuItem to={getDiscoverUrl(true)}>
  267. <MenuItemText>
  268. {queryFilterDescription ?? t('Matching search filters')}
  269. </MenuItemText>
  270. <MenuItemCount value={group.filtered.count} />
  271. </StyledMenuItem>
  272. <MenuItem divider />
  273. </Fragment>
  274. )}
  275. <StyledMenuItem to={getDiscoverUrl()}>
  276. <MenuItemText>{t(`Total in ${summary}`)}</MenuItemText>
  277. <MenuItemCount value={group.count} />
  278. </StyledMenuItem>
  279. {group.lifetime && (
  280. <Fragment>
  281. <MenuItem divider />
  282. <StyledMenuItem>
  283. <MenuItemText>{t('Since issue began')}</MenuItemText>
  284. <MenuItemCount value={group.lifetime.count} />
  285. </StyledMenuItem>
  286. </Fragment>
  287. )}
  288. </StyledDropdownList>
  289. )}
  290. </span>
  291. </GuideAnchor>
  292. );
  293. }}
  294. </DeprecatedDropdownMenu>
  295. );
  296. const groupUsersCount = !defined(primaryUserCount) ? (
  297. <Placeholder height="18px" />
  298. ) : (
  299. <DeprecatedDropdownMenu isNestedDropdown>
  300. {({isOpen, getRootProps, getActorProps, getMenuProps}) => {
  301. const topLevelCx = classNames('dropdown', {'anchor-middle': true, open: isOpen});
  302. return (
  303. <span {...getRootProps({className: topLevelCx})}>
  304. <span {...getActorProps({})}>
  305. <div className="dropdown-actor-title">
  306. <PrimaryCount value={primaryUserCount} />
  307. {secondaryUserCount !== undefined && useFilteredStats && (
  308. <SecondaryCount dark value={secondaryUserCount} />
  309. )}
  310. </div>
  311. </span>
  312. {useFilteredStats && (
  313. <StyledDropdownList
  314. {...getMenuProps({className: 'dropdown-menu inverted'})}
  315. >
  316. {group.filtered && (
  317. <Fragment>
  318. <StyledMenuItem to={getDiscoverUrl(true)}>
  319. <MenuItemText>
  320. {queryFilterDescription ?? t('Matching search filters')}
  321. </MenuItemText>
  322. <MenuItemCount value={group.filtered.userCount} />
  323. </StyledMenuItem>
  324. <MenuItem divider />
  325. </Fragment>
  326. )}
  327. <StyledMenuItem to={getDiscoverUrl()}>
  328. <MenuItemText>{t(`Total in ${summary}`)}</MenuItemText>
  329. <MenuItemCount value={group.userCount} />
  330. </StyledMenuItem>
  331. {group.lifetime && (
  332. <Fragment>
  333. <MenuItem divider />
  334. <StyledMenuItem>
  335. <MenuItemText>{t('Since issue began')}</MenuItemText>
  336. <MenuItemCount value={group.lifetime.userCount} />
  337. </StyledMenuItem>
  338. </Fragment>
  339. )}
  340. </StyledDropdownList>
  341. )}
  342. </span>
  343. );
  344. }}
  345. </DeprecatedDropdownMenu>
  346. );
  347. return (
  348. <Wrapper
  349. data-test-id="group"
  350. data-test-reviewed={reviewed}
  351. onClick={displayReprocessingLayout ? undefined : toggleSelect}
  352. reviewed={reviewed}
  353. unresolved={group.status === 'unresolved'}
  354. actionTaken={actionTaken}
  355. useTintRow={useTintRow ?? true}
  356. >
  357. {canSelect && (
  358. <GroupCheckBoxWrapper>
  359. <GroupCheckBox id={group.id} disabled={!!displayReprocessingLayout} />
  360. </GroupCheckBoxWrapper>
  361. )}
  362. <GroupSummary canSelect={canSelect}>
  363. <EventOrGroupHeader
  364. index={index}
  365. organization={organization}
  366. includeLink
  367. data={group}
  368. query={query}
  369. size="normal"
  370. onClick={trackClick}
  371. />
  372. <EventOrGroupExtraDetails data={group} showInboxTime={showInboxTime} />
  373. </GroupSummary>
  374. {hasGuideAnchor && <GuideAnchor target="issue_stream" />}
  375. {withChart && !displayReprocessingLayout && (
  376. <ChartWrapper
  377. className={`hidden-xs hidden-sm ${narrowGroups ? 'hidden-md' : ''}`}
  378. >
  379. {!group.filtered?.stats && !group.stats ? (
  380. <Placeholder height="24px" />
  381. ) : (
  382. <GroupChart
  383. statsPeriod={statsPeriod!}
  384. data={group}
  385. showSecondaryPoints={showSecondaryPoints}
  386. showMarkLine
  387. />
  388. )}
  389. </ChartWrapper>
  390. )}
  391. {displayReprocessingLayout ? (
  392. renderReprocessingColumns()
  393. ) : (
  394. <Fragment>
  395. <EventCountsWrapper>{groupCount}</EventCountsWrapper>
  396. <EventCountsWrapper>{groupUsersCount}</EventCountsWrapper>
  397. <AssigneeWrapper className="hidden-xs hidden-sm">
  398. <AssigneeSelector
  399. id={group.id}
  400. memberList={memberList}
  401. onAssign={trackAssign}
  402. />
  403. </AssigneeWrapper>
  404. </Fragment>
  405. )}
  406. </Wrapper>
  407. );
  408. }
  409. const StreamGroup = withOrganization(BaseGroupRow);
  410. export default StreamGroup;
  411. // Position for wrapper is relative for overlay actions
  412. const Wrapper = styled(PanelItem)<{
  413. actionTaken: boolean;
  414. reviewed: boolean;
  415. unresolved: boolean;
  416. useTintRow: boolean;
  417. }>`
  418. position: relative;
  419. padding: ${space(1.5)} 0;
  420. line-height: 1.1;
  421. ${p =>
  422. p.useTintRow &&
  423. (p.reviewed || !p.unresolved) &&
  424. !p.actionTaken &&
  425. css`
  426. animation: tintRow 0.2s linear forwards;
  427. position: relative;
  428. /*
  429. * A mask that fills the entire row and makes the text opaque. Doing this because
  430. * opacity adds a stacking context in CSS so we need to apply it to another element.
  431. */
  432. &:after {
  433. content: '';
  434. pointer-events: none;
  435. position: absolute;
  436. left: 0;
  437. right: 0;
  438. top: 0;
  439. bottom: 0;
  440. width: 100%;
  441. height: 100%;
  442. background-color: ${p.theme.bodyBackground};
  443. opacity: 0.4;
  444. }
  445. @keyframes tintRow {
  446. 0% {
  447. background-color: ${p.theme.bodyBackground};
  448. }
  449. 100% {
  450. background-color: ${p.theme.backgroundSecondary};
  451. }
  452. }
  453. `};
  454. `;
  455. const GroupSummary = styled('div')<{canSelect: boolean}>`
  456. overflow: hidden;
  457. margin-left: ${p => space(p.canSelect ? 1 : 2)};
  458. margin-right: ${space(1)};
  459. flex: 1;
  460. width: 66.66%;
  461. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  462. width: 50%;
  463. }
  464. `;
  465. const GroupCheckBoxWrapper = styled('div')`
  466. margin-left: ${space(2)};
  467. align-self: flex-start;
  468. height: 15px;
  469. display: flex;
  470. align-items: center;
  471. & input[type='checkbox'] {
  472. margin: 0;
  473. display: block;
  474. }
  475. `;
  476. const primaryStatStyle = (theme: Theme) => css`
  477. font-size: ${theme.fontSizeLarge};
  478. font-variant-numeric: tabular-nums;
  479. `;
  480. const PrimaryCount = styled(Count)`
  481. ${p => primaryStatStyle(p.theme)};
  482. `;
  483. const secondaryStatStyle = (theme: Theme) => css`
  484. font-size: ${theme.fontSizeLarge};
  485. font-variant-numeric: tabular-nums;
  486. :before {
  487. content: '/';
  488. padding-left: ${space(0.25)};
  489. padding-right: 2px;
  490. color: ${theme.gray300};
  491. }
  492. `;
  493. const SecondaryCount = styled(({value, ...p}) => <Count {...p} value={value} />)`
  494. ${p => secondaryStatStyle(p.theme)}
  495. `;
  496. const StyledDropdownList = styled('ul')`
  497. z-index: ${p => p.theme.zIndex.hovercard};
  498. `;
  499. interface MenuItemProps extends React.HTMLAttributes<HTMLDivElement> {
  500. to?: LocationDescriptor;
  501. }
  502. const StyledMenuItem = styled(({to, children, ...p}: MenuItemProps) => (
  503. <MenuItem noAnchor>
  504. {to ? (
  505. // @ts-expect-error allow target _blank for this link to open in new window
  506. <Link to={to} target="_blank">
  507. <div {...p}>{children}</div>
  508. </Link>
  509. ) : (
  510. <div className="dropdown-toggle">
  511. <div {...p}>{children}</div>
  512. </div>
  513. )}
  514. </MenuItem>
  515. ))`
  516. margin: 0;
  517. display: flex;
  518. flex-direction: row;
  519. justify-content: space-between;
  520. `;
  521. const menuItemStatStyles = css`
  522. text-align: right;
  523. font-weight: bold;
  524. font-variant-numeric: tabular-nums;
  525. padding-left: ${space(1)};
  526. `;
  527. const MenuItemCount = styled(({value, ...p}) => (
  528. <div {...p}>
  529. <Count value={value} />
  530. </div>
  531. ))`
  532. ${menuItemStatStyles};
  533. color: ${p => p.theme.subText};
  534. `;
  535. const MenuItemText = styled('div')`
  536. white-space: nowrap;
  537. font-weight: normal;
  538. text-align: left;
  539. padding-right: ${space(1)};
  540. color: ${p => p.theme.textColor};
  541. `;
  542. const ChartWrapper = styled('div')`
  543. width: 200px;
  544. margin: 0 ${space(2)};
  545. align-self: center;
  546. `;
  547. const EventCountsWrapper = styled('div')`
  548. display: flex;
  549. justify-content: flex-end;
  550. align-self: center;
  551. width: 60px;
  552. margin: 0 ${space(2)};
  553. @media (min-width: ${p => p.theme.breakpoints.xlarge}) {
  554. width: 80px;
  555. }
  556. `;
  557. const AssigneeWrapper = styled('div')`
  558. width: 80px;
  559. margin: 0 ${space(2)};
  560. align-self: center;
  561. `;
  562. // Reprocessing
  563. const StartedColumn = styled('div')`
  564. align-self: center;
  565. margin: 0 ${space(2)};
  566. color: ${p => p.theme.gray500};
  567. ${p => p.theme.overflowEllipsis};
  568. width: 85px;
  569. @media (min-width: ${p => p.theme.breakpoints.small}) {
  570. display: block;
  571. width: 140px;
  572. }
  573. `;
  574. const EventsReprocessedColumn = styled('div')`
  575. align-self: center;
  576. margin: 0 ${space(2)};
  577. color: ${p => p.theme.gray500};
  578. ${p => p.theme.overflowEllipsis};
  579. width: 75px;
  580. @media (min-width: ${p => p.theme.breakpoints.small}) {
  581. width: 140px;
  582. }
  583. `;
  584. const ProgressColumn = styled('div')`
  585. margin: 0 ${space(2)};
  586. align-self: center;
  587. display: none;
  588. @media (min-width: ${p => p.theme.breakpoints.small}) {
  589. display: block;
  590. width: 160px;
  591. }
  592. `;