import {Fragment} from 'react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {Location} from 'history'; import AssigneeSelector from 'sentry/components/assigneeSelector'; import Count from 'sentry/components/count'; import Link from 'sentry/components/links/link'; import {getRelativeSummary} from 'sentry/components/organizations/timeRangeSelector/utils'; import Tooltip from 'sentry/components/tooltip'; import {DEFAULT_STATS_PERIOD} from 'sentry/constants'; import {t} from 'sentry/locale'; import MemberListStore from 'sentry/stores/memberListStore'; import space from 'sentry/styles/space'; import {Organization} from 'sentry/types'; import EventView, {EventData} from 'sentry/utils/discover/eventView'; import {FieldKey} from 'sentry/views/dashboardsV2/widgetBuilder/issueWidget/fields'; import {Container, FieldShortId, OverflowLink} from '../discover/styles'; /** * Types, functions and definitions for rendering fields in discover results. */ type RenderFunctionBaggage = { location: Location; organization: Organization; eventView?: EventView; }; type FieldFormatterRenderFunctionPartial = ( data: EventData, baggage: RenderFunctionBaggage ) => React.ReactNode; type SpecialFieldRenderFunc = ( data: EventData, baggage: RenderFunctionBaggage ) => React.ReactNode; type SpecialField = { renderFunc: SpecialFieldRenderFunc; sortField: string | null; }; type SpecialFields = { assignee: SpecialField; count: SpecialField; events: SpecialField; issue: SpecialField; lifetimeCount: SpecialField; lifetimeEvents: SpecialField; lifetimeUserCount: SpecialField; lifetimeUsers: SpecialField; links: SpecialField; userCount: SpecialField; users: SpecialField; }; /** * "Special fields" either do not map 1:1 to an single column in the event database, * or they require custom UI formatting that can't be handled by the datatype formatters. */ const SPECIAL_FIELDS: SpecialFields = { issue: { sortField: null, renderFunc: (data, {organization}) => { const issueID = data['issue.id']; if (!issueID) { return ( ); } const target = { pathname: `/organizations/${organization.slug}/issues/${issueID}/`, }; return ( ); }, }, assignee: { sortField: null, renderFunc: data => { const memberList = MemberListStore.getAll(); return ( ); }, }, lifetimeEvents: { sortField: null, renderFunc: (data, {organization}) => issuesCountRenderer(data, organization, 'lifetimeEvents'), }, lifetimeUsers: { sortField: null, renderFunc: (data, {organization}) => issuesCountRenderer(data, organization, 'lifetimeUsers'), }, events: { sortField: 'freq', renderFunc: (data, {organization}) => issuesCountRenderer(data, organization, 'events'), }, users: { sortField: 'user', renderFunc: (data, {organization}) => issuesCountRenderer(data, organization, 'users'), }, lifetimeCount: { sortField: null, renderFunc: (data, {organization}) => issuesCountRenderer(data, organization, 'lifetimeEvents'), }, lifetimeUserCount: { sortField: null, renderFunc: (data, {organization}) => issuesCountRenderer(data, organization, 'lifetimeUsers'), }, count: { sortField: null, renderFunc: (data, {organization}) => issuesCountRenderer(data, organization, 'events'), }, userCount: { sortField: null, renderFunc: (data, {organization}) => issuesCountRenderer(data, organization, 'users'), }, links: { sortField: null, renderFunc: ({links}) => , }, }; const issuesCountRenderer = ( data: EventData, organization: Organization, field: 'events' | 'users' | 'lifetimeEvents' | 'lifetimeUsers' ) => { const {start, end, period} = data; const isUserField = !!/user/i.exec(field.toLowerCase()); const primaryCount = data[field]; const count = data[isUserField ? 'users' : 'events']; const lifetimeCount = data[isUserField ? 'lifetimeUsers' : 'lifetimeEvents']; const filteredCount = data[isUserField ? 'filteredUsers' : 'filteredEvents']; const discoverLink = getDiscoverUrl(data, organization); const filteredDiscoverLink = getDiscoverUrl(data, organization, true); const selectionDateString = !!start && !!end ? 'time range' : getRelativeSummary(period || DEFAULT_STATS_PERIOD).toLowerCase(); return ( {filteredCount ? ( {t('Matching search filters')} ) : null} {t('Total in %s', selectionDateString)} {t('Since issue began')} } > {['events', 'users'].includes(field) && filteredCount ? ( ) : ( )} ); }; const getDiscoverUrl = ( data: EventData, organization: Organization, filtered?: boolean ) => { const commonQuery = {projects: [Number(data.projectId)]}; const discoverView = EventView.fromSavedQuery({ ...commonQuery, id: undefined, start: data.start, end: data.end, range: data.period, name: data.title, fields: ['title', 'release', 'environment', 'user', 'timestamp'], orderby: '-timestamp', query: `issue.id:${data.id}${filtered ? data.discoverSearchQuery : ''}`, version: 2, }); return discoverView.getResultsViewUrlTarget(organization.slug); }; export function getSortField(field: string): string | null { if (SPECIAL_FIELDS.hasOwnProperty(field)) { return SPECIAL_FIELDS[field as keyof typeof SPECIAL_FIELDS].sortField; } switch (field) { case FieldKey.LAST_SEEN: return 'date'; case FieldKey.FIRST_SEEN: return 'new'; default: return null; } } const contentStyle = css` width: 100%; justify-content: space-between; display: flex; padding: 6px 10px; `; const StyledContent = styled('div')` ${contentStyle}; `; const StyledLink = styled(Link)` ${contentStyle}; color: ${p => p.theme.gray400}; &:hover { color: ${p => p.theme.gray400}; background: ${p => p.theme.hover}; } `; const SecondaryCount = styled(Count)` :before { content: '/'; padding-left: ${space(0.25)}; padding-right: 2px; } `; const WrappedCount = styled(({value, ...p}) => (
))` text-align: right; font-weight: bold; font-variant-numeric: tabular-nums; padding-left: ${space(2)}; color: ${p => p.theme.subText}; `; const Divider = styled('div')` height: 1px; overflow: hidden; background-color: ${p => p.theme.innerBorder}; `; const ActorContainer = styled('div')` display: flex; justify-content: left; margin-left: 18px; /* IconUser is the only one with 20px. We are setting 24px here to make the height consistent */ height: 24px; :hover { cursor: default; } `; const LinksContainer = styled('span')` white-space: nowrap; `; /** * Get the field renderer for the named field and metadata * * @param {String} field name * @param {object} metadata mapping. * @returns {Function} */ export function getIssueFieldRenderer( field: string ): FieldFormatterRenderFunctionPartial | null { if (SPECIAL_FIELDS.hasOwnProperty(field)) { return SPECIAL_FIELDS[field].renderFunc; } // Return null if there is no field renderer for this field // Should check the discover field renderer for this field return null; }