Skip to content

Commit 4face8e

Browse files
jamieQclaude
andauthored
feat(preprod): Add Download CSV button to build distribution view (#117927)
Adds a "Download CSV" button to the mobile-builds distribution view (shown only on the distribution display) that exports build distribution stats. It uses a native browser download — an anchor pointed at the `/builds-export/` endpoint — so the browser saves the file from the response's `Content-Disposition`. This is the simplest fit for a low-volume, row-capped export. A small, unit-tested `getBuildsExportHref` helper builds the URL from the list filters. The backend `/builds-export/` endpoint landed separately in #117539 (frontend and backend ship as separate PRs). Refs EME-1035 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent df21186 commit 4face8e

6 files changed

Lines changed: 92 additions & 0 deletions

File tree

static/app/components/preprod/preprodBuildsSearchControls.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import {Button} from '@sentry/scraps/button';
12
import {CompactSelect, type SelectOption} from '@sentry/scraps/compactSelect';
23
import {Container, Flex} from '@sentry/scraps/layout';
34
import {OverlayTrigger} from '@sentry/scraps/overlayTrigger';
45

56
import {MOBILE_BUILDS_ALLOWED_KEYS} from 'sentry/components/preprod/constants';
67
import {PreprodBuildsDisplay} from 'sentry/components/preprod/preprodBuildsDisplay';
78
import {PreprodSearchBar} from 'sentry/components/preprod/preprodSearchBar';
9+
import {IconDownload} from 'sentry/icons';
810
import {t} from 'sentry/locale';
911

1012
const displaySelectOptions: Array<SelectOption<PreprodBuildsDisplay>> = [
@@ -43,6 +45,10 @@ interface PreprodBuildsSearchControlsProps {
4345
* Called on every keystroke (for controlled input with debounce)
4446
*/
4547
onChange?: (query: string, state: {queryIsValid: boolean}) => void;
48+
/**
49+
* When provided, renders a "Download CSV" button in the controls.
50+
*/
51+
onExportCsv?: () => void;
4652
/**
4753
* Called when search is submitted (e.g., on Enter)
4854
*/
@@ -62,6 +68,7 @@ export function PreprodBuildsSearchControls({
6268
onChange,
6369
onSearch,
6470
onDisplayChange,
71+
onExportCsv,
6572
}: PreprodBuildsSearchControlsProps) {
6673
return (
6774
<Flex
@@ -79,6 +86,11 @@ export function PreprodBuildsSearchControls({
7986
projects={projects}
8087
/>
8188
</Container>
89+
{onExportCsv && (
90+
<Button icon={<IconDownload />} onClick={onExportCsv}>
91+
{t('Download CSV')}
92+
</Button>
93+
)}
8294
{!hideDisplayToggle && (
8395
<Container maxWidth="200px">
8496
<CompactSelect

static/app/utils/analytics/preprodBuildAnalyticsEvents.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export type PreprodBuildEventParameters = {
4444
'preprod.builds.details.open_insights_sidebar': BasePreprodBuildEvent & {
4545
source: 'metric_card' | 'insight_table';
4646
};
47+
'preprod.builds.distribution.download_csv': BasePreprodBuildEvent;
4748
'preprod.builds.install_modal.opened': BasePreprodBuildEvent & {
4849
source: 'build_details_sidebar' | 'builds_table';
4950
};
@@ -144,6 +145,7 @@ export const preprodBuildEventMap: Record<PreprodBuildAnalyticsKey, string | nul
144145
'preprod.builds.compare.select_base_build': 'Preprod Build Comparison: Base Selected',
145146
'preprod.builds.compare.trigger_comparison':
146147
'Preprod Build Comparison: Compare Triggered',
148+
'preprod.builds.distribution.download_csv': 'Preprod Builds: Distribution Download CSV',
147149
'preprod.builds.install_modal.opened': 'Preprod Builds: Install Modal Opened',
148150
'preprod.builds.onboarding.viewed': 'Preprod Builds: Onboarding Viewed',
149151
'preprod.builds.onboarding.docs_clicked': 'Preprod Builds: Onboarding Docs Clicked',

static/app/views/explore/releases/list/index.spec.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,18 @@ describe('ReleasesList', () => {
631631
expect(router.location.query.query).toBeFalsy();
632632
});
633633

634+
it('shows the Download CSV button on the distribution display', async () => {
635+
renderMobileBuildsTab({display: 'distribution'});
636+
expect(await screen.findByRole('button', {name: 'Download CSV'})).toBeInTheDocument();
637+
});
638+
639+
it('hides the Download CSV button on the size display', async () => {
640+
renderMobileBuildsTab();
641+
// Wait for the controls to render before asserting the button is absent.
642+
expect(await screen.findByRole('button', {name: 'Display Size'})).toBeInTheDocument();
643+
expect(screen.queryByRole('button', {name: 'Download CSV'})).not.toBeInTheDocument();
644+
});
645+
634646
it('allows searching within the mobile-builds tab', async () => {
635647
const mobileProject = ProjectFixture({
636648
id: '13',

static/app/views/explore/releases/list/mobileBuilds.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ import {ProjectsStore} from 'sentry/stores/projectsStore';
2323
import type {Organization} from 'sentry/types/organization';
2424
import {trackAnalytics} from 'sentry/utils/analytics';
2525
import {selectJsonWithHeaders} from 'sentry/utils/api/apiOptions';
26+
import {downloadFromHref} from 'sentry/utils/downloadFromHref';
2627
import {useLocation} from 'sentry/utils/useLocation';
2728
import {useNavigate} from 'sentry/utils/useNavigate';
2829
import {usePreprodBuildsAnalytics} from 'sentry/views/preprod/hooks/usePreprodBuildsAnalytics';
2930
import type {BuildDetailsApiResponse} from 'sentry/views/preprod/types/buildDetailsTypes';
3031
import {buildDetailsApiOptions} from 'sentry/views/preprod/utils/buildDetailsApiOptions';
32+
import {getBuildsExportHref} from 'sentry/views/preprod/utils/buildsExportHref';
3133
import {getUpdatedQueryForDisplay} from 'sentry/views/preprod/utils/installableQueryUtils';
3234

3335
import {MobileBuildsChart} from './mobileBuildsChart';
@@ -120,6 +122,12 @@ export function MobileBuilds({
120122
[location, navigate, searchQuery]
121123
);
122124

125+
const handleExportCsv = useCallback(() => {
126+
const url = `${organization.links.regionUrl}${getBuildsExportHref(organization.slug, buildsQueryParams)}`;
127+
downloadFromHref(`${organization.slug}-build-distribution.csv`, url);
128+
trackAnalytics('preprod.builds.distribution.download_csv', {organization});
129+
}, [organization, buildsQueryParams]);
130+
123131
const builds = buildsResponse?.json ?? [];
124132
const pageLinks = buildsResponse?.headers.Link ?? undefined;
125133
const hasSearchQuery = !!searchQuery?.trim();
@@ -216,6 +224,11 @@ export function MobileBuilds({
216224
hideDisplayToggle={hideDisplayToggle}
217225
onSearch={handleSearch}
218226
onDisplayChange={handleDisplayChange}
227+
onExportCsv={
228+
activeDisplay === PreprodBuildsDisplay.DISTRIBUTION
229+
? handleExportCsv
230+
: undefined
231+
}
219232
/>
220233

221234
{buildsError && <LoadingError onRetry={refetch} />}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {getBuildsExportHref} from 'sentry/views/preprod/utils/buildsExportHref';
2+
3+
describe('getBuildsExportHref', () => {
4+
it('targets the org-scoped export endpoint', () => {
5+
expect(getBuildsExportHref('my-org', {statsPeriod: '90d'})).toBe(
6+
'/api/0/organizations/my-org/builds-export/?statsPeriod=90d'
7+
);
8+
});
9+
10+
it('drops pagination params', () => {
11+
const href = getBuildsExportHref('my-org', {
12+
per_page: 25,
13+
cursor: '0:100:0',
14+
query: 'foo',
15+
});
16+
expect(href).not.toContain('per_page');
17+
expect(href).not.toContain('cursor');
18+
expect(href).toContain('query=foo');
19+
});
20+
21+
it('serializes multiple projects as repeated params', () => {
22+
const href = getBuildsExportHref('my-org', {
23+
project: ['1', '2'],
24+
});
25+
expect(href).toContain('project=1');
26+
expect(href).toContain('project=2');
27+
});
28+
29+
it('includes the search query and date range', () => {
30+
const href = getBuildsExportHref('my-org', {
31+
query: 'installable:true',
32+
statsPeriod: '90d',
33+
});
34+
expect(decodeURIComponent(href)).toContain('query=installable:true');
35+
expect(href).toContain('statsPeriod=90d');
36+
});
37+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as qs from 'query-string';
2+
3+
/**
4+
* Build the CSV export URL from the builds-list query params.
5+
*/
6+
export function getBuildsExportHref(
7+
organizationSlug: string,
8+
queryParams: Record<string, unknown>
9+
): string {
10+
const exportParams = {...queryParams};
11+
// This endpoint doesn't paginate
12+
delete exportParams.per_page;
13+
delete exportParams.cursor;
14+
15+
return `/api/0/organizations/${organizationSlug}/builds-export/?${qs.stringify(exportParams)}`;
16+
}

0 commit comments

Comments
 (0)