highlightsDataSection.tsx 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. import {useCallback, useMemo, useRef} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {openModal} from 'sentry/actionCreators/modal';
  5. import {hasEveryAccess} from 'sentry/components/acl/access';
  6. import {Button} from 'sentry/components/button';
  7. import ButtonBar from 'sentry/components/buttonBar';
  8. import ErrorBoundary from 'sentry/components/errorBoundary';
  9. import {ContextCardContent} from 'sentry/components/events/contexts/contextCard';
  10. import {getContextMeta} from 'sentry/components/events/contexts/utils';
  11. import {EventDataSection} from 'sentry/components/events/eventDataSection';
  12. import {
  13. TreeColumn,
  14. TreeContainer,
  15. } from 'sentry/components/events/eventTags/eventTagsTree';
  16. import EventTagsTreeRow from 'sentry/components/events/eventTags/eventTagsTreeRow';
  17. import {useIssueDetailsColumnCount} from 'sentry/components/events/eventTags/util';
  18. import EditHighlightsModal from 'sentry/components/events/highlights/editHighlightsModal';
  19. import {
  20. EMPTY_HIGHLIGHT_DEFAULT,
  21. getHighlightContextData,
  22. getHighlightTagData,
  23. HIGHLIGHT_DOCS_LINK,
  24. } from 'sentry/components/events/highlights/util';
  25. import useFeedbackWidget from 'sentry/components/feedback/widget/useFeedbackWidget';
  26. import ExternalLink from 'sentry/components/links/externalLink';
  27. import LoadingIndicator from 'sentry/components/loadingIndicator';
  28. import {IconEdit, IconMegaphone} from 'sentry/icons';
  29. import {t, tct} from 'sentry/locale';
  30. import {space} from 'sentry/styles/space';
  31. import type {Event} from 'sentry/types/event';
  32. import type {Project} from 'sentry/types/project';
  33. import {trackAnalytics} from 'sentry/utils/analytics';
  34. import theme from 'sentry/utils/theme';
  35. import {useDetailedProject} from 'sentry/utils/useDetailedProject';
  36. import {useLocation} from 'sentry/utils/useLocation';
  37. import useOrganization from 'sentry/utils/useOrganization';
  38. interface HighlightsDataSectionProps {
  39. event: Event;
  40. project: Project;
  41. viewAllRef?: React.RefObject<HTMLElement>;
  42. }
  43. function useOpenEditHighlightsModal({
  44. detailedProject,
  45. event,
  46. }: {
  47. event: Event;
  48. detailedProject?: Project;
  49. }) {
  50. const organization = useOrganization();
  51. const isProjectAdmin = hasEveryAccess(['project:admin'], {
  52. organization: organization,
  53. project: detailedProject,
  54. });
  55. const editProps = useMemo(
  56. () => ({
  57. disabled: !isProjectAdmin,
  58. title: !isProjectAdmin ? t('Only Project Admins can edit highlights.') : undefined,
  59. }),
  60. [isProjectAdmin]
  61. );
  62. const openEditHighlightsModal = useCallback(() => {
  63. trackAnalytics('highlights.issue_details.edit_clicked', {organization});
  64. openModal(
  65. deps => (
  66. <EditHighlightsModal
  67. event={event}
  68. highlightContext={detailedProject?.highlightContext ?? {}}
  69. highlightTags={detailedProject?.highlightTags ?? []}
  70. highlightPreset={detailedProject?.highlightPreset}
  71. project={detailedProject!}
  72. {...deps}
  73. />
  74. ),
  75. {modalCss: highlightModalCss}
  76. );
  77. }, [organization, detailedProject, event]);
  78. return {openEditHighlightsModal, editProps};
  79. }
  80. function EditHighlightsButton({project, event}: {event: Event; project: Project}) {
  81. const organization = useOrganization();
  82. const {isLoading, data: detailedProject} = useDetailedProject({
  83. orgSlug: organization.slug,
  84. projectSlug: project.slug,
  85. });
  86. const {openEditHighlightsModal, editProps} = useOpenEditHighlightsModal({
  87. detailedProject,
  88. event,
  89. });
  90. return (
  91. <Button
  92. size="xs"
  93. icon={<IconEdit />}
  94. onClick={openEditHighlightsModal}
  95. title={editProps.title}
  96. disabled={isLoading || editProps.disabled}
  97. >
  98. {t('Edit')}
  99. </Button>
  100. );
  101. }
  102. function HighlightsData({
  103. event,
  104. project,
  105. }: Pick<HighlightsDataSectionProps, 'event' | 'project'>) {
  106. const organization = useOrganization();
  107. const location = useLocation();
  108. const containerRef = useRef<HTMLDivElement>(null);
  109. const columnCount = useIssueDetailsColumnCount(containerRef);
  110. const {isLoading, data: detailedProject} = useDetailedProject({
  111. orgSlug: organization.slug,
  112. projectSlug: project.slug,
  113. });
  114. const {openEditHighlightsModal, editProps} = useOpenEditHighlightsModal({
  115. detailedProject,
  116. event,
  117. });
  118. const highlightContext = useMemo(
  119. () => detailedProject?.highlightContext ?? project?.highlightContext ?? {},
  120. [detailedProject, project]
  121. );
  122. const highlightTags = useMemo(
  123. () => detailedProject?.highlightTags ?? project?.highlightTags ?? [],
  124. [detailedProject, project]
  125. );
  126. // The API will return default values for tags/context. The only way to have none is to set it to
  127. // empty yourself, meaning the user has disabled highlights
  128. const hasDisabledHighlights =
  129. Object.values(highlightContext).flat().length === 0 && highlightTags.length === 0;
  130. const highlightContextDataItems = getHighlightContextData({
  131. event,
  132. project,
  133. organization,
  134. highlightContext,
  135. location,
  136. });
  137. const highlightContextRows = highlightContextDataItems.reduce<React.ReactNode[]>(
  138. (rowList, {alias, data}, i) => {
  139. const meta = getContextMeta(event, alias);
  140. const newRows = data.map((item, j) => (
  141. <HighlightContextContent
  142. key={`highlight-ctx-${i}-${j}`}
  143. meta={meta}
  144. item={item}
  145. alias={alias}
  146. config={{includeAliasInSubject: true}}
  147. data-test-id="highlight-context-row"
  148. />
  149. ));
  150. return [...rowList, ...newRows];
  151. },
  152. []
  153. );
  154. const highlightTagItems = getHighlightTagData({event, highlightTags});
  155. const highlightTagRows = highlightTagItems.map((content, i) => (
  156. <EventTagsTreeRow
  157. key={`highlight-tag-${i}`}
  158. content={content}
  159. event={event}
  160. tagKey={content.originalTag.key}
  161. project={detailedProject ?? project}
  162. config={{
  163. disableActions: content.value === EMPTY_HIGHLIGHT_DEFAULT,
  164. disableRichValue: content.value === EMPTY_HIGHLIGHT_DEFAULT,
  165. }}
  166. data-test-id="highlight-tag-row"
  167. />
  168. ));
  169. const rows = [...highlightTagRows, ...highlightContextRows];
  170. const columns: React.ReactNode[] = [];
  171. const columnSize = Math.ceil(rows.length / columnCount);
  172. for (let i = 0; i < rows.length; i += columnSize) {
  173. columns.push(
  174. <HighlightColumn key={`highlight-column-${i}`}>
  175. {rows.slice(i, i + columnSize)}
  176. </HighlightColumn>
  177. );
  178. }
  179. return (
  180. <HighlightContainer columnCount={columnCount} ref={containerRef}>
  181. {isLoading ? (
  182. <EmptyHighlights>
  183. <HighlightsLoadingIndicator hideMessage size={50} />
  184. </EmptyHighlights>
  185. ) : hasDisabledHighlights ? (
  186. <EmptyHighlights>
  187. <EmptyHighlightsContent>
  188. {t("There's nothing here...")}
  189. <AddHighlightsButton
  190. size="xs"
  191. onClick={openEditHighlightsModal}
  192. {...editProps}
  193. >
  194. {t('Add Highlights')}
  195. </AddHighlightsButton>
  196. </EmptyHighlightsContent>
  197. </EmptyHighlights>
  198. ) : (
  199. columns
  200. )}
  201. </HighlightContainer>
  202. );
  203. }
  204. function HighlightsFeedback() {
  205. const buttonRef = useRef<HTMLButtonElement>(null);
  206. const feedback = useFeedbackWidget({
  207. buttonRef,
  208. messagePlaceholder: t(
  209. 'How can we make tags, context or highlights more useful to you?'
  210. ),
  211. });
  212. if (!feedback) {
  213. return null;
  214. }
  215. return (
  216. <Button
  217. ref={buttonRef}
  218. aria-label={t('Give Feedback')}
  219. icon={<IconMegaphone />}
  220. size={'xs'}
  221. >
  222. {t('Feedback')}
  223. </Button>
  224. );
  225. }
  226. export default function HighlightsDataSection({
  227. viewAllRef,
  228. event,
  229. project,
  230. }: HighlightsDataSectionProps) {
  231. const organization = useOrganization();
  232. const viewAllButton = viewAllRef ? (
  233. <Button
  234. onClick={() => {
  235. trackAnalytics('highlights.issue_details.view_all_clicked', {organization});
  236. viewAllRef?.current?.scrollIntoView({behavior: 'smooth'});
  237. }}
  238. size="xs"
  239. >
  240. {t('View All')}
  241. </Button>
  242. ) : null;
  243. return (
  244. <EventDataSection
  245. key="event-highlights"
  246. type="event-highlights"
  247. title={t('Event Highlights')}
  248. help={tct(
  249. 'Promoted tags and context items saved for this project. [link:Learn more]',
  250. {
  251. link: <ExternalLink openInNewTab href={HIGHLIGHT_DOCS_LINK} />,
  252. }
  253. )}
  254. isHelpHoverable
  255. data-test-id="event-highlights"
  256. actions={
  257. <ErrorBoundary mini>
  258. <ButtonBar gap={1}>
  259. <HighlightsFeedback />
  260. {viewAllButton}
  261. <EditHighlightsButton project={project} event={event} />
  262. </ButtonBar>
  263. </ErrorBoundary>
  264. }
  265. >
  266. <ErrorBoundary mini message={t('There was an error loading event highlights')}>
  267. <HighlightsData event={event} project={project} />
  268. </ErrorBoundary>
  269. </EventDataSection>
  270. );
  271. }
  272. const HighlightContainer = styled(TreeContainer)<{columnCount: number}>`
  273. margin-top: 0;
  274. margin-bottom: ${space(2)};
  275. `;
  276. const EmptyHighlights = styled('div')`
  277. padding: ${space(2)} ${space(1)};
  278. border-radius: ${p => p.theme.borderRadius};
  279. border: 1px dashed ${p => p.theme.translucentBorder};
  280. background: ${p => p.theme.bodyBackground};
  281. grid-column: 1 / -1;
  282. display: flex;
  283. text-align: center;
  284. justify-content: center;
  285. align-items: center;
  286. color: ${p => p.theme.subText};
  287. `;
  288. const EmptyHighlightsContent = styled('div')`
  289. text-align: center;
  290. `;
  291. const HighlightsLoadingIndicator = styled(LoadingIndicator)`
  292. margin: 0 auto;
  293. `;
  294. const AddHighlightsButton = styled(Button)`
  295. display: block;
  296. margin: ${space(1)} auto 0;
  297. `;
  298. const HighlightColumn = styled(TreeColumn)`
  299. grid-column: span 1;
  300. `;
  301. const HighlightContextContent = styled(ContextCardContent)`
  302. font-size: ${p => p.theme.fontSizeSmall};
  303. `;
  304. export const highlightModalCss = css`
  305. width: 850px;
  306. padding: 0 ${space(2)};
  307. margin: ${space(2)} 0;
  308. /* Disable overriding margins with breakpoint on default modal */
  309. @media (min-width: ${theme.breakpoints.medium}) {
  310. margin: ${space(2)} 0;
  311. padding: 0 ${space(2)};
  312. }
  313. `;