import {browserHistory} from 'react-router';
import {createListeners} from 'sentry-test/createListeners';
import {selectDropdownMenuItem} from 'sentry-test/dropdownMenu';
import {enforceActOnUseLegacyStoreHook, mountWithTheme} from 'sentry-test/enzyme';
import {initializeOrg} from 'sentry-test/initializeOrg';
import {mountGlobalModal} from 'sentry-test/modal';
import {act} from 'sentry-test/reactTestingLibrary';
import {triggerPress} from 'sentry-test/utils';
import * as modals from 'sentry/actionCreators/modal';
import ProjectsStore from 'sentry/stores/projectsStore';
import {DashboardState} from 'sentry/views/dashboardsV2/types';
import * as types from 'sentry/views/dashboardsV2/types';
import ViewEditDashboard from 'sentry/views/dashboardsV2/view';
import {OrganizationContext} from 'sentry/views/organizationContext';
describe('Dashboards > Detail', function () {
enforceActOnUseLegacyStoreHook();
const organization = TestStubs.Organization({
features: ['global-views', 'dashboards-basic', 'dashboards-edit', 'discover-query'],
});
const projects = [TestStubs.Project()];
describe('prebuilt dashboards', function () {
let wrapper;
let initialData, mockVisit;
beforeEach(function () {
act(() => ProjectsStore.loadInitialData(projects));
initialData = initializeOrg({organization});
MockApiClient.addMockResponse({
url: '/organizations/org-slug/tags/',
body: [],
});
MockApiClient.addMockResponse({
url: '/organizations/org-slug/projects/',
body: [TestStubs.Project()],
});
MockApiClient.addMockResponse({
url: '/organizations/org-slug/dashboards/',
body: [
TestStubs.Dashboard([], {id: 'default-overview', title: 'Default'}),
TestStubs.Dashboard([], {id: '1', title: 'Custom Errors'}),
],
});
MockApiClient.addMockResponse({
url: '/organizations/org-slug/dashboards/default-overview/',
body: TestStubs.Dashboard([], {id: 'default-overview', title: 'Default'}),
});
mockVisit = MockApiClient.addMockResponse({
url: '/organizations/org-slug/dashboards/1/visit/',
method: 'POST',
body: [],
statusCode: 200,
});
MockApiClient.addMockResponse({
url: '/organizations/org-slug/users/',
method: 'GET',
body: [],
});
});
afterEach(function () {
MockApiClient.clearMockResponses();
if (wrapper) {
wrapper.unmount();
}
});
it('can delete', async function () {
const deleteMock = MockApiClient.addMockResponse({
url: '/organizations/org-slug/dashboards/default-overview/',
method: 'DELETE',
});
wrapper = mountWithTheme(
,
initialData.routerContext
);
await tick();
wrapper.update();
// Enter edit mode.
wrapper.find('Controls Button[data-test-id="dashboard-edit"]').simulate('click');
const modal = await mountGlobalModal();
// Click delete, confirm will show
wrapper.find('Controls Button[data-test-id="dashboard-delete"]').simulate('click');
await tick();
await modal.update();
// Click confirm
modal.find('button[aria-label="Confirm"]').simulate('click');
expect(deleteMock).toHaveBeenCalled();
});
it('can rename and save', async function () {
const fireEvent = createListeners('window');
const updateMock = MockApiClient.addMockResponse({
url: '/organizations/org-slug/dashboards/default-overview/',
method: 'PUT',
body: TestStubs.Dashboard([], {id: '8', title: 'Updated prebuilt'}),
});
wrapper = mountWithTheme(
,
initialData.routerContext
);
await tick();
wrapper.update();
// Enter edit mode.
wrapper.find('Controls Button[data-test-id="dashboard-edit"]').simulate('click');
await tick();
wrapper.update();
// Rename
const dashboardTitle = wrapper.find('DashboardTitle Label');
dashboardTitle.simulate('click');
wrapper.find('StyledInput').simulate('change', {
target: {innerText: 'Updated prebuilt', value: 'Updated prebuilt'},
});
act(() => {
// Press enter
fireEvent.keyDown('Enter');
});
wrapper.find('Controls Button[data-test-id="dashboard-commit"]').simulate('click');
await tick();
wrapper.update();
expect(updateMock).toHaveBeenCalledWith(
'/organizations/org-slug/dashboards/default-overview/',
expect.objectContaining({
data: expect.objectContaining({title: 'Updated prebuilt'}),
})
);
// Should redirect to the new dashboard.
expect(browserHistory.replace).toHaveBeenCalledWith(
expect.objectContaining({
pathname: '/organizations/org-slug/dashboard/8/',
})
);
});
it('disables buttons based on features', async function () {
initialData = initializeOrg({
organization: TestStubs.Organization({
features: ['global-views', 'dashboards-basic', 'discover-query'],
projects: [TestStubs.Project()],
}),
});
wrapper = mountWithTheme(
,
initialData.routerContext
);
await tick();
wrapper.update();
// Edit should be disabled
const editProps = wrapper
.find('Controls Button[data-test-id="dashboard-edit"]')
.props();
expect(editProps.disabled).toBe(true);
expect(mockVisit).not.toHaveBeenCalled();
});
});
describe('custom dashboards', function () {
let wrapper, initialData, widgets, mockVisit;
const openEditModal = jest.spyOn(modals, 'openAddDashboardWidgetModal');
beforeEach(function () {
initialData = initializeOrg({organization});
types.MAX_WIDGETS = 30;
widgets = [
TestStubs.Widget(
[
{
name: '',
conditions: 'event.type:error',
fields: ['count()'],
aggregates: ['count()'],
columns: [],
},
],
{
title: 'Errors',
interval: '1d',
id: '1',
}
),
TestStubs.Widget(
[
{
name: '',
conditions: 'event.type:transaction',
fields: ['count()'],
aggregates: ['count()'],
columns: [],
},
],
{
title: 'Transactions',
interval: '1d',
id: '2',
}
),
TestStubs.Widget(
[
{
name: '',
conditions: 'event.type:transaction transaction:/api/cats',
fields: ['p50()'],
aggregates: ['p50()'],
columns: [],
},
],
{
title: 'p50 of /api/cats',
interval: '1d',
id: '3',
}
),
];
mockVisit = MockApiClient.addMockResponse({
url: '/organizations/org-slug/dashboards/1/visit/',
method: 'POST',
body: [],
statusCode: 200,
});
MockApiClient.addMockResponse({
url: '/organizations/org-slug/tags/',
body: [],
});
MockApiClient.addMockResponse({
url: '/organizations/org-slug/projects/',
body: [TestStubs.Project()],
});
MockApiClient.addMockResponse({
url: '/organizations/org-slug/dashboards/',
body: [
TestStubs.Dashboard([], {
id: 'default-overview',
title: 'Default',
widgetDisplay: ['area'],
}),
TestStubs.Dashboard([], {
id: '1',
title: 'Custom Errors',
widgetDisplay: ['area'],
}),
],
});
MockApiClient.addMockResponse({
method: 'GET',
url: '/organizations/org-slug/dashboards/1/',
body: TestStubs.Dashboard(widgets, {id: '1', title: 'Custom Errors'}),
});
MockApiClient.addMockResponse({
url: '/organizations/org-slug/dashboards/1/',
method: 'PUT',
body: TestStubs.Dashboard(widgets, {id: '1', title: 'Custom Errors'}),
});
MockApiClient.addMockResponse({
url: '/organizations/org-slug/events-stats/',
body: {data: []},
});
MockApiClient.addMockResponse({
method: 'POST',
url: '/organizations/org-slug/dashboards/widgets/',
body: [],
});
MockApiClient.addMockResponse({
method: 'GET',
url: '/organizations/org-slug/recent-searches/',
body: [],
});
MockApiClient.addMockResponse({
method: 'GET',
url: '/organizations/org-slug/issues/',
body: [],
});
MockApiClient.addMockResponse({
url: '/organizations/org-slug/eventsv2/',
method: 'GET',
body: [],
});
MockApiClient.addMockResponse({
url: '/organizations/org-slug/users/',
method: 'GET',
body: [],
});
});
afterEach(function () {
MockApiClient.clearMockResponses();
jest.clearAllMocks();
if (wrapper) {
wrapper.unmount();
}
});
it('can remove widgets', async function () {
const updateMock = MockApiClient.addMockResponse({
url: '/organizations/org-slug/dashboards/1/',
method: 'PUT',
body: TestStubs.Dashboard([widgets[0]], {id: '1', title: 'Custom Errors'}),
});
wrapper = mountWithTheme(
,
initialData.routerContext
);
await tick();
wrapper.update();
expect(mockVisit).toHaveBeenCalledTimes(1);
// Enter edit mode.
wrapper.find('Controls Button[data-test-id="dashboard-edit"]').simulate('click');
const card = wrapper.find('WidgetCard').first();
card.find('WidgetCardPanel').simulate('mouseOver');
// Remove the second and third widgets
wrapper
.find('WidgetCard')
.at(1)
.find('Button[data-test-id="widget-delete"]')
.simulate('click');
wrapper
.find('WidgetCard')
.at(1)
.find('Button[data-test-id="widget-delete"]')
.simulate('click');
// Save changes
wrapper.find('Controls Button[data-test-id="dashboard-commit"]').simulate('click');
await tick();
expect(updateMock).toHaveBeenCalled();
expect(updateMock).toHaveBeenCalledWith(
'/organizations/org-slug/dashboards/1/',
expect.objectContaining({
data: expect.objectContaining({
title: 'Custom Errors',
widgets: [widgets[0]],
}),
})
);
// Visit should not be called again on dashboard update
expect(mockVisit).toHaveBeenCalledTimes(1);
});
it('opens edit modal for widgets', async function () {
wrapper = mountWithTheme(
,
initialData.routerContext
);
await tick();
wrapper.update();
// Enter edit mode.
wrapper.find('Controls Button[data-test-id="dashboard-edit"]').simulate('click');
wrapper.update();
const card = wrapper.find('WidgetCard').first();
card.find('WidgetCardPanel').simulate('mouseOver');
// Edit the first widget
wrapper
.find('WidgetCard')
.first()
.find('Button[data-test-id="widget-edit"]')
.simulate('click');
expect(openEditModal).toHaveBeenCalledTimes(1);
expect(openEditModal).toHaveBeenCalledWith(
expect.objectContaining({
widget: {
id: '1',
interval: '1d',
queries: [
{
conditions: 'event.type:error',
fields: ['count()'],
aggregates: ['count()'],
columns: [],
name: '',
},
],
title: 'Errors',
type: 'line',
},
})
);
});
it('does not update if api update fails', async function () {
const fireEvent = createListeners('window');
window.confirm = jest.fn(() => true);
MockApiClient.addMockResponse({
url: '/organizations/org-slug/dashboards/1/',
method: 'PUT',
statusCode: 400,
});
wrapper = mountWithTheme(
,
initialData.routerContext
);
await tick();
wrapper.update();
// Enter edit mode.
wrapper.find('Controls Button[data-test-id="dashboard-edit"]').simulate('click');
// Rename
const dashboardTitle = wrapper.find('DashboardTitle Label');
dashboardTitle.simulate('click');
wrapper.find('StyledInput').simulate('change', {
target: {innerText: 'Updated Name', value: 'Updated Name'},
});
act(() => {
// Press enter
fireEvent.keyDown('Enter');
});
wrapper.find('Controls Button[data-test-id="dashboard-commit"]').simulate('click');
await tick();
wrapper.update();
expect(wrapper.find('DashboardTitle EditableText').props().value).toEqual(
'Updated Name'
);
wrapper.find('Controls Button[data-test-id="dashboard-cancel"]').simulate('click');
expect(wrapper.find('DashboardTitle EditableText').props().value).toEqual(
'Custom Errors'
);
});
it('shows add widget option', async function () {
wrapper = mountWithTheme(
,
initialData.routerContext
);
await tick();
wrapper.update();
// Enter edit mode.
wrapper.find('Controls Button[data-test-id="dashboard-edit"]').simulate('click');
wrapper.update();
expect(wrapper.find('AddWidget').exists()).toBe(true);
});
it('opens custom modal when add widget option is clicked', async function () {
wrapper = mountWithTheme(
,
initialData.routerContext
);
await tick();
wrapper.update();
// Enter edit mode.
wrapper.find('Controls Button[data-test-id="dashboard-edit"]').simulate('click');
wrapper.update();
wrapper.find('AddButton[data-test-id="widget-add"]').simulate('click');
expect(openEditModal).toHaveBeenCalledTimes(1);
});
it('opens widget library when add widget option is clicked', async function () {
initialData = initializeOrg({
organization: TestStubs.Organization({
features: [
'global-views',
'dashboards-basic',
'dashboards-edit',
'discover-query',
],
projects: [TestStubs.Project()],
}),
});
wrapper = mountWithTheme(
,
initialData.routerContext
);
await tick();
wrapper.update();
// Enter edit mode.
wrapper.find('Controls Button[data-test-id="dashboard-edit"]').simulate('click');
wrapper.update();
wrapper.find('AddButton[data-test-id="widget-add"]').simulate('click');
expect(openEditModal).toHaveBeenCalledTimes(1);
expect(openEditModal).toHaveBeenCalledWith(
expect.objectContaining({
source: types.DashboardWidgetSource.LIBRARY,
})
);
});
it('hides add widget option', async function () {
types.MAX_WIDGETS = 1;
wrapper = mountWithTheme(
,
initialData.routerContext
);
await tick();
wrapper.update();
// Enter edit mode.
wrapper.find('Controls Button[data-test-id="dashboard-edit"]').simulate('click');
wrapper.update();
expect(wrapper.find('AddWidget').exists()).toBe(false);
});
it('hides and shows breadcrumbs based on feature', async function () {
const newOrg = initializeOrg({
organization: TestStubs.Organization({
features: ['global-views', 'dashboards-basic', 'discover-query'],
projects: [TestStubs.Project()],
}),
});
wrapper = mountWithTheme(
,
newOrg.routerContext
);
await tick();
wrapper.update();
expect(wrapper.find('Breadcrumbs').exists()).toBe(false);
wrapper = mountWithTheme(
,
initialData.routerContext
);
await tick();
wrapper.update();
const breadcrumbs = wrapper.find('Breadcrumbs');
expect(breadcrumbs.exists()).toBe(true);
expect(breadcrumbs.find('BreadcrumbLink').find('a').text()).toEqual('Dashboards');
expect(breadcrumbs.find('BreadcrumbItem').last().text()).toEqual('Custom Errors');
});
it('unsets newWidget after rendering', async function () {
initialData.router.location = {
query: {
displayType: 'line',
interval: '5m',
queryConditions: ['title:test', 'event.type:test'],
queryFields: ['count()', 'failure_count()'],
queryNames: ['1', '2'],
queryOrderby: '',
title: 'Widget Title',
},
};
wrapper = mountWithTheme(
,
initialData.routerContext
);
expect(wrapper.find('DashboardDetail').props().initialState).toEqual(
DashboardState.EDIT
);
expect(wrapper.find('DashboardDetail').props().newWidget).toBeDefined();
await act(async () => {
// Wrap await tick in act because componentDidMount in Dashboard triggers
// state change when parsing widget from location
await tick();
});
wrapper.update();
// The newWidget state was cleared after adding the widget
expect(wrapper.find('DashboardDetail').props().newWidget).toBeUndefined();
await act(async () => {
await tick();
});
});
it('enters edit mode when given a new widget in location query', async function () {
initialData.router.location = {
query: {
displayType: 'line',
interval: '5m',
queryConditions: ['title:test', 'event.type:test'],
queryFields: ['count()', 'failure_count()'],
queryNames: ['1', '2'],
queryOrderby: '',
title: 'Widget Title',
},
};
wrapper = mountWithTheme(
,
initialData.routerContext
);
expect(wrapper.find('DashboardDetail').props().initialState).toEqual(
DashboardState.EDIT
);
await act(async () => {
// Wrap await tick in act because componentDidMount in Dashboard triggers
// state change when parsing widget from location
await tick();
});
});
it('enters view mode when not given a new widget in location query', async function () {
wrapper = mountWithTheme(
,
initialData.routerContext
);
await tick();
wrapper.update();
expect(wrapper.find('DashboardDetail').props().initialState).toEqual(
DashboardState.VIEW
);
});
it('opens add widget to custom modal', async function () {
types.MAX_WIDGETS = 10;
initialData = initializeOrg({
organization: TestStubs.Organization({
features: [
'global-views',
'dashboards-basic',
'dashboards-edit',
'discover-query',
],
projects: [TestStubs.Project()],
}),
});
wrapper = mountWithTheme(
,
initialData.routerContext
);
await tick();
wrapper.update();
expect(wrapper.find('Controls Tooltip').prop('disabled')).toBe(true);
// Enter Add Widget mode
wrapper
.find('Controls Button[data-test-id="add-widget-library"]')
.simulate('click');
expect(openEditModal).toHaveBeenCalledTimes(1);
expect(openEditModal).toHaveBeenCalledWith(
expect.objectContaining({
source: types.DashboardWidgetSource.LIBRARY,
})
);
});
it('disables add library widgets when max widgets reached', async function () {
types.MAX_WIDGETS = 3;
initialData = initializeOrg({
organization: TestStubs.Organization({
features: [
'global-views',
'dashboards-basic',
'dashboards-edit',
'discover-query',
],
projects: [TestStubs.Project()],
}),
});
wrapper = mountWithTheme(
,
initialData.routerContext
);
await tick();
wrapper.update();
expect(wrapper.find('WidgetCard')).toHaveLength(3);
expect(
wrapper.find('Controls Button[data-test-id="add-widget-library"]').props()
.disabled
).toEqual(true);
expect(wrapper.find('Controls Tooltip').prop('disabled')).toBe(false);
await act(async () => {
triggerPress(wrapper.first().find('MenuControlWrap Button').first());
await tick();
wrapper.update();
});
expect(
wrapper.find(`MenuItemWrap[data-test-id="duplicate-widget"]`).props()[
'aria-disabled'
]
).toEqual(true);
});
it('opens edit modal when editing widget from context menu', async function () {
wrapper = mountWithTheme(
,
initialData.routerContext
);
await tick();
wrapper.update();
expect(wrapper.find('WidgetCard')).toHaveLength(3);
await selectDropdownMenuItem({
wrapper,
specifiers: {prefix: 'WidgetCard', first: true},
itemKey: 'edit-widget',
});
expect(openEditModal).toHaveBeenCalledTimes(1);
expect(openEditModal).toHaveBeenCalledWith(
expect.objectContaining({
widget: {
id: '1',
interval: '1d',
queries: [
{
conditions: 'event.type:error',
fields: ['count()'],
aggregates: ['count()'],
columns: [],
name: '',
},
],
title: 'Errors',
type: 'line',
},
})
);
});
});
});