import {useState} from 'react';
import styled from '@emotion/styled';
import type {LegendComponentOption} from 'echarts';
import type {Location} from 'history';
import type {Client} from 'sentry/api';
import type {BadgeProps} from 'sentry/components/badge/badge';
import ErrorBoundary from 'sentry/components/errorBoundary';
import {isWidgetViewerPath} from 'sentry/components/modals/widgetViewerModal/utils';
import Panel from 'sentry/components/panels/panel';
import PanelAlert from 'sentry/components/panels/panelAlert';
import Placeholder from 'sentry/components/placeholder';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {PageFilters} from 'sentry/types/core';
import type {Series} from 'sentry/types/echarts';
import type {WithRouterProps} from 'sentry/types/legacyReactRouter';
import type {Organization} from 'sentry/types/organization';
import {defined} from 'sentry/utils';
import {getFormattedDate} from 'sentry/utils/dates';
import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
import type {AggregationOutputType} from 'sentry/utils/discover/fields';
import {hasOnDemandMetricWidgetFeature} from 'sentry/utils/onDemandMetrics/features';
import {useExtractionStatus} from 'sentry/utils/performance/contexts/metricsEnhancedPerformanceDataContext';
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 {useDiscoverSplitAlert} from 'sentry/views/dashboards/discoverSplitAlert';
import {MetricWidgetCard} from 'sentry/views/dashboards/metrics/widgetCard';
import type {DashboardFilters, Widget} from '../types';
import {DisplayType, OnDemandExtractionState, WidgetType} from '../types';
import {DEFAULT_RESULTS_LIMIT} from '../widgetBuilder/utils';
import type WidgetLegendSelectionState from '../widgetLegendSelectionState';
import {BigNumberWidget} from '../widgets/bigNumberWidget/bigNumberWidget';
import type {Meta} from '../widgets/common/types';
import {WidgetFrame} from '../widgets/common/widgetFrame';
import {useDashboardsMEPContext} from './dashboardsMEPContext';
import WidgetCardChartContainer from './widgetCardChartContainer';
import {getMenuOptions, useIndexedEventsWarning} from './widgetCardContextMenu';
import {WidgetCardDataLoader} from './widgetCardDataLoader';
const SESSION_DURATION_INGESTION_STOP_DATE = new Date('2023-01-12');
export const SESSION_DURATION_ALERT_TEXT = 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')
);
export const SESSION_DURATION_ALERT = (
{SESSION_DURATION_ALERT_TEXT}
);
type Props = WithRouterProps & {
api: Client;
isEditingDashboard: boolean;
location: Location;
organization: Organization;
selection: PageFilters;
widget: Widget;
widgetLegendState: WidgetLegendSelectionState;
widgetLimitReached: boolean;
dashboardFilters?: DashboardFilters;
index?: string;
isEditingWidget?: boolean;
isMobile?: boolean;
isPreview?: boolean;
isWidgetInvalid?: boolean;
legendOptions?: LegendComponentOption;
onDataFetched?: (results: TableDataWithTitle[]) => void;
onDelete?: () => void;
onDuplicate?: () => void;
onEdit?: () => void;
onLegendSelectChanged?: () => void;
onSetTransactionsDataset?: () => void;
onUpdate?: (widget: Widget | null) => void;
onWidgetSplitDecision?: (splitDecision: WidgetType) => void;
renderErrorMessage?: (errorMessage?: string) => React.ReactNode;
shouldResize?: boolean;
showContextMenu?: boolean;
showStoredAlert?: boolean;
tableItemLimit?: number;
windowWidth?: number;
};
type Data = {
pageLinks?: string;
tableResults?: TableDataWithTitle[];
timeseriesResults?: Series[];
timeseriesResultsTypes?: Record;
totalIssuesCount?: string;
};
function WidgetCard(props: Props) {
const [data, setData] = useState();
const onDataFetched = (newData: Data) => {
if (props.onDataFetched && newData.tableResults) {
props.onDataFetched(newData.tableResults);
}
setData(newData);
};
const {
api,
organization,
selection,
widget,
isMobile,
renderErrorMessage,
tableItemLimit,
windowWidth,
dashboardFilters,
isWidgetInvalid,
location,
onWidgetSplitDecision,
shouldResize,
onLegendSelectChanged,
onSetTransactionsDataset,
legendOptions,
widgetLegendState,
} = 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'))
);
const {isMetricsData} = useDashboardsMEPContext();
const extractionStatus = useExtractionStatus({queryKey: widget});
const indexedEventsWarning = useIndexedEventsWarning();
const onDemandWarning = useOnDemandWarning({widget});
const discoverSplitAlert = useDiscoverSplitAlert({widget, onSetTransactionsDataset});
const sessionDurationWarning = hasSessionDuration ? SESSION_DURATION_ALERT_TEXT : null;
if (widget.widgetType === WidgetType.METRICS) {
return (
);
}
const onFullScreenViewClick = () => {
if (!isWidgetViewerPath(location.pathname)) {
props.router.push({
pathname: `${location.pathname}${
location.pathname.endsWith('/') ? '' : '/'
}widget/${props.index}/`,
query: location.query,
});
}
};
const onDemandExtractionBadge: BadgeProps | undefined =
extractionStatus === 'extracted'
? {
text: t('Extracted'),
}
: extractionStatus === 'not-extracted'
? {
text: t('Not Extracted'),
}
: undefined;
const indexedDataBadge: BadgeProps | undefined = indexedEventsWarning
? {
text: t('Indexed'),
}
: undefined;
const badges = [indexedDataBadge, onDemandExtractionBadge].filter(
Boolean
) as BadgeProps[];
const warnings = [onDemandWarning, discoverSplitAlert, sessionDurationWarning].filter(
Boolean
) as string[];
const actionsDisabled = Boolean(props.isPreview);
const actionsMessage = actionsDisabled
? t('This is a preview only. To edit, you must add this dashboard.')
: undefined;
const actions = props.showContextMenu
? getMenuOptions(
organization,
selection,
widget,
Boolean(isMetricsData),
props.widgetLimitReached,
props.onDelete,
props.onDuplicate,
props.onEdit
)
: [];
const widgetQueryError = isWidgetInvalid
? t('Widget query condition is invalid.')
: undefined;
return (
{t('Error loading widget data')}}
>
0
}
disabled={Number(props.index) !== 0}
>
{widget.displayType === DisplayType.BIG_NUMBER ? (
{({loading, errorMessage, tableResults}) => {
// Big Number widgets only support one query, so we take the first query's results and meta
const tableData = tableResults?.[0]?.data;
const tableMeta = tableResults?.[0]?.meta as Meta | undefined;
const fields = Object.keys(tableMeta?.fields ?? {});
let field = fields[0];
let selectedField = field;
if (defined(widget.queries[0].selectedAggregate)) {
const index = widget.queries[0].selectedAggregate;
selectedField = widget.queries[0].aggregates[index];
if (fields.includes(selectedField)) {
field = selectedField;
}
}
const value = tableData?.[0]?.[selectedField];
return (
);
}}
) : (
)}
);
}
export default withApi(withOrganization(withPageFilters(withSentryRouter(WidgetCard))));
function useOnDemandWarning(props: {widget: Widget}): string | null {
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 t(
'This widget is using indexed data because it has a column with too many unique values.'
);
}
if (widgetReachedSpecLimit) {
return t(
"This widget is using indexed data because you've reached your organization limit for dynamically extracted metrics."
);
}
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 WidgetCardContextMenuContainer = styled('div')`
opacity: 1;
transition: opacity 0.1s;
`;
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;
&:not(:hover):not(:focus-within) {
${WidgetCardContextMenuContainer} {
opacity: 0;
${p => p.theme.visuallyHidden}
}
}
:hover {
background-color: ${p => p.theme.surface200};
transition:
background-color 100ms linear,
box-shadow 100ms linear;
box-shadow: ${p => p.theme.dropShadowLight};
}
`;
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};
`;