import type {Location} from 'history'; import {LocationFixture} from 'sentry-fixture/locationFixture'; import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; import type {TableDataRow} from 'sentry/utils/discover/discoverQuery'; import EventView from 'sentry/utils/discover/eventView'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import CellAction, {Actions, updateQuery} from 'sentry/views/discover/table/cellAction'; import type {TableColumn} from 'sentry/views/discover/table/types'; const defaultData: TableDataRow = { transaction: 'best-transaction', count: 19, timestamp: '2020-06-09T01:46:25+00:00', release: 'F2520C43515BD1F0E8A6BD46233324641A370BF6', 'measurements.fcp': 1234, 'percentile(measurements.fcp, 0.5)': 1234, // TODO: Fix this type // @ts-ignore 'error.handled': [null], // TODO: Fix this type // @ts-ignore 'error.type': [ 'ServerException', 'ClickhouseError', 'QueryException', 'QueryException', ], id: '42', }; function renderComponent({ eventView, handleCellAction = jest.fn(), columnIndex = 0, data = defaultData, }: { eventView: EventView; columnIndex?: number; data?: TableDataRow; handleCellAction?: ( action: Actions, value: React.ReactText | null[] | string[] | null ) => void; }) { return render( some content ); } describe('Discover -> CellAction', function () { const location: Location = LocationFixture({ query: { id: '42', name: 'best query', field: [ 'transaction', 'count()', 'timestamp', 'release', 'nullValue', 'measurements.fcp', 'percentile(measurements.fcp, 0.5)', 'error.handled', 'error.type', ], widths: ['437', '647', '416', '905'], sort: ['title'], query: 'event.type:transaction', project: ['123'], start: '2019-10-01T00:00:00', end: '2019-10-02T00:00:00', statsPeriod: '14d', environment: ['staging'], yAxis: 'p95', }, }); const view = EventView.fromLocation(location); async function openMenu() { await userEvent.click(screen.getByRole('button', {name: 'Actions'})); } describe('hover menu button', function () { it('shows no menu by default', function () { renderComponent({eventView: view}); expect(screen.queryByRole('button', {name: 'Actions'})).toBeInTheDocument(); }); }); describe('opening the menu', function () { it('toggles the menu on click', async function () { renderComponent({eventView: view}); await openMenu(); expect( screen.getByRole('menuitemradio', {name: 'Add to filter'}) ).toBeInTheDocument(); }); }); describe('per cell actions', function () { let handleCellAction!: jest.Mock; beforeEach(function () { handleCellAction = jest.fn(); }); it('add button appends condition', async function () { renderComponent({eventView: view, handleCellAction}); await openMenu(); await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'})); expect(handleCellAction).toHaveBeenCalledWith('add', 'best-transaction'); }); it('exclude button adds condition', async function () { renderComponent({eventView: view, handleCellAction}); await openMenu(); await userEvent.click( screen.getByRole('menuitemradio', {name: 'Exclude from filter'}) ); expect(handleCellAction).toHaveBeenCalledWith('exclude', 'best-transaction'); }); it('exclude button appends exclusions', async function () { const excludeView = EventView.fromLocation( LocationFixture({ query: {...location.query, query: '!transaction:nope'}, }) ); renderComponent({eventView: excludeView, handleCellAction}); await openMenu(); await userEvent.click( screen.getByRole('menuitemradio', {name: 'Exclude from filter'}) ); expect(handleCellAction).toHaveBeenCalledWith('exclude', 'best-transaction'); }); it('go to release button goes to release health page', async function () { renderComponent({eventView: view, handleCellAction, columnIndex: 3}); await openMenu(); await userEvent.click(screen.getByRole('menuitemradio', {name: 'Go to release'})); expect(handleCellAction).toHaveBeenCalledWith( 'release', 'F2520C43515BD1F0E8A6BD46233324641A370BF6' ); }); it('greater than button adds condition', async function () { renderComponent({eventView: view, handleCellAction, columnIndex: 2}); await openMenu(); await userEvent.click( screen.getByRole('menuitemradio', {name: 'Show values greater than'}) ); expect(handleCellAction).toHaveBeenCalledWith( 'show_greater_than', '2020-06-09T01:46:25+00:00' ); }); it('less than button adds condition', async function () { renderComponent({eventView: view, handleCellAction, columnIndex: 2}); await openMenu(); await userEvent.click( screen.getByRole('menuitemradio', {name: 'Show values less than'}) ); expect(handleCellAction).toHaveBeenCalledWith( 'show_less_than', '2020-06-09T01:46:25+00:00' ); }); it('error.handled with null adds condition', async function () { renderComponent({ eventView: view, handleCellAction, columnIndex: 7, data: defaultData, }); await openMenu(); await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'})); expect(handleCellAction).toHaveBeenCalledWith('add', 1); }); it('error.type with array values adds condition', async function () { renderComponent({ eventView: view, handleCellAction, columnIndex: 8, data: defaultData, }); await openMenu(); await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'})); expect(handleCellAction).toHaveBeenCalledWith('add', [ 'ServerException', 'ClickhouseError', 'QueryException', 'QueryException', ]); }); it('error.handled with 0 adds condition', async function () { renderComponent({ eventView: view, handleCellAction, columnIndex: 7, data: { ...defaultData, // TODO: Fix this type // @ts-ignore 'error.handled': ['0'], }, }); await openMenu(); await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'})); expect(handleCellAction).toHaveBeenCalledWith('add', ['0']); }); it('show appropriate actions for string cells', async function () { renderComponent({eventView: view, handleCellAction, columnIndex: 0}); await openMenu(); expect( screen.getByRole('menuitemradio', {name: 'Add to filter'}) ).toBeInTheDocument(); expect( screen.getByRole('menuitemradio', {name: 'Exclude from filter'}) ).toBeInTheDocument(); expect( screen.queryByRole('menuitemradio', {name: 'Show values greater than'}) ).not.toBeInTheDocument(); expect( screen.queryByRole('menuitemradio', {name: 'Show values less than'}) ).not.toBeInTheDocument(); }); it('show appropriate actions for string cells with null values', async function () { renderComponent({eventView: view, handleCellAction, columnIndex: 4}); await openMenu(); expect( screen.getByRole('menuitemradio', {name: 'Add to filter'}) ).toBeInTheDocument(); expect( screen.getByRole('menuitemradio', {name: 'Exclude from filter'}) ).toBeInTheDocument(); }); it('show appropriate actions for number cells', async function () { renderComponent({eventView: view, handleCellAction, columnIndex: 1}); await openMenu(); expect( screen.queryByRole('menuitemradio', {name: 'Add to filter'}) ).not.toBeInTheDocument(); expect( screen.queryByRole('menuitemradio', {name: 'Exclude from filter'}) ).not.toBeInTheDocument(); expect( screen.getByRole('menuitemradio', {name: 'Show values greater than'}) ).toBeInTheDocument(); expect( screen.getByRole('menuitemradio', {name: 'Show values less than'}) ).toBeInTheDocument(); }); it('show appropriate actions for date cells', async function () { renderComponent({eventView: view, handleCellAction, columnIndex: 2}); await openMenu(); expect( screen.getByRole('menuitemradio', {name: 'Add to filter'}) ).toBeInTheDocument(); expect( screen.queryByRole('menuitemradio', {name: 'Exclude from filter'}) ).not.toBeInTheDocument(); expect( screen.getByRole('menuitemradio', {name: 'Show values greater than'}) ).toBeInTheDocument(); expect( screen.getByRole('menuitemradio', {name: 'Show values less than'}) ).toBeInTheDocument(); }); it('show appropriate actions for release cells', async function () { renderComponent({eventView: view, handleCellAction, columnIndex: 3}); await openMenu(); expect( screen.getByRole('menuitemradio', {name: 'Go to release'}) ).toBeInTheDocument(); }); it('show appropriate actions for empty release cells', async function () { renderComponent({ eventView: view, handleCellAction, columnIndex: 3, // TODO: Fix this type // @ts-ignore data: {...defaultData, release: null}, }); await openMenu(); expect( screen.queryByRole('menuitemradio', {name: 'Go to release'}) ).not.toBeInTheDocument(); }); it('show appropriate actions for measurement cells', async function () { renderComponent({eventView: view, handleCellAction, columnIndex: 5}); await openMenu(); expect( screen.queryByRole('menuitemradio', {name: 'Add to filter'}) ).not.toBeInTheDocument(); expect( screen.queryByRole('menuitemradio', {name: 'Exclude from filter'}) ).not.toBeInTheDocument(); expect( screen.getByRole('menuitemradio', {name: 'Show values greater than'}) ).toBeInTheDocument(); expect( screen.getByRole('menuitemradio', {name: 'Show values less than'}) ).toBeInTheDocument(); }); it('show appropriate actions for empty measurement cells', async function () { renderComponent({ eventView: view, handleCellAction, columnIndex: 5, data: { ...defaultData, // TODO: Fix this type // @ts-ignore 'measurements.fcp': null, }, }); await openMenu(); expect( screen.getByRole('menuitemradio', {name: 'Add to filter'}) ).toBeInTheDocument(); expect( screen.getByRole('menuitemradio', {name: 'Exclude from filter'}) ).toBeInTheDocument(); expect( screen.queryByRole('menuitemradio', {name: 'Show values greater than'}) ).not.toBeInTheDocument(); expect( screen.queryByRole('menuitemradio', {name: 'Show values less than'}) ).not.toBeInTheDocument(); }); it('show appropriate actions for numeric function cells', async function () { renderComponent({eventView: view, handleCellAction, columnIndex: 6}); await openMenu(); expect( screen.getByRole('menuitemradio', {name: 'Show values greater than'}) ).toBeInTheDocument(); expect( screen.getByRole('menuitemradio', {name: 'Show values less than'}) ).toBeInTheDocument(); }); it('show appropriate actions for empty numeric function cells', function () { renderComponent({ eventView: view, handleCellAction, columnIndex: 6, data: { ...defaultData, // TODO: Fix this type // @ts-ignore 'percentile(measurements.fcp, 0.5)': null, }, }); expect(screen.queryByRole('button', {name: 'Actions'})).not.toBeInTheDocument(); }); }); }); describe('updateQuery()', function () { const columnA: TableColumn = { key: 'a', name: 'a', type: 'number', isSortable: false, column: { kind: 'field', field: 'a', }, width: -1, }; const columnB: TableColumn = { key: 'b', name: 'b', type: 'number', isSortable: false, column: { kind: 'field', field: 'b', }, width: -1, }; it('modifies the query with has/!has', function () { let results = new MutableSearch([]); // TODO: Fix this type // @ts-ignore updateQuery(results, Actions.ADD, columnA, null); expect(results.formatString()).toEqual('!has:a'); // TODO: Fix this type // @ts-ignore updateQuery(results, Actions.EXCLUDE, columnA, null); expect(results.formatString()).toEqual('has:a'); // TODO: Fix this type // @ts-ignore updateQuery(results, Actions.ADD, columnA, null); expect(results.formatString()).toEqual('!has:a'); results = new MutableSearch([]); // TODO: Fix this type // @ts-ignore updateQuery(results, Actions.ADD, columnA, [null]); expect(results.formatString()).toEqual('!has:a'); }); it('modifies the query with additions', function () { const results = new MutableSearch([]); updateQuery(results, Actions.ADD, columnA, '1'); expect(results.formatString()).toEqual('a:1'); updateQuery(results, Actions.ADD, columnB, '1'); expect(results.formatString()).toEqual('a:1 b:1'); updateQuery(results, Actions.ADD, columnA, '2'); expect(results.formatString()).toEqual('b:1 a:2'); updateQuery(results, Actions.ADD, columnA, ['1', '2', '3']); expect(results.formatString()).toEqual('b:1 a:2 a:1 a:3'); }); it('modifies the query with exclusions', function () { const results = new MutableSearch([]); updateQuery(results, Actions.EXCLUDE, columnA, '1'); expect(results.formatString()).toEqual('!a:1'); updateQuery(results, Actions.EXCLUDE, columnB, '1'); expect(results.formatString()).toEqual('!a:1 !b:1'); updateQuery(results, Actions.EXCLUDE, columnA, '2'); expect(results.formatString()).toEqual('!b:1 !a:1 !a:2'); updateQuery(results, Actions.EXCLUDE, columnA, ['1', '2', '3']); expect(results.formatString()).toEqual('!b:1 !a:1 !a:2 !a:3'); }); it('modifies the query with a mix of additions and exclusions', function () { const results = new MutableSearch([]); updateQuery(results, Actions.ADD, columnA, '1'); expect(results.formatString()).toEqual('a:1'); updateQuery(results, Actions.ADD, columnB, '2'); expect(results.formatString()).toEqual('a:1 b:2'); updateQuery(results, Actions.EXCLUDE, columnA, '3'); expect(results.formatString()).toEqual('b:2 !a:3'); updateQuery(results, Actions.EXCLUDE, columnB, '4'); expect(results.formatString()).toEqual('!a:3 !b:4'); results.addFilterValues('!a', ['*dontescapeme*'], false); expect(results.formatString()).toEqual('!a:3 !b:4 !a:*dontescapeme*'); updateQuery(results, Actions.EXCLUDE, columnA, '*escapeme*'); expect(results.formatString()).toEqual( '!b:4 !a:3 !a:*dontescapeme* !a:"\\*escapeme\\*"' ); updateQuery(results, Actions.ADD, columnA, '5'); expect(results.formatString()).toEqual('!b:4 a:5'); updateQuery(results, Actions.ADD, columnB, '6'); expect(results.formatString()).toEqual('a:5 b:6'); }); it('modifies the query with greater/less than', function () { const results = new MutableSearch([]); updateQuery(results, Actions.SHOW_GREATER_THAN, columnA, 1); expect(results.formatString()).toEqual('a:>1'); updateQuery(results, Actions.SHOW_GREATER_THAN, columnA, 2); expect(results.formatString()).toEqual('a:>2'); updateQuery(results, Actions.SHOW_LESS_THAN, columnA, 3); expect(results.formatString()).toEqual('a:<3'); updateQuery(results, Actions.SHOW_LESS_THAN, columnA, 4); expect(results.formatString()).toEqual('a:<4'); }); it('modifies the query with greater/less than on duration fields', function () { const columnADuration: TableColumn = { ...columnA, type: 'duration', }; const results = new MutableSearch([]); updateQuery(results, Actions.SHOW_GREATER_THAN, columnADuration, 1); expect(results.formatString()).toEqual('a:>1.00ms'); updateQuery(results, Actions.SHOW_GREATER_THAN, columnADuration, 2); expect(results.formatString()).toEqual('a:>2.00ms'); updateQuery(results, Actions.SHOW_LESS_THAN, columnADuration, 3); expect(results.formatString()).toEqual('a:<3.00ms'); updateQuery(results, Actions.SHOW_LESS_THAN, columnADuration, 4.1234); expect(results.formatString()).toEqual('a:<4.12ms'); }); it('does not error for special actions', function () { const results = new MutableSearch([]); updateQuery(results, Actions.RELEASE, columnA, ''); updateQuery(results, Actions.DRILLDOWN, columnA, ''); }); it('errors for unknown actions', function () { const results = new MutableSearch([]); // TODO: Fix this type // @ts-ignore expect(() => updateQuery(results, 'unknown', columnA, '')).toThrow(); }); });