group.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827
  1. import * as React from 'react';
  2. import {css, Theme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import classNames from 'classnames';
  5. import AssigneeSelector from 'app/components/assigneeSelector';
  6. import GuideAnchor from 'app/components/assistant/guideAnchor';
  7. import Count from 'app/components/count';
  8. import DropdownMenu from 'app/components/dropdownMenu';
  9. import EventOrGroupExtraDetails from 'app/components/eventOrGroupExtraDetails';
  10. import EventOrGroupHeader from 'app/components/eventOrGroupHeader';
  11. import Link from 'app/components/links/link';
  12. import MenuItem from 'app/components/menuItem';
  13. import {getRelativeSummary} from 'app/components/organizations/timeRangeSelector/utils';
  14. import {PanelItem} from 'app/components/panels';
  15. import Placeholder from 'app/components/placeholder';
  16. import ProgressBar from 'app/components/progressBar';
  17. import GroupChart from 'app/components/stream/groupChart';
  18. import GroupCheckBox from 'app/components/stream/groupCheckBox';
  19. import TimeSince from 'app/components/timeSince';
  20. import {DEFAULT_STATS_PERIOD} from 'app/constants';
  21. import {t} from 'app/locale';
  22. import GroupStore from 'app/stores/groupStore';
  23. import SelectedGroupStore from 'app/stores/selectedGroupStore';
  24. import overflowEllipsis from 'app/styles/overflowEllipsis';
  25. import space from 'app/styles/space';
  26. import {
  27. GlobalSelection,
  28. Group,
  29. GroupReprocessing,
  30. InboxDetails,
  31. NewQuery,
  32. Organization,
  33. User,
  34. } from 'app/types';
  35. import {defined, percent, valueIsEqual} from 'app/utils';
  36. import {trackAnalyticsEvent} from 'app/utils/analytics';
  37. import {callIfFunction} from 'app/utils/callIfFunction';
  38. import EventView from 'app/utils/discover/eventView';
  39. import {formatPercentage} from 'app/utils/formatters';
  40. import {queryToObj} from 'app/utils/stream';
  41. import withGlobalSelection from 'app/utils/withGlobalSelection';
  42. import withOrganization from 'app/utils/withOrganization';
  43. import {TimePeriodType} from 'app/views/alerts/rules/details/constants';
  44. import {
  45. getTabs,
  46. isForReviewQuery,
  47. IssueDisplayOptions,
  48. Query,
  49. } from 'app/views/issueList/utils';
  50. const DiscoveryExclusionFields: string[] = [
  51. 'query',
  52. 'status',
  53. 'bookmarked_by',
  54. 'assigned',
  55. 'assigned_to',
  56. 'unassigned',
  57. 'subscribed_by',
  58. 'active_at',
  59. 'first_release',
  60. 'first_seen',
  61. 'is',
  62. '__text',
  63. ];
  64. export const DEFAULT_STREAM_GROUP_STATS_PERIOD = '24h';
  65. const DEFAULT_DISPLAY = IssueDisplayOptions.EVENTS;
  66. const defaultProps = {
  67. statsPeriod: DEFAULT_STREAM_GROUP_STATS_PERIOD,
  68. canSelect: true,
  69. withChart: true,
  70. useFilteredStats: false,
  71. useTintRow: true,
  72. display: DEFAULT_DISPLAY,
  73. narrowGroups: false,
  74. };
  75. type Props = {
  76. id: string;
  77. selection: GlobalSelection;
  78. organization: Organization;
  79. displayReprocessingLayout?: boolean;
  80. query?: string;
  81. hasGuideAnchor?: boolean;
  82. memberList?: User[];
  83. showInboxTime?: boolean;
  84. index?: number;
  85. customStatsPeriod?: TimePeriodType;
  86. display?: IssueDisplayOptions;
  87. // TODO(ts): higher order functions break defaultprops export types
  88. queryFilterDescription?: string;
  89. } & Partial<typeof defaultProps>;
  90. type State = {
  91. data: Group;
  92. reviewed: boolean;
  93. actionTaken: boolean;
  94. };
  95. class StreamGroup extends React.Component<Props, State> {
  96. static defaultProps = defaultProps;
  97. state: State = this.getInitialState();
  98. getInitialState(): State {
  99. const {id, useFilteredStats} = this.props;
  100. const data = GroupStore.get(id) as Group;
  101. return {
  102. data: {
  103. ...data,
  104. filtered: useFilteredStats ? data.filtered : null,
  105. },
  106. reviewed: false,
  107. actionTaken: false,
  108. };
  109. }
  110. componentWillReceiveProps(nextProps: Props) {
  111. if (
  112. nextProps.id !== this.props.id ||
  113. nextProps.useFilteredStats !== this.props.useFilteredStats
  114. ) {
  115. const data = GroupStore.get(this.props.id) as Group;
  116. this.setState({
  117. data: {
  118. ...data,
  119. filtered: nextProps.useFilteredStats ? data.filtered : null,
  120. },
  121. });
  122. }
  123. }
  124. shouldComponentUpdate(nextProps: Props, nextState: State) {
  125. if (nextProps.statsPeriod !== this.props.statsPeriod) {
  126. return true;
  127. }
  128. if (!valueIsEqual(this.state.data, nextState.data)) {
  129. return true;
  130. }
  131. return false;
  132. }
  133. componentWillUnmount() {
  134. callIfFunction(this.listener);
  135. }
  136. listener = GroupStore.listen(itemIds => this.onGroupChange(itemIds), undefined);
  137. onGroupChange(itemIds: Set<string>) {
  138. const {id, query} = this.props;
  139. if (!itemIds.has(id)) {
  140. return;
  141. }
  142. const actionTaken = this.state.data.status !== 'unresolved';
  143. const data = GroupStore.get(id) as Group;
  144. this.setState(state => {
  145. // When searching is:for_review and the inbox reason is removed
  146. const reviewed =
  147. state.reviewed ||
  148. (isForReviewQuery(query) &&
  149. (state.data.inbox as InboxDetails)?.reason !== undefined &&
  150. data.inbox === false);
  151. return {data, reviewed, actionTaken};
  152. });
  153. }
  154. /** Shared between two events */
  155. sharedAnalytics() {
  156. const {query, organization} = this.props;
  157. const {data} = this.state;
  158. const tab = getTabs(organization).find(([tabQuery]) => tabQuery === query)?.[1];
  159. const owners = data?.owners || [];
  160. return {
  161. organization_id: organization.id,
  162. group_id: data.id,
  163. tab: tab?.analyticsName || 'other',
  164. was_shown_suggestion: owners.length > 0,
  165. };
  166. }
  167. trackClick = () => {
  168. const {query, organization} = this.props;
  169. const {data} = this.state;
  170. if (query === Query.FOR_REVIEW) {
  171. trackAnalyticsEvent({
  172. eventKey: 'inbox_tab.issue_clicked',
  173. eventName: 'Clicked Issue from Inbox Tab',
  174. organization_id: organization.id,
  175. group_id: data.id,
  176. });
  177. }
  178. if (query !== undefined) {
  179. trackAnalyticsEvent({
  180. eventKey: 'issues_stream.issue_clicked',
  181. eventName: 'Clicked Issue from Issues Stream',
  182. ...this.sharedAnalytics(),
  183. });
  184. }
  185. };
  186. trackAssign: React.ComponentProps<typeof AssigneeSelector>['onAssign'] = (
  187. type,
  188. _assignee,
  189. suggestedAssignee
  190. ) => {
  191. const {query} = this.props;
  192. if (query !== undefined) {
  193. trackAnalyticsEvent({
  194. eventKey: 'issues_stream.issue_assigned',
  195. eventName: 'Assigned Issue from Issues Stream',
  196. ...this.sharedAnalytics(),
  197. did_assign_suggestion: !!suggestedAssignee,
  198. assigned_suggestion_reason: suggestedAssignee?.suggestedReason,
  199. assigned_type: type,
  200. });
  201. }
  202. };
  203. toggleSelect = (evt: React.MouseEvent<HTMLDivElement>) => {
  204. const targetElement = evt.target as Partial<HTMLElement>;
  205. if (targetElement?.tagName?.toLowerCase() === 'a') {
  206. return;
  207. }
  208. if (targetElement?.tagName?.toLowerCase() === 'input') {
  209. return;
  210. }
  211. let e = targetElement;
  212. while (e.parentElement) {
  213. if (e?.tagName?.toLowerCase() === 'a') {
  214. return;
  215. }
  216. e = e.parentElement!;
  217. }
  218. SelectedGroupStore.toggleSelect(this.state.data.id);
  219. };
  220. getDiscoverUrl(isFiltered?: boolean) {
  221. const {organization, query, selection, customStatsPeriod} = this.props;
  222. const {data} = this.state;
  223. // when there is no discover feature open events page
  224. const hasDiscoverQuery = organization.features.includes('discover-basic');
  225. const queryTerms: string[] = [];
  226. if (isFiltered && typeof query === 'string') {
  227. const queryObj = queryToObj(query);
  228. for (const queryTag in queryObj)
  229. if (!DiscoveryExclusionFields.includes(queryTag)) {
  230. const queryVal = queryObj[queryTag].includes(' ')
  231. ? `"${queryObj[queryTag]}"`
  232. : queryObj[queryTag];
  233. queryTerms.push(`${queryTag}:${queryVal}`);
  234. }
  235. if (queryObj.__text) {
  236. queryTerms.push(queryObj.__text);
  237. }
  238. }
  239. const commonQuery = {projects: [Number(data.project.id)]};
  240. const searchQuery = (queryTerms.length ? ' ' : '') + queryTerms.join(' ');
  241. if (hasDiscoverQuery) {
  242. const {period, start, end} = customStatsPeriod ?? (selection.datetime || {});
  243. const discoverQuery: NewQuery = {
  244. ...commonQuery,
  245. id: undefined,
  246. name: data.title || data.type,
  247. fields: ['title', 'release', 'environment', 'user', 'timestamp'],
  248. orderby: '-timestamp',
  249. query: `issue.id:${data.id}${searchQuery}`,
  250. version: 2,
  251. };
  252. if (!!start && !!end) {
  253. discoverQuery.start = String(start);
  254. discoverQuery.end = String(end);
  255. } else {
  256. discoverQuery.range = period || DEFAULT_STATS_PERIOD;
  257. }
  258. const discoverView = EventView.fromSavedQuery(discoverQuery);
  259. return discoverView.getResultsViewUrlTarget(organization.slug);
  260. }
  261. return {
  262. pathname: `/organizations/${organization.slug}/issues/${data.id}/events/`,
  263. query: {
  264. ...commonQuery,
  265. query: searchQuery,
  266. },
  267. };
  268. }
  269. renderReprocessingColumns() {
  270. const {data} = this.state;
  271. const {statusDetails, count} = data as GroupReprocessing;
  272. const {info, pendingEvents} = statusDetails;
  273. const {totalEvents, dateCreated} = info;
  274. const remainingEventsToReprocess = totalEvents - pendingEvents;
  275. const remainingEventsToReprocessPercent = percent(
  276. remainingEventsToReprocess,
  277. totalEvents
  278. );
  279. const value = remainingEventsToReprocessPercent || 100;
  280. return (
  281. <React.Fragment>
  282. <StartedColumn>
  283. <TimeSince date={dateCreated} />
  284. </StartedColumn>
  285. <EventsReprocessedColumn>
  286. {!defined(count) ? (
  287. <Placeholder height="17px" />
  288. ) : (
  289. <React.Fragment>
  290. <Count value={totalEvents} />
  291. {'/'}
  292. <Count value={Number(count)} />
  293. </React.Fragment>
  294. )}
  295. </EventsReprocessedColumn>
  296. <ProgressColumn>
  297. <ProgressBar value={value} />
  298. </ProgressColumn>
  299. </React.Fragment>
  300. );
  301. }
  302. render() {
  303. const {data, reviewed, actionTaken} = this.state;
  304. const {
  305. index,
  306. query,
  307. hasGuideAnchor,
  308. canSelect,
  309. memberList,
  310. withChart,
  311. statsPeriod,
  312. selection,
  313. organization,
  314. displayReprocessingLayout,
  315. showInboxTime,
  316. useFilteredStats,
  317. useTintRow,
  318. customStatsPeriod,
  319. display,
  320. queryFilterDescription,
  321. narrowGroups,
  322. } = this.props;
  323. const {period, start, end} = selection.datetime || {};
  324. const summary =
  325. customStatsPeriod?.label.toLowerCase() ??
  326. (!!start && !!end
  327. ? 'time range'
  328. : getRelativeSummary(period || DEFAULT_STATS_PERIOD).toLowerCase());
  329. // Use data.filtered to decide on which value to use
  330. // In case of the query has filters but we avoid showing both sets of filtered/unfiltered stats
  331. // we use useFilteredStats param passed to Group for deciding
  332. const primaryCount = data.filtered ? data.filtered.count : data.count;
  333. const secondaryCount = data.filtered ? data.count : undefined;
  334. const primaryUserCount = data.filtered ? data.filtered.userCount : data.userCount;
  335. const secondaryUserCount = data.filtered ? data.userCount : undefined;
  336. const showSecondaryPoints = Boolean(
  337. withChart && data && data.filtered && statsPeriod && useFilteredStats
  338. );
  339. const showSessions = display === IssueDisplayOptions.SESSIONS;
  340. // calculate a percentage count based on session data if the user has selected sessions display
  341. const primaryPercent =
  342. showSessions &&
  343. data.sessionCount &&
  344. formatPercentage(Number(primaryCount) / Number(data.sessionCount));
  345. const secondaryPercent =
  346. showSessions &&
  347. data.sessionCount &&
  348. secondaryCount &&
  349. formatPercentage(Number(secondaryCount) / Number(data.sessionCount));
  350. return (
  351. <Wrapper
  352. data-test-id="group"
  353. onClick={displayReprocessingLayout ? undefined : this.toggleSelect}
  354. reviewed={reviewed}
  355. unresolved={data.status === 'unresolved'}
  356. actionTaken={actionTaken}
  357. useTintRow={useTintRow ?? true}
  358. >
  359. {canSelect && (
  360. <GroupCheckBoxWrapper>
  361. <GroupCheckBox id={data.id} disabled={!!displayReprocessingLayout} />
  362. </GroupCheckBoxWrapper>
  363. )}
  364. <GroupSummary canSelect={!!canSelect}>
  365. <EventOrGroupHeader
  366. index={index}
  367. organization={organization}
  368. includeLink
  369. data={data}
  370. query={query}
  371. size="normal"
  372. onClick={this.trackClick}
  373. />
  374. <EventOrGroupExtraDetails
  375. hasGuideAnchor={hasGuideAnchor}
  376. data={data}
  377. showInboxTime={showInboxTime}
  378. />
  379. </GroupSummary>
  380. {hasGuideAnchor && <GuideAnchor target="issue_stream" />}
  381. {withChart && !displayReprocessingLayout && (
  382. <ChartWrapper
  383. className={`hidden-xs hidden-sm ${narrowGroups ? 'hidden-md' : ''}`}
  384. >
  385. {!data.filtered?.stats && !data.stats ? (
  386. <Placeholder height="24px" />
  387. ) : (
  388. <GroupChart
  389. statsPeriod={statsPeriod!}
  390. data={data}
  391. showSecondaryPoints={showSecondaryPoints}
  392. />
  393. )}
  394. </ChartWrapper>
  395. )}
  396. {displayReprocessingLayout ? (
  397. this.renderReprocessingColumns()
  398. ) : (
  399. <React.Fragment>
  400. <EventUserWrapper>
  401. {!defined(primaryCount) ? (
  402. <Placeholder height="18px" />
  403. ) : (
  404. <DropdownMenu isNestedDropdown>
  405. {({isOpen, getRootProps, getActorProps, getMenuProps}) => {
  406. const topLevelCx = classNames('dropdown', {
  407. 'anchor-middle': true,
  408. open: isOpen,
  409. });
  410. return (
  411. <GuideAnchor target="dynamic_counts" disabled={!hasGuideAnchor}>
  412. <span
  413. {...getRootProps({
  414. className: topLevelCx,
  415. })}
  416. >
  417. <span {...getActorProps({})}>
  418. <div className="dropdown-actor-title">
  419. {primaryPercent ? (
  420. <PrimaryPercent>{primaryPercent}</PrimaryPercent>
  421. ) : (
  422. <PrimaryCount value={primaryCount} />
  423. )}
  424. {secondaryCount !== undefined &&
  425. useFilteredStats &&
  426. (secondaryPercent ? (
  427. <SecondaryPercent>{secondaryPercent}</SecondaryPercent>
  428. ) : (
  429. <SecondaryCount value={secondaryCount} />
  430. ))}
  431. </div>
  432. </span>
  433. {useFilteredStats && (
  434. <StyledDropdownList
  435. {...getMenuProps({className: 'dropdown-menu inverted'})}
  436. >
  437. {data.filtered && (
  438. <React.Fragment>
  439. <StyledMenuItem to={this.getDiscoverUrl(true)}>
  440. <MenuItemText>
  441. {queryFilterDescription ??
  442. t('Matching search filters')}
  443. </MenuItemText>
  444. {primaryPercent ? (
  445. <MenuItemPercent>{primaryPercent}</MenuItemPercent>
  446. ) : (
  447. <MenuItemCount value={data.filtered.count} />
  448. )}
  449. </StyledMenuItem>
  450. <MenuItem divider />
  451. </React.Fragment>
  452. )}
  453. <StyledMenuItem to={this.getDiscoverUrl()}>
  454. <MenuItemText>{t(`Total in ${summary}`)}</MenuItemText>
  455. {secondaryPercent ? (
  456. <MenuItemPercent>{secondaryPercent}</MenuItemPercent>
  457. ) : (
  458. <MenuItemCount value={secondaryPercent || data.count} />
  459. )}
  460. </StyledMenuItem>
  461. {data.lifetime && (
  462. <React.Fragment>
  463. <MenuItem divider />
  464. <StyledMenuItem>
  465. <MenuItemText>{t('Since issue began')}</MenuItemText>
  466. <MenuItemCount value={data.lifetime.count} />
  467. </StyledMenuItem>
  468. </React.Fragment>
  469. )}
  470. </StyledDropdownList>
  471. )}
  472. </span>
  473. </GuideAnchor>
  474. );
  475. }}
  476. </DropdownMenu>
  477. )}
  478. </EventUserWrapper>
  479. <EventUserWrapper>
  480. {!defined(primaryUserCount) ? (
  481. <Placeholder height="18px" />
  482. ) : (
  483. <DropdownMenu isNestedDropdown>
  484. {({isOpen, getRootProps, getActorProps, getMenuProps}) => {
  485. const topLevelCx = classNames('dropdown', {
  486. 'anchor-middle': true,
  487. open: isOpen,
  488. });
  489. return (
  490. <span
  491. {...getRootProps({
  492. className: topLevelCx,
  493. })}
  494. >
  495. <span {...getActorProps({})}>
  496. <div className="dropdown-actor-title">
  497. <PrimaryCount value={primaryUserCount} />
  498. {secondaryUserCount !== undefined && useFilteredStats && (
  499. <SecondaryCount dark value={secondaryUserCount} />
  500. )}
  501. </div>
  502. </span>
  503. {useFilteredStats && (
  504. <StyledDropdownList
  505. {...getMenuProps({className: 'dropdown-menu inverted'})}
  506. >
  507. {data.filtered && (
  508. <React.Fragment>
  509. <StyledMenuItem to={this.getDiscoverUrl(true)}>
  510. <MenuItemText>
  511. {queryFilterDescription ??
  512. t('Matching search filters')}
  513. </MenuItemText>
  514. <MenuItemCount value={data.filtered.userCount} />
  515. </StyledMenuItem>
  516. <MenuItem divider />
  517. </React.Fragment>
  518. )}
  519. <StyledMenuItem to={this.getDiscoverUrl()}>
  520. <MenuItemText>{t(`Total in ${summary}`)}</MenuItemText>
  521. <MenuItemCount value={data.userCount} />
  522. </StyledMenuItem>
  523. {data.lifetime && (
  524. <React.Fragment>
  525. <MenuItem divider />
  526. <StyledMenuItem>
  527. <MenuItemText>{t('Since issue began')}</MenuItemText>
  528. <MenuItemCount value={data.lifetime.userCount} />
  529. </StyledMenuItem>
  530. </React.Fragment>
  531. )}
  532. </StyledDropdownList>
  533. )}
  534. </span>
  535. );
  536. }}
  537. </DropdownMenu>
  538. )}
  539. </EventUserWrapper>
  540. <AssigneeWrapper className="hidden-xs hidden-sm">
  541. <AssigneeSelector
  542. id={data.id}
  543. memberList={memberList}
  544. onAssign={this.trackAssign}
  545. />
  546. </AssigneeWrapper>
  547. </React.Fragment>
  548. )}
  549. </Wrapper>
  550. );
  551. }
  552. }
  553. export default withGlobalSelection(withOrganization(StreamGroup));
  554. // Position for wrapper is relative for overlay actions
  555. const Wrapper = styled(PanelItem)<{
  556. reviewed: boolean;
  557. unresolved: boolean;
  558. actionTaken: boolean;
  559. useTintRow: boolean;
  560. }>`
  561. position: relative;
  562. padding: ${space(1.5)} 0;
  563. line-height: 1.1;
  564. ${p =>
  565. p.useTintRow &&
  566. (p.reviewed || !p.unresolved) &&
  567. !p.actionTaken &&
  568. css`
  569. animation: tintRow 0.2s linear forwards;
  570. position: relative;
  571. /*
  572. * A mask that fills the entire row and makes the text opaque. Doing this because
  573. * opacity adds a stacking context in CSS so we need to apply it to another element.
  574. */
  575. &:after {
  576. content: '';
  577. pointer-events: none;
  578. position: absolute;
  579. left: 0;
  580. right: 0;
  581. top: 0;
  582. bottom: 0;
  583. width: 100%;
  584. height: 100%;
  585. background-color: ${p.theme.bodyBackground};
  586. opacity: 0.4;
  587. z-index: 1;
  588. }
  589. @keyframes tintRow {
  590. 0% {
  591. background-color: ${p.theme.bodyBackground};
  592. }
  593. 100% {
  594. background-color: ${p.theme.backgroundSecondary};
  595. }
  596. }
  597. `};
  598. `;
  599. const GroupSummary = styled('div')<{canSelect: boolean}>`
  600. overflow: hidden;
  601. margin-left: ${p => space(p.canSelect ? 1 : 2)};
  602. margin-right: ${space(1)};
  603. flex: 1;
  604. width: 66.66%;
  605. @media (min-width: ${p => p.theme.breakpoints[1]}) {
  606. width: 50%;
  607. }
  608. `;
  609. const GroupCheckBoxWrapper = styled('div')`
  610. margin-left: ${space(2)};
  611. align-self: flex-start;
  612. & input[type='checkbox'] {
  613. margin: 0;
  614. display: block;
  615. }
  616. `;
  617. const primaryStatStyle = (theme: Theme) => css`
  618. font-size: ${theme.fontSizeLarge};
  619. `;
  620. const PrimaryCount = styled(Count)`
  621. ${p => primaryStatStyle(p.theme)};
  622. `;
  623. const PrimaryPercent = styled('div')`
  624. ${p => primaryStatStyle(p.theme)};
  625. `;
  626. const secondaryStatStyle = (theme: Theme) => css`
  627. font-size: ${theme.fontSizeLarge};
  628. :before {
  629. content: '/';
  630. padding-left: ${space(0.25)};
  631. padding-right: 2px;
  632. color: ${theme.gray300};
  633. }
  634. `;
  635. const SecondaryCount = styled(({value, ...p}) => <Count {...p} value={value} />)`
  636. ${p => secondaryStatStyle(p.theme)}
  637. `;
  638. const SecondaryPercent = styled('div')`
  639. ${p => secondaryStatStyle(p.theme)}
  640. `;
  641. const StyledDropdownList = styled('ul')`
  642. z-index: ${p => p.theme.zIndex.hovercard};
  643. `;
  644. type MenuItemProps = React.HTMLProps<HTMLDivElement> & {
  645. to?: React.ComponentProps<typeof Link>['to'];
  646. };
  647. const StyledMenuItem = styled(({to, children, ...p}: MenuItemProps) => (
  648. <MenuItem noAnchor>
  649. {to ? (
  650. // @ts-expect-error allow target _blank for this link to open in new window
  651. <Link to={to} target="_blank">
  652. <div {...p}>{children}</div>
  653. </Link>
  654. ) : (
  655. <div className="dropdown-toggle">
  656. <div {...p}>{children}</div>
  657. </div>
  658. )}
  659. </MenuItem>
  660. ))`
  661. margin: 0;
  662. display: flex;
  663. flex-direction: row;
  664. justify-content: space-between;
  665. `;
  666. const menuItemStatStyles = css`
  667. text-align: right;
  668. font-weight: bold;
  669. padding-left: ${space(1)};
  670. `;
  671. const MenuItemCount = styled(({value, ...p}) => (
  672. <div {...p}>
  673. <Count value={value} />
  674. </div>
  675. ))`
  676. ${menuItemStatStyles};
  677. color: ${p => p.theme.subText};
  678. `;
  679. const MenuItemPercent = styled('div')`
  680. ${menuItemStatStyles};
  681. `;
  682. const MenuItemText = styled('div')`
  683. white-space: nowrap;
  684. font-weight: normal;
  685. text-align: left;
  686. padding-right: ${space(1)};
  687. color: ${p => p.theme.textColor};
  688. `;
  689. const ChartWrapper = styled('div')`
  690. width: 160px;
  691. margin: 0 ${space(2)};
  692. align-self: center;
  693. `;
  694. const EventUserWrapper = styled('div')`
  695. display: flex;
  696. justify-content: flex-end;
  697. align-self: center;
  698. width: 60px;
  699. margin: 0 ${space(2)};
  700. @media (min-width: ${p => p.theme.breakpoints[3]}) {
  701. width: 80px;
  702. }
  703. `;
  704. const AssigneeWrapper = styled('div')`
  705. width: 80px;
  706. margin: 0 ${space(2)};
  707. align-self: center;
  708. `;
  709. // Reprocessing
  710. const StartedColumn = styled('div')`
  711. align-self: center;
  712. margin: 0 ${space(2)};
  713. color: ${p => p.theme.gray500};
  714. ${overflowEllipsis};
  715. width: 85px;
  716. @media (min-width: ${p => p.theme.breakpoints[0]}) {
  717. display: block;
  718. width: 140px;
  719. }
  720. `;
  721. const EventsReprocessedColumn = styled('div')`
  722. align-self: center;
  723. margin: 0 ${space(2)};
  724. color: ${p => p.theme.gray500};
  725. ${overflowEllipsis};
  726. width: 75px;
  727. @media (min-width: ${p => p.theme.breakpoints[0]}) {
  728. width: 140px;
  729. }
  730. `;
  731. const ProgressColumn = styled('div')`
  732. margin: 0 ${space(2)};
  733. align-self: center;
  734. display: none;
  735. @media (min-width: ${p => p.theme.breakpoints[0]}) {
  736. display: block;
  737. width: 160px;
  738. }
  739. `;