Skip to content

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

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions static/app/types/project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -52,16 +58,21 @@ export type Project = {
scrapeJavaScript: boolean;
scrubIPAddresses: boolean;
sensitiveFields: string[];
slug: string;
subjectTemplate: string;
team: Team;
teams: Team[];
verifySSL: boolean;
builtinSymbolSources?: string[];
defaultEnvironment?: string;
hasUserReports?: boolean;
/**
* @deprecated
*/
latestDeploys?: Record<string, Pick<Deploy, 'dateFinished' | 'version'>> | null;
latestRelease?: {version: string} | null;
options?: Record<string, boolean | string>;
platform?: PlatformKey;
securityToken?: string;
securityTokenHeader?: string;
sessionStats?: {
Expand Down
123 changes: 123 additions & 0 deletions static/app/utils/project/useProject.spec.tsx
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));
});
});
73 changes: 73 additions & 0 deletions static/app/utils/project/useProject.tsx
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>({
Copy link
Member

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 and useProjectBySlug to avoid having to deal with two separate caches

Copy link
Contributor

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

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}`]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this is the same as the line above it

Copy link
Member Author

Choose a reason for hiding this comment

The 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
Copy link
Member

Choose a reason for hiding this comment

The 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 isLoading and isError?

? byIdCache.data?.[lookupId] ?? bySlugCache.data?.[lookupId]
: undefined;
}