123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321 |
- 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 (
- <Container>
- <FieldShortId shortId={`${data.issue}`} />
- </Container>
- );
- }
- const target = {
- pathname: `/organizations/${organization.slug}/issues/${issueID}/`,
- };
- return (
- <Container>
- <OverflowLink to={target} aria-label={issueID}>
- <FieldShortId shortId={`${data.issue}`} />
- </OverflowLink>
- </Container>
- );
- },
- },
- assignee: {
- sortField: null,
- renderFunc: data => {
- const memberList = MemberListStore.getAll();
- return (
- <ActorContainer>
- <AssigneeSelector id={data.id} memberList={memberList} noDropdown />
- </ActorContainer>
- );
- },
- },
- 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}) => <LinksContainer dangerouslySetInnerHTML={{__html: 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 (
- <Container>
- <Tooltip
- isHoverable
- skipWrapper
- overlayStyle={{padding: 0}}
- title={
- <div>
- {filteredCount ? (
- <Fragment>
- <StyledLink to={filteredDiscoverLink}>
- {t('Matching search filters')}
- <WrappedCount value={filteredCount} />
- </StyledLink>
- <Divider />
- </Fragment>
- ) : null}
- <StyledLink to={discoverLink}>
- {t('Total in %s', selectionDateString)}
- <WrappedCount value={count} />
- </StyledLink>
- <Divider />
- <StyledContent>
- {t('Since issue began')}
- <WrappedCount value={lifetimeCount} />
- </StyledContent>
- </div>
- }
- >
- <span>
- {['events', 'users'].includes(field) && filteredCount ? (
- <Fragment>
- <Count value={filteredCount} />
- <SecondaryCount value={primaryCount} />
- </Fragment>
- ) : (
- <Count value={primaryCount} />
- )}
- </span>
- </Tooltip>
- </Container>
- );
- };
- 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}) => (
- <div {...p}>
- <Count value={value} />
- </div>
- ))`
- 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;
- }
|