import React, {Component, Fragment} from 'react';
import LazyLoad from 'react-lazyload';
import {WithRouterProps} from 'react-router';
import {useSortable} from '@dnd-kit/sortable';
import styled from '@emotion/styled';
import {Location} from 'history';
import {Client} from 'sentry/api';
import {Alert} from 'sentry/components/alert';
import {Button} from 'sentry/components/button';
import ErrorPanel from 'sentry/components/charts/errorPanel';
import {HeaderTitle} from 'sentry/components/charts/styles';
import ErrorBoundary from 'sentry/components/errorBoundary';
import ExternalLink from 'sentry/components/links/externalLink';
import Panel from 'sentry/components/panels/panel';
import PanelAlert from 'sentry/components/panels/panelAlert';
import Placeholder from 'sentry/components/placeholder';
import {parseSearch} from 'sentry/components/searchSyntax/parser';
import {Tooltip} from 'sentry/components/tooltip';
import {IconCopy, IconDelete, IconEdit, IconGrabbable, IconWarning} from 'sentry/icons';
import {t, tct} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import {Organization, PageFilters} from 'sentry/types';
import {Series} from 'sentry/types/echarts';
import {getFormattedDate} from 'sentry/utils/dates';
import {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
import {AggregationOutputType, parseFunction} from 'sentry/utils/discover/fields';
import {
MEPConsumer,
MEPState,
} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry';
import withApi from 'sentry/utils/withApi';
import withOrganization from 'sentry/utils/withOrganization';
import withPageFilters from 'sentry/utils/withPageFilters';
// eslint-disable-next-line no-restricted-imports
import withSentryRouter from 'sentry/utils/withSentryRouter';
import {DRAG_HANDLE_CLASS} from '../dashboard';
import {DashboardFilters, DisplayType, Widget, WidgetType} from '../types';
import {DEFAULT_RESULTS_LIMIT} from '../widgetBuilder/utils';
import {DashboardsMEPConsumer, DashboardsMEPProvider} from './dashboardsMEPContext';
import WidgetCardChartContainer from './widgetCardChartContainer';
import WidgetCardContextMenu from './widgetCardContextMenu';
const SESSION_DURATION_INGESTION_STOP_DATE = new Date('2023-01-12');
export const SESSION_DURATION_ALERT = (
{t(
'session.duration is no longer being recorded as of %s. Data in this widget may be incomplete.',
getFormattedDate(SESSION_DURATION_INGESTION_STOP_DATE, 'MMM D, YYYY')
)}
);
type DraggableProps = Pick, 'attributes' | 'listeners'>;
type Props = WithRouterProps & {
api: Client;
isEditing: boolean;
location: Location;
organization: Organization;
selection: PageFilters;
widget: Widget;
widgetLimitReached: boolean;
dashboardFilters?: DashboardFilters;
draggableProps?: DraggableProps;
hideToolbar?: boolean;
index?: string;
isMobile?: boolean;
isPreview?: boolean;
isWidgetInvalid?: boolean;
noDashboardsMEPProvider?: boolean;
noLazyLoad?: boolean;
onDelete?: () => void;
onDuplicate?: () => void;
onEdit?: () => void;
renderErrorMessage?: (errorMessage?: string) => React.ReactNode;
showContextMenu?: boolean;
showStoredAlert?: boolean;
tableItemLimit?: number;
windowWidth?: number;
};
type State = {
pageLinks?: string;
seriesData?: Series[];
seriesResultsType?: Record;
tableData?: TableDataWithTitle[];
totalIssuesCount?: string;
};
type SearchFilterKey = {key?: {value: string}};
const ERROR_FIELDS = [
'error.handled',
'error.unhandled',
'error.mechanism',
'error.type',
'error.value',
];
class WidgetCard extends Component {
state: State = {};
renderToolbar() {
const {
onEdit,
onDelete,
onDuplicate,
draggableProps,
hideToolbar,
isEditing,
isMobile,
} = this.props;
if (!isEditing) {
return null;
}
return (
{!isMobile && (
}
borderless
className={DRAG_HANDLE_CLASS}
{...draggableProps?.listeners}
{...draggableProps?.attributes}
/>
)}
}
/>
}
/>
}
/>
);
}
renderContextMenu() {
const {
widget,
selection,
organization,
showContextMenu,
isPreview,
widgetLimitReached,
onEdit,
onDuplicate,
onDelete,
isEditing,
router,
location,
index,
} = this.props;
const {seriesData, tableData, pageLinks, totalIssuesCount, seriesResultsType} =
this.state;
if (isEditing) {
return null;
}
return (
);
}
setData = ({
tableResults,
timeseriesResults,
totalIssuesCount,
pageLinks,
timeseriesResultsTypes,
}: {
pageLinks?: string;
tableResults?: TableDataWithTitle[];
timeseriesResults?: Series[];
timeseriesResultsTypes?: Record;
totalIssuesCount?: string;
}) => {
this.setState({
seriesData: timeseriesResults,
tableData: tableResults,
totalIssuesCount,
pageLinks,
seriesResultsType: timeseriesResultsTypes,
});
};
render() {
const {
api,
organization,
selection,
widget,
isMobile,
renderErrorMessage,
tableItemLimit,
windowWidth,
noLazyLoad,
showStoredAlert,
noDashboardsMEPProvider,
dashboardFilters,
isWidgetInvalid,
location,
} = this.props;
if (widget.displayType === DisplayType.TOP_N) {
const queries = widget.queries.map(query => ({
...query,
// Use the last aggregate because that's where the y-axis is stored
aggregates: query.aggregates.length
? [query.aggregates[query.aggregates.length - 1]]
: [],
}));
widget.queries = queries;
widget.limit = DEFAULT_RESULTS_LIMIT;
}
const hasSessionDuration = widget.queries.some(query =>
query.aggregates.some(aggregate => aggregate.includes('session.duration'))
);
function conditionalWrapWithDashboardsMEPProvider(component: React.ReactNode) {
if (noDashboardsMEPProvider) {
return component;
}
return {component};
}
const widgetContainsErrorFields = widget.queries.some(
({columns, aggregates, conditions}) =>
ERROR_FIELDS.some(
errorField =>
columns.includes(errorField) ||
aggregates.some(aggregate =>
parseFunction(aggregate)?.arguments.includes(errorField)
) ||
parseSearch(conditions)?.some(
filter => (filter as SearchFilterKey).key?.value === errorField
)
)
);
return (
{t('Error loading widget data')}}
>
{conditionalWrapWithDashboardsMEPProvider(
0
}
disabled={Number(this.props.index) !== 0}
>
{widget.title}
{widget.description && (
{widget.description}
)}
{({}) => {
// TODO(Tele-Team): Re-enable this when we have a better way to determine if the data is transaction only
// if (
// isMetricsData === false &&
// widget.widgetType === WidgetType.DISCOVER
// ) {
// return (
//
//
//
// );
// }
return null;
}}
{this.renderContextMenu()}
{hasSessionDuration && SESSION_DURATION_ALERT}
{isWidgetInvalid ? (
{renderErrorMessage?.('Widget query condition is invalid.')}
) : noLazyLoad ? (
) : (
)}
{this.renderToolbar()}
{!organization.features.includes('performance-mep-bannerless-ui') &&
(organization.features.includes('dashboards-mep') ||
organization.features.includes('mep-rollout-flag')) && (
{metricSettingContext => {
return (
{({isMetricsData}) => {
if (
showStoredAlert &&
isMetricsData === false &&
widget.widgetType === WidgetType.DISCOVER &&
metricSettingContext &&
metricSettingContext.metricSettingState !==
MEPState.TRANSACTIONS_ONLY
) {
if (!widgetContainsErrorFields) {
return (
{tct(
"Your selection is only applicable to [indexedData: indexed event data]. We've automatically adjusted your results.",
{
indexedData: (
),
}
)}
);
}
}
return null;
}}
);
}}
)}
)}
);
}
}
export default withApi(withOrganization(withPageFilters(withSentryRouter(WidgetCard))));
const ErrorCard = styled(Placeholder)`
display: flex;
align-items: center;
justify-content: center;
background-color: ${p => p.theme.alert.error.backgroundLight};
border: 1px solid ${p => p.theme.alert.error.border};
color: ${p => p.theme.alert.error.textLight};
border-radius: ${p => p.theme.borderRadius};
margin-bottom: ${space(2)};
`;
export const WidgetCardPanel = styled(Panel, {
shouldForwardProp: prop => prop !== 'isDragging',
})<{
isDragging: boolean;
}>`
margin: 0;
visibility: ${p => (p.isDragging ? 'hidden' : 'visible')};
/* If a panel overflows due to a long title stretch its grid sibling */
height: 100%;
min-height: 96px;
display: flex;
flex-direction: column;
`;
const ToolbarPanel = styled('div')`
position: absolute;
top: 0;
left: 0;
z-index: 2;
width: 100%;
height: 100%;
display: flex;
justify-content: flex-end;
align-items: flex-start;
background-color: ${p => p.theme.overlayBackgroundAlpha};
border-radius: calc(${p => p.theme.panelBorderRadius} - 1px);
`;
const IconContainer = styled('div')`
display: flex;
margin: ${space(1)};
touch-action: none;
`;
const GrabbableButton = styled(Button)`
cursor: grab;
`;
const WidgetTitle = styled(HeaderTitle)`
${p => p.theme.overflowEllipsis};
font-weight: normal;
`;
const WidgetHeader = styled('div')`
padding: ${space(2)} ${space(1)} 0 ${space(3)};
min-height: 36px;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
`;
const StoredDataAlert = styled(Alert)`
margin-top: ${space(1)};
margin-bottom: 0;
`;
const StyledErrorPanel = styled(ErrorPanel)`
padding: ${space(2)};
`;
const WidgetHeaderDescription = styled('div')`
display: flex;
flex-direction: column;
gap: ${space(0.5)};
`;
export const WidgetDescription = styled('small')`
${p => p.theme.overflowEllipsis}
color: ${p => p.theme.gray300};
`;