group.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787
  1. import {Fragment, useCallback, useMemo, useRef} from 'react';
  2. import type {Theme} from '@emotion/react';
  3. import {css} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import type {LocationDescriptor} from 'history';
  6. import {assignToActor, clearAssignment} from 'sentry/actionCreators/group';
  7. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  8. import {AssigneeBadge} from 'sentry/components/assigneeBadge';
  9. import AssigneeSelectorDropdown, {
  10. type AssignableEntity,
  11. } from 'sentry/components/assigneeSelectorDropdown';
  12. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  13. import {Button} from 'sentry/components/button';
  14. import GroupStatusChart from 'sentry/components/charts/groupStatusChart';
  15. import Checkbox from 'sentry/components/checkbox';
  16. import Count from 'sentry/components/count';
  17. import EventOrGroupExtraDetails from 'sentry/components/eventOrGroupExtraDetails';
  18. import EventOrGroupHeader from 'sentry/components/eventOrGroupHeader';
  19. import {getBadgeProperties} from 'sentry/components/group/inboxBadges/statusBadge';
  20. import type {GroupListColumn} from 'sentry/components/issues/groupList';
  21. import Link from 'sentry/components/links/link';
  22. import PanelItem from 'sentry/components/panels/panelItem';
  23. import Placeholder from 'sentry/components/placeholder';
  24. import ProgressBar from 'sentry/components/progressBar';
  25. import {joinQuery, parseSearch, Token} from 'sentry/components/searchSyntax/parser';
  26. import {getRelativeSummary} from 'sentry/components/timeRangeSelector/utils';
  27. import TimeSince from 'sentry/components/timeSince';
  28. import {Tooltip} from 'sentry/components/tooltip';
  29. import {DEFAULT_STATS_PERIOD} from 'sentry/constants';
  30. import {t} from 'sentry/locale';
  31. import DemoWalkthroughStore from 'sentry/stores/demoWalkthroughStore';
  32. import GroupStore from 'sentry/stores/groupStore';
  33. import SelectedGroupStore from 'sentry/stores/selectedGroupStore';
  34. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  35. import {space} from 'sentry/styles/space';
  36. import type {TimeseriesValue} from 'sentry/types/core';
  37. import type {
  38. Group,
  39. GroupReprocessing,
  40. InboxDetails,
  41. PriorityLevel,
  42. } from 'sentry/types/group';
  43. import {IssueCategory} from 'sentry/types/group';
  44. import type {NewQuery, Organization} from 'sentry/types/organization';
  45. import type {User} from 'sentry/types/user';
  46. import {defined, percent} from 'sentry/utils';
  47. import {trackAnalytics} from 'sentry/utils/analytics';
  48. import {isDemoWalkthrough} from 'sentry/utils/demoMode';
  49. import EventView from 'sentry/utils/discover/eventView';
  50. import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig';
  51. import {useMutation} from 'sentry/utils/queryClient';
  52. import type RequestError from 'sentry/utils/requestError/requestError';
  53. import usePageFilters from 'sentry/utils/usePageFilters';
  54. import withOrganization from 'sentry/utils/withOrganization';
  55. import type {TimePeriodType} from 'sentry/views/alerts/rules/metric/details/constants';
  56. import GroupPriority from 'sentry/views/issueDetails/groupPriority';
  57. import {
  58. DISCOVER_EXCLUSION_FIELDS,
  59. getTabs,
  60. isForReviewQuery,
  61. } from 'sentry/views/issueList/utils';
  62. export const DEFAULT_STREAM_GROUP_STATS_PERIOD = '24h';
  63. type Props = {
  64. id: string;
  65. organization: Organization;
  66. canSelect?: boolean;
  67. customStatsPeriod?: TimePeriodType;
  68. displayReprocessingLayout?: boolean;
  69. hasGuideAnchor?: boolean;
  70. index?: number;
  71. memberList?: User[];
  72. narrowGroups?: boolean;
  73. onPriorityChange?: (newPriority: PriorityLevel) => void;
  74. query?: string;
  75. queryFilterDescription?: string;
  76. showLastTriggered?: boolean;
  77. source?: string;
  78. statsPeriod?: string;
  79. useFilteredStats?: boolean;
  80. useTintRow?: boolean;
  81. withChart?: boolean;
  82. withColumns?: GroupListColumn[];
  83. };
  84. function GroupCheckbox({
  85. group,
  86. displayReprocessingLayout,
  87. }: {
  88. group: Group;
  89. displayReprocessingLayout?: boolean;
  90. }) {
  91. const {records: selectedGroupMap} = useLegacyStore(SelectedGroupStore);
  92. const isSelected = selectedGroupMap.get(group.id) ?? false;
  93. const onChange = useCallback(
  94. (evt: React.ChangeEvent<HTMLInputElement>) => {
  95. const mouseEvent = evt.nativeEvent as MouseEvent;
  96. if (mouseEvent.shiftKey) {
  97. SelectedGroupStore.shiftToggleItems(group.id);
  98. } else {
  99. SelectedGroupStore.toggleSelect(group.id);
  100. }
  101. },
  102. [group.id]
  103. );
  104. return (
  105. <GroupCheckBoxWrapper>
  106. <Checkbox
  107. id={group.id}
  108. aria-label={t('Select Issue')}
  109. checked={isSelected}
  110. disabled={!!displayReprocessingLayout}
  111. onChange={onChange}
  112. />
  113. </GroupCheckBoxWrapper>
  114. );
  115. }
  116. function BaseGroupRow({
  117. id,
  118. organization,
  119. customStatsPeriod,
  120. displayReprocessingLayout,
  121. hasGuideAnchor,
  122. index,
  123. memberList,
  124. query,
  125. queryFilterDescription,
  126. source,
  127. statsPeriod = DEFAULT_STREAM_GROUP_STATS_PERIOD,
  128. canSelect = true,
  129. withChart = true,
  130. withColumns = ['graph', 'event', 'users', 'priority', 'assignee', 'lastTriggered'],
  131. useFilteredStats = false,
  132. useTintRow = true,
  133. narrowGroups = false,
  134. showLastTriggered = false,
  135. onPriorityChange,
  136. }: Props) {
  137. const groups = useLegacyStore(GroupStore);
  138. const group = useMemo(
  139. () => groups.find(item => item.id === id) as Group | undefined,
  140. [groups, id]
  141. );
  142. const originalInboxState = useRef(group?.inbox as InboxDetails | null);
  143. const {selection} = usePageFilters();
  144. const referrer = source ? `${source}-issue-stream` : 'issue-stream';
  145. const {period, start, end} = selection.datetime || {};
  146. const summary =
  147. customStatsPeriod?.label.toLowerCase() ??
  148. (!!start && !!end
  149. ? 'time range'
  150. : getRelativeSummary(period || DEFAULT_STATS_PERIOD).toLowerCase());
  151. const sharedAnalytics = useMemo(() => {
  152. const tab = getTabs().find(([tabQuery]) => tabQuery === query)?.[1];
  153. const owners = group?.owners ?? [];
  154. return {
  155. organization,
  156. group_id: group?.id ?? '',
  157. tab: tab?.analyticsName || 'other',
  158. was_shown_suggestion: owners.length > 0,
  159. };
  160. }, [organization, group, query]);
  161. const {mutate: handleAssigneeChange, isLoading: assigneeLoading} = useMutation<
  162. AssignableEntity | null,
  163. RequestError,
  164. AssignableEntity | null
  165. >({
  166. mutationFn: async (
  167. newAssignee: AssignableEntity | null
  168. ): Promise<AssignableEntity | null> => {
  169. if (newAssignee) {
  170. await assignToActor({
  171. id: group!.id,
  172. orgSlug: organization.slug,
  173. actor: {id: newAssignee.id, type: newAssignee.type},
  174. assignedBy: 'assignee_selector',
  175. });
  176. return Promise.resolve(newAssignee);
  177. }
  178. await clearAssignment(group!.id, organization.slug, 'assignee_selector');
  179. return Promise.resolve(null);
  180. },
  181. onSuccess: (newAssignee: AssignableEntity | null) => {
  182. if (query !== undefined && newAssignee) {
  183. trackAnalytics('issues_stream.issue_assigned', {
  184. ...sharedAnalytics,
  185. did_assign_suggestion: !!newAssignee.suggestedAssignee,
  186. assigned_suggestion_reason: newAssignee.suggestedAssignee?.suggestedReason,
  187. assigned_type: newAssignee.type,
  188. });
  189. }
  190. },
  191. onError: () => {
  192. addErrorMessage('Failed to update assignee');
  193. },
  194. });
  195. const wrapperToggle = useCallback(
  196. (evt: React.MouseEvent<HTMLDivElement>) => {
  197. const targetElement = evt.target as Partial<HTMLElement>;
  198. if (!group) {
  199. return;
  200. }
  201. // Ignore clicks on links
  202. if (targetElement?.tagName?.toLowerCase() === 'a') {
  203. return;
  204. }
  205. // Ignore clicks on the selection checkbox
  206. if (targetElement?.tagName?.toLowerCase() === 'input') {
  207. return;
  208. }
  209. let e = targetElement;
  210. while (e.parentElement) {
  211. if (e?.tagName?.toLowerCase() === 'a') {
  212. return;
  213. }
  214. e = e.parentElement!;
  215. }
  216. if (evt.shiftKey) {
  217. SelectedGroupStore.shiftToggleItems(group.id);
  218. window.getSelection()?.removeAllRanges();
  219. } else {
  220. SelectedGroupStore.toggleSelect(group.id);
  221. }
  222. },
  223. [group]
  224. );
  225. const groupStats = useMemo<ReadonlyArray<TimeseriesValue>>(() => {
  226. if (!group) {
  227. return [];
  228. }
  229. return group.filtered
  230. ? group.filtered.stats?.[statsPeriod]
  231. : group.stats?.[statsPeriod];
  232. }, [group, statsPeriod]);
  233. const groupSecondaryStats = useMemo<ReadonlyArray<TimeseriesValue>>(() => {
  234. if (!group) {
  235. return [];
  236. }
  237. return group.filtered ? group.stats?.[statsPeriod] : [];
  238. }, [group, statsPeriod]);
  239. if (!group) {
  240. return null;
  241. }
  242. const getDiscoverUrl = (isFiltered?: boolean): LocationDescriptor => {
  243. // when there is no discover feature open events page
  244. const hasDiscoverQuery = organization.features.includes('discover-basic');
  245. const parsedResult = parseSearch(
  246. isFiltered && typeof query === 'string' ? query : ''
  247. );
  248. const filteredTerms = parsedResult?.filter(
  249. p => !(p.type === Token.FILTER && DISCOVER_EXCLUSION_FIELDS.includes(p.key.text))
  250. );
  251. const filteredQuery = joinQuery(filteredTerms, true);
  252. const commonQuery = {projects: [Number(group.project.id)]};
  253. if (hasDiscoverQuery) {
  254. const stats = customStatsPeriod ?? (selection.datetime || {});
  255. const discoverQuery: NewQuery = {
  256. ...commonQuery,
  257. id: undefined,
  258. name: group.title || group.type,
  259. fields: ['title', 'release', 'environment', 'user', 'timestamp'],
  260. orderby: '-timestamp',
  261. query: `issue:${group.shortId}${filteredQuery}`,
  262. version: 2,
  263. };
  264. if (!!stats.start && !!stats.end) {
  265. discoverQuery.start = new Date(stats.start).toISOString();
  266. discoverQuery.end = new Date(stats.end).toISOString();
  267. if (stats.utc) {
  268. discoverQuery.utc = true;
  269. }
  270. } else {
  271. discoverQuery.range = stats.period || DEFAULT_STATS_PERIOD;
  272. }
  273. const discoverView = EventView.fromSavedQuery(discoverQuery);
  274. return discoverView.getResultsViewUrlTarget(organization.slug);
  275. }
  276. return {
  277. pathname: `/organizations/${organization.slug}/issues/${group.id}/events/`,
  278. query: {
  279. referrer,
  280. stream_index: index,
  281. ...commonQuery,
  282. query: filteredQuery,
  283. },
  284. };
  285. };
  286. const renderReprocessingColumns = () => {
  287. const {statusDetails, count} = group as GroupReprocessing;
  288. const {info, pendingEvents} = statusDetails;
  289. if (!info) {
  290. return null;
  291. }
  292. const {totalEvents, dateCreated} = info;
  293. const remainingEventsToReprocess = totalEvents - pendingEvents;
  294. const remainingEventsToReprocessPercent = percent(
  295. remainingEventsToReprocess,
  296. totalEvents
  297. );
  298. return (
  299. <Fragment>
  300. <StartedColumn>
  301. <TimeSince date={dateCreated} />
  302. </StartedColumn>
  303. <EventsReprocessedColumn>
  304. {!defined(count) ? (
  305. <Placeholder height="17px" />
  306. ) : (
  307. <Fragment>
  308. <Count value={remainingEventsToReprocess} />
  309. {'/'}
  310. <Count value={totalEvents} />
  311. </Fragment>
  312. )}
  313. </EventsReprocessedColumn>
  314. <ProgressColumn>
  315. <ProgressBar value={remainingEventsToReprocessPercent} />
  316. </ProgressColumn>
  317. </Fragment>
  318. );
  319. };
  320. const issueTypeConfig = getConfigForIssueType(group, group.project);
  321. const reviewed =
  322. // Original state had an inbox reason
  323. originalInboxState.current?.reason !== undefined &&
  324. // Updated state has been removed from inbox
  325. !group!.inbox &&
  326. // Only apply reviewed on the "for review" tab
  327. isForReviewQuery(query);
  328. // Use data.filtered to decide on which value to use
  329. // In case of the query has filters but we avoid showing both sets of filtered/unfiltered stats
  330. // we use useFilteredStats param passed to Group for deciding
  331. const primaryCount = group.filtered ? group.filtered.count : group.count;
  332. const secondaryCount = group.filtered ? group.count : undefined;
  333. const primaryUserCount = group.filtered ? group.filtered.userCount : group.userCount;
  334. const secondaryUserCount = group.filtered ? group.userCount : undefined;
  335. // preview stats
  336. const lastTriggeredDate = group.lastTriggered;
  337. const showSecondaryPoints = Boolean(
  338. withChart && group && group.filtered && statsPeriod && useFilteredStats
  339. );
  340. const groupCategoryCountTitles: Record<IssueCategory, string> = {
  341. [IssueCategory.ERROR]: t('Error Events'),
  342. [IssueCategory.PERFORMANCE]: t('Transaction Events'),
  343. [IssueCategory.PROFILE]: t('Profile Events'),
  344. [IssueCategory.CRON]: t('Cron Events'),
  345. [IssueCategory.REPLAY]: t('Replay Events'),
  346. };
  347. const groupCount = !defined(primaryCount) ? (
  348. <Placeholder height="18px" width="40px" />
  349. ) : (
  350. <GuideAnchor target="dynamic_counts" disabled={!hasGuideAnchor}>
  351. <Tooltip
  352. disabled={!useFilteredStats}
  353. isHoverable
  354. title={
  355. <CountTooltipContent>
  356. <h4>{groupCategoryCountTitles[group.issueCategory]}</h4>
  357. {group.filtered && (
  358. <Fragment>
  359. <div>{queryFilterDescription ?? t('Matching filters')}</div>
  360. <Link to={getDiscoverUrl(true)}>
  361. <Count value={group.filtered?.count} />
  362. </Link>
  363. </Fragment>
  364. )}
  365. <Fragment>
  366. <div>{t('Total in %s', summary)}</div>
  367. <Link to={getDiscoverUrl()}>
  368. <Count value={group.count} />
  369. </Link>
  370. </Fragment>
  371. {group.lifetime && (
  372. <Fragment>
  373. <div>{t('Since issue began')}</div>
  374. <Count value={group.lifetime.count} />
  375. </Fragment>
  376. )}
  377. </CountTooltipContent>
  378. }
  379. >
  380. <PrimaryCount value={primaryCount} />
  381. {secondaryCount !== undefined && useFilteredStats && (
  382. <SecondaryCount value={secondaryCount} />
  383. )}
  384. </Tooltip>
  385. </GuideAnchor>
  386. );
  387. const groupUsersCount = !defined(primaryUserCount) ? (
  388. <Placeholder height="18px" width="40px" />
  389. ) : (
  390. <Tooltip
  391. isHoverable
  392. disabled={!usePageFilters}
  393. title={
  394. <CountTooltipContent>
  395. <h4>{t('Affected Users')}</h4>
  396. {group.filtered && (
  397. <Fragment>
  398. <div>{queryFilterDescription ?? t('Matching filters')}</div>
  399. <Link to={getDiscoverUrl(true)}>
  400. <Count value={group.filtered?.userCount} />
  401. </Link>
  402. </Fragment>
  403. )}
  404. <Fragment>
  405. <div>{t('Total in %s', summary)}</div>
  406. <Link to={getDiscoverUrl()}>
  407. <Count value={group.userCount} />
  408. </Link>
  409. </Fragment>
  410. {group.lifetime && (
  411. <Fragment>
  412. <div>{t('Since issue began')}</div>
  413. <Count value={group.lifetime.userCount} />
  414. </Fragment>
  415. )}
  416. </CountTooltipContent>
  417. }
  418. >
  419. <PrimaryCount value={primaryUserCount} />
  420. {secondaryUserCount !== undefined && useFilteredStats && (
  421. <SecondaryCount dark value={secondaryUserCount} />
  422. )}
  423. </Tooltip>
  424. );
  425. const lastTriggered = !defined(lastTriggeredDate) ? (
  426. <Placeholder height="18px" />
  427. ) : (
  428. <TimeSince
  429. tooltipPrefix={t('Last Triggered')}
  430. date={lastTriggeredDate}
  431. suffix={t('ago')}
  432. unitStyle="short"
  433. />
  434. );
  435. const issueStreamAnchor = isDemoWalkthrough() ? (
  436. <GuideAnchor target="issue_stream" disabled={!DemoWalkthroughStore.get('issue')} />
  437. ) : (
  438. <GuideAnchor target="issue_stream" />
  439. );
  440. return (
  441. <Wrapper
  442. data-test-id="group"
  443. data-test-reviewed={reviewed}
  444. onClick={displayReprocessingLayout || !canSelect ? undefined : wrapperToggle}
  445. reviewed={reviewed}
  446. useTintRow={useTintRow ?? true}
  447. >
  448. {canSelect && (
  449. <GroupCheckbox
  450. group={group}
  451. displayReprocessingLayout={displayReprocessingLayout}
  452. />
  453. )}
  454. <GroupSummary canSelect={canSelect}>
  455. <EventOrGroupHeader
  456. index={index}
  457. organization={organization}
  458. data={group}
  459. query={query}
  460. source={referrer}
  461. />
  462. <EventOrGroupExtraDetails data={group} />
  463. </GroupSummary>
  464. {hasGuideAnchor && issueStreamAnchor}
  465. {withChart && !displayReprocessingLayout && issueTypeConfig.stats.enabled && (
  466. <ChartWrapper narrowGroups={narrowGroups}>
  467. <GroupStatusChart
  468. hideZeros
  469. loading={!defined(groupStats)}
  470. stats={groupStats}
  471. secondaryStats={groupSecondaryStats}
  472. showSecondaryPoints={showSecondaryPoints}
  473. groupStatus={getBadgeProperties(group.status, group.substatus)?.status}
  474. showMarkLine
  475. />
  476. </ChartWrapper>
  477. )}
  478. {displayReprocessingLayout ? (
  479. renderReprocessingColumns()
  480. ) : (
  481. <Fragment>
  482. {withColumns.includes('event') && issueTypeConfig.stats.enabled && (
  483. <EventCountsWrapper leftMargin={space(0)}>{groupCount}</EventCountsWrapper>
  484. )}
  485. {withColumns.includes('users') && issueTypeConfig.stats.enabled && (
  486. <EventCountsWrapper>{groupUsersCount}</EventCountsWrapper>
  487. )}
  488. {withColumns.includes('priority') ? (
  489. <PriorityWrapper narrowGroups={narrowGroups}>
  490. {group.priority ? (
  491. <GroupPriority group={group} onChange={onPriorityChange} />
  492. ) : null}
  493. </PriorityWrapper>
  494. ) : null}
  495. {withColumns.includes('assignee') && (
  496. <AssigneeWrapper narrowGroups={narrowGroups}>
  497. <AssigneeSelectorDropdown
  498. group={group}
  499. loading={assigneeLoading}
  500. memberList={memberList}
  501. onAssign={(assignedActor: AssignableEntity | null) =>
  502. handleAssigneeChange(assignedActor)
  503. }
  504. onClear={() => handleAssigneeChange(null)}
  505. trigger={(props, isOpen) => (
  506. <StyledDropdownButton
  507. {...props}
  508. borderless
  509. aria-label={t('Modify issue assignee')}
  510. size="zero"
  511. >
  512. <AssigneeBadge
  513. assignedTo={group.assignedTo ?? undefined}
  514. assignmentReason={
  515. group.owners?.find(owner => {
  516. const [_ownershipType, ownerId] = owner.owner.split(':');
  517. return ownerId === group.assignedTo?.id;
  518. })?.type
  519. }
  520. loading={assigneeLoading}
  521. chevronDirection={isOpen ? 'up' : 'down'}
  522. />
  523. </StyledDropdownButton>
  524. )}
  525. />
  526. </AssigneeWrapper>
  527. )}
  528. {showLastTriggered && <EventCountsWrapper>{lastTriggered}</EventCountsWrapper>}
  529. </Fragment>
  530. )}
  531. </Wrapper>
  532. );
  533. }
  534. const StreamGroup = withOrganization(BaseGroupRow);
  535. export default StreamGroup;
  536. const StyledDropdownButton = styled(Button)`
  537. font-weight: ${p => p.theme.fontWeightNormal};
  538. border: none;
  539. padding: 0;
  540. height: unset;
  541. border-radius: 10px;
  542. box-shadow: none;
  543. `;
  544. // Position for wrapper is relative for overlay actions
  545. const Wrapper = styled(PanelItem)<{
  546. reviewed: boolean;
  547. useTintRow: boolean;
  548. }>`
  549. position: relative;
  550. padding: ${space(1.5)} 0;
  551. line-height: 1.1;
  552. ${p =>
  553. p.useTintRow &&
  554. p.reviewed &&
  555. css`
  556. animation: tintRow 0.2s linear forwards;
  557. position: relative;
  558. /*
  559. * A mask that fills the entire row and makes the text opaque. Doing this because
  560. * opacity adds a stacking context in CSS so we need to apply it to another element.
  561. */
  562. &:after {
  563. content: '';
  564. pointer-events: none;
  565. position: absolute;
  566. left: 0;
  567. right: 0;
  568. top: 0;
  569. bottom: 0;
  570. width: 100%;
  571. height: 100%;
  572. background-color: ${p.theme.bodyBackground};
  573. opacity: 0.4;
  574. }
  575. @keyframes tintRow {
  576. 0% {
  577. background-color: ${p.theme.bodyBackground};
  578. }
  579. 100% {
  580. background-color: ${p.theme.backgroundSecondary};
  581. }
  582. }
  583. `};
  584. `;
  585. const GroupSummary = styled('div')<{canSelect: boolean}>`
  586. overflow: hidden;
  587. margin-left: ${p => space(p.canSelect ? 1 : 2)};
  588. margin-right: ${space(1)};
  589. flex: 1;
  590. width: 66.66%;
  591. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  592. width: 50%;
  593. }
  594. `;
  595. const GroupCheckBoxWrapper = styled('div')`
  596. margin-left: ${space(2)};
  597. align-self: flex-start;
  598. height: 15px;
  599. display: flex;
  600. align-items: center;
  601. `;
  602. const primaryStatStyle = (theme: Theme) => css`
  603. font-size: ${theme.fontSizeLarge};
  604. font-variant-numeric: tabular-nums;
  605. `;
  606. const PrimaryCount = styled(Count)`
  607. ${p => primaryStatStyle(p.theme)};
  608. `;
  609. const secondaryStatStyle = (theme: Theme) => css`
  610. font-size: ${theme.fontSizeLarge};
  611. font-variant-numeric: tabular-nums;
  612. :before {
  613. content: '/';
  614. padding-left: ${space(0.25)};
  615. padding-right: 2px;
  616. color: ${theme.gray300};
  617. }
  618. `;
  619. const SecondaryCount = styled(({value, ...p}) => <Count {...p} value={value} />)`
  620. ${p => secondaryStatStyle(p.theme)}
  621. `;
  622. const CountTooltipContent = styled('div')`
  623. display: grid;
  624. grid-template-columns: 1fr max-content;
  625. gap: ${space(1)} ${space(3)};
  626. text-align: left;
  627. font-size: ${p => p.theme.fontSizeMedium};
  628. align-items: center;
  629. h4 {
  630. color: ${p => p.theme.gray300};
  631. font-size: ${p => p.theme.fontSizeExtraSmall};
  632. text-transform: uppercase;
  633. grid-column: 1 / -1;
  634. margin-bottom: ${space(0.25)};
  635. }
  636. `;
  637. const ChartWrapper = styled('div')<{narrowGroups: boolean}>`
  638. width: 200px;
  639. align-self: center;
  640. /* prettier-ignore */
  641. @media (max-width: ${p =>
  642. p.narrowGroups ? p.theme.breakpoints.xlarge : p.theme.breakpoints.large}) {
  643. display: none;
  644. }
  645. `;
  646. const EventCountsWrapper = styled('div')<{leftMargin?: string}>`
  647. display: flex;
  648. justify-content: flex-end;
  649. align-self: center;
  650. width: 60px;
  651. margin: 0 ${space(2)};
  652. margin-left: ${p => p.leftMargin ?? space(2)};
  653. @media (min-width: ${p => p.theme.breakpoints.xlarge}) {
  654. width: 80px;
  655. }
  656. `;
  657. const PriorityWrapper = styled('div')<{narrowGroups: boolean}>`
  658. width: 70px;
  659. margin: 0 ${space(2)};
  660. align-self: center;
  661. display: flex;
  662. justify-content: flex-end;
  663. /* prettier-ignore */
  664. @media (max-width: ${p =>
  665. p.narrowGroups ? p.theme.breakpoints.large : p.theme.breakpoints.medium}) {
  666. display: none;
  667. }
  668. `;
  669. const AssigneeWrapper = styled('div')<{narrowGroups: boolean}>`
  670. width: 60px;
  671. margin: 0 ${space(2)};
  672. align-self: center;
  673. /* prettier-ignore */
  674. @media (max-width: ${p =>
  675. p.narrowGroups ? p.theme.breakpoints.large : p.theme.breakpoints.medium}) {
  676. display: none;
  677. }
  678. `;
  679. // Reprocessing
  680. const StartedColumn = styled('div')`
  681. align-self: center;
  682. margin: 0 ${space(2)};
  683. color: ${p => p.theme.gray500};
  684. ${p => p.theme.overflowEllipsis};
  685. width: 85px;
  686. @media (min-width: ${p => p.theme.breakpoints.small}) {
  687. display: block;
  688. width: 140px;
  689. }
  690. `;
  691. const EventsReprocessedColumn = styled('div')`
  692. align-self: center;
  693. margin: 0 ${space(2)};
  694. color: ${p => p.theme.gray500};
  695. ${p => p.theme.overflowEllipsis};
  696. width: 75px;
  697. @media (min-width: ${p => p.theme.breakpoints.small}) {
  698. width: 140px;
  699. }
  700. `;
  701. const ProgressColumn = styled('div')`
  702. margin: 0 ${space(2)};
  703. align-self: center;
  704. display: none;
  705. @media (min-width: ${p => p.theme.breakpoints.small}) {
  706. display: block;
  707. width: 160px;
  708. }
  709. `;