mergedItem.tsx 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. import {useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Button, LinkButton} from 'sentry/components/button';
  4. import Checkbox from 'sentry/components/checkbox';
  5. import {Flex} from 'sentry/components/container/flex';
  6. import EventOrGroupHeader from 'sentry/components/eventOrGroupHeader';
  7. import {Tooltip} from 'sentry/components/tooltip';
  8. import {IconChevron, IconLink} from 'sentry/icons';
  9. import {t} from 'sentry/locale';
  10. import type {Fingerprint} from 'sentry/stores/groupingStore';
  11. import GroupingStore from 'sentry/stores/groupingStore';
  12. import {space} from 'sentry/styles/space';
  13. import {useLocation} from 'sentry/utils/useLocation';
  14. import useOrganization from 'sentry/utils/useOrganization';
  15. import {createIssueLink} from 'sentry/views/issueList/utils';
  16. interface Props {
  17. fingerprint: Fingerprint;
  18. totalFingerprint: number;
  19. }
  20. function MergedItem({fingerprint, totalFingerprint}: Props) {
  21. const organization = useOrganization();
  22. const location = useLocation();
  23. const [busy, setBusy] = useState(false);
  24. const [collapsed, setCollapsed] = useState(false);
  25. const [checked, setChecked] = useState(false);
  26. function onGroupChange({unmergeState}) {
  27. if (!unmergeState) {
  28. return;
  29. }
  30. const stateForId = unmergeState.has(fingerprint.id)
  31. ? unmergeState.get(fingerprint.id)
  32. : undefined;
  33. if (!stateForId) {
  34. return;
  35. }
  36. Object.keys(stateForId).forEach(key => {
  37. if (key === 'collapsed') {
  38. setCollapsed(Boolean(stateForId[key]));
  39. } else if (key === 'checked') {
  40. setChecked(Boolean(stateForId[key]));
  41. } else if (key === 'busy') {
  42. setBusy(Boolean(stateForId[key]));
  43. }
  44. });
  45. }
  46. function handleToggleEvents() {
  47. GroupingStore.onToggleCollapseFingerprint(fingerprint.id);
  48. }
  49. function handleToggle() {
  50. const {latestEvent} = fingerprint;
  51. if (busy) {
  52. return;
  53. }
  54. // clicking anywhere in the row will toggle the checkbox
  55. GroupingStore.onToggleUnmerge([fingerprint.id, latestEvent.id]);
  56. }
  57. function handleCheckClick() {
  58. // noop because of react warning about being a controlled input without `onChange`
  59. // we handle change via row click
  60. }
  61. function renderFingerprint(id: string, label?: string) {
  62. if (!label) {
  63. return id;
  64. }
  65. return (
  66. <Tooltip title={id}>
  67. <code>{label}</code>
  68. </Tooltip>
  69. );
  70. }
  71. useEffect(() => {
  72. const teardown = GroupingStore.listen(data => onGroupChange(data), undefined);
  73. return () => {
  74. teardown();
  75. };
  76. // eslint-disable-next-line react-hooks/exhaustive-deps
  77. }, []);
  78. const {latestEvent, id, label} = fingerprint;
  79. const checkboxDisabled = busy || totalFingerprint === 1;
  80. const issueLink = latestEvent
  81. ? createIssueLink({
  82. organization,
  83. location,
  84. data: latestEvent,
  85. eventId: latestEvent.id,
  86. referrer: 'merged-item',
  87. })
  88. : null;
  89. // `latestEvent` can be null if last event w/ fingerprint is not within retention period
  90. return (
  91. <MergedGroup busy={busy}>
  92. <Controls expanded={!collapsed}>
  93. <FingerprintLabel onClick={handleToggle}>
  94. <Tooltip
  95. containerDisplayMode="flex"
  96. disabled={!checkboxDisabled}
  97. title={
  98. checkboxDisabled && totalFingerprint === 1
  99. ? t('To check, the list must contain 2 or more items')
  100. : undefined
  101. }
  102. >
  103. <Checkbox
  104. value={id}
  105. checked={checked}
  106. disabled={checkboxDisabled}
  107. onChange={handleCheckClick}
  108. size="xs"
  109. />
  110. </Tooltip>
  111. {renderFingerprint(id, label)}
  112. </FingerprintLabel>
  113. <Button
  114. aria-label={
  115. collapsed ? t('Show %s fingerprints', id) : t('Collapse %s fingerprints', id)
  116. }
  117. size="zero"
  118. borderless
  119. icon={<IconChevron direction={collapsed ? 'down' : 'up'} size="xs" />}
  120. onClick={handleToggleEvents}
  121. />
  122. </Controls>
  123. {!collapsed && (
  124. <MergedEventList>
  125. {issueLink ? (
  126. <Flex align="center" gap={space(0.5)}>
  127. <LinkButton
  128. to={issueLink}
  129. icon={<IconLink color={'linkColor'} />}
  130. title={t('View latest event')}
  131. aria-label={t('View latest event')}
  132. borderless
  133. size="xs"
  134. style={{marginLeft: space(1)}}
  135. />
  136. <EventDetails>
  137. <EventOrGroupHeader
  138. data={latestEvent}
  139. organization={organization}
  140. hideIcons
  141. hideLevel
  142. source="merged-item"
  143. />
  144. </EventDetails>
  145. </Flex>
  146. ) : null}
  147. </MergedEventList>
  148. )}
  149. </MergedGroup>
  150. );
  151. }
  152. const MergedGroup = styled('div')<{busy: boolean}>`
  153. ${p => p.busy && 'opacity: 0.2'};
  154. `;
  155. const Controls = styled('div')<{expanded: boolean}>`
  156. display: flex;
  157. justify-content: space-between;
  158. background-color: ${p => p.theme.backgroundSecondary};
  159. ${p => p.expanded && `border-bottom: 1px solid ${p.theme.innerBorder}`};
  160. padding: ${space(0.5)} ${space(1)};
  161. ${MergedGroup}:not(:first-child) & {
  162. border-top: 1px solid ${p => p.theme.innerBorder};
  163. }
  164. ${MergedGroup}:last-child & {
  165. ${p => !p.expanded && `border-bottom: none`};
  166. ${p =>
  167. !p.expanded &&
  168. `border-radius: 0 0 ${p.theme.borderRadius} ${p.theme.borderRadius}`};
  169. }
  170. `;
  171. const FingerprintLabel = styled('label')`
  172. display: flex;
  173. align-items: center;
  174. gap: ${space(1)};
  175. font-family: ${p => p.theme.text.familyMono};
  176. line-height: 1;
  177. font-weight: ${p => p.theme.fontWeightNormal};
  178. margin: 0;
  179. `;
  180. const MergedEventList = styled('div')`
  181. overflow: hidden;
  182. border: none;
  183. background-color: ${p => p.theme.background};
  184. `;
  185. const EventDetails = styled('div')`
  186. display: flex;
  187. justify-content: space-between;
  188. padding: ${space(1)};
  189. `;
  190. export default MergedItem;