diff --git a/static/app/views/automations/list.spec.tsx b/static/app/views/automations/list.spec.tsx index 0da895264313..8d3a458e0c15 100644 --- a/static/app/views/automations/list.spec.tsx +++ b/static/app/views/automations/list.spec.tsx @@ -81,6 +81,68 @@ describe('AutomationsList', () => { expect(within(row).getByText('1 monitor')).toBeInTheDocument(); }); + it('displays capped result counts as a lower bound', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/workflows/', + body: Array.from({length: 20}, (_, index) => + AutomationFixture({id: `${index}`, name: `Automation ${index}`, detectorIds: []}) + ), + headers: { + Link: '; rel="previous"; results="false"; cursor="0:0:1", ; rel="next"; results="true"; cursor="0:1:0"', + 'X-Hits': '1000', + 'X-Max-Hits': '1000', + }, + }); + + render(, {organization}); + + expect(await screen.findByTestId('pagination')).toHaveTextContent('1-20 of 1000+'); + }); + + it('falls back to the default hit ceiling when X-Max-Hits is missing', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/workflows/', + body: Array.from({length: 20}, (_, index) => + AutomationFixture({id: `${index}`, name: `Automation ${index}`, detectorIds: []}) + ), + headers: { + Link: '; rel="previous"; results="false"; cursor="0:0:1", ; rel="next"; results="true"; cursor="0:1:0"', + 'X-Hits': '1000', + }, + }); + + render(, {organization}); + + expect(await screen.findByTestId('pagination')).toHaveTextContent('1-20 of 1000+'); + }); + + it('treats totals as capped when the page starts past X-Hits', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/workflows/', + body: Array.from({length: 20}, (_, index) => + AutomationFixture({id: `${index}`, name: `Automation ${index}`, detectorIds: []}) + ), + headers: { + Link: '; rel="previous"; results="true"; cursor="0:50:1", ; rel="next"; results="true"; cursor="0:52:0"', + 'X-Hits': '1000', + }, + }); + + render(, { + organization, + initialRouterConfig: { + location: { + pathname: '/organizations/org-slug/monitors/alerts/', + query: {cursor: '0:51:0'}, + }, + }, + }); + + expect(await screen.findByTestId('pagination')).toHaveTextContent( + '1,021-1,040 of 1000+' + ); + }); + it('displays connected detectors and projects via a single batch request', async () => { const project2 = ProjectFixture({id: '2', slug: 'project-2'}); const detector2 = MetricDetectorFixture({ diff --git a/static/app/views/automations/list.tsx b/static/app/views/automations/list.tsx index a870bf702978..ba1de3c5528c 100644 --- a/static/app/views/automations/list.tsx +++ b/static/app/views/automations/list.tsx @@ -3,15 +3,17 @@ import {useQuery} from '@tanstack/react-query'; import {LinkButton} from '@sentry/scraps/button'; import {Flex} from '@sentry/scraps/layout'; -import {getPaginationCaption, Pagination} from '@sentry/scraps/pagination'; +import {Pagination} from '@sentry/scraps/pagination'; import {ProjectPageFilter} from 'sentry/components/pageFilters/project/projectPageFilter'; +import {QueryCount} from 'sentry/components/queryCount'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {AlertsMonitorsShowcaseButton} from 'sentry/components/workflowEngine/alertsMonitorsShowcaseButton'; import {WorkflowEngineListLayout as ListLayout} from 'sentry/components/workflowEngine/layout/list'; import {IconAdd} from 'sentry/icons'; -import {t} from 'sentry/locale'; +import {t, tct} from 'sentry/locale'; import {selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; +import {parseCursor} from 'sentry/utils/cursor'; import {parseLinkHeader} from 'sentry/utils/parseLinkHeader'; import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry'; import {useLocation} from 'sentry/utils/useLocation'; @@ -41,8 +43,9 @@ export default function AutomationsList() { const automations = data?.json; const hits = data?.headers['X-Hits'] ?? 0; - // If maxHits is not set, we assume there is no max - const maxHits = data?.headers['X-Max-Hits'] ?? Infinity; + // OffsetPaginator currently omits X-Max-Hits. Fall back to the server's default + // hit ceiling so early pages can still render lower-bound totals like "1000+". + const maxHits = data?.headers['X-Max-Hits'] ?? 1000; const pageLinks = data?.headers.Link; const allResultsVisible = useCallback(() => { @@ -53,15 +56,24 @@ export default function AutomationsList() { return links && !links.previous!.results && !links.next!.results; }, [pageLinks]); - const paginationCaption = - isLoading || !automations - ? undefined - : getPaginationCaption({ - cursor, - limit: AUTOMATION_LIST_PAGE_LIMIT, - pageLength: automations.length, - total: hits, - }); + const offset = parseCursor(cursor)?.offset ?? 0; + const pageStart = offset * AUTOMATION_LIST_PAGE_LIMIT + 1; + // Also treat deep pages past the reported hit count as capped, in case the + // response omits both a useful max and a stable ceiling. + const isCappedTotal = hits >= maxHits || pageStart > hits; + const cappedTotal = isCappedTotal ? maxHits : undefined; + const queryCount = isCappedTotal ? `${cappedTotal}+` : `${hits}`; + + let paginationCaption: React.ReactNode; + if (!isLoading && automations && automations.length > 0) { + const end = pageStart + automations.length - 1; + + paginationCaption = tct('[start]-[end] of [total]', { + start: pageStart.toLocaleString(), + end: end.toLocaleString(), + total: , + }); + } return ( @@ -86,7 +98,7 @@ export default function AutomationsList() { isError={isError} isSuccess={isSuccess} sort={sort} - queryCount={hits > maxHits ? `${maxHits}+` : `${hits}`} + queryCount={queryCount} allResultsVisible={allResultsVisible()} />