Skip to content

Commit

Permalink
feat: add mutations and hooks for membership request (#899)
Browse files Browse the repository at this point in the history
* feat: add mutations and hooks for membership request

* refactor: invalidate keys on mutate

* refactor: add enabled for get requests

* refactor: allow enabled option on invitation hook

* feat: add enroll mutation

* feat: import enroll

* fix: enroll

* test: add tests

* refactor: update translations
  • Loading branch information
pyphilia authored Sep 23, 2024
1 parent c40959c commit 24098c9
Show file tree
Hide file tree
Showing 22 changed files with 677 additions and 9 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"@eslint/eslintrc": "3.1.0",
"@eslint/js": "9.10.0",
"@graasp/sdk": "4.29.1",
"@graasp/translations": "1.37.1",
"@graasp/translations": "1.38.0",
"@testing-library/dom": "10.4.0",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import configureItemPublicationHooks from '../item/publication/hooks.js';
import configureMemberHooks from '../member/hooks.js';
import configurePublicProfileHooks from '../member/publicProfile/hooks.js';
import configureSubscriptionHooks from '../member/subscription/hooks.js';
import configureMembershipRequestHooks from '../membership/request/hooks.js';
import { QueryClientConfig } from '../types.js';
import configureActionHooks from './action.js';
import configureAppsHooks from './apps.js';
Expand Down Expand Up @@ -60,6 +61,7 @@ export default (
...configureItemGeolocationHooks(queryConfig),
...configureEmbeddedLinkHooks(queryConfig),
...configureItemPublicationHooks(queryConfig),
...configureMembershipRequestHooks(queryConfig),
useDebounce,
};
};
7 changes: 5 additions & 2 deletions src/hooks/invitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ export default (queryConfig: QueryClientConfig) => {
},
});

const useItemInvitations = (itemId?: UUID) =>
const useItemInvitations = (
itemId?: UUID,
options: { enabled?: boolean } = {},
) =>
useQuery({
queryKey: itemKeys.single(itemId).invitation,
queryFn: () => {
Expand All @@ -37,7 +40,7 @@ export default (queryConfig: QueryClientConfig) => {

return Api.getInvitationsForItem(itemId, queryConfig);
},
enabled: Boolean(itemId),
enabled: Boolean(itemId) && (options?.enabled ?? true),
...defaultQueryOptions,
});

Expand Down
13 changes: 13 additions & 0 deletions src/item/itemLogin/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { DiscriminatedItem } from '@graasp/sdk';

import { verifyAuthentication } from '../../api/axios.js';
import { PartialQueryConfigForApi } from '../../types.js';
import { buildEnroll } from './routes.js';

export const enroll = async (
{ itemId }: { itemId: DiscriminatedItem['id'] },
{ API_HOST, axios }: PartialQueryConfigForApi,
) =>
verifyAuthentication(() => {
return axios.post<void>(`${API_HOST}/${buildEnroll(itemId)}`);
});
106 changes: 106 additions & 0 deletions src/item/itemLogin/mutations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { FolderItemFactory, HttpMethod } from '@graasp/sdk';
import { SUCCESS_MESSAGES } from '@graasp/translations';

import { act } from '@testing-library/react';
import { StatusCodes } from 'http-status-codes';
import nock from 'nock';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { OK_RESPONSE, UNAUTHORIZED_RESPONSE } from '../../../test/constants.js';
import {
mockMutation,
setUpTest,
waitForMutation,
} from '../../../test/utils.js';
import { itemKeys } from '../../keys.js';
import { buildEnroll } from './routes.js';
import { enrollRoutine } from './routines.js';

const mockedNotifier = vi.fn();
const { wrapper, queryClient, mutations } = setUpTest({
notifier: mockedNotifier,
});

const item = FolderItemFactory();
const itemId = item.id;
const key = itemKeys.single(itemId).content;
const membershipKey = itemKeys.single(itemId).memberships;

describe('useEnroll', () => {
const mutation = mutations.useEnroll;
const route = `/${buildEnroll(itemId)}`;

afterEach(() => {
nock.cleanAll();
queryClient.clear();
});

it('Enroll', async () => {
// set data in cache
queryClient.setQueryData(key, item);
queryClient.setQueryData(membershipKey, [{}]);

const endpoints = [
{
response: OK_RESPONSE,
method: HttpMethod.Post,
route,
},
];

const mockedMutation = await mockMutation({
endpoints,
mutation,
wrapper,
});

await act(async () => {
mockedMutation.mutate({ itemId });
await waitForMutation();
});

expect(mockedNotifier).toHaveBeenCalledWith({
type: enrollRoutine.SUCCESS,
payload: { message: SUCCESS_MESSAGES.ENROLL },
});

expect(queryClient.getQueryState(key)?.isInvalidated).toBeTruthy();
expect(
queryClient.getQueryState(membershipKey)?.isInvalidated,
).toBeTruthy();
});

it('Unauthorized to enroll', async () => {
// set data in cache
queryClient.setQueryData(key, item);
queryClient.setQueryData(membershipKey, [{}]);

const endpoints = [
{
response: UNAUTHORIZED_RESPONSE,
statusCode: StatusCodes.UNAUTHORIZED,
method: HttpMethod.Patch,
route,
},
];

const mockedMutation = await mockMutation({
endpoints,
mutation,
wrapper,
});

await act(async () => {
mockedMutation.mutate({ itemId });
await waitForMutation();
});

const state = queryClient.getQueryState(key);
expect(state?.isInvalidated).toBeTruthy();

expect(mockedNotifier).toHaveBeenCalledWith({
type: enrollRoutine.FAILURE,
payload: expect.anything(),
});
});
});
37 changes: 37 additions & 0 deletions src/item/itemLogin/mutations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { UUID } from '@graasp/sdk';
import { SUCCESS_MESSAGES } from '@graasp/translations';

import { useMutation, useQueryClient } from '@tanstack/react-query';

import { itemKeys } from '../../keys.js';
import { QueryClientConfig } from '../../types.js';
import { enroll } from './api.js';
import { enrollRoutine } from './routines.js';

export const useEnroll = (queryConfig: QueryClientConfig) => () => {
const { notifier } = queryConfig;

const queryClient = useQueryClient();
return useMutation(
(payload: { itemId: UUID }) => enroll(payload, queryConfig),
{
onSuccess: () => {
notifier?.({
type: enrollRoutine.SUCCESS,
payload: { message: SUCCESS_MESSAGES.ENROLL },
});
},
onError: (error: Error, _args, _context) => {
notifier?.({
type: enrollRoutine.FAILURE,
payload: { error },
});
},
onSettled: (_data, _error, { itemId }) => {
// on success, enroll should have given membership to the user
// invalidate full item because of packed
queryClient.invalidateQueries(itemKeys.single(itemId).content);
},
},
);
};
6 changes: 6 additions & 0 deletions src/item/itemLogin/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { DiscriminatedItem } from '@graasp/sdk';

import { ITEMS_ROUTE } from '../routes.js';

export const buildEnroll = (itemId: DiscriminatedItem['id']) =>
`${ITEMS_ROUTE}/${itemId}/enroll`;
3 changes: 3 additions & 0 deletions src/item/itemLogin/routines.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import createRoutine from '../../routines/utils.js';

export const enrollRoutine = createRoutine('ENROLL');
8 changes: 7 additions & 1 deletion src/item/reorder/mutations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { SUCCESS_MESSAGES } from '@graasp/translations';

import { act } from '@testing-library/react';
import { StatusCodes } from 'http-status-codes';
import nock from 'nock';
import { v4 } from 'uuid';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { UNAUTHORIZED_RESPONSE } from '../../../test/constants.js';
import {
Expand All @@ -29,6 +30,11 @@ describe('useReorderItem', () => {
const mutation = mutations.useReorderItem;
const { id: parentItemId } = FolderItemFactory();

afterEach(() => {
nock.cleanAll();
queryClient.clear();
});

it('Reorder item', async () => {
const route = `/${buildReorderItemRoute({ id: child.id })}`;

Expand Down
51 changes: 51 additions & 0 deletions src/membership/request/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
CompleteMembershipRequest,
Member,
MembershipRequestStatus,
UUID,
} from '@graasp/sdk';

import { PartialQueryConfigForApi } from '../../types.js';
import {
buildDeleteMembershipRequestRoute,
buildGetOwnMembershipRequestRoute,
buildRequestMembershipRoute,
} from './routes.js';

export const requestMembership = async (
{ id }: { id: UUID },
{ API_HOST, axios }: PartialQueryConfigForApi,
) =>
axios
.post(`${API_HOST}/${buildRequestMembershipRoute(id)}`)
.then(({ data }) => data);

export const getOwnMembershipRequest = async (
{ id }: { id: UUID },
{ API_HOST, axios }: PartialQueryConfigForApi,
) =>
axios
.get<{
status: MembershipRequestStatus;
}>(`${API_HOST}/${buildGetOwnMembershipRequestRoute(id)}`)
.then(({ data }) => data);

export const getMembershipRequests = async (
{ id }: { id: UUID },
{ API_HOST, axios }: PartialQueryConfigForApi,
) =>
axios
.get<
CompleteMembershipRequest[]
>(`${API_HOST}/${buildRequestMembershipRoute(id)}`)
.then(({ data }) => data);

export const deleteMembershipRequest = async (
{ itemId, memberId }: { itemId: UUID; memberId: Member['id'] },
{ API_HOST, axios }: PartialQueryConfigForApi,
) =>
axios
.delete(
`${API_HOST}/${buildDeleteMembershipRequestRoute({ itemId, memberId })}`,
)
.then(({ data }) => data);
90 changes: 90 additions & 0 deletions src/membership/request/hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {
CompleteMembershipRequest,
MemberFactory,
MembershipRequestStatus,
PackedFolderItemFactory,
} from '@graasp/sdk';

import nock from 'nock';
import { afterEach, describe, expect, it } from 'vitest';

import { mockHook, setUpTest } from '../../../test/utils.js';
import { membershipRequestsKeys } from './keys.js';
import {
buildGetOwnMembershipRequestRoute,
buildRequestMembershipRoute,
} from './routes.js';

const { hooks, wrapper, queryClient } = setUpTest();

const item = PackedFolderItemFactory();
const itemId = item.id;

describe('Action Hooks', () => {
afterEach(() => {
nock.cleanAll();
queryClient.clear();
});

describe('useOwnMembershipRequest', () => {
const hook = () => hooks.useOwnMembershipRequest(itemId);
const route = `/${buildGetOwnMembershipRequestRoute(itemId)}`;

it(`Return own membership request`, async () => {
const response = {
status: MembershipRequestStatus.Approved,
};
const endpoints = [
{
route,
response,
},
];
const { data } = await mockHook({
endpoints,
hook,
wrapper,
});

expect(data).toMatchObject(response);

// verify cache keys
expect(
queryClient.getQueryData(membershipRequestsKeys.own(itemId)),
).toMatchObject(response);
});
});

describe('useMembershipRequests', () => {
const hook = () => hooks.useMembershipRequests(itemId);
const route = `/${buildRequestMembershipRoute(itemId)}`;

it(`Return own membership request`, async () => {
const response: CompleteMembershipRequest[] = [
{
item,
createdAt: new Date().toISOString(),
member: MemberFactory(),
},
];
const endpoints = [
{
route,
response,
},
];
const { data } = await mockHook({
endpoints,
hook,
wrapper,
});

expect(data).toMatchObject(response);

// verify cache keys
expect(
queryClient.getQueryData(membershipRequestsKeys.single(itemId)),
).toMatchObject(response);
});
});
});
Loading

0 comments on commit 24098c9

Please sign in to comment.