123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337 |
- import {useCallback, useMemo, useRef} from 'react';
- import {css} from '@emotion/react';
- import styled from '@emotion/styled';
- import {openModal} from 'sentry/actionCreators/modal';
- import {hasEveryAccess} from 'sentry/components/acl/access';
- import {Button} from 'sentry/components/button';
- import ButtonBar from 'sentry/components/buttonBar';
- import ErrorBoundary from 'sentry/components/errorBoundary';
- import {ContextCardContent} from 'sentry/components/events/contexts/contextCard';
- import {getContextMeta} from 'sentry/components/events/contexts/utils';
- import {
- TreeColumn,
- TreeContainer,
- } from 'sentry/components/events/eventTags/eventTagsTree';
- import EventTagsTreeRow from 'sentry/components/events/eventTags/eventTagsTreeRow';
- import {useIssueDetailsColumnCount} from 'sentry/components/events/eventTags/util';
- import EditHighlightsModal from 'sentry/components/events/highlights/editHighlightsModal';
- import {
- EMPTY_HIGHLIGHT_DEFAULT,
- getHighlightContextData,
- getHighlightTagData,
- HIGHLIGHT_DOCS_LINK,
- } from 'sentry/components/events/highlights/util';
- import ExternalLink from 'sentry/components/links/externalLink';
- import LoadingIndicator from 'sentry/components/loadingIndicator';
- import {IconEdit, IconMegaphone} from 'sentry/icons';
- import {t, tct} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import type {Event} from 'sentry/types/event';
- import type {Project} from 'sentry/types/project';
- import {trackAnalytics} from 'sentry/utils/analytics';
- import theme from 'sentry/utils/theme';
- import {useDetailedProject} from 'sentry/utils/useDetailedProject';
- import {useFeedbackForm} from 'sentry/utils/useFeedbackForm';
- import {useLocation} from 'sentry/utils/useLocation';
- import useOrganization from 'sentry/utils/useOrganization';
- import {FoldSectionKey} from 'sentry/views/issueDetails/streamline/foldSection';
- import {InterimSection} from 'sentry/views/issueDetails/streamline/interimSection';
- interface HighlightsDataSectionProps {
- event: Event;
- project: Project;
- viewAllRef?: React.RefObject<HTMLElement>;
- }
- function useOpenEditHighlightsModal({
- detailedProject,
- event,
- }: {
- event: Event;
- detailedProject?: Project;
- }) {
- const organization = useOrganization();
- const isProjectAdmin = hasEveryAccess(['project:admin'], {
- organization: organization,
- project: detailedProject,
- });
- const editProps = useMemo(
- () => ({
- disabled: !isProjectAdmin,
- title: !isProjectAdmin ? t('Only Project Admins can edit highlights.') : undefined,
- }),
- [isProjectAdmin]
- );
- const openEditHighlightsModal = useCallback(() => {
- trackAnalytics('highlights.issue_details.edit_clicked', {organization});
- openModal(
- deps => (
- <EditHighlightsModal
- event={event}
- highlightContext={detailedProject?.highlightContext ?? {}}
- highlightTags={detailedProject?.highlightTags ?? []}
- highlightPreset={detailedProject?.highlightPreset}
- project={detailedProject!}
- {...deps}
- />
- ),
- {modalCss: highlightModalCss}
- );
- }, [organization, detailedProject, event]);
- return {openEditHighlightsModal, editProps};
- }
- function EditHighlightsButton({project, event}: {event: Event; project: Project}) {
- const organization = useOrganization();
- const {isLoading, data: detailedProject} = useDetailedProject({
- orgSlug: organization.slug,
- projectSlug: project.slug,
- });
- const {openEditHighlightsModal, editProps} = useOpenEditHighlightsModal({
- detailedProject,
- event,
- });
- return (
- <Button
- size="xs"
- icon={<IconEdit />}
- onClick={openEditHighlightsModal}
- title={editProps.title}
- disabled={isLoading || editProps.disabled}
- >
- {t('Edit')}
- </Button>
- );
- }
- function HighlightsData({
- event,
- project,
- }: Pick<HighlightsDataSectionProps, 'event' | 'project'>) {
- const organization = useOrganization();
- const location = useLocation();
- const containerRef = useRef<HTMLDivElement>(null);
- const columnCount = useIssueDetailsColumnCount(containerRef);
- const {isLoading, data: detailedProject} = useDetailedProject({
- orgSlug: organization.slug,
- projectSlug: project.slug,
- });
- const {openEditHighlightsModal, editProps} = useOpenEditHighlightsModal({
- detailedProject,
- event,
- });
- const highlightContext = useMemo(
- () => detailedProject?.highlightContext ?? project?.highlightContext ?? {},
- [detailedProject, project]
- );
- const highlightTags = useMemo(
- () => detailedProject?.highlightTags ?? project?.highlightTags ?? [],
- [detailedProject, project]
- );
- // The API will return default values for tags/context. The only way to have none is to set it to
- // empty yourself, meaning the user has disabled highlights
- const hasDisabledHighlights =
- Object.values(highlightContext).flat().length === 0 && highlightTags.length === 0;
- const highlightContextDataItems = getHighlightContextData({
- event,
- project,
- organization,
- highlightContext,
- location,
- });
- const highlightContextRows = highlightContextDataItems.reduce<React.ReactNode[]>(
- (rowList, {alias, data}, i) => {
- const meta = getContextMeta(event, alias);
- const newRows = data.map((item, j) => (
- <HighlightContextContent
- key={`highlight-ctx-${i}-${j}`}
- meta={meta}
- item={item}
- alias={alias}
- config={{includeAliasInSubject: true}}
- data-test-id="highlight-context-row"
- />
- ));
- return [...rowList, ...newRows];
- },
- []
- );
- const highlightTagItems = getHighlightTagData({event, highlightTags});
- const highlightTagRows = highlightTagItems.map((content, i) => (
- <EventTagsTreeRow
- key={`highlight-tag-${i}`}
- content={content}
- event={event}
- tagKey={content.originalTag.key}
- project={detailedProject ?? project}
- config={{
- disableActions: content.value === EMPTY_HIGHLIGHT_DEFAULT,
- disableRichValue: content.value === EMPTY_HIGHLIGHT_DEFAULT,
- }}
- data-test-id="highlight-tag-row"
- />
- ));
- const rows = [...highlightTagRows, ...highlightContextRows];
- const columns: React.ReactNode[] = [];
- const columnSize = Math.ceil(rows.length / columnCount);
- for (let i = 0; i < rows.length; i += columnSize) {
- columns.push(
- <HighlightColumn key={`highlight-column-${i}`}>
- {rows.slice(i, i + columnSize)}
- </HighlightColumn>
- );
- }
- return (
- <HighlightContainer columnCount={columnCount} ref={containerRef}>
- {isLoading ? (
- <EmptyHighlights>
- <HighlightsLoadingIndicator hideMessage size={50} />
- </EmptyHighlights>
- ) : hasDisabledHighlights ? (
- <EmptyHighlights>
- <EmptyHighlightsContent>
- {t("There's nothing here...")}
- <AddHighlightsButton
- size="xs"
- onClick={openEditHighlightsModal}
- {...editProps}
- >
- {t('Add Highlights')}
- </AddHighlightsButton>
- </EmptyHighlightsContent>
- </EmptyHighlights>
- ) : (
- columns
- )}
- </HighlightContainer>
- );
- }
- export default function HighlightsDataSection({
- viewAllRef,
- event,
- project,
- }: HighlightsDataSectionProps) {
- const organization = useOrganization();
- const openForm = useFeedbackForm();
- const viewAllButton = viewAllRef ? (
- <Button
- onClick={() => {
- trackAnalytics('highlights.issue_details.view_all_clicked', {organization});
- viewAllRef?.current?.scrollIntoView({behavior: 'smooth'});
- }}
- size="xs"
- >
- {t('View All')}
- </Button>
- ) : null;
- return (
- <InterimSection
- key="event-highlights"
- type={FoldSectionKey.HIGHLIGHTS}
- title={t('Event Highlights')}
- help={tct(
- 'Promoted tags and context items saved for this project. [link:Learn more]',
- {
- link: <ExternalLink openInNewTab href={HIGHLIGHT_DOCS_LINK} />,
- }
- )}
- isHelpHoverable
- data-test-id="event-highlights"
- actions={
- <ErrorBoundary mini>
- <ButtonBar gap={1}>
- {openForm && (
- <Button
- aria-label={t('Give Feedback')}
- icon={<IconMegaphone />}
- size={'xs'}
- onClick={() =>
- openForm({
- messagePlaceholder: t(
- 'How can we make tags, context or highlights more useful to you?'
- ),
- tags: {
- ['feedback.source']: 'issue_details_highlights',
- ['feedback.owner']: 'issues',
- },
- })
- }
- >
- {t('Feedback')}
- </Button>
- )}
- {viewAllButton}
- <EditHighlightsButton project={project} event={event} />
- </ButtonBar>
- </ErrorBoundary>
- }
- >
- <ErrorBoundary mini message={t('There was an error loading event highlights')}>
- <HighlightsData event={event} project={project} />
- </ErrorBoundary>
- </InterimSection>
- );
- }
- const HighlightContainer = styled(TreeContainer)<{columnCount: number}>`
- margin-top: 0;
- margin-bottom: ${space(2)};
- `;
- const EmptyHighlights = styled('div')`
- padding: ${space(2)} ${space(1)};
- border-radius: ${p => p.theme.borderRadius};
- border: 1px dashed ${p => p.theme.translucentBorder};
- background: ${p => p.theme.bodyBackground};
- grid-column: 1 / -1;
- display: flex;
- text-align: center;
- justify-content: center;
- align-items: center;
- color: ${p => p.theme.subText};
- `;
- const EmptyHighlightsContent = styled('div')`
- text-align: center;
- `;
- const HighlightsLoadingIndicator = styled(LoadingIndicator)`
- margin: 0 auto;
- `;
- const AddHighlightsButton = styled(Button)`
- display: block;
- margin: ${space(1)} auto 0;
- `;
- const HighlightColumn = styled(TreeColumn)`
- grid-column: span 1;
- `;
- const HighlightContextContent = styled(ContextCardContent)`
- font-size: ${p => p.theme.fontSizeSmall};
- `;
- export const highlightModalCss = css`
- width: 850px;
- padding: 0 ${space(2)};
- margin: ${space(2)} 0;
- /* Disable overriding margins with breakpoint on default modal */
- @media (min-width: ${theme.breakpoints.medium}) {
- margin: ${space(2)} 0;
- padding: 0 ${space(2)};
- }
- `;
|