import * as React from 'react'; import {browserHistory} from 'react-router'; import {components, OptionProps} from 'react-select'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; import cloneDeep from 'lodash/cloneDeep'; import pick from 'lodash/pick'; import set from 'lodash/set'; import {validateWidget} from 'sentry/actionCreators/dashboards'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {ModalRenderProps} from 'sentry/actionCreators/modal'; import {Client} from 'sentry/api'; import Button from 'sentry/components/button'; import ButtonBar from 'sentry/components/buttonBar'; import IssueWidgetQueriesForm from 'sentry/components/dashboards/issueWidgetQueriesForm'; import WidgetQueriesForm from 'sentry/components/dashboards/widgetQueriesForm'; import SelectControl from 'sentry/components/forms/selectControl'; import {PanelAlert} from 'sentry/components/panels'; import {t, tct} from 'sentry/locale'; import space from 'sentry/styles/space'; import { DateString, GlobalSelection, Organization, RelativePeriod, SelectValue, TagCollection, } from 'sentry/types'; import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent'; import Measurements from 'sentry/utils/measurements/measurements'; import withApi from 'sentry/utils/withApi'; import withGlobalSelection from 'sentry/utils/withGlobalSelection'; import withTags from 'sentry/utils/withTags'; import {DISPLAY_TYPE_CHOICES} from 'sentry/views/dashboardsV2/data'; import { DashboardDetails, DashboardListItem, DashboardWidgetSource, DisplayType, MAX_WIDGETS, Widget, WidgetQuery, WidgetType, } from 'sentry/views/dashboardsV2/types'; import { mapErrors, normalizeQueries, } from 'sentry/views/dashboardsV2/widget/eventWidget/utils'; import {generateIssueWidgetFieldOptions} from 'sentry/views/dashboardsV2/widget/issueWidget/utils'; import WidgetCard from 'sentry/views/dashboardsV2/widgetCard'; import {WidgetTemplate} from 'sentry/views/dashboardsV2/widgetLibrary/data'; import {generateFieldOptions} from 'sentry/views/eventsV2/utils'; import Input from 'sentry/views/settings/components/forms/controls/input'; import RadioGroup from 'sentry/views/settings/components/forms/controls/radioGroup'; import Field from 'sentry/views/settings/components/forms/field'; import FieldLabel from 'sentry/views/settings/components/forms/field/fieldLabel'; import Tooltip from '../tooltip'; import {TAB, TabsButtonBar} from './dashboardWidgetLibraryModal/tabsButtonBar'; export type DashboardWidgetModalOptions = { organization: Organization; dashboard?: DashboardDetails; selection?: GlobalSelection; onAddWidget?: (data: Widget) => void; widget?: Widget; onUpdateWidget?: (nextWidget: Widget) => void; defaultWidgetQuery?: WidgetQuery; defaultTableColumns?: readonly string[]; defaultTitle?: string; displayType?: DisplayType; source: DashboardWidgetSource; start?: DateString; end?: DateString; statsPeriod?: RelativePeriod | string; selectedWidgets?: WidgetTemplate[]; onAddLibraryWidget?: (widgets: Widget[]) => void; }; type Props = ModalRenderProps & DashboardWidgetModalOptions & { api: Client; organization: Organization; selection: GlobalSelection; tags: TagCollection; }; type FlatValidationError = { [key: string]: string | FlatValidationError[] | FlatValidationError; }; type State = { title: string; displayType: Widget['displayType']; interval: Widget['interval']; queries: Widget['queries']; loading: boolean; errors?: Record; dashboards: DashboardListItem[]; selectedDashboard?: SelectValue; userHasModified: boolean; widgetType: WidgetType; }; const newQuery = { name: '', fields: ['count()'], conditions: '', orderby: '', }; const newIssueQuery = { name: '', fields: ['issue', 'assignee', 'title'] as string[], conditions: '', orderby: '', }; const DATASET_CHOICES: [WidgetType, string][] = [ [WidgetType.DISCOVER, t('All Events (Errors, Transactions, CSP)')], [WidgetType.ISSUE, t('Issues (Assignees, Status, ...)')], ]; class AddDashboardWidgetModal extends React.Component { constructor(props: Props) { super(props); const {widget, defaultWidgetQuery, defaultTitle, displayType} = props; if (!widget) { this.state = { title: defaultTitle ?? '', displayType: displayType ?? DisplayType.LINE, interval: '5m', queries: [defaultWidgetQuery ? {...defaultWidgetQuery} : {...newQuery}], errors: undefined, loading: !!this.omitDashboardProp, dashboards: [], userHasModified: false, widgetType: WidgetType.DISCOVER, }; return; } this.state = { title: widget.title, displayType: widget.displayType, interval: widget.interval, queries: normalizeQueries(widget.displayType, widget.queries), errors: undefined, loading: false, dashboards: [], userHasModified: false, widgetType: widget.widgetType ?? WidgetType.DISCOVER, }; } componentDidMount() { if (this.omitDashboardProp) { this.fetchDashboards(); } this.handleDefaultFields(); } get omitDashboardProp() { // when opening from discover or issues page, the user selects the dashboard in the widget UI return [ DashboardWidgetSource.DISCOVERV2, DashboardWidgetSource.ISSUE_DETAILS, ].includes(this.props.source); } get fromLibrary() { return this.props.source === DashboardWidgetSource.LIBRARY; } handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); const { api, closeModal, organization, onAddWidget, onUpdateWidget, widget: previousWidget, source, } = this.props; this.setState({loading: true}); let errors: FlatValidationError = {}; const widgetData: Widget = pick(this.state, [ 'title', 'displayType', 'interval', 'queries', 'widgetType', ]); // Only Table and Top N views need orderby if (![DisplayType.TABLE, DisplayType.TOP_N].includes(widgetData.displayType)) { widgetData.queries.forEach(query => { query.orderby = ''; }); } try { await validateWidget(api, organization.slug, widgetData); if (typeof onUpdateWidget === 'function' && !!previousWidget) { onUpdateWidget({ id: previousWidget?.id, ...widgetData, }); addSuccessMessage(t('Updated widget.')); trackAdvancedAnalyticsEvent('dashboards_views.edit_widget_modal.confirm', { organization, }); } else if (onAddWidget) { onAddWidget(widgetData); addSuccessMessage(t('Added widget.')); trackAdvancedAnalyticsEvent('dashboards_views.add_widget_modal.confirm', { organization, }); } if (source === DashboardWidgetSource.DASHBOARDS) { closeModal(); } } catch (err) { errors = mapErrors(err?.responseJSON ?? {}, {}); this.setState({errors}); } finally { this.setState({loading: false}); if (this.omitDashboardProp) { this.handleSubmitFromSelectedDashboard(errors, widgetData); } if (this.fromLibrary) { this.handleSubmitFromLibrary(errors, widgetData); } } }; handleSubmitFromSelectedDashboard = async ( errors: FlatValidationError, widgetData: Widget ) => { const {closeModal, organization, selection} = this.props; const {selectedDashboard, dashboards} = this.state; // Validate that a dashboard was selected since api call to /dashboards/widgets/ does not check for dashboard if ( !selectedDashboard || !( dashboards.find(({title, id}) => { return title === selectedDashboard?.label && id === selectedDashboard?.value; }) || selectedDashboard.value === 'new' ) ) { errors.dashboard = t('This field may not be blank'); this.setState({errors}); } if (!Object.keys(errors).length && selectedDashboard) { closeModal(); const queryData: { queryNames: string[]; queryConditions: string[]; queryFields: string[]; queryOrderby: string; } = { queryNames: [], queryConditions: [], queryFields: widgetData.queries[0].fields, 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 global header selection ...selection.datetime, project: selection.projects, environment: selection.environments, }; trackAdvancedAnalyticsEvent('discover_views.add_to_dashboard.confirm', { organization, }); if (selectedDashboard.value === 'new') { browserHistory.push({ pathname: `/organizations/${organization.slug}/dashboards/new/`, query: pathQuery, }); } else { browserHistory.push({ pathname: `/organizations/${organization.slug}/dashboard/${selectedDashboard.value}/`, query: pathQuery, }); } } }; handleSubmitFromLibrary = async (errors: FlatValidationError, widgetData: Widget) => { const {closeModal, dashboard, onAddLibraryWidget} = this.props; if (!dashboard) { errors.dashboard = t('This field may not be blank'); this.setState({errors}); addErrorMessage(t('Widget may only be added to a Dashboard')); } if (!Object.keys(errors).length && dashboard && onAddLibraryWidget) { onAddLibraryWidget([...dashboard.widgets, widgetData]); closeModal(); } }; handleDefaultFields = () => { const {defaultWidgetQuery, defaultTableColumns, widget} = this.props; this.setState(prevState => { const newState = cloneDeep(prevState); const displayType = prevState.displayType as Widget['displayType']; const normalized = normalizeQueries(displayType, prevState.queries); if (!prevState.userHasModified) { // If the Widget is an issue widget, if ( displayType === DisplayType.TABLE && widget?.widgetType === WidgetType.ISSUE ) { set(newState, 'queries', widget.queries); set(newState, 'widgetType', WidgetType.ISSUE); 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 if (displayType === DisplayType.TABLE) { normalized.forEach(query => { query.fields = [...defaultTableColumns]; }); } else if (displayType === DisplayType.TOP_N) { normalized.forEach(query => { // Append Y-Axis to query.fields since TOP_N view assumes the last field is the Y-Axis query.fields = [...defaultTableColumns, defaultWidgetQuery.fields[0]]; query.orderby = defaultWidgetQuery.orderby; }); } else { normalized.forEach(query => { query.fields = [...defaultWidgetQuery.fields]; }); } } } set(newState, 'widgetType', WidgetType.DISCOVER); set(newState, 'queries', normalized); return {...newState, errors: undefined}; }); }; handleFieldChange = (field: string) => (value: string) => { const {organization, source} = this.props; this.setState(prevState => { const newState = cloneDeep(prevState); set(newState, field, value); trackAdvancedAnalyticsEvent('dashboards_views.add_widget_modal.change', { from: source, field, value, widget_type: prevState.widgetType, organization, }); return {...newState, errors: undefined}; }); if (field === 'displayType') { this.handleDefaultFields(); } }; handleQueryChange = (widgetQuery: WidgetQuery, index: number) => { this.setState(prevState => { const newState = cloneDeep(prevState); set(newState, `queries.${index}`, widgetQuery); set(newState, 'userHasModified', true); return {...newState, errors: undefined}; }); }; handleQueryRemove = (index: number) => { this.setState(prevState => { const newState = cloneDeep(prevState); newState.queries.splice(index, 1); return {...newState, errors: undefined}; }); }; handleAddSearchConditions = () => { this.setState(prevState => { const newState = cloneDeep(prevState); const query = cloneDeep(newQuery); query.fields = this.state.queries[0].fields; newState.queries.push(query); return newState; }); }; handleDatasetChange = (widgetType: string) => { const {widget} = this.props; this.setState(prevState => { const newState = cloneDeep(prevState); newState.queries.splice(0, newState.queries.length); set(newState, 'widgetType', widgetType); const defaultQuery = widgetType === WidgetType.ISSUE ? newIssueQuery : newQuery; newState.queries.push( ...(widget?.widgetType === widgetType ? widget.queries : [defaultQuery]) ); set(newState, 'userHasModified', true); return {...newState, errors: undefined}; }); }; canAddSearchConditions() { const rightDisplayType = ['line', 'area', 'stacked_area', 'bar'].includes( this.state.displayType ); const underQueryLimit = this.state.queries.length < 3; return rightDisplayType && underQueryLimit; } async fetchDashboards() { const {api, organization} = this.props; const promise: Promise = api.requestPromise( `/organizations/${organization.slug}/dashboards/`, { method: 'GET', query: {sort: 'myDashboardsAndRecentlyViewed'}, } ); try { const dashboards = await promise; this.setState({ dashboards, }); } catch (error) { const errorResponse = error?.responseJSON ?? null; if (errorResponse) { addErrorMessage(errorResponse); } else { addErrorMessage(t('Unable to fetch dashboards')); } } this.setState({loading: false}); } handleDashboardChange(option: SelectValue) { this.setState({selectedDashboard: option}); } renderDashboardSelector() { const {errors, loading, dashboards} = this.state; const dashboardOptions = dashboards.map(d => { return { label: d.title, value: d.id, isDisabled: d.widgetDisplay.length >= MAX_WIDGETS, }; }); return (

{t( `Choose which dashboard you'd like to add this query to. It will appear as a widget.` )}

) => this.handleDashboardChange(option)} disabled={loading} components={{ Option: ({label, data, ...optionProps}: OptionProps) => ( ), }} />
); } render() { const { Footer, Body, Header, organization, selection, tags, widget: previousWidget, start, end, statsPeriod, dashboard, selectedWidgets, onUpdateWidget, onAddLibraryWidget, source, } = this.props; const state = this.state; const errors = state.errors; // Construct GlobalSelection object using statsPeriod/start/end props so we can render widget graph using saved timeframe from Saved/Prebuilt Query const querySelection: GlobalSelection = statsPeriod ? {...selection, datetime: {start: null, end: null, period: statsPeriod, utc: null}} : start && end ? {...selection, datetime: {start, end, period: '', utc: null}} : selection; const fieldOptions = (measurementKeys: string[]) => generateFieldOptions({ organization, tagKeys: Object.values(tags).map(({key}) => key), measurementKeys, }); const issueWidgetFieldOptions = generateIssueWidgetFieldOptions(); const isUpdatingWidget = typeof onUpdateWidget === 'function' && !!previousWidget; return (

{this.omitDashboardProp ? t('Add Widget to Dashboard') : this.fromLibrary ? t('Add Widget(s)') : isUpdatingWidget ? t('Edit Widget') : t('Add Widget')}

{this.omitDashboardProp && this.renderDashboardSelector()} {this.fromLibrary && dashboard && onAddLibraryWidget ? ( ) : null} ) => { this.handleFieldChange('title')(event.target.value); }} disabled={state.loading} /> this.handleFieldChange('displayType')(option.value)} disabled={state.loading} /> {organization.features.includes('issues-in-dashboards') && [DashboardWidgetSource.DASHBOARDS, DashboardWidgetSource.LIBRARY].includes( source ) && state.displayType === DisplayType.TABLE && ( {t('Data Set')} )} {state.widgetType === WidgetType.ISSUE ? ( this.handleQueryChange(widgetQuery, 0)} /> undefined} onEdit={() => undefined} onDuplicate={() => undefined} widgetLimitReached={false} renderErrorMessage={errorMessage => typeof errorMessage === 'string' && ( {errorMessage} ) } isSorting={false} currentWidgetDragging={false} noLazyLoad /> ) : ( {({measurements}) => { const measurementKeys = Object.values(measurements).map(({key}) => key); const amendedFieldOptions = fieldOptions(measurementKeys); return ( this.handleQueryChange(widgetQuery, queryIndex) } canAddSearchConditions={this.canAddSearchConditions()} handleAddSearchConditions={this.handleAddSearchConditions} handleDeleteQuery={this.handleQueryRemove} /> ); }} undefined} onEdit={() => undefined} onDuplicate={() => undefined} widgetLimitReached={false} renderErrorMessage={errorMessage => typeof errorMessage === 'string' && ( {errorMessage} ) } isSorting={false} currentWidgetDragging={false} noLazyLoad /> )}
); } } const DoubleFieldWrapper = styled('div')` display: inline-grid; grid-template-columns: repeat(2, 1fr); grid-column-gap: ${space(1)}; width: 100%; `; export const modalCss = css` width: 100%; max-width: 700px; margin: 70px auto; `; const StyledField = styled(Field)` position: relative; `; const StyledRadioGroup = styled(RadioGroup)` padding-bottom: ${space(2)}; `; const StyledFieldLabel = styled(FieldLabel)` padding-bottom: ${space(1)}; `; export default withApi(withGlobalSelection(withTags(AddDashboardWidgetModal)));