12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262 |
- import {useEffect, useMemo, useState} from 'react';
- import {RouteComponentProps} from 'react-router';
- import styled from '@emotion/styled';
- import cloneDeep from 'lodash/cloneDeep';
- import isEmpty from 'lodash/isEmpty';
- 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 {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 EventView from 'sentry/utils/discover/eventView';
- import {
- explodeField,
- generateFieldAsString,
- getColumnsAndAggregates,
- getColumnsAndAggregatesAsStrings,
- QueryFieldValue,
- } from 'sentry/utils/discover/fields';
- import {MetricsCardinalityProvider} from 'sentry/utils/performance/contexts/metricsCardinality';
- import {MEPSettingProvider} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
- 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 {
- DashboardDetails,
- DashboardWidgetSource,
- DisplayType,
- Widget,
- WidgetQuery,
- 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 {
- 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 {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,
- };
- export const DATA_SET_TO_WIDGET_TYPE = {
- [DataSet.EVENTS]: WidgetType.DISCOVER,
- [DataSet.ISSUES]: WidgetType.ISSUE,
- [DataSet.RELEASES]: WidgetType.RELEASE,
- };
- 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;
- description?: string;
- errors?: Record<string, any>;
- selectedDashboard?: DashboardDetails['id'];
- 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} = 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 api = useApi();
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [datasetConfig, setDataSetConfig] = useState<ReturnType<typeof getDatasetConfig>>(
- getDatasetConfig(WidgetType.DISCOVER)
- );
- 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: [],
- limit: limit ? Number(limit) : undefined,
- errors: undefined,
- description: undefined,
- loading: !!notDashboardsOrigin,
- userHasModified: false,
- prebuiltWidgetId: null,
- dataSet: DataSet.EVENTS,
- 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,
- 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 =
- state.dataSet === DataSet.EVENTS
- ? WidgetType.DISCOVER
- : state.dataSet === DataSet.ISSUES
- ? WidgetType.ISSUE
- : WidgetType.RELEASE;
- const currentWidget = {
- title: state.title,
- description: state.description,
- displayType: state.displayType,
- 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: isEmpty(queryParamsWithoutSource) ? 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)
- )
- );
- }
- 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 (widgetToBeUpdated) {
- widgetData.layout = widgetToBeUpdated?.layout;
- }
- // Only Time Series charts shall have a limit
- if (!isTimeseriesChart) {
- widgetData.limit = undefined;
- }
- if (!(await dataIsValid(widgetData))) {
- 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 =
- !isEmpty(queryParamsWithoutSource) || query
- ? {
- ...queryParamsWithoutSource,
- ...query,
- }
- : undefined;
- if (id === NEW_DASHBOARD_ID) {
- router.push(
- normalizeUrl({
- pathname: `/organizations/${organization.slug}/dashboards/new/`,
- query: pathQuery,
- })
- );
- return;
- }
- router.push(
- normalizeUrl({
- pathname: `/organizations/${organization.slug}/dashboard/${id}/`,
- query: pathQuery,
- })
- );
- }
- 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}>
- <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}
- 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}
- 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}
- />
- )}
- {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}
- />
- )}
- </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>
- </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)};
- `;
|