From 0abd945bd463949b78321bcfc09c04c8a9328143 Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Fri, 22 Nov 2024 17:17:51 -0500 Subject: [PATCH] feat(explore): Convert buttons into context menu --- static/app/views/explore/charts/index.tsx | 68 ++----------- .../components/addToDashboardButton.tsx | 97 ------------------- .../explore/components/chartContextMenu.tsx | 96 ++++++++++++++++++ .../useAddToDashboard.spec.tsx} | 30 +++--- .../views/explore/hooks/useAddToDashboard.tsx | 89 +++++++++++++++++ static/app/views/explore/hooks/useSorts.tsx | 58 +++++------ 6 files changed, 238 insertions(+), 200 deletions(-) delete mode 100644 static/app/views/explore/components/addToDashboardButton.tsx create mode 100644 static/app/views/explore/components/chartContextMenu.tsx rename static/app/views/explore/{components/addToDashboardButton.spec.tsx => hooks/useAddToDashboard.spec.tsx} (90%) create mode 100644 static/app/views/explore/hooks/useAddToDashboard.tsx diff --git a/static/app/views/explore/charts/index.tsx b/static/app/views/explore/charts/index.tsx index 3adafeeb16f32a..9d6adf3548558c 100644 --- a/static/app/views/explore/charts/index.tsx +++ b/static/app/views/explore/charts/index.tsx @@ -2,13 +2,11 @@ import type {Dispatch, SetStateAction} from 'react'; import {Fragment, useCallback, useEffect, useMemo} from 'react'; import styled from '@emotion/styled'; -import Feature from 'sentry/components/acl/feature'; import {getInterval} from 'sentry/components/charts/utils'; import {CompactSelect} from 'sentry/components/compactSelect'; -import {DropdownMenu} from 'sentry/components/dropdownMenu'; import {Tooltip} from 'sentry/components/tooltip'; import {CHART_PALETTE} from 'sentry/constants/chartPalette'; -import {IconClock, IconGraph, IconSubscribed} from 'sentry/icons'; +import {IconClock, IconGraph} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {dedupeArray} from 'sentry/utils/dedupeArray'; @@ -18,12 +16,9 @@ import { prettifyParsedFunction, } from 'sentry/utils/discover/fields'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; -import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; -import useProjects from 'sentry/utils/useProjects'; import {formatVersion} from 'sentry/utils/versions/formatVersion'; -import {Dataset} from 'sentry/views/alerts/rules/metric/types'; -import {AddToDashboardButton} from 'sentry/views/explore/components/addToDashboardButton'; +import ChartContextMenu from 'sentry/views/explore/components/chartContextMenu'; import {useChartInterval} from 'sentry/views/explore/hooks/useChartInterval'; import {useDataset} from 'sentry/views/explore/hooks/useDataset'; import {useVisualizes} from 'sentry/views/explore/hooks/useVisualizes'; @@ -33,7 +28,6 @@ import Chart, { } from 'sentry/views/insights/common/components/chart'; import ChartPanel from 'sentry/views/insights/common/components/chartPanel'; import {useSortedTimeSeries} from 'sentry/views/insights/common/queries/useSortedTimeSeries'; -import {getAlertsUrl} from 'sentry/views/insights/common/utils/getAlertsUrl'; import {CHART_HEIGHT} from 'sentry/views/insights/database/settings'; import {useGroupBys} from '../hooks/useGroupBys'; @@ -67,9 +61,6 @@ export const EXPLORE_CHART_GROUP = 'explore-charts_group'; // TODO: Update to support aggregate mode and multiple queries / visualizations export function ExploreCharts({query, setError}: ExploreChartsProps) { const pageFilters = usePageFilters(); - const organization = useOrganization(); - const {projects} = useProjects(); - const [dataset] = useDataset(); const [visualizes, setVisualizes] = useVisualizes(); const [interval, setInterval, intervalOptions] = useChartInterval(); @@ -184,29 +175,6 @@ export function ExploreCharts({query, setError}: ExploreChartsProps) { ? 'area' : 'bar'; - const project = - projects.length === 1 - ? projects[0] - : projects.find(p => p.id === `${pageFilters.selection.projects[0]}`); - const singleProject = - (pageFilters.selection.projects.length === 1 || projects.length === 1) && - project; - const alertsUrls = singleProject - ? visualizeYAxes.map(yAxis => ({ - key: yAxis, - label: yAxis, - to: getAlertsUrl({ - project, - query, - pageFilters: pageFilters.selection, - aggregate: yAxis, - orgSlug: organization.slug, - dataset: Dataset.EVENTS_ANALYTICS_PLATFORM, - interval, - }), - })) - : undefined; - const data = getSeries(dedupedYAxes, formattedYAxes); const outputTypes = new Set( @@ -251,32 +219,12 @@ export function ExploreCharts({query, setError}: ExploreChartsProps) { options={intervalOptions} /> - - - , - }} - position="bottom-end" - items={alertsUrls ?? []} - menuTitle={t('Create an alert for')} - isDisabled={!alertsUrls || alertsUrls.length === 0} - /> - - - - - + visualizes[visualizeIndex].yAxes.slice(0, MAX_NUM_Y_AXES), - [visualizes, visualizeIndex] - ); - const fields = useMemo(() => { - if (resultMode === 'samples') { - return sampleFields.filter(Boolean); - } - - return [...groupBys, ...yAxes].filter(Boolean); - }, [groupBys, resultMode, sampleFields, yAxes]); - const [sorts] = useSorts({fields}); - const [query] = useUserQuery(); - - const discoverQuery: NewQuery = useMemo(() => { - const search = new MutableSearch(query); - - return { - name: t('Custom Explore Widget'), - fields, - orderby: sorts.map(formatSort), - query: search.formatString(), - version: 2, - dataset, - yAxis: yAxes, - }; - }, [dataset, fields, sorts, query, yAxes]); - - const eventView = useMemo(() => { - const newEventView = EventView.fromNewQueryWithPageFilters(discoverQuery, selection); - newEventView.dataset = dataset; - return newEventView; - }, [discoverQuery, selection, dataset]); - - const handleAddToDashboard = useCallback(() => { - handleAddQueryToDashboard({ - organization, - location, - eventView, - router, - yAxis: eventView.yAxis, - widgetType: WidgetType.SPANS, - }); - }, [organization, location, eventView, router]); - - return ( - - ; +} + describe('AddToDashboardButton', () => { beforeEach(() => { jest.clearAllMocks(); @@ -29,15 +34,10 @@ describe('AddToDashboardButton', () => { jest.mocked(useResultMode).mockReturnValue(['samples', jest.fn()]); }); - it('renders', async () => { - render(); - await userEvent.hover(screen.getByLabelText('Add to Dashboard')); - expect(await screen.findByText('Add to Dashboard')).toBeInTheDocument(); - }); - it('opens the dashboard modal with the correct query for samples mode', async () => { - render(); - await userEvent.click(screen.getByLabelText('Add to Dashboard')); + render(); + + await userEvent.click(screen.getByText('Add to Dashboard')); // The table columns are encoded as the fields for the defaultWidgetQuery expect(openAddToDashboardModal).toHaveBeenCalledWith( @@ -107,8 +107,8 @@ describe('AddToDashboardButton', () => { jest.fn(), ]); - render(); - await userEvent.click(screen.getByLabelText('Add to Dashboard')); + render(); + await userEvent.click(screen.getByText('Add to Dashboard')); // The group by and the yAxes are encoded as the fields for the defaultTableQuery expect(openAddToDashboardModal).toHaveBeenCalledWith( @@ -163,8 +163,8 @@ describe('AddToDashboardButton', () => { it('uses the yAxes for the aggregate mode', async () => { jest.mocked(useResultMode).mockReturnValue(['aggregate', jest.fn()]); - render(); - await userEvent.click(screen.getByLabelText('Add to Dashboard')); + render(); + await userEvent.click(screen.getByText('Add to Dashboard')); expect(openAddToDashboardModal).toHaveBeenCalledWith( expect.objectContaining({ @@ -219,8 +219,8 @@ describe('AddToDashboardButton', () => { jest.fn(), ]); - render(); - await userEvent.click(screen.getByLabelText('Add to Dashboard')); + render(); + await userEvent.click(screen.getByText('Add to Dashboard')); expect(openAddToDashboardModal).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/static/app/views/explore/hooks/useAddToDashboard.tsx b/static/app/views/explore/hooks/useAddToDashboard.tsx new file mode 100644 index 00000000000000..d50a54eb26722d --- /dev/null +++ b/static/app/views/explore/hooks/useAddToDashboard.tsx @@ -0,0 +1,89 @@ +import {useCallback} from 'react'; + +import {t} from 'sentry/locale'; +import type {NewQuery} from 'sentry/types/organization'; +import EventView from 'sentry/utils/discover/eventView'; +import {MutableSearch} from 'sentry/utils/tokenizeSearch'; +import {useLocation} from 'sentry/utils/useLocation'; +import useOrganization from 'sentry/utils/useOrganization'; +import usePageFilters from 'sentry/utils/usePageFilters'; +import useRouter from 'sentry/utils/useRouter'; +import {WidgetType} from 'sentry/views/dashboards/types'; +import {MAX_NUM_Y_AXES} from 'sentry/views/dashboards/widgetBuilder/buildSteps/yAxisStep/yAxisSelector'; +import {handleAddQueryToDashboard} from 'sentry/views/discover/utils'; +import {useDataset} from 'sentry/views/explore/hooks/useDataset'; +import {useGroupBys} from 'sentry/views/explore/hooks/useGroupBys'; +import {useResultMode} from 'sentry/views/explore/hooks/useResultsMode'; +import {useSampleFields} from 'sentry/views/explore/hooks/useSampleFields'; +import {calculateSorts} from 'sentry/views/explore/hooks/useSorts'; +import {useUserQuery} from 'sentry/views/explore/hooks/useUserQuery'; +import {useVisualizes} from 'sentry/views/explore/hooks/useVisualizes'; +import {formatSort} from 'sentry/views/explore/tables/aggregatesTable'; + +export function useAddToDashboard() { + const location = useLocation(); + const router = useRouter(); + const {selection} = usePageFilters(); + const organization = useOrganization(); + + const [resultMode] = useResultMode(); + const [dataset] = useDataset(); + const {groupBys} = useGroupBys(); + const [visualizes] = useVisualizes(); + const [sampleFields] = useSampleFields(); + const [query] = useUserQuery(); + + const getEventView = useCallback( + (visualizeIndex: number) => { + const yAxes = visualizes[visualizeIndex].yAxes.slice(0, MAX_NUM_Y_AXES); + + let fields; + if (resultMode === 'samples') { + fields = sampleFields.filter(Boolean); + } else { + fields = [...groupBys, ...yAxes].filter(Boolean); + } + + const search = new MutableSearch(query); + const sorts = calculateSorts(fields, location); + + const discoverQuery: NewQuery = { + name: t('Custom Explore Widget'), + fields, + orderby: sorts.map(formatSort), + query: search.formatString(), + version: 2, + dataset, + yAxis: yAxes, + }; + + const newEventView = EventView.fromNewQueryWithPageFilters( + discoverQuery, + selection + ); + newEventView.dataset = dataset; + return newEventView; + }, + [visualizes, resultMode, sampleFields, groupBys, query, location, dataset, selection] + ); + + const addToDashboard = useCallback( + (visualizeIndex: number) => { + const eventView = getEventView(visualizeIndex); + + handleAddQueryToDashboard({ + organization, + location, + eventView, + router, + yAxis: eventView.yAxis, + widgetType: WidgetType.SPANS, + }); + }, + [organization, location, getEventView, router] + ); + + return { + addToDashboard, + }; +} diff --git a/static/app/views/explore/hooks/useSorts.tsx b/static/app/views/explore/hooks/useSorts.tsx index 67f83410b3bf4c..cbb113ce9e9ab2 100644 --- a/static/app/views/explore/hooks/useSorts.tsx +++ b/static/app/views/explore/hooks/useSorts.tsx @@ -30,34 +30,7 @@ function useSortsImpl({ location, navigate, }: ImplOptions): [Sort[], (newSorts: Sort[]) => void] { - const sorts = useMemo(() => { - const rawSorts = decodeSorts(location.query.sort); - - // Try to assign a default sort if possible - if (!rawSorts.length || !rawSorts.some(rawSort => fields.includes(rawSort.field))) { - if (fields.includes('timestamp')) { - return [ - { - field: 'timestamp', - kind: 'desc' as const, - }, - ]; - } - - if (fields.length) { - return [ - { - field: fields[0], - kind: 'desc' as const, - }, - ]; - } - - return []; - } - - return rawSorts; - }, [fields, location.query.sort]); + const sorts = useMemo(() => calculateSorts(fields, location), [fields, location]); const setSort = useCallback( (newSorts: Sort[]) => { @@ -77,3 +50,32 @@ function useSortsImpl({ return [sorts, setSort]; } + +export function calculateSorts(fields: Field[], location: Location) { + const rawSorts = decodeSorts(location.query.sort); + + // Try to assign a default sort if possible + if (!rawSorts.length || !rawSorts.some(rawSort => fields.includes(rawSort.field))) { + if (fields.includes('timestamp')) { + return [ + { + field: 'timestamp', + kind: 'desc' as const, + }, + ]; + } + + if (fields.length) { + return [ + { + field: fields[0], + kind: 'desc' as const, + }, + ]; + } + + return []; + } + + return rawSorts; +}