mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-10-30 14:49:14 +00:00
1369 lines
48 KiB
TypeScript
1369 lines
48 KiB
TypeScript
import {useEffect, useMemo, useState} from 'react';
|
|
import type {RouteComponentProps} from 'react-router';
|
|
import styled from '@emotion/styled';
|
|
import cloneDeep from 'lodash/cloneDeep';
|
|
import omit from 'lodash/omit';
|
|
import set from 'lodash/set';
|
|
|
|
import {validateWidget} from 'sentry/actionCreators/dashboards';
|
|
import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
|
|
import {fetchOrgMembers} from 'sentry/actionCreators/members';
|
|
import {loadOrganizationTags} from 'sentry/actionCreators/tags';
|
|
import FieldWrapper from 'sentry/components/forms/fieldGroup/fieldWrapper';
|
|
import InputField from 'sentry/components/forms/fields/inputField';
|
|
import TextareaField from 'sentry/components/forms/fields/textareaField';
|
|
import * as Layout from 'sentry/components/layouts/thirds';
|
|
import List from 'sentry/components/list';
|
|
import ListItem from 'sentry/components/list/listItem';
|
|
import LoadingError from 'sentry/components/loadingError';
|
|
import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
|
|
import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
|
|
import {t} from 'sentry/locale';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {DateString, Organization, PageFilters, TagCollection} from 'sentry/types';
|
|
import {defined, objectIsEmpty} from 'sentry/utils';
|
|
import {trackAnalytics} from 'sentry/utils/analytics';
|
|
import {CustomMeasurementsProvider} from 'sentry/utils/customMeasurements/customMeasurementsProvider';
|
|
import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
|
|
import EventView from 'sentry/utils/discover/eventView';
|
|
import type {QueryFieldValue} from 'sentry/utils/discover/fields';
|
|
import {
|
|
explodeField,
|
|
generateFieldAsString,
|
|
getColumnsAndAggregates,
|
|
getColumnsAndAggregatesAsStrings,
|
|
} from 'sentry/utils/discover/fields';
|
|
import {MetricsCardinalityProvider} from 'sentry/utils/performance/contexts/metricsCardinality';
|
|
import {MetricsResultsMetaProvider} from 'sentry/utils/performance/contexts/metricsEnhancedPerformanceDataContext';
|
|
import {MEPSettingProvider} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
|
|
import {OnDemandControlProvider} from 'sentry/utils/performance/contexts/onDemandControl';
|
|
import useApi from 'sentry/utils/useApi';
|
|
import {normalizeUrl} from 'sentry/utils/withDomainRequired';
|
|
import withPageFilters from 'sentry/utils/withPageFilters';
|
|
import withTags from 'sentry/utils/withTags';
|
|
import {
|
|
assignTempId,
|
|
enforceWidgetHeightValues,
|
|
generateWidgetsAfterCompaction,
|
|
getDefaultWidgetHeight,
|
|
} from 'sentry/views/dashboards/layoutUtils';
|
|
import type {DashboardDetails, Widget, WidgetQuery} from 'sentry/views/dashboards/types';
|
|
import {
|
|
DashboardWidgetSource,
|
|
DisplayType,
|
|
WidgetType,
|
|
} from 'sentry/views/dashboards/types';
|
|
import {MetricsDataSwitcher} from 'sentry/views/performance/landing/metricsDataSwitcher';
|
|
|
|
import {DEFAULT_STATS_PERIOD} from '../data';
|
|
import {getDatasetConfig} from '../datasetConfig/base';
|
|
import {hasThresholdMaxValue} from '../utils';
|
|
import {
|
|
DashboardsMEPConsumer,
|
|
DashboardsMEPProvider,
|
|
} from '../widgetCard/dashboardsMEPContext';
|
|
|
|
import {BuildStep} from './buildSteps/buildStep';
|
|
import {ColumnsStep} from './buildSteps/columnsStep';
|
|
import {DataSetStep} from './buildSteps/dataSetStep';
|
|
import {FilterResultsStep} from './buildSteps/filterResultsStep';
|
|
import {GroupByStep} from './buildSteps/groupByStep';
|
|
import {SortByStep} from './buildSteps/sortByStep';
|
|
import type {
|
|
ThresholdMaxKeys,
|
|
ThresholdsConfig,
|
|
} from './buildSteps/thresholdsStep/thresholdsStep';
|
|
import ThresholdsStep from './buildSteps/thresholdsStep/thresholdsStep';
|
|
import {VisualizationStep} from './buildSteps/visualizationStep';
|
|
import {YAxisStep} from './buildSteps/yAxisStep';
|
|
import {Footer} from './footer';
|
|
import {Header} from './header';
|
|
import {
|
|
DataSet,
|
|
DEFAULT_RESULTS_LIMIT,
|
|
generateOrderOptions,
|
|
getIsTimeseriesChart,
|
|
getParsedDefaultWidgetQuery,
|
|
getResultsLimit,
|
|
mapErrors,
|
|
NEW_DASHBOARD_ID,
|
|
normalizeQueries,
|
|
} from './utils';
|
|
import {WidgetLibrary} from './widgetLibrary';
|
|
|
|
const WIDGET_TYPE_TO_DATA_SET = {
|
|
[WidgetType.DISCOVER]: DataSet.EVENTS,
|
|
[WidgetType.ISSUE]: DataSet.ISSUES,
|
|
[WidgetType.RELEASE]: DataSet.RELEASES,
|
|
[WidgetType.METRICS]: DataSet.METRICS,
|
|
};
|
|
|
|
export const DATA_SET_TO_WIDGET_TYPE = {
|
|
[DataSet.EVENTS]: WidgetType.DISCOVER,
|
|
[DataSet.ISSUES]: WidgetType.ISSUE,
|
|
[DataSet.RELEASES]: WidgetType.RELEASE,
|
|
[DataSet.METRICS]: WidgetType.METRICS,
|
|
};
|
|
|
|
interface RouteParams {
|
|
dashboardId: string;
|
|
orgId: string;
|
|
widgetIndex?: string;
|
|
}
|
|
|
|
interface QueryData {
|
|
queryConditions: string[];
|
|
queryFields: string[];
|
|
queryNames: string[];
|
|
queryOrderby: string;
|
|
}
|
|
|
|
interface Props extends RouteComponentProps<RouteParams, {}> {
|
|
dashboard: DashboardDetails;
|
|
onSave: (widgets: Widget[]) => void;
|
|
organization: Organization;
|
|
selection: PageFilters;
|
|
tags: TagCollection;
|
|
displayType?: DisplayType;
|
|
end?: DateString;
|
|
start?: DateString;
|
|
statsPeriod?: string | null;
|
|
}
|
|
|
|
interface State {
|
|
dataSet: DataSet;
|
|
displayType: Widget['displayType'];
|
|
interval: Widget['interval'];
|
|
limit: Widget['limit'];
|
|
loading: boolean;
|
|
prebuiltWidgetId: null | string;
|
|
queries: Widget['queries'];
|
|
queryConditionsValid: boolean;
|
|
title: string;
|
|
userHasModified: boolean;
|
|
dataType?: string;
|
|
dataUnit?: string;
|
|
description?: string;
|
|
errors?: Record<string, any>;
|
|
selectedDashboard?: DashboardDetails['id'];
|
|
thresholds?: ThresholdsConfig | null;
|
|
widgetToBeUpdated?: Widget;
|
|
}
|
|
|
|
function WidgetBuilder({
|
|
dashboard,
|
|
params,
|
|
location,
|
|
organization,
|
|
selection,
|
|
start,
|
|
end,
|
|
statsPeriod,
|
|
onSave,
|
|
route,
|
|
router,
|
|
tags,
|
|
}: Props) {
|
|
const {widgetIndex, orgId, dashboardId} = params;
|
|
const {source, displayType, defaultTitle, limit, dataset} = location.query;
|
|
const defaultWidgetQuery = getParsedDefaultWidgetQuery(
|
|
location.query.defaultWidgetQuery
|
|
);
|
|
|
|
// defaultTableColumns can be a single string if location.query only contains
|
|
// 1 value for this key. Ensure it is a string[]
|
|
let {defaultTableColumns}: {defaultTableColumns: string[]} = location.query;
|
|
if (typeof defaultTableColumns === 'string') {
|
|
defaultTableColumns = [defaultTableColumns];
|
|
}
|
|
|
|
const hasReleaseHealthFeature = organization.features.includes('dashboards-rh-widget');
|
|
|
|
const filteredDashboardWidgets = dashboard.widgets.filter(({widgetType}) => {
|
|
if (widgetType === WidgetType.RELEASE) {
|
|
return hasReleaseHealthFeature;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const isEditing = defined(widgetIndex);
|
|
const widgetIndexNum = Number(widgetIndex);
|
|
const isValidWidgetIndex =
|
|
widgetIndexNum >= 0 &&
|
|
widgetIndexNum < filteredDashboardWidgets.length &&
|
|
Number.isInteger(widgetIndexNum);
|
|
const orgSlug = organization.slug;
|
|
|
|
// Construct PageFilters object using statsPeriod/start/end props so we can
|
|
// render widget graph using saved timeframe from Saved/Prebuilt Query
|
|
const pageFilters: PageFilters = statsPeriod
|
|
? {...selection, datetime: {start: null, end: null, period: statsPeriod, utc: null}}
|
|
: start && end
|
|
? {...selection, datetime: {start, end, period: null, utc: null}}
|
|
: selection;
|
|
|
|
// when opening from discover or issues page, the user selects the dashboard in the widget UI
|
|
const notDashboardsOrigin = [
|
|
DashboardWidgetSource.DISCOVERV2,
|
|
DashboardWidgetSource.ISSUE_DETAILS,
|
|
].includes(source);
|
|
|
|
const dataSet = dataset ? dataset : DataSet.EVENTS;
|
|
|
|
const api = useApi();
|
|
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
const [datasetConfig, setDataSetConfig] = useState<ReturnType<typeof getDatasetConfig>>(
|
|
getDatasetConfig(DATA_SET_TO_WIDGET_TYPE[dataSet])
|
|
);
|
|
|
|
const defaultThresholds: ThresholdsConfig = {max_values: {}, unit: null};
|
|
const [state, setState] = useState<State>(() => {
|
|
const defaultState: State = {
|
|
title: defaultTitle ?? t('Custom Widget'),
|
|
displayType:
|
|
(displayType === DisplayType.TOP_N ? DisplayType.AREA : displayType) ??
|
|
DisplayType.TABLE,
|
|
interval: '5m',
|
|
queries: [],
|
|
thresholds: defaultThresholds,
|
|
limit: limit ? Number(limit) : undefined,
|
|
errors: undefined,
|
|
description: undefined,
|
|
dataType: undefined,
|
|
dataUnit: undefined,
|
|
loading: !!notDashboardsOrigin,
|
|
userHasModified: false,
|
|
prebuiltWidgetId: null,
|
|
dataSet,
|
|
queryConditionsValid: true,
|
|
selectedDashboard: dashboard.id || NEW_DASHBOARD_ID,
|
|
};
|
|
|
|
if (defaultWidgetQuery) {
|
|
defaultState.queries = [
|
|
{
|
|
...defaultWidgetQuery,
|
|
orderby:
|
|
defaultWidgetQuery.orderby ||
|
|
(datasetConfig.getTableSortOptions
|
|
? datasetConfig.getTableSortOptions(organization, defaultWidgetQuery)[0]
|
|
.value
|
|
: ''),
|
|
},
|
|
];
|
|
|
|
if (
|
|
![DisplayType.TABLE, DisplayType.TOP_N].includes(defaultState.displayType) &&
|
|
!(
|
|
getIsTimeseriesChart(defaultState.displayType) &&
|
|
defaultState.queries[0].columns.length
|
|
)
|
|
) {
|
|
defaultState.queries[0].orderby = '';
|
|
}
|
|
} else {
|
|
defaultState.queries = [{...datasetConfig.defaultWidgetQuery}];
|
|
}
|
|
|
|
return defaultState;
|
|
});
|
|
|
|
const [widgetToBeUpdated, setWidgetToBeUpdated] = useState<Widget | null>(null);
|
|
|
|
// For analytics around widget library selection
|
|
const [latestLibrarySelectionTitle, setLatestLibrarySelectionTitle] = useState<
|
|
string | null
|
|
>(null);
|
|
|
|
useEffect(() => {
|
|
trackAnalytics('dashboards_views.widget_builder.opened', {
|
|
organization,
|
|
new_widget: !isEditing,
|
|
});
|
|
|
|
if (objectIsEmpty(tags)) {
|
|
loadOrganizationTags(api, organization.slug, {
|
|
...selection,
|
|
// Pin the request to 14d to avoid timeouts, see DD-967 for
|
|
// more information
|
|
datetime: {period: '14d', start: null, end: null, utc: null},
|
|
});
|
|
}
|
|
|
|
if (isEditing && isValidWidgetIndex) {
|
|
const widgetFromDashboard = filteredDashboardWidgets[widgetIndexNum];
|
|
|
|
let queries;
|
|
let newDisplayType = widgetFromDashboard.displayType;
|
|
let newLimit = widgetFromDashboard.limit;
|
|
if (widgetFromDashboard.displayType === DisplayType.TOP_N) {
|
|
newLimit = DEFAULT_RESULTS_LIMIT;
|
|
newDisplayType = DisplayType.AREA;
|
|
|
|
queries = normalizeQueries({
|
|
displayType: newDisplayType,
|
|
queries: widgetFromDashboard.queries,
|
|
widgetType: widgetFromDashboard.widgetType ?? WidgetType.DISCOVER,
|
|
}).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]]
|
|
: [],
|
|
}));
|
|
} else {
|
|
queries = normalizeQueries({
|
|
displayType: newDisplayType,
|
|
queries: widgetFromDashboard.queries,
|
|
widgetType: widgetFromDashboard.widgetType ?? WidgetType.DISCOVER,
|
|
});
|
|
}
|
|
|
|
setState({
|
|
title: widgetFromDashboard.title,
|
|
description: widgetFromDashboard.description,
|
|
displayType: newDisplayType,
|
|
interval: widgetFromDashboard.interval,
|
|
queries,
|
|
errors: undefined,
|
|
loading: false,
|
|
userHasModified: false,
|
|
thresholds: widgetFromDashboard.thresholds ?? defaultThresholds,
|
|
dataSet: widgetFromDashboard.widgetType
|
|
? WIDGET_TYPE_TO_DATA_SET[widgetFromDashboard.widgetType]
|
|
: DataSet.EVENTS,
|
|
limit: newLimit,
|
|
prebuiltWidgetId: null,
|
|
queryConditionsValid: true,
|
|
});
|
|
setDataSetConfig(getDatasetConfig(widgetFromDashboard.widgetType));
|
|
setWidgetToBeUpdated(widgetFromDashboard);
|
|
}
|
|
// This should only run once on mount
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchOrgMembers(api, organization.slug, selection.projects?.map(String));
|
|
}, [selection.projects, api, organization.slug]);
|
|
|
|
useEffect(() => {
|
|
const onUnload = () => {
|
|
if (!isSubmitting && state.userHasModified) {
|
|
return t('You have unsaved changes, are you sure you want to leave?');
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
router.setRouteLeaveHook(route, onUnload);
|
|
}, [isSubmitting, state.userHasModified, route, router]);
|
|
|
|
const widgetType = DATA_SET_TO_WIDGET_TYPE[state.dataSet];
|
|
|
|
const currentWidget = {
|
|
title: state.title,
|
|
description: state.description,
|
|
displayType: state.displayType,
|
|
thresholds: state.thresholds,
|
|
interval: state.interval,
|
|
queries: state.queries,
|
|
limit: state.limit,
|
|
widgetType,
|
|
};
|
|
|
|
const currentDashboardId = state.selectedDashboard ?? dashboardId;
|
|
const queryParamsWithoutSource = omit(location.query, 'source');
|
|
const previousLocation = {
|
|
pathname:
|
|
defined(currentDashboardId) && currentDashboardId !== NEW_DASHBOARD_ID
|
|
? `/organizations/${orgId}/dashboard/${currentDashboardId}/`
|
|
: `/organizations/${orgId}/dashboards/${NEW_DASHBOARD_ID}/`,
|
|
query:
|
|
Object.keys(queryParamsWithoutSource).length === 0
|
|
? undefined
|
|
: queryParamsWithoutSource,
|
|
};
|
|
|
|
const isTimeseriesChart = getIsTimeseriesChart(state.displayType);
|
|
|
|
const isTabularChart = [DisplayType.TABLE, DisplayType.TOP_N].includes(
|
|
state.displayType
|
|
);
|
|
|
|
function updateFieldsAccordingToDisplayType(newDisplayType: DisplayType) {
|
|
setState(prevState => {
|
|
const newState = cloneDeep(prevState);
|
|
|
|
if (!datasetConfig.supportedDisplayTypes.includes(newDisplayType)) {
|
|
// Set to Events dataset if Display Type is not supported by
|
|
// current dataset
|
|
set(
|
|
newState,
|
|
'queries',
|
|
normalizeQueries({
|
|
displayType: newDisplayType,
|
|
queries: [{...getDatasetConfig(WidgetType.DISCOVER).defaultWidgetQuery}],
|
|
widgetType: WidgetType.DISCOVER,
|
|
})
|
|
);
|
|
set(newState, 'dataSet', DataSet.EVENTS);
|
|
setDataSetConfig(getDatasetConfig(WidgetType.DISCOVER));
|
|
return {...newState, errors: undefined};
|
|
}
|
|
|
|
const normalized = normalizeQueries({
|
|
displayType: newDisplayType,
|
|
queries: prevState.queries,
|
|
widgetType: DATA_SET_TO_WIDGET_TYPE[prevState.dataSet],
|
|
});
|
|
|
|
if (newDisplayType === DisplayType.TOP_N) {
|
|
// TOP N display should only allow a single query
|
|
normalized.splice(1);
|
|
}
|
|
|
|
if (!prevState.userHasModified) {
|
|
// Default widget provided by Add to Dashboard from Discover
|
|
if (defaultWidgetQuery && defaultTableColumns) {
|
|
// If switching to Table visualization, use saved query fields for Y-Axis if user has not made query changes
|
|
// This is so the widget can reflect the same columns as the table in Discover without requiring additional user input
|
|
if (newDisplayType === DisplayType.TABLE) {
|
|
normalized.forEach(query => {
|
|
const tableQuery = getColumnsAndAggregates(defaultTableColumns);
|
|
query.columns = [...tableQuery.columns];
|
|
query.aggregates = [...tableQuery.aggregates];
|
|
query.fields = [...defaultTableColumns];
|
|
query.orderby =
|
|
defaultWidgetQuery.orderby ??
|
|
(query.fields.length ? `${query.fields[0]}` : '-');
|
|
});
|
|
} else if (newDisplayType === displayType) {
|
|
// When switching back to original display type, default fields back to the fields provided from the discover query
|
|
normalized.forEach(query => {
|
|
query.fields = [
|
|
...defaultWidgetQuery.columns,
|
|
...defaultWidgetQuery.aggregates,
|
|
];
|
|
query.aggregates = [...defaultWidgetQuery.aggregates];
|
|
query.columns = [...defaultWidgetQuery.columns];
|
|
if (
|
|
!!defaultWidgetQuery.orderby &&
|
|
(displayType === DisplayType.TOP_N || defaultWidgetQuery.columns.length)
|
|
) {
|
|
query.orderby = defaultWidgetQuery.orderby;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
set(newState, 'queries', normalized);
|
|
|
|
if (
|
|
getIsTimeseriesChart(newDisplayType) &&
|
|
normalized[0].columns.filter(column => !!column).length
|
|
) {
|
|
// If a limit already exists (i.e. going between timeseries) then keep it,
|
|
// otherwise calculate a limit
|
|
newState.limit =
|
|
prevState.limit ??
|
|
Math.min(
|
|
getResultsLimit(normalized.length, normalized[0].columns.length),
|
|
DEFAULT_RESULTS_LIMIT
|
|
);
|
|
} else {
|
|
newState.limit = undefined;
|
|
}
|
|
|
|
set(newState, 'userHasModified', true);
|
|
return {...newState, errors: undefined};
|
|
});
|
|
}
|
|
|
|
function getUpdateWidgetIndex() {
|
|
if (!widgetToBeUpdated) {
|
|
return -1;
|
|
}
|
|
|
|
return dashboard.widgets.findIndex(widget => {
|
|
if (defined(widget.id)) {
|
|
return widget.id === widgetToBeUpdated.id;
|
|
}
|
|
|
|
if (defined(widget.tempId)) {
|
|
return widget.tempId === widgetToBeUpdated.tempId;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
}
|
|
|
|
function handleDisplayTypeOrAnnotationChange<
|
|
F extends keyof Pick<State, 'displayType' | 'title' | 'description'>,
|
|
>(field: F, value: State[F]) {
|
|
value &&
|
|
trackAnalytics('dashboards_views.widget_builder.change', {
|
|
from: source,
|
|
field,
|
|
value,
|
|
widget_type: widgetType,
|
|
organization,
|
|
new_widget: !isEditing,
|
|
});
|
|
|
|
setState(prevState => {
|
|
const newState = cloneDeep(prevState);
|
|
set(newState, field, value);
|
|
if (['title', 'description'].includes(field)) {
|
|
set(newState, 'userHasModified', true);
|
|
}
|
|
return {...newState, errors: undefined};
|
|
});
|
|
|
|
if (field === 'displayType' && value !== state.displayType) {
|
|
updateFieldsAccordingToDisplayType(value as DisplayType);
|
|
}
|
|
}
|
|
|
|
function handleDataSetChange(newDataSet: string) {
|
|
trackAnalytics('dashboards_views.widget_builder.change', {
|
|
from: source,
|
|
field: 'dataSet',
|
|
value: newDataSet,
|
|
widget_type: widgetType,
|
|
organization,
|
|
new_widget: !isEditing,
|
|
});
|
|
setState(prevState => {
|
|
const newState = cloneDeep(prevState);
|
|
newState.queries.splice(0, newState.queries.length);
|
|
set(newState, 'dataSet', newDataSet);
|
|
|
|
if (newDataSet === DataSet.ISSUES) {
|
|
set(newState, 'displayType', DisplayType.TABLE);
|
|
}
|
|
|
|
const config = getDatasetConfig(DATA_SET_TO_WIDGET_TYPE[newDataSet]);
|
|
setDataSetConfig(config);
|
|
|
|
newState.queries.push(
|
|
...(widgetToBeUpdated?.widgetType &&
|
|
WIDGET_TYPE_TO_DATA_SET[widgetToBeUpdated.widgetType] === newDataSet
|
|
? widgetToBeUpdated.queries
|
|
: [{...config.defaultWidgetQuery}])
|
|
);
|
|
|
|
set(newState, 'userHasModified', true);
|
|
return {...newState, errors: undefined};
|
|
});
|
|
}
|
|
|
|
function handleAddSearchConditions() {
|
|
setState(prevState => {
|
|
const newState = cloneDeep(prevState);
|
|
const config = getDatasetConfig(DATA_SET_TO_WIDGET_TYPE[prevState.dataSet]);
|
|
const query = cloneDeep(config.defaultWidgetQuery);
|
|
query.fields = prevState.queries[0].fields;
|
|
query.aggregates = prevState.queries[0].aggregates;
|
|
query.columns = prevState.queries[0].columns;
|
|
query.orderby = prevState.queries[0].orderby;
|
|
newState.queries.push(query);
|
|
return newState;
|
|
});
|
|
}
|
|
|
|
function handleQueryRemove(index: number) {
|
|
setState(prevState => {
|
|
const newState = cloneDeep(prevState);
|
|
newState.queries.splice(index, 1);
|
|
return {...newState, errors: undefined};
|
|
});
|
|
}
|
|
|
|
function handleQueryChange(queryIndex: number, newQuery: WidgetQuery) {
|
|
setState(prevState => {
|
|
const newState = cloneDeep(prevState);
|
|
set(newState, `queries.${queryIndex}`, newQuery);
|
|
set(newState, 'userHasModified', true);
|
|
return {...newState, errors: undefined};
|
|
});
|
|
}
|
|
|
|
function getHandleColumnFieldChange(isMetricsData?: boolean) {
|
|
function handleColumnFieldChange(newFields: QueryFieldValue[]) {
|
|
const fieldStrings = newFields.map(generateFieldAsString);
|
|
const splitFields = getColumnsAndAggregatesAsStrings(newFields);
|
|
const newState = cloneDeep(state);
|
|
let newQuery = cloneDeep(newState.queries[0]);
|
|
|
|
newQuery.fields = fieldStrings;
|
|
newQuery.aggregates = splitFields.aggregates;
|
|
newQuery.columns = splitFields.columns;
|
|
newQuery.fieldAliases = splitFields.fieldAliases;
|
|
|
|
if (datasetConfig.handleColumnFieldChangeOverride) {
|
|
newQuery = datasetConfig.handleColumnFieldChangeOverride(newQuery);
|
|
}
|
|
|
|
if (datasetConfig.handleOrderByReset) {
|
|
// If widget is metric backed, don't default to sorting by transaction unless its the only column
|
|
// Sorting by transaction is not supported in metrics
|
|
if (
|
|
isMetricsData &&
|
|
fieldStrings.some(
|
|
fieldString => !['transaction', 'title'].includes(fieldString)
|
|
)
|
|
) {
|
|
newQuery = datasetConfig.handleOrderByReset(
|
|
newQuery,
|
|
fieldStrings.filter(
|
|
fieldString => !['transaction', 'title'].includes(fieldString)
|
|
)
|
|
);
|
|
} else {
|
|
newQuery = datasetConfig.handleOrderByReset(newQuery, fieldStrings);
|
|
}
|
|
}
|
|
|
|
set(newState, 'queries', [newQuery]);
|
|
set(newState, 'userHasModified', true);
|
|
setState(newState);
|
|
}
|
|
return handleColumnFieldChange;
|
|
}
|
|
|
|
function handleYAxisChange(newFields: QueryFieldValue[]) {
|
|
const fieldStrings = newFields.map(generateFieldAsString);
|
|
const newState = cloneDeep(state);
|
|
|
|
const newQueries = state.queries.map(query => {
|
|
let newQuery = cloneDeep(query);
|
|
|
|
if (state.displayType === DisplayType.TOP_N) {
|
|
// Top N queries use n-1 fields for columns and the nth field for y-axis
|
|
newQuery.fields = [
|
|
...(newQuery.fields?.slice(0, newQuery.fields.length - 1) ?? []),
|
|
...fieldStrings,
|
|
];
|
|
newQuery.aggregates = [
|
|
...newQuery.aggregates.slice(0, newQuery.aggregates.length - 1),
|
|
...fieldStrings,
|
|
];
|
|
} else {
|
|
newQuery.fields = [...newQuery.columns, ...fieldStrings];
|
|
newQuery.aggregates = fieldStrings;
|
|
}
|
|
|
|
if (datasetConfig.handleOrderByReset) {
|
|
newQuery = datasetConfig.handleOrderByReset(newQuery, fieldStrings);
|
|
}
|
|
|
|
return newQuery;
|
|
});
|
|
|
|
set(newState, 'queries', newQueries);
|
|
set(newState, 'userHasModified', true);
|
|
|
|
const groupByFields = newState.queries[0].columns.filter(
|
|
field => !(field === 'equation|')
|
|
);
|
|
if (groupByFields.length === 0) {
|
|
set(newState, 'limit', undefined);
|
|
} else {
|
|
set(
|
|
newState,
|
|
'limit',
|
|
Math.min(
|
|
newState.limit ?? DEFAULT_RESULTS_LIMIT,
|
|
getResultsLimit(newQueries.length, newQueries[0].aggregates.length)
|
|
)
|
|
);
|
|
}
|
|
|
|
newState.thresholds = defaultThresholds;
|
|
|
|
setState(newState);
|
|
}
|
|
|
|
function handleGroupByChange(newFields: QueryFieldValue[]) {
|
|
const fieldStrings = newFields.map(generateFieldAsString);
|
|
|
|
const newState = cloneDeep(state);
|
|
|
|
const newQueries = state.queries.map(query => {
|
|
const newQuery = cloneDeep(query);
|
|
newQuery.columns = fieldStrings;
|
|
|
|
if (!fieldStrings.length) {
|
|
// The grouping was cleared, so clear the orderby
|
|
newQuery.orderby = '';
|
|
} else if (!newQuery.orderby) {
|
|
const orderOptions = generateOrderOptions({
|
|
widgetType: widgetType ?? WidgetType.DISCOVER,
|
|
columns: query.columns,
|
|
aggregates: query.aggregates,
|
|
});
|
|
let orderOption: string;
|
|
// If no orderby options are available because of DISABLED_SORTS
|
|
if (!orderOptions.length) {
|
|
newQuery.orderby = '';
|
|
} else {
|
|
orderOption = orderOptions[0].value;
|
|
newQuery.orderby = `-${orderOption}`;
|
|
}
|
|
}
|
|
return newQuery;
|
|
});
|
|
|
|
set(newState, 'userHasModified', true);
|
|
set(newState, 'queries', newQueries);
|
|
|
|
const groupByFields = newState.queries[0].columns.filter(
|
|
field => !(field === 'equation|')
|
|
);
|
|
|
|
if (groupByFields.length === 0) {
|
|
set(newState, 'limit', undefined);
|
|
} else {
|
|
set(
|
|
newState,
|
|
'limit',
|
|
Math.min(
|
|
newState.limit ?? DEFAULT_RESULTS_LIMIT,
|
|
getResultsLimit(newQueries.length, newQueries[0].aggregates.length)
|
|
)
|
|
);
|
|
}
|
|
|
|
setState(newState);
|
|
}
|
|
|
|
function handleLimitChange(newLimit: number) {
|
|
setState(prevState => ({...prevState, limit: newLimit}));
|
|
}
|
|
|
|
function handleSortByChange(newSortBy: string) {
|
|
const newState = cloneDeep(state);
|
|
|
|
state.queries.forEach((query, index) => {
|
|
const newQuery = cloneDeep(query);
|
|
newQuery.orderby = newSortBy;
|
|
set(newState, `queries.${index}`, newQuery);
|
|
});
|
|
|
|
set(newState, 'userHasModified', true);
|
|
setState(newState);
|
|
}
|
|
|
|
function handleDelete() {
|
|
if (!isEditing) {
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
let nextWidgetList = [...dashboard.widgets];
|
|
const updateWidgetIndex = getUpdateWidgetIndex();
|
|
nextWidgetList.splice(updateWidgetIndex, 1);
|
|
nextWidgetList = generateWidgetsAfterCompaction(nextWidgetList);
|
|
|
|
onSave(nextWidgetList);
|
|
router.push(normalizeUrl(previousLocation));
|
|
}
|
|
|
|
async function handleSave() {
|
|
const widgetData: Widget = assignTempId(currentWidget);
|
|
|
|
if (widgetData.thresholds && !hasThresholdMaxValue(widgetData.thresholds)) {
|
|
widgetData.thresholds = null;
|
|
}
|
|
|
|
if (widgetToBeUpdated) {
|
|
widgetData.layout = widgetToBeUpdated?.layout;
|
|
}
|
|
|
|
// Only Time Series charts shall have a limit
|
|
if (!isTimeseriesChart) {
|
|
widgetData.limit = undefined;
|
|
}
|
|
|
|
const isValid = await dataIsValid(widgetData);
|
|
|
|
if (!isValid) {
|
|
return;
|
|
}
|
|
|
|
if (latestLibrarySelectionTitle) {
|
|
// User has selected a widget library in this session
|
|
trackAnalytics('dashboards_views.widget_library.add_widget', {
|
|
organization,
|
|
title: latestLibrarySelectionTitle,
|
|
});
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
if (notDashboardsOrigin) {
|
|
submitFromSelectedDashboard(widgetData);
|
|
return;
|
|
}
|
|
|
|
if (widgetToBeUpdated) {
|
|
let nextWidgetList = [...dashboard.widgets];
|
|
const updateWidgetIndex = getUpdateWidgetIndex();
|
|
const nextWidgetData = {...widgetData, id: widgetToBeUpdated.id};
|
|
|
|
// Only modify and re-compact if the default height has changed
|
|
if (
|
|
getDefaultWidgetHeight(widgetToBeUpdated.displayType) !==
|
|
getDefaultWidgetHeight(widgetData.displayType)
|
|
) {
|
|
nextWidgetList[updateWidgetIndex] = enforceWidgetHeightValues(nextWidgetData);
|
|
nextWidgetList = generateWidgetsAfterCompaction(nextWidgetList);
|
|
} else {
|
|
nextWidgetList[updateWidgetIndex] = nextWidgetData;
|
|
}
|
|
|
|
onSave(nextWidgetList);
|
|
addSuccessMessage(t('Updated widget.'));
|
|
goToDashboards(dashboardId ?? NEW_DASHBOARD_ID);
|
|
trackAnalytics('dashboards_views.widget_builder.save', {
|
|
organization,
|
|
data_set: widgetData.widgetType ?? WidgetType.DISCOVER,
|
|
new_widget: false,
|
|
});
|
|
return;
|
|
}
|
|
|
|
onSave([...dashboard.widgets, widgetData]);
|
|
addSuccessMessage(t('Added widget.'));
|
|
goToDashboards(dashboardId ?? NEW_DASHBOARD_ID);
|
|
trackAnalytics('dashboards_views.widget_builder.save', {
|
|
organization,
|
|
data_set: widgetData.widgetType ?? WidgetType.DISCOVER,
|
|
new_widget: true,
|
|
});
|
|
}
|
|
|
|
async function dataIsValid(widgetData: Widget): Promise<boolean> {
|
|
setState({...state, loading: true});
|
|
try {
|
|
await validateWidget(api, organization.slug, widgetData);
|
|
return true;
|
|
} catch (error) {
|
|
setState({
|
|
...state,
|
|
loading: false,
|
|
errors: {...state.errors, ...mapErrors(error?.responseJSON ?? {}, {})},
|
|
});
|
|
addErrorMessage(t('Unable to save widget'));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function submitFromSelectedDashboard(widgetData: Widget) {
|
|
if (!state.selectedDashboard) {
|
|
return;
|
|
}
|
|
|
|
const queryData: QueryData = {
|
|
queryNames: [],
|
|
queryConditions: [],
|
|
queryFields: [
|
|
...widgetData.queries[0].columns,
|
|
...widgetData.queries[0].aggregates,
|
|
],
|
|
queryOrderby: widgetData.queries[0].orderby,
|
|
};
|
|
|
|
widgetData.queries.forEach(query => {
|
|
queryData.queryNames.push(query.name);
|
|
queryData.queryConditions.push(query.conditions);
|
|
});
|
|
|
|
const pathQuery = {
|
|
displayType: widgetData.displayType,
|
|
interval: widgetData.interval,
|
|
title: widgetData.title,
|
|
...queryData,
|
|
// Propagate page filters
|
|
project: pageFilters.projects,
|
|
environment: pageFilters.environments,
|
|
...omit(pageFilters.datetime, 'period'),
|
|
statsPeriod: pageFilters.datetime?.period,
|
|
};
|
|
|
|
addSuccessMessage(t('Added widget.'));
|
|
goToDashboards(state.selectedDashboard, pathQuery);
|
|
}
|
|
|
|
function goToDashboards(id: string, query?: Record<string, any>) {
|
|
const pathQuery =
|
|
Object.keys(queryParamsWithoutSource).length > 0 || query
|
|
? {
|
|
...queryParamsWithoutSource,
|
|
...query,
|
|
}
|
|
: {};
|
|
|
|
const sanitizedQuery = omit(pathQuery, ['defaultWidgetQuery', 'defaultTitle']);
|
|
|
|
if (id === NEW_DASHBOARD_ID) {
|
|
router.push(
|
|
normalizeUrl({
|
|
pathname: `/organizations/${organization.slug}/dashboards/new/`,
|
|
query: sanitizedQuery,
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
router.push(
|
|
normalizeUrl({
|
|
pathname: `/organizations/${organization.slug}/dashboard/${id}/`,
|
|
query: sanitizedQuery,
|
|
})
|
|
);
|
|
}
|
|
|
|
function handleThresholdChange(maxKey: ThresholdMaxKeys, value: string) {
|
|
setState(prevState => {
|
|
const newState = cloneDeep(prevState);
|
|
|
|
if (value === '') {
|
|
delete newState.thresholds?.max_values[maxKey];
|
|
|
|
if (newState.thresholds && !hasThresholdMaxValue(newState.thresholds)) {
|
|
newState.thresholds.max_values = {};
|
|
}
|
|
} else {
|
|
if (newState.thresholds) {
|
|
newState.thresholds.max_values[maxKey] = Number(value);
|
|
}
|
|
}
|
|
|
|
return newState;
|
|
});
|
|
}
|
|
|
|
function handleThresholdUnitChange(unit: string) {
|
|
setState(prevState => {
|
|
const newState = cloneDeep(prevState);
|
|
|
|
if (newState.thresholds) {
|
|
newState.thresholds.unit = unit;
|
|
}
|
|
|
|
return newState;
|
|
});
|
|
}
|
|
|
|
function handleWidgetDataFetched(tableData: TableDataWithTitle[]) {
|
|
const tableMeta = {...tableData[0].meta};
|
|
const keys = Object.keys(tableMeta);
|
|
const field = keys[0];
|
|
const dataType = tableMeta[field];
|
|
const dataUnit = tableMeta.units?.[field];
|
|
|
|
setState(prevState => {
|
|
const newState = cloneDeep(prevState);
|
|
|
|
newState.dataType = dataType;
|
|
newState.dataUnit = dataUnit;
|
|
|
|
if (newState.thresholds && !newState.thresholds.unit) {
|
|
newState.thresholds.unit = dataUnit ?? null;
|
|
}
|
|
|
|
return newState;
|
|
});
|
|
}
|
|
|
|
function isFormInvalid() {
|
|
if (
|
|
(notDashboardsOrigin && !state.selectedDashboard) ||
|
|
!state.queryConditionsValid
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function setQueryConditionsValid(validSearch: boolean) {
|
|
setState({...state, queryConditionsValid: validSearch});
|
|
}
|
|
|
|
const canAddSearchConditions =
|
|
[DisplayType.LINE, DisplayType.AREA, DisplayType.BAR].includes(state.displayType) &&
|
|
state.queries.length < 3;
|
|
|
|
const hideLegendAlias = [DisplayType.TABLE, DisplayType.BIG_NUMBER].includes(
|
|
state.displayType
|
|
);
|
|
|
|
// Tabular visualizations will always have only one query and that query cannot be deleted,
|
|
// so we will always have the first query available to get data from.
|
|
const {columns, aggregates, fields, fieldAliases = []} = state.queries[0];
|
|
|
|
const explodedColumns = useMemo(() => {
|
|
return columns.map((field, index) =>
|
|
explodeField({field, alias: fieldAliases[index]})
|
|
);
|
|
}, [columns, fieldAliases]);
|
|
|
|
const explodedAggregates = useMemo(() => {
|
|
return aggregates.map((field, index) =>
|
|
explodeField({field, alias: fieldAliases[index]})
|
|
);
|
|
}, [aggregates, fieldAliases]);
|
|
|
|
const explodedFields = defined(fields)
|
|
? fields.map((field, index) => explodeField({field, alias: fieldAliases[index]}))
|
|
: [...explodedColumns, ...explodedAggregates];
|
|
|
|
const groupByValueSelected = currentWidget.queries.some(query => {
|
|
const noEmptyColumns = query.columns.filter(column => !!column);
|
|
return noEmptyColumns.length > 0;
|
|
});
|
|
|
|
// The SortBy field shall only be displayed in tabular visualizations or
|
|
// on time-series visualizations when at least one groupBy value is selected
|
|
const displaySortByStep = (isTimeseriesChart && groupByValueSelected) || isTabularChart;
|
|
|
|
if (isEditing && !isValidWidgetIndex) {
|
|
return (
|
|
<SentryDocumentTitle title={dashboard.title} orgSlug={orgSlug}>
|
|
<Layout.Page withPadding>
|
|
<LoadingError message={t('The widget you want to edit was not found.')} />
|
|
</Layout.Page>
|
|
</SentryDocumentTitle>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SentryDocumentTitle title={dashboard.title} orgSlug={orgSlug}>
|
|
<PageFiltersContainer
|
|
defaultSelection={{
|
|
datetime: {start: null, end: null, utc: null, period: DEFAULT_STATS_PERIOD},
|
|
}}
|
|
>
|
|
<CustomMeasurementsProvider organization={organization} selection={selection}>
|
|
<OnDemandControlProvider location={location}>
|
|
<MetricsResultsMetaProvider>
|
|
<DashboardsMEPProvider>
|
|
<MetricsCardinalityProvider
|
|
organization={organization}
|
|
location={location}
|
|
>
|
|
<MetricsDataSwitcher
|
|
organization={organization}
|
|
eventView={EventView.fromLocation(location)}
|
|
location={location}
|
|
hideLoadingIndicator
|
|
>
|
|
{metricsDataSide => (
|
|
<MEPSettingProvider
|
|
location={location}
|
|
forceTransactions={metricsDataSide.forceTransactionsOnly}
|
|
>
|
|
<Layout.Page>
|
|
<Header
|
|
orgSlug={orgSlug}
|
|
dashboardTitle={dashboard.title}
|
|
goBackLocation={previousLocation}
|
|
/>
|
|
<Body>
|
|
<MainWrapper>
|
|
<Main>
|
|
<BuildSteps symbol="colored-numeric">
|
|
<NameWidgetStep title={t('Name your widget')}>
|
|
<TitleInput
|
|
name="title"
|
|
inline={false}
|
|
aria-label={t('Widget title')}
|
|
placeholder={t('Enter title')}
|
|
error={state.errors?.title}
|
|
data-test-id="widget-builder-title-input"
|
|
onChange={newTitle => {
|
|
handleDisplayTypeOrAnnotationChange(
|
|
'title',
|
|
newTitle
|
|
);
|
|
}}
|
|
value={state.title}
|
|
/>
|
|
<StyledTextAreaField
|
|
name="description"
|
|
rows={4}
|
|
autosize
|
|
inline={false}
|
|
aria-label={t('Widget Description')}
|
|
placeholder={t('Enter description (Optional)')}
|
|
error={state.errors?.description}
|
|
onChange={newDescription => {
|
|
handleDisplayTypeOrAnnotationChange(
|
|
'description',
|
|
newDescription
|
|
);
|
|
}}
|
|
value={state.description}
|
|
/>
|
|
</NameWidgetStep>
|
|
<VisualizationStep
|
|
location={location}
|
|
onDataFetched={handleWidgetDataFetched}
|
|
widget={currentWidget}
|
|
dashboardFilters={dashboard.filters}
|
|
organization={organization}
|
|
pageFilters={pageFilters}
|
|
displayType={state.displayType}
|
|
error={state.errors?.displayType}
|
|
onChange={newDisplayType => {
|
|
handleDisplayTypeOrAnnotationChange(
|
|
'displayType',
|
|
newDisplayType
|
|
);
|
|
}}
|
|
noDashboardsMEPProvider
|
|
isWidgetInvalid={!state.queryConditionsValid}
|
|
/>
|
|
<DataSetStep
|
|
dataSet={state.dataSet}
|
|
displayType={state.displayType}
|
|
onChange={handleDataSetChange}
|
|
hasReleaseHealthFeature={hasReleaseHealthFeature}
|
|
/>
|
|
{isTabularChart && (
|
|
<DashboardsMEPConsumer>
|
|
{({isMetricsData}) => (
|
|
<ColumnsStep
|
|
dataSet={state.dataSet}
|
|
displayType={state.displayType}
|
|
widgetType={widgetType}
|
|
queryErrors={state.errors?.queries}
|
|
onQueryChange={handleQueryChange}
|
|
handleColumnFieldChange={getHandleColumnFieldChange(
|
|
isMetricsData
|
|
)}
|
|
explodedFields={explodedFields}
|
|
tags={tags}
|
|
organization={organization}
|
|
/>
|
|
)}
|
|
</DashboardsMEPConsumer>
|
|
)}
|
|
{![DisplayType.TABLE].includes(state.displayType) && (
|
|
<YAxisStep
|
|
dataSet={state.dataSet}
|
|
displayType={state.displayType}
|
|
widgetType={widgetType}
|
|
queryErrors={state.errors?.queries}
|
|
onYAxisChange={newFields => {
|
|
handleYAxisChange(newFields);
|
|
}}
|
|
aggregates={explodedAggregates}
|
|
tags={tags}
|
|
organization={organization}
|
|
/>
|
|
)}
|
|
<FilterResultsStep
|
|
queries={state.queries}
|
|
hideLegendAlias={hideLegendAlias}
|
|
canAddSearchConditions={canAddSearchConditions}
|
|
organization={organization}
|
|
queryErrors={state.errors?.queries}
|
|
onAddSearchConditions={handleAddSearchConditions}
|
|
onQueryChange={handleQueryChange}
|
|
onQueryRemove={handleQueryRemove}
|
|
selection={pageFilters}
|
|
widgetType={widgetType}
|
|
dashboardFilters={dashboard.filters}
|
|
location={location}
|
|
onQueryConditionChange={setQueryConditionsValid}
|
|
/>
|
|
{isTimeseriesChart && (
|
|
<GroupByStep
|
|
columns={columns
|
|
.filter(field => !(field === 'equation|'))
|
|
.map((field, index) =>
|
|
explodeField({
|
|
field,
|
|
alias: fieldAliases[index],
|
|
})
|
|
)}
|
|
onGroupByChange={handleGroupByChange}
|
|
organization={organization}
|
|
tags={tags}
|
|
dataSet={state.dataSet}
|
|
queries={state.queries}
|
|
/>
|
|
)}
|
|
{displaySortByStep && (
|
|
<SortByStep
|
|
limit={state.limit}
|
|
displayType={state.displayType}
|
|
queries={state.queries}
|
|
dataSet={state.dataSet}
|
|
error={state.errors?.orderby}
|
|
onSortByChange={handleSortByChange}
|
|
onLimitChange={handleLimitChange}
|
|
organization={organization}
|
|
widgetType={widgetType}
|
|
tags={tags}
|
|
/>
|
|
)}
|
|
{state.displayType === 'big_number' &&
|
|
state.dataType !== 'date' &&
|
|
organization.features.includes(
|
|
'dashboard-widget-indicators'
|
|
) && (
|
|
<ThresholdsStep
|
|
onThresholdChange={handleThresholdChange}
|
|
onUnitChange={handleThresholdUnitChange}
|
|
thresholdsConfig={state.thresholds ?? null}
|
|
dataType={state.dataType}
|
|
dataUnit={state.dataUnit}
|
|
errors={state.errors?.thresholds}
|
|
/>
|
|
)}
|
|
</BuildSteps>
|
|
</Main>
|
|
<Footer
|
|
goBackLocation={previousLocation}
|
|
isEditing={isEditing}
|
|
onSave={handleSave}
|
|
onDelete={handleDelete}
|
|
invalidForm={isFormInvalid()}
|
|
/>
|
|
</MainWrapper>
|
|
<Side>
|
|
<WidgetLibrary
|
|
organization={organization}
|
|
selectedWidgetId={
|
|
state.userHasModified ? null : state.prebuiltWidgetId
|
|
}
|
|
onWidgetSelect={prebuiltWidget => {
|
|
setLatestLibrarySelectionTitle(prebuiltWidget.title);
|
|
setDataSetConfig(
|
|
getDatasetConfig(
|
|
prebuiltWidget.widgetType || WidgetType.DISCOVER
|
|
)
|
|
);
|
|
const {id, ...prebuiltWidgetProps} = prebuiltWidget;
|
|
setState({
|
|
...state,
|
|
...prebuiltWidgetProps,
|
|
dataSet: prebuiltWidget.widgetType
|
|
? WIDGET_TYPE_TO_DATA_SET[prebuiltWidget.widgetType]
|
|
: DataSet.EVENTS,
|
|
userHasModified: false,
|
|
prebuiltWidgetId: id || null,
|
|
});
|
|
}}
|
|
bypassOverwriteModal={!state.userHasModified}
|
|
/>
|
|
</Side>
|
|
</Body>
|
|
</Layout.Page>
|
|
</MEPSettingProvider>
|
|
)}
|
|
</MetricsDataSwitcher>
|
|
</MetricsCardinalityProvider>
|
|
</DashboardsMEPProvider>
|
|
</MetricsResultsMetaProvider>
|
|
</OnDemandControlProvider>
|
|
</CustomMeasurementsProvider>
|
|
</PageFiltersContainer>
|
|
</SentryDocumentTitle>
|
|
);
|
|
}
|
|
|
|
export default withPageFilters(withTags(WidgetBuilder));
|
|
|
|
const TitleInput = styled(InputField)`
|
|
padding: 0 ${space(2)} 0 0;
|
|
`;
|
|
|
|
const BuildSteps = styled(List)`
|
|
gap: ${space(4)};
|
|
max-width: 100%;
|
|
`;
|
|
|
|
const Body = styled(Layout.Body)`
|
|
&& {
|
|
gap: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
grid-template-rows: 1fr;
|
|
|
|
@media (min-width: ${p => p.theme.breakpoints.large}) {
|
|
grid-template-columns: minmax(100px, auto) 400px;
|
|
}
|
|
|
|
@media (min-width: ${p => p.theme.breakpoints.xlarge}) {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
`;
|
|
|
|
// HACK: Since we add 30px of padding to the ListItems
|
|
// there is 30px of overlap when the screen is just above 1200px.
|
|
// When we're up to 1230px (1200 + 30 to account for the padding)
|
|
// we decrease the width of ListItems by 30px
|
|
const Main = styled(Layout.Main)`
|
|
max-width: 1000px;
|
|
flex: 1;
|
|
|
|
padding: ${space(4)} ${space(2)};
|
|
|
|
@media (min-width: ${p => p.theme.breakpoints.medium}) {
|
|
padding: ${space(4)};
|
|
}
|
|
|
|
@media (max-width: calc(${p => p.theme.breakpoints.large} + ${space(4)})) {
|
|
${ListItem} {
|
|
width: calc(100% - ${space(4)});
|
|
}
|
|
}
|
|
`;
|
|
|
|
const Side = styled(Layout.Side)`
|
|
padding: ${space(4)} ${space(2)};
|
|
|
|
@media (max-width: ${p => p.theme.breakpoints.large}) {
|
|
border-top: 1px solid ${p => p.theme.gray200};
|
|
grid-row: 2/2;
|
|
grid-column: 1/-1;
|
|
max-width: 100%;
|
|
}
|
|
|
|
@media (min-width: ${p => p.theme.breakpoints.large}) {
|
|
border-left: 1px solid ${p => p.theme.gray200};
|
|
|
|
/* to be consistent with Layout.Body in other verticals */
|
|
padding-right: ${space(4)};
|
|
max-width: 400px;
|
|
}
|
|
`;
|
|
|
|
const MainWrapper = styled('div')`
|
|
display: flex;
|
|
flex-direction: column;
|
|
|
|
@media (max-width: ${p => p.theme.breakpoints.large}) {
|
|
grid-column: 1/-1;
|
|
}
|
|
`;
|
|
|
|
const NameWidgetStep = styled(BuildStep)`
|
|
${FieldWrapper} {
|
|
padding: 0 ${space(2)} 0 0;
|
|
border-bottom: none;
|
|
}
|
|
`;
|
|
|
|
const StyledTextAreaField = styled(TextareaField)`
|
|
margin-top: ${space(1.5)};
|
|
`;
|