Files
2024-02-15 17:01:50 +00:00

492 lines
16 KiB
TypeScript

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 {ExtractedMetricsTag} from 'sentry/utils/performance/contexts/metricsEnhancedPerformanceDataContext';
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 {DASHBOARD_CHART_GROUP} from 'sentry/views/dashboards/dashboard';
import {MetricWidgetCard} from 'sentry/views/dashboards/widgetCard/metricWidgetCard';
import {Toolbar} from 'sentry/views/dashboards/widgetCard/toolbar';
import type {DashboardFilters, Widget} from '../types';
import {DisplayType, 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 = (
<PanelAlert type="warning">
{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')
)}
</PanelAlert>
);
type DraggableProps = Pick<ReturnType<typeof useSortable>, '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<string, AggregationOutputType>;
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<Props, State> {
state: State = {};
renderToolbar() {
const {
onEdit,
onDelete,
onDuplicate,
draggableProps,
hideToolbar,
isEditingDashboard,
isMobile,
} = this.props;
if (!isEditingDashboard) {
return null;
}
return (
<Toolbar
onEdit={onEdit}
onDelete={onDelete}
onDuplicate={onDuplicate}
draggableProps={draggableProps}
hideToolbar={hideToolbar}
isMobile={isMobile}
/>
);
}
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 (
<WidgetCardContextMenu
organization={organization}
widget={widget}
selection={selection}
showContextMenu={showContextMenu}
isPreview={isPreview}
widgetLimitReached={widgetLimitReached}
onDuplicate={onDuplicate}
onEdit={onEdit}
onDelete={onDelete}
router={router}
location={location}
index={index}
seriesData={seriesData}
seriesResultsType={seriesResultsType}
tableData={tableData}
pageLinks={pageLinks}
totalIssuesCount={totalIssuesCount}
/>
);
}
setData = ({
tableResults,
timeseriesResults,
totalIssuesCount,
pageLinks,
timeseriesResultsTypes,
}: {
pageLinks?: string;
tableResults?: TableDataWithTitle[];
timeseriesResults?: Series[];
timeseriesResultsTypes?: Record<string, AggregationOutputType>;
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 <DashboardsMEPProvider>{component}</DashboardsMEPProvider>;
}
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 (
<MetricWidgetCard
index={this.props.index}
isEditingDashboard={this.props.isEditingDashboard}
onEdit={this.props.onEdit}
onDelete={this.props.onDelete}
onDuplicate={this.props.onDuplicate}
router={this.props.router}
location={this.props.location}
organization={organization}
selection={selection}
widget={widget}
dashboardFilters={dashboardFilters}
renderErrorMessage={renderErrorMessage}
/>
);
}
}
return (
<ErrorBoundary
customComponent={<ErrorCard>{t('Error loading widget data')}</ErrorCard>}
>
{conditionalWrapWithDashboardsMEPProvider(
<Fragment>
<VisuallyCompleteWithData
id="DashboardList-FirstWidgetCard"
hasData={
((this.state.tableData?.length || this.state.seriesData?.length) ?? 0) > 0
}
disabled={Number(this.props.index) !== 0}
>
<WidgetCardPanel isDragging={false}>
<WidgetHeaderWrapper>
<WidgetHeaderDescription>
<WidgetTitleRow>
<Tooltip
title={widget.title}
containerDisplayMode="grid"
showOnlyOnOverflow
>
<WidgetTitle>{widget.title}</WidgetTitle>
</Tooltip>
{widget.thresholds &&
hasThresholdMaxValue(widget.thresholds) &&
this.state.tableData &&
organization.features.includes('dashboard-widget-indicators') &&
getColoredWidgetIndicator(
widget.thresholds,
this.state.tableData
)}
<ExtractedMetricsTag queryKey={widget} />
</WidgetTitleRow>
{widget.description && (
<Tooltip
title={widget.description}
containerDisplayMode="grid"
showOnlyOnOverflow
isHoverable
>
<WidgetDescription>{widget.description}</WidgetDescription>
</Tooltip>
)}
</WidgetHeaderDescription>
{this.renderContextMenu()}
</WidgetHeaderWrapper>
{hasSessionDuration && SESSION_DURATION_ALERT}
{isWidgetInvalid ? (
<Fragment>
{renderErrorMessage?.('Widget query condition is invalid.')}
<StyledErrorPanel>
<IconWarning color="gray500" size="lg" />
</StyledErrorPanel>
</Fragment>
) : noLazyLoad ? (
<WidgetCardChartContainer
location={location}
api={api}
organization={organization}
selection={selection}
widget={widget}
isMobile={isMobile}
renderErrorMessage={renderErrorMessage}
tableItemLimit={tableItemLimit}
windowWidth={windowWidth}
onDataFetched={this.setData}
dashboardFilters={dashboardFilters}
chartGroup={DASHBOARD_CHART_GROUP}
/>
) : (
<LazyLoad once resize height={200}>
<WidgetCardChartContainer
location={location}
api={api}
organization={organization}
selection={selection}
widget={widget}
isMobile={isMobile}
renderErrorMessage={renderErrorMessage}
tableItemLimit={tableItemLimit}
windowWidth={windowWidth}
onDataFetched={this.setData}
dashboardFilters={dashboardFilters}
chartGroup={DASHBOARD_CHART_GROUP}
/>
</LazyLoad>
)}
{this.renderToolbar()}
</WidgetCardPanel>
</VisuallyCompleteWithData>
{!organization.features.includes('performance-mep-bannerless-ui') && (
<MEPConsumer>
{metricSettingContext => {
return (
<DashboardsMEPConsumer>
{({isMetricsData}) => {
if (
showStoredAlert &&
isMetricsData === false &&
widget.widgetType === WidgetType.DISCOVER &&
metricSettingContext &&
metricSettingContext.metricSettingState !==
MEPState.TRANSACTIONS_ONLY
) {
if (!widgetContainsErrorFields) {
return (
<StoredDataAlert showIcon>
{tct(
"Your selection is only applicable to [indexedData: indexed event data]. We've automatically adjusted your results.",
{
indexedData: (
<ExternalLink href="https://docs.sentry.io/product/dashboards/widget-builder/#errors--transactions" />
),
}
)}
</StoredDataAlert>
);
}
}
return null;
}}
</DashboardsMEPConsumer>
);
}}
</MEPConsumer>
)}
</Fragment>
)}
</ErrorBoundary>
);
}
}
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 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)};
`;