diff --git a/static/app/views/issueList/groupListBody.tsx b/static/app/views/issueList/groupListBody.tsx index db50be1fbbc9..1a79f5cffa3e 100644 --- a/static/app/views/issueList/groupListBody.tsx +++ b/static/app/views/issueList/groupListBody.tsx @@ -9,7 +9,6 @@ import {LoadingStreamGroup, StreamGroup} from 'sentry/components/stream/group'; import {SupergroupRow} from 'sentry/components/stream/supergroups/supergroupRow'; import {GroupStore} from 'sentry/stores/groupStore'; import type {Group} from 'sentry/types/group'; -import {useApi} from 'sentry/utils/useApi'; import {useMedia} from 'sentry/utils/useMedia'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useSyncedLocalStorageState} from 'sentry/utils/useSyncedLocalStorageState'; @@ -95,7 +94,6 @@ export function GroupListBody({ onActionTaken, supergroupLookup, }: GroupListBodyProps) { - const api = useApi(); const organization = useOrganization(); if (loading) { @@ -114,7 +112,6 @@ export function GroupListBody({ if (!groupIds.length) { return ( { const defaultProps = { - api: new MockApiClient(), query: '', organization: OrganizationFixture(), groupIds: [], + selectedProjectIds: [], }; it('displays default empty state when first event has been sent', async () => { - MockApiClient.addMockResponse({ + const projectsMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/projects/', body: [], }); @@ -25,6 +26,7 @@ describe('NoGroupsHandler', () => { render(); expect(await screen.findByText('No issues match your search')).toBeInTheDocument(); + expect(projectsMock).not.toHaveBeenCalled(); }); it('displays default empty state when an error occurs', async () => { @@ -43,6 +45,25 @@ describe('NoGroupsHandler', () => { }); it('displays waiting for events state when first event has not been sent', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/projects/', + body: [ProjectFixture()], + }); + MockApiClient.addMockResponse({ + url: '/projects/org-slug/project-slug/issues/', + body: [], + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/sent-first-event/', + body: {sentFirstEvent: false}, + }); + + render(); + + expect(await screen.findByText(/Waiting for events/i)).toBeInTheDocument(); + }); + + it('displays waiting for events state when no projects exist yet', async () => { MockApiClient.addMockResponse({ url: '/organizations/org-slug/projects/', body: [], diff --git a/static/app/views/issueList/noGroupsHandler/index.tsx b/static/app/views/issueList/noGroupsHandler/index.tsx index 0f002c6c0e40..608ba884160b 100644 --- a/static/app/views/issueList/noGroupsHandler/index.tsx +++ b/static/app/views/issueList/noGroupsHandler/index.tsx @@ -1,12 +1,14 @@ -import {Component, lazy, Suspense} from 'react'; +import {lazy, Suspense} from 'react'; +import {skipToken, useQuery} from '@tanstack/react-query'; -import type {Client} from 'sentry/api'; import {EmptyStateWarning} from 'sentry/components/emptyStateWarning'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {ALL_ACCESS_PROJECTS} from 'sentry/components/pageFilters/constants'; import {Placeholder} from 'sentry/components/placeholder'; import {t} from 'sentry/locale'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; import {NoIssuesMatched} from 'sentry/views/issueList/noGroupsHandler/noIssuesMatched'; import {DEFAULT_QUERY, FOR_REVIEW_QUERIES} from 'sentry/views/issueList/utils'; @@ -15,20 +17,51 @@ import {NoUnresolvedIssues} from './noUnresolvedIssues'; const WaitingForEvents = lazy(() => import('sentry/components/waitingForEvents')); const UpdatedEmptyState = lazy(() => import('sentry/components/updatedEmptyState')); -type Props = { - api: Client; +interface NoGroupsHandlerProps { groupIds: string[]; organization: Organization; query: string; + selectedProjectIds: number[]; emptyMessage?: React.ReactNode; - selectedProjectIds?: number[]; -}; +} + +interface SentFirstEventResponse { + sentFirstEvent: boolean; +} + +interface EmptyResultProps { + query: string; + emptyMessage?: React.ReactNode; +} -type State = { - fetchingSentFirstEvent: boolean; - firstEventProjects?: Project[] | null; - sentFirstEvent?: boolean; -}; +const UPDATED_EMPTY_STATE_PLATFORMS = new Set([ + 'python-django', + 'node', + 'javascript-nextjs', + 'android', + 'apple-ios', + 'dotnet', + 'dotnet-aspnetcore', + 'flutter', + 'go', + 'java', + 'java-spring-boot', + 'javascript', + 'javascript-angular', + 'javascript-react', + 'javascript-vue', + 'node-express', + 'node-nestjs', + 'php', + 'php-laravel', + 'python', + 'python-fastapi', + 'python-flask', + 'react-native', + 'ruby', + 'ruby-rails', + 'unity', +]); /** * Component which is rendered when no groups/issues were found. This could @@ -36,187 +69,127 @@ type State = { * having no issues be returned from a query. This component will conditionally * render one of those states. */ -export class NoGroupsHandler extends Component { - state: State = { - fetchingSentFirstEvent: true, - sentFirstEvent: false, - firstEventProjects: null, - }; - - componentDidMount() { - this.fetchSentFirstEvent(); - this._isMounted = true; +export function NoGroupsHandler({ + emptyMessage, + groupIds, + organization, + query, + selectedProjectIds, +}: NoGroupsHandlerProps) { + // If no projects are selected, then we must check every project the user is a + // member of and make sure there are no first events for all of the projects. + // Set project to -1 for all projects. Do not pass a project id for "my projects". + const explicitSelectedProjectIds = + selectedProjectIds.length && !selectedProjectIds.includes(ALL_ACCESS_PROJECTS) + ? selectedProjectIds + : undefined; + const selectedProjectQuery = explicitSelectedProjectIds + ?.map(id => `id:${id}`) + .join(' '); + + const sentFirstEventQuery = useQuery({ + ...apiOptions.as()( + '/organizations/$organizationIdOrSlug/sent-first-event/', + { + path: {organizationIdOrSlug: organization.slug}, + query: {project: explicitSelectedProjectIds}, + staleTime: 0, + } + ), + retry: false, + }); + + const shouldFetchProject = sentFirstEventQuery.data?.sentFirstEvent === false; + + const projectsQuery = useQuery({ + ...apiOptions.as()('/organizations/$organizationIdOrSlug/projects/', { + path: shouldFetchProject ? {organizationIdOrSlug: organization.slug} : skipToken, + query: { + collapse: ['latestDeploys', 'unusedFeatures'], + per_page: 1, + query: selectedProjectQuery, + }, + staleTime: 0, + }), + retry: false, + }); + + if (sentFirstEventQuery.isPending || (shouldFetchProject && projectsQuery.isPending)) { + return ; } - componentWillUnmount() { - this._isMounted = false; + if (sentFirstEventQuery.isError || (shouldFetchProject && projectsQuery.isError)) { + return ; } - /** - * This is a bit hacky, but this is causing flakiness in frontend tests - * `issueList/overview` is being unmounted during tests before the requests - * in `this.fetchSentFirstEvent` are completed and causing this React warning: - * - * Warning: Can't perform a React state update on an unmounted component. - * This is a no-op, but it indicates a memory leak in your application. - * To fix, cancel all subscriptions and asynchronous tasks in the - * componentWillUnmount method. - * - * This is something to revisit if we refactor API client - */ - private _isMounted = false; - - async fetchSentFirstEvent() { - this.setState({ - fetchingSentFirstEvent: true, - }); - - const {organization, selectedProjectIds, api} = this.props; - let sentFirstEvent = false; - let projects = []; - - // If no projects are selected, then we must check every project the user is a - // member of and make sure there are no first events for all of the projects - // Set project to -1 for all projects - // Do not pass a project id for "my projects" - let firstEventQuery: {project?: number[]} = {}; - const projectsQuery: {per_page: number; query?: string} = {per_page: 1}; - - if (selectedProjectIds?.length && !selectedProjectIds.includes(-1)) { - firstEventQuery = {project: selectedProjectIds}; - projectsQuery.query = selectedProjectIds.map(id => `id:${id}`).join(' '); - } - - try { - [{sentFirstEvent}, projects] = await Promise.all([ - // checks to see if selection has sent a first event - api.requestPromise(`/organizations/${organization.slug}/sent-first-event/`, { - query: firstEventQuery, - }), - // retrieves a single project to feed to WaitingForEvents from renderStreamBody - api.requestPromise(`/organizations/${organization.slug}/projects/`, { - query: projectsQuery, - }), - ]); - } catch { - this.setState({ - fetchingSentFirstEvent: false, - sentFirstEvent: true, - firstEventProjects: undefined, - }); - return; - } - - // See comment where this property is initialized - // FIXME - if (!this._isMounted) { - return; - } - - this.setState({ - fetchingSentFirstEvent: false, - sentFirstEvent, - firstEventProjects: projects, - }); + if (!sentFirstEventQuery.data?.sentFirstEvent) { + return ( + + ); } - renderLoading() { - return ; - } + return ; +} - renderAwaitingEvents(projects: State['firstEventProjects']) { - const {organization, groupIds} = this.props; - const project = projects && projects.length > 0 ? projects[0] : undefined; - const sampleIssueId = groupIds.length > 0 ? groupIds[0] : undefined; - - const updatedEmptyStatePlatforms = [ - 'python-django', - 'node', - 'javascript-nextjs', - 'android', - 'apple-ios', - 'dotnet', - 'dotnet-aspnetcore', - 'flutter', - 'go', - 'java', - 'java-spring-boot', - 'javascript', - 'javascript-angular', - 'javascript-react', - 'javascript-vue', - 'node-express', - 'node-nestjs', - 'php', - 'php-laravel', - 'python', - 'python-fastapi', - 'python-flask', - 'react-native', - 'ruby', - 'ruby-rails', - 'unity', - ]; - - const hasUpdatedEmptyState = - project?.platform && updatedEmptyStatePlatforms.includes(project.platform); +interface AwaitingEventsProps { + groupIds: string[]; + organization: Organization; + projects: Project[]; +} +function AwaitingEvents({groupIds, organization, projects}: AwaitingEventsProps) { + const project = projects?.[0]; + const sampleIssueId = groupIds.length > 0 ? groupIds[0] : undefined; + + const hasUpdatedEmptyState = + project?.platform && UPDATED_EMPTY_STATE_PLATFORMS.has(project.platform); + + return ( + }> + {!hasUpdatedEmptyState && ( + + )} + {hasUpdatedEmptyState && } + + ); +} + +function EmptyResult({emptyMessage, query}: EmptyResultProps) { + if (query === DEFAULT_QUERY) { return ( - }> - {!hasUpdatedEmptyState && ( - - )} - {hasUpdatedEmptyState && } - + ); } - renderEmpty() { - const {emptyMessage} = this.props; - if (emptyMessage) { - return ( - -

{emptyMessage}

-
- ); - } - return ; + if (FOR_REVIEW_QUERIES.includes(query || '')) { + return ( + + ); } - render() { - const {fetchingSentFirstEvent, sentFirstEvent, firstEventProjects} = this.state; - const {query} = this.props; - - if (fetchingSentFirstEvent) { - return this.renderLoading(); - } - if (!sentFirstEvent) { - return this.renderAwaitingEvents(firstEventProjects); - } - if (query === DEFAULT_QUERY) { - return ( - - ); - } - - if (FOR_REVIEW_QUERIES.includes(query || '')) { - return ( - - ); - } - - return this.renderEmpty(); + if (emptyMessage) { + return ( + +

{emptyMessage}

+
+ ); } + + return ; } diff --git a/static/app/views/projectDetail/projectIssues.tsx b/static/app/views/projectDetail/projectIssues.tsx index b14d01fcc71e..510c3315122e 100644 --- a/static/app/views/projectDetail/projectIssues.tsx +++ b/static/app/views/projectDetail/projectIssues.tsx @@ -193,7 +193,6 @@ export function ProjectIssues({organization, location, projectId, query, api}: P