import {Component, Fragment} from 'react';
import LazyLoad from 'react-lazyload';
import type {WithRouterProps} from 'react-router';
import type {useSortable} from '@dnd-kit/sortable';
import styled from '@emotion/styled';
import type {Location} from 'history';
import type {Client} from 'sentry/api';
import {Alert} from 'sentry/components/alert';
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 {IconWarning} from 'sentry/icons';
import {t, tct} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Organization, PageFilters} from 'sentry/types';
import type {Series} from 'sentry/types/echarts';
import {getFormattedDate} from 'sentry/utils/dates';
import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
import type {AggregationOutputType} from 'sentry/utils/discover/fields';
import {parseFunction} from 'sentry/utils/discover/fields';
import {hasDDMFeature} from 'sentry/utils/metrics/features';
import {hasOnDemandMetricWidgetFeature} from 'sentry/utils/onDemandMetrics/features';
import {ExtractedMetricsTag} from 'sentry/utils/performance/contexts/metricsEnhancedPerformanceDataContext';
import {
MEPConsumer,
MEPState,
} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry';
import useOrganization from 'sentry/utils/useOrganization';
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 {DASHBOARD_CHART_GROUP} from 'sentry/views/dashboards/dashboard';
import {MetricWidgetCard} from 'sentry/views/dashboards/metrics/widgetCard';
import {Toolbar} from 'sentry/views/dashboards/widgetCard/toolbar';
import type {DashboardFilters, Widget} from '../types';
import {DisplayType, OnDemandExtractionState, WidgetType} from '../types';
import {getColoredWidgetIndicator, hasThresholdMaxValue} from '../utils';
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;
isEditingDashboard: boolean;
location: Location;
organization: Organization;
selection: PageFilters;
widget: Widget;
widgetLimitReached: boolean;
dashboardFilters?: DashboardFilters;
draggableProps?: DraggableProps;
hideToolbar?: boolean;
index?: string;
isEditingWidget?: boolean;
isMobile?: boolean;
isPreview?: boolean;
isWidgetInvalid?: boolean;
noDashboardsMEPProvider?: boolean;
noLazyLoad?: boolean;
onDataFetched?: (results: TableDataWithTitle[]) => void;
onDelete?: () => void;
onDuplicate?: () => void;
onEdit?: () => void;
onUpdate?: (widget: Widget | null) => 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,
isEditingDashboard,
isMobile,
} = this.props;
if (!isEditingDashboard) {
return null;
}
return (
);
}
renderContextMenu() {
const {
widget,
selection,
organization,
showContextMenu,
isPreview,
widgetLimitReached,
onEdit,
onDuplicate,
onDelete,
isEditingDashboard,
router,
location,
index,
} = this.props;
const {seriesData, tableData, pageLinks, totalIssuesCount, seriesResultsType} =
this.state;
if (isEditingDashboard) {
return null;
}
return (
);
}
setData = ({
tableResults,
timeseriesResults,
totalIssuesCount,
pageLinks,
timeseriesResultsTypes,
}: {
pageLinks?: string;
tableResults?: TableDataWithTitle[];
timeseriesResults?: Series[];
timeseriesResultsTypes?: Record;
totalIssuesCount?: string;
}) => {
const {onDataFetched} = this.props;
if (onDataFetched && tableResults) {
onDataFetched(tableResults);
}
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};
}
// prettier-ignore
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
)
)
);
if (widget.widgetType === WidgetType.METRICS) {
if (hasDDMFeature(organization)) {
return (
);
}
}
return (
{t('Error loading widget data')}}
>
{conditionalWrapWithDashboardsMEPProvider(
0
}
disabled={Number(this.props.index) !== 0}
>
{widget.title}
{widget.thresholds &&
hasThresholdMaxValue(widget.thresholds) &&
this.state.tableData &&
organization.features.includes('dashboard-widget-indicators') &&
getColoredWidgetIndicator(
widget.thresholds,
this.state.tableData
)}
{widget.description && (
{widget.description}
)}
{this.renderContextMenu()}
{hasSessionDuration && SESSION_DURATION_ALERT}
{isWidgetInvalid ? (
{renderErrorMessage?.('Widget query condition is invalid.')}
) : noLazyLoad ? (
) : (
)}
{this.renderToolbar()}
{!organization.features.includes('performance-mep-bannerless-ui') && (
{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))));
function DisplayOnDemandWarnings(props: {widget: Widget}) {
const organization = useOrganization();
if (!hasOnDemandMetricWidgetFeature(organization)) {
return null;
}
// prettier-ignore
const widgetContainsHighCardinality = props.widget.queries.some(
wq =>
wq.onDemand?.some(
d => d.extractionState === OnDemandExtractionState.DISABLED_HIGH_CARDINALITY
)
);
// prettier-ignore
const widgetReachedSpecLimit = props.widget.queries.some(
wq =>
wq.onDemand?.some(
d => d.extractionState === OnDemandExtractionState.DISABLED_SPEC_LIMIT
)
);
if (widgetContainsHighCardinality) {
return (
);
}
if (widgetReachedSpecLimit) {
return (
);
}
return null;
}
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 StoredDataAlert = styled(Alert)`
margin-top: ${space(1)};
margin-bottom: 0;
`;
const StyledErrorPanel = styled(ErrorPanel)`
padding: ${space(2)};
`;
export const WidgetTitleRow = styled('span')`
display: flex;
align-items: center;
gap: ${space(0.75)};
`;
export const WidgetDescription = styled('small')`
${p => p.theme.overflowEllipsis}
color: ${p => p.theme.gray300};
`;
const WidgetTitle = styled(HeaderTitle)`
${p => p.theme.overflowEllipsis};
font-weight: normal;
`;
const WidgetHeaderWrapper = 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 WidgetHeaderDescription = styled('div')`
display: flex;
flex-direction: column;
gap: ${space(0.5)};
`;