-
-
Notifications
You must be signed in to change notification settings - Fork 4.4k
feat(useProject): Create a hook to fetch individual projects and move away from the ProjectStore #68038
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(useProject): Create a hook to fetch individual projects and move away from the ProjectStore #68038
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; | ||
}; | ||
} | ||
|
||
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)); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<string, Project>; | ||
|
||
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<AggQueryKey, ProjectStore>({ | ||
cacheKey: `/organizations/${organization.slug}/projects/#project-by-id`, | ||
bufferLimit: 5, | ||
getQueryKey, | ||
responseReducer: useMemo(() => makeResponseReducer('id'), []), | ||
}); | ||
|
||
const bySlugCache = useAggregatedQueryKeys<AggQueryKey, ProjectStore>({ | ||
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}`]); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like this is the same as the line above it There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. D'oh. I ripped out some stuff from that other PR, kept the wrong stuff! |
||
} | ||
} | ||
}, [id, slug, byIdCache, bySlugCache]); | ||
|
||
const lookupId = id ?? slug; | ||
return lookupId | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It looks like this just returns the project data, but shouldn't it also return |
||
? byIdCache.data?.[lookupId] ?? bySlugCache.data?.[lookupId] | ||
: undefined; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't have a strong opinion on this, but I wonder if we should split this into two hooks
useProjectById
anduseProjectBySlug
to avoid having to deal with two separate cachesThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if we do end up splitting this into two hooks i'd vouch for splitting the useTeamsById hook into
useTeamsById
+useTeamsBySlug
for consistency