import {DashboardFixture} from 'sentry-fixture/dashboard';
import {LocationFixture} from 'sentry-fixture/locationFixture';
import {OrganizationFixture} from 'sentry-fixture/organization';
import {WidgetFixture} from 'sentry-fixture/widget';
import {initializeOrg} from 'sentry-test/initializeOrg';
import {
render,
renderGlobalModal,
screen,
userEvent,
waitFor,
} from 'sentry-test/reactTestingLibrary';
import * as modal from 'sentry/actionCreators/modal';
import * as LineChart from 'sentry/components/charts/lineChart';
import SimpleTableChart from 'sentry/components/charts/simpleTableChart';
import {DatasetSource} from 'sentry/utils/discover/types';
import {MINUTE, SECOND} from 'sentry/utils/formatters';
import {MEPSettingProvider} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
import type {Widget} from 'sentry/views/dashboards/types';
import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
import WidgetCard from 'sentry/views/dashboards/widgetCard';
import ReleaseWidgetQueries from 'sentry/views/dashboards/widgetCard/releaseWidgetQueries';
import WidgetLegendSelectionState from '../widgetLegendSelectionState';
import {DashboardsMEPProvider} from './dashboardsMEPContext';
jest.mock('sentry/components/charts/simpleTableChart', () => jest.fn(() =>
));
jest.mock('sentry/views/dashboards/widgetCard/releaseWidgetQueries');
describe('Dashboards > WidgetCard', function () {
const {router, organization} = initializeOrg({
organization: OrganizationFixture({
features: ['dashboards-edit', 'discover-basic'],
}),
router: {orgId: 'orgId'},
} as Parameters[0]);
const renderWithProviders = (component: React.ReactNode) =>
render(
{component}
,
{organization, router}
);
const multipleQueryWidget: Widget = {
title: 'Errors',
description: 'Valid widget description',
interval: '5m',
displayType: DisplayType.LINE,
widgetType: WidgetType.DISCOVER,
queries: [
{
conditions: 'event.type:error',
fields: ['count()', 'failure_count()'],
aggregates: ['count()', 'failure_count()'],
columns: [],
name: 'errors',
orderby: '',
},
{
conditions: 'event.type:default',
fields: ['count()', 'failure_count()'],
aggregates: ['count()', 'failure_count()'],
columns: [],
name: 'default',
orderby: '',
},
],
};
const selection = {
projects: [1],
environments: ['prod'],
datetime: {
period: '14d',
start: null,
end: null,
utc: false,
},
};
const api = new MockApiClient();
let eventsMock: jest.Mock;
const widgetLegendState = new WidgetLegendSelectionState({
location: LocationFixture(),
dashboard: DashboardFixture([multipleQueryWidget]),
organization,
router,
});
beforeEach(function () {
MockApiClient.addMockResponse({
url: '/organizations/org-slug/events-stats/',
body: {meta: {isMetricsData: false}},
});
eventsMock = MockApiClient.addMockResponse({
url: '/organizations/org-slug/events/',
body: {
meta: {fields: {title: 'string'}},
data: [{title: 'title'}],
},
});
});
afterEach(function () {
MockApiClient.clearMockResponses();
});
it('renders with Open in Discover button and opens the Query Selector Modal when clicked', async function () {
const spy = jest.spyOn(modal, 'openDashboardWidgetQuerySelectorModal');
renderWithProviders(
undefined}
onEdit={() => undefined}
onDuplicate={() => undefined}
renderErrorMessage={() => undefined}
showContextMenu
widgetLimitReached={false}
widgetLegendState={widgetLegendState}
/>
);
await userEvent.click(await screen.findByLabelText('Widget actions'));
await userEvent.click(screen.getByRole('menuitemradio', {name: 'Open in Discover'}));
expect(spy).toHaveBeenCalledWith({
isMetricsData: false,
organization,
widget: multipleQueryWidget,
});
});
it('renders with Open in Discover button', async function () {
renderWithProviders(
undefined}
onEdit={() => undefined}
onDuplicate={() => undefined}
renderErrorMessage={() => undefined}
showContextMenu
widgetLimitReached={false}
widgetLegendState={widgetLegendState}
/>
);
await userEvent.click(await screen.findByLabelText('Widget actions'));
expect(screen.getByRole('link', {name: 'Open in Discover'})).toHaveAttribute(
'href',
'/organizations/org-slug/discover/results/?environment=prod&field=count%28%29&field=failure_count%28%29&name=Errors&project=1&query=event.type%3Aerror&statsPeriod=14d&yAxis=count%28%29&yAxis=failure_count%28%29'
);
});
it('renders widget description in dashboard', async function () {
renderWithProviders(
undefined}
onEdit={() => undefined}
onDuplicate={() => undefined}
renderErrorMessage={() => undefined}
showContextMenu
widgetLimitReached={false}
widgetLegendState={widgetLegendState}
/>
);
await userEvent.hover(await screen.findByLabelText('Widget description'));
expect(await screen.findByText('Valid widget description')).toBeInTheDocument();
});
it('renders Discover button with prepended fields pulled from equations', async function () {
renderWithProviders(
undefined}
onEdit={() => undefined}
onDuplicate={() => undefined}
renderErrorMessage={() => undefined}
showContextMenu
widgetLimitReached={false}
widgetLegendState={widgetLegendState}
/>
);
await userEvent.click(await screen.findByLabelText('Widget actions'));
expect(screen.getByRole('link', {name: 'Open in Discover'})).toHaveAttribute(
'href',
'/organizations/org-slug/discover/results/?environment=prod&field=count_if%28transaction.duration%2Cequals%2C300%29&field=failure_count%28%29&field=count%28%29&field=equation%7C%28count%28%29%20%2B%20failure_count%28%29%29%20%2F%20count_if%28transaction.duration%2Cequals%2C300%29&name=Errors&project=1&query=event.type%3Aerror&statsPeriod=14d&yAxis=equation%7C%28count%28%29%20%2B%20failure_count%28%29%29%20%2F%20count_if%28transaction.duration%2Cequals%2C300%29'
);
});
it('renders button to open Discover with Top N', async function () {
renderWithProviders(
undefined}
onEdit={() => undefined}
onDuplicate={() => undefined}
renderErrorMessage={() => undefined}
showContextMenu
widgetLimitReached={false}
widgetLegendState={widgetLegendState}
/>
);
await userEvent.click(await screen.findByLabelText('Widget actions'));
expect(screen.getByRole('link', {name: 'Open in Discover'})).toHaveAttribute(
'href',
'/organizations/org-slug/discover/results/?display=top5&environment=prod&field=transaction&field=count%28%29&name=Errors&project=1&query=event.type%3Aerror&statsPeriod=14d&yAxis=count%28%29'
);
});
it('allows Open in Discover when the widget contains custom measurements', async function () {
renderWithProviders(
undefined}
onEdit={() => undefined}
onDuplicate={() => undefined}
renderErrorMessage={() => undefined}
showContextMenu
widgetLimitReached={false}
widgetLegendState={widgetLegendState}
/>
);
await userEvent.click(await screen.findByLabelText('Widget actions'));
expect(screen.getByRole('link', {name: 'Open in Discover'})).toHaveAttribute(
'href',
'/organizations/org-slug/discover/results/?environment=prod&field=p99%28measurements.custom.measurement%29&name=Errors&project=1&query=&statsPeriod=14d&yAxis=p99%28measurements.custom.measurement%29'
);
});
it('calls onDuplicate when Duplicate Widget is clicked', async function () {
const mock = jest.fn();
renderWithProviders(
undefined}
onEdit={() => undefined}
onDuplicate={mock}
renderErrorMessage={() => undefined}
showContextMenu
widgetLimitReached={false}
widgetLegendState={widgetLegendState}
/>
);
await userEvent.click(await screen.findByLabelText('Widget actions'));
await userEvent.click(screen.getByRole('menuitemradio', {name: 'Duplicate Widget'}));
expect(mock).toHaveBeenCalledTimes(1);
});
it('does not add duplicate widgets if max widget is reached', async function () {
const mock = jest.fn();
renderWithProviders(
undefined}
onEdit={() => undefined}
onDuplicate={mock}
renderErrorMessage={() => undefined}
showContextMenu
widgetLegendState={widgetLegendState}
widgetLimitReached
/>
);
await userEvent.click(await screen.findByLabelText('Widget actions'));
await userEvent.click(screen.getByRole('menuitemradio', {name: 'Duplicate Widget'}));
expect(mock).toHaveBeenCalledTimes(0);
});
it('calls onEdit when Edit Widget is clicked', async function () {
const mock = jest.fn();
renderWithProviders(
undefined}
onEdit={mock}
onDuplicate={() => undefined}
renderErrorMessage={() => undefined}
showContextMenu
widgetLimitReached={false}
widgetLegendState={widgetLegendState}
/>
);
await userEvent.click(await screen.findByLabelText('Widget actions'));
await userEvent.click(screen.getByRole('menuitemradio', {name: 'Edit Widget'}));
expect(mock).toHaveBeenCalledTimes(1);
});
it('renders delete widget option', async function () {
const mock = jest.fn();
renderWithProviders(
undefined}
onDuplicate={() => undefined}
renderErrorMessage={() => undefined}
showContextMenu
widgetLimitReached={false}
widgetLegendState={widgetLegendState}
/>
);
await userEvent.click(await screen.findByLabelText('Widget actions'));
await userEvent.click(screen.getByRole('menuitemradio', {name: 'Delete Widget'}));
// Confirm Modal
renderGlobalModal();
await screen.findByRole('dialog');
await userEvent.click(screen.getByTestId('confirm-button'));
expect(mock).toHaveBeenCalled();
});
it('calls events with a limit of 20 items', async function () {
const mock = jest.fn();
renderWithProviders(
undefined}
onDuplicate={() => undefined}
renderErrorMessage={() => undefined}
showContextMenu
widgetLimitReached={false}
tableItemLimit={20}
widgetLegendState={widgetLegendState}
/>
);
await waitFor(() => {
expect(eventsMock).toHaveBeenCalledWith(
'/organizations/org-slug/events/',
expect.objectContaining({
query: expect.objectContaining({
per_page: 20,
}),
})
);
});
});
it('calls events with a default limit of 5 items', async function () {
const mock = jest.fn();
renderWithProviders(
undefined}
onDuplicate={() => undefined}
renderErrorMessage={() => undefined}
showContextMenu
widgetLimitReached={false}
widgetLegendState={widgetLegendState}
/>
);
await waitFor(() => {
expect(eventsMock).toHaveBeenCalledWith(
'/organizations/org-slug/events/',
expect.objectContaining({
query: expect.objectContaining({
per_page: 5,
}),
})
);
});
});
it('has sticky table headers', async function () {
const tableWidget: Widget = {
title: 'Table Widget',
interval: '5m',
displayType: DisplayType.TABLE,
widgetType: WidgetType.DISCOVER,
queries: [
{
conditions: '',
fields: ['transaction', 'count()'],
columns: ['transaction'],
aggregates: ['count()'],
name: 'Table',
orderby: '',
},
],
};
renderWithProviders(
undefined}
onEdit={() => undefined}
onDuplicate={() => undefined}
renderErrorMessage={() => undefined}
showContextMenu
widgetLimitReached={false}
tableItemLimit={20}
widgetLegendState={widgetLegendState}
/>
);
await waitFor(() => expect(eventsMock).toHaveBeenCalled());
await waitFor(() =>
expect(SimpleTableChart).toHaveBeenCalledWith(
expect.objectContaining({stickyHeaders: true}),
expect.anything()
)
);
});
it('calls release queries', function () {
const widget: Widget = {
title: 'Release Widget',
interval: '5m',
displayType: DisplayType.LINE,
widgetType: WidgetType.RELEASE,
queries: [],
};
renderWithProviders(
undefined}
onEdit={() => undefined}
onDuplicate={() => undefined}
renderErrorMessage={() => undefined}
showContextMenu
widgetLimitReached={false}
tableItemLimit={20}
widgetLegendState={widgetLegendState}
/>
);
expect(ReleaseWidgetQueries).toHaveBeenCalledTimes(1);
});
it('opens the widget viewer modal when a widget has no id', async () => {
const widget: Widget = {
title: 'Widget',
interval: '5m',
displayType: DisplayType.LINE,
widgetType: WidgetType.DISCOVER,
queries: [],
};
renderWithProviders(
undefined}
onEdit={() => undefined}
onDuplicate={() => undefined}
renderErrorMessage={() => undefined}
showContextMenu
widgetLimitReached={false}
index="10"
isPreview
widgetLegendState={widgetLegendState}
/>
);
await userEvent.click(await screen.findByLabelText('Open Full-Screen View'));
expect(router.push).toHaveBeenCalledWith(
expect.objectContaining({pathname: '/mock-pathname/widget/10/'})
);
});
it('renders chart using axis and tooltip formatters from custom measurement meta', async function () {
const spy = jest.spyOn(LineChart, 'LineChart');
const eventsStatsMock = MockApiClient.addMockResponse({
url: '/organizations/org-slug/events-stats/',
body: {
data: [
[
1658262600,
[
{
count: 24,
},
],
],
],
meta: {
fields: {
time: 'date',
p95_measurements_custom: 'duration',
},
units: {
time: null,
p95_measurements_custom: 'millisecond',
},
isMetricsData: true,
tips: {},
},
},
});
renderWithProviders(
undefined}
onEdit={() => undefined}
onDuplicate={() => undefined}
renderErrorMessage={() => undefined}
showContextMenu
widgetLimitReached={false}
widgetLegendState={widgetLegendState}
/>
);
await waitFor(function () {
expect(eventsStatsMock).toHaveBeenCalled();
});
await waitFor(() => {
const mockCall = spy.mock.calls?.at(-1)?.[0];
expect(mockCall?.tooltip).toBeDefined();
// @ts-expect-error
expect(mockCall?.yAxis.axisLabel.formatter(24, 'p95(measurements.custom)')).toEqual(
'24ms'
);
});
});
it('renders label in seconds when there is a transition from seconds to minutes in the y axis', async function () {
const spy = jest.spyOn(LineChart, 'LineChart');
const eventsStatsMock = MockApiClient.addMockResponse({
url: '/organizations/org-slug/events-stats/',
body: {
data: [
[
1658262600,
[
{
count: 40 * SECOND,
},
],
],
[
1658262601,
[
{
count: 50 * SECOND,
},
],
],
[
1658262602,
[
{
count: MINUTE,
},
],
],
[
1658262603,
[
{
count: 1.3 * MINUTE,
},
],
],
],
meta: {
fields: {
time: 'date',
p50_transaction_duration: 'duration',
},
units: {
time: null,
p50_transaction_duration: 'millisecond',
},
isMetricsData: false,
tips: {},
},
},
});
renderWithProviders(
undefined}
onEdit={() => undefined}
onDuplicate={() => undefined}
renderErrorMessage={() => undefined}
showContextMenu
widgetLimitReached={false}
widgetLegendState={widgetLegendState}
/>
);
await waitFor(function () {
expect(eventsStatsMock).toHaveBeenCalled();
});
await waitFor(() => {
const mockCall = spy.mock.calls?.at(-1)?.[0];
expect(mockCall?.yAxis).toBeDefined();
expect(
// @ts-expect-error
mockCall?.yAxis.axisLabel.formatter(60000, 'p50(transaction.duration)')
).toEqual('60s');
// @ts-expect-error
expect(mockCall?.yAxis?.minInterval).toEqual(SECOND);
});
});
it('displays indexed badge in preview mode', async function () {
renderWithProviders(
undefined}
onEdit={() => undefined}
onDuplicate={() => undefined}
renderErrorMessage={() => undefined}
showContextMenu
widgetLimitReached={false}
isPreview
widgetLegendState={widgetLegendState}
/>
);
expect(await screen.findByText('Indexed')).toBeInTheDocument();
});
it('displays the discover split warning icon when the dataset source is forced', async function () {
const testWidget = {
...WidgetFixture(),
datasetSource: DatasetSource.FORCED,
widgetType: WidgetType.ERRORS,
};
renderWithProviders(
undefined}
onEdit={() => undefined}
onDuplicate={() => undefined}
renderErrorMessage={() => undefined}
showContextMenu
widgetLimitReached={false}
isPreview
widgetLegendState={widgetLegendState}
/>
);
await userEvent.hover(screen.getByLabelText('Widget warnings'));
expect(
await screen.findByText(/We're splitting our datasets up/)
).toBeInTheDocument();
});
});