diff --git a/static/app/views/explore/logs/logsSidebarContext.tsx b/static/app/views/explore/logs/logsSidebarContext.tsx new file mode 100644 index 00000000000000..4e3e6ed75312cc --- /dev/null +++ b/static/app/views/explore/logs/logsSidebarContext.tsx @@ -0,0 +1,36 @@ +import {createContext, useContext, useMemo} from 'react'; + +interface LogsSidebarContextValue { + setSidebarOpen: (open: boolean) => void; + sidebarOpen: boolean; +} + +const LogsSidebarContext = createContext(null); + +interface LogsSidebarProviderProps { + children: React.ReactNode; + setSidebarOpen: (open: boolean) => void; + sidebarOpen: boolean; +} + +export function LogsSidebarProvider({ + children, + sidebarOpen, + setSidebarOpen, +}: LogsSidebarProviderProps) { + const value = useMemo( + () => ({sidebarOpen, setSidebarOpen}), + [sidebarOpen, setSidebarOpen] + ); + return ( + {children} + ); +} + +/** + * Returns the logs sidebar context, or null when there is no surrounding + * `LogsSidebarProvider` (e.g. embedded usages outside the logs tab). + */ +export function useLogsSidebar() { + return useContext(LogsSidebarContext); +} diff --git a/static/app/views/explore/logs/logsTab.tsx b/static/app/views/explore/logs/logsTab.tsx index f73a52c75388c2..2ef2cbb9ebcd04 100644 --- a/static/app/views/explore/logs/logsTab.tsx +++ b/static/app/views/explore/logs/logsTab.tsx @@ -1,4 +1,4 @@ -import {Fragment, memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import styled from '@emotion/styled'; import {useQueryClient} from '@tanstack/react-query'; @@ -58,6 +58,7 @@ import {LogsExportSwitch} from 'sentry/views/explore/logs/exports/logsExportSwit import {AutorefreshToggle} from 'sentry/views/explore/logs/logsAutoRefresh'; import {LogsDownSamplingAlert} from 'sentry/views/explore/logs/logsDownsamplingAlert'; import {LogsGraph} from 'sentry/views/explore/logs/logsGraph'; +import {LogsSidebarProvider} from 'sentry/views/explore/logs/logsSidebarContext'; import {LogsTabSeerComboBox} from 'sentry/views/explore/logs/logsTabSeerComboBox'; import {LogsToolbar} from 'sentry/views/explore/logs/logsToolbar'; import { @@ -444,7 +445,7 @@ function LogsTabContentInner({datePageFilterProps, tableExpando}: LogsTabProps) const {infiniteLogsQueryResult} = useLogsPageData(); return ( - + - + ); } diff --git a/static/app/views/explore/logs/tables/logsTableRow.spec.tsx b/static/app/views/explore/logs/tables/logsTableRow.spec.tsx index 4381194355952b..9bb399074e2c87 100644 --- a/static/app/views/explore/logs/tables/logsTableRow.spec.tsx +++ b/static/app/views/explore/logs/tables/logsTableRow.spec.tsx @@ -17,11 +17,15 @@ import { import {PageFiltersStore} from 'sentry/components/pageFilters/store'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import {LogsAnalyticsPageSource} from 'sentry/utils/analytics/logsAnalyticsEvent'; -import {LOGS_FIELDS_KEY} from 'sentry/views/explore/contexts/logs/logsPageParams'; +import { + LOGS_FIELDS_KEY, + LOGS_GROUP_BY_KEY, +} from 'sentry/views/explore/contexts/logs/logsPageParams'; import {LOGS_SORT_BYS_KEY} from 'sentry/views/explore/contexts/logs/sortBys'; import {type TraceItemResponseAttribute} from 'sentry/views/explore/hooks/useTraceItemDetails'; import {DEFAULT_TRACE_ITEM_HOVER_TIMEOUT} from 'sentry/views/explore/logs/constants'; import {LogsQueryParamsProvider} from 'sentry/views/explore/logs/logsQueryParamsProvider'; +import {LogsSidebarProvider} from 'sentry/views/explore/logs/logsSidebarContext'; import {LogRowContent} from 'sentry/views/explore/logs/tables/logsTableRow'; import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; @@ -577,6 +581,146 @@ describe('logsTableRow', () => { expect(copiedUrl).toContain('logsQuery=id%3A1'); }); + it('adds a group by from the attribute actions menu', async () => { + const {router} = render( + , + {organization, initialRouterConfig, additionalWrapper: ProviderWrapper} + ); + + const logTableRow = await screen.findByTestId('log-table-row'); + await userEvent.click(logTableRow); + + await waitFor(() => { + expect(rowDetailsMock).toHaveBeenCalledTimes(1); + }); + + const severityRow = await screen.findByTestId('tree-key-severity'); + const attributeTreeRow = severityRow.closest('[data-test-id="attribute-tree-row"]')!; + expect(attributeTreeRow).toBeInTheDocument(); + + await userEvent.hover(attributeTreeRow); + await userEvent.click( + within(attributeTreeRow as HTMLElement).getByRole('button', { + name: 'Attribute Actions Menu', + }) + ); + + const groupByItem = await screen.findByRole('menuitemradio', {name: 'Group by'}); + await userEvent.click(groupByItem); + + expect(router.location.query).toEqual( + expect.objectContaining({ + mode: 'aggregate', + aggregateField: expect.arrayContaining(['{"groupBy":"severity"}']), + }) + ); + }); + + it('opens the sidebar when group by is clicked', async () => { + const setSidebarOpen = jest.fn(); + + function SidebarWrapper({children}: {children?: React.ReactNode}) { + return ( + + + + {children} +
+
+
+ ); + } + + render( + , + {organization, initialRouterConfig, additionalWrapper: SidebarWrapper} + ); + + const logTableRow = await screen.findByTestId('log-table-row'); + await userEvent.click(logTableRow); + + await waitFor(() => { + expect(rowDetailsMock).toHaveBeenCalledTimes(1); + }); + + const severityRow = await screen.findByTestId('tree-key-severity'); + const attributeTreeRow = severityRow.closest('[data-test-id="attribute-tree-row"]')!; + await userEvent.hover(attributeTreeRow); + await userEvent.click( + within(attributeTreeRow as HTMLElement).getByRole('button', { + name: 'Attribute Actions Menu', + }) + ); + + await userEvent.click(await screen.findByRole('menuitemradio', {name: 'Group by'})); + + expect(setSidebarOpen).toHaveBeenCalledWith(true); + }); + + it('disables the group by menu item when the attribute is already grouped by', async () => { + render( + , + { + organization, + initialRouterConfig: { + ...initialRouterConfig, + location: { + ...initialRouterConfig.location, + query: { + ...initialRouterConfig.location.query, + mode: 'aggregate', + [LOGS_GROUP_BY_KEY]: 'severity', + }, + }, + }, + additionalWrapper: ProviderWrapper, + } + ); + + const logTableRow = await screen.findByTestId('log-table-row'); + await userEvent.click(logTableRow); + + await waitFor(() => { + expect(rowDetailsMock).toHaveBeenCalledTimes(1); + }); + + const severityRow = await screen.findByTestId('tree-key-severity'); + const attributeTreeRow = severityRow.closest('[data-test-id="attribute-tree-row"]')!; + await userEvent.hover(attributeTreeRow); + await userEvent.click( + within(attributeTreeRow as HTMLElement).getByRole('button', { + name: 'Attribute Actions Menu', + }) + ); + + const groupByItem = await screen.findByRole('menuitemradio', {name: 'Group by'}); + expect(groupByItem).toHaveAttribute('aria-disabled', 'true'); + }); + it('does not toggle row when clicking cell action menu items', async () => { const mockWriteText = jest.fn().mockResolvedValue(undefined); Object.defineProperty(window.navigator, 'clipboard', { diff --git a/static/app/views/explore/logs/useLogAttributesTreeActions.tsx b/static/app/views/explore/logs/useLogAttributesTreeActions.tsx index 9e1553857668af..e29546201f65eb 100644 --- a/static/app/views/explore/logs/useLogAttributesTreeActions.tsx +++ b/static/app/views/explore/logs/useLogAttributesTreeActions.tsx @@ -3,19 +3,26 @@ import {useCallback} from 'react'; import type {MenuItemProps} from 'sentry/components/dropdownMenu'; import {t} from 'sentry/locale'; import type {AttributesTreeContent} from 'sentry/views/explore/components/traceItemAttributes/attributesTree'; +import {useLogsSidebar} from 'sentry/views/explore/logs/logsSidebarContext'; import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; import { useQueryParamsFields, + useQueryParamsGroupBys, useQueryParamsSearch, useSetQueryParamsFields, + useSetQueryParamsGroupBys, useSetQueryParamsSearch, } from 'sentry/views/explore/queryParams/context'; +import {Mode} from 'sentry/views/explore/queryParams/mode'; export function useLogAttributesTreeActions({embedded}: {embedded: boolean}) { const setLogsSearch = useSetQueryParamsSearch(); const search = useQueryParamsSearch(); const fields = useQueryParamsFields(); const setLogFields = useSetQueryParamsFields(); + const groupBys = useQueryParamsGroupBys(); + const setGroupBys = useSetQueryParamsGroupBys(); + const sidebar = useLogsSidebar(); const addSearchFilter = useCallback( (content: AttributesTreeContent, negated?: boolean) => { @@ -50,6 +57,26 @@ export function useLogAttributesTreeActions({embedded}: {embedded: boolean}) { [setLogFields, fields] ); + const addGroupBy = useCallback( + (content: AttributesTreeContent) => { + const originalAttribute = content.originalAttribute; + if (!originalAttribute) { + return; + } + const key = originalAttribute.original_attribute_key; + // Drop empty placeholder group bys, dedupe, then append the new key. + const newGroupBys = groupBys.filter(Boolean); + if (!newGroupBys.includes(key)) { + newGroupBys.push(key); + } + setGroupBys(newGroupBys, Mode.AGGREGATE); + // Reveal the Visualize / Group By controls so the user can see the + // grouping they just added. + sidebar?.setSidebarOpen(true); + }, + [setGroupBys, groupBys, sidebar] + ); + return (content: AttributesTreeContent) => { if (!content.originalAttribute) { return []; @@ -73,6 +100,13 @@ export function useLogAttributesTreeActions({embedded}: {embedded: boolean}) { disabled: fields.includes(content.originalAttribute.original_attribute_key), onAction: () => addColumn(content), }, + { + key: 'add-group-by', + label: t('Group by'), + hidden: embedded, + disabled: groupBys.includes(content.originalAttribute.original_attribute_key), + onAction: () => addGroupBy(content), + }, ]; return items;