Skip to content
This repository was archived by the owner on Feb 12, 2025. It is now read-only.

feat: handle favorite queries #275

Merged
merged 5 commits into from
Jun 20, 2023
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"Alexandre Chau"
],
"dependencies": {
"@graasp/sdk": "1.0.0-rc1",
"@graasp/sdk": "1.0.0",
"@graasp/translations": "1.13.0",
"axios": "0.27.2",
"crypto-js": "4.1.1",
Expand Down
1 change: 1 addition & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ export * from './action';
export * from './invitation';
export * from './subscription';
export * from './itemPublish';
export * from './itemFavorite';
36 changes: 36 additions & 0 deletions src/api/itemFavorite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Item, ItemFavorite, UUID } from '@graasp/sdk';

import { QueryClientConfig } from '../types';
import configureAxios, { verifyAuthentication } from './axios';
import { GET_FAVORITE_ITEMS_ROUTE, buildFavoriteItemRoute } from './routes';

const axios = configureAxios();

export const getFavoriteItems = async ({
API_HOST,
}: QueryClientConfig): Promise<Item[]> =>
verifyAuthentication(() =>
axios
.get(`${API_HOST}/${GET_FAVORITE_ITEMS_ROUTE}`)
.then(({ data }) => data),
);

export const addFavoriteItem = async (
id: UUID,
{ API_HOST }: QueryClientConfig,
): Promise<ItemFavorite> =>
verifyAuthentication(() =>
axios
.post(`${API_HOST}/${buildFavoriteItemRoute(id)}`)
.then(({ data }) => data),
);

export const removeFavoriteItem = async (
id: UUID,
{ API_HOST }: QueryClientConfig,
): Promise<UUID> =>
verifyAuthentication(() =>
axios
.delete(`${API_HOST}/${buildFavoriteItemRoute(id)}`)
.then(({ data }) => data),
);
7 changes: 7 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const SUBSCRIPTION_ROUTE = 'subscriptions';
export const GET_OWN_ITEMS_ROUTE = `${ITEMS_ROUTE}/own`;
export const INVITATIONS_ROUTE = `invitations`;
export const GET_RECYCLED_ITEMS_DATA_ROUTE = `${ITEMS_ROUTE}/recycled`;
export const GET_FAVORITE_ITEMS_ROUTE = `${ITEMS_ROUTE}/favorite`;
export const SHARED_ITEM_WITH_ROUTE = `${ITEMS_ROUTE}/shared-with`;
export const CATEGORIES_ROUTE = `${ITEMS_ROUTE}/categories`;
export const ETHERPAD_ROUTE = `${ITEMS_ROUTE}/etherpad`;
Expand Down Expand Up @@ -206,6 +207,9 @@ export const buildRecycleItemsRoute = (ids: UUID[]) =>
},
)}`;

export const buildFavoriteItemRoute = (itemId: UUID) =>
`${GET_FAVORITE_ITEMS_ROUTE}/${itemId}`;

export const buildRestoreItemsRoute = (ids: UUID[]) =>
`${ITEMS_ROUTE}/restore${qs.stringify(
{ id: ids },
Expand Down Expand Up @@ -241,6 +245,7 @@ export const buildDeleteItemCategoryRoute = (args: {
itemId: UUID;
itemCategoryId: UUID;
}) => `${ITEMS_ROUTE}/${args.itemId}/categories/${args.itemCategoryId}`;

export const buildGetApiAccessTokenRoute = (id: UUID) =>
`${APPS_ROUTE}/${id}/api-access-token`;

Expand Down Expand Up @@ -444,6 +449,8 @@ export const API_ROUTES = {
GET_ITEM_VALIDATION_STATUSES_ROUTE,
GET_OWN_ITEMS_ROUTE,
GET_RECYCLED_ITEMS_DATA_ROUTE,
GET_FAVORITE_ITEMS_ROUTE,
buildFavoriteItemRoute,
GET_TAGS_ROUTE,
ITEMS_ROUTE,
SHARED_ITEM_WITH_ROUTE,
Expand Down
6 changes: 4 additions & 2 deletions src/config/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ export const buildSearchByKeywordKey = (fields: SearchFields) => [
];

export const RECYCLED_ITEMS_DATA_KEY = 'recycledItemsData';

export const FAVORITE_ITEMS_KEY = 'favoriteItems';

export const buildItemThumbnailKey = ({
id,
size = DEFAULT_THUMBNAIL_SIZE,
Expand Down Expand Up @@ -251,6 +254,7 @@ export const DATA_KEYS = {
buildItemsByCategoriesKey,
buildSearchByKeywordKey,
RECYCLED_ITEMS_DATA_KEY,
FAVORITE_ITEMS_KEY,
buildItemThumbnailKey,
buildAvatarKey,
buildGetLikesForMemberKey,
Expand Down Expand Up @@ -312,8 +316,6 @@ export const MUTATION_KEYS = {
POST_ETHERPAD: 'postEtherpad',
POST_ITEM_LIKE: 'postItemLike',
DELETE_ITEM_LIKE: 'deleteItemLike',
ADD_FAVORITE_ITEM: 'addFavoriteItem',
DELETE_FAVORITE_ITEM: 'deleteFavoriteItem',
POST_ITEM_VALIDATION: 'postItemValidation',
UPDATE_ITEM_VALIDATION_REVIEW: 'updateItemValidationReview',
EXPORT_ACTIONS: 'exportActions',
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import configureCategoryHooks from './category';
import configureChatHooks from './chat';
import configureInvitationHooks from './invitation';
import configureItemHooks from './item';
import configureItemFavoriteHooks from './itemFavorite';
import configureItemFlagHooks from './itemFlag';
import configureItemLikeHooks from './itemLike';
import configureItemLoginHooks from './itemLogin';
Expand Down Expand Up @@ -50,6 +51,7 @@ export default (
...configureItemLoginHooks(queryConfig),
...configureItemPublishedHooks(queryConfig),
...configureItemValidationHooks(queryConfig),
...configureItemFavoriteHooks(queryConfig),
...configureAppsHooks(queryConfig),
...configureActionHooks(queryConfig),
...configureInvitationHooks(queryConfig),
Expand Down
44 changes: 44 additions & 0 deletions src/hooks/itemFavorite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { StatusCodes } from 'http-status-codes';

import { FAVORITE_ITEM, UNAUTHORIZED_RESPONSE } from '../../test/constants';
import { mockHook, setUpTest } from '../../test/utils';
import { GET_FAVORITE_ITEMS_ROUTE } from '../api/routes';
import { FAVORITE_ITEMS_KEY } from '../config/keys';

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

describe('useFavoriteItems', () => {
const route = `/${GET_FAVORITE_ITEMS_ROUTE}`;
const key = FAVORITE_ITEMS_KEY;

const hook = () => hooks.useFavoriteItems();

it(`Retrieve favorite items`, async () => {
const response = FAVORITE_ITEM;
const endpoints = [{ route, response: response.toJS() }];
await mockHook({ endpoints, hook, wrapper });

// verify cache keys
expect(queryClient.getQueryData(key)).toEqualImmutable(response);
});

it(`Unauthorized`, async () => {
const endpoints = [
{
route,
response: UNAUTHORIZED_RESPONSE,
statusCode: StatusCodes.UNAUTHORIZED,
},
];
const { data, isError } = await mockHook({
hook,
wrapper,
endpoints,
});

expect(data).toBeFalsy();
expect(isError).toBeTruthy();
// verify cache keys
expect(queryClient.getQueryData(key)).toBeFalsy();
});
});
25 changes: 25 additions & 0 deletions src/hooks/itemFavorite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { List } from 'immutable';
import { useQuery } from 'react-query';

import { convertJs } from '@graasp/sdk';
import { ItemFavoriteRecord } from '@graasp/sdk/frontend';

import * as Api from '../api';
import { FAVORITE_ITEMS_KEY } from '../config/keys';
import { QueryClientConfig } from '../types';

export default (queryConfig: QueryClientConfig) => {
const { defaultQueryOptions } = queryConfig;

const useFavoriteItems = () =>
useQuery<List<ItemFavoriteRecord>, Error>({
queryKey: FAVORITE_ITEMS_KEY,
queryFn: () =>
Api.getFavoriteItems(queryConfig).then((data) => convertJs(data)),
...defaultQueryOptions,
});

return {
useFavoriteItems,
};
};
2 changes: 2 additions & 0 deletions src/mutations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import invitationMutations from './invitation';
import itemMutations from './item';
import itemCategoryMutations from './itemCategory';
import itemExportMutations from './itemExport';
import itemFavoriteMutations from './itemFavorite';
import flagsMutations from './itemFlag';
import itemLikeMutations from './itemLike';
import itemLoginMutations from './itemLogin';
Expand All @@ -32,6 +33,7 @@ const configureMutations = (
...chatMutations(queryClient, queryConfig),
...mentionMutations(queryClient, queryConfig),
...itemCategoryMutations(queryClient, queryConfig),
...itemFavoriteMutations(queryConfig),
...itemExportMutations(queryClient, queryConfig),
...itemLikeMutations(queryClient, queryConfig),
...itemValidationMutations(queryClient, queryConfig),
Expand Down
149 changes: 149 additions & 0 deletions src/mutations/itemFavorite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { act } from '@testing-library/react-hooks';
import { StatusCodes } from 'http-status-codes';
import nock from 'nock';

import { HttpMethod } from '@graasp/sdk';

import { UNAUTHORIZED_RESPONSE } from '../../test/constants';
import { mockMutation, setUpTest, waitForMutation } from '../../test/utils';
import { buildFavoriteItemRoute } from '../api/routes';
import { FAVORITE_ITEMS_KEY } from '../config/keys';
import { addFavoriteItemRoutine, deleteFavoriteItemRoutine } from '../routines';

const mockedNotifier = jest.fn();
const { wrapper, queryClient, mutations } = setUpTest({
notifier: mockedNotifier,
});
describe('Favorite Mutations', () => {
afterEach(() => {
queryClient.clear();
nock.cleanAll();
});

describe('useAddFavoriteItem', () => {
const itemId = 'item-id';
const route = `/${buildFavoriteItemRoute(itemId)}`;
const mutation = mutations.useAddFavoriteItem;

it(`Successfully add favorite item`, async () => {
// set random data in cache
queryClient.setQueryData(FAVORITE_ITEMS_KEY, 'mock');
const endpoints = [
{
response: itemId,
method: HttpMethod.POST,
route,
},
];
const mockedMutation = await mockMutation({
mutation,
wrapper,
endpoints,
});

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

expect(
queryClient.getQueryState(FAVORITE_ITEMS_KEY)?.isInvalidated,
).toBeTruthy();
expect(mockedNotifier).toHaveBeenCalledWith({
type: addFavoriteItemRoutine.SUCCESS,
});
});

it(`Unauthorized`, async () => {
const endpoints = [
{
response: UNAUTHORIZED_RESPONSE,
statusCode: StatusCodes.UNAUTHORIZED,
method: HttpMethod.POST,
route,
},
];

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

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

expect(mockedNotifier).toHaveBeenCalledWith(
expect.objectContaining({
type: addFavoriteItemRoutine.FAILURE,
}),
);
});
});

describe('useRemoveFavoriteItem', () => {
const itemId = 'item-id';
const route = `/${buildFavoriteItemRoute(itemId)}`;
const mutation = mutations.useRemoveFavoriteItem;

it('Delete item like', async () => {
queryClient.setQueryData(FAVORITE_ITEMS_KEY, itemId);

const endpoints = [
{
response: itemId,
method: HttpMethod.DELETE,
route,
},
];

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

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

expect(
queryClient.getQueryState(FAVORITE_ITEMS_KEY)?.isInvalidated,
).toBeTruthy();
expect(mockedNotifier).toHaveBeenCalledWith({
type: deleteFavoriteItemRoutine.SUCCESS,
});
});

it('Unauthorized to delete item like', async () => {
const endpoints = [
{
response: UNAUTHORIZED_RESPONSE,
statusCode: StatusCodes.UNAUTHORIZED,
method: HttpMethod.DELETE,
route,
},
];

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

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

expect(mockedNotifier).toHaveBeenCalledWith(
expect.objectContaining({
type: deleteFavoriteItemRoutine.FAILURE,
}),
);
});
});
});
Loading