Skip to content

Commit 8d995ab

Browse files
mtopo27claude
andauthored
feat(preprod): Auto-filter installable:true when switching to Distribution view (#112533)
Auto-populate `installable:true` in the search bar when a user switches to the Distribution display on the mobile builds tab. The token is visible and removable by the user. Switching away from Distribution strips the token automatically. Direct navigation to a Distribution URL (e.g. shared links) respects the URL as-is — no automatic injection. This keeps shared links faithful to what the sender intended. Uses `MutableSearch` for safe token manipulation (handles duplicates, replaces `installable:false`, etc). **Files changed:** - New `installableQueryUtils.ts` helper with `addInstallableFilter` / `removeInstallableFilter` - Updated `handleDisplayChange` in both `mobileBuilds.tsx` and `preprodBuilds.tsx` - Unit tests for helpers + integration tests for display switching https://github.com/user-attachments/assets/a8fcc4f2-970e-44e5-8468-4eb92ed58c06 Refs EME-1017 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dc7f396 commit 8d995ab

File tree

5 files changed

+177
-44
lines changed

5 files changed

+177
-44
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import {PreprodBuildsDisplay} from 'sentry/components/preprod/preprodBuildsDisplay';
2+
3+
import {
4+
addInstallableFilter,
5+
getUpdatedQueryForDisplay,
6+
removeInstallableFilter,
7+
} from './installableQueryUtils';
8+
9+
describe('addInstallableFilter', () => {
10+
it('adds installable:true to an empty query', () => {
11+
expect(addInstallableFilter('')).toBe('installable:true');
12+
});
13+
14+
it('appends installable:true to an existing query', () => {
15+
expect(addInstallableFilter('app_id:com.example')).toBe(
16+
'app_id:com.example installable:true'
17+
);
18+
});
19+
20+
it('does not double-add if already present', () => {
21+
expect(addInstallableFilter('installable:true')).toBe('installable:true');
22+
});
23+
24+
it('does not double-add when mixed with other tokens', () => {
25+
expect(addInstallableFilter('app_id:com.example installable:true')).toBe(
26+
'app_id:com.example installable:true'
27+
);
28+
});
29+
30+
it('replaces installable:false with installable:true', () => {
31+
expect(addInstallableFilter('installable:false')).toBe('installable:true');
32+
});
33+
});
34+
35+
describe('removeInstallableFilter', () => {
36+
it('removes installable:true from a query', () => {
37+
expect(removeInstallableFilter('app_id:com.example installable:true')).toBe(
38+
'app_id:com.example'
39+
);
40+
});
41+
42+
it('returns empty string when only installable:true', () => {
43+
expect(removeInstallableFilter('installable:true')).toBe('');
44+
});
45+
46+
it('is a no-op when installable is not present', () => {
47+
expect(removeInstallableFilter('app_id:com.example')).toBe('app_id:com.example');
48+
});
49+
50+
it('returns empty string for empty input', () => {
51+
expect(removeInstallableFilter('')).toBe('');
52+
});
53+
});
54+
55+
describe('getUpdatedQueryForDisplay', () => {
56+
it('adds installable:true for Distribution display', () => {
57+
expect(getUpdatedQueryForDisplay('', PreprodBuildsDisplay.DISTRIBUTION)).toBe(
58+
'installable:true'
59+
);
60+
});
61+
62+
it('removes installable filter for Size display', () => {
63+
expect(
64+
getUpdatedQueryForDisplay('installable:true', PreprodBuildsDisplay.SIZE)
65+
).toBeUndefined();
66+
});
67+
68+
it('handles null query', () => {
69+
expect(getUpdatedQueryForDisplay(null, PreprodBuildsDisplay.DISTRIBUTION)).toBe(
70+
'installable:true'
71+
);
72+
});
73+
74+
it('preserves other tokens when switching to Distribution', () => {
75+
expect(
76+
getUpdatedQueryForDisplay('app_id:com.example', PreprodBuildsDisplay.DISTRIBUTION)
77+
).toBe('app_id:com.example installable:true');
78+
});
79+
80+
it('returns undefined when result is empty', () => {
81+
expect(getUpdatedQueryForDisplay('', PreprodBuildsDisplay.SIZE)).toBeUndefined();
82+
});
83+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import {PreprodBuildsDisplay} from 'sentry/components/preprod/preprodBuildsDisplay';
2+
import {MutableSearch} from 'sentry/components/searchSyntax/mutableSearch';
3+
4+
const INSTALLABLE_KEY = 'installable';
5+
const INSTALLABLE_VALUE = 'true';
6+
7+
/**
8+
* Adds `installable:true` to a search query string if not already present.
9+
*/
10+
export function addInstallableFilter(query: string): string {
11+
const search = new MutableSearch(query);
12+
const existing = search.getFilterValues(INSTALLABLE_KEY);
13+
if (existing.includes(INSTALLABLE_VALUE)) {
14+
return query;
15+
}
16+
search.setFilterValues(INSTALLABLE_KEY, [INSTALLABLE_VALUE]);
17+
return search.formatString();
18+
}
19+
20+
/**
21+
* Removes the `installable` filter from a search query string.
22+
*/
23+
export function removeInstallableFilter(query: string): string {
24+
const search = new MutableSearch(query);
25+
search.removeFilter(INSTALLABLE_KEY);
26+
return search.formatString();
27+
}
28+
29+
/**
30+
* Returns the updated query string for a display change.
31+
* Distribution display adds `installable:true`; other displays strip it.
32+
*/
33+
export function getUpdatedQueryForDisplay(
34+
currentQuery: string | null,
35+
display: PreprodBuildsDisplay
36+
): string | undefined {
37+
const trimmed = (currentQuery ?? '').trim();
38+
const updated =
39+
display === PreprodBuildsDisplay.DISTRIBUTION
40+
? addInstallableFilter(trimmed)
41+
: removeInstallableFilter(trimmed);
42+
return updated || undefined;
43+
}

static/app/views/releases/detail/commitsAndFiles/preprodBuilds.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {useCallback, useContext, useEffect, useMemo, useState} from 'react';
1+
import {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
22
import {useQuery} from '@tanstack/react-query';
33

44
import {Container} from '@sentry/scraps/layout';
@@ -26,6 +26,7 @@ import {formatVersion} from 'sentry/utils/versions/formatVersion';
2626
import {usePreprodBuildsAnalytics} from 'sentry/views/preprod/hooks/usePreprodBuildsAnalytics';
2727
import type {BuildDetailsApiResponse} from 'sentry/views/preprod/types/buildDetailsTypes';
2828
import {buildDetailsApiOptions} from 'sentry/views/preprod/utils/buildDetailsApiOptions';
29+
import {getUpdatedQueryForDisplay} from 'sentry/views/preprod/utils/installableQueryUtils';
2930
import {ReleaseContext} from 'sentry/views/releases/detail';
3031

3132
import {PreprodOnboarding} from './preprodOnboarding';
@@ -53,12 +54,18 @@ export default function PreprodBuilds() {
5354

5455
const [localSearchQuery, setLocalSearchQuery] = useState(urlSearchQuery || '');
5556
const debouncedLocalSearchQuery = useDebouncedValue(localSearchQuery);
57+
const prevDebouncedRef = useRef(debouncedLocalSearchQuery);
5658

5759
useEffect(() => {
5860
setLocalSearchQuery(urlSearchQuery || '');
5961
}, [urlSearchQuery]);
6062

6163
useEffect(() => {
64+
if (debouncedLocalSearchQuery === prevDebouncedRef.current) {
65+
return;
66+
}
67+
prevDebouncedRef.current = debouncedLocalSearchQuery;
68+
6269
if (debouncedLocalSearchQuery !== (urlSearchQuery || '')) {
6370
navigate({
6471
...location,
@@ -129,10 +136,11 @@ export default function PreprodBuilds() {
129136
...location.query,
130137
cursor: undefined,
131138
display,
139+
query: getUpdatedQueryForDisplay(urlSearchQuery, display),
132140
},
133141
});
134142
},
135-
[location, navigate]
143+
[location, navigate, urlSearchQuery]
136144
);
137145

138146
const builds = buildsResponse?.json ?? [];

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

Lines changed: 33 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -559,82 +559,75 @@ describe('ReleasesList', () => {
559559
);
560560
});
561561

562-
it('toggles display mode in the mobile-builds tab', async () => {
563-
const organizationWithDistribution = OrganizationFixture({
564-
slug: organization.slug,
565-
features: [...organization.features],
566-
});
562+
function renderMobileBuildsTab(queryOverrides: Record<string, string> = {}) {
567563
const mobileProject = ProjectFixture({
568564
id: '15',
569565
slug: 'mobile-project-4',
570566
platform: 'android',
571567
features: ['releases'],
572568
});
573-
574569
ProjectsStore.loadInitialData([mobileProject]);
575570
PageFiltersStore.updateProjects([Number(mobileProject.id)], null);
576571

577572
MockApiClient.addMockResponse({
578573
url: `/organizations/${organization.slug}/builds/`,
579-
body: [
580-
{
581-
id: 'build-id',
582-
project_id: 15,
583-
project_slug: 'mobile-project-4',
584-
state: 1,
585-
app_info: {
586-
app_id: 'com.example.app',
587-
name: 'Example App',
588-
platform: 'android',
589-
build_number: '1',
590-
version: '1.0.0',
591-
date_added: '2024-01-01T00:00:00Z',
592-
},
593-
distribution_info: {
594-
is_installable: true,
595-
download_count: 12,
596-
release_notes: null,
597-
},
598-
size_info: {},
599-
vcs_info: {
600-
head_sha: 'abcdef1',
601-
pr_number: 123,
602-
head_ref: 'main',
603-
},
604-
},
605-
],
574+
body: [],
606575
});
607-
608576
MockApiClient.addMockResponse({
609577
url: `/organizations/${organization.slug}/recent-searches/`,
610578
body: [],
611579
});
612-
580+
MockApiClient.addMockResponse({
581+
url: `/organizations/${organization.slug}/recent-searches/`,
582+
method: 'POST',
583+
body: [],
584+
});
613585
MockApiClient.addMockResponse({
614586
url: `/organizations/${organization.slug}/trace-items/attributes/`,
615587
body: [],
616588
});
617589

618-
const {router} = render(<ReleasesList />, {
619-
organization: organizationWithDistribution,
590+
return render(<ReleasesList />, {
591+
organization,
620592
initialRouterConfig: {
621593
location: {
622594
pathname: `/organizations/${organization.slug}/releases/`,
623-
query: {tab: 'mobile-builds', cursor: '123', display: 'users'},
595+
query: {tab: 'mobile-builds', ...queryOverrides},
624596
},
625597
},
626598
});
599+
}
627600

628-
expect(await screen.findByText('Example App')).toBeInTheDocument();
601+
it('toggles display mode in the mobile-builds tab and injects installable:true', async () => {
602+
const {router} = renderMobileBuildsTab();
629603

630-
const displayTrigger = screen.getByRole('button', {name: 'Display Size'});
604+
const displayTrigger = await screen.findByRole('button', {name: 'Display Size'});
631605
await userEvent.click(displayTrigger);
632606

633607
const distributionOption = screen.getByRole('option', {name: 'Distribution'});
634608
await userEvent.click(distributionOption);
635609

636610
expect(router.location.query.display).toBe(PreprodBuildsDisplay.DISTRIBUTION);
637611
expect(router.location.query.cursor).toBeUndefined();
612+
expect(router.location.query.query).toBe('installable:true');
613+
});
614+
615+
it('strips installable:true when switching from Distribution to Size', async () => {
616+
const {router} = renderMobileBuildsTab({
617+
display: 'distribution',
618+
query: 'installable:true',
619+
});
620+
621+
const displayTrigger = await screen.findByRole('button', {
622+
name: 'Display Distribution',
623+
});
624+
await userEvent.click(displayTrigger);
625+
626+
const sizeOption = screen.getByRole('option', {name: 'Size'});
627+
await userEvent.click(sizeOption);
628+
629+
expect(router.location.query.display).toBe(PreprodBuildsDisplay.SIZE);
630+
expect(router.location.query.query).toBeFalsy();
638631
});
639632

640633
it('allows searching within the mobile-builds tab', async () => {

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {useLocation} from 'sentry/utils/useLocation';
2222
import {useNavigate} from 'sentry/utils/useNavigate';
2323
import {usePreprodBuildsAnalytics} from 'sentry/views/preprod/hooks/usePreprodBuildsAnalytics';
2424
import {buildDetailsApiOptions} from 'sentry/views/preprod/utils/buildDetailsApiOptions';
25+
import {getUpdatedQueryForDisplay} from 'sentry/views/preprod/utils/installableQueryUtils';
2526

2627
import {MobileBuildsChart} from './mobileBuildsChart';
2728

@@ -95,10 +96,15 @@ export function MobileBuilds({organization, selectedProjectIds}: Props) {
9596
(display: PreprodBuildsDisplay) => {
9697
navigate({
9798
...location,
98-
query: {...location.query, cursor: undefined, display},
99+
query: {
100+
...location.query,
101+
cursor: undefined,
102+
display,
103+
query: getUpdatedQueryForDisplay(searchQuery, display),
104+
},
99105
});
100106
},
101-
[location, navigate]
107+
[location, navigate, searchQuery]
102108
);
103109

104110
const builds = buildsResponse?.json ?? [];

0 commit comments

Comments
 (0)