From a97d3cf5b9f5d9306492ae89b21511a647605cd8 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Mon, 1 Apr 2024 14:10:08 -0700 Subject: [PATCH] feat(useProject): Create a hook to fetch individual projects and move away from the ProjectStore --- static/app/types/project.tsx | 11 ++ static/app/utils/project/useProject.spec.tsx | 123 +++++++++++++++++++ static/app/utils/project/useProject.tsx | 73 +++++++++++ 3 files changed, 207 insertions(+) create mode 100644 static/app/utils/project/useProject.spec.tsx create mode 100644 static/app/utils/project/useProject.tsx diff --git a/static/app/types/project.tsx b/static/app/types/project.tsx index 9af37f377177fd..91c252fa58b691 100644 --- a/static/app/types/project.tsx +++ b/static/app/types/project.tsx @@ -20,9 +20,15 @@ export type Project = { digestsMinDelay: number; dynamicSamplingBiases: DynamicSamplingBias[] | null; environments: string[]; + /** + * @deprecated + */ eventProcessing: { symbolicationDegraded: boolean; }; + /** + * @deprecated + */ features: string[]; firstEvent: string | null; firstTransactionEvent: boolean; @@ -52,6 +58,7 @@ export type Project = { scrapeJavaScript: boolean; scrubIPAddresses: boolean; sensitiveFields: string[]; + slug: string; subjectTemplate: string; team: Team; teams: Team[]; @@ -59,9 +66,13 @@ export type Project = { builtinSymbolSources?: string[]; defaultEnvironment?: string; hasUserReports?: boolean; + /** + * @deprecated + */ latestDeploys?: Record> | null; latestRelease?: {version: string} | null; options?: Record; + platform?: PlatformKey; securityToken?: string; securityTokenHeader?: string; sessionStats?: { diff --git a/static/app/utils/project/useProject.spec.tsx b/static/app/utils/project/useProject.spec.tsx new file mode 100644 index 00000000000000..dfecda9e1d2e7f --- /dev/null +++ b/static/app/utils/project/useProject.spec.tsx @@ -0,0 +1,123 @@ +import type {ReactNode} from 'react'; +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import {makeTestQueryClient} from 'sentry-test/queryClient'; +import {reactHooks} from 'sentry-test/reactTestingLibrary'; + +import useProject from 'sentry/utils/project/useProject'; +import type {QueryClient} from 'sentry/utils/queryClient'; +import {QueryClientProvider} from 'sentry/utils/queryClient'; +import useOrganization from 'sentry/utils/useOrganization'; + +jest.mock('sentry/utils/useOrganization'); + +function makeWrapper(queryClient: QueryClient) { + return function wrapper({children}: {children?: ReactNode}) { + return {children}; + }; +} + +describe('useProject', () => { + const mockOrg = OrganizationFixture(); + jest.mocked(useOrganization).mockReturnValue(mockOrg); + + const project10 = ProjectFixture({id: '10', slug: 'ten'}); + const project20 = ProjectFixture({id: '20', slug: 'twenty'}); + + it('should fetch by id when an id is passed in', async () => { + const mockResponse = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/projects/', + body: [project10], + match: [MockApiClient.matchQuery({query: 'id:10'})], + }); + + const {waitFor} = reactHooks.renderHook(useProject, { + wrapper: makeWrapper(makeTestQueryClient()), + initialProps: {id: project10.id}, + }); + + await waitFor(() => expect(mockResponse).toHaveBeenCalled()); + expect(mockResponse).toHaveBeenCalledWith( + '/organizations/org-slug/projects/', + expect.objectContaining({query: {query: 'id:10'}}) + ); + }); + + it('should batch and fetch by id when an id is passed in', async () => { + const mockResponse = MockApiClient.addMockResponse({ + url: `/organizations/org-slug/projects/`, + body: [project10, project20], + match: [MockApiClient.matchQuery({query: 'id:10 id:20'})], + }); + + const queryClient = makeTestQueryClient(); + const {waitFor} = reactHooks.renderHook(useProject, { + wrapper: makeWrapper(queryClient), + initialProps: {id: project10.id}, + }); + reactHooks.renderHook(useProject, { + wrapper: makeWrapper(queryClient), + initialProps: {id: project20.id}, + }); + + await waitFor(() => expect(mockResponse).toHaveBeenCalled()); + expect(mockResponse).toHaveBeenCalledWith( + '/organizations/org-slug/projects/', + expect.objectContaining({query: {query: 'id:10 id:20'}}) + ); + }); + + it('should fetch by slug when a slug is passed in', async () => { + const mockResponse = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/projects/', + body: [project10], + match: [MockApiClient.matchQuery({query: 'slug:ten'})], + }); + + const {waitFor} = reactHooks.renderHook(useProject, { + wrapper: makeWrapper(makeTestQueryClient()), + initialProps: {slug: project10.slug}, + }); + + await waitFor(() => expect(mockResponse).toHaveBeenCalled()); + expect(mockResponse).toHaveBeenCalledWith( + '/organizations/org-slug/projects/', + expect.objectContaining({query: {query: 'slug:ten'}}) + ); + }); + + it('should return projects by id if they are in the cache', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/projects/', + body: [project10], + match: [MockApiClient.matchQuery({query: 'id:10'})], + }); + + const {result, waitFor} = reactHooks.renderHook(useProject, { + wrapper: makeWrapper(makeTestQueryClient()), + initialProps: {id: project10.id}, + }); + + expect(result.current).toBeUndefined(); + + await waitFor(() => expect(result.current).toBe(project10)); + }); + + it('should return projects by slug if they are in the cache', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/projects/', + body: [project10], + match: [MockApiClient.matchQuery({query: 'slug:ten'})], + }); + + const {result, waitFor} = reactHooks.renderHook(useProject, { + wrapper: makeWrapper(makeTestQueryClient()), + initialProps: {slug: project10.slug}, + }); + + expect(result.current).toBeUndefined(); + + await waitFor(() => expect(result.current).toBe(project10)); + }); +}); diff --git a/static/app/utils/project/useProject.tsx b/static/app/utils/project/useProject.tsx new file mode 100644 index 00000000000000..286986524b09a2 --- /dev/null +++ b/static/app/utils/project/useProject.tsx @@ -0,0 +1,73 @@ +import {useCallback, useEffect, useMemo} from 'react'; + +import type {ApiResult} from 'sentry/api'; +import type {Project} from 'sentry/types'; +import useAggregatedQueryKeys from 'sentry/utils/api/useAggregatedQueryKeys'; +import type {ApiQueryKey} from 'sentry/utils/queryClient'; +import useOrganization from 'sentry/utils/useOrganization'; + +type Props = + | {slug: string | undefined; id?: never} + | {id: string | undefined; slug?: never}; + +type AggQueryKey = string; + +type ProjectStore = Record; + +function makeResponseReducer(fieldName: string) { + return ( + prevState: undefined | ProjectStore, + response: ApiResult, + _aggregates: readonly AggQueryKey[] + ) => ({ + ...prevState, + ...Object.fromEntries(response[0].map(project => [project[fieldName], project])), + }); +} + +export default function useProject({slug, id}: Props) { + const organization = useOrganization(); + + const getQueryKey = useCallback( + (ids: readonly AggQueryKey[]): ApiQueryKey => [ + `/organizations/${organization.slug}/projects/`, + { + query: { + query: ids.join(' '), + }, + }, + ], + [organization.slug] + ); + + const byIdCache = useAggregatedQueryKeys({ + cacheKey: `/organizations/${organization.slug}/projects/#project-by-id`, + bufferLimit: 5, + getQueryKey, + responseReducer: useMemo(() => makeResponseReducer('id'), []), + }); + + const bySlugCache = useAggregatedQueryKeys({ + cacheKey: `/organizations/${organization.slug}/projects/#project-by-slug`, + bufferLimit: 5, + getQueryKey, + responseReducer: useMemo(() => makeResponseReducer('slug'), []), + }); + + useEffect(() => { + if (id) { + byIdCache.buffer([`id:${id}`]); + } else if (slug) { + if (bySlugCache.data?.[slug]) { + bySlugCache.buffer([`slug:${slug}`]); + } else { + bySlugCache.buffer([`slug:${slug}`]); + } + } + }, [id, slug, byIdCache, bySlugCache]); + + const lookupId = id ?? slug; + return lookupId + ? byIdCache.data?.[lookupId] ?? bySlugCache.data?.[lookupId] + : undefined; +}