diff --git a/static/app/views/discover/table/cellAction.spec.tsx b/static/app/views/discover/table/cellAction.spec.tsx index 6da1a1162e44..82172e304461 100644 --- a/static/app/views/discover/table/cellAction.spec.tsx +++ b/static/app/views/discover/table/cellAction.spec.tsx @@ -5,7 +5,12 @@ 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 {Actions, CellAction, updateQuery} from 'sentry/views/discover/table/cellAction'; +import { + Actions, + ActionTriggerType, + CellAction, + updateQuery, +} from 'sentry/views/discover/table/cellAction'; import type {TableColumn} from 'sentry/views/discover/table/types'; const defaultData: TableDataRow = { @@ -32,6 +37,8 @@ function renderComponent({ handleCellAction = jest.fn(), columnIndex = 0, data = defaultData, + pin, + triggerType, }: { eventView: EventView; columnIndex?: number; @@ -40,12 +47,16 @@ function renderComponent({ action: Actions, value: string | number | null[] | string[] | null ) => void; + pin?: React.ReactNode; + triggerType?: ActionTriggerType; }) { return render( some content @@ -389,6 +400,28 @@ describe('Discover -> CellAction', () => { expect(screen.queryByRole('button', {name: 'Actions'})).not.toBeInTheDocument(); }); }); + + describe('pin prop', () => { + it('renders the pin element with the bold hover trigger', () => { + renderComponent({ + eventView: view, + triggerType: ActionTriggerType.BOLD_HOVER, + pin: , + }); + + expect(screen.getByRole('button', {name: 'pin me'})).toBeInTheDocument(); + }); + + it('renders the pin element with the ellipsis trigger', () => { + renderComponent({ + eventView: view, + triggerType: ActionTriggerType.ELLIPSIS, + pin: , + }); + + expect(screen.getByRole('button', {name: 'pin me'})).toBeInTheDocument(); + }); + }); }); describe('updateQuery()', () => { diff --git a/static/app/views/discover/table/cellAction.tsx b/static/app/views/discover/table/cellAction.tsx index eb10d660332f..3f2af1bb344f 100644 --- a/static/app/views/discover/table/cellAction.tsx +++ b/static/app/views/discover/table/cellAction.tsx @@ -348,11 +348,13 @@ export enum ActionTriggerType { } type Props = React.PropsWithoutRef> & { + pin?: React.ReactNode; triggerType?: ActionTriggerType; usePortalOnDropdown?: boolean; }; export function CellAction({ + pin, triggerType = ActionTriggerType.BOLD_HOVER, allowActions, usePortalOnDropdown, @@ -373,6 +375,7 @@ export function CellAction({ if (triggerType === ActionTriggerType.BOLD_HOVER) { return ( {cellActions?.length ? ( @@ -428,12 +431,16 @@ export function CellAction({ ) : ( children )} + {pin} ); } return ( - + {children} {cellActions?.length && ( )} + {pin} ); } -const Container = styled('div')` +const Container = styled('div')<{containsPin?: boolean}>` + --logsPinButtonArea: 2rem; position: relative; - width: 100%; + width: ${p => + p.containsPin + ? `calc(100% - var(--logsPinButtonArea) + ${p.theme.space.md})` + : `100%`}; height: 100%; display: flex; flex-direction: column; diff --git a/static/app/views/explore/logs/pinning/PinnedLogs.spec.tsx b/static/app/views/explore/logs/pinning/PinnedLogs.spec.tsx new file mode 100644 index 000000000000..19bcd94bccda --- /dev/null +++ b/static/app/views/explore/logs/pinning/PinnedLogs.spec.tsx @@ -0,0 +1,135 @@ +import {LogFixture} from 'sentry-fixture/log'; +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import { + render, + screen, + userEvent, + type RenderOptions, +} from 'sentry-test/reactTestingLibrary'; + +import {PinnedLogs} from 'sentry/views/explore/logs/pinning/PinnedLogs'; +import {LogsPinningProvider} from 'sentry/views/explore/logs/pinning/useLogsPinning'; +import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; +import type {LogTableRowItem} from 'sentry/views/explore/logs/utils'; + +const allRows: LogTableRowItem[] = [ + LogFixture({ + [OurLogKnownFieldKey.ID]: 'log-1', + [OurLogKnownFieldKey.PROJECT_ID]: '1', + [OurLogKnownFieldKey.ORGANIZATION_ID]: 1, + [OurLogKnownFieldKey.MESSAGE]: 'first pinned log', + }), + LogFixture({ + [OurLogKnownFieldKey.ID]: 'log-2', + [OurLogKnownFieldKey.PROJECT_ID]: '1', + [OurLogKnownFieldKey.ORGANIZATION_ID]: 1, + [OurLogKnownFieldKey.MESSAGE]: 'second pinned log', + }), +]; + +const renderRow = (dataRow: LogTableRowItem) => ( + + {dataRow[OurLogKnownFieldKey.MESSAGE]} + +); + +function renderPinnedLogs(options: RenderOptions = {}) { + return render( + + + + +
, + { + organization: OrganizationFixture({features: ['ourlogs-pinning']}), + ...options, + } + ); +} + +describe('PinnedLogs', () => { + it('renders nothing when the feature is disabled even if rows are pinned', () => { + renderPinnedLogs({ + organization: OrganizationFixture({features: []}), + initialRouterConfig: { + location: {pathname: '/', query: {logsPinned: 'log-1'}}, + }, + }); + + expect(screen.queryByRole('toolbar')).not.toBeInTheDocument(); + }); + + it('renders nothing when no rows are pinned', () => { + renderPinnedLogs(); + + expect(screen.queryByRole('toolbar')).not.toBeInTheDocument(); + }); + + it('renders the pinned row when its id is present in allRows', () => { + renderPinnedLogs({ + initialRouterConfig: { + location: {pathname: '/', query: {logsPinned: 'log-1'}}, + }, + }); + + expect(screen.getByTestId('pinned-row-log-1')).toBeInTheDocument(); + }); + + it('does not render a row when the pinned id is missing from allRows', () => { + renderPinnedLogs({ + initialRouterConfig: { + location: {pathname: '/', query: {logsPinned: 'missing-log'}}, + }, + }); + + expect(screen.queryByTestId('pinned-row-missing-log')).not.toBeInTheDocument(); + }); + + it('shows the count of pinned rows in the collapse toggle label', () => { + renderPinnedLogs({ + initialRouterConfig: { + location: {pathname: '/', query: {logsPinned: ['log-1', 'log-2']}}, + }, + }); + + expect(screen.getByRole('button', {name: 'Collapse 2 pinned'})).toBeInTheDocument(); + }); + + it('hides the rendered pinned rows when the collapse button is clicked', async () => { + renderPinnedLogs({ + initialRouterConfig: { + location: {pathname: '/', query: {logsPinned: 'log-1'}}, + }, + }); + + await userEvent.click(screen.getByRole('button', {name: 'Collapse 1 pinned'})); + + expect(screen.queryByTestId('pinned-row-log-1')).not.toBeInTheDocument(); + }); + + it('shows the rendered pinned rows again when the toggle button is clicked twice', async () => { + renderPinnedLogs({ + initialRouterConfig: { + location: {pathname: '/', query: {logsPinned: 'log-1'}}, + }, + }); + + await userEvent.click(screen.getByRole('button', {name: 'Collapse 1 pinned'})); + await userEvent.click(screen.getByRole('button', {name: 'Expand 1 pinned'})); + + expect(screen.getByTestId('pinned-row-log-1')).toBeInTheDocument(); + }); + + it('removes the rendered pinned rows when the Clear all button is clicked', async () => { + renderPinnedLogs({ + initialRouterConfig: { + location: {pathname: '/', query: {logsPinned: 'log-1'}}, + }, + }); + + await userEvent.click(screen.getByRole('button', {name: 'Clear all pins'})); + + expect(screen.queryByTestId('pinned-row-log-1')).not.toBeInTheDocument(); + }); +}); diff --git a/static/app/views/explore/logs/pinning/PinnedLogs.tsx b/static/app/views/explore/logs/pinning/PinnedLogs.tsx new file mode 100644 index 000000000000..a3938975a9d2 --- /dev/null +++ b/static/app/views/explore/logs/pinning/PinnedLogs.tsx @@ -0,0 +1,89 @@ +import {Fragment, useEffect, useState} from 'react'; +import styled from '@emotion/styled'; + +import {Button} from '@sentry/scraps/button'; +import {Flex} from '@sentry/scraps/layout'; + +import {GridRow} from 'sentry/components/tables/gridEditable/styles'; +import {IconChevron, IconClose} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {TableBody} from 'sentry/views/explore/components/table'; +import {useLogsPinning} from 'sentry/views/explore/logs/pinning/useLogsPinning'; +import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; +import type {LogTableRowItem} from 'sentry/views/explore/logs/utils'; + +interface Props { + allRows: LogTableRowItem[]; + renderRow: (dataRow: LogTableRowItem) => React.ReactNode; +} + +export function PinnedLogs({allRows, renderRow}: Props) { + const logsPinning = useLogsPinning(); + const [expanded, setExpanded] = useState(true); + const pinsCount = logsPinning?.pinnedRows.size; + + useEffect(() => { + if (!pinsCount) { + setExpanded(true); + } + }, [pinsCount]); + + if (!logsPinning || !pinsCount) { + return null; + } + + return ( + + {expanded && + Array.from(logsPinning.pinnedRows).map(rowId => { + const dataRow = allRows.find(datum => datum[OurLogKnownFieldKey.ID] === rowId); + + // TODO(LOGS-781): this is not correct yet because the virtualizer might not have found it yet. + // Will have to manually re-fetch data. + if (!dataRow) { + return null; + } + + return {renderRow(dataRow)}; + })} + + + + + + + + + + ); +} + +const PinnedTableBody = styled(TableBody)` + border-bottom: 1px solid ${p => p.theme.tokens.border.primary}; + height: max-content; + overflow-y: auto; + overflow-x: hidden; + scrollbar-gutter: stable; + scrollbar-width: thin; +`; + +const PinnedGridBodyCell = styled('td')` + grid-column: 1 / -1; + padding: ${p => p.theme.space.sm}; +`; diff --git a/static/app/views/explore/logs/pinning/useLogsPinning.spec.tsx b/static/app/views/explore/logs/pinning/useLogsPinning.spec.tsx new file mode 100644 index 000000000000..9851ae310bd3 --- /dev/null +++ b/static/app/views/explore/logs/pinning/useLogsPinning.spec.tsx @@ -0,0 +1,128 @@ +import {act, renderHookWithProviders} from 'sentry-test/reactTestingLibrary'; + +import { + LogsPinningProvider, + useLogsPinning, +} from 'sentry/views/explore/logs/pinning/useLogsPinning'; + +jest.mock('sentry/views/explore/logs/pinning/useOurLogsPinning', () => ({ + useOurLogsPinningEnabled: () => true, +})); + +describe('useLogsPinning', () => { + it('returns undefined when no LogsPinningProvider wraps the consumer', () => { + const {result} = renderHookWithProviders(() => useLogsPinning()); + + expect(result.current).toBeUndefined(); + }); + + it('starts with an empty pinnedRows when the location has no logsPinned query', () => { + const {result} = renderHookWithProviders(() => useLogsPinning(), { + additionalWrapper: LogsPinningProvider, + }); + + expect(result.current?.pinnedRows).toEqual(new Set()); + }); + + it('starts with a single id in pinnedRows when the location has a single logsPinned value', () => { + const {result} = renderHookWithProviders(() => useLogsPinning(), { + additionalWrapper: LogsPinningProvider, + initialRouterConfig: { + location: {pathname: '/', query: {logsPinned: 'log-1'}}, + }, + }); + + expect(result.current?.pinnedRows).toEqual(new Set(['log-1'])); + }); + + it('starts with multiple ids in pinnedRows when the location has multiple logsPinned values', () => { + const {result} = renderHookWithProviders(() => useLogsPinning(), { + additionalWrapper: LogsPinningProvider, + initialRouterConfig: { + location: {pathname: '/', query: {logsPinned: ['log-1', 'log-2']}}, + }, + }); + + expect(result.current?.pinnedRows).toEqual(new Set(['log-1', 'log-2'])); + }); + + it('filters out empty values from the logsPinned query when initializing pinnedRows', () => { + const {result} = renderHookWithProviders(() => useLogsPinning(), { + additionalWrapper: LogsPinningProvider, + initialRouterConfig: { + location: {pathname: '/', query: {logsPinned: ['log-1', '']}}, + }, + }); + + expect(result.current?.pinnedRows).toEqual(new Set(['log-1'])); + }); + + it('adds the id to pinnedRows when togglePinnedRow is called for an unpinned id', () => { + const {result} = renderHookWithProviders(() => useLogsPinning(), { + additionalWrapper: LogsPinningProvider, + }); + + act(() => { + result.current?.togglePinnedRow('log-1'); + }); + + expect(result.current?.pinnedRows).toEqual(new Set(['log-1'])); + }); + + it('removes the id from pinnedRows when togglePinnedRow is called for a pinned id', () => { + const {result} = renderHookWithProviders(() => useLogsPinning(), { + additionalWrapper: LogsPinningProvider, + initialRouterConfig: { + location: {pathname: '/', query: {logsPinned: 'log-1'}}, + }, + }); + + act(() => { + result.current?.togglePinnedRow('log-1'); + }); + + expect(result.current?.pinnedRows).toEqual(new Set()); + }); + + it('empties pinnedRows when clearPinnedRows is called', () => { + const {result} = renderHookWithProviders(() => useLogsPinning(), { + additionalWrapper: LogsPinningProvider, + initialRouterConfig: { + location: {pathname: '/', query: {logsPinned: ['log-1', 'log-2']}}, + }, + }); + + act(() => { + result.current?.clearPinnedRows(); + }); + + expect(result.current?.pinnedRows).toEqual(new Set()); + }); + + it('writes the pinned id to the URL query string when togglePinnedRow is called', () => { + const {result, router} = renderHookWithProviders(() => useLogsPinning(), { + additionalWrapper: LogsPinningProvider, + }); + + act(() => { + result.current?.togglePinnedRow('log-1'); + }); + + expect(router.location.query.logsPinned).toContain('log-1'); + }); + + it('removes the logsPinned key from the URL when clearPinnedRows is called', () => { + const {result, router} = renderHookWithProviders(() => useLogsPinning(), { + additionalWrapper: LogsPinningProvider, + initialRouterConfig: { + location: {pathname: '/', query: {logsPinned: 'log-1'}}, + }, + }); + + act(() => { + result.current?.clearPinnedRows(); + }); + + expect(router.location.query.logsPinned).toHaveLength(0); + }); +}); diff --git a/static/app/views/explore/logs/pinning/useLogsPinning.tsx b/static/app/views/explore/logs/pinning/useLogsPinning.tsx new file mode 100644 index 000000000000..894e52cdb005 --- /dev/null +++ b/static/app/views/explore/logs/pinning/useLogsPinning.tsx @@ -0,0 +1,84 @@ +import type {ReactNode} from 'react'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +import {decodeList} from 'sentry/utils/queryString'; +import {useLocation} from 'sentry/utils/useLocation'; +import {useNavigate} from 'sentry/utils/useNavigate'; +import {useOurLogsPinningEnabled} from 'sentry/views/explore/logs/pinning/useOurLogsPinning'; + +const LOGS_PINNED_KEY = 'logsPinned'; + +interface LogsPinning { + clearPinnedRows: () => void; + pinnedRows: Set; + togglePinnedRow: (id: string) => void; +} + +const LogsPinningContext = createContext(undefined); + +export function LogsPinningProvider({children}: {children: ReactNode}) { + const location = useLocation(); + const navigate = useNavigate(); + + const [pinnedRows, setPinnedRows] = useState>(() => { + return new Set(decodeList(location.query?.[LOGS_PINNED_KEY]).filter(Boolean)); + }); + + const togglePinnedRow = useCallback((id: string) => { + setPinnedRows(previous => { + const next = new Set(previous); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const clearPinnedRows = useCallback(() => { + setPinnedRows(new Set()); + }, []); + + useEffect(() => { + navigate( + { + ...location, + query: { + ...location.query, + [LOGS_PINNED_KEY]: Array.from(pinnedRows), + }, + }, + {replace: true} + ); + // location is intentionally omitted — we only want to sync pinnedRows to the URL. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [navigate, pinnedRows]); + + const value = useMemo( + () => ({ + clearPinnedRows, + pinnedRows, + togglePinnedRow, + }), + [clearPinnedRows, pinnedRows, togglePinnedRow] + ); + + return ( + {children} + ); +} + +export function useLogsPinning() { + const logsPinningEnabled = useOurLogsPinningEnabled(); + const context = useContext(LogsPinningContext); + + return logsPinningEnabled ? context : undefined; +} diff --git a/static/app/views/explore/logs/pinning/useOurLogsPinning.spec.tsx b/static/app/views/explore/logs/pinning/useOurLogsPinning.spec.tsx new file mode 100644 index 000000000000..86b283f4cada --- /dev/null +++ b/static/app/views/explore/logs/pinning/useOurLogsPinning.spec.tsx @@ -0,0 +1,48 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {renderHookWithProviders} from 'sentry-test/reactTestingLibrary'; + +import {useOurLogsPinningEnabled} from 'sentry/views/explore/logs/pinning/useOurLogsPinning'; + +describe('useOurLogsPinningEnabled', () => { + it('returns true when the organization has the ourlogs-pinning feature', () => { + const {result} = renderHookWithProviders(() => useOurLogsPinningEnabled(), { + organization: OrganizationFixture({features: ['ourlogs-pinning']}), + }); + + expect(result.current).toBe(true); + }); + + it('returns true when the location query has logsPinning set to true', () => { + const {result} = renderHookWithProviders(() => useOurLogsPinningEnabled(), { + organization: OrganizationFixture({features: []}), + initialRouterConfig: { + location: {pathname: '/', query: {logsPinning: 'true'}}, + }, + }); + + expect(result.current).toBe(true); + }); + + it('returns false when neither the feature nor the query are set', () => { + const {result} = renderHookWithProviders(() => useOurLogsPinningEnabled(), { + organization: OrganizationFixture({features: []}), + initialRouterConfig: { + location: {pathname: '/'}, + }, + }); + + expect(result.current).toBe(false); + }); + + it('returns false when the location query has logsPinning set to a value other than true', () => { + const {result} = renderHookWithProviders(() => useOurLogsPinningEnabled(), { + organization: OrganizationFixture({features: []}), + initialRouterConfig: { + location: {pathname: '/', query: {logsPinning: 'false'}}, + }, + }); + + expect(result.current).toBe(false); + }); +}); diff --git a/static/app/views/explore/logs/pinning/useOurLogsPinning.tsx b/static/app/views/explore/logs/pinning/useOurLogsPinning.tsx new file mode 100644 index 000000000000..17cbc6d3659d --- /dev/null +++ b/static/app/views/explore/logs/pinning/useOurLogsPinning.tsx @@ -0,0 +1,12 @@ +import {useLocation} from 'sentry/utils/useLocation'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +export function useOurLogsPinningEnabled() { + const organization = useOrganization(); + const location = useLocation(); + + return ( + organization.features.includes('ourlogs-pinning') || + location.query.logsPinning === 'true' + ); +} diff --git a/static/app/views/explore/logs/styles.tsx b/static/app/views/explore/logs/styles.tsx index bb6e8ec607b9..38a3952a6ad7 100644 --- a/static/app/views/explore/logs/styles.tsx +++ b/static/app/views/explore/logs/styles.tsx @@ -31,6 +31,9 @@ const StyledPanel = styled(Panel)` `; export const LogTableRow = styled(TableRow)` + margin-right: -1rem; + padding-right: 1rem; + &:not(thead > &) { cursor: ${p => (p.isClickable ? 'pointer' : 'default')}; @@ -66,6 +69,15 @@ export const LogTableRow = styled(TableRow)` } } + &[data-row-pinned='true']:not(thead > &) { + background-color: ${p => p.theme.tokens.background.transparent.accent.muted}; + + &:hover { + background-color: ${p => + p.theme.tokens.interactive.transparent.accent.selected.background.active}; + } + } + &.beforeHoverTime + &.afterHoverTime:before { border-top: 1px solid ${p => p.theme.tokens.border.accent.moderate}; content: ''; @@ -122,7 +134,7 @@ export const LogTableBodyCell = styled(TableBodyCell)` } &:last-child { - padding: 2px ${p => p.theme.space.xl}; + padding: 0 ${p => p.theme.space.md}; } `; @@ -286,6 +298,26 @@ export const LogsFilteredHelperText = styled('span')` background-color: ${p => p.theme.colors.gray200}; `; +export const LogPinButton = styled(Button)<{isPinned: boolean | undefined}>` + position: absolute; + right: calc(-1 * var(--logsPinButtonArea)); + opacity: ${p => (p.isPinned ? 1 : 0)}; + transition: opacity 0.1s; + z-index: 1; + + ${LogTableRow}:focus-within &, + ${LogTableRow}:hover & { + background: none; + opacity: 1; + } + + &:focus-within svg, + &:hover svg { + fill: ${p => p.theme.tokens.content.accent}; + transition: fill ${p => p.theme.motion.smooth.fast}; + } +`; + export const WrappingText = styled('div')<{wrapText?: boolean}>` white-space: ${p => (p.wrapText ? 'pre-wrap' : 'nowrap')}; overflow: hidden; diff --git a/static/app/views/explore/logs/tables/logsInfiniteTable.spec.tsx b/static/app/views/explore/logs/tables/logsInfiniteTable.spec.tsx index cc8482245847..1c8856528df1 100644 --- a/static/app/views/explore/logs/tables/logsInfiniteTable.spec.tsx +++ b/static/app/views/explore/logs/tables/logsInfiniteTable.spec.tsx @@ -264,11 +264,7 @@ describe('LogsInfiniteTable', () => { const actionsButton = within(cell).queryByRole('button', { name: 'Actions', }); - if (field === 'timestamp') { - expect(actionsButton).toBeNull(); - } else { - expect(actionsButton).toBeInTheDocument(); - } + expect(actionsButton).toBeInTheDocument(); } } for (const mock of traceItemMocks) { @@ -476,4 +472,65 @@ describe('LogsInfiniteTable', () => { await screen.findByText('abc123de'); await screen.findByText('abc123ee'); }); + + it('renders a pin button on a hovered row when ourlogs-pinning is enabled', async () => { + mockUseLocation.mockReturnValue( + LocationFixture({ + pathname: `/organizations/${organization.slug}/explore/logs/?end=2025-04-10T20%3A04%3A51&project=${project.id}&start=2025-04-10T14%3A37%3A55`, + query: { + [LOGS_FIELDS_KEY]: visibleColumnFields, + [LOGS_SORT_BYS_KEY]: '-timestamp', + [LOGS_QUERY_KEY]: 'severity:error', + logsPinning: 'true', + }, + }) + ); + + renderWithProviders( + + ); + + const [firstRow] = await screen.findAllByTestId('log-table-row'); + await userEvent.hover(firstRow!); + + expect( + await within(firstRow!).findByRole('button', {name: 'Pin log row'}) + ).toBeInTheDocument(); + }); + + it('does not render a pin button when ourlogs-pinning is disabled', async () => { + renderWithProviders( + + ); + + const [firstRow] = await screen.findAllByTestId('log-table-row'); + await userEvent.hover(firstRow!); + + expect( + within(firstRow!).queryByRole('button', {name: 'Pin log row'}) + ).not.toBeInTheDocument(); + }); + + it('marks the row as pinned when its id is in the logsPinned query', async () => { + mockUseLocation.mockReturnValue( + LocationFixture({ + pathname: `/organizations/${organization.slug}/explore/logs/?end=2025-04-10T20%3A04%3A51&project=${project.id}&start=2025-04-10T14%3A37%3A55`, + query: { + [LOGS_FIELDS_KEY]: visibleColumnFields, + [LOGS_SORT_BYS_KEY]: '-timestamp', + [LOGS_QUERY_KEY]: 'severity:error', + logsPinning: 'true', + logsPinned: '1', + }, + }) + ); + + renderWithProviders( + + ); + + const [firstRow] = await screen.findAllByTestId('log-table-row'); + + expect(firstRow).toHaveAttribute('data-row-pinned', 'true'); + }); }); diff --git a/static/app/views/explore/logs/tables/logsInfiniteTable.tsx b/static/app/views/explore/logs/tables/logsInfiniteTable.tsx index 45e2423182bd..f476870679f4 100644 --- a/static/app/views/explore/logs/tables/logsInfiniteTable.tsx +++ b/static/app/views/explore/logs/tables/logsInfiniteTable.tsx @@ -45,6 +45,8 @@ import { MINIMUM_INFINITE_SCROLL_FETCH_COOLDOWN_MS, QUANTIZE_MINUTES, } from 'sentry/views/explore/logs/constants'; +import {PinnedLogs} from 'sentry/views/explore/logs/pinning/PinnedLogs'; +import {LogsPinningProvider} from 'sentry/views/explore/logs/pinning/useLogsPinning'; import { FirstTableHeadCell, FloatingBackToTopContainer, @@ -481,7 +483,7 @@ export function LogsInfiniteTable({ } return ( - + )} + {!isPending && ( + { + const pinnedId = dataRow[OurLogKnownFieldKey.ID]; + const pinnedExpandKey = `pinned-${pinnedId}`; + return ( + + ); + }} + /> + )} ) : null} - + ); } diff --git a/static/app/views/explore/logs/tables/logsTableRow.tsx b/static/app/views/explore/logs/tables/logsTableRow.tsx index 016368bb60af..133bcbb5bb88 100644 --- a/static/app/views/explore/logs/tables/logsTableRow.tsx +++ b/static/app/views/explore/logs/tables/logsTableRow.tsx @@ -13,7 +13,7 @@ import ProjectBadge from 'sentry/components/idBadge/projectBadge'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; import {useCaseInsensitivity} from 'sentry/components/searchQueryBuilder/hooks'; -import {IconAdd, IconJson, IconSubtract, IconWarning} from 'sentry/icons'; +import {IconAdd, IconJson, IconPin, IconSubtract, IconWarning} from 'sentry/icons'; import {IconChevron} from 'sentry/icons/iconChevron'; import {t} from 'sentry/locale'; import type {PageFilters} from 'sentry/types/core'; @@ -63,6 +63,7 @@ import { } from 'sentry/views/explore/logs/fieldRenderers'; import {useLogsFrozenIsFrozen} from 'sentry/views/explore/logs/logsFrozenContext'; import {useLogsAnalyticsPageSource} from 'sentry/views/explore/logs/logsQueryParamsProvider'; +import {useLogsPinning} from 'sentry/views/explore/logs/pinning/useLogsPinning'; import { DetailsBody, DetailsContent, @@ -76,6 +77,7 @@ import { LogsTableBodyFirstCell, LogTableBodyCell, LogTableRow, + LogPinButton, StyledChevronButton, TraceIconStyleWrapper, } from 'sentry/views/explore/logs/styles'; @@ -115,6 +117,7 @@ type LogsRowProps = { openWithExpandedIds?: string[]; replay?: ReplayEmbeddedTableOptions; }; + expansionKey?: string; isExpanded?: boolean; logEnd?: string; logStart?: string; @@ -204,6 +207,7 @@ export const LogRowContent = memo(function LogRowContent({ isExpanded, onExpand, onCollapse, + expansionKey: expansionKeyProp, onExpandHeight, blockRowExpanding, onEmbeddedRowClick, @@ -221,16 +225,19 @@ export const LogRowContent = memo(function LogRowContent({ const autorefreshEnabled = useLogsAutoRefreshEnabled(); const setAutorefresh = useSetLogsAutoRefresh(); const measureRef = useRef(null); - const [shouldRenderHoverElements, setShouldRenderHoverElements] = useState(false); + + const rowId = String(dataRow[OurLogKnownFieldKey.ID]); + const expansionKey = expansionKeyProp ?? rowId; + const logsPinning = useLogsPinning(); + const isPinned = logsPinning?.pinnedRows.has(rowId); + + const [shouldRenderHoverElements, setShouldRenderHoverElements] = useState(isPinned); // This only applies in embedded views where clicking doesn't expand row details. function onClick(event: SyntheticEvent) { if (onEmbeddedRowClick && event.nativeEvent instanceof MouseEvent) { event.preventDefault(); - onEmbeddedRowClick( - String(dataRow[OurLogKnownFieldKey.ID]), - event as React.MouseEvent - ); + onEmbeddedRowClick(rowId, event as React.MouseEvent); return; } } @@ -265,9 +272,9 @@ export const LogRowContent = memo(function LogRowContent({ function toggleExpanded() { if (onExpand) { if (isExpanded) { - onCollapse?.(String(dataRow[OurLogKnownFieldKey.ID])); + onCollapse?.(expansionKey); } else { - onExpand?.(String(dataRow[OurLogKnownFieldKey.ID])); + onExpand?.(expansionKey); } } else { setExpanded(e => !e); @@ -277,7 +284,7 @@ export const LogRowContent = memo(function LogRowContent({ } trackAnalytics('logs.table.row_expanded', { - log_id: String(dataRow[OurLogKnownFieldKey.ID]), + log_id: rowId, page_source: analyticsPageSource, organization, }); @@ -285,12 +292,9 @@ export const LogRowContent = memo(function LogRowContent({ useLayoutEffect(() => { if (measureRef.current && isExpanded) { - onExpandHeight?.( - String(dataRow[OurLogKnownFieldKey.ID]), - measureRef.current.clientHeight - ); + onExpandHeight?.(expansionKey, measureRef.current.clientHeight); } - }, [isExpanded, onExpandHeight, dataRow]); + }, [expansionKey, isExpanded, onExpandHeight]); const addSearchFilter = useAddSearchFilter(); const {copy} = useCopyToClipboard(); @@ -311,7 +315,7 @@ export const LogRowContent = memo(function LogRowContent({ ? DEFAULT_TRACE_ITEM_HOVER_TIMEOUT_WITH_AUTO_REFRESH : DEFAULT_TRACE_ITEM_HOVER_TIMEOUT; const {hoverProps, traceItemsResult} = useFetchTraceItemDetailsOnHover({ - traceItemId: String(dataRow[OurLogKnownFieldKey.ID]), + traceItemId: rowId, projectId: String(dataRow[OurLogKnownFieldKey.PROJECT_ID]), traceId: String(dataRow[OurLogKnownFieldKey.TRACE_ID]), traceItemType: TraceItemDataset.LOGS, @@ -387,13 +391,15 @@ export const LogRowContent = memo(function LogRowContent({ { setShouldRenderHoverElements(true); - if (rowInteractProps.onMouseEnter) { - rowInteractProps.onMouseEnter(e); - } + rowInteractProps.onMouseEnter?.(e); + }} + onMouseLeave={e => { + rowInteractProps.onMouseLeave?.(e); }} > @@ -430,11 +436,51 @@ export const LogRowContent = memo(function LogRowContent({ )} - {fields?.map(field => { + {fields?.map((field, index) => { + const pin = + logsPinning && index === fields.length - 1 ? ( + + } + isPinned={isPinned} + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + logsPinning.togglePinnedRow(rowId); + }} + size="xs" + variant="transparent" + /> + ) : null; + + const shouldRenderActions = + (showCellActions ?? !embedded) && + field !== OurLogKnownFieldKey.TIMESTAMP && + shouldRenderHoverElements; + const value = (dataRow as OurLogsResponseItem)[field]; + const extraMenuItems = + field === OurLogKnownFieldKey.MESSAGE + ? getExploreSimilarSpansMenuItems({ + message: value, + organization, + selection, + showExploreSimilarSpansLink, + }) + : undefined; + if (!defined(value)) { - return ; + return ( + + {shouldRenderActions ? ( + + {pin} + + ) : null} + + ); } const renderedField = ( @@ -460,20 +506,6 @@ export const LogRowContent = memo(function LogRowContent({ type: FieldValueType.STRING, }; - const shouldRenderActions = - (showCellActions ?? !embedded) && - field !== OurLogKnownFieldKey.TIMESTAMP && - shouldRenderHoverElements; - const extraMenuItems = - field === OurLogKnownFieldKey.MESSAGE - ? getExploreSimilarSpansMenuItems({ - message: value, - organization, - selection, - showExploreSimilarSpansLink, - }) - : undefined; - return ( {shouldRenderActions ? ( @@ -521,6 +553,7 @@ export const LogRowContent = memo(function LogRowContent({ }} allowActions={ALLOWED_CELL_ACTIONS} extraMenuItems={extraMenuItems} + pin={pin} triggerType={ActionTriggerType.ELLIPSIS} > {renderedField} diff --git a/tests/js/sentry-test/reactTestingLibrary.tsx b/tests/js/sentry-test/reactTestingLibrary.tsx index 8fea9514de5f..c71de455d71b 100644 --- a/tests/js/sentry-test/reactTestingLibrary.tsx +++ b/tests/js/sentry-test/reactTestingLibrary.tsx @@ -90,7 +90,7 @@ export interface RouterConfig { routes?: string[]; } -interface RenderOptions extends rtl.RenderOptions, ProviderOptions { +export interface RenderOptions extends rtl.RenderOptions, ProviderOptions { initialRouterConfig?: RouterConfig; outletContext?: Record; }