highlightsDataSection.tsx 10 KB

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