1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375 |
- 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 {hasDDMFeature} from 'sentry/utils/metrics/features';
- 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
- : source === DashboardWidgetSource.DDM
- ? DataSet.METRICS
- : 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}
- hasCustomMetricsFeature={hasDDMFeature(organization)}
- />
- {isTabularChart && (
- <DashboardsMEPConsumer>
- {({isMetricsData}) => (
- <ColumnsStep
- dataSet={state.dataSet}
- queries={state.queries}
- 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)};
- `;
|