diff --git a/static/app/views/preprod/utils/installableQueryUtils.spec.ts b/static/app/views/preprod/utils/installableQueryUtils.spec.ts new file mode 100644 index 00000000000000..d6d3dc51e647ee --- /dev/null +++ b/static/app/views/preprod/utils/installableQueryUtils.spec.ts @@ -0,0 +1,83 @@ +import {PreprodBuildsDisplay} from 'sentry/components/preprod/preprodBuildsDisplay'; + +import { + addInstallableFilter, + getUpdatedQueryForDisplay, + removeInstallableFilter, +} from './installableQueryUtils'; + +describe('addInstallableFilter', () => { + it('adds installable:true to an empty query', () => { + expect(addInstallableFilter('')).toBe('installable:true'); + }); + + it('appends installable:true to an existing query', () => { + expect(addInstallableFilter('app_id:com.example')).toBe( + 'app_id:com.example installable:true' + ); + }); + + it('does not double-add if already present', () => { + expect(addInstallableFilter('installable:true')).toBe('installable:true'); + }); + + it('does not double-add when mixed with other tokens', () => { + expect(addInstallableFilter('app_id:com.example installable:true')).toBe( + 'app_id:com.example installable:true' + ); + }); + + it('replaces installable:false with installable:true', () => { + expect(addInstallableFilter('installable:false')).toBe('installable:true'); + }); +}); + +describe('removeInstallableFilter', () => { + it('removes installable:true from a query', () => { + expect(removeInstallableFilter('app_id:com.example installable:true')).toBe( + 'app_id:com.example' + ); + }); + + it('returns empty string when only installable:true', () => { + expect(removeInstallableFilter('installable:true')).toBe(''); + }); + + it('is a no-op when installable is not present', () => { + expect(removeInstallableFilter('app_id:com.example')).toBe('app_id:com.example'); + }); + + it('returns empty string for empty input', () => { + expect(removeInstallableFilter('')).toBe(''); + }); +}); + +describe('getUpdatedQueryForDisplay', () => { + it('adds installable:true for Distribution display', () => { + expect(getUpdatedQueryForDisplay('', PreprodBuildsDisplay.DISTRIBUTION)).toBe( + 'installable:true' + ); + }); + + it('removes installable filter for Size display', () => { + expect( + getUpdatedQueryForDisplay('installable:true', PreprodBuildsDisplay.SIZE) + ).toBeUndefined(); + }); + + it('handles null query', () => { + expect(getUpdatedQueryForDisplay(null, PreprodBuildsDisplay.DISTRIBUTION)).toBe( + 'installable:true' + ); + }); + + it('preserves other tokens when switching to Distribution', () => { + expect( + getUpdatedQueryForDisplay('app_id:com.example', PreprodBuildsDisplay.DISTRIBUTION) + ).toBe('app_id:com.example installable:true'); + }); + + it('returns undefined when result is empty', () => { + expect(getUpdatedQueryForDisplay('', PreprodBuildsDisplay.SIZE)).toBeUndefined(); + }); +}); diff --git a/static/app/views/preprod/utils/installableQueryUtils.ts b/static/app/views/preprod/utils/installableQueryUtils.ts new file mode 100644 index 00000000000000..a7cda985d045ff --- /dev/null +++ b/static/app/views/preprod/utils/installableQueryUtils.ts @@ -0,0 +1,43 @@ +import {PreprodBuildsDisplay} from 'sentry/components/preprod/preprodBuildsDisplay'; +import {MutableSearch} from 'sentry/components/searchSyntax/mutableSearch'; + +const INSTALLABLE_KEY = 'installable'; +const INSTALLABLE_VALUE = 'true'; + +/** + * Adds `installable:true` to a search query string if not already present. + */ +export function addInstallableFilter(query: string): string { + const search = new MutableSearch(query); + const existing = search.getFilterValues(INSTALLABLE_KEY); + if (existing.includes(INSTALLABLE_VALUE)) { + return query; + } + search.setFilterValues(INSTALLABLE_KEY, [INSTALLABLE_VALUE]); + return search.formatString(); +} + +/** + * Removes the `installable` filter from a search query string. + */ +export function removeInstallableFilter(query: string): string { + const search = new MutableSearch(query); + search.removeFilter(INSTALLABLE_KEY); + return search.formatString(); +} + +/** + * Returns the updated query string for a display change. + * Distribution display adds `installable:true`; other displays strip it. + */ +export function getUpdatedQueryForDisplay( + currentQuery: string | null, + display: PreprodBuildsDisplay +): string | undefined { + const trimmed = (currentQuery ?? '').trim(); + const updated = + display === PreprodBuildsDisplay.DISTRIBUTION + ? addInstallableFilter(trimmed) + : removeInstallableFilter(trimmed); + return updated || undefined; +} diff --git a/static/app/views/releases/detail/commitsAndFiles/preprodBuilds.tsx b/static/app/views/releases/detail/commitsAndFiles/preprodBuilds.tsx index cecff1e1dfb1d4..863e77cb853ee9 100644 --- a/static/app/views/releases/detail/commitsAndFiles/preprodBuilds.tsx +++ b/static/app/views/releases/detail/commitsAndFiles/preprodBuilds.tsx @@ -1,4 +1,4 @@ -import {useCallback, useContext, useEffect, useMemo, useState} from 'react'; +import {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {useQuery} from '@tanstack/react-query'; import {Container} from '@sentry/scraps/layout'; @@ -26,6 +26,7 @@ import {formatVersion} from 'sentry/utils/versions/formatVersion'; import {usePreprodBuildsAnalytics} from 'sentry/views/preprod/hooks/usePreprodBuildsAnalytics'; import type {BuildDetailsApiResponse} from 'sentry/views/preprod/types/buildDetailsTypes'; import {buildDetailsApiOptions} from 'sentry/views/preprod/utils/buildDetailsApiOptions'; +import {getUpdatedQueryForDisplay} from 'sentry/views/preprod/utils/installableQueryUtils'; import {ReleaseContext} from 'sentry/views/releases/detail'; import {PreprodOnboarding} from './preprodOnboarding'; @@ -53,12 +54,18 @@ export default function PreprodBuilds() { const [localSearchQuery, setLocalSearchQuery] = useState(urlSearchQuery || ''); const debouncedLocalSearchQuery = useDebouncedValue(localSearchQuery); + const prevDebouncedRef = useRef(debouncedLocalSearchQuery); useEffect(() => { setLocalSearchQuery(urlSearchQuery || ''); }, [urlSearchQuery]); useEffect(() => { + if (debouncedLocalSearchQuery === prevDebouncedRef.current) { + return; + } + prevDebouncedRef.current = debouncedLocalSearchQuery; + if (debouncedLocalSearchQuery !== (urlSearchQuery || '')) { navigate({ ...location, @@ -129,10 +136,11 @@ export default function PreprodBuilds() { ...location.query, cursor: undefined, display, + query: getUpdatedQueryForDisplay(urlSearchQuery, display), }, }); }, - [location, navigate] + [location, navigate, urlSearchQuery] ); const builds = buildsResponse?.json ?? []; diff --git a/static/app/views/releases/list/index.spec.tsx b/static/app/views/releases/list/index.spec.tsx index d175e0a8fed5c4..ad13c773ec02ec 100644 --- a/static/app/views/releases/list/index.spec.tsx +++ b/static/app/views/releases/list/index.spec.tsx @@ -555,75 +555,49 @@ describe('ReleasesList', () => { ); }); - it('toggles display mode in the mobile-builds tab', async () => { - const organizationWithDistribution = OrganizationFixture({ - slug: organization.slug, - features: [...organization.features], - }); + function renderMobileBuildsTab(queryOverrides: Record = {}) { const mobileProject = ProjectFixture({ id: '15', slug: 'mobile-project-4', platform: 'android', features: ['releases'], }); - ProjectsStore.loadInitialData([mobileProject]); PageFiltersStore.updateProjects([Number(mobileProject.id)], null); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/builds/`, - body: [ - { - id: 'build-id', - project_id: 15, - project_slug: 'mobile-project-4', - state: 1, - app_info: { - app_id: 'com.example.app', - name: 'Example App', - platform: 'android', - build_number: '1', - version: '1.0.0', - date_added: '2024-01-01T00:00:00Z', - }, - distribution_info: { - is_installable: true, - download_count: 12, - release_notes: null, - }, - size_info: {}, - vcs_info: { - head_sha: 'abcdef1', - pr_number: 123, - head_ref: 'main', - }, - }, - ], + body: [], }); - MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/recent-searches/`, body: [], }); - + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/recent-searches/`, + method: 'POST', + body: [], + }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/trace-items/attributes/`, body: [], }); - const {router} = render(, { - organization: organizationWithDistribution, + return render(, { + organization, initialRouterConfig: { location: { pathname: `/organizations/${organization.slug}/releases/`, - query: {tab: 'mobile-builds', cursor: '123', display: 'users'}, + query: {tab: 'mobile-builds', ...queryOverrides}, }, }, }); + } - expect(await screen.findByText('Example App')).toBeInTheDocument(); + it('toggles display mode in the mobile-builds tab and injects installable:true', async () => { + const {router} = renderMobileBuildsTab(); - const displayTrigger = screen.getByRole('button', {name: 'Display Size'}); + const displayTrigger = await screen.findByRole('button', {name: 'Display Size'}); await userEvent.click(displayTrigger); const distributionOption = screen.getByRole('option', {name: 'Distribution'}); @@ -631,6 +605,25 @@ describe('ReleasesList', () => { expect(router.location.query.display).toBe(PreprodBuildsDisplay.DISTRIBUTION); expect(router.location.query.cursor).toBeUndefined(); + expect(router.location.query.query).toBe('installable:true'); + }); + + it('strips installable:true when switching from Distribution to Size', async () => { + const {router} = renderMobileBuildsTab({ + display: 'distribution', + query: 'installable:true', + }); + + const displayTrigger = await screen.findByRole('button', { + name: 'Display Distribution', + }); + await userEvent.click(displayTrigger); + + const sizeOption = screen.getByRole('option', {name: 'Size'}); + await userEvent.click(sizeOption); + + expect(router.location.query.display).toBe(PreprodBuildsDisplay.SIZE); + expect(router.location.query.query).toBeFalsy(); }); it('allows searching within the mobile-builds tab', async () => { diff --git a/static/app/views/releases/list/mobileBuilds.tsx b/static/app/views/releases/list/mobileBuilds.tsx index ca784073d2b4d3..2147c3bc6f945c 100644 --- a/static/app/views/releases/list/mobileBuilds.tsx +++ b/static/app/views/releases/list/mobileBuilds.tsx @@ -22,6 +22,7 @@ import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import {usePreprodBuildsAnalytics} from 'sentry/views/preprod/hooks/usePreprodBuildsAnalytics'; import {buildDetailsApiOptions} from 'sentry/views/preprod/utils/buildDetailsApiOptions'; +import {getUpdatedQueryForDisplay} from 'sentry/views/preprod/utils/installableQueryUtils'; import {MobileBuildsChart} from './mobileBuildsChart'; @@ -95,10 +96,15 @@ export function MobileBuilds({organization, selectedProjectIds}: Props) { (display: PreprodBuildsDisplay) => { navigate({ ...location, - query: {...location.query, cursor: undefined, display}, + query: { + ...location.query, + cursor: undefined, + display, + query: getUpdatedQueryForDisplay(searchQuery, display), + }, }); }, - [location, navigate] + [location, navigate, searchQuery] ); const builds = buildsResponse?.json ?? [];