mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-10-30 14:49:14 +00:00
1024 lines
36 KiB
TypeScript
1024 lines
36 KiB
TypeScript
import {cloneElement, Component, isValidElement} from 'react';
|
|
import type {PlainRoute, RouteComponentProps} from 'react-router';
|
|
import {browserHistory} from 'react-router';
|
|
import styled from '@emotion/styled';
|
|
import cloneDeep from 'lodash/cloneDeep';
|
|
import isEqual from 'lodash/isEqual';
|
|
import isEqualWith from 'lodash/isEqualWith';
|
|
import omit from 'lodash/omit';
|
|
|
|
import {
|
|
createDashboard,
|
|
deleteDashboard,
|
|
updateDashboard,
|
|
} from 'sentry/actionCreators/dashboards';
|
|
import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
|
|
import {openWidgetViewerModal} from 'sentry/actionCreators/modal';
|
|
import type {Client} from 'sentry/api';
|
|
import {Breadcrumbs} from 'sentry/components/breadcrumbs';
|
|
import HookOrDefault from 'sentry/components/hookOrDefault';
|
|
import * as Layout from 'sentry/components/layouts/thirds';
|
|
import {
|
|
isWidgetViewerPath,
|
|
WidgetViewerQueryField,
|
|
} from 'sentry/components/modals/widgetViewerModal/utils';
|
|
import NoProjectMessage from 'sentry/components/noProjectMessage';
|
|
import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
|
|
import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
|
|
import {USING_CUSTOMER_DOMAIN} from 'sentry/constants';
|
|
import {t} from 'sentry/locale';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {Organization, PageFilters, Project} from 'sentry/types';
|
|
import {defined} from 'sentry/utils';
|
|
import {trackAnalytics} from 'sentry/utils/analytics';
|
|
import EventView from 'sentry/utils/discover/eventView';
|
|
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 withApi from 'sentry/utils/withApi';
|
|
import {normalizeUrl} from 'sentry/utils/withDomainRequired';
|
|
import withOrganization from 'sentry/utils/withOrganization';
|
|
import withPageFilters from 'sentry/utils/withPageFilters';
|
|
import withProjects from 'sentry/utils/withProjects';
|
|
import {
|
|
cloneDashboard,
|
|
getCurrentPageFilters,
|
|
getDashboardFiltersFromURL,
|
|
hasUnsavedFilterChanges,
|
|
isWidgetUsingTransactionName,
|
|
resetPageFilters,
|
|
} from 'sentry/views/dashboards/utils';
|
|
import {DataSet} from 'sentry/views/dashboards/widgetBuilder/utils';
|
|
import {MetricsDashboardContextProvider} from 'sentry/views/dashboards/widgetCard/metricsContext';
|
|
import {MetricsDataSwitcherAlert} from 'sentry/views/performance/landing/metricsDataSwitcherAlert';
|
|
|
|
import {defaultMetricWidget} from '../../utils/metrics/dashboard';
|
|
import {generatePerformanceEventView} from '../performance/data';
|
|
import {MetricsDataSwitcher} from '../performance/landing/metricsDataSwitcher';
|
|
import {DiscoverQueryPageSource} from '../performance/utils';
|
|
|
|
import type {WidgetViewerContextProps} from './widgetViewer/widgetViewerContext';
|
|
import {WidgetViewerContext} from './widgetViewer/widgetViewerContext';
|
|
import Controls from './controls';
|
|
import Dashboard from './dashboard';
|
|
import {DEFAULT_STATS_PERIOD} from './data';
|
|
import FiltersBar from './filtersBar';
|
|
import {
|
|
assignDefaultLayout,
|
|
assignTempId,
|
|
calculateColumnDepths,
|
|
generateWidgetsAfterCompaction,
|
|
getDashboardLayout,
|
|
} from './layoutUtils';
|
|
import DashboardTitle from './title';
|
|
import type {
|
|
DashboardDetails,
|
|
DashboardFilters,
|
|
DashboardListItem,
|
|
Widget,
|
|
} from './types';
|
|
import {
|
|
DashboardFilterKeys,
|
|
DashboardState,
|
|
DashboardWidgetSource,
|
|
MAX_WIDGETS,
|
|
WidgetType,
|
|
} from './types';
|
|
|
|
const UNSAVED_MESSAGE = t('You have unsaved changes, are you sure you want to leave?');
|
|
|
|
export const UNSAVED_FILTERS_MESSAGE = t(
|
|
'You have unsaved dashboard filters. You can save or discard them.'
|
|
);
|
|
|
|
const HookHeader = HookOrDefault({hookName: 'component:dashboards-header'});
|
|
|
|
type RouteParams = {
|
|
dashboardId?: string;
|
|
templateId?: string;
|
|
widgetId?: number | string;
|
|
widgetIndex?: number;
|
|
};
|
|
|
|
type Props = RouteComponentProps<RouteParams, {}> & {
|
|
api: Client;
|
|
dashboard: DashboardDetails;
|
|
dashboards: DashboardListItem[];
|
|
initialState: DashboardState;
|
|
organization: Organization;
|
|
projects: Project[];
|
|
route: PlainRoute;
|
|
selection: PageFilters;
|
|
children?: React.ReactNode;
|
|
newWidget?: Widget;
|
|
onDashboardUpdate?: (updatedDashboard: DashboardDetails) => void;
|
|
onSetNewWidget?: () => void;
|
|
};
|
|
|
|
type State = {
|
|
dashboardState: DashboardState;
|
|
modifiedDashboard: DashboardDetails | null;
|
|
widgetLimitReached: boolean;
|
|
} & WidgetViewerContextProps;
|
|
|
|
class DashboardDetail extends Component<Props, State> {
|
|
state: State = {
|
|
dashboardState: this.props.initialState,
|
|
modifiedDashboard: this.updateModifiedDashboard(this.props.initialState),
|
|
widgetLimitReached: this.props.dashboard.widgets.length >= MAX_WIDGETS,
|
|
setData: data => {
|
|
this.setState(data);
|
|
},
|
|
};
|
|
|
|
componentDidMount() {
|
|
const {route, router} = this.props;
|
|
router.setRouteLeaveHook(route, this.onRouteLeave);
|
|
window.addEventListener('beforeunload', this.onUnload);
|
|
this.checkIfShouldMountWidgetViewerModal();
|
|
}
|
|
|
|
componentDidUpdate(prevProps: Props) {
|
|
this.checkIfShouldMountWidgetViewerModal();
|
|
|
|
if (prevProps.initialState !== this.props.initialState) {
|
|
// Widget builder can toggle Edit state when saving
|
|
this.setState({dashboardState: this.props.initialState});
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
window.removeEventListener('beforeunload', this.onUnload);
|
|
}
|
|
|
|
checkIfShouldMountWidgetViewerModal() {
|
|
const {
|
|
params: {widgetId, dashboardId},
|
|
organization,
|
|
dashboard,
|
|
location,
|
|
router,
|
|
} = this.props;
|
|
const {seriesData, tableData, pageLinks, totalIssuesCount, seriesResultsType} =
|
|
this.state;
|
|
if (isWidgetViewerPath(location.pathname)) {
|
|
const widget =
|
|
defined(widgetId) &&
|
|
(dashboard.widgets.find(({id}) => {
|
|
// This ternary exists because widgetId is in some places typed as string, while
|
|
// in other cases it is typed as number. Instead of changing the type everywhere,
|
|
// we check for both cases at runtime as I am not sure which is the correct type.
|
|
return typeof widgetId === 'number' ? id === String(widgetId) : id === widgetId;
|
|
}) ??
|
|
dashboard.widgets[widgetId]);
|
|
if (widget) {
|
|
openWidgetViewerModal({
|
|
organization,
|
|
widget,
|
|
seriesData,
|
|
seriesResultsType,
|
|
tableData,
|
|
pageLinks,
|
|
totalIssuesCount,
|
|
dashboardFilters: getDashboardFiltersFromURL(location) ?? dashboard.filters,
|
|
onMetricWidgetEdit: (updatedWidget: Widget) => {
|
|
const widgets = [...dashboard.widgets];
|
|
|
|
const widgetIndex = dashboard.widgets.indexOf(widget);
|
|
widgets[widgetIndex] = {...widgets[widgetIndex], ...updatedWidget};
|
|
|
|
this.handleUpdateWidgetList(widgets);
|
|
},
|
|
onClose: () => {
|
|
// Filter out Widget Viewer Modal query params when exiting the Modal
|
|
const query = omit(location.query, Object.values(WidgetViewerQueryField));
|
|
router.push({
|
|
pathname: location.pathname.replace(/widget\/[0-9]+\/$/, ''),
|
|
query,
|
|
});
|
|
},
|
|
onEdit: () => {
|
|
const widgetIndex = dashboard.widgets.indexOf(widget);
|
|
if (dashboardId) {
|
|
router.push(
|
|
normalizeUrl({
|
|
pathname: `/organizations/${organization.slug}/dashboard/${dashboardId}/widget/${widgetIndex}/edit/`,
|
|
query: {
|
|
...location.query,
|
|
source: DashboardWidgetSource.DASHBOARDS,
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
},
|
|
});
|
|
trackAnalytics('dashboards_views.widget_viewer.open', {
|
|
organization,
|
|
widget_type: widget.widgetType ?? WidgetType.DISCOVER,
|
|
display_type: widget.displayType,
|
|
});
|
|
} else {
|
|
// Replace the URL if the widget isn't found and raise an error in toast
|
|
router.replace(
|
|
normalizeUrl({
|
|
pathname: `/organizations/${organization.slug}/dashboard/${dashboard.id}/`,
|
|
query: location.query,
|
|
})
|
|
);
|
|
addErrorMessage(t('Widget not found'));
|
|
}
|
|
}
|
|
}
|
|
|
|
updateModifiedDashboard(dashboardState: DashboardState) {
|
|
const {dashboard} = this.props;
|
|
switch (dashboardState) {
|
|
case DashboardState.PREVIEW:
|
|
case DashboardState.CREATE:
|
|
case DashboardState.EDIT:
|
|
return cloneDashboard(dashboard);
|
|
default: {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
get isPreview() {
|
|
const {dashboardState} = this.state;
|
|
return DashboardState.PREVIEW === dashboardState;
|
|
}
|
|
|
|
get isEditingDashboard() {
|
|
const {dashboardState} = this.state;
|
|
return [
|
|
DashboardState.EDIT,
|
|
DashboardState.CREATE,
|
|
DashboardState.PENDING_DELETE,
|
|
].includes(dashboardState);
|
|
}
|
|
|
|
get isWidgetBuilderRouter() {
|
|
const {location, params, organization} = this.props;
|
|
const {dashboardId, widgetIndex} = params;
|
|
|
|
const widgetBuilderRoutes = [
|
|
`/organizations/${organization.slug}/dashboards/new/widget/new/`,
|
|
`/organizations/${organization.slug}/dashboard/${dashboardId}/widget/new/`,
|
|
`/organizations/${organization.slug}/dashboards/new/widget/${widgetIndex}/edit/`,
|
|
`/organizations/${organization.slug}/dashboard/${dashboardId}/widget/${widgetIndex}/edit/`,
|
|
];
|
|
|
|
if (USING_CUSTOMER_DOMAIN) {
|
|
// TODO: replace with url generation later on.
|
|
widgetBuilderRoutes.push(
|
|
...[
|
|
`/dashboards/new/widget/new/`,
|
|
`/dashboard/${dashboardId}/widget/new/`,
|
|
`/dashboards/new/widget/${widgetIndex}/edit/`,
|
|
`/dashboard/${dashboardId}/widget/${widgetIndex}/edit/`,
|
|
]
|
|
);
|
|
}
|
|
|
|
return widgetBuilderRoutes.includes(location.pathname);
|
|
}
|
|
|
|
get dashboardTitle() {
|
|
const {dashboard} = this.props;
|
|
const {modifiedDashboard} = this.state;
|
|
return modifiedDashboard ? modifiedDashboard.title : dashboard.title;
|
|
}
|
|
|
|
onEdit = () => {
|
|
const {dashboard, organization} = this.props;
|
|
trackAnalytics('dashboards2.edit.start', {organization});
|
|
|
|
this.setState({
|
|
dashboardState: DashboardState.EDIT,
|
|
modifiedDashboard: cloneDashboard(dashboard),
|
|
});
|
|
};
|
|
|
|
onRouteLeave = () => {
|
|
const {dashboard} = this.props;
|
|
const {modifiedDashboard} = this.state;
|
|
|
|
if (
|
|
![
|
|
DashboardState.VIEW,
|
|
DashboardState.PENDING_DELETE,
|
|
DashboardState.PREVIEW,
|
|
].includes(this.state.dashboardState) &&
|
|
!isEqual(modifiedDashboard, dashboard)
|
|
) {
|
|
return UNSAVED_MESSAGE;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
onUnload = (event: BeforeUnloadEvent) => {
|
|
const {dashboard} = this.props;
|
|
const {modifiedDashboard} = this.state;
|
|
|
|
if (
|
|
[
|
|
DashboardState.VIEW,
|
|
DashboardState.PENDING_DELETE,
|
|
DashboardState.PREVIEW,
|
|
].includes(this.state.dashboardState) ||
|
|
isEqual(modifiedDashboard, dashboard)
|
|
) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.returnValue = UNSAVED_MESSAGE;
|
|
};
|
|
|
|
onDelete = (dashboard: State['modifiedDashboard']) => () => {
|
|
const {api, organization, location} = this.props;
|
|
if (!dashboard?.id) {
|
|
return;
|
|
}
|
|
|
|
const previousDashboardState = this.state.dashboardState;
|
|
|
|
this.setState({dashboardState: DashboardState.PENDING_DELETE}, () => {
|
|
deleteDashboard(api, organization.slug, dashboard.id)
|
|
.then(() => {
|
|
addSuccessMessage(t('Dashboard deleted'));
|
|
trackAnalytics('dashboards2.delete', {organization});
|
|
browserHistory.replace({
|
|
pathname: `/organizations/${organization.slug}/dashboards/`,
|
|
query: location.query,
|
|
});
|
|
})
|
|
.catch(() => {
|
|
this.setState({
|
|
dashboardState: previousDashboardState,
|
|
});
|
|
});
|
|
});
|
|
};
|
|
|
|
onCancel = () => {
|
|
const {organization, dashboard, location, params} = this.props;
|
|
const {modifiedDashboard} = this.state;
|
|
|
|
let hasDashboardChanged = !isEqual(modifiedDashboard, dashboard);
|
|
|
|
// If a dashboard has every layout undefined, then ignore the layout field
|
|
// when checking equality because it is a dashboard from before the grid feature
|
|
const isLegacyLayout = dashboard.widgets.every(({layout}) => !defined(layout));
|
|
if (isLegacyLayout) {
|
|
hasDashboardChanged = !isEqual(
|
|
{
|
|
...modifiedDashboard,
|
|
widgets: modifiedDashboard?.widgets.map(widget => omit(widget, 'layout')),
|
|
},
|
|
{...dashboard, widgets: dashboard.widgets.map(widget => omit(widget, 'layout'))}
|
|
);
|
|
}
|
|
|
|
// Don't confirm preview cancellation regardless of dashboard state
|
|
if (hasDashboardChanged && !this.isPreview) {
|
|
// Ignore no-alert here, so that the confirm on cancel matches onUnload & onRouteLeave
|
|
/* eslint no-alert:0 */
|
|
if (!confirm(UNSAVED_MESSAGE)) {
|
|
return;
|
|
}
|
|
}
|
|
if (params.dashboardId) {
|
|
trackAnalytics('dashboards2.edit.cancel', {organization});
|
|
this.setState({
|
|
dashboardState: DashboardState.VIEW,
|
|
modifiedDashboard: null,
|
|
});
|
|
return;
|
|
}
|
|
trackAnalytics('dashboards2.create.cancel', {organization});
|
|
browserHistory.replace(
|
|
normalizeUrl({
|
|
pathname: `/organizations/${organization.slug}/dashboards/`,
|
|
query: location.query,
|
|
})
|
|
);
|
|
};
|
|
|
|
handleChangeFilter = (activeFilters: DashboardFilters) => {
|
|
const {dashboard, location} = this.props;
|
|
const {modifiedDashboard} = this.state;
|
|
const newModifiedDashboard = modifiedDashboard || dashboard;
|
|
|
|
if (
|
|
Object.keys(activeFilters).every(
|
|
key => !newModifiedDashboard.filters?.[key] && activeFilters[key].length === 0
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const filterParams: DashboardFilters = {};
|
|
Object.keys(activeFilters).forEach(key => {
|
|
filterParams[key] = activeFilters[key].length ? activeFilters[key] : '';
|
|
});
|
|
|
|
if (
|
|
!isEqualWith(activeFilters, dashboard.filters, (a, b) => {
|
|
// This is to handle the case where dashboard filters has release:[] and the new filter is release:""
|
|
if (a.length === 0 && b.length === 0) {
|
|
return a === b;
|
|
}
|
|
return undefined;
|
|
})
|
|
) {
|
|
browserHistory.push({
|
|
...location,
|
|
query: {
|
|
...location.query,
|
|
...filterParams,
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
handleUpdateWidgetList = (widgets: Widget[]) => {
|
|
const {organization, dashboard, api, onDashboardUpdate, location} = this.props;
|
|
const {modifiedDashboard} = this.state;
|
|
|
|
// Use the new widgets for calculating layout because widgets has
|
|
// the most up to date information in edit state
|
|
const currentLayout = getDashboardLayout(widgets);
|
|
const layoutColumnDepths = calculateColumnDepths(currentLayout);
|
|
const newModifiedDashboard = {
|
|
...cloneDashboard(modifiedDashboard || dashboard),
|
|
widgets: assignDefaultLayout(widgets, layoutColumnDepths),
|
|
};
|
|
this.setState({
|
|
modifiedDashboard: newModifiedDashboard,
|
|
widgetLimitReached: widgets.length >= MAX_WIDGETS,
|
|
});
|
|
if (this.isEditingDashboard || this.isPreview) {
|
|
return;
|
|
}
|
|
updateDashboard(api, organization.slug, newModifiedDashboard).then(
|
|
(newDashboard: DashboardDetails) => {
|
|
if (onDashboardUpdate) {
|
|
onDashboardUpdate(newDashboard);
|
|
this.setState({
|
|
modifiedDashboard: null,
|
|
});
|
|
}
|
|
addSuccessMessage(t('Dashboard updated'));
|
|
if (dashboard && newDashboard.id !== dashboard.id) {
|
|
browserHistory.replace(
|
|
normalizeUrl({
|
|
pathname: `/organizations/${organization.slug}/dashboard/${newDashboard.id}/`,
|
|
query: {
|
|
...location.query,
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
},
|
|
// `updateDashboard` does its own error handling
|
|
() => undefined
|
|
);
|
|
};
|
|
|
|
handleAddCustomWidget = (widget: Widget) => {
|
|
const {dashboard} = this.props;
|
|
const {modifiedDashboard} = this.state;
|
|
const newModifiedDashboard = modifiedDashboard || dashboard;
|
|
this.onUpdateWidget([...newModifiedDashboard.widgets, widget]);
|
|
};
|
|
|
|
handleAddMetricWidget = (layout?: Widget['layout']) => {
|
|
const {dashboard, selection} = this.props;
|
|
|
|
const widgetCopy = cloneDeep(
|
|
assignTempId({
|
|
layout,
|
|
...defaultMetricWidget(selection),
|
|
widgetType: WidgetType.METRICS,
|
|
})
|
|
);
|
|
|
|
const nextList = generateWidgetsAfterCompaction([...dashboard.widgets, widgetCopy]);
|
|
|
|
this.onUpdateWidget(nextList);
|
|
if (!this.isEditingDashboard) {
|
|
this.handleUpdateWidgetList(nextList);
|
|
}
|
|
|
|
// TODO: open widget details modal to allow users to edit the newly created widget
|
|
};
|
|
|
|
onAddWidget = (dataset: DataSet) => {
|
|
const {
|
|
organization,
|
|
dashboard,
|
|
router,
|
|
location,
|
|
params: {dashboardId},
|
|
} = this.props;
|
|
|
|
if (dataset === DataSet.METRICS) {
|
|
this.handleAddMetricWidget();
|
|
return;
|
|
}
|
|
this.setState({
|
|
modifiedDashboard: cloneDashboard(dashboard),
|
|
});
|
|
|
|
if (dashboardId) {
|
|
router.push(
|
|
normalizeUrl({
|
|
pathname: `/organizations/${organization.slug}/dashboard/${dashboardId}/widget/new/`,
|
|
query: {
|
|
...location.query,
|
|
source: DashboardWidgetSource.DASHBOARDS,
|
|
dataset,
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
onCommit = () => {
|
|
const {api, organization, location, dashboard, onDashboardUpdate} = this.props;
|
|
const {modifiedDashboard, dashboardState} = this.state;
|
|
|
|
switch (dashboardState) {
|
|
case DashboardState.PREVIEW:
|
|
case DashboardState.CREATE: {
|
|
if (modifiedDashboard) {
|
|
if (this.isPreview) {
|
|
trackAnalytics('dashboards_manage.templates.add', {
|
|
organization,
|
|
dashboard_id: dashboard.id,
|
|
dashboard_title: dashboard.title,
|
|
was_previewed: true,
|
|
});
|
|
}
|
|
const newModifiedDashboard = {
|
|
...cloneDashboard(modifiedDashboard),
|
|
...getCurrentPageFilters(location),
|
|
filters: getDashboardFiltersFromURL(location) ?? modifiedDashboard.filters,
|
|
};
|
|
createDashboard(
|
|
api,
|
|
organization.slug,
|
|
newModifiedDashboard,
|
|
this.isPreview
|
|
).then(
|
|
(newDashboard: DashboardDetails) => {
|
|
addSuccessMessage(t('Dashboard created'));
|
|
trackAnalytics('dashboards2.create.complete', {organization});
|
|
this.setState({
|
|
dashboardState: DashboardState.VIEW,
|
|
});
|
|
|
|
// redirect to new dashboard
|
|
browserHistory.replace(
|
|
normalizeUrl({
|
|
pathname: `/organizations/${organization.slug}/dashboard/${newDashboard.id}/`,
|
|
query: {
|
|
query: omit(location.query, Object.values(DashboardFilterKeys)),
|
|
},
|
|
})
|
|
);
|
|
},
|
|
() => undefined
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case DashboardState.EDIT: {
|
|
// only update the dashboard if there are changes
|
|
if (modifiedDashboard) {
|
|
if (isEqual(dashboard, modifiedDashboard)) {
|
|
this.setState({
|
|
dashboardState: DashboardState.VIEW,
|
|
modifiedDashboard: null,
|
|
});
|
|
return;
|
|
}
|
|
updateDashboard(api, organization.slug, modifiedDashboard).then(
|
|
(newDashboard: DashboardDetails) => {
|
|
if (onDashboardUpdate) {
|
|
onDashboardUpdate(newDashboard);
|
|
}
|
|
addSuccessMessage(t('Dashboard updated'));
|
|
trackAnalytics('dashboards2.edit.complete', {organization});
|
|
this.setState({
|
|
dashboardState: DashboardState.VIEW,
|
|
modifiedDashboard: null,
|
|
});
|
|
|
|
if (dashboard && newDashboard.id !== dashboard.id) {
|
|
browserHistory.replace(
|
|
normalizeUrl({
|
|
pathname: `/organizations/${organization.slug}/dashboard/${newDashboard.id}/`,
|
|
query: {
|
|
...location.query,
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
},
|
|
// `updateDashboard` does its own error handling
|
|
() => undefined
|
|
);
|
|
|
|
return;
|
|
}
|
|
this.setState({
|
|
dashboardState: DashboardState.VIEW,
|
|
modifiedDashboard: null,
|
|
});
|
|
break;
|
|
}
|
|
case DashboardState.VIEW:
|
|
default: {
|
|
this.setState({
|
|
dashboardState: DashboardState.VIEW,
|
|
modifiedDashboard: null,
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
setModifiedDashboard = (dashboard: DashboardDetails) => {
|
|
this.setState({
|
|
modifiedDashboard: dashboard,
|
|
});
|
|
};
|
|
|
|
onUpdateWidget = (widgets: Widget[]) => {
|
|
this.setState((state: State) => ({
|
|
...state,
|
|
widgetLimitReached: widgets.length >= MAX_WIDGETS,
|
|
modifiedDashboard: {
|
|
...(state.modifiedDashboard || this.props.dashboard),
|
|
widgets,
|
|
},
|
|
}));
|
|
};
|
|
|
|
renderWidgetBuilder() {
|
|
const {children, dashboard} = this.props;
|
|
const {modifiedDashboard} = this.state;
|
|
|
|
return isValidElement(children)
|
|
? cloneElement<any>(children, {
|
|
dashboard: modifiedDashboard ?? dashboard,
|
|
onSave: this.isEditingDashboard
|
|
? this.onUpdateWidget
|
|
: this.handleUpdateWidgetList,
|
|
})
|
|
: children;
|
|
}
|
|
|
|
renderDefaultDashboardDetail() {
|
|
const {organization, dashboard, dashboards, params, router, location} = this.props;
|
|
const {modifiedDashboard, dashboardState, widgetLimitReached} = this.state;
|
|
const {dashboardId} = params;
|
|
|
|
return (
|
|
<PageFiltersContainer
|
|
disablePersistence
|
|
defaultSelection={{
|
|
datetime: {
|
|
start: null,
|
|
end: null,
|
|
utc: false,
|
|
period: DEFAULT_STATS_PERIOD,
|
|
},
|
|
}}
|
|
>
|
|
<Layout.Page withPadding>
|
|
<OnDemandControlProvider location={location}>
|
|
<MetricsDashboardContextProvider>
|
|
<MetricsResultsMetaProvider>
|
|
<NoProjectMessage organization={organization}>
|
|
<StyledPageHeader>
|
|
<Layout.Title>
|
|
<DashboardTitle
|
|
dashboard={modifiedDashboard ?? dashboard}
|
|
onUpdate={this.setModifiedDashboard}
|
|
isEditingDashboard={this.isEditingDashboard}
|
|
/>
|
|
</Layout.Title>
|
|
<Controls
|
|
organization={organization}
|
|
dashboards={dashboards}
|
|
onEdit={this.onEdit}
|
|
onCancel={this.onCancel}
|
|
onCommit={this.onCommit}
|
|
onAddWidget={this.onAddWidget}
|
|
onDelete={this.onDelete(dashboard)}
|
|
dashboardState={dashboardState}
|
|
widgetLimitReached={widgetLimitReached}
|
|
/>
|
|
</StyledPageHeader>
|
|
<HookHeader organization={organization} />
|
|
<FiltersBar
|
|
filters={{}} // Default Dashboards don't have filters set
|
|
location={location}
|
|
hasUnsavedChanges={false}
|
|
isEditingDashboard={false}
|
|
isPreview={false}
|
|
onDashboardFilterChange={this.handleChangeFilter}
|
|
/>
|
|
<MetricsCardinalityProvider
|
|
organization={organization}
|
|
location={location}
|
|
>
|
|
<MetricsDataSwitcher
|
|
organization={organization}
|
|
eventView={EventView.fromLocation(location)}
|
|
location={location}
|
|
>
|
|
{metricsDataSide => (
|
|
<MEPSettingProvider
|
|
location={location}
|
|
forceTransactions={metricsDataSide.forceTransactionsOnly}
|
|
>
|
|
<Dashboard
|
|
paramDashboardId={dashboardId}
|
|
dashboard={modifiedDashboard ?? dashboard}
|
|
organization={organization}
|
|
isEditingDashboard={this.isEditingDashboard}
|
|
widgetLimitReached={widgetLimitReached}
|
|
onUpdate={this.onUpdateWidget}
|
|
handleUpdateWidgetList={this.handleUpdateWidgetList}
|
|
handleAddCustomWidget={this.handleAddCustomWidget}
|
|
handleAddMetricWidget={this.handleAddMetricWidget}
|
|
isPreview={this.isPreview}
|
|
router={router}
|
|
location={location}
|
|
/>
|
|
</MEPSettingProvider>
|
|
)}
|
|
</MetricsDataSwitcher>
|
|
</MetricsCardinalityProvider>
|
|
</NoProjectMessage>
|
|
</MetricsResultsMetaProvider>
|
|
</MetricsDashboardContextProvider>
|
|
</OnDemandControlProvider>
|
|
</Layout.Page>
|
|
</PageFiltersContainer>
|
|
);
|
|
}
|
|
|
|
getBreadcrumbLabel() {
|
|
const {dashboardState} = this.state;
|
|
|
|
let label = this.dashboardTitle;
|
|
if (dashboardState === DashboardState.CREATE) {
|
|
label = t('Create Dashboard');
|
|
} else if (this.isPreview) {
|
|
label = t('Preview Dashboard');
|
|
}
|
|
return label;
|
|
}
|
|
|
|
renderDashboardDetail() {
|
|
const {
|
|
api,
|
|
organization,
|
|
dashboard,
|
|
dashboards,
|
|
params,
|
|
router,
|
|
location,
|
|
newWidget,
|
|
onSetNewWidget,
|
|
onDashboardUpdate,
|
|
projects,
|
|
} = this.props;
|
|
const {modifiedDashboard, dashboardState, widgetLimitReached, seriesData, setData} =
|
|
this.state;
|
|
const {dashboardId} = params;
|
|
|
|
const hasUnsavedFilters =
|
|
dashboard.id !== 'default-overview' &&
|
|
dashboardState !== DashboardState.CREATE &&
|
|
hasUnsavedFilterChanges(dashboard, location);
|
|
|
|
const eventView = generatePerformanceEventView(location, projects, {}, organization);
|
|
|
|
const isDashboardUsingTransaction = dashboard.widgets.some(
|
|
isWidgetUsingTransactionName
|
|
);
|
|
|
|
return (
|
|
<SentryDocumentTitle title={dashboard.title} orgSlug={organization.slug}>
|
|
<PageFiltersContainer
|
|
disablePersistence
|
|
defaultSelection={{
|
|
datetime: {
|
|
start: null,
|
|
end: null,
|
|
utc: false,
|
|
period: DEFAULT_STATS_PERIOD,
|
|
},
|
|
}}
|
|
>
|
|
<Layout.Page>
|
|
<OnDemandControlProvider location={location}>
|
|
<MetricsDashboardContextProvider>
|
|
<MetricsResultsMetaProvider>
|
|
<NoProjectMessage organization={organization}>
|
|
<Layout.Header>
|
|
<Layout.HeaderContent>
|
|
<Breadcrumbs
|
|
crumbs={[
|
|
{
|
|
label: t('Dashboards'),
|
|
to: `/organizations/${organization.slug}/dashboards/`,
|
|
},
|
|
{
|
|
label: this.getBreadcrumbLabel(),
|
|
},
|
|
]}
|
|
/>
|
|
<Layout.Title>
|
|
<DashboardTitle
|
|
dashboard={modifiedDashboard ?? dashboard}
|
|
onUpdate={this.setModifiedDashboard}
|
|
isEditingDashboard={this.isEditingDashboard}
|
|
/>
|
|
</Layout.Title>
|
|
</Layout.HeaderContent>
|
|
<Layout.HeaderActions>
|
|
<Controls
|
|
organization={organization}
|
|
dashboards={dashboards}
|
|
hasUnsavedFilters={hasUnsavedFilters}
|
|
onEdit={this.onEdit}
|
|
onCancel={this.onCancel}
|
|
onCommit={this.onCommit}
|
|
onAddWidget={this.onAddWidget}
|
|
onDelete={this.onDelete(dashboard)}
|
|
dashboardState={dashboardState}
|
|
widgetLimitReached={widgetLimitReached}
|
|
/>
|
|
</Layout.HeaderActions>
|
|
</Layout.Header>
|
|
<Layout.Body>
|
|
<Layout.Main fullWidth>
|
|
<MetricsCardinalityProvider
|
|
organization={organization}
|
|
location={location}
|
|
>
|
|
<MetricsDataSwitcher
|
|
organization={organization}
|
|
eventView={eventView}
|
|
location={location}
|
|
>
|
|
{metricsDataSide => (
|
|
<MEPSettingProvider
|
|
location={location}
|
|
forceTransactions={metricsDataSide.forceTransactionsOnly}
|
|
>
|
|
{isDashboardUsingTransaction ? (
|
|
<MetricsDataSwitcherAlert
|
|
organization={organization}
|
|
eventView={eventView}
|
|
projects={projects}
|
|
location={location}
|
|
router={router}
|
|
source={DiscoverQueryPageSource.DISCOVER}
|
|
{...metricsDataSide}
|
|
/>
|
|
) : null}
|
|
<FiltersBar
|
|
filters={(modifiedDashboard ?? dashboard).filters}
|
|
location={location}
|
|
hasUnsavedChanges={hasUnsavedFilters}
|
|
isEditingDashboard={
|
|
dashboardState !== DashboardState.CREATE &&
|
|
this.isEditingDashboard
|
|
}
|
|
isPreview={this.isPreview}
|
|
onDashboardFilterChange={this.handleChangeFilter}
|
|
onCancel={() => {
|
|
resetPageFilters(dashboard, location);
|
|
this.setState({
|
|
modifiedDashboard: {
|
|
...(modifiedDashboard ?? dashboard),
|
|
filters: dashboard.filters,
|
|
},
|
|
});
|
|
}}
|
|
onSave={() => {
|
|
const newModifiedDashboard = {
|
|
...cloneDashboard(modifiedDashboard ?? dashboard),
|
|
...getCurrentPageFilters(location),
|
|
filters:
|
|
getDashboardFiltersFromURL(location) ??
|
|
(modifiedDashboard ?? dashboard).filters,
|
|
};
|
|
updateDashboard(
|
|
api,
|
|
organization.slug,
|
|
newModifiedDashboard
|
|
).then(
|
|
(newDashboard: DashboardDetails) => {
|
|
if (onDashboardUpdate) {
|
|
onDashboardUpdate(newDashboard);
|
|
this.setState({
|
|
modifiedDashboard: null,
|
|
});
|
|
}
|
|
addSuccessMessage(t('Dashboard filters updated'));
|
|
browserHistory.replace(
|
|
normalizeUrl({
|
|
pathname: `/organizations/${organization.slug}/dashboard/${newDashboard.id}/`,
|
|
query: omit(
|
|
location.query,
|
|
Object.values(DashboardFilterKeys)
|
|
),
|
|
})
|
|
);
|
|
},
|
|
// `updateDashboard` does its own error handling
|
|
() => undefined
|
|
);
|
|
}}
|
|
/>
|
|
|
|
<WidgetViewerContext.Provider
|
|
value={{seriesData, setData}}
|
|
>
|
|
<Dashboard
|
|
paramDashboardId={dashboardId}
|
|
dashboard={modifiedDashboard ?? dashboard}
|
|
organization={organization}
|
|
isEditingDashboard={this.isEditingDashboard}
|
|
widgetLimitReached={widgetLimitReached}
|
|
onUpdate={this.onUpdateWidget}
|
|
handleUpdateWidgetList={this.handleUpdateWidgetList}
|
|
handleAddCustomWidget={this.handleAddCustomWidget}
|
|
handleAddMetricWidget={this.handleAddMetricWidget}
|
|
router={router}
|
|
location={location}
|
|
newWidget={newWidget}
|
|
onSetNewWidget={onSetNewWidget}
|
|
isPreview={this.isPreview}
|
|
/>
|
|
</WidgetViewerContext.Provider>
|
|
</MEPSettingProvider>
|
|
)}
|
|
</MetricsDataSwitcher>
|
|
</MetricsCardinalityProvider>
|
|
</Layout.Main>
|
|
</Layout.Body>
|
|
</NoProjectMessage>
|
|
</MetricsResultsMetaProvider>
|
|
</MetricsDashboardContextProvider>
|
|
</OnDemandControlProvider>
|
|
</Layout.Page>
|
|
</PageFiltersContainer>
|
|
</SentryDocumentTitle>
|
|
);
|
|
}
|
|
|
|
render() {
|
|
const {organization} = this.props;
|
|
|
|
if (this.isWidgetBuilderRouter) {
|
|
return this.renderWidgetBuilder();
|
|
}
|
|
|
|
if (organization.features.includes('dashboards-edit')) {
|
|
return this.renderDashboardDetail();
|
|
}
|
|
|
|
return this.renderDefaultDashboardDetail();
|
|
}
|
|
}
|
|
|
|
const StyledPageHeader = styled('div')`
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr);
|
|
grid-row-gap: ${space(2)};
|
|
align-items: center;
|
|
margin-bottom: ${space(2)};
|
|
|
|
@media (min-width: ${p => p.theme.breakpoints.medium}) {
|
|
grid-template-columns: minmax(0, 1fr) max-content;
|
|
grid-column-gap: ${space(2)};
|
|
height: 40px;
|
|
}
|
|
`;
|
|
|
|
export default withPageFilters(withProjects(withApi(withOrganization(DashboardDetail))));
|