diff --git a/static/app/components/preprod/preprodBuildsSearchControls.tsx b/static/app/components/preprod/preprodBuildsSearchControls.tsx new file mode 100644 index 00000000000000..ea19efc0950bfe --- /dev/null +++ b/static/app/components/preprod/preprodBuildsSearchControls.tsx @@ -0,0 +1,92 @@ +import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; + +import {CompactSelect, type SelectOption} from 'sentry/components/core/compactSelect'; +import {Container, Flex} from 'sentry/components/core/layout'; +import {PreprodBuildsDisplay} from 'sentry/components/preprod/preprodBuildsDisplay'; +import {PreprodSearchBar} from 'sentry/components/preprod/preprodSearchBar'; +import {t} from 'sentry/locale'; +import useOrganization from 'sentry/utils/useOrganization'; + +const displaySelectOptions: Array> = [ + {value: PreprodBuildsDisplay.SIZE, label: t('Size')}, + {value: PreprodBuildsDisplay.DISTRIBUTION, label: t('Distribution')}, +]; + +interface PreprodBuildsSearchControlsProps { + /** + * Current display mode value from URL query + */ + display: PreprodBuildsDisplay; + /** + * Initial search query value + */ + initialQuery: string; + /** + * Called when display mode changes + */ + onDisplayChange: (display: PreprodBuildsDisplay) => void; + /** + * Project IDs to filter search attributes + */ + projects: number[]; + /** + * Called on every keystroke (for controlled input with debounce) + */ + onChange?: (query: string, state: {queryIsValid: boolean}) => void; + /** + * Called when search is submitted (e.g., on Enter) + */ + onSearch?: (query: string) => void; +} + +/** + * Reusable search controls for preprod builds pages. + * Combines search bar with optional display mode toggle. + */ +export function PreprodBuildsSearchControls({ + initialQuery, + display, + projects, + onChange, + onSearch, + onDisplayChange, +}: PreprodBuildsSearchControlsProps) { + const organization = useOrganization(); + const hasDistributionFeature = organization.features.includes( + 'preprod-build-distribution' + ); + + return ( + + + + + {hasDistributionFeature && ( + + onDisplayChange(option.value)} + trigger={triggerProps => ( + + )} + /> + + )} + + ); +} diff --git a/static/app/components/preprod/preprodSearchBar.tsx b/static/app/components/preprod/preprodSearchBar.tsx index b94e4b1399a85a..8d5059996cec3a 100644 --- a/static/app/components/preprod/preprodSearchBar.tsx +++ b/static/app/components/preprod/preprodSearchBar.tsx @@ -27,9 +27,6 @@ interface PreprodSearchBarProps { * When true, parens and logical operators (AND, OR) will be marked as invalid. */ disallowLogicalOperators?: boolean; - /** - * List of attribute keys to hide from the search bar. Defaults to HIDDEN_PREPROD_ATTRIBUTES. - */ hiddenKeys?: string[]; onChange?: (query: string, state: {queryIsValid: boolean}) => void; onSearch?: (query: string) => void; @@ -80,11 +77,15 @@ export function PreprodSearchBar({ useTraceItemAttributesWithConfig(traceItemAttributeConfig, 'number', hiddenKeys); const filteredStringAttributes = useMemo( - () => filterAttributes(stringAttributes, allowedKeys), + () => ({ + ...filterAttributes(stringAttributes, allowedKeys), + }), [stringAttributes, allowedKeys] ); const filteredNumberAttributes = useMemo( - () => filterAttributes(numberAttributes, allowedKeys), + () => ({ + ...filterAttributes(numberAttributes, allowedKeys), + }), [numberAttributes, allowedKeys] ); diff --git a/static/app/views/preprod/components/preprodBuildsSearchBar.tsx b/static/app/views/preprod/components/preprodBuildsSearchBar.tsx deleted file mode 100644 index 4b32c76d10e31d..00000000000000 --- a/static/app/views/preprod/components/preprodBuildsSearchBar.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import {Fragment} from 'react'; - -import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; - -import {CompactSelect, type SelectOption} from 'sentry/components/core/compactSelect'; -import {Container} from 'sentry/components/core/layout'; -import {PreprodBuildsDisplay} from 'sentry/components/preprod/preprodBuildsDisplay'; -import SearchBar from 'sentry/components/searchBar'; -import {t} from 'sentry/locale'; - -const displaySelectOptions: Array> = [ - {value: PreprodBuildsDisplay.SIZE, label: t('Size')}, - {value: PreprodBuildsDisplay.DISTRIBUTION, label: t('Distribution')}, -]; - -type DisplayOptionsProps = { - onSelect: (display: PreprodBuildsDisplay) => void; - selected: PreprodBuildsDisplay; -}; - -type Props = { - disabled?: boolean; - displayOptions?: DisplayOptionsProps; - onChange?: (query: string) => void; - onSearch?: (query: string) => void; - query?: string; -}; - -export default function PreprodBuildsSearchBar({ - onChange, - onSearch, - query, - disabled, - displayOptions, -}: Props) { - return ( - - - - - {displayOptions && ( - - displayOptions.onSelect(option.value)} - trigger={triggerProps => ( - - )} - /> - - )} - - ); -} diff --git a/static/app/views/releases/detail/commitsAndFiles/preprodBuilds.tsx b/static/app/views/releases/detail/commitsAndFiles/preprodBuilds.tsx index 48ee059543659f..993e30e49673d4 100644 --- a/static/app/views/releases/detail/commitsAndFiles/preprodBuilds.tsx +++ b/static/app/views/releases/detail/commitsAndFiles/preprodBuilds.tsx @@ -1,12 +1,13 @@ import {useCallback, useContext, useEffect, useMemo, useState} from 'react'; -import {Container, Flex} from 'sentry/components/core/layout'; +import {Container} from 'sentry/components/core/layout'; import * as Layout from 'sentry/components/layouts/thirds'; import LoadingError from 'sentry/components/loadingError'; import { getPreprodBuildsDisplay, PreprodBuildsDisplay, } from 'sentry/components/preprod/preprodBuildsDisplay'; +import {PreprodBuildsSearchControls} from 'sentry/components/preprod/preprodBuildsSearchControls'; import {PreprodBuildsTable} from 'sentry/components/preprod/preprodBuildsTable'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; import {t} from 'sentry/locale'; @@ -22,10 +23,8 @@ import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import {useParams} from 'sentry/utils/useParams'; import {formatVersion} from 'sentry/utils/versions/formatVersion'; -import PreprodBuildsSearchBar from 'sentry/views/preprod/components/preprodBuildsSearchBar'; import {usePreprodBuildsAnalytics} from 'sentry/views/preprod/hooks/usePreprodBuildsAnalytics'; import type {BuildDetailsApiResponse} from 'sentry/views/preprod/types/buildDetailsTypes'; -import type {ListBuildsApiResponse} from 'sentry/views/preprod/types/listBuildsTypes'; import {ReleaseContext} from 'sentry/views/releases/detail'; import {PreprodOnboarding} from './preprodOnboarding'; @@ -96,12 +95,11 @@ export default function PreprodBuilds() { error: buildsError, refetch, getResponseHeader, - }: UseApiQueryResult< - ListBuildsApiResponse, - RequestError - > = useApiQuery( + }: UseApiQueryResult = useApiQuery< + BuildDetailsApiResponse[] + >( [ - getApiUrl(`/organizations/$organizationIdOrSlug/preprodartifacts/list-builds/`, { + getApiUrl(`/organizations/$organizationIdOrSlug/builds/`, { path: {organizationIdOrSlug: organization.slug}, }), {query: queryParams}, @@ -112,7 +110,7 @@ export default function PreprodBuilds() { } ); - const handleSearch = (query: string) => { + const handleSearch = (query: string, _state?: {queryIsValid: boolean}) => { setLocalSearchQuery(query); }; @@ -130,7 +128,7 @@ export default function PreprodBuilds() { [location] ); - const builds = buildsData?.builds || []; + const builds = buildsData ?? []; const pageLinks = getResponseHeader?.('Link') || null; const hasSearchQuery = !!urlSearchQuery?.trim(); @@ -171,23 +169,13 @@ export default function PreprodBuilds() { /> {buildsError && } - - - + {showOnboarding ? ( diff --git a/static/app/views/releases/list/index.spec.tsx b/static/app/views/releases/list/index.spec.tsx index 8f7fc93a7eaadd..77d89aee92da31 100644 --- a/static/app/views/releases/list/index.spec.tsx +++ b/static/app/views/releases/list/index.spec.tsx @@ -43,11 +43,13 @@ describe('ReleasesList', () => { beforeEach(() => { act(() => ProjectsStore.loadInitialData(projects)); - PageFiltersStore.onInitializeUrlState({ - projects: [], - environments: [], - datetime: {period: null, utc: null, start: null, end: null}, - }); + act(() => + PageFiltersStore.onInitializeUrlState({ + projects: [], + environments: [], + datetime: {period: null, utc: null, start: null, end: null}, + }) + ); endpointMock = MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/releases/`, body: [ @@ -530,8 +532,13 @@ describe('ReleasesList', () => { PageFiltersStore.updateProjects([Number(mobileProject.id)], null); const buildsMock = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/preprodartifacts/list-builds/`, - body: {builds: []}, + url: `/organizations/${organization.slug}/builds/`, + body: [], + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/recent-searches/`, + body: [], }); render(, { @@ -547,7 +554,7 @@ describe('ReleasesList', () => { expect(await screen.findByText(/Upload Mobile Builds to Sentry/)).toBeInTheDocument(); expect(buildsMock).toHaveBeenCalledWith( - `/organizations/${organization.slug}/preprodartifacts/list-builds/`, + `/organizations/${organization.slug}/builds/`, expect.objectContaining({ query: expect.objectContaining({per_page: 25, statsPeriod: '7d'}), }) @@ -570,36 +577,39 @@ describe('ReleasesList', () => { PageFiltersStore.updateProjects([Number(mobileProject.id)], null); MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/preprodartifacts/list-builds/`, - body: { - builds: [ - { - 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', - }, + 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', + }, + }, + ], + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/recent-searches/`, + body: [], }); const {router} = render(, { @@ -636,31 +646,39 @@ describe('ReleasesList', () => { PageFiltersStore.updateProjects([Number(mobileProject.id)], null); const buildsMock = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/preprodartifacts/list-builds/`, - body: { - builds: [ - { - id: 'build-id', - project_id: 13, - project_slug: 'mobile-project-2', - 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', - }, - size_info: {}, - vcs_info: { - head_sha: 'abcdef1', - pr_number: 123, - head_ref: 'main', - }, + url: `/organizations/${organization.slug}/builds/`, + body: [ + { + id: 'build-id', + project_id: 13, + project_slug: 'mobile-project-2', + 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', }, - ], - }, + size_info: {}, + vcs_info: { + head_sha: 'abcdef1', + pr_number: 123, + head_ref: 'main', + }, + }, + ], + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/recent-searches/`, + body: [], + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/trace-items/attributes/branch/values/`, + body: [], }); render(, { @@ -673,15 +691,11 @@ describe('ReleasesList', () => { }, }); - expect( - await screen.findByPlaceholderText( - 'Search by build, SHA, branch name, or pull request' - ) - ).toBeInTheDocument(); + expect(await screen.findByTestId('query-builder-input')).toBeInTheDocument(); await waitFor(() => expect(buildsMock).toHaveBeenCalledWith( - `/organizations/${organization.slug}/preprodartifacts/list-builds/`, + `/organizations/${organization.slug}/builds/`, expect.objectContaining({ query: expect.objectContaining({ per_page: 25, @@ -692,25 +706,21 @@ describe('ReleasesList', () => { ) ); - const searchInput = screen.getByPlaceholderText( - 'Search by build, SHA, branch name, or pull request' - ); - - // Clear the input first - await userEvent.clear(searchInput); + const searchInput = screen.getByTestId('query-builder-input'); - // Type the search term and press Enter to submit - await userEvent.type(searchInput, 'branch:main{enter}'); + // Type additional search term and press Enter to submit + await userEvent.type(searchInput, ' branch:main{enter}'); // Wait for the API call with the complete search query + // Note: The SearchQueryBuilder appends to the existing query rather than replacing await waitFor(() => expect(buildsMock).toHaveBeenCalledWith( - `/organizations/${organization.slug}/preprodartifacts/list-builds/`, + `/organizations/${organization.slug}/builds/`, expect.objectContaining({ query: expect.objectContaining({ per_page: 25, statsPeriod: '14d', - query: 'branch:main', + query: 'sha:abcdef1 branch:main', }), }) ) @@ -729,8 +739,8 @@ describe('ReleasesList', () => { PageFiltersStore.updateProjects([Number(mobileProject.id)], null); MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/preprodartifacts/list-builds/`, - body: {builds: []}, + url: `/organizations/${organization.slug}/builds/`, + body: [], }); MockApiClient.addMockResponse({ @@ -738,6 +748,11 @@ describe('ReleasesList', () => { body: [], }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/recent-searches/`, + body: [], + }); + const {router} = render(, { organization, initialRouterConfig: { diff --git a/static/app/views/releases/list/mobileBuilds.tsx b/static/app/views/releases/list/mobileBuilds.tsx index f8fa48f2b5c329..b784c611dc10e5 100644 --- a/static/app/views/releases/list/mobileBuilds.tsx +++ b/static/app/views/releases/list/mobileBuilds.tsx @@ -1,7 +1,7 @@ import {useCallback, useEffect, useMemo} from 'react'; import {parseAsString, useQueryState} from 'nuqs'; -import {Flex, Stack} from '@sentry/scraps/layout'; +import {Stack} from '@sentry/scraps/layout'; import LoadingError from 'sentry/components/loadingError'; import LoadingIndicator from 'sentry/components/loadingIndicator'; @@ -10,6 +10,7 @@ import { getPreprodBuildsDisplay, PreprodBuildsDisplay, } from 'sentry/components/preprod/preprodBuildsDisplay'; +import {PreprodBuildsSearchControls} from 'sentry/components/preprod/preprodBuildsSearchControls'; import {PreprodBuildsTable} from 'sentry/components/preprod/preprodBuildsTable'; import {PreprodOnboardingPanel} from 'sentry/components/preprod/preprodOnboardingPanel'; import ProjectsStore from 'sentry/stores/projectsStore'; @@ -20,9 +21,8 @@ import {useApiQuery, type UseApiQueryResult} from 'sentry/utils/queryClient'; import type RequestError from 'sentry/utils/requestError/requestError'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; -import PreprodBuildsSearchBar from 'sentry/views/preprod/components/preprodBuildsSearchBar'; import {usePreprodBuildsAnalytics} from 'sentry/views/preprod/hooks/usePreprodBuildsAnalytics'; -import type {ListBuildsApiResponse} from 'sentry/views/preprod/types/listBuildsTypes'; +import type {BuildDetailsApiResponse} from 'sentry/views/preprod/types/buildDetailsTypes'; type Props = { organization: Organization; @@ -71,12 +71,11 @@ export default function MobileBuilds({organization, selectedProjectIds}: Props) error: buildsError, refetch, getResponseHeader, - }: UseApiQueryResult< - ListBuildsApiResponse, - RequestError - > = useApiQuery( + }: UseApiQueryResult = useApiQuery< + BuildDetailsApiResponse[] + >( [ - getApiUrl(`/organizations/$organizationIdOrSlug/preprodartifacts/list-builds/`, { + getApiUrl(`/organizations/$organizationIdOrSlug/builds/`, { path: {organizationIdOrSlug: organization.slug}, }), {query: buildsQueryParams}, @@ -107,7 +106,7 @@ export default function MobileBuilds({organization, selectedProjectIds}: Props) [location, navigate] ); - const builds = buildsData?.builds ?? []; + const builds = buildsData ?? []; const pageLinks = getResponseHeader?.('Link') ?? undefined; const hasSearchQuery = !!searchQuery?.trim(); const showProjectColumn = selectedProjectIds.length > 1; @@ -161,23 +160,13 @@ export default function MobileBuilds({organization, selectedProjectIds}: Props) return ( - - - + {buildsError && }