diff --git a/static/app/components/events/ourlogs/ourlogsDrawer.tsx b/static/app/components/events/ourlogs/ourlogsDrawer.tsx index e266c5a386d2bc..eaa8cd6d15cdd4 100644 --- a/static/app/components/events/ourlogs/ourlogsDrawer.tsx +++ b/static/app/components/events/ourlogs/ourlogsDrawer.tsx @@ -1,4 +1,4 @@ -import {useMemo, useRef} from 'react'; +import {useMemo} from 'react'; import moment from 'moment-timezone'; import {ProjectAvatar} from '@sentry/scraps/avatar'; @@ -83,7 +83,6 @@ export function OurlogsDrawer({ const searchQueryBuilderProps = useTraceItemSearchQueryBuilderProps( tracesItemSearchQueryBuilderProps ); - const containerRef = useRef(null); const additionalData = useMemo( () => ({ @@ -156,12 +155,12 @@ export function OurlogsDrawer({ )} - - + + diff --git a/static/app/components/events/ourlogs/ourlogsSection.spec.tsx b/static/app/components/events/ourlogs/ourlogsSection.spec.tsx index e7d855ad86b00f..4e6da88799f269 100644 --- a/static/app/components/events/ourlogs/ourlogsSection.spec.tsx +++ b/static/app/components/events/ourlogs/ourlogsSection.spec.tsx @@ -43,6 +43,7 @@ jest.mock('@tanstack/react-virtual', () => { {key: '3', index: 2, start: 100, end: 150, lane: 0}, ]), getTotalSize: jest.fn().mockReturnValue(150), + measure: jest.fn(), scrollToIndex: jest.fn(), options: { scrollMargin: 0, diff --git a/static/app/components/panels/panelBody.tsx b/static/app/components/panels/panelBody.tsx index cf9e3814367987..df16b1ea523688 100644 --- a/static/app/components/panels/panelBody.tsx +++ b/static/app/components/panels/panelBody.tsx @@ -3,10 +3,12 @@ import styled from '@emotion/styled'; import {textStyles} from 'sentry/styles/text'; type BaseProps = { + display?: 'contents'; withPadding?: boolean; }; export const PanelBody = styled('div')` + ${p => p.display && `display: ${p.display};`} padding: ${p => (p.withPadding ? p.theme.space.xl : undefined)}; ${textStyles}; `; diff --git a/static/app/components/tables/gridEditable/styles.tsx b/static/app/components/tables/gridEditable/styles.tsx index 4d799b950a49d7..f10d2529ce0e89 100644 --- a/static/app/components/tables/gridEditable/styles.tsx +++ b/static/app/components/tables/gridEditable/styles.tsx @@ -47,14 +47,16 @@ export const HeaderButtonContainer = styled('div')` export const Body = styled( ({ children, + contentsBody, showVerticalScrollbar: _, ...props }: React.ComponentProps & { children?: React.ReactNode; + contentsBody?: boolean; showVerticalScrollbar?: boolean; }) => ( - {children} + {children} ) )` @@ -98,6 +100,8 @@ export const Grid = styled('table')<{ ? css` height: 100%; max-height: ${typeof p.height === 'number' ? p.height + 'px' : p.height}; + flex: 1; + min-height: 0; &:has(> thead + tbody) { grid-template-rows: auto 1fr; @@ -336,7 +340,3 @@ export const GridResizer = styled('div')<{dataRows: number}>` opacity: 0.4; } `; - -const StyledPanelBody = styled(PanelBody)` - height: 100%; -`; diff --git a/static/app/utils/fixtures/virtualization.ts b/static/app/utils/fixtures/virtualization.ts new file mode 100644 index 00000000000000..3c092e1fe7da58 --- /dev/null +++ b/static/app/utils/fixtures/virtualization.ts @@ -0,0 +1,18 @@ +/** + * Tanstack Virtual renders zero items in the default zero-sized JSDom environment. + * This forces all elements to have a non-zero size to render at least a few rows. + * https://github.com/TanStack/virtual/issues/641 + */ +export function mockGetBoundingClientRect() { + Element.prototype.getBoundingClientRect = jest.fn(() => ({ + width: 500, + height: 500, + top: 0, + left: 0, + bottom: 500, + right: 500, + x: 0, + y: 0, + toJSON: () => {}, + })); +} diff --git a/static/app/utils/useIsShortViewport.tsx b/static/app/utils/useIsShortViewport.tsx new file mode 100644 index 00000000000000..0c2ee58ee94174 --- /dev/null +++ b/static/app/utils/useIsShortViewport.tsx @@ -0,0 +1,7 @@ +import {useMedia} from 'sentry/utils/useMedia'; + +export const SHORT_VIEWPORT_HEIGHT = 900; + +export function useIsShortViewport(): boolean { + return useMedia(`(max-height: ${SHORT_VIEWPORT_HEIGHT}px)`); +} diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx index 7e3c2f4b8500f4..419d1085f4e957 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx @@ -35,6 +35,7 @@ import {defined, escape} from 'sentry/utils'; import {uniq} from 'sentry/utils/array/uniq'; import type {AggregationOutputType} from 'sentry/utils/discover/fields'; import {RangeMap, type Range} from 'sentry/utils/number/rangeMap'; +import {useIsShortViewport} from 'sentry/utils/useIsShortViewport'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import {useWidgetSyncContext} from 'sentry/views/dashboards/contexts/widgetSyncContext'; @@ -134,6 +135,7 @@ export function TimeSeriesWidgetVisualization(props: TimeSeriesWidgetVisualizati // have the same difference in `timestamp`s) even though this is rare, since // the backend zerofills the data + const isShortViewport = useIsShortViewport(); const chartRef = useRef(null); const unregisterRef = useRef<(() => void) | null>(null); const {register: registerWithWidgetSyncContext, groupName} = useWidgetSyncContext(); @@ -245,8 +247,11 @@ export function TimeSeriesWidgetVisualization(props: TimeSeriesWidgetVisualizati const axisRangeProp = getAxisRange(props.axisRange) ?? 'auto'; + const yAxisSplitNumber = isShortViewport ? 2 : 5; + const leftYAxis = TimeSeriesWidgetYAxis( { + splitNumber: yAxisSplitNumber, axisLabel: { formatter: (value: number) => formatYAxisValue(value, leftYAxisType, unitForType[leftYAxisType] ?? undefined), @@ -260,6 +265,7 @@ export function TimeSeriesWidgetVisualization(props: TimeSeriesWidgetVisualizati const rightYAxis = rightYAxisType ? TimeSeriesWidgetYAxis( { + splitNumber: yAxisSplitNumber, axisLabel: { formatter: (value: number) => formatYAxisValue( diff --git a/static/app/views/explore/components/overChartButtonGroup.tsx b/static/app/views/explore/components/overChartButtonGroup.tsx index 03c72e396e27cc..1102841c173fb5 100644 --- a/static/app/views/explore/components/overChartButtonGroup.tsx +++ b/static/app/views/explore/components/overChartButtonGroup.tsx @@ -4,7 +4,6 @@ export function OverChartButtonGroup(props: FlexProps<'div'>) { return ( diff --git a/static/app/views/explore/components/styles.tsx b/static/app/views/explore/components/styles.tsx index f9a3eb5a3b2fea..ee24f47eb58076 100644 --- a/static/app/views/explore/components/styles.tsx +++ b/static/app/views/explore/components/styles.tsx @@ -1,7 +1,7 @@ import {css} from '@emotion/react'; import styled from '@emotion/styled'; -import {Container, type ContainerProps} from '@sentry/scraps/layout'; +import {Flex, type FlexProps} from '@sentry/scraps/layout'; import * as Layout from 'sentry/components/layouts/thirds'; import {SchemaHintsSection} from 'sentry/views/explore/components/schemaHints/schemaHintsList'; @@ -30,14 +30,16 @@ export const ExploreControlSection = styled('aside')<{expanded: boolean}>` } `; -export function ExploreContentSection(props: ContainerProps) { +export function ExploreContentSection(props: FlexProps<'div'>) { const hasPageFrame = useHasPageFrameFeature(); return ( - ); diff --git a/static/app/views/explore/components/table.tsx b/static/app/views/explore/components/table.tsx index 8c71850944f409..57cfd1484e71f9 100644 --- a/static/app/views/explore/components/table.tsx +++ b/static/app/views/explore/components/table.tsx @@ -1,5 +1,5 @@ import type React from 'react'; -import {useCallback, useEffect, useMemo, useRef} from 'react'; +import {useCallback, useEffect, useMemo, useRef, type CSSProperties} from 'react'; import styled from '@emotion/styled'; import {COL_WIDTH_MINIMUM} from 'sentry/components/tables/gridEditable'; @@ -18,16 +18,17 @@ import {defined} from 'sentry/utils'; import {Actions} from 'sentry/views/discover/table/cellAction'; interface TableProps extends React.ComponentProps { + height?: CSSProperties['height']; ref?: React.Ref; showVerticalScrollbar?: boolean; // Size of the loading element in order to match the height of the row. size?: number; } -export function Table({ref, children, style, ...props}: TableProps) { +export function Table({ref, children, height, style, ...props}: TableProps) { return ( <_TableWrapper {...props}> - <_Table ref={ref} style={style}> + <_Table ref={ref} height={height} style={style}> {children} diff --git a/static/app/views/explore/components/viewportConstrainedPage.tsx b/static/app/views/explore/components/viewportConstrainedPage.tsx new file mode 100644 index 00000000000000..10fe34b6c3cbbd --- /dev/null +++ b/static/app/views/explore/components/viewportConstrainedPage.tsx @@ -0,0 +1,55 @@ +import styled from '@emotion/styled'; + +import type {FlexProps} from '@sentry/scraps/layout'; + +import * as Layout from 'sentry/components/layouts/thirds'; +import {SHORT_VIEWPORT_HEIGHT} from 'sentry/utils/useIsShortViewport'; + +interface ViewportConstrainedPageProps extends FlexProps<'main'> { + constrained?: boolean; + hideFooter?: boolean; +} + +/** + * A page layout that constrains itself to the viewport height to prevent + * window-level scrolling. Uses CSS size containment so that the page's + * intrinsic size doesn't bubble up through the flex chain — the flex + * algorithm sizes it to exactly the remaining space after siblings + * (TopBar, Footer, etc.), and content within must manage its own + * overflow (e.g. via scrollable table bodies). + * + * When constrained, the global footer sibling is hidden on smaller + * viewport heights and when `hideFooter` is set. + */ +export function ViewportConstrainedPage({ + constrained = true, + hideFooter, + ...rest +}: ViewportConstrainedPageProps) { + if (!constrained) { + return ; + } + + return ( + + ); +} + +const ConstrainedPage = styled(Layout.Page)` + contain: size; + + @media (max-height: ${SHORT_VIEWPORT_HEIGHT}px) { + ~ footer { + display: none; + } + } + + &[data-hide-footer] ~ footer { + display: none; + } +`; diff --git a/static/app/views/explore/logs/content.spec.tsx b/static/app/views/explore/logs/content.spec.tsx index 8bc517f6624ca3..04a9ec085bed72 100644 --- a/static/app/views/explore/logs/content.spec.tsx +++ b/static/app/views/explore/logs/content.spec.tsx @@ -16,11 +16,14 @@ import {ProjectsStore} from 'sentry/stores/projectsStore'; import {TeamStore} from 'sentry/stores/teamStore'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; +import {mockGetBoundingClientRect} from 'sentry/utils/fixtures/virtualization'; import {LOGS_AUTO_REFRESH_KEY} from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext'; import type {OurLogsResponseItem} from 'sentry/views/explore/logs/types'; import LogsPage from './content'; +beforeEach(mockGetBoundingClientRect); + describe('LogsPage', () => { let organization: Organization; let project: Project; diff --git a/static/app/views/explore/logs/content.tsx b/static/app/views/explore/logs/content.tsx index ee69894fac734d..4ebebdfa5eff23 100644 --- a/static/app/views/explore/logs/content.tsx +++ b/static/app/views/explore/logs/content.tsx @@ -1,5 +1,5 @@ import {LinkButton} from '@sentry/scraps/button'; -import {Grid, Stack} from '@sentry/scraps/layout'; +import {Grid} from '@sentry/scraps/layout'; import {AnalyticsArea} from 'sentry/components/analyticsArea'; import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; @@ -20,11 +20,13 @@ import {useMaxPickableDays} from 'sentry/utils/useMaxPickableDays'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useProjects} from 'sentry/utils/useProjects'; import {ExploreBreadcrumb} from 'sentry/views/explore/components/breadcrumb'; +import {ViewportConstrainedPage} from 'sentry/views/explore/components/viewportConstrainedPage'; import {LogsPageDataProvider} from 'sentry/views/explore/contexts/logs/logsPageData'; import {useGetSavedQuery} from 'sentry/views/explore/hooks/useGetSavedQueries'; import {LogsTabOnboarding} from 'sentry/views/explore/logs/logsOnboarding'; import {LogsQueryParamsProvider} from 'sentry/views/explore/logs/logsQueryParamsProvider'; import {LogsTabContent} from 'sentry/views/explore/logs/logsTab'; +import {useTableExpando} from 'sentry/views/explore/logs/tables/useTableExpando'; import { useQueryParamsId, useQueryParamsTitle, @@ -34,6 +36,7 @@ import {useOnboardingProject} from 'sentry/views/insights/common/queries/useOnbo export default function LogsContent() { const organization = useOrganization(); + const tableExpando = useTableExpando(); const maxPickableDays = useMaxPickableDays({ dataCategories: [DataCategory.LOG_BYTE], }); @@ -63,7 +66,10 @@ export default function LogsContent() { analyticsPageSource={LogsAnalyticsPageSource.EXPLORE_LOGS} source="location" > - + {defined(onboardingProject) ? ( @@ -73,10 +79,13 @@ export default function LogsContent() { datePageFilterProps={datePageFilterProps} /> ) : ( - + )} - + diff --git a/static/app/views/explore/logs/logsGraph.tsx b/static/app/views/explore/logs/logsGraph.tsx index f45cdc78d1b4d7..b9c29223a2df07 100644 --- a/static/app/views/explore/logs/logsGraph.tsx +++ b/static/app/views/explore/logs/logsGraph.tsx @@ -15,6 +15,7 @@ import {trackAnalytics} from 'sentry/utils/analytics'; import {EventView} from 'sentry/utils/discover/eventView'; import {DiscoverDatasets} from 'sentry/utils/discover/types'; import {useChartInterval} from 'sentry/utils/useChartInterval'; +import {useIsShortViewport} from 'sentry/utils/useIsShortViewport'; import {useLocation} from 'sentry/utils/useLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useProjects} from 'sentry/utils/useProjects'; @@ -118,6 +119,7 @@ function Graph({ timeseriesResult, visualize, }: GraphProps) { + const isShortViewport = useIsShortViewport(); const {isEmpty: tableIsEmpty, isPending: tableIsPending} = useLogsPageDataQueryResult(); const aggregate = visualize.yAxis; @@ -235,7 +237,7 @@ function Graph({ /> ) } - height={visualize.visible ? 200 : 50} + height={visualize.visible ? (isShortViewport ? 175 : 200) : 50} revealActions="always" /> ); diff --git a/static/app/views/explore/logs/logsTab.spec.tsx b/static/app/views/explore/logs/logsTab.spec.tsx index a65043b70f2bd9..0a86d104317db7 100644 --- a/static/app/views/explore/logs/logsTab.spec.tsx +++ b/static/app/views/explore/logs/logsTab.spec.tsx @@ -5,6 +5,7 @@ import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrar import type {DatePageFilterProps} from 'sentry/components/pageFilters/date/datePageFilter'; import {LogsAnalyticsPageSource} from 'sentry/utils/analytics/logsAnalyticsEvent'; +import {mockGetBoundingClientRect} from 'sentry/utils/fixtures/virtualization'; import {LOGS_AUTO_REFRESH_KEY} from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext'; import {LogsPageDataProvider} from 'sentry/views/explore/contexts/logs/logsPageData'; import { @@ -15,8 +16,23 @@ import {LOGS_SORT_BYS_KEY} from 'sentry/views/explore/contexts/logs/sortBys'; import {AlwaysPresentLogFields} from 'sentry/views/explore/logs/constants'; import {LogsQueryParamsProvider} from 'sentry/views/explore/logs/logsQueryParamsProvider'; import {LogsTabContent} from 'sentry/views/explore/logs/logsTab'; +import {useTableExpando} from 'sentry/views/explore/logs/tables/useTableExpando'; import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; +function LogsTabContentHarness({ + datePageFilterProps, +}: { + datePageFilterProps: DatePageFilterProps; +}) { + const tableExpando = useTableExpando(); + return ( + + ); +} + const datePageFilterProps: DatePageFilterProps = { defaultPeriod: '7d' as const, maxPickableDays: 7, @@ -28,6 +44,8 @@ const datePageFilterProps: DatePageFilterProps = { }), }; +beforeEach(mockGetBoundingClientRect); + describe('LogsTabContent', () => { const {organization, project, setupPageFilters} = initializeLogsTest(); @@ -175,7 +193,7 @@ describe('LogsTabContent', () => { it('should call APIs as expected', async () => { render( - + , {initialRouterConfig, organization} ); @@ -227,7 +245,7 @@ describe('LogsTabContent', () => { it('should switch between modes', async () => { render( - + , {initialRouterConfig, organization} ); @@ -271,7 +289,7 @@ describe('LogsTabContent', () => { it('should pass caseInsensitive to the query', async () => { render( - + , {initialRouterConfig, organization} ); @@ -328,7 +346,7 @@ describe('LogsTabContent', () => { autorefreshEnabledRouterConfig.location.query[LOGS_AUTO_REFRESH_KEY] = 'enabled'; render( - + , { initialRouterConfig: autorefreshEnabledRouterConfig, diff --git a/static/app/views/explore/logs/logsTab.tsx b/static/app/views/explore/logs/logsTab.tsx index fe63ef0c9f1477..a2c10489ad57c0 100644 --- a/static/app/views/explore/logs/logsTab.tsx +++ b/static/app/views/explore/logs/logsTab.tsx @@ -1,4 +1,5 @@ import {Fragment, memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; import {TabList, Tabs} from '@sentry/scraps/tabs'; @@ -95,8 +96,11 @@ import {useRawCounts} from 'sentry/views/explore/useRawCounts'; // eslint-disable-next-line boundaries/dependencies import QuotaExceededAlert from 'getsentry/components/performance/quotaExceededAlert'; +import type {TableExpando} from './tables/useTableExpando'; + type LogsTabProps = { datePageFilterProps: DatePageFilterProps; + tableExpando: TableExpando; }; interface LogsSearchBarProps { @@ -237,7 +241,7 @@ const LogsSearchSection = memo(function LogsSearchSection({ ); }); -export function LogsTabContent({datePageFilterProps}: LogsTabProps) { +export function LogsTabContent({datePageFilterProps, tableExpando}: LogsTabProps) { const pageFilters = usePageFilters(); const fields = useQueryParamsFields(); const mode = useQueryParamsMode(); @@ -429,41 +433,48 @@ export function LogsTabContent({datePageFilterProps}: LogsTabProps) { datePageFilterProps={datePageFilterProps} searchBarWidthOffset={columnEditorButtonRef.current?.clientWidth} /> - - + + {sidebarOpen ? : null} - - - - - } - onClick={() => setSidebarOpen(!sidebarOpen)} - > - {sidebarOpen ? null : t('Advanced')} - - - + + + {!tableExpando.expanded && ( + + + } + onClick={() => setSidebarOpen(!sidebarOpen)} + > + {sidebarOpen ? null : t('Advanced')} + + + + )} - - - + {!tableExpando.expanded && ( + + + + )} @@ -507,22 +518,35 @@ export function LogsTabContent({datePageFilterProps}: LogsTabProps) { } /> + {tableExpando.enabled && tableExpando.button} )} {tableTab === 'logs' ? ( ) : ( )} - + ); } + +const ViewportConstrainedBody = styled(ExploreBodyContent)` + flex-direction: row; + min-height: 0; +`; + +const LogsControlSection = styled(ExploreControlSection)` + @media (max-width: ${p => p.theme.breakpoints.md}) { + display: none; + } +`; diff --git a/static/app/views/explore/logs/styles.tsx b/static/app/views/explore/logs/styles.tsx index 9086df330465d8..d6b05ecb5fe43c 100644 --- a/static/app/views/explore/logs/styles.tsx +++ b/static/app/views/explore/logs/styles.tsx @@ -12,6 +12,7 @@ import {GRID_BODY_ROW_HEIGHT} from 'sentry/components/tables/gridEditable/styles import {NumberContainer} from 'sentry/utils/discover/styles'; import {unreachable} from 'sentry/utils/unreachable'; import { + Table, TableBody, TableBodyCell, TableHeadCell, @@ -125,8 +126,22 @@ export const LogTableBodyCell = styled(TableBodyCell)` } `; +function ContentsTable(props: React.ComponentProps) { + return ; +} + +export const LogTable = styled(ContentsTable)` + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + margin-bottom: 0; + overflow-x: hidden; +`; + export const LogTableBody = styled(TableBody)<{ disableBodyPadding?: boolean; + expanded?: boolean; showHeader?: boolean; }>` ${p => @@ -138,6 +153,19 @@ export const LogTableBody = styled(TableBody)<{ padding-top: ${p.theme.space.md}; padding-bottom: ${p.theme.space.md}; `} + overflow-x: hidden; + overflow-anchor: none; + + /* If a parent renderer bails out, the element might default to 0px: which causes Tanstack Virtual to stay at 0. */ + min-height: 1px; + + ${p => + p.expanded === undefined + ? '' + : ` + overflow-y: auto; + height: 100%; + `} `; export const LogDetailTableBodyCell = styled(TableBodyCell)` @@ -287,23 +315,35 @@ export function TableActionsContainer(props: FlexProps<'div'>) { return ; } -export const LogsItemContainer = styled('div')` - flex: 1 1 auto; - margin-top: ${p => p.theme.space.md}; - margin-bottom: ${p => p.theme.space.md}; -`; +export function LogsItemContainer(props: FlexProps<'div'>) { + return ( + + ); +} -export const LogsTableActionsContainer = styled(LogsItemContainer)` - margin-bottom: 0; - display: flex; - justify-content: space-between; -`; +export function LogsTableActionsContainer(props: FlexProps<'div'>) { + return ( + + ); +} -export const LogsGraphContainer = styled(LogsItemContainer)` - display: flex; - flex-direction: column; - gap: ${p => p.theme.space.md}; -`; +export function LogsGraphContainer(props: FlexProps<'div'>) { + return ( + + ); +} export const AutoRefreshLabel = styled('label')` display: flex; @@ -414,15 +454,15 @@ export const LogsSidebarCollapseButton = styled(Button)<{sidebarOpen: boolean}>` export const FloatingBackToTopContainer = styled('div')<{ inReplay?: boolean; - tableLeft?: number; + position?: 'absolute' | 'fixed'; tableWidth?: number; }>` - position: ${p => (p.inReplay ? 'absolute' : 'fixed')}; + --floatingWidth: ${p => (p.tableWidth ? `${p.tableWidth}px` : '100%')}; + position: ${p => p.position}; z-index: 1; opacity: ${p => (p.inReplay ? 1 : 0.9)}; - ${p => (p.inReplay ? 'top: 90px;' : 'top: 20px;')} - ${p => (p.inReplay ? '' : p.tableLeft ? `left: ${p.tableLeft}px;` : 'left: 0;')} - width: ${p => (p.tableWidth ? `${p.tableWidth}px` : '100%')}; + top: ${p => (p.inReplay ? p.theme.space.md : '65px')}; + width: var(--floatingWidth); display: flex; justify-content: center; diff --git a/static/app/views/explore/logs/tables/logsInfiniteTable.spec.tsx b/static/app/views/explore/logs/tables/logsInfiniteTable.spec.tsx index 8f90c846d45a9a..7ad8cfb3b32e47 100644 --- a/static/app/views/explore/logs/tables/logsInfiniteTable.spec.tsx +++ b/static/app/views/explore/logs/tables/logsInfiniteTable.spec.tsx @@ -52,6 +52,7 @@ jest.mock('@tanstack/react-virtual', () => { {key: '3', index: 2, start: 100, end: 150, lane: 0}, ]), getTotalSize: jest.fn().mockReturnValue(150), + measure: jest.fn(), options: { scrollMargin: 0, }, @@ -66,6 +67,7 @@ jest.mock('@tanstack/react-virtual', () => { {key: '3', index: 2, start: 100, end: 150, lane: 0}, ]), getTotalSize: jest.fn().mockReturnValue(150), + measure: jest.fn(), options: { scrollMargin: 0, }, diff --git a/static/app/views/explore/logs/tables/logsInfiniteTable.tsx b/static/app/views/explore/logs/tables/logsInfiniteTable.tsx index ef55e746115331..bbc51f1fde38d3 100644 --- a/static/app/views/explore/logs/tables/logsInfiniteTable.tsx +++ b/static/app/views/explore/logs/tables/logsInfiniteTable.tsx @@ -1,5 +1,14 @@ -import type {CSSProperties, RefObject} from 'react'; -import {Fragment, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import { + Fragment, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type RefObject, +} from 'react'; import styled from '@emotion/styled'; import * as Sentry from '@sentry/react'; import type {Virtualizer} from '@tanstack/react-virtual'; @@ -22,7 +31,6 @@ import type {Event} from 'sentry/types/event'; import type {TagCollection} from 'sentry/types/group'; import {defined} from 'sentry/utils'; import { - Table, TableBodyCell, TableHead, TableHeadCell, @@ -44,6 +52,7 @@ import { FloatingBottomContainer, HoveringRowLoadingRendererContainer, LOGS_GRID_BODY_ROW_HEIGHT, + LogTable, LogTableBody, LogTableRow, } from 'sentry/views/explore/logs/styles'; @@ -89,28 +98,27 @@ type LogsTableProps = { showVerticalScrollbar?: boolean; }; emptyRenderer?: () => React.ReactNode; + expanded?: boolean; localOnlyItemFilters?: { filterText: string; filteredItems: OurLogsResponseItem[]; }; numberAttributes?: TagCollection; - scrollContainer?: React.RefObject; stringAttributes?: TagCollection; }; const {info, fmt} = Sentry.logger; const LOGS_GRID_SCROLL_PIXEL_REVERSE_THRESHOLD = LOGS_GRID_BODY_ROW_HEIGHT * 2; // If you are less than this number of pixels from the top of the table while scrolling backward, fetch the previous page. -const LOGS_OVERSCAN_AMOUNT = 50; // How many items to render beyond the visible area. export function LogsInfiniteTable({ embedded = false, + expanded, localOnlyItemFilters, emptyRenderer, numberAttributes, stringAttributes, booleanAttributes, - scrollContainer, embeddedStyling, embeddedOptions, additionalData, @@ -255,23 +263,30 @@ export function LogsInfiniteTable({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchString, localOnlyItemFilters?.filterText]); + const isContainedVirtualizer = expanded !== undefined; + const windowVirtualizer = useWindowVirtualizer({ - count: data?.length ?? 0, + count: isContainedVirtualizer ? 0 : (data?.length ?? 0), estimateSize, - overscan: LOGS_OVERSCAN_AMOUNT, + overscan: 50, getItemKey: (index: number) => data?.[index]?.[OurLogKnownFieldKey.ID] ?? index, scrollMargin: tableBodyRef.current?.offsetTop ?? 0, }); - const containerVirtualizer = useVirtualizer({ - count: data?.length ?? 0, + const containerVirtualizer = useVirtualizer({ + count: isContainedVirtualizer ? (data?.length ?? 0) : 0, estimateSize, - overscan: LOGS_OVERSCAN_AMOUNT, - getScrollElement: () => scrollContainer?.current ?? null, + overscan: expanded ? 50 : 25, + getScrollElement: () => tableBodyRef?.current, getItemKey: (index: number) => data?.[index]?.[OurLogKnownFieldKey.ID] ?? index, }); - const virtualizer = scrollContainer?.current ? containerVirtualizer : windowVirtualizer; + const virtualizer = isContainedVirtualizer ? containerVirtualizer : windowVirtualizer; + + useLayoutEffect(() => { + virtualizer.measure(); + }, [expanded, virtualizer]); + const virtualItems = virtualizer.getVirtualItems(); const firstItem = virtualItems[0]?.start; @@ -292,13 +307,13 @@ export function LogsInfiniteTable({ useEffect(() => { if ( pseudoRowIndex !== -1 && - scrollContainer?.current && + tableBodyRef?.current && !additionalData?.scrollToDisabled ) { setTimeout(() => { const scrollToIndex = pseudoRowIndex === -2 ? baseDataLength.current : pseudoRowIndex; - containerVirtualizer.scrollToIndex(scrollToIndex, { + virtualizer.scrollToIndex(scrollToIndex, { behavior: 'smooth', align: 'center', }); @@ -306,8 +321,8 @@ export function LogsInfiniteTable({ } }, [ pseudoRowIndex, - containerVirtualizer, - scrollContainer, + virtualizer, + tableBodyRef, baseDataLength, additionalData?.scrollToDisabled, ]); @@ -336,9 +351,7 @@ export function LogsInfiniteTable({ ] : [0, 0]; - const {scrollDirection, scrollOffset, isScrolling} = scrollContainer - ? containerVirtualizer - : virtualizer; + const {scrollDirection, scrollOffset, isScrolling} = virtualizer; useEffect(() => { if (isFunctionScrolling && !isScrolling && scrollOffset === 0) { @@ -451,10 +464,11 @@ export function LogsInfiniteTable({ return ( -
{paddingTop > 0 && ( @@ -537,10 +552,10 @@ export function LogsInfiniteTable({ )} -
+ {!embeddedOptions?.replay && ( diff --git a/static/app/views/explore/logs/tables/useTableExpando.spec.tsx b/static/app/views/explore/logs/tables/useTableExpando.spec.tsx new file mode 100644 index 00000000000000..50e420b3ee56a7 --- /dev/null +++ b/static/app/views/explore/logs/tables/useTableExpando.spec.tsx @@ -0,0 +1,33 @@ +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import {useTableExpando} from './useTableExpando'; + +function ExpandoWrapper() { + const {button} = useTableExpando(); + return
{button}
; +} + +describe('useTableExpando', () => { + it('is contracted when the button is not yet clicked', () => { + render(); + + expect(screen.getByText('Expand')).toBeInTheDocument(); + }); + + it('expands when the button is clicked', async () => { + render(); + + await userEvent.click(screen.getByText('Expand')); + + expect(screen.getByText('Collapse')).toBeInTheDocument(); + }); + + it('contracts when the button is clicked a second time', async () => { + render(); + + await userEvent.click(screen.getByText('Expand')); + await userEvent.click(screen.getByText('Collapse')); + + expect(screen.getByText('Expand')).toBeInTheDocument(); + }); +}); diff --git a/static/app/views/explore/logs/tables/useTableExpando.tsx b/static/app/views/explore/logs/tables/useTableExpando.tsx new file mode 100644 index 00000000000000..891dd488355a21 --- /dev/null +++ b/static/app/views/explore/logs/tables/useTableExpando.tsx @@ -0,0 +1,55 @@ +import {useCallback, useState} from 'react'; +import styled from '@emotion/styled'; + +import {Button} from '@sentry/scraps/button'; + +import {IconContract, IconExpand} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {useLocation} from 'sentry/utils/useLocation'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {TableActionButton} from 'sentry/views/explore/components/tableActionButton'; + +export interface TableExpando { + button: React.ReactNode; + enabled: boolean; + expanded: boolean | undefined; +} + +export function useTableExpando(): TableExpando { + const organization = useOrganization(); + const location = useLocation(); + const enabled = + organization.features.includes('ourlogs-table-expando') || + location.query.logsTableExpando === 'true'; + const [expandedState, setExpandedState] = useState(false); + + const [Icon, text] = expandedState + ? [IconContract, t('Collapse')] + : [IconExpand, t('Expand')]; + + const toggleExpanded = useCallback(() => { + setExpandedState(previousExpanded => !previousExpanded); + }, []); + + const buttonProps = { + 'aria-label': text, + icon: , + onClick: toggleExpanded, + size: 'sm', + } as const; + + return { + button: ( + {text}} + mobile={} + /> + ), + enabled, + expanded: enabled ? expandedState : undefined, + }; +} + +const ExpandoButton = styled(Button)` + scroll-margin-top: ${p => p.theme.space.md}; +`; diff --git a/static/app/views/performance/newTraceDetails/index.tsx b/static/app/views/performance/newTraceDetails/index.tsx index adedd207d7155e..3775bee5ecbe9b 100644 --- a/static/app/views/performance/newTraceDetails/index.tsx +++ b/static/app/views/performance/newTraceDetails/index.tsx @@ -137,7 +137,6 @@ function TraceViewImpl({traceSlug}: {traceSlug: string}) { logs: logsData, traceId: traceSlug, }); - const traceInnerLayoutRef = useRef(null); const {tabOptions, currentTab, onTabChange} = useTraceLayoutTabs({ tree, @@ -162,7 +161,7 @@ function TraceViewImpl({traceSlug}: {traceSlug: string}) { logs={logsData} metrics={metricsData} /> - + ) : null} - {currentTab === TraceLayoutTabKeys.LOGS ? ( - - ) : null} + {currentTab === TraceLayoutTabKeys.LOGS ? : null} {currentTab === TraceLayoutTabKeys.METRICS ? ( diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/components/logDetails.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/components/logDetails.tsx index 8f491e49ff54da..4d7a77f55a7c90 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/components/logDetails.tsx +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/components/logDetails.tsx @@ -1,5 +1,3 @@ -import {useRef} from 'react'; - import {t} from 'sentry/locale'; import {useLogsPageDataQueryResult} from 'sentry/views/explore/contexts/logs/logsPageData'; import {LogsInfiniteTable} from 'sentry/views/explore/logs/tables/logsInfiniteTable'; @@ -9,18 +7,16 @@ import {FoldSection} from 'sentry/views/issueDetails/streamline/foldSection'; export function LogDetails() { const logsQueryResult = useLogsPageDataQueryResult(); - const scrollContainer = useRef(null); if (!logsQueryResult?.data?.length) { return null; } return ( - + ); } diff --git a/static/app/views/performance/newTraceDetails/traceOurlogs.spec.tsx b/static/app/views/performance/newTraceDetails/traceOurlogs.spec.tsx index c9faae3d9139cf..5ea45a120794fc 100644 --- a/static/app/views/performance/newTraceDetails/traceOurlogs.spec.tsx +++ b/static/app/views/performance/newTraceDetails/traceOurlogs.spec.tsx @@ -1,8 +1,8 @@ -import {useRef} from 'react'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {render, screen} from 'sentry-test/reactTestingLibrary'; +import {mockGetBoundingClientRect} from 'sentry/utils/fixtures/virtualization'; import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; import { TraceViewLogsDataProvider, @@ -12,14 +12,15 @@ import { const TRACE_SLUG = '00000000000000000000000000000000'; function Component({traceSlug}: {traceSlug: string}) { - const ref = useRef(null); return ( - + ); } +beforeEach(mockGetBoundingClientRect); + describe('TraceViewLogsSection', () => { it('renders empty logs', async () => { const organization = OrganizationFixture({features: ['ourlogs-enabled']}); diff --git a/static/app/views/performance/newTraceDetails/traceOurlogs.tsx b/static/app/views/performance/newTraceDetails/traceOurlogs.tsx index eb7e46aae42ba8..6f0a5c9c6c78db 100644 --- a/static/app/views/performance/newTraceDetails/traceOurlogs.tsx +++ b/static/app/views/performance/newTraceDetails/traceOurlogs.tsx @@ -41,23 +41,15 @@ export function TraceViewLogsDataProvider({ ); } -export function TraceViewLogsSection({ - scrollContainer, -}: { - scrollContainer: React.RefObject; -}) { +export function TraceViewLogsSection() { return ( - + ); } -function LogsSectionContent({ - scrollContainer, -}: { - scrollContainer: React.RefObject; -}) { +function LogsSectionContent() { const organization = useOrganization(); const {selection} = usePageFilters(); const traceIds = useLogsFrozenTraceIds(); @@ -85,7 +77,7 @@ function LogsSectionContent({ {t('Open in Logs')}
- + ); @@ -93,9 +85,18 @@ function LogsSectionContent({ const TableContainer = styled('div')` margin-top: ${p => p.theme.space.xl}; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; `; const StyledPanel = styled(Panel)` padding: ${p => p.theme.space.xl}; + padding-bottom: 0; margin: 0; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; `; diff --git a/static/app/views/replays/detail/ourlogs/index.tsx b/static/app/views/replays/detail/ourlogs/index.tsx index 04949f05f6e2dd..523488db1e03a0 100644 --- a/static/app/views/replays/detail/ourlogs/index.tsx +++ b/static/app/views/replays/detail/ourlogs/index.tsx @@ -1,4 +1,4 @@ -import {useCallback, useMemo, useRef} from 'react'; +import {useCallback, useMemo} from 'react'; import styled from '@emotion/styled'; import {Placeholder} from 'sentry/components/placeholder'; @@ -73,7 +73,6 @@ function OurLogsContent({replayId, startTimestampMs}: OurLogsContentProps) { const {attributes: stringAttributes} = useLogItemAttributes({}, 'string'); const {attributes: numberAttributes} = useLogItemAttributes({}, 'number'); const {attributes: booleanAttributes} = useLogItemAttributes({}, 'boolean'); - const scrollContainerRef = useRef(null); const {currentTime, setCurrentTime} = useReplayContext(); const [currentHoverTime] = useCurrentHoverTime(); @@ -121,7 +120,7 @@ function OurLogsContent({replayId, startTimestampMs}: OurLogsContentProps) { return ( - + {isPending ? ( ) : ( @@ -129,10 +128,10 @@ function OurLogsContent({replayId, startTimestampMs}: OurLogsContentProps) { stringAttributes={stringAttributes} numberAttributes={numberAttributes} booleanAttributes={booleanAttributes} - scrollContainer={scrollContainerRef} allowPagination embedded embeddedOptions={embeddedOptions} + expanded localOnlyItemFilters={{ filteredItems: filteredLogItems, filterText: filterProps.searchTerm, @@ -164,10 +163,10 @@ const BorderedSection = styled(FluidHeight)<{isStatus?: boolean}>` `; const TableScrollContainer = styled('div')` - overflow-y: auto; - overflow-x: hidden; - height: 100%; - min-height: 0; + overflow-y: hidden; + position: relative; + display: flex; + flex-direction: column; border: 1px solid ${p => p.theme.tokens.border.primary}; border-radius: ${p => p.theme.radius.md}; `;