Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions static/app/views/preprod/utils/installableQueryUtils.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
43 changes: 43 additions & 0 deletions static/app/views/preprod/utils/installableQueryUtils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -129,10 +136,11 @@ export default function PreprodBuilds() {
...location.query,
cursor: undefined,
display,
query: getUpdatedQueryForDisplay(urlSearchQuery, display),
Comment thread
cursor[bot] marked this conversation as resolved.
},
});
},
[location, navigate]
[location, navigate, urlSearchQuery]
Comment thread
sentry[bot] marked this conversation as resolved.
);

const builds = buildsResponse?.json ?? [];
Expand Down
73 changes: 33 additions & 40 deletions static/app/views/releases/list/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -555,82 +555,75 @@ 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<string, string> = {}) {
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(<ReleasesList />, {
organization: organizationWithDistribution,
return render(<ReleasesList />, {
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'});
await userEvent.click(distributionOption);

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 () => {
Expand Down
10 changes: 8 additions & 2 deletions static/app/views/releases/list/mobileBuilds.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 ?? [];
Expand Down
Loading