diff --git a/static/app/constants/releases.tsx b/static/app/constants/releases.tsx index 73c486649f34e7..be8cd6adabb283 100644 --- a/static/app/constants/releases.tsx +++ b/static/app/constants/releases.tsx @@ -1,3 +1,5 @@ +import {t} from 'sentry/locale'; + export enum ReleasesSortOption { CRASH_FREE_USERS = 'crash_free_users', CRASH_FREE_SESSIONS = 'crash_free_sessions', @@ -9,3 +11,27 @@ export enum ReleasesSortOption { SEMVER = 'semver', ADOPTION = 'adoption', } + +/** + * Default sort option used when no valid sort is specified or when + * a sort option's requirements aren't met (e.g., ADOPTION requires exactly one environment). + */ +export const DEFAULT_RELEASES_SORT = ReleasesSortOption.DATE; + +/** + * Sort options available for dashboard release filtering. + * + * Note: CRASH_FREE_USERS and CRASH_FREE_SESSIONS are intentionally excluded. + * These options are only shown in the releases list page where there's a + * "display mode" toggle (users vs sessions) that determines which one to show. + * See: static/app/views/releases/list/releasesSortOptions.tsx + */ +export const RELEASES_SORT_OPTIONS = { + [ReleasesSortOption.SESSIONS_24_HOURS]: t('Active Sessions'), + [ReleasesSortOption.USERS_24_HOURS]: t('Active Users'), + [ReleasesSortOption.ADOPTION]: t('Adoption'), + [ReleasesSortOption.BUILD]: t('Build Number'), + [ReleasesSortOption.DATE]: t('Date Created'), + [ReleasesSortOption.SEMVER]: t('Semantic Version'), + [ReleasesSortOption.SESSIONS]: t('Total Sessions'), +} as const; diff --git a/static/app/views/dashboards/components/releasesSortSelect.tsx b/static/app/views/dashboards/components/releasesSortSelect.tsx new file mode 100644 index 00000000000000..e322fde61f505c --- /dev/null +++ b/static/app/views/dashboards/components/releasesSortSelect.tsx @@ -0,0 +1,61 @@ +import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; + +import {CompactSelect} from 'sentry/components/core/compactSelect'; +import {RELEASES_SORT_OPTIONS, ReleasesSortOption} from 'sentry/constants/releases'; +import {IconSort} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import usePageFilters from 'sentry/utils/usePageFilters'; + +interface ReleasesSortSelectProps { + onChange: (sortBy: ReleasesSortOption) => void; + sortBy: ReleasesSortOption; + disabled?: boolean; +} + +export function ReleasesSortSelect({ + sortBy, + onChange, + disabled, +}: ReleasesSortSelectProps) { + const {selection} = usePageFilters(); + const {environments} = selection; + return ( + { + onChange(option.value); + }} + options={( + Object.keys(RELEASES_SORT_OPTIONS) as Array + ).map(name => { + const filter = RELEASES_SORT_OPTIONS[name]; + if (name !== ReleasesSortOption.ADOPTION) { + return { + label: filter, + value: name, + }; + } + + // Adoption sort requires exactly one environment because it calculates + // the percentage of sessions/users in that specific environment + const isNotSingleEnvironment = environments.length !== 1; + return { + label: filter, + value: name, + disabled: isNotSingleEnvironment, + tooltip: isNotSingleEnvironment + ? t('Select one environment to use this sort option.') + : undefined, + }; + })} + trigger={triggerProps => ( + } + aria-label={t('Sort Releases')} + /> + )} + /> + ); +} diff --git a/static/app/views/dashboards/detail.spec.tsx b/static/app/views/dashboards/detail.spec.tsx index 9887e723ac4786..b5f0da3aa67ba7 100644 --- a/static/app/views/dashboards/detail.spec.tsx +++ b/static/app/views/dashboards/detail.spec.tsx @@ -1531,7 +1531,7 @@ describe('Dashboards > Detail', () => { ], }); // Mocked search results - MockApiClient.addMockResponse({ + const searchMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/releases/', body: [ ReleaseFixture({ @@ -1571,6 +1571,12 @@ describe('Dashboards > Detail', () => { await userEvent.click(await screen.findByText('All Releases')); await userEvent.type(screen.getByPlaceholderText('Search\u2026'), 's'); + + // Wait for debounce and search to complete + await waitFor(() => { + expect(searchMock).toHaveBeenCalled(); + }); + await userEvent.click(await screen.findByRole('option', {name: 'search-result'})); // Validate that after search is cleared, search result still appears diff --git a/static/app/views/dashboards/filtersBar.tsx b/static/app/views/dashboards/filtersBar.tsx index aecd9bd0a0fb10..4b5cd75214dcef 100644 --- a/static/app/views/dashboards/filtersBar.tsx +++ b/static/app/views/dashboards/filtersBar.tsx @@ -1,6 +1,7 @@ -import {Fragment, useMemo, useState} from 'react'; +import {Fragment, useEffect, useMemo, useState} from 'react'; import styled from '@emotion/styled'; import type {Location} from 'history'; +import {createParser, useQueryState} from 'nuqs'; import {Button} from 'sentry/components/core/button'; import {ButtonBar} from 'sentry/components/core/button/buttonBar'; @@ -8,6 +9,11 @@ import {DatePageFilter} from 'sentry/components/organizations/datePageFilter'; import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter'; import PageFilterBar from 'sentry/components/organizations/pageFilterBar'; import {ProjectPageFilter} from 'sentry/components/organizations/projectPageFilter'; +import { + DEFAULT_RELEASES_SORT, + RELEASES_SORT_OPTIONS, + ReleasesSortOption, +} from 'sentry/constants/releases'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {DataCategory} from 'sentry/types/core'; @@ -17,6 +23,7 @@ import {trackAnalytics} from 'sentry/utils/analytics'; import {ToggleOnDemand} from 'sentry/utils/performance/contexts/onDemandControl'; import {useMaxPickableDays} from 'sentry/utils/useMaxPickableDays'; import useOrganization from 'sentry/utils/useOrganization'; +import usePageFilters from 'sentry/utils/usePageFilters'; import {useUser} from 'sentry/utils/useUser'; import {useUserTeams} from 'sentry/utils/useUserTeams'; import AddFilter from 'sentry/views/dashboards/globalFilter/addFilter'; @@ -31,7 +38,7 @@ import { } from 'sentry/views/dashboards/utils/prebuiltConfigs'; import {checkUserHasEditAccess} from './utils/checkUserHasEditAccess'; -import ReleasesSelectControl from './releasesSelectControl'; +import {SortableReleasesSelect} from './sortableReleasesSelect'; import type { DashboardDetails, DashboardFilters, @@ -138,6 +145,18 @@ export default function FiltersBar({ // Calculate maxPickableDays based on the data categories const maxPickableDaysOptions = useMaxPickableDays({dataCategories}); + // Release sort state - validates and defaults to DATE via custom parser + const [releaseSort, setReleaseSort] = useQueryState('sortReleasesBy', parseReleaseSort); + + // Reset sort to default if ADOPTION is selected but environment requirement isn't met + const {selection} = usePageFilters(); + const {environments} = selection; + useEffect(() => { + if (releaseSort === ReleasesSortOption.ADOPTION && environments.length !== 1) { + setReleaseSort(DEFAULT_RELEASES_SORT); + } + }, [releaseSort, environments.length, setReleaseSort]); + const hasEditAccess = checkUserHasEditAccess( currentUser, userTeams, @@ -204,19 +223,17 @@ export default function FiltersBar({ }} /> - { onDashboardFilterChange({ ...activeFilters, [DashboardFilterKeys.GLOBAL_FILTER]: activeGlobalFilters, }); - trackAnalytics('dashboards2.filter.change', { - organization, - filter_type: 'release', - }); }} - selectedReleases={selectedReleases} - isDisabled={isEditingDashboard} + onSortChange={setReleaseSort} /> {organization.features.includes('dashboards-global-filters') && ( @@ -301,6 +318,16 @@ export default function FiltersBar({ ); } +const parseReleaseSort = createParser({ + parse: (value: string): ReleasesSortOption => { + if (value in RELEASES_SORT_OPTIONS) { + return value as ReleasesSortOption; + } + return DEFAULT_RELEASES_SORT; + }, + serialize: (value: ReleasesSortOption): string => value, +}).withDefault(DEFAULT_RELEASES_SORT); + const Wrapper = styled('div')` display: flex; flex-direction: row; diff --git a/static/app/views/dashboards/hooks/useReleases.tsx b/static/app/views/dashboards/hooks/useReleases.tsx index 5a308d4ae86045..800904a9c110b3 100644 --- a/static/app/views/dashboards/hooks/useReleases.tsx +++ b/static/app/views/dashboards/hooks/useReleases.tsx @@ -1,42 +1,164 @@ +import {useCallback, useMemo} from 'react'; +import type {UseQueryResult} from '@tanstack/react-query'; +import chunk from 'lodash/chunk'; + +import type {ApiResult} from 'sentry/api'; +import {DEFAULT_RELEASES_SORT, ReleasesSortOption} from 'sentry/constants/releases'; import type {Release} from 'sentry/types/release'; import getApiUrl from 'sentry/utils/api/getApiUrl'; -import type {ApiQueryKey} from 'sentry/utils/queryClient'; -import {useApiQuery} from 'sentry/utils/queryClient'; +import type {TableData} from 'sentry/utils/discover/discoverQuery'; +import {DiscoverDatasets} from 'sentry/utils/discover/types'; +import { + fetchDataQuery, + useApiQuery, + useQueries, + type ApiQueryKey, +} from 'sentry/utils/queryClient'; +import {escapeFilterValue} from 'sentry/utils/tokenizeSearch'; import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; +type ReleaseWithCount = Release & { + count?: number; +}; + +// Maximum releases per event count query to avoid overly long query strings +const RELEASES_PER_CHUNK = 10; + /** * Hook to fetch releases for dashboard filtering. * - * This is similar to the Insights version (static/app/views/insights/common/queries/useReleases.tsx) - * but simplified for general dashboard use - it doesn't include the mobile-specific metrics queries - * that the Insights version uses for event counts. + * Fetches releases from the releases API and optionally enriches them with + * event counts from the spans dataset. Event counts are lazy-loaded only + * when the dropdown is open to reduce API calls. + * + * @param searchTerm - Filter releases by version name + * @param sortBy - Sort order for releases (date, sessions, users, etc.) + * @param eventCountsEnabled - Whether to fetch event counts (enables lazy loading) * * @tested_via ReleasesSelectControl component tests (releasesSelectControl.spec.tsx) */ -export function useReleases(searchTerm?: string) { +export function useReleases( + searchTerm: string, + sortBy: ReleasesSortOption, + eventCountsEnabled = false +): { + data: ReleaseWithCount[]; + isLoading: boolean; +} { const organization = useOrganization(); const {selection, isReady} = usePageFilters(); - const {environments, projects} = selection; + const {environments, projects, datetime} = selection; - const queryKey: ApiQueryKey = [ - getApiUrl('/organizations/$organizationIdOrSlug/releases/', { - path: {organizationIdOrSlug: organization.slug}, - }), - { - query: { - project: projects, - per_page: 50, - environment: environments, - query: searchTerm, - sort: 'date', + // Normalize sort option: ADOPTION requires exactly one environment because it + // calculates the percentage of sessions/users in that specific environment. + // Reset to default if the requirement isn't met. + const activeSort = + sortBy === ReleasesSortOption.ADOPTION && environments.length !== 1 + ? DEFAULT_RELEASES_SORT + : (sortBy ?? DEFAULT_RELEASES_SORT); + + // Fetch releases + const releaseResults = useApiQuery( + [ + getApiUrl('/organizations/$organizationIdOrSlug/releases/', { + path: {organizationIdOrSlug: organization.slug}, + }), + { + query: { + project: projects, + per_page: 50, + environment: environments, + query: searchTerm, + sort: activeSort, + // flatten=1 groups releases across projects when sorting by non-date fields, + // flatten=0 keeps releases separate per project for date sorting + flatten: activeSort === ReleasesSortOption.DATE ? 0 : 1, + }, }, + ], + { + staleTime: Infinity, + enabled: isReady, + retry: false, + } + ); + + const allReleases = useMemo(() => releaseResults.data ?? [], [releaseResults.data]); + + const chunks = useMemo( + () => (allReleases.length ? chunk(allReleases, RELEASES_PER_CHUNK) : []), + [allReleases] + ); + + // Combine function for useQueries - extracts metrics stats from query results. + // Wrapped in useCallback to maintain referential stability. + // The result is structurally shared by TanStack Query, so it won't change + // reference unless the underlying data changes. + const combineMetricsResults = useCallback( + (results: Array, Error>>) => { + const isFetched = results.every(result => result.isFetched); + if (!isFetched) { + return {metricsStats: {}, metricsFetched: false}; + } + const stats: Record = {}; + results.forEach(result => + result.data?.[0]?.data?.forEach(row => { + const releaseVersion = row.release; + if (typeof releaseVersion === 'string') { + stats[releaseVersion] = {count: row['count()'] as number}; + } + }) + ); + return {metricsStats: stats, metricsFetched: true}; }, - ]; + [] + ); - return useApiQuery(queryKey, { - staleTime: Infinity, - enabled: isReady, - retry: false, + const {metricsStats, metricsFetched} = useQueries({ + queries: chunks.map(releaseChunk => { + const queryKey: ApiQueryKey = [ + getApiUrl('/organizations/$organizationIdOrSlug/events/', { + path: {organizationIdOrSlug: organization.slug}, + }), + { + query: { + field: ['release', 'count()'], + query: `release:[${releaseChunk.map(r => `"${escapeFilterValue(r.version)}"`).join(',')}]`, + dataset: DiscoverDatasets.SPANS, + project: projects, + environment: environments, + start: datetime.start, + end: datetime.end, + statsPeriod: datetime.period, + referrer: 'api.dashboards-release-selector', + }, + }, + ]; + return { + queryKey, + queryFn: fetchDataQuery, + staleTime: Infinity, + enabled: isReady && !releaseResults.isPending && eventCountsEnabled, + retry: false, + }; + }), + combine: combineMetricsResults, }); + + // Enrich releases with event counts + const enrichedReleases: ReleaseWithCount[] = useMemo(() => { + if (!metricsFetched) { + return allReleases; + } + return allReleases.map(release => ({ + ...release, + count: metricsStats[release.version]?.count, + })); + }, [allReleases, metricsFetched, metricsStats]); + + return { + data: enrichedReleases, + isLoading: releaseResults.isPending || (eventCountsEnabled && !metricsFetched), + }; } diff --git a/static/app/views/dashboards/releasesSelectControl.spec.tsx b/static/app/views/dashboards/releasesSelectControl.spec.tsx index 3cdcf4b1f9c917..6e62f0ef7aba30 100644 --- a/static/app/views/dashboards/releasesSelectControl.spec.tsx +++ b/static/app/views/dashboards/releasesSelectControl.spec.tsx @@ -1,81 +1,91 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {PageFiltersFixture} from 'sentry-fixture/pageFilters'; import {ReleaseFixture} from 'sentry-fixture/release'; import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; +import {ReleasesSortOption} from 'sentry/constants/releases'; import PageFiltersStore from 'sentry/stores/pageFiltersStore'; -import ReleasesSelectControl from 'sentry/views/dashboards/releasesSelectControl'; - -const defaultReleases = [ - ReleaseFixture({ - id: '1', - shortVersion: 'sentry-android-shop@1.2.0', - version: 'sentry-android-shop@1.2.0', - }), - ReleaseFixture({ - id: '2', - shortVersion: 'sentry-android-shop@1.3.0', - version: 'sentry-android-shop@1.3.0', - }), - ReleaseFixture({ - id: '3', - shortVersion: 'sentry-android-shop@1.4.0', - version: 'sentry-android-shop@1.4.0', - }), -]; - -describe('Dashboards > ReleasesSelectControl', () => { - beforeEach(() => { - MockApiClient.clearMockResponses(); - PageFiltersStore.onInitializeUrlState( - { - projects: [], - environments: [], - datetime: {start: null, end: null, period: '14d', utc: null}, - }, - false - ); +import {ReleasesSelectControl} from 'sentry/views/dashboards/releasesSelectControl'; +import type {DashboardFilters} from 'sentry/views/dashboards/types'; + +function renderReleasesSelect({ + handleChangeFilter, +}: { + handleChangeFilter?: (activeFilters: DashboardFilters) => void; +} = {}) { + const organization = OrganizationFixture(); + + // Initialize PageFiltersStore + PageFiltersStore.init(); + PageFiltersStore.onInitializeUrlState( + PageFiltersFixture({ + projects: [1], + environments: ['production'], + }) + ); + + // Mock releases API + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/releases/`, + body: [ + ReleaseFixture({ + id: '1', + shortVersion: 'sentry-android-shop@1.2.0', + version: 'sentry-android-shop@1.2.0', + dateCreated: '2021-03-19T01:00:00Z', + }), + ReleaseFixture({ + id: '2', + shortVersion: 'sentry-android-shop@1.3.0', + version: 'sentry-android-shop@1.3.0', + dateCreated: '2021-03-20T01:00:00Z', + }), + ReleaseFixture({ + id: '3', + shortVersion: 'sentry-android-shop@1.4.0', + version: 'sentry-android-shop@1.4.0', + dateCreated: '2021-03-21T01:00:00Z', + }), + ], }); - it('updates menu title with selection', async () => { - const mockRequest = MockApiClient.addMockResponse({ - url: '/organizations/org-slug/releases/', - body: defaultReleases, - }); + // Mock events API for event counts + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/events/`, + body: { + data: [], + }, + }); - render(); + render( + , + {organization} + ); +} - // Wait for the API request to complete - await waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(1)); +describe('Dashboards > ReleasesSelectControl', () => { + it('updates menu title with selection', async () => { + renderReleasesSelect(); - // Component should render with default text expect(await screen.findByText('All Releases')).toBeInTheDocument(); - // Open the dropdown await userEvent.click(screen.getByText('All Releases')); - - // Wait for the releases to load and appear in the dropdown - expect(await screen.findByText('sentry-android-shop@1.2.0')).toBeInTheDocument(); - - // Click on a release + expect(await screen.findByText('Latest Release(s)')).toBeInTheDocument(); await userEvent.click(screen.getByText('sentry-android-shop@1.2.0')); - // Close the dropdown await userEvent.click(document.body); - // Verify the selected release is shown in the trigger - expect(screen.getByText('sentry-android-shop@1.2.0')).toBeInTheDocument(); + expect(await screen.findByText('sentry-android-shop@1.2.0')).toBeInTheDocument(); expect(screen.queryByText('+1')).not.toBeInTheDocument(); }); it('updates menu title with multiple selections', async () => { - const mockRequest = MockApiClient.addMockResponse({ - url: '/organizations/org-slug/releases/', - body: defaultReleases, - }); - - render(); - - await waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(1)); + renderReleasesSelect(); expect(await screen.findByText('All Releases')).toBeInTheDocument(); @@ -85,54 +95,131 @@ describe('Dashboards > ReleasesSelectControl', () => { await userEvent.click(document.body); - expect(screen.getByText('sentry-android-shop@1.2.0')).toBeInTheDocument(); + expect(await screen.findByText('sentry-android-shop@1.2.0')).toBeInTheDocument(); expect(screen.getByText('+1')).toBeInTheDocument(); }); - it('updates releases when searching', async () => { - const mockRequest = MockApiClient.addMockResponse({ - url: '/organizations/org-slug/releases/', - body: defaultReleases, + it('triggers search when filtering by releases', async () => { + const organization = OrganizationFixture(); + + // Initialize PageFiltersStore + PageFiltersStore.init(); + PageFiltersStore.onInitializeUrlState( + PageFiltersFixture({ + projects: [1], + environments: ['production'], + }) + ); + + // Mock initial releases + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/releases/`, + body: [ + ReleaseFixture({ + version: 'sentry-android-shop@1.2.0', + dateCreated: '2021-03-19T01:00:00Z', + }), + ], }); - render(); + // Mock search results + const searchMock = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/releases/`, + body: [ + ReleaseFixture({ + version: 'sentry-android-shop@1.2.0', + dateCreated: '2021-03-19T01:00:00Z', + }), + ], + match: [MockApiClient.matchQuery({query: 'se'})], + }); - await waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(1)); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/events/`, + body: {data: []}, + }); + + render( + , + {organization} + ); expect(await screen.findByText('All Releases')).toBeInTheDocument(); await userEvent.click(screen.getByText('All Releases')); + await userEvent.type(screen.getByPlaceholderText('Search…'), 'se'); - // Initially all releases should be visible - expect(await screen.findByText('sentry-android-shop@1.2.0')).toBeInTheDocument(); - expect(screen.getByText('sentry-android-shop@1.3.0')).toBeInTheDocument(); - expect(screen.getByText('sentry-android-shop@1.4.0')).toBeInTheDocument(); + await waitFor(() => expect(searchMock).toHaveBeenCalled()); + }); - // When user types in the search box, the component should filter releases - // Note: The actual filtering is done by the CompactSelect component itself, - // and the search term is passed to the useReleases hook which would refetch with the search term - await userEvent.type(screen.getByPlaceholderText('Search\u2026'), 'se'); + it('resets search on close', async () => { + const organization = OrganizationFixture(); - // In a real scenario, the hook would be called with the search term - // but since we're mocking the network, we're just verifying the search interaction works - }); + // Initialize PageFiltersStore + PageFiltersStore.init(); + PageFiltersStore.onInitializeUrlState( + PageFiltersFixture({ + projects: [1], + environments: ['production'], + }) + ); - it('triggers handleChangeFilter with the release versions', async () => { - const mockRequest = MockApiClient.addMockResponse({ - url: '/organizations/org-slug/releases/', - body: defaultReleases, + const initialMock = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/releases/`, + body: [ + ReleaseFixture({ + version: 'sentry-android-shop@1.2.0', + dateCreated: '2021-03-19T01:00:00Z', + }), + ], }); - const mockHandleChangeFilter = jest.fn(); + const searchMock = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/releases/`, + body: [ + ReleaseFixture({ + version: 'sentry-android-shop@1.2.0', + dateCreated: '2021-03-19T01:00:00Z', + }), + ], + match: [MockApiClient.matchQuery({query: 'se'})], + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/events/`, + body: {data: []}, + }); render( + sortBy={ReleasesSortOption.DATE} + handleChangeFilter={jest.fn()} + />, + {organization} ); - await waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(1)); + expect(await screen.findByText('All Releases')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('All Releases')); + await userEvent.type(screen.getByPlaceholderText('Search…'), 'se'); + + await waitFor(() => expect(searchMock).toHaveBeenCalled()); + + // Close the dropdown + await userEvent.click(document.body); + + // Search should be reset - initial mock should be called again + await waitFor(() => expect(initialMock).toHaveBeenCalledTimes(2)); + }); + + it('triggers handleChangeFilter with the release versions', async () => { + const mockHandleChangeFilter = jest.fn(); + renderReleasesSelect({handleChangeFilter: mockHandleChangeFilter}); expect(await screen.findByText('All Releases')).toBeInTheDocument(); @@ -151,18 +238,40 @@ describe('Dashboards > ReleasesSelectControl', () => { }); it('includes Latest Release(s) even if no matching releases', async () => { - const mockRequest = MockApiClient.addMockResponse({ - url: '/organizations/org-slug/releases/', + const organization = OrganizationFixture(); + + // Initialize PageFiltersStore + PageFiltersStore.init(); + PageFiltersStore.onInitializeUrlState( + PageFiltersFixture({ + projects: [1], + environments: ['production'], + }) + ); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/releases/`, body: [], }); - render(); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/events/`, + body: {data: []}, + }); - await waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(1)); + render( + , + {organization} + ); expect(await screen.findByText('All Releases')).toBeInTheDocument(); await userEvent.click(screen.getByText('All Releases')); + await userEvent.type(screen.getByPlaceholderText('Search…'), 'latest'); expect(await screen.findByText('Latest Release(s)')).toBeInTheDocument(); }); diff --git a/static/app/views/dashboards/releasesSelectControl.tsx b/static/app/views/dashboards/releasesSelectControl.tsx index a0502f2c953f88..c2388f06be6247 100644 --- a/static/app/views/dashboards/releasesSelectControl.tsx +++ b/static/app/views/dashboards/releasesSelectControl.tsx @@ -3,27 +3,32 @@ import styled from '@emotion/styled'; import debounce from 'lodash/debounce'; import isEqual from 'lodash/isEqual'; +import {Container, Grid} from '@sentry/scraps/layout'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {Badge} from 'sentry/components/core/badge'; import {CompactSelect} from 'sentry/components/core/compactSelect'; +import {DateTime} from 'sentry/components/dateTime'; import TextOverflow from 'sentry/components/textOverflow'; import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants'; +import {RELEASES_SORT_OPTIONS, ReleasesSortOption} from 'sentry/constants/releases'; import {IconReleases} from 'sentry/icons'; -import {t} from 'sentry/locale'; +import {t, tct, tn} from 'sentry/locale'; import {space} from 'sentry/styles/space'; +import {defined} from 'sentry/utils'; import {useReleases} from './hooks/useReleases'; import type {DashboardFilters} from './types'; import {DashboardFilterKeys} from './types'; -type Props = { +interface ReleasesSelectControlProps { selectedReleases: string[]; + sortBy: ReleasesSortOption; className?: string; handleChangeFilter?: (activeFilters: DashboardFilters) => void; id?: string; isDisabled?: boolean; -}; +} const ALIASED_RELEASES = [ { @@ -35,16 +40,24 @@ const ALIASED_RELEASES = [ }, ]; -function ReleasesSelectControl({ +export function ReleasesSelectControl({ handleChangeFilter, selectedReleases, + sortBy, className, isDisabled, id, -}: Props) { - const [searchTerm, setSearchTerm] = useState(''); - const {data: releases = [], isLoading: loading} = useReleases(searchTerm); +}: ReleasesSelectControlProps) { + const [searchTerm, setSearchTerm] = useState(''); const [activeReleases, setActiveReleases] = useState(selectedReleases); + const [isReleasesDropdownOpen, setIsReleasesDropdownOpen] = useState(false); + + // Event counts are lazy-loaded only when the dropdown is open to reduce API calls + const {data: releases, isLoading: loading} = useReleases( + searchTerm, + sortBy, + isReleasesDropdownOpen + ); function resetSearch() { setSearchTerm(''); @@ -79,25 +92,44 @@ function ReleasesSelectControl({ options={[ { value: '_releases', - label: t('Sorted by date created'), + label: tct('Sorted by [sortBy]', { + sortBy: + sortBy in RELEASES_SORT_OPTIONS + ? RELEASES_SORT_OPTIONS[sortBy as keyof typeof RELEASES_SORT_OPTIONS] + : sortBy, + }), options: [ ...ALIASED_RELEASES, ...activeReleases .filter(version => version !== 'latest') - .map(version => ({ - label: version, - value: version, - })), + .map(version => { + // Find the release in the releases array to get dateCreated and count + const release = releases.find(r => r.version === version); + return { + label: version, + value: version, + details: ( + + ), + }; + }), ...releases .filter(({version}) => !activeReleasesSet.has(version)) - .map(({version}) => ({ - label: version, - value: version, - })), + .map(({version, dateCreated, count}) => { + return { + label: version, + value: version, + details: , + }; + }), ], }, ]} onChange={opts => setActiveReleases(opts.map(opt => opt.value as string))} + onOpenChange={setIsReleasesDropdownOpen} onClose={() => { resetSearch(); if (!isEqual(activeReleases, selectedReleases)) { @@ -109,21 +141,38 @@ function ReleasesSelectControl({ value={activeReleases} trigger={triggerProps => ( }> - { - - {triggerLabel}{' '} - {activeReleases.length > 1 && ( - {`+${activeReleases.length - 1}`} - )} - - } + + {triggerLabel}{' '} + {activeReleases.length > 1 && ( + {`+${activeReleases.length - 1}`} + )} + )} /> ); } -export default ReleasesSelectControl; +type LabelDetailsProps = { + dateCreated?: string; + eventCount?: number; +}; + +function LabelDetails(props: LabelDetailsProps) { + return ( + + + {defined(props.eventCount) && tn('%s event', '%s events', props.eventCount)} + + + + {defined(props.dateCreated) && ( + + )} + + + ); +} const StyledBadge = styled(Badge)` flex-shrink: 0; diff --git a/static/app/views/dashboards/sortableReleasesSelect.tsx b/static/app/views/dashboards/sortableReleasesSelect.tsx new file mode 100644 index 00000000000000..8e0f757039b1c1 --- /dev/null +++ b/static/app/views/dashboards/sortableReleasesSelect.tsx @@ -0,0 +1,70 @@ +import styled from '@emotion/styled'; + +import PageFilterBar from 'sentry/components/organizations/pageFilterBar'; +import type {ReleasesSortOption} from 'sentry/constants/releases'; +import {trackAnalytics} from 'sentry/utils/analytics'; +import useOrganization from 'sentry/utils/useOrganization'; + +import {ReleasesSortSelect} from './components/releasesSortSelect'; +import {ReleasesSelectControl} from './releasesSelectControl'; +import type {DashboardFilters} from './types'; + +interface SortableReleasesSelectProps { + selectedReleases: string[]; + sortBy: ReleasesSortOption; + handleChangeFilter?: (activeFilters: DashboardFilters) => void; + isDisabled?: boolean; + onSortChange?: (sortBy: ReleasesSortOption) => void; +} + +export function SortableReleasesSelect({ + selectedReleases, + sortBy, + handleChangeFilter, + isDisabled, + onSortChange, +}: SortableReleasesSelectProps) { + const organization = useOrganization(); + + return ( + + { + handleChangeFilter?.(activeFilters); + trackAnalytics('dashboards2.filter.change', { + organization, + filter_type: 'release', + }); + }} + selectedReleases={selectedReleases} + isDisabled={isDisabled} + /> + { + onSortChange?.(value); + trackAnalytics('dashboards2.filter.change', { + organization, + filter_type: 'release_sort', + }); + }} + disabled={isDisabled} + /> + + ); +} + +// TURBOHACK: The regular `PageFilterBar` forces its last child (which is +// usually the date range selector) to have a minimum width of 4rem. In _this_ +// case the last child is a release sort selector, which does not need a minimum +// width at all. This is a short-term turbohack because what we want is to move +// the sort selector _into_ the release selector, at which point this will +// become moot. +const StyledPageFilterBar = styled(PageFilterBar)` + & > * { + &:last-child { + min-width: 0; + } + } +`; diff --git a/static/app/views/dashboards/widgetBuilder/components/filtersBar.tsx b/static/app/views/dashboards/widgetBuilder/components/filtersBar.tsx index a161c145c58d15..654635061dde30 100644 --- a/static/app/views/dashboards/widgetBuilder/components/filtersBar.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/filtersBar.tsx @@ -5,8 +5,9 @@ import {DatePageFilter} from 'sentry/components/organizations/datePageFilter'; import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter'; import PageFilterBar from 'sentry/components/organizations/pageFilterBar'; import {ProjectPageFilter} from 'sentry/components/organizations/projectPageFilter'; +import {ReleasesSortOption} from 'sentry/constants/releases'; import {t} from 'sentry/locale'; -import ReleasesSelectControl from 'sentry/views/dashboards/releasesSelectControl'; +import {ReleasesSelectControl} from 'sentry/views/dashboards/releasesSelectControl'; function WidgetBuilderFilterBar({releases}: {releases: string[]}) { return ( @@ -22,6 +23,7 @@ function WidgetBuilderFilterBar({releases}: {releases: string[]}) { isDisabled id="releases-select-control" selectedReleases={releases} + sortBy={ReleasesSortOption.DATE} />