123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131 |
- 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 {generateOrderOptions} from 'sentry/components/dashboards/widgetQueriesForm';
- import * as Layout from 'sentry/components/layouts/thirds';
- import List from 'sentry/components/list';
- 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 {PageContent} from 'sentry/styles/organization';
- import space from 'sentry/styles/space';
- import {
- DateString,
- Organization,
- PageFilters,
- SelectValue,
- SessionMetric,
- TagCollection,
- } from 'sentry/types';
- import {defined, objectIsEmpty} from 'sentry/utils';
- import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
- import {
- explodeField,
- generateFieldAsString,
- getAggregateAlias,
- getColumnsAndAggregates,
- getColumnsAndAggregatesAsStrings,
- QueryFieldValue,
- stripDerivedMetricsPrefix,
- } from 'sentry/utils/discover/fields';
- import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse';
- import useApi from 'sentry/utils/useApi';
- import withPageFilters from 'sentry/utils/withPageFilters';
- import withTags from 'sentry/utils/withTags';
- import {
- assignTempId,
- enforceWidgetHeightValues,
- generateWidgetsAfterCompaction,
- getDefaultWidgetHeight,
- } from 'sentry/views/dashboardsV2/layoutUtils';
- import {
- DashboardDetails,
- DashboardListItem,
- DashboardWidgetSource,
- DisplayType,
- Widget,
- WidgetQuery,
- WidgetType,
- } from 'sentry/views/dashboardsV2/types';
- import {IssueSortOptions} from 'sentry/views/issueList/utils';
- import {DEFAULT_STATS_PERIOD} from '../data';
- import {ColumnsStep} from './buildSteps/columnsStep';
- import {DashboardStep} from './buildSteps/dashboardStep';
- 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,
- getParsedDefaultWidgetQuery,
- getResultsLimit,
- mapErrors,
- NEW_DASHBOARD_ID,
- normalizeQueries,
- } from './utils';
- import {WidgetLibrary} from './widgetLibrary';
- function getDataSetQuery(widgetBuilderNewDesign: boolean): Record<DataSet, WidgetQuery> {
- return {
- [DataSet.EVENTS]: {
- name: '',
- fields: ['count()'],
- columns: [],
- fieldAliases: [],
- aggregates: ['count()'],
- conditions: '',
- orderby: widgetBuilderNewDesign ? '-count' : '',
- },
- [DataSet.ISSUES]: {
- name: '',
- fields: ['issue', 'assignee', 'title'] as string[],
- columns: ['issue', 'assignee', 'title'],
- fieldAliases: [],
- aggregates: [],
- conditions: '',
- orderby: widgetBuilderNewDesign ? IssueSortOptions.DATE : '',
- },
- [DataSet.RELEASE]: {
- name: '',
- fields: [`sum(${SessionMetric.SESSION})`],
- columns: [],
- fieldAliases: [],
- aggregates: [`sum(${SessionMetric.SESSION})`],
- conditions: '',
- orderby: widgetBuilderNewDesign ? `-sum(${SessionMetric.SESSION})` : '',
- },
- };
- }
- const WIDGET_TYPE_TO_DATA_SET = {
- [WidgetType.DISCOVER]: DataSet.EVENTS,
- [WidgetType.ISSUE]: DataSet.ISSUES,
- [WidgetType.METRICS]: DataSet.RELEASE,
- };
- const DATA_SET_TO_WIDGET_TYPE = {
- [DataSet.EVENTS]: WidgetType.DISCOVER,
- [DataSet.ISSUES]: WidgetType.ISSUE,
- [DataSet.RELEASE]: 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 {
- dashboards: DashboardListItem[];
- dataSet: DataSet;
- displayType: Widget['displayType'];
- interval: Widget['interval'];
- limit: Widget['limit'];
- loading: boolean;
- queries: Widget['queries'];
- title: string;
- userHasModified: boolean;
- errors?: Record<string, any>;
- selectedDashboard?: SelectValue<string>;
- widgetToBeUpdated?: Widget;
- }
- function WidgetBuilder({
- dashboard,
- params,
- location,
- organization,
- selection,
- start,
- end,
- statsPeriod,
- onSave,
- router,
- tags,
- }: Props) {
- const {widgetIndex, orgId, dashboardId} = params;
- const {source, displayType, defaultTitle, defaultTableColumns, limit} = location.query;
- const defaultWidgetQuery = getParsedDefaultWidgetQuery(
- location.query.defaultWidgetQuery
- );
- useEffect(() => {
- if (objectIsEmpty(tags)) {
- loadOrganizationTags(api, organization.slug, selection);
- }
- }, []);
- const isEditing = defined(widgetIndex);
- const widgetIndexNum = Number(widgetIndex);
- const isValidWidgetIndex =
- widgetIndexNum >= 0 &&
- widgetIndexNum < dashboard.widgets.length &&
- Number.isInteger(widgetIndexNum);
- const orgSlug = organization.slug;
- // Feature flag for new widget builder design. This feature is still a work in progress and not yet available internally.
- const widgetBuilderNewDesign = organization.features.includes(
- 'new-widget-builder-experience-design'
- );
- // 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 [state, setState] = useState<State>(() => {
- return {
- title: defaultTitle ?? t('Custom Widget'),
- displayType: displayType ?? DisplayType.TABLE,
- interval: '5m',
- queries: [
- defaultWidgetQuery
- ? widgetBuilderNewDesign
- ? {
- ...defaultWidgetQuery,
- orderby:
- defaultWidgetQuery.orderby ||
- generateOrderOptions({
- widgetType: WidgetType.DISCOVER,
- widgetBuilderNewDesign,
- columns: defaultWidgetQuery.columns,
- aggregates: defaultWidgetQuery.aggregates,
- })[0].value,
- }
- : {...defaultWidgetQuery}
- : {...getDataSetQuery(widgetBuilderNewDesign)[DataSet.EVENTS]},
- ],
- limit,
- errors: undefined,
- loading: !!notDashboardsOrigin,
- dashboards: [],
- userHasModified: false,
- dataSet: DataSet.EVENTS,
- };
- });
- const [widgetToBeUpdated, setWidgetToBeUpdated] = useState<Widget | null>(null);
- useEffect(() => {
- trackAdvancedAnalyticsEvent('dashboards_views.widget_builder.opened', {
- organization,
- new_widget: !isEditing,
- });
- if (isEditing && isValidWidgetIndex) {
- const widgetFromDashboard = dashboard.widgets[widgetIndexNum];
- const visualization =
- widgetBuilderNewDesign && widgetFromDashboard.displayType === DisplayType.TOP_N
- ? DisplayType.TABLE
- : widgetFromDashboard.displayType;
- setState({
- title: widgetFromDashboard.title,
- displayType: visualization,
- interval: widgetFromDashboard.interval,
- queries: normalizeQueries({
- displayType: visualization,
- queries: widgetFromDashboard.queries,
- widgetType: widgetFromDashboard.widgetType ?? WidgetType.DISCOVER,
- widgetBuilderNewDesign,
- }),
- errors: undefined,
- loading: false,
- dashboards: [],
- userHasModified: false,
- dataSet: widgetFromDashboard.widgetType
- ? WIDGET_TYPE_TO_DATA_SET[widgetFromDashboard.widgetType]
- : DataSet.EVENTS,
- limit: widgetFromDashboard.limit,
- });
- setWidgetToBeUpdated(widgetFromDashboard);
- }
- }, []);
- useEffect(() => {
- if (notDashboardsOrigin) {
- fetchDashboards();
- }
- if (widgetBuilderNewDesign) {
- setState(prevState => ({
- ...prevState,
- selectedDashboard: {
- label: dashboard.title,
- value: dashboard.id || NEW_DASHBOARD_ID,
- },
- }));
- }
- }, [source]);
- useEffect(() => {
- fetchOrgMembers(api, organization.slug, selection.projects?.map(String));
- }, [selection.projects]);
- const widgetType =
- state.dataSet === DataSet.EVENTS
- ? WidgetType.DISCOVER
- : state.dataSet === DataSet.ISSUES
- ? WidgetType.ISSUE
- : WidgetType.METRICS;
- const currentWidget = {
- title: state.title,
- displayType: state.displayType,
- interval: state.interval,
- queries: state.queries,
- limit: state.limit,
- widgetType,
- };
- const currentDashboardId = state.selectedDashboard?.value ?? 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 = [
- DisplayType.LINE,
- DisplayType.BAR,
- DisplayType.AREA,
- ].includes(state.displayType);
- const isTabularChart = [DisplayType.TABLE, DisplayType.TOP_N].includes(
- state.displayType
- );
- function updateFieldsAccordingToDisplayType(newDisplayType: DisplayType) {
- setState(prevState => {
- const newState = cloneDeep(prevState);
- const normalized = normalizeQueries({
- displayType: newDisplayType,
- queries: prevState.queries,
- widgetType: DATA_SET_TO_WIDGET_TYPE[prevState.dataSet],
- widgetBuilderNewDesign,
- });
- if (newDisplayType === DisplayType.TOP_N) {
- // TOP N display should only allow a single query
- normalized.splice(1);
- }
- if (widgetBuilderNewDesign && !isTabularChart && !isTimeseriesChart) {
- newState.limit = undefined;
- }
- if (
- (prevState.displayType === DisplayType.TABLE &&
- widgetToBeUpdated?.widgetType &&
- WIDGET_TYPE_TO_DATA_SET[widgetToBeUpdated.widgetType] === DataSet.ISSUES) ||
- (prevState.dataSet === DataSet.RELEASE &&
- newDisplayType === DisplayType.WORLD_MAP)
- ) {
- // World Map display type only supports Events Dataset
- // so set state to default events query.
- set(
- newState,
- 'queries',
- normalizeQueries({
- displayType: newDisplayType,
- queries: [{...getDataSetQuery(widgetBuilderNewDesign)[DataSet.EVENTS]}],
- widgetType: WidgetType.DISCOVER,
- widgetBuilderNewDesign,
- })
- );
- set(newState, 'dataSet', DataSet.EVENTS);
- return {...newState, errors: undefined};
- }
- if (!prevState.userHasModified) {
- // If the Widget is an issue widget,
- if (
- newDisplayType === DisplayType.TABLE &&
- widgetToBeUpdated?.widgetType === WidgetType.ISSUE
- ) {
- set(newState, 'queries', widgetToBeUpdated.queries);
- set(newState, 'dataSet', DataSet.ISSUES);
- return {...newState, errors: undefined};
- }
- // 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];
- });
- } 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) {
- query.orderby = defaultWidgetQuery.orderby;
- }
- });
- }
- }
- }
- if (prevState.dataSet === DataSet.ISSUES) {
- set(newState, 'dataSet', DataSet.EVENTS);
- }
- set(newState, 'queries', normalized);
- return {...newState, errors: undefined};
- });
- }
- function handleDisplayTypeOrTitleChange<
- F extends keyof Pick<State, 'displayType' | 'title'>
- >(field: F, value: State[F]) {
- trackAdvancedAnalyticsEvent('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);
- return {...newState, errors: undefined};
- });
- if (field === 'displayType' && value !== state.displayType) {
- updateFieldsAccordingToDisplayType(value as DisplayType);
- }
- }
- function handleDataSetChange(newDataSet: string) {
- 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);
- }
- newState.queries.push(
- ...(widgetToBeUpdated?.widgetType &&
- WIDGET_TYPE_TO_DATA_SET[widgetToBeUpdated.widgetType] === newDataSet
- ? widgetToBeUpdated.queries
- : [{...getDataSetQuery(widgetBuilderNewDesign)[newDataSet]}])
- );
- set(newState, 'userHasModified', true);
- return {...newState, errors: undefined};
- });
- }
- function handleAddSearchConditions() {
- setState(prevState => {
- const newState = cloneDeep(prevState);
- const query = cloneDeep(getDataSetQuery(widgetBuilderNewDesign)[prevState.dataSet]);
- query.fields = prevState.queries[0].fields;
- query.aggregates = prevState.queries[0].aggregates;
- query.columns = prevState.queries[0].columns;
- 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 handleYAxisOrColumnFieldChange(
- newFields: QueryFieldValue[],
- isColumn = false
- ) {
- const fieldStrings = newFields.map(generateFieldAsString);
- const aggregateAliasFieldStrings =
- state.dataSet === DataSet.RELEASE
- ? fieldStrings.map(stripDerivedMetricsPrefix)
- : fieldStrings.map(getAggregateAlias);
- const columnsAndAggregates = isColumn
- ? getColumnsAndAggregatesAsStrings(newFields)
- : undefined;
- const newState = cloneDeep(state);
- const newQueries = state.queries.map(query => {
- const isDescending = query.orderby.startsWith('-');
- const orderbyAggregateAliasField = query.orderby.replace('-', '');
- const prevAggregateAliasFieldStrings = query.aggregates.map(aggregate =>
- state.dataSet === DataSet.RELEASE
- ? stripDerivedMetricsPrefix(aggregate)
- : getAggregateAlias(aggregate)
- );
- const newQuery = cloneDeep(query);
- if (isColumn) {
- newQuery.fields = fieldStrings;
- newQuery.aggregates = columnsAndAggregates?.aggregates ?? [];
- } else 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;
- }
- // Prevent overwriting columns when setting y-axis for time series
- if (!(widgetBuilderNewDesign && isTimeseriesChart) && isColumn) {
- newQuery.columns = columnsAndAggregates?.columns ?? [];
- }
- if (
- !aggregateAliasFieldStrings.includes(orderbyAggregateAliasField) &&
- query.orderby !== ''
- ) {
- if (prevAggregateAliasFieldStrings.length === newFields.length) {
- // The Field that was used in orderby has changed. Get the new field.
- const newOrderByValue =
- aggregateAliasFieldStrings[
- prevAggregateAliasFieldStrings.indexOf(orderbyAggregateAliasField)
- ];
- if (isDescending) {
- newQuery.orderby = `-${newOrderByValue}`;
- } else {
- newQuery.orderby = newOrderByValue;
- }
- } else {
- newQuery.orderby = widgetBuilderNewDesign ? aggregateAliasFieldStrings[0] : '';
- }
- }
- if (widgetBuilderNewDesign) {
- newQuery.fieldAliases = columnsAndAggregates?.fieldAliases ?? [];
- }
- return newQuery;
- });
- set(newState, 'queries', newQueries);
- set(newState, 'userHasModified', true);
- if (widgetBuilderNewDesign && isTimeseriesChart) {
- 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;
- return newQuery;
- });
- set(newState, 'userHasModified', true);
- set(newState, 'queries', newQueries);
- if (widgetBuilderNewDesign && isTimeseriesChart) {
- 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;
- }
- let nextWidgetList = [...dashboard.widgets];
- nextWidgetList.splice(widgetIndexNum, 1);
- nextWidgetList = generateWidgetsAfterCompaction(nextWidgetList);
- onSave(nextWidgetList);
- router.push(previousLocation);
- }
- async function handleSave() {
- const widgetData: Widget = assignTempId(currentWidget);
- if (widgetToBeUpdated) {
- widgetData.layout = widgetToBeUpdated?.layout;
- }
- // Only Table and Top N views need orderby
- if (!widgetBuilderNewDesign && !isTabularChart) {
- widgetData.queries.forEach(query => {
- query.orderby = '';
- });
- }
- if (!widgetBuilderNewDesign) {
- widgetData.queries.forEach(query => omit(query, 'fieldAliases'));
- }
- // Only Time Series charts shall have a limit
- if (widgetBuilderNewDesign && !isTimeseriesChart) {
- widgetData.limit = undefined;
- }
- if (!(await dataIsValid(widgetData))) {
- return;
- }
- if (notDashboardsOrigin) {
- submitFromSelectedDashboard(widgetData);
- return;
- }
- if (!!widgetToBeUpdated) {
- let nextWidgetList = [...dashboard.widgets];
- const updateIndex = nextWidgetList.indexOf(widgetToBeUpdated);
- 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[updateIndex] = enforceWidgetHeightValues(nextWidgetData);
- nextWidgetList = generateWidgetsAfterCompaction(nextWidgetList);
- } else {
- nextWidgetList[updateIndex] = nextWidgetData;
- }
- onSave(nextWidgetList);
- addSuccessMessage(t('Updated widget.'));
- goToDashboards(dashboardId ?? NEW_DASHBOARD_ID);
- trackAdvancedAnalyticsEvent('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);
- trackAdvancedAnalyticsEvent('dashboards_views.widget_builder.save', {
- organization,
- data_set: widgetData.widgetType ?? WidgetType.DISCOVER,
- new_widget: true,
- });
- }
- async function dataIsValid(widgetData: Widget): Promise<boolean> {
- if (notDashboardsOrigin) {
- // Validate that a dashboard was selected since api call to /dashboards/widgets/ does not check for dashboard
- if (
- !state.selectedDashboard ||
- !(
- state.dashboards.find(
- ({title, id}) =>
- title === state.selectedDashboard?.label &&
- id === state.selectedDashboard?.value
- ) || state.selectedDashboard.value === NEW_DASHBOARD_ID
- )
- ) {
- setState({
- ...state,
- errors: {...state.errors, dashboard: t('This field may not be blank')},
- });
- return false;
- }
- }
- 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 ?? {}, {})},
- });
- return false;
- }
- }
- async function fetchDashboards() {
- const promise: Promise<DashboardListItem[]> = api.requestPromise(
- `/organizations/${organization.slug}/dashboards/`,
- {
- method: 'GET',
- query: {sort: 'myDashboardsAndRecentlyViewed'},
- }
- );
- try {
- const dashboards = await promise;
- setState(prevState => ({...prevState, dashboards, loading: false}));
- } catch (error) {
- const errorMessage = t('Unable to fetch dashboards');
- addErrorMessage(errorMessage);
- handleXhrErrorResponse(errorMessage)(error);
- setState(prevState => ({...prevState, loading: 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.value, pathQuery);
- }
- function goToDashboards(id: string, query?: Record<string, any>) {
- const pathQuery =
- !isEmpty(queryParamsWithoutSource) || query
- ? {
- ...queryParamsWithoutSource,
- ...query,
- }
- : undefined;
- if (id === NEW_DASHBOARD_ID) {
- router.push({
- pathname: `/organizations/${organization.slug}/dashboards/new/`,
- query: pathQuery,
- });
- return;
- }
- router.push({
- pathname: `/organizations/${organization.slug}/dashboard/${id}/`,
- query: pathQuery,
- });
- }
- function isFormInvalid() {
- if (notDashboardsOrigin && !state.selectedDashboard) {
- return true;
- }
- return false;
- }
- if (isEditing && !isValidWidgetIndex) {
- return (
- <SentryDocumentTitle title={dashboard.title} orgSlug={orgSlug}>
- <PageContent>
- <LoadingError message={t('The widget you want to edit was not found.')} />
- </PageContent>
- </SentryDocumentTitle>
- );
- }
- const canAddSearchConditions =
- [DisplayType.LINE, DisplayType.AREA, DisplayType.BAR].includes(state.displayType) &&
- state.queries.length < 3;
- const hideLegendAlias = [
- DisplayType.TABLE,
- DisplayType.WORLD_MAP,
- 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];
- return (
- <SentryDocumentTitle title={dashboard.title} orgSlug={orgSlug}>
- <PageFiltersContainer
- skipLoadLastUsed={organization.features.includes('global-views')}
- defaultSelection={{
- datetime: {start: null, end: null, utc: false, period: DEFAULT_STATS_PERIOD},
- }}
- >
- <PageContentWithoutPadding>
- <Header
- orgSlug={orgSlug}
- title={state.title}
- dashboardTitle={dashboard.title}
- goBackLocation={previousLocation}
- onChangeTitle={newTitle => {
- handleDisplayTypeOrTitleChange('title', newTitle);
- }}
- />
- <Body>
- <MainWrapper>
- <Main>
- <BuildSteps symbol="colored-numeric">
- <VisualizationStep
- widget={currentWidget}
- organization={organization}
- pageFilters={pageFilters}
- displayType={state.displayType}
- error={state.errors?.displayType}
- onChange={newDisplayType => {
- handleDisplayTypeOrTitleChange('displayType', newDisplayType);
- }}
- />
- <DataSetStep
- dataSet={state.dataSet}
- displayType={state.displayType}
- onChange={handleDataSetChange}
- widgetBuilderNewDesign={widgetBuilderNewDesign}
- />
- {isTabularChart && (
- <ColumnsStep
- dataSet={state.dataSet}
- queries={state.queries}
- displayType={state.displayType}
- widgetType={widgetType}
- queryErrors={state.errors?.queries}
- onQueryChange={handleQueryChange}
- onYAxisOrColumnFieldChange={newFields => {
- handleYAxisOrColumnFieldChange(newFields, true);
- }}
- explodedFields={explodedFields}
- tags={tags}
- organization={organization}
- />
- )}
- {![DisplayType.TABLE].includes(state.displayType) && (
- <YAxisStep
- dataSet={state.dataSet}
- displayType={state.displayType}
- widgetType={widgetType}
- queryErrors={state.errors?.queries}
- onYAxisChange={newFields => {
- handleYAxisOrColumnFieldChange(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}
- />
- {widgetBuilderNewDesign && 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}
- />
- )}
- {((widgetBuilderNewDesign && isTimeseriesChart) || isTabularChart) && (
- <SortByStep
- limit={state.limit}
- displayType={state.displayType}
- queries={state.queries}
- dataSet={state.dataSet}
- widgetBuilderNewDesign={widgetBuilderNewDesign}
- error={state.errors?.orderby}
- onSortByChange={handleSortByChange}
- onLimitChange={handleLimitChange}
- organization={organization}
- widgetType={widgetType}
- />
- )}
- {notDashboardsOrigin && !widgetBuilderNewDesign && (
- <DashboardStep
- error={state.errors?.dashboard}
- dashboards={state.dashboards}
- onChange={selectedDashboard =>
- setState({
- ...state,
- selectedDashboard,
- errors: {...state.errors, dashboard: undefined},
- })
- }
- disabled={state.loading}
- />
- )}
- </BuildSteps>
- </Main>
- <Footer
- goBackLocation={previousLocation}
- isEditing={isEditing}
- onSave={handleSave}
- onDelete={handleDelete}
- invalidForm={isFormInvalid()}
- />
- </MainWrapper>
- <Side>
- <WidgetLibrary
- widgetBuilderNewDesign={widgetBuilderNewDesign}
- onWidgetSelect={prebuiltWidget =>
- setState({
- ...state,
- ...prebuiltWidget,
- dataSet: prebuiltWidget.widgetType
- ? WIDGET_TYPE_TO_DATA_SET[prebuiltWidget.widgetType]
- : DataSet.EVENTS,
- userHasModified: false,
- })
- }
- bypassOverwriteModal={!state.userHasModified}
- />
- </Side>
- </Body>
- </PageContentWithoutPadding>
- </PageFiltersContainer>
- </SentryDocumentTitle>
- );
- }
- export default withPageFilters(withTags(WidgetBuilder));
- const PageContentWithoutPadding = styled(PageContent)`
- padding: 0;
- `;
- const BuildSteps = styled(List)`
- gap: ${space(4)};
- max-width: 100%;
- `;
- const Body = styled(Layout.Body)`
- grid-template-rows: 1fr;
- && {
- gap: 0;
- padding: 0;
- }
- @media (max-width: ${p => p.theme.breakpoints[3]}) {
- grid-template-columns: 1fr;
- }
- @media (min-width: ${p => p.theme.breakpoints[2]}) {
- /* 325px + 16px + 16px to match Side component width, padding-left and padding-right */
- grid-template-columns: minmax(100px, auto) calc(325px + ${space(2) + space(2)});
- }
- @media (min-width: ${p => p.theme.breakpoints[3]}) {
- /* 325px + 16px + 30px to match Side component width, padding-left and padding-right */
- grid-template-columns: minmax(100px, auto) calc(325px + ${space(2) + space(4)});
- }
- `;
- const Main = styled(Layout.Main)`
- max-width: 1000px;
- flex: 1;
- padding: ${space(4)} ${space(2)};
- @media (min-width: ${p => p.theme.breakpoints[1]}) {
- padding: ${space(4)};
- }
- `;
- const Side = styled(Layout.Side)`
- padding: ${space(4)} ${space(2)};
- @media (min-width: ${p => p.theme.breakpoints[3]}) {
- border-left: 1px solid ${p => p.theme.gray200};
- /* to be consistent with Layout.Body in other verticals */
- padding-right: ${space(4)};
- }
- @media (max-width: ${p => p.theme.breakpoints[3]}) {
- border-top: 1px solid ${p => p.theme.gray200};
- }
- @media (max-width: ${p => p.theme.breakpoints[3]}) {
- grid-row: 2/2;
- grid-column: 1/1;
- }
- `;
- const MainWrapper = styled('div')`
- display: flex;
- flex-direction: column;
- `;
|