diff --git a/static/app/actionCreators/projects.spec.tsx b/static/app/actionCreators/projects.spec.tsx deleted file mode 100644 index 33ab6711d7a4..000000000000 --- a/static/app/actionCreators/projects.spec.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import {initializeOrg} from 'sentry-test/initializeOrg'; - -import {_debouncedLoadStats} from 'sentry/actionCreators/projects'; - -describe('Projects ActionCreators', () => { - const api = new MockApiClient(); - const {organization, project} = initializeOrg(); - - it('loadStatsForProject', () => { - jest.useFakeTimers(); - const mock = MockApiClient.addMockResponse({ - url: '/organizations/org-slug/projects/', - }); - expect(mock).not.toHaveBeenCalled(); - - _debouncedLoadStats( - api, - new Set([...Array.from({length: 50})].map((_, i) => String(i))), - { - projectId: project.id, - orgId: organization.slug, - } - ); - - expect(mock).toHaveBeenCalledTimes(5); - expect(mock).toHaveBeenLastCalledWith( - '/organizations/org-slug/projects/', - expect.objectContaining({ - query: { - statsPeriod: '24h', - query: 'id:40 id:41 id:42 id:43 id:44 id:45 id:46 id:47 id:48 id:49', - }, - }) - ); - }); - - it('loadStatsForProjectFixture() with additional query', () => { - jest.useFakeTimers(); - const mock = MockApiClient.addMockResponse({ - url: '/organizations/org-slug/projects/', - }); - expect(mock).not.toHaveBeenCalled(); - - _debouncedLoadStats(api, new Set(['1', '2', '3']), { - projectId: project.id, - orgId: organization.slug, - query: {transactionStats: '1'}, - }); - - expect(mock).toHaveBeenCalledTimes(1); - expect(mock).toHaveBeenLastCalledWith( - '/organizations/org-slug/projects/', - expect.objectContaining({ - query: { - statsPeriod: '24h', - query: 'id:1 id:2 id:3', - transactionStats: '1', - }, - }) - ); - }); -}); diff --git a/static/app/actionCreators/projects.tsx b/static/app/actionCreators/projects.tsx index d410d0900aac..07add93b23e9 100644 --- a/static/app/actionCreators/projects.tsx +++ b/static/app/actionCreators/projects.tsx @@ -1,7 +1,4 @@ import {queryOptions, skipToken, useQueryClient} from '@tanstack/react-query'; -import type {Query} from 'history'; -import chunk from 'lodash/chunk'; -import debounce from 'lodash/debounce'; import { addErrorMessage, @@ -9,8 +6,7 @@ import { addSuccessMessage, } from 'sentry/actionCreators/indicator'; import type {Client} from 'sentry/api'; -import {t, tct} from 'sentry/locale'; -import {ProjectsStatsStore} from 'sentry/stores/projectsStatsStore'; +import {tct} from 'sentry/locale'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import type {Team} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; @@ -22,12 +18,9 @@ type UpdateParams = { orgId: string; projectId: string; data?: Record; - query?: Query; }; export function update(api: Client, params: UpdateParams) { - ProjectsStatsStore.onUpdate(params.projectId, params.data as Partial); - const endpoint = `/projects/${params.orgId}/${params.projectId}/`; return api .requestPromise(endpoint, { @@ -40,82 +33,11 @@ export function update(api: Client, params: UpdateParams) { return data; }, (err: Error) => { - ProjectsStatsStore.onUpdateError(err, params.projectId); throw err; } ); } -// This is going to queue up a list of project ids we need to fetch stats for -// Will be cleared when debounced function fires -export const _projectStatsToFetch = new Set(); - -// Max projects to query at a time, otherwise if we fetch too many in the same request -// it can timeout -const MAX_PROJECTS_TO_FETCH = 10; - -const _queryForStats = ( - api: Client, - projects: string[], - orgId: string, - additionalQuery: Query | undefined -) => { - const idQueryParams = projects.map(project => `id:${project}`).join(' '); - const endpoint = `/organizations/${orgId}/projects/`; - - const query: Query = { - statsPeriod: '24h', - query: idQueryParams, - ...additionalQuery, - }; - - return api.requestPromise(endpoint, { - query, - }); -}; - -export const _debouncedLoadStats = debounce( - (api: Client, projectSet: Set, params: UpdateParams) => { - const storedProjects = ProjectsStatsStore.getAll(); - const existingProjectStats = Object.values(storedProjects).map(({id}) => id); - const projects = Array.from(projectSet).filter( - project => !existingProjectStats.includes(project) - ); - - if (!projects.length) { - _projectStatsToFetch.clear(); - return; - } - - // Split projects into more manageable chunks to query, otherwise we can - // potentially face server timeouts - const queries = chunk(projects, MAX_PROJECTS_TO_FETCH).map(chunkedProjects => - _queryForStats(api, chunkedProjects, params.orgId, params.query) - ); - - Promise.all(queries) - .then(results => { - ProjectsStatsStore.onStatsLoadSuccess( - results.reduce((acc, result) => acc.concat(result), []) - ); - }) - .catch(() => { - addErrorMessage(t('Unable to fetch all project stats')); - }); - - // Reset projects list - _projectStatsToFetch.clear(); - }, - 50 -); - -export function loadStatsForProject(api: Client, project: string, params: UpdateParams) { - // Queue up a list of projects that we need stats for - // and call a debounced function to fetch stats for list of projects - _projectStatsToFetch.add(project); - _debouncedLoadStats(api, _projectStatsToFetch, params); -} - export function transferProject( api: Client, orgId: string, diff --git a/static/app/stores/projectsStatsStore.tsx b/static/app/stores/projectsStatsStore.tsx deleted file mode 100644 index 3f2c183b6966..000000000000 --- a/static/app/stores/projectsStatsStore.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import {createStore} from 'reflux'; - -import type {Project} from 'sentry/types/project'; - -import type {StrictStoreDefinition} from './types'; - -type SlugStatsMapping = Record; -type State = SlugStatsMapping; - -interface ProjectsStatsStoreDefinition extends StrictStoreDefinition { - getAll(): SlugStatsMapping; - getBySlug(slug: string): Project; - getInitialState(): SlugStatsMapping; - onStatsLoadSuccess(projects: Project[]): void; - onUpdate(projectSlug: string, data: Partial): void; - onUpdateError(err: Error, projectSlug: string): void; - onUpdateSuccess(data: Project): void; - reset(): void; - updatingItems: Map; -} - -/** - * This is a store specifically used by the dashboard, so that we can - * clear the store when the Dashboard unmounts - * (as to not disrupt ProjectsStore which a lot more components use) - */ -const storeConfig: ProjectsStatsStoreDefinition = { - state: {}, - updatingItems: new Map(), - - init() { - // XXX: Do not use `this.listenTo` in this store. We avoid usage of reflux - // listeners due to their leaky nature in tests. - - this.reset(); - }, - - getInitialState() { - return this.state; - }, - - reset() { - this.state = {}; - this.updatingItems.clear(); - }, - - onStatsLoadSuccess(projects) { - projects.forEach(project => { - this.state = {...this.state, [project.slug]: project}; - }); - this.trigger(this.state); - }, - - /** - * Optimistic updates - * @param projectSlug Project slug - * @param data Project data - */ - onUpdate(projectSlug, data) { - const project = this.getBySlug(projectSlug); - this.updatingItems.set(projectSlug, project); - if (!project) { - return; - } - - const newProject: Project = { - ...project, - ...data, - }; - - this.state = { - ...this.state, - [project.slug]: newProject, - }; - this.trigger(this.state); - }, - - onUpdateSuccess(data: Project) { - // Remove project from updating map - this.updatingItems.delete(data.slug); - }, - - /** - * Revert project data when there was an error updating project details - * @param err Error object - * @param data Previous project data - */ - onUpdateError(_err, projectSlug) { - const project = this.updatingItems.get(projectSlug); - if (!project) { - return; - } - - this.updatingItems.delete(projectSlug); - // Restore old project - this.state = { - ...this.state, - [project.slug]: {...project}, - }; - this.trigger(this.state); - }, - - getAll() { - return this.state; - }, - - getState() { - return this.state; - }, - - getBySlug(slug) { - return this.state[slug]!; - }, -}; - -export const ProjectsStatsStore = createStore(storeConfig); diff --git a/static/app/types/project.tsx b/static/app/types/project.tsx index 0c6b7a764d8f..b283fde09a19 100644 --- a/static/app/types/project.tsx +++ b/static/app/types/project.tsx @@ -20,6 +20,8 @@ export type AvatarProject = { platform?: PlatformKey; }; +export type ProjectStats = TimeseriesValue[]; + /** * Matches the response from `ProjectSummarySerializer` used by * `GET /organizations/{org}/projects/`. @@ -73,8 +75,8 @@ interface ProjectSummary extends AvatarProject { hasHealthData: boolean; previousCrashFreeRate: number | null; }; - stats?: TimeseriesValue[]; - transactionStats?: TimeseriesValue[]; + stats?: ProjectStats; + transactionStats?: ProjectStats; } /** diff --git a/static/app/utils/api/useAggregatedQueryKeys.tsx b/static/app/utils/api/useAggregatedQueryKeys.tsx index 20aa36507255..d497880229b5 100644 --- a/static/app/utils/api/useAggregatedQueryKeys.tsx +++ b/static/app/utils/api/useAggregatedQueryKeys.tsx @@ -12,7 +12,7 @@ import {uniq} from 'sentry/utils/array/uniq'; const BUFFER_WAIT_MS = 20; -interface Props { +interface Props { /** * The queryKey reducer * @@ -21,14 +21,14 @@ interface Props { */ getQueryOptions: ( ids: readonly AggregatableQueryKey[] - ) => UseQueryOptions, Error, Data, ApiQueryKey>; + ) => UseQueryOptions, Error, ResponseData, ApiQueryKey>; /** * Data reducer, to integrate new requests with the previous state */ responseReducer: ( prevState: undefined | Data, - result: ApiResponse, + result: ApiResponse, aggregates: readonly AggregatableQueryKey[] ) => undefined | Data; @@ -82,13 +82,13 @@ function isQueryKeyInList(queryList: unknown[]) { * - You will implement `responseReducer(prev: Data, result: ApiResponse)` which * combines `defaultData` with the data that was fetched with the queryKey. */ -export function useAggregatedQueryKeys({ +export function useAggregatedQueryKeys({ cacheKey, getQueryOptions, onError, responseReducer, bufferLimit = 50, -}: Props) { +}: Props) { const queryClient = useQueryClient(); const cache = queryClient.getQueryCache(); @@ -105,14 +105,14 @@ export function useAggregatedQueryKeys({ const prevQueryKeys = useRef([]); const readCache = useCallback( - () => + (aggregates = prevQueryKeys.current) => queryClient - .getQueriesData>({ + .getQueriesData>({ predicate: ({queryKey}) => isApiQueryKeyForUrl(queryKey), }) .flatMap(([, val]) => (defined(val) ? [val] : [])) .reduce( - (prevValue, val) => responseReducer(prevValue, val, prevQueryKeys.current), + (prevValue, val) => responseReducer(prevValue, val, aggregates), undefined ), [isApiQueryKeyForUrl, queryClient, responseReducer] @@ -208,7 +208,7 @@ export function useAggregatedQueryKeys({ .forEach(queryKey => queryClient.setQueryData(queryKey, true)); if (newQueryKeys.length) { - setData(readCache()); + setData(readCache(queryKeys)); // Grab anything in the queue, including the newQueryKeys const existingQueuedQueries = cache.findAll({ queryKey: ['aggregate', cacheKey, url, 'queued'], @@ -242,5 +242,5 @@ export function useAggregatedQueryKeys({ }); }, [cache, isApiQueryKeyForUrl, readCache]); - return useMemo(() => ({buffer, data}), [buffer, data]); + return useMemo(() => ({buffer, data, read: readCache}), [buffer, data, readCache]); } diff --git a/static/app/utils/project/sortProjects.tsx b/static/app/utils/project/sortProjects.tsx index f1ffa0320492..9aea98c7d4d3 100644 --- a/static/app/utils/project/sortProjects.tsx +++ b/static/app/utils/project/sortProjects.tsx @@ -1,6 +1,9 @@ -import type {Project} from 'sentry/types/project'; +type SortableProject = { + isBookmarked: boolean; + slug: string; +}; -function projectDisplayCompare(a: Project, b: Project): number { +function projectDisplayCompare(a: SortableProject, b: SortableProject): number { if (a.isBookmarked !== b.isBookmarked) { return a.isBookmarked ? -1 : 1; } @@ -8,8 +11,8 @@ function projectDisplayCompare(a: Project, b: Project): number { } /** - * Sort a list of projects by bookmarkedness, then by id + * Sort a list of projects by bookmarkedness, then by slug */ -export function sortProjects(projects: readonly Project[]): Project[] { +export function sortProjects(projects: readonly T[]): T[] { return projects.toSorted(projectDisplayCompare); } diff --git a/static/app/views/projectDetail/charts/projectErrorsBasicChart.tsx b/static/app/views/projectDetail/charts/projectErrorsBasicChart.tsx index 6f1a956d12ed..e682d95a06a1 100644 --- a/static/app/views/projectDetail/charts/projectErrorsBasicChart.tsx +++ b/static/app/views/projectDetail/charts/projectErrorsBasicChart.tsx @@ -9,12 +9,13 @@ import {HeaderTitleLegend} from 'sentry/components/charts/styles'; import {LoadingError} from 'sentry/components/loadingError'; import {DEFAULT_STATS_PERIOD} from 'sentry/constants'; import {t} from 'sentry/locale'; -import type {Project} from 'sentry/types/project'; +import type {Project, ProjectStats} from 'sentry/types/project'; import {defined} from 'sentry/utils'; import {apiOptions} from 'sentry/utils/api/apiOptions'; import {useOrganization} from 'sentry/utils/useOrganization'; export const ERRORS_BASIC_CHART_PERIODS = ['1h', '24h', '7d', '14d', '30d']; +type ProjectErrorsResponse = Project & {stats?: ProjectStats}; type Props = { onTotalValuesChange: (value: number | null) => void; @@ -36,14 +37,17 @@ export function ProjectErrorsBasicChart({projectId, onTotalValuesChange}: Props) isError, isSuccess, } = useQuery({ - ...apiOptions.as()('/organizations/$organizationIdOrSlug/projects/', { - path: {organizationIdOrSlug: organization.slug}, - query: { - statsPeriod, - query: `id:${projectId}`, - }, - staleTime: 0, - }), + ...apiOptions.as()( + '/organizations/$organizationIdOrSlug/projects/', + { + path: {organizationIdOrSlug: organization.slug}, + query: { + statsPeriod, + query: `id:${projectId}`, + }, + staleTime: 0, + } + ), enabled: defined(projectId), }); const stats = useMemo(() => { diff --git a/static/app/views/projectsDashboard/deploys.tsx b/static/app/views/projectsDashboard/deploys.tsx index 745d797a0d92..08c081dfd357 100644 --- a/static/app/views/projectsDashboard/deploys.tsx +++ b/static/app/views/projectsDashboard/deploys.tsx @@ -14,17 +14,18 @@ const DEPLOY_COUNT = 2; type Props = { project: Project; + latestDeploys?: Project['latestDeploys']; }; -export function Deploys({project}: Props) { - const flattenedDeploys = Object.entries(project.latestDeploys || {}).map( +export function Deploys({latestDeploys, project}: Props) { + const flattenedDeploys = Object.entries(latestDeploys ?? {}).map( ([environment, value]): Pick< DeployType, 'version' | 'dateFinished' | 'environment' > => ({environment, ...value}) ); - const deploys = (flattenedDeploys || []) + const deploys = flattenedDeploys .sort( (a, b) => new Date(b.dateFinished).getTime() - new Date(a.dateFinished).getTime() ) diff --git a/static/app/views/projectsDashboard/index.spec.tsx b/static/app/views/projectsDashboard/index.spec.tsx index 6fb9defb6471..d8da889e9ed7 100644 --- a/static/app/views/projectsDashboard/index.spec.tsx +++ b/static/app/views/projectsDashboard/index.spec.tsx @@ -3,7 +3,6 @@ import {ProjectFixture} from 'sentry-fixture/project'; import {TeamFixture} from 'sentry-fixture/team'; import { - act, render, screen, userEvent, @@ -11,8 +10,6 @@ import { within, } from 'sentry-test/reactTestingLibrary'; -import * as projectsActions from 'sentry/actionCreators/projects'; -import {ProjectsStatsStore} from 'sentry/stores/projectsStatsStore'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import {TeamStore} from 'sentry/stores/teamStore'; import ProjectsDashboard from 'sentry/views/projectsDashboard'; @@ -52,19 +49,17 @@ describe('ProjectsDashboard', () => { url: `/organizations/${org.slug}/projects/`, body: [], }); - ProjectsStatsStore.reset(); ProjectsStore.loadInitialData([]); }); afterEach(() => { TeamStore.reset(); - projectsActions._projectStatsToFetch.clear(); MockApiClient.clearMockResponses(); }); describe('empty state', () => { it('renders with 1 project, with no first event', async () => { - const projects = [ProjectFixture({teams, firstEvent: null, stats: []})]; + const projects = [ProjectFixture({teams, firstEvent: null})]; ProjectsStore.loadInitialData(projects); const teamsWithOneProject = [TeamFixture({projects})]; @@ -80,7 +75,6 @@ describe('ProjectsDashboard', () => { expect(screen.getByText('My Teams')).toBeInTheDocument(); expect(screen.getByText('Resources')).toBeInTheDocument(); expect(await screen.findByTestId('badge-display-name')).toBeInTheDocument(); - expect(screen.queryByTestId('loading-placeholder')).not.toBeInTheDocument(); }); }); @@ -93,7 +87,6 @@ describe('ProjectsDashboard', () => { slug: 'project1', teams: [teamA], firstEvent: new Date().toISOString(), - stats: [], }), ProjectFixture({ id: '2', @@ -101,7 +94,6 @@ describe('ProjectsDashboard', () => { teams: [teamA], isBookmarked: true, firstEvent: new Date().toISOString(), - stats: [], }), ]; @@ -111,7 +103,6 @@ describe('ProjectsDashboard', () => { render(); expect(await screen.findByText('My Teams')).toBeInTheDocument(); expect(screen.getAllByTestId('badge-display-name')).toHaveLength(2); - expect(screen.queryByTestId('loading-placeholder')).not.toBeInTheDocument(); }); it('renders only projects for my teams by default', async () => { @@ -122,7 +113,6 @@ describe('ProjectsDashboard', () => { slug: 'project1', teams: [teamA], firstEvent: new Date().toISOString(), - stats: [], }), ]; @@ -134,7 +124,6 @@ describe('ProjectsDashboard', () => { teams: [], isBookmarked: true, firstEvent: new Date().toISOString(), - stats: [], }), ]); const teamsWithTwoProjects = [TeamFixture({projects: teamProjects})]; @@ -155,7 +144,6 @@ describe('ProjectsDashboard', () => { slug: 'project1', teams: [teamA], firstEvent: new Date().toISOString(), - stats: [], }), ]; teamA.projects = teamProjects; @@ -166,7 +154,6 @@ describe('ProjectsDashboard', () => { slug: 'project2', teams: [teamB], firstEvent: new Date().toISOString(), - stats: [], }), ]; teamB.projects = teamBProjects; @@ -211,7 +198,6 @@ describe('ProjectsDashboard', () => { slug: 'project1', teams: [teamA], firstEvent: new Date().toISOString(), - stats: [], }), ]; teamA.projects = teamAProjects; @@ -223,7 +209,6 @@ describe('ProjectsDashboard', () => { name: 'project2', teams: [teamB], firstEvent: new Date().toISOString(), - stats: [], isMember: false, }), ]; @@ -264,7 +249,6 @@ describe('ProjectsDashboard', () => { slug: 'project1', teams: [teamA], firstEvent: new Date().toISOString(), - stats: [], }), ]; teamA.projects = teamProjects; @@ -276,7 +260,6 @@ describe('ProjectsDashboard', () => { slug: 'project2', teams: [], firstEvent: new Date().toISOString(), - stats: [], }), ]); const teamsWithTwoProjects = [ @@ -308,12 +291,10 @@ describe('ProjectsDashboard', () => { ProjectFixture({ id: '1', slug: 'project1', - stats: [], }), ProjectFixture({ id: '2', slug: 'project2', - stats: [], }), ], }); @@ -338,7 +319,6 @@ describe('ProjectsDashboard', () => { slug: 'project1', teams: [teamC], firstEvent: new Date().toISOString(), - stats: [], }), ProjectFixture({ id: '2', @@ -346,21 +326,18 @@ describe('ProjectsDashboard', () => { teams: [teamC], isBookmarked: true, firstEvent: new Date().toISOString(), - stats: [], }), ProjectFixture({ id: '3', slug: 'project3', teams: [teamD], firstEvent: new Date().toISOString(), - stats: [], }), ProjectFixture({ id: '4', slug: 'project4', teams: [], firstEvent: new Date().toISOString(), - stats: [], }), ]; @@ -396,7 +373,6 @@ describe('ProjectsDashboard', () => { slug: 'project1', teams: [teamA], firstEvent: new Date().toISOString(), - stats: [], }), ProjectFixture({ id: '2', @@ -404,7 +380,6 @@ describe('ProjectsDashboard', () => { teams: [teamA], isBookmarked: true, firstEvent: new Date().toISOString(), - stats: [], }), ]; @@ -420,7 +395,6 @@ describe('ProjectsDashboard', () => { await waitFor(() => { expect(screen.queryByText('project1')).not.toBeInTheDocument(); }); - expect(screen.queryByTestId('loading-placeholder')).not.toBeInTheDocument(); }); it('renders bookmarked projects first in team list', async () => { @@ -431,42 +405,36 @@ describe('ProjectsDashboard', () => { slug: 'm', teams: [teamA], isBookmarked: false, - stats: [], }), ProjectFixture({ id: '12', slug: 'm-fave', teams: [teamA], isBookmarked: true, - stats: [], }), ProjectFixture({ id: '13', slug: 'a-fave', teams: [teamA], isBookmarked: true, - stats: [], }), ProjectFixture({ id: '14', slug: 'z-fave', teams: [teamA], isBookmarked: true, - stats: [], }), ProjectFixture({ id: '15', slug: 'a', teams: [teamA], isBookmarked: false, - stats: [], }), ProjectFixture({ id: '16', slug: 'z', teams: [teamA], isBookmarked: false, - stats: [], }), ]; @@ -476,15 +444,7 @@ describe('ProjectsDashboard', () => { MockApiClient.addMockResponse({ url: `/organizations/${org.slug}/projects/`, - body: [ - ProjectFixture({ - teams, - stats: [ - [1517281200, 2], - [1517310000, 1], - ], - }), - ], + body: [ProjectFixture({teams})], }); render(); @@ -504,124 +464,4 @@ describe('ProjectsDashboard', () => { expect(within(projectName[5]!).getByText('z')).toBeInTheDocument(); }); }); - - describe('ProjectsStatsStore', () => { - const teamA = TeamFixture({slug: 'team1', isMember: true}); - const projects = [ - ProjectFixture({ - id: '1', - slug: 'm', - teams, - isBookmarked: false, - }), - ProjectFixture({ - id: '2', - slug: 'm-fave', - teams: [teamA], - isBookmarked: true, - }), - ProjectFixture({ - id: '3', - slug: 'a-fave', - teams: [teamA], - isBookmarked: true, - }), - ProjectFixture({ - id: '4', - slug: 'z-fave', - teams: [teamA], - isBookmarked: true, - }), - ProjectFixture({ - id: '5', - slug: 'a', - teams: [teamA], - isBookmarked: false, - }), - ProjectFixture({ - id: '6', - slug: 'z', - teams: [teamA], - isBookmarked: false, - }), - ]; - - beforeEach(() => { - const teamsWithStatTestProjects = [TeamFixture({projects})]; - TeamStore.loadInitialData(teamsWithStatTestProjects); - }); - - it('uses ProjectsStatsStore to load stats', async () => { - ProjectsStore.loadInitialData(projects); - - jest.useFakeTimers(); - ProjectsStatsStore.onStatsLoadSuccess([ - {...projects[0]!, stats: [[1517281200, 2]]}, - ]); - const loadStatsSpy = jest.spyOn(projectsActions, 'loadStatsForProject'); - const mock = MockApiClient.addMockResponse({ - url: `/organizations/${org.slug}/projects/`, - body: projects.map(project => ({ - ...project, - stats: [ - [1517281200, 2], - [1517310000, 1], - ], - })), - }); - - const {unmount} = render(); - - expect(loadStatsSpy).toHaveBeenCalledTimes(6); - expect(mock).not.toHaveBeenCalled(); - - const projectSummary = screen.getAllByTestId('summary-links'); - // Has 5 Loading Cards because 1 project has been loaded in store already - expect( - within(projectSummary[0]!).getByTestId('loading-placeholder') - ).toBeInTheDocument(); - expect( - within(projectSummary[1]!).getByTestId('loading-placeholder') - ).toBeInTheDocument(); - expect( - within(projectSummary[2]!).getByTestId('loading-placeholder') - ).toBeInTheDocument(); - expect( - within(projectSummary[3]!).getByTestId('loading-placeholder') - ).toBeInTheDocument(); - expect(within(projectSummary[4]!).getByText('Errors: 2')).toBeInTheDocument(); - expect( - within(projectSummary[5]!).getByTestId('loading-placeholder') - ).toBeInTheDocument(); - - // Advance timers so that batched request fires - act(() => jest.advanceTimersByTime(51)); - expect(mock).toHaveBeenCalledTimes(1); - // query ids = 3, 2, 4 = bookmarked - // 1 - already loaded in store so shouldn't be in query - expect(mock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - query: expect.objectContaining({ - query: 'id:3 id:2 id:4 id:5 id:6', - }), - }) - ); - jest.useRealTimers(); - - // All cards have loaded - await waitFor(() => { - expect(within(projectSummary[0]!).getByText('Errors: 3')).toBeInTheDocument(); - }); - expect(within(projectSummary[1]!).getByText('Errors: 3')).toBeInTheDocument(); - expect(within(projectSummary[2]!).getByText('Errors: 3')).toBeInTheDocument(); - expect(within(projectSummary[3]!).getByText('Errors: 3')).toBeInTheDocument(); - expect(within(projectSummary[4]!).getByText('Errors: 3')).toBeInTheDocument(); - expect(within(projectSummary[5]!).getByText('Errors: 3')).toBeInTheDocument(); - - // Resets store when it unmounts - unmount(); - expect(ProjectsStatsStore.getAll()).toEqual({}); - }); - }); }); diff --git a/static/app/views/projectsDashboard/index.tsx b/static/app/views/projectsDashboard/index.tsx index ce8d7b764e59..b5c127ca54a4 100644 --- a/static/app/views/projectsDashboard/index.tsx +++ b/static/app/views/projectsDashboard/index.tsx @@ -18,7 +18,6 @@ import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants'; import {IconAdd, IconUser} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {ProjectsStatsStore} from 'sentry/stores/projectsStatsStore'; import type {Team} from 'sentry/types/organization'; import type {Project, TeamWithProjects} from 'sentry/types/project'; import { @@ -136,11 +135,6 @@ function Dashboard() { const organization = useOrganization(); const hasPageFrameFeature = useHasPageFrameFeature(); - useEffect(() => { - return function cleanup() { - ProjectsStatsStore.reset(); - }; - }, []); const {teams: userTeams, isLoading: loadingTeams, isError} = useUserTeams(); const isAllTeams = location.query.team === ''; const selectedTeams = getTeamParams(location.query.team ?? 'myteams'); diff --git a/static/app/views/projectsDashboard/projectCard.spec.tsx b/static/app/views/projectsDashboard/projectCard.spec.tsx index 416d3a2960c1..ffed02b3b0df 100644 --- a/static/app/views/projectsDashboard/projectCard.spec.tsx +++ b/static/app/views/projectsDashboard/projectCard.spec.tsx @@ -3,39 +3,66 @@ import {ProjectFixture} from 'sentry-fixture/project'; import {render, screen, within} from 'sentry-test/reactTestingLibrary'; +import {type ProjectStats} from 'sentry/types/project'; import {ProjectCard} from 'sentry/views/projectsDashboard/projectCard'; -// NOTE: Unmocking debounce so that the actionCreator never fires -jest.unmock('lodash/debounce'); +function addProjectStatsResponse({ + latestDeploys, + organization = OrganizationFixture(), + project = ProjectFixture({platform: 'javascript'}), + stats, + transactionStats, +}: { + latestDeploys?: ReturnType['latestDeploys']; + organization?: ReturnType; + project?: ReturnType; + stats?: ProjectStats; + transactionStats?: ProjectStats; +} = {}) { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/projects/`, + body: [ + { + ...project, + latestDeploys, + stats, + transactionStats, + }, + ], + }); +} describe('ProjectCard', () => { - const createWrapper = () => - render( - - ); + const createWrapper = async () => { + const project = ProjectFixture({platform: 'javascript'}); + addProjectStatsResponse({ + project, + stats: [ + [1525042800, 1], + [1525046400, 2], + ], + transactionStats: [ + [1525042800, 4], + [1525046400, 8], + ], + }); + + render(); + await screen.findByText('Errors: 3'); + }; afterEach(() => { MockApiClient.clearMockResponses(); }); - it('renders', () => { - createWrapper(); + it('renders', async () => { + await createWrapper(); }); - it('renders latest 2 deploys', () => { + it('renders latest 2 deploys', async () => { + const project = ProjectFixture({ + platform: 'javascript', + }); const latestDeploys = { beta: { dateFinished: '2018-05-10T20:56:40.092Z', @@ -51,83 +78,79 @@ describe('ProjectCard', () => { }, }; - render( - - ); + addProjectStatsResponse({ + project, + stats: [ + [1525042800, 1], + [1525046400, 2], + ], + latestDeploys, + }); + + render(); expect(screen.queryByRole('button', {name: 'Track Deploys'})).not.toBeInTheDocument(); - expect(screen.getByText('beta')).toBeInTheDocument(); + expect(await screen.findByText('beta')).toBeInTheDocument(); expect(screen.getByText('production')).toBeInTheDocument(); expect(screen.queryByText('staging')).not.toBeInTheDocument(); }); - it('renders empty state if no deploys', () => { - createWrapper(); + it('renders empty state if no deploys', async () => { + await createWrapper(); expect(screen.getByRole('button', {name: 'Track Deploys'})).toBeInTheDocument(); }); - it('renders with platform', () => { - createWrapper(); + it('renders with platform', async () => { + await createWrapper(); expect(screen.getByTestId('platform-icon-javascript')).toBeInTheDocument(); }); - it('renders header link for errors', () => { - render( - - ); - - expect(screen.getByTestId('project-errors')).toBeInTheDocument(); - expect(screen.getByText('Errors: 6')).toBeInTheDocument(); + it('renders header link for errors', async () => { + const project = ProjectFixture({ + platform: 'javascript', + }); + addProjectStatsResponse({ + project, + stats: [ + [1525042800, 3], + [1525046400, 3], + ], + }); + + render(); + + expect(await screen.findByTestId('project-errors')).toBeInTheDocument(); + expect(await screen.findByText('Errors: 6')).toBeInTheDocument(); // No transactions as the feature isn't set. expect(screen.queryByTestId('project-transactions')).not.toBeInTheDocument(); }); - it('renders header link for transactions', () => { + it('renders header link for transactions', async () => { const organization = OrganizationFixture({features: ['performance-view']}); - - render( - , - {organization} - ); - - expect(screen.getByTestId('project-errors')).toBeInTheDocument(); - expect(screen.getByTestId('project-transactions')).toBeInTheDocument(); - expect(screen.getByText('Transactions: 8')).toBeInTheDocument(); + const project = ProjectFixture({ + platform: 'javascript', + }); + addProjectStatsResponse({ + organization, + project, + stats: [ + [1525042800, 3], + [1525046400, 3], + ], + transactionStats: [ + [1525042800, 4], + [1525046400, 4], + ], + }); + + render(, {organization}); + + expect(await screen.findByTestId('project-errors')).toBeInTheDocument(); + expect(await screen.findByTestId('project-transactions')).toBeInTheDocument(); + expect(await screen.findByText('Transactions: 8')).toBeInTheDocument(); }); it('renders loading placeholder card if there are no stats', () => { diff --git a/static/app/views/projectsDashboard/projectCard.tsx b/static/app/views/projectsDashboard/projectCard.tsx index 6c11de500163..3509eb4260ab 100644 --- a/static/app/views/projectsDashboard/projectCard.tsx +++ b/static/app/views/projectsDashboard/projectCard.tsx @@ -1,4 +1,4 @@ -import {Fragment, useEffect} from 'react'; +import {Fragment} from 'react'; import styled from '@emotion/styled'; import round from 'lodash/round'; @@ -6,7 +6,6 @@ import {LinkButton} from '@sentry/scraps/button'; import {Grid, Container} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; -import {loadStatsForProject} from 'sentry/actionCreators/projects'; import {IdBadge} from 'sentry/components/idBadge'; import {Panel} from 'sentry/components/panels/panel'; import {Placeholder} from 'sentry/components/placeholder'; @@ -22,13 +21,9 @@ import { } from 'sentry/components/scoreCard'; import {IconArrow, IconSettings} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {ProjectsStatsStore} from 'sentry/stores/projectsStatsStore'; -import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import type {Project} from 'sentry/types/project'; import {defined} from 'sentry/utils'; -import {DiscoverDatasets} from 'sentry/utils/discover/types'; import {formatAbbreviatedNumber} from 'sentry/utils/formatters'; -import {useApi} from 'sentry/utils/useApi'; import {useOrganization} from 'sentry/utils/useOrganization'; import { CRASH_FREE_DECIMAL_THRESHOLD, @@ -42,6 +37,7 @@ import {MissingReleasesButtons} from 'sentry/views/projectDetail/missingFeatureB import {Deploys} from './deploys'; import {ProjectChart} from './projectChart'; +import {useProjectStats} from './useProjectStats'; interface ProjectCardProps { hasProjectAccess: boolean; @@ -52,28 +48,17 @@ export function ProjectCard({ project: simpleProject, hasProjectAccess, }: ProjectCardProps) { - const api = useApi(); const organization = useOrganization(); - - const statsProject = useLegacyStore(ProjectsStatsStore)[simpleProject.slug]; - const project = statsProject ?? simpleProject; - - const {stats, slug, transactionStats, sessionStats} = project; - const {hasHealthData, currentCrashFreeRate, previousCrashFreeRate} = sessionStats || {}; - const hasPerformance = organization.features.includes('performance-view'); - - useEffect(() => { - loadStatsForProject(api, project.id, { - orgId: organization.slug, - projectId: project.id, - query: { - transactionStats: hasPerformance ? '1' : undefined, - dataset: DiscoverDatasets.METRICS_ENHANCED, - sessionStats: '1', - }, - }); - }, [project, organization.slug, hasPerformance, api]); + const {getOne: getProjectStats} = useProjectStats({ + organization, + hasPerformance, + }); + + const projectStats = getProjectStats(simpleProject); + const {stats, transactionStats, sessionStats, latestDeploys} = projectStats; + const {slug} = simpleProject; + const {hasHealthData, currentCrashFreeRate, previousCrashFreeRate} = sessionStats || {}; const crashFreeTrend = defined(currentCrashFreeRate) && defined(previousCrashFreeRate) @@ -90,7 +75,7 @@ export function ProjectCard({ } /> @@ -112,9 +97,9 @@ export function ProjectCard({ const totalTransactions = transactionStats?.reduce((sum, [_, value]) => sum + value, 0) ?? 0; - const hasFirstEvent = !!project.firstEvent || project.firstTransactionEvent; - const domainView = project - ? platformToDomainView([project], [parseInt(project.id, 10)]) + const hasFirstEvent = !!simpleProject.firstEvent || simpleProject.firstTransactionEvent; + const domainView = simpleProject + ? platformToDomainView([simpleProject], [parseInt(simpleProject.id, 10)]) : 'backend'; return ( @@ -122,7 +107,7 @@ export function ProjectCard({ - + @@ -144,14 +129,14 @@ export function ProjectCard({ {t('Errors: %s', formatAbbreviatedNumber(totalErrors))} {hasPerformance && ( {t('Transactions: %s', formatAbbreviatedNumber(totalTransactions))} {totalTransactions === 0 && ( @@ -175,7 +160,7 @@ export function ProjectCard({ firstEvent={hasFirstEvent} stats={stats} transactionStats={transactionStats} - project={project} + project={simpleProject} /> ) : ( @@ -209,7 +194,11 @@ export function ProjectCard({
{t('Latest Deploys')} - {stats ? : } + {stats ? ( + + ) : ( + + )}
diff --git a/static/app/views/projectsDashboard/projectChart.tsx b/static/app/views/projectsDashboard/projectChart.tsx index d76704bd2d79..800aeb860d54 100644 --- a/static/app/views/projectsDashboard/projectChart.tsx +++ b/static/app/views/projectsDashboard/projectChart.tsx @@ -4,7 +4,7 @@ import {useTheme} from '@emotion/react'; import {BaseChart} from 'sentry/components/charts/baseChart'; import {t} from 'sentry/locale'; import type {Organization} from 'sentry/types/organization'; -import type {Project} from 'sentry/types/project'; +import type {Project, ProjectStats} from 'sentry/types/project'; import {axisLabelFormatter} from 'sentry/utils/discover/charts'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -17,9 +17,9 @@ type BaseChartProps = React.ComponentProps; type Props = { firstEvent: boolean; project: Project; - stats: Project['stats']; + stats: ProjectStats | undefined; onBarClick?: (data: {seriesName: string; timestamp: number; value: number}) => void; - transactionStats?: Project['transactionStats']; + transactionStats?: ProjectStats; }; export function ProjectChart({ diff --git a/static/app/views/projectsDashboard/useProjectStats.spec.tsx b/static/app/views/projectsDashboard/useProjectStats.spec.tsx new file mode 100644 index 000000000000..6555f5200dd9 --- /dev/null +++ b/static/app/views/projectsDashboard/useProjectStats.spec.tsx @@ -0,0 +1,124 @@ +import type {ReactNode} from 'react'; +import {QueryClientProvider} from '@tanstack/react-query'; +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import {makeTestQueryClient} from 'sentry-test/queryClient'; +import { + act, + renderHook, + renderHookWithProviders, + waitFor, +} from 'sentry-test/reactTestingLibrary'; + +import type {ProjectStats} from 'sentry/types/project'; +import {DiscoverDatasets} from 'sentry/utils/discover/types'; +import {useProjectStats} from 'sentry/views/projectsDashboard/useProjectStats'; + +describe('useProjectStats', () => { + const organization = OrganizationFixture(); + const initialStats: ProjectStats = [[1517281200, 2]]; + const responseStats: ProjectStats = [ + [1517281200, 2], + [1517310000, 1], + ]; + + afterEach(() => { + MockApiClient.clearMockResponses(); + }); + + it('uses initial stats and batches missing project ids', async () => { + const projects = [ + ProjectFixture({id: '1', slug: 'm'}), + ProjectFixture({id: '2', slug: 'a'}), + ProjectFixture({id: '3', slug: 'z'}), + ]; + const projectWithInitialStats = { + ...projects[0]!, + stats: initialStats, + transactionStats: initialStats, + }; + + const mock = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/projects/`, + body: projects.map(project => ({ + ...project, + stats: responseStats, + transactionStats: responseStats, + })), + }); + + const {result} = renderHookWithProviders( + () => useProjectStats({organization, hasPerformance: true}), + {organization} + ); + + act(() => { + expect(result.current.getOne(projectWithInitialStats).stats).toEqual(initialStats); + result.current.getOne(projects[1]!); + result.current.getOne(projects[2]!); + }); + + expect(mock).not.toHaveBeenCalled(); + + await waitFor(() => expect(mock).toHaveBeenCalledTimes(1)); + expect(mock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + query: expect.objectContaining({ + dataset: DiscoverDatasets.METRICS_ENHANCED, + query: 'id:2 id:3', + sessionStats: '1', + statsPeriod: '24h', + transactionStats: '1', + }), + }) + ); + + await waitFor(() => { + expect(result.current.getOne(projects[1]!).stats).toEqual(responseStats); + expect(result.current.getOne(projects[2]!).stats).toEqual(responseStats); + }); + }); + + it('keeps cached stats when the hook remounts', async () => { + const queryClient = makeTestQueryClient(); + const wrapper = ({children}: {children?: ReactNode}) => ( + {children} + ); + const project = ProjectFixture({id: '1', slug: 'm'}); + const mock = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/projects/`, + body: [ + { + ...project, + stats: responseStats, + }, + ], + }); + + const {result, unmount} = renderHook( + () => useProjectStats({organization, hasPerformance: false}), + {wrapper} + ); + + act(() => { + expect(result.current.getOne(project).stats).toBeUndefined(); + }); + + await waitFor(() => + expect(result.current.getOne(project).stats).toEqual(responseStats) + ); + expect(mock).toHaveBeenCalledTimes(1); + + unmount(); + + const {result: remountResult} = renderHook( + () => useProjectStats({organization, hasPerformance: false}), + {wrapper} + ); + + expect(remountResult.current.getOne(project).stats).toEqual(responseStats); + expect(mock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/static/app/views/projectsDashboard/useProjectStats.tsx b/static/app/views/projectsDashboard/useProjectStats.tsx new file mode 100644 index 000000000000..35f173b438c0 --- /dev/null +++ b/static/app/views/projectsDashboard/useProjectStats.tsx @@ -0,0 +1,127 @@ +import {useCallback, useMemo} from 'react'; + +import {addErrorMessage} from 'sentry/actionCreators/indicator'; +import {t} from 'sentry/locale'; +import type {Organization} from 'sentry/types/organization'; +import type {Project, ProjectStats} from 'sentry/types/project'; +import type {ApiResponse} from 'sentry/utils/api/apiFetch'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {useAggregatedQueryKeys} from 'sentry/utils/api/useAggregatedQueryKeys'; +import {DiscoverDatasets} from 'sentry/utils/discover/types'; + +const MAX_PROJECTS_TO_FETCH = 10; + +type ProjectSessionStats = { + currentCrashFreeRate: number | null; + hasHealthData: boolean; + previousCrashFreeRate: number | null; +}; + +type ProjectStatsData = { + latestDeploys?: Project['latestDeploys']; + sessionStats?: ProjectSessionStats; + stats?: ProjectStats; + transactionStats?: ProjectStats; +}; + +type ProjectStatsResponse = Project & ProjectStatsData; +type ProjectStatsState = Record; + +function hasStats(stats: ProjectStatsData | undefined, hasPerformance: boolean) { + return ( + stats?.stats !== undefined && + (!hasPerformance || stats.transactionStats !== undefined) + ); +} + +function getStatsData(project: ProjectStatsResponse): ProjectStatsData { + return { + latestDeploys: project.latestDeploys, + sessionStats: project.sessionStats, + stats: project.stats, + transactionStats: project.transactionStats, + }; +} + +interface Props { + hasPerformance: boolean; + organization: Organization; +} + +export function useProjectStats({hasPerformance, organization}: Props) { + const projectStats = useAggregatedQueryKeys< + string, + ProjectStatsState, + ProjectStatsResponse[] + >({ + cacheKey: `project-dashboard-stats:${organization.slug}:transaction-stats:${hasPerformance}`, + bufferLimit: MAX_PROJECTS_TO_FETCH, + getQueryOptions: useCallback( + ids => + apiOptions.as()( + '/organizations/$organizationIdOrSlug/projects/', + { + path: {organizationIdOrSlug: organization.slug}, + query: { + statsPeriod: '24h', + query: ids.map(id => `id:${id}`).join(' '), + transactionStats: hasPerformance ? '1' : undefined, + dataset: DiscoverDatasets.METRICS_ENHANCED, + sessionStats: '1', + }, + staleTime: 0, + } + ), + [hasPerformance, organization.slug] + ), + onError: useCallback(() => { + addErrorMessage(t('Unable to fetch all project stats')); + }, []), + responseReducer: useCallback( + ( + prevState: ProjectStatsState | undefined, + response: ApiResponse, + aggregates: readonly string[] + ) => { + const aggregateIds = new Set(aggregates); + const nextState: ProjectStatsState = Object.fromEntries( + Object.entries(prevState ?? {}).filter(([projectId]) => + aggregateIds.has(projectId) + ) + ); + + for (const statsProject of response.json) { + if ( + aggregateIds.has(statsProject.id) && + hasStats(statsProject, hasPerformance) + ) { + nextState[statsProject.id] = getStatsData(statsProject); + } + } + + return nextState; + }, + [hasPerformance] + ), + }); + + const getOne = useCallback( + (project: Project): ProjectStatsData => { + const initialStats = getStatsData(project); + const cachedStats = projectStats.read([project.id])?.[project.id]; + + if (cachedStats && hasStats(cachedStats, hasPerformance)) { + return cachedStats; + } + + if (!hasStats(initialStats, hasPerformance)) { + projectStats.buffer([project.id]); + } + + return projectStats.data?.[project.id] ?? initialStats; + }, + [hasPerformance, projectStats] + ); + + return useMemo(() => ({getOne}), [getOne]); +} diff --git a/static/app/views/settings/organizationProjects/index.tsx b/static/app/views/settings/organizationProjects/index.tsx index 42694b32c355..fb4eeadf6cca 100644 --- a/static/app/views/settings/organizationProjects/index.tsx +++ b/static/app/views/settings/organizationProjects/index.tsx @@ -17,7 +17,7 @@ import {SearchBar} from 'sentry/components/searchBar'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants'; import {t} from 'sentry/locale'; -import type {Project} from 'sentry/types/project'; +import type {Project, ProjectStats} from 'sentry/types/project'; import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; import {sortProjects} from 'sentry/utils/project/sortProjects'; import {decodeScalar} from 'sentry/utils/queryString'; @@ -33,6 +33,7 @@ import {CreateProjectButton} from 'sentry/views/settings/organizationProjects/cr import {ProjectStatsGraph} from './projectStatsGraph'; const ITEMS_PER_PAGE = 50; +type ProjectListItem = Project & {stats?: ProjectStats}; function OrganizationProjects() { const organization = useOrganization(); @@ -47,17 +48,20 @@ function OrganizationProjects() { isPending, isError, } = useQuery({ - ...apiOptions.as()('/organizations/$organizationIdOrSlug/projects/', { - path: {organizationIdOrSlug: organization.slug}, - query: { - ...location.query, - query, - per_page: ITEMS_PER_PAGE, - statsPeriod: '24h', - collapse: ['latestDeploys', 'unusedFeatures'], - }, - staleTime: 0, - }), + ...apiOptions.as()( + '/organizations/$organizationIdOrSlug/projects/', + { + path: {organizationIdOrSlug: organization.slug}, + query: { + ...location.query, + query, + per_page: ITEMS_PER_PAGE, + statsPeriod: '24h', + collapse: ['latestDeploys', 'unusedFeatures'], + }, + staleTime: 0, + } + ), select: selectJsonWithHeaders, }); @@ -113,7 +117,7 @@ function OrganizationProjects() { - + ))} diff --git a/static/app/views/settings/organizationProjects/projectStatsGraph.tsx b/static/app/views/settings/organizationProjects/projectStatsGraph.tsx index 687cb4db09a3..1508e5a71326 100644 --- a/static/app/views/settings/organizationProjects/projectStatsGraph.tsx +++ b/static/app/views/settings/organizationProjects/projectStatsGraph.tsx @@ -1,32 +1,26 @@ -import {Fragment} from 'react'; import LazyLoad from 'react-lazyload'; import {MiniBarChart} from 'sentry/components/charts/miniBarChart'; import {t} from 'sentry/locale'; import type {Series} from 'sentry/types/echarts'; -import type {Project} from 'sentry/types/project'; +import type {ProjectStats} from 'sentry/types/project'; type Props = { - project: Project; - stats?: Project['stats']; + stats?: ProjectStats; }; -export function ProjectStatsGraph({project, stats}: Props) { - stats = stats || project.stats || []; +export function ProjectStatsGraph({stats}: Props) { + const chartStats = stats ?? []; const series: Series[] = [ { seriesName: t('Events'), - data: stats.map(point => ({name: point[0] * 1000, value: point[1]})), + data: chartStats.map(point => ({name: point[0] * 1000, value: point[1]})), }, ]; return ( - - {series && ( - - - - )} - + + + ); } diff --git a/static/gsAdmin/views/customerDetails.spec.tsx b/static/gsAdmin/views/customerDetails.spec.tsx index 1ded2618df62..4152171ac7cb 100644 --- a/static/gsAdmin/views/customerDetails.spec.tsx +++ b/static/gsAdmin/views/customerDetails.spec.tsx @@ -645,7 +645,12 @@ function setUpMocks( }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/projects/?statsPeriod=30d`, - body: [ProjectFixture({})], + body: [ + { + ...ProjectFixture({}), + stats: [], + }, + ], }); MockApiClient.addMockResponse({ url: `/customers/${organization.slug}/history/`, diff --git a/tests/js/getsentry-test/fixtures/project.ts b/tests/js/getsentry-test/fixtures/project.ts index 3f052ae99848..f731111bafee 100644 --- a/tests/js/getsentry-test/fixtures/project.ts +++ b/tests/js/getsentry-test/fixtures/project.ts @@ -21,38 +21,6 @@ export function ProjectFixture(params: Partial): TProject { name: 'squirrel-finder-backend', platform: 'python-flask', slug: 'squirrel-finder-backend', - stats: [ - [1514764800, 0], - [1514851200, 0], - [1514937600, 0], - [1515024000, 0], - [1515110400, 0], - [1515196800, 0], - [1515283200, 0], - [1515369600, 0], - [1515456000, 0], - [1515542400, 0], - [1515628800, 0], - [1515715200, 0], - [1515801600, 0], - [1515888000, 0], - [1515974400, 0], - [1516060800, 0], - [1516147200, 0], - [1516233600, 0], - [1516320000, 0], - [1516406400, 0], - [1516492800, 0], - [1516579200, 0], - [1516665600, 0], - [1516752000, 0], - [1516838400, 0], - [1516924800, 0], - [1517011200, 0], - [1517097600, 0], - [1517184000, 0], - [1517270400, 0], - ], team: rodentOpsTeam, teams: [rodentOpsTeam], ...params,