import {DashboardFixture} from 'sentry-fixture/dashboard'; import {LocationFixture} from 'sentry-fixture/locationFixture'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {ProjectFixture} from 'sentry-fixture/project'; import {ReleaseFixture} from 'sentry-fixture/release'; import {RouteComponentPropsFixture} from 'sentry-fixture/routeComponentPropsFixture'; import {TeamFixture} from 'sentry-fixture/team'; import {UserFixture} from 'sentry-fixture/user'; import {WidgetFixture} from 'sentry-fixture/widget'; import {initializeOrg} from 'sentry-test/initializeOrg'; import { act, render, screen, userEvent, waitFor, within, } from 'sentry-test/reactTestingLibrary'; import * as modals from 'sentry/actionCreators/modal'; import ConfigStore from 'sentry/stores/configStore'; import PageFiltersStore from 'sentry/stores/pageFiltersStore'; import ProjectsStore from 'sentry/stores/projectsStore'; import TeamStore from 'sentry/stores/teamStore'; import {browserHistory} from 'sentry/utils/browserHistory'; import CreateDashboard from 'sentry/views/dashboards/create'; import {handleUpdateDashboardSplit} from 'sentry/views/dashboards/detail'; import EditAccessSelector from 'sentry/views/dashboards/editAccessSelector'; import * as types from 'sentry/views/dashboards/types'; import ViewEditDashboard from 'sentry/views/dashboards/view'; import {OrganizationContext} from 'sentry/views/organizationContext'; describe('Dashboards > Detail', function () { const organization = OrganizationFixture({ features: ['global-views', 'dashboards-basic', 'dashboards-edit', 'discover-query'], }); const projects = [ProjectFixture()]; describe('prebuilt dashboards', function () { let initialData; 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: [ProjectFixture()], }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/', body: [ DashboardFixture([], {id: 'default-overview', title: 'Default'}), DashboardFixture([], {id: '1', title: 'Custom Errors'}), ], }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/default-overview/', body: DashboardFixture([], {id: 'default-overview', title: 'Default'}), }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/visit/', method: 'POST', body: [], statusCode: 200, }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/users/', method: 'GET', body: [], }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/sdk-updates/', body: [], }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/prompts-activity/', body: {}, }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/events/', method: 'GET', body: [], }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', body: {data: []}, }); MockApiClient.addMockResponse({ method: 'GET', url: '/organizations/org-slug/issues/', body: [], }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/releases/', body: [], }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/metrics/meta/', body: [], }); }); afterEach(function () { MockApiClient.clearMockResponses(); }); it('assigns unique IDs to all widgets so grid keys are unique', async function () { MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', body: {data: []}, }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/default-overview/', body: DashboardFixture( [ WidgetFixture({ queries: [ { name: '', conditions: 'event.type:error', fields: ['count()'], aggregates: ['count()'], columns: [], orderby: '-count()', }, ], title: 'Default Widget 1', interval: '1d', }), WidgetFixture({ queries: [ { name: '', conditions: 'event.type:transaction', fields: ['count()'], aggregates: ['count()'], columns: [], orderby: '-count()', }, ], title: 'Default Widget 2', interval: '1d', }), ], {id: 'default-overview', title: 'Default'} ), }); initialData = initializeOrg({ organization: OrganizationFixture({ features: ['global-views', 'dashboards-basic', 'discover-query'], }), }); render( {null} , {router: initialData.router} ); expect(await screen.findByText('Default Widget 1')).toBeInTheDocument(); expect(screen.getByText('Default Widget 2')).toBeInTheDocument(); }); it('opens the widget viewer modal in a prebuilt dashboard using the widget id specified in the url', async () => { const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal'); render( {null} , {router: initialData.router, organization: initialData.organization} ); await waitFor(() => { expect(openWidgetViewerModal).toHaveBeenCalledWith( expect.objectContaining({ organization: initialData.organization, widget: expect.objectContaining({ displayType: 'line', interval: '5m', queries: [ { aggregates: ['count()'], columns: [], conditions: '!event.type:transaction', fields: ['count()'], name: 'Events', orderby: 'count()', }, ], title: 'Events', widgetType: types.WidgetType.DISCOVER, }), onClose: expect.anything(), }) ); }); }); }); describe('custom dashboards', function () { let initialData, widgets, mockVisit, mockPut; beforeEach(function () { window.confirm = jest.fn(); initialData = initializeOrg({ organization, router: { location: LocationFixture(), }, }); PageFiltersStore.init(); PageFiltersStore.onInitializeUrlState( { projects: [], environments: [], datetime: {start: null, end: null, period: '14d', utc: null}, }, new Set() ); widgets = [ WidgetFixture({ queries: [ { name: '', conditions: 'event.type:error', fields: ['count()'], columns: [], aggregates: ['count()'], orderby: '-count()', }, ], title: 'Errors', interval: '1d', widgetType: types.WidgetType.DISCOVER, id: '1', }), WidgetFixture({ queries: [ { name: '', conditions: 'event.type:transaction', fields: ['count()'], columns: [], aggregates: ['count()'], orderby: '-count()', }, ], title: 'Transactions', interval: '1d', widgetType: types.WidgetType.DISCOVER, id: '2', }), WidgetFixture({ queries: [ { name: '', conditions: 'event.type:transaction transaction:/api/cats', fields: ['p50()'], columns: [], aggregates: ['p50()'], orderby: '-p50()', }, ], 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: [ProjectFixture()], }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/', body: [ { ...DashboardFixture([], { id: 'default-overview', title: 'Default', }), widgetDisplay: ['area'], }, { ...DashboardFixture([], { id: '1', title: 'Custom Errors', }), widgetDisplay: ['area'], }, ], }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', body: DashboardFixture(widgets, { id: '1', title: 'Custom Errors', filters: {}, createdBy: UserFixture({id: '1'}), }), }); mockPut = MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', method: 'PUT', body: DashboardFixture(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/events/', method: 'GET', body: [], }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/users/', method: 'GET', body: [], }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/releases/', body: [], }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/sdk-updates/', body: [], }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/prompts-activity/', body: {}, }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/metrics/meta/', body: [], }); }); afterEach(function () { MockApiClient.clearMockResponses(); jest.clearAllMocks(); }); it('can remove widgets', async function () { const updateMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', method: 'PUT', body: DashboardFixture([widgets[0]], {id: '1', title: 'Custom Errors'}), }); render( {null} , {router: initialData.router} ); await waitFor(() => expect(mockVisit).toHaveBeenCalledTimes(1)); // Enter edit mode. await userEvent.click(screen.getByRole('button', {name: 'Edit Dashboard'})); // Remove the second and third widgets await userEvent.click( (await screen.findAllByRole('button', {name: 'Delete Widget'}))[1] ); await userEvent.click( (await screen.findAllByRole('button', {name: 'Delete Widget'}))[1] ); // Save changes await userEvent.click(screen.getByRole('button', {name: 'Save and Finish'})); expect(updateMock).toHaveBeenCalled(); expect(updateMock).toHaveBeenCalledWith( '/organizations/org-slug/dashboards/1/', expect.objectContaining({ data: expect.objectContaining({ title: 'Custom Errors', widgets: [expect.objectContaining(widgets[0])], }), }) ); // Visit should not be called again on dashboard update expect(mockVisit).toHaveBeenCalledTimes(1); }); it('appends dashboard-level filters to series request', async function () { MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', body: DashboardFixture(widgets, { id: '1', title: 'Custom Errors', filters: {release: ['abc@1.2.0']}, }), }); const mock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', body: [], }); render( {null} , {router: initialData.router} ); await waitFor(() => expect(mock).toHaveBeenLastCalledWith( '/organizations/org-slug/events-stats/', expect.objectContaining({ query: expect.objectContaining({ query: '(event.type:transaction transaction:/api/cats) release:"abc@1.2.0" ', }), }) ) ); }); it('shows add widget option', async function () { render( {null} , {router: initialData.router} ); // Enter edit mode. await userEvent.click(screen.getByRole('button', {name: 'Edit Dashboard'})); expect(await screen.findByRole('button', {name: 'Add widget'})).toBeInTheDocument(); }); it('shows add widget option with dataset selector flag', async function () { initialData = initializeOrg({ organization: OrganizationFixture({ features: [ 'global-views', 'dashboards-basic', 'dashboards-edit', 'discover-query', 'custom-metrics', 'performance-discover-dataset-selector', ], }), }); render( {null} , {router: initialData.router} ); await userEvent.click(screen.getAllByText('Add Widget')[0]); const menuOptions = await screen.findAllByTestId('menu-list-item-label'); expect(menuOptions.map(e => e.textContent)).toEqual([ 'Errors', 'Transactions', 'Issues', 'Releases', 'Metrics', ]); }); it('shows add widget option without dataset selector flag', async function () { initialData = initializeOrg({ organization: OrganizationFixture({ features: [ 'global-views', 'dashboards-basic', 'dashboards-edit', 'discover-query', 'custom-metrics', ], }), }); render( {null} , {router: initialData.router} ); await userEvent.click(screen.getAllByText('Add Widget')[0]); const menuOptions = await screen.findAllByTestId('menu-list-item-label'); expect(menuOptions.map(e => e.textContent)).toEqual([ 'Errors and Transactions', 'Issues', 'Releases', 'Metrics', ]); }); it('shows top level release filter', async function () { const mockReleases = MockApiClient.addMockResponse({ url: '/organizations/org-slug/releases/', body: [ReleaseFixture()], }); initialData = initializeOrg({ organization: OrganizationFixture({ features: [ 'global-views', 'dashboards-basic', 'dashboards-edit', 'discover-query', ], }), }); render( {null} , {router: initialData.router} ); expect(await screen.findByText('All Releases')).toBeInTheDocument(); expect(mockReleases).toHaveBeenCalledTimes(2); // Called once when PageFiltersStore is initialized }); it('hides add widget option', async function () { // @ts-expect-error this is assigning to readonly property... types.MAX_WIDGETS = 1; render( {null} , {router: initialData.router} ); // Enter edit mode. await userEvent.click(await screen.findByRole('button', {name: 'Edit Dashboard'})); expect(screen.queryByRole('button', {name: 'Add widget'})).not.toBeInTheDocument(); }); it('renders successfully if more widgets than stored layouts', async function () { // A case where someone has async added widgets to a dashboard MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', body: DashboardFixture( [ WidgetFixture({ queries: [ { name: '', conditions: 'event.type:error', fields: ['count()'], aggregates: ['count()'], columns: [], orderby: '-count()', }, ], title: 'First Widget', interval: '1d', id: '1', layout: {x: 0, y: 0, w: 2, h: 6, minH: 0}, }), WidgetFixture({ queries: [ { name: '', conditions: 'event.type:error', fields: ['count()'], aggregates: ['count()'], columns: [], orderby: '-count()', }, ], title: 'Second Widget', interval: '1d', id: '2', }), ], {id: '1', title: 'Custom Errors'} ), }); render( {null} , {router: initialData.router, organization: initialData.organization} ); expect(await screen.findByText('First Widget')).toBeInTheDocument(); expect(await screen.findByText('Second Widget')).toBeInTheDocument(); }); it('does not trigger request if layout not updated', async () => { MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', body: DashboardFixture( [ WidgetFixture({ queries: [ { name: '', conditions: 'event.type:error', fields: ['count()'], aggregates: ['count()'], columns: [], orderby: '-count()', }, ], title: 'First Widget', interval: '1d', id: '1', layout: {x: 0, y: 0, w: 2, h: 6, minH: 0}, }), ], {id: '1', title: 'Custom Errors'} ), }); render( {null} , {router: initialData.router, organization: initialData.organization} ); await userEvent.click(await screen.findByText('Edit Dashboard')); await userEvent.click(await screen.findByText('Save and Finish')); expect(screen.getByText('Edit Dashboard')).toBeInTheDocument(); expect(mockPut).not.toHaveBeenCalled(); }); it('renders the custom resize handler for a widget', async () => { MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', body: DashboardFixture( [ WidgetFixture({ queries: [ { name: '', conditions: 'event.type:error', fields: ['count()'], aggregates: ['count()'], columns: [], orderby: '-count()', }, ], title: 'First Widget', interval: '1d', id: '1', layout: {x: 0, y: 0, w: 2, h: 6, minH: 0}, }), ], {id: '1', title: 'Custom Errors'} ), }); render( {null} , {router: initialData.router, organization: initialData.organization} ); await userEvent.click(await screen.findByText('Edit Dashboard')); const widget = (await screen.findByText('First Widget')).closest( '.react-grid-item' ) as HTMLElement; const resizeHandle = within(widget).getByTestId('custom-resize-handle'); expect(resizeHandle).toBeVisible(); }); it('does not trigger an alert when the widgets have no layout and user cancels without changes', async () => { MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', body: DashboardFixture( [ WidgetFixture({ queries: [ { name: '', conditions: 'event.type:error', fields: ['count()'], aggregates: ['count()'], columns: [], orderby: '-count()', }, ], title: 'First Widget', interval: '1d', id: '1', layout: null, }), ], {id: '1', title: 'Custom Errors'} ), }); render( {null} , {router: initialData.router, organization: initialData.organization} ); await userEvent.click(await screen.findByText('Edit Dashboard')); await userEvent.click(await screen.findByText('Cancel')); expect(window.confirm).not.toHaveBeenCalled(); }); it('opens the widget viewer modal using the widget id specified in the url', async () => { const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal'); const widget = WidgetFixture({ queries: [ { name: '', conditions: 'event.type:error', fields: ['count()'], aggregates: ['count()'], columns: [], orderby: '', }, ], title: 'First Widget', interval: '1d', id: '1', layout: null, }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', body: DashboardFixture([widget], {id: '1', title: 'Custom Errors'}), }); render( {null} , {router: initialData.router, organization: initialData.organization} ); await waitFor(() => { expect(openWidgetViewerModal).toHaveBeenCalledWith( expect.objectContaining({ organization: initialData.organization, widget, onClose: expect.anything(), }) ); }); }); it('redirects user to dashboard url if widget is not found', async () => { const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal'); MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', body: DashboardFixture([], {id: '1', title: 'Custom Errors'}), }); render( {null} , {router: initialData.router, organization: initialData.organization} ); expect(await screen.findByText('All Releases')).toBeInTheDocument(); expect(openWidgetViewerModal).not.toHaveBeenCalled(); expect(initialData.router.replace).toHaveBeenCalledWith( expect.objectContaining({ pathname: '/organizations/org-slug/dashboard/1/', query: {}, }) ); }); it('saves a new dashboard with the page filters', async () => { const mockPOST = MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/', method: 'POST', body: [], }); render( {null} , { router: initialData.router, organization: initialData.organization, } ); await userEvent.click(await screen.findByText('Save and Finish')); expect(mockPOST).toHaveBeenCalledWith( '/organizations/org-slug/dashboards/', expect.objectContaining({ data: expect.objectContaining({ projects: [2], environment: ['alpha', 'beta'], period: '7d', }), }) ); }); it('saves a template with the page filters', async () => { const mockPOST = MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/', method: 'POST', body: [], }); render( {null} , { router: initialData.router, organization: initialData.organization, } ); await userEvent.click(await screen.findByText('Add Dashboard')); expect(mockPOST).toHaveBeenCalledWith( '/organizations/org-slug/dashboards/', expect.objectContaining({ data: expect.objectContaining({ projects: [2], environment: ['alpha', 'beta'], period: '7d', }), }) ); }); it('does not render save and cancel buttons on templates', async () => { MockApiClient.addMockResponse({ url: '/organizations/org-slug/releases/', body: [ ReleaseFixture({ shortVersion: 'sentry-android-shop@1.2.0', version: 'sentry-android-shop@1.2.0', }), ], }); render( {null} , { router: initialData.router, organization: initialData.organization, } ); await userEvent.click(await screen.findByText('24H')); await userEvent.click(screen.getByText('Last 7 days')); await screen.findByText('7D'); expect(screen.queryByText('Cancel')).not.toBeInTheDocument(); expect(screen.queryByText('Save')).not.toBeInTheDocument(); }); it('opens the widget viewer with saved dashboard filters', async () => { const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal'); MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', body: DashboardFixture(widgets, { id: '1', filters: {release: ['sentry-android-shop@1.2.0']}, }), }); render( {null} , {router: initialData.router, organization: initialData.organization} ); await waitFor(() => { expect(openWidgetViewerModal).toHaveBeenCalledWith( expect.objectContaining({ dashboardFilters: {release: ['sentry-android-shop@1.2.0']}, }) ); }); }); it('opens the widget viewer with unsaved dashboard filters', async () => { const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal'); MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', body: DashboardFixture(widgets, { id: '1', filters: {release: ['sentry-android-shop@1.2.0']}, }), }); render( {null} , {router: initialData.router, organization: initialData.organization} ); await waitFor(() => { expect(openWidgetViewerModal).toHaveBeenCalledWith( expect.objectContaining({ dashboardFilters: {release: ['unsaved-release-filter@1.2.0']}, }) ); }); }); it('can save dashboard filters in existing dashboard', async () => { MockApiClient.addMockResponse({ url: '/organizations/org-slug/releases/', body: [ ReleaseFixture({ shortVersion: 'sentry-android-shop@1.2.0', version: 'sentry-android-shop@1.2.0', }), ], }); const testData = initializeOrg({ organization: OrganizationFixture({ features: [ 'global-views', 'dashboards-basic', 'dashboards-edit', 'discover-query', ], }), router: { location: { ...LocationFixture(), query: { statsPeriod: '7d', release: ['sentry-android-shop@1.2.0'], }, }, }, }); render( {null} , {router: testData.router, organization: testData.organization} ); await userEvent.click(await screen.findByText('Save')); expect(mockPut).toHaveBeenCalledWith( '/organizations/org-slug/dashboards/1/', expect.objectContaining({ data: expect.objectContaining({ period: '7d', filters: {release: ['sentry-android-shop@1.2.0']}, }), }) ); }); it('can clear dashboard filters in compact select', async () => { MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', body: DashboardFixture(widgets, { id: '1', title: 'Custom Errors', filters: {release: ['sentry-android-shop@1.2.0']}, }), }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/releases/', body: [ ReleaseFixture({ shortVersion: 'sentry-android-shop@1.2.0', version: 'sentry-android-shop@1.2.0', }), ], }); const testData = initializeOrg({ organization: OrganizationFixture({ features: [ 'global-views', 'dashboards-basic', 'dashboards-edit', 'discover-query', ], }), router: { location: { ...LocationFixture(), query: { statsPeriod: '7d', }, }, }, }); render( {null} , {router: testData.router, organization: testData.organization} ); await screen.findByText('7D'); await userEvent.click(await screen.findByText('sentry-android-shop@1.2.0')); await userEvent.click(screen.getAllByText('Clear')[0]); screen.getByText('All Releases'); await userEvent.click(document.body); await waitFor(() => { expect(browserHistory.push).toHaveBeenCalledWith( expect.objectContaining({ query: expect.objectContaining({ release: '', }), }) ); }); }); it('can save absolute time range in existing dashboard', async () => { const testData = initializeOrg({ organization: OrganizationFixture({ features: [ 'global-views', 'dashboards-basic', 'dashboards-edit', 'discover-query', ], }), router: { location: { ...LocationFixture(), query: { start: '2022-07-14T07:00:00', end: '2022-07-19T23:59:59', utc: 'true', }, }, }, }); render( {null} , {router: testData.router, organization: testData.organization} ); await userEvent.click(await screen.findByText('Save')); expect(mockPut).toHaveBeenCalledWith( '/organizations/org-slug/dashboards/1/', expect.objectContaining({ data: expect.objectContaining({ start: '2022-07-14T07:00:00.000', end: '2022-07-19T23:59:59.000', utc: true, }), }) ); }); it('can clear dashboard filters in existing dashboard', async () => { MockApiClient.addMockResponse({ url: '/organizations/org-slug/releases/', body: [ ReleaseFixture({ shortVersion: 'sentry-android-shop@1.2.0', version: 'sentry-android-shop@1.2.0', }), ], }); const testData = initializeOrg({ organization: OrganizationFixture({ features: [ 'global-views', 'dashboards-basic', 'dashboards-edit', 'discover-query', ], }), router: { location: { ...LocationFixture(), query: { statsPeriod: '7d', environment: ['alpha', 'beta'], }, }, }, }); render( {null} , {router: testData.router, organization: testData.organization} ); await screen.findByText('7D'); await userEvent.click(await screen.findByText('All Releases')); await userEvent.click(screen.getByText('sentry-android-shop@1.2.0')); await userEvent.keyboard('{Escape}'); await userEvent.click(screen.getByText('Cancel')); screen.getByText('All Releases'); expect(browserHistory.replace).toHaveBeenCalledWith( expect.objectContaining({ query: expect.objectContaining({ project: undefined, statsPeriod: undefined, environment: undefined, }), }) ); }); it('disables the Edit Dashboard button when there are unsaved filters', async () => { MockApiClient.addMockResponse({ url: '/organizations/org-slug/releases/', body: [ ReleaseFixture({ shortVersion: 'sentry-android-shop@1.2.0', version: 'sentry-android-shop@1.2.0', }), ], }); const testData = initializeOrg({ organization: OrganizationFixture({ features: [ 'global-views', 'dashboards-basic', 'dashboards-edit', 'discover-basic', 'discover-query', ], }), router: { location: { ...LocationFixture(), query: { statsPeriod: '7d', environment: ['alpha', 'beta'], }, }, }, }); render( {null} , {router: testData.router, organization: testData.organization} ); expect(await screen.findByText('Save')).toBeInTheDocument(); expect(screen.getByText('Cancel')).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Edit Dashboard'})).toBeDisabled(); }); it('ignores the order of selection of page filters to render unsaved filters', async () => { const testProjects = [ ProjectFixture({id: '1', name: 'first', environments: ['alpha', 'beta']}), ProjectFixture({id: '2', name: 'second', environments: ['alpha', 'beta']}), ]; act(() => ProjectsStore.loadInitialData(testProjects)); MockApiClient.addMockResponse({ url: '/organizations/org-slug/projects/', body: testProjects, }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', body: DashboardFixture(widgets, { id: '1', title: 'Custom Errors', filters: {}, environment: ['alpha', 'beta'], }), }); const testData = initializeOrg({ organization: OrganizationFixture({ features: [ 'global-views', 'dashboards-basic', 'dashboards-edit', 'discover-query', ], }), router: { location: { ...LocationFixture(), query: { environment: ['beta', 'alpha'], // Reversed order from saved dashboard }, }, }, }); render( {null} , {router: testData.router, organization: testData.organization} ); await waitFor(() => expect(screen.queryAllByText('Loading\u2026')).toEqual([])); await userEvent.click(screen.getByRole('button', {name: 'All Envs'})); expect(screen.getByRole('row', {name: 'alpha'})).toHaveAttribute( 'aria-selected', 'true' ); expect(screen.getByRole('row', {name: 'beta'})).toHaveAttribute( 'aria-selected', 'true' ); // Save and Cancel should not appear because alpha, beta is the same as beta, alpha expect(screen.queryByText('Save')).not.toBeInTheDocument(); expect(screen.queryByText('Cancel')).not.toBeInTheDocument(); }); it('uses releases from the URL query params', async function () { const testData = initializeOrg({ organization: OrganizationFixture({ features: [ 'global-views', 'dashboards-basic', 'dashboards-edit', 'discover-query', ], }), router: { location: { ...LocationFixture(), query: { release: ['not-selected-1'], }, }, }, }); render( {null} , {router: testData.router, organization: testData.organization} ); await screen.findByText(/not-selected-1/); screen.getByText('Save'); screen.getByText('Cancel'); }); it('resets release in URL params', async function () { MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', body: DashboardFixture(widgets, { id: '1', title: 'Custom Errors', filters: { release: ['abc'], }, }), }); const testData = initializeOrg({ organization: OrganizationFixture({ features: [ 'global-views', 'dashboards-basic', 'dashboards-edit', 'discover-query', ], }), router: { location: { ...LocationFixture(), query: { release: ['not-selected-1'], }, }, }, }); render( {null} , {router: testData.router, organization: testData.organization} ); await screen.findByText(/not-selected-1/); await userEvent.click(screen.getByText('Cancel')); // release isn't used in the redirect expect(browserHistory.replace).toHaveBeenCalledWith( expect.objectContaining({ query: { end: undefined, environment: undefined, project: undefined, start: undefined, statsPeriod: undefined, utc: undefined, }, }) ); }); it('reflects selections in the release filter in the query params', async function () { MockApiClient.addMockResponse({ url: '/organizations/org-slug/releases/', body: [ ReleaseFixture({ shortVersion: 'sentry-android-shop@1.2.0', version: 'sentry-android-shop@1.2.0', }), ], }); const testData = initializeOrg({ organization: OrganizationFixture({ features: [ 'global-views', 'dashboards-basic', 'dashboards-edit', 'discover-query', ], }), router: { location: LocationFixture(), }, }); render( {null} , {router: testData.router, organization: testData.organization} ); await userEvent.click(await screen.findByText('All Releases')); await userEvent.click(screen.getByText('sentry-android-shop@1.2.0')); await userEvent.click(document.body); await waitFor(() => { expect(browserHistory.push).toHaveBeenCalledWith( expect.objectContaining({ query: expect.objectContaining({ release: ['sentry-android-shop@1.2.0'], }), }) ); }); }); it('persists release selections made during search requests that do not appear in default query', async function () { // Default response MockApiClient.addMockResponse({ url: '/organizations/org-slug/releases/', body: [ ReleaseFixture({ shortVersion: 'sentry-android-shop@1.2.0', version: 'sentry-android-shop@1.2.0', }), ], }); // Mocked search results MockApiClient.addMockResponse({ url: '/organizations/org-slug/releases/', body: [ ReleaseFixture({ id: '9', shortVersion: 'search-result', version: 'search-result', }), ], match: [MockApiClient.matchData({query: 's'})], }); const testData = initializeOrg({ organization: OrganizationFixture({ features: [ 'global-views', 'dashboards-basic', 'dashboards-edit', 'discover-basic', 'discover-query', ], }), router: { location: LocationFixture(), }, }); render( {null} , {router: testData.router, organization: testData.organization} ); await userEvent.click(await screen.findByText('All Releases')); await userEvent.type(screen.getAllByPlaceholderText('Search\u2026')[2], 's'); await userEvent.click(await screen.findByRole('option', {name: 'search-result'})); // Validate that after search is cleared, search result still appears expect(await screen.findByText('Latest Release(s)')).toBeInTheDocument(); expect(screen.getByRole('option', {name: 'search-result'})).toBeInTheDocument(); }); it('renders edit access selector', async function () { render( , { router: initialData.router, organization: { features: ['dashboards-edit-access'], ...initialData.organization, }, } ); await userEvent.click(await screen.findByText('Edit Access:')); expect(screen.getByText('Creator')).toBeInTheDocument(); expect(screen.getByText('All users')).toBeInTheDocument(); }); it('creates and updates new permissions for dashboard with no edit perms initialized', async function () { const mockPUT = MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', method: 'PUT', body: DashboardFixture([], {id: '1', title: 'Custom Errors'}), }); render( {null} , { router: initialData.router, organization: { features: ['dashboards-edit-access', ...initialData.organization.features], }, } ); await userEvent.click(await screen.findByText('Edit Access:')); // deselects 'All users' so only creator has edit access expect(await screen.findByText('All users')).toBeEnabled(); expect(await screen.findByRole('option', {name: 'All users'})).toHaveAttribute( 'aria-selected', 'true' ); await userEvent.click(screen.getByRole('option', {name: 'All users'})); expect(await screen.findByRole('option', {name: 'All users'})).toHaveAttribute( 'aria-selected', 'false' ); await userEvent.click(await screen.findByText('Save Changes')); await waitFor(() => { expect(mockPUT).toHaveBeenCalledTimes(1); expect(mockPUT).toHaveBeenCalledWith( '/organizations/org-slug/dashboards/1/', expect.objectContaining({ data: expect.objectContaining({ permissions: {isEditableByEveryone: false, teamsWithEditAccess: []}, }), }) ); }); }); it('creator can update permissions for dashboard', async function () { const mockPUT = MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', method: 'PUT', body: DashboardFixture([], {id: '1', title: 'Custom Errors'}), }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', body: DashboardFixture([], { id: '1', title: 'Custom Errors', createdBy: UserFixture({id: '781629'}), permissions: {isEditableByEveryone: false}, }), }); const currentUser = UserFixture({id: '781629'}); ConfigStore.set('user', currentUser); render( {null} , { router: initialData.router, organization: { features: ['dashboards-edit-access', ...initialData.organization.features], }, } ); await userEvent.click(await screen.findByText('Edit Access:')); // selects 'All users' so everyone has edit access expect(await screen.findByText('All users')).toBeEnabled(); expect(await screen.findByRole('option', {name: 'All users'})).toHaveAttribute( 'aria-selected', 'false' ); await userEvent.click(screen.getByRole('option', {name: 'All users'})); expect(await screen.findByRole('option', {name: 'All users'})).toHaveAttribute( 'aria-selected', 'true' ); await userEvent.click(await screen.findByText('Save Changes')); await waitFor(() => { expect(mockPUT).toHaveBeenCalledTimes(1); expect(mockPUT).toHaveBeenCalledWith( '/organizations/org-slug/dashboards/1/', expect.objectContaining({ data: expect.objectContaining({ permissions: {isEditableByEveryone: true, teamsWithEditAccess: []}, }), }) ); }); }); it('creator can update permissions with teams for dashboard', async function () { const mockPUT = MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', method: 'PUT', body: DashboardFixture([], {id: '1', title: 'Custom Errors'}), }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', body: DashboardFixture([], { id: '1', title: 'Custom Errors', createdBy: UserFixture({id: '781629'}), permissions: {isEditableByEveryone: false}, }), }); const currentUser = UserFixture({id: '781629'}); ConfigStore.set('user', currentUser); const teamData = [ { id: '1', slug: 'team1', name: 'Team 1', }, { id: '2', slug: 'team2', name: 'Team 2', }, { id: '3', slug: 'team3', name: 'Team 3', }, ]; const teams = teamData.map(data => TeamFixture(data)); TeamStore.loadInitialData(teams); render( {null} , { router: initialData.router, organization: { features: ['dashboards-edit-access', ...initialData.organization.features], }, } ); await userEvent.click(await screen.findByText('Edit Access:')); expect(await screen.findByText('All users')).toBeEnabled(); expect(await screen.findByRole('option', {name: 'All users'})).toHaveAttribute( 'aria-selected', 'false' ); await userEvent.click(screen.getByRole('option', {name: '#team1'})); await userEvent.click(screen.getByRole('option', {name: '#team2'})); await userEvent.click(await screen.findByText('Save Changes')); await waitFor(() => { expect(mockPUT).toHaveBeenCalledTimes(1); expect(mockPUT).toHaveBeenCalledWith( '/organizations/org-slug/dashboards/1/', expect.objectContaining({ data: expect.objectContaining({ permissions: {isEditableByEveryone: false, teamsWithEditAccess: [1, 2]}, }), }) ); }); }); it('disables edit dashboard and add widget button if user cannot edit dashboard', async function () { MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/', body: [ DashboardFixture([], { id: '1', title: 'Custom Errors', createdBy: UserFixture({id: '238900'}), permissions: {isEditableByEveryone: false}, }), ], }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', body: DashboardFixture([], { id: '1', title: 'Custom Errors', createdBy: UserFixture({id: '238900'}), permissions: {isEditableByEveryone: false}, }), }); const currentUser = UserFixture({id: '781629'}); ConfigStore.set('user', currentUser); render( {null} , { router: initialData.router, organization: { features: ['dashboards-edit-access', ...initialData.organization.features], }, } ); await screen.findByText('Edit Access:'); expect(screen.getByRole('button', {name: 'Edit Dashboard'})).toBeDisabled(); expect(screen.getByRole('button', {name: 'Add Widget'})).toBeDisabled(); }); it('disables widget edit, duplicate, and delete button when user does not have edit perms', async function () { const widget = { displayType: types.DisplayType.TABLE, interval: '1d', queries: [ { name: 'Test Widget', fields: ['count()', 'count_unique(user)', 'epm()', 'project'], columns: ['project'], aggregates: ['count()', 'count_unique(user)', 'epm()'], conditions: '', orderby: '', }, ], title: 'Transactions', id: '1', widgetType: types.WidgetType.DISCOVER, }; const mockDashboard = DashboardFixture([widget], { id: '1', title: 'Custom Errors', createdBy: UserFixture({id: '238900'}), permissions: {isEditableByEveryone: false}, }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/', body: mockDashboard, }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/dashboards/1/', body: mockDashboard, }); const currentUser = UserFixture({id: '781629'}); ConfigStore.set('user', currentUser); render( {null} , { router: initialData.router, organization: { features: ['dashboards-edit-access', ...initialData.organization.features], }, } ); await screen.findByText('Edit Access:'); expect(screen.getByRole('button', {name: 'Edit Dashboard'})).toBeDisabled(); expect(screen.getByRole('button', {name: 'Add Widget'})).toBeDisabled(); await userEvent.click(await screen.findByLabelText('Widget actions')); expect( screen.getByRole('menuitemradio', {name: 'Duplicate Widget'}) ).toHaveAttribute('aria-disabled', 'true'); expect(screen.getByRole('menuitemradio', {name: 'Delete Widget'})).toHaveAttribute( 'aria-disabled', 'true' ); expect(screen.getByRole('menuitemradio', {name: 'Edit Widget'})).toHaveAttribute( 'aria-disabled', 'true' ); }); describe('discover split', function () { it('calls the dashboard callbacks with the correct widgetType for discover split', function () { const widget = { displayType: types.DisplayType.TABLE, interval: '1d', queries: [ { name: 'Test Widget', fields: ['count()', 'count_unique(user)', 'epm()', 'project'], columns: ['project'], aggregates: ['count()', 'count_unique(user)', 'epm()'], conditions: '', orderby: '', }, ], title: 'Transactions', id: '1', widgetType: types.WidgetType.DISCOVER, }; const mockDashboard = DashboardFixture([widget], { id: '1', title: 'Custom Errors', }); const mockModifiedDashboard = DashboardFixture([widget], { id: '1', title: 'Custom Errors', }); const mockOnDashboardUpdate = jest.fn(); const mockStateSetter = jest .fn() .mockImplementation(fn => fn({modifiedDashboard: mockModifiedDashboard})); handleUpdateDashboardSplit({ widgetId: '1', splitDecision: types.WidgetType.ERRORS, dashboard: mockDashboard, modifiedDashboard: mockModifiedDashboard, onDashboardUpdate: mockOnDashboardUpdate, stateSetter: mockStateSetter, }); expect(mockOnDashboardUpdate).toHaveBeenCalledWith({ ...mockDashboard, widgets: [{...widget, widgetType: types.WidgetType.ERRORS}], }); expect(mockStateSetter).toHaveReturnedWith({ modifiedDashboard: { ...mockModifiedDashboard, widgets: [{...widget, widgetType: types.WidgetType.ERRORS}], }, }); }); }); }); });