From 527183d6f63f902f47b7ce30bb2ed08e04e75700 Mon Sep 17 00:00:00 2001 From: pyphilia Date: Fri, 29 Oct 2021 14:42:31 +0200 Subject: [PATCH] feat: add thumbnails mutations and hooks --- src/api/item.ts | 62 ++++++++++++++++- src/api/member.ts | 64 ++++++++++++++++- src/api/routes.ts | 55 ++++++++++++++- src/config/constants.ts | 8 +++ src/config/keys.ts | 17 +++++ src/hooks/item.test.ts | 130 +++++++++++++++++++++++++++++++++++ src/hooks/item.ts | 31 ++++++++- src/hooks/member.test.ts | 123 ++++++++++++++++++++++++++++++++- src/hooks/member.ts | 29 +++++++- src/mutations/item.test.ts | 97 +++++++++++++++++++++++++- src/mutations/item.ts | 34 ++++++++- src/mutations/member.test.ts | 111 +++++++++++++++++++++++++++++- src/mutations/member.ts | 37 +++++++++- src/mutations/thumbnails.ts | 32 +++++++++ src/queryClient.ts | 13 +++- src/routines/item.ts | 3 + src/routines/member.ts | 1 + src/types.ts | 9 +++ src/utils/thumbnails.ts | 15 ++++ test/constants.ts | 2 + test/utils.tsx | 4 +- 21 files changed, 856 insertions(+), 21 deletions(-) create mode 100644 src/mutations/thumbnails.ts create mode 100644 src/utils/thumbnails.ts diff --git a/src/api/item.ts b/src/api/item.ts index d2741abc..2a1729aa 100644 --- a/src/api/item.ts +++ b/src/api/item.ts @@ -6,6 +6,8 @@ import { buildDeleteItemRoute, buildDeleteItemsRoute, buildDownloadFilesRoute, + buildDownloadItemThumbnailRoute, + buildDownloadPublicItemThumbnailRoute, buildEditItemRoute, buildGetChildrenRoute, buildGetItemRoute, @@ -24,6 +26,7 @@ import { buildRestoreItemsRoute, buildS3FileUrl, buildS3UploadFileRoute, + buildUploadItemThumbnailRoute, GET_OWN_ITEMS_ROUTE, GET_RECYCLED_ITEMS_ROUTE, SHARE_ITEM_WITH_ROUTE, @@ -37,7 +40,10 @@ import { } from './utils'; import { getParentsIdsFromPath } from '../utils/item'; import { ExtendedItem, Item, QueryClientConfig, UUID } from '../types'; -import { FALLBACK_TO_PUBLIC_FOR_STATUS_CODES } from '../config/constants'; +import { + DEFAULT_THUMBNAIL_SIZES, + FALLBACK_TO_PUBLIC_FOR_STATUS_CODES, +} from '../config/constants'; export const getItem = ( id: UUID, @@ -316,6 +322,33 @@ export const uploadItemToS3 = async ( return response.json(); }; +export const uploadItemThumbnailToS3 = async ( + { + itemId, + filename, + contentType, + }: { itemId: UUID; filename: string; contentType: string }, + { API_HOST }: QueryClientConfig, +) => { + const response = await fetch( + `${API_HOST}/${buildUploadItemThumbnailRoute(itemId)}`, + { + // Send and receive JSON. + ...DEFAULT_POST, + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + filename, + contentType, + }), + }, + ).then(failOnError); + + return response.json(); +}; + export const getS3FileUrl = async ( { id }: { id: UUID }, { API_HOST, S3_FILES_HOST }: QueryClientConfig, @@ -395,3 +428,30 @@ export const restoreItems = async ( }).then(failOnError); return res.ok; }; + +export const downloadItemThumbnail = async ( + { id, size = DEFAULT_THUMBNAIL_SIZES }: { id: UUID; size?: string }, + { API_HOST }: QueryClientConfig, +) => { + let res = await fetch( + `${API_HOST}/${buildDownloadItemThumbnailRoute({ id, size })}`, + { + ...DEFAULT_GET, + headers: {}, + }, + ) + + // try to fetch public items if cannot access privately + if (FALLBACK_TO_PUBLIC_FOR_STATUS_CODES.includes(res.status)) { + res = await fetch( + `${API_HOST}/${buildDownloadPublicItemThumbnailRoute({ id, size })}`, + DEFAULT_GET, + ).then(failOnError); + } + + if (!res.ok) { + throw new Error(res.statusText); + } + + return res; +}; diff --git a/src/api/member.ts b/src/api/member.ts index 3c278194..edec12f4 100644 --- a/src/api/member.ts +++ b/src/api/member.ts @@ -1,6 +1,6 @@ import { StatusCodes } from 'http-status-codes'; import axios from 'axios'; -import { failOnError, DEFAULT_GET, DEFAULT_PATCH } from './utils'; +import { failOnError, DEFAULT_GET, DEFAULT_PATCH, DEFAULT_POST } from './utils'; import { buildGetMemberBy, buildGetMember, @@ -9,9 +9,13 @@ import { buildGetMembersRoute, buildGetPublicMembersRoute, buildGetPublicMember, + buildUploadAvatarRoute, + buildDownloadAvatarRoute, + buildDownloadPublicAvatarRoute, } from './routes'; import { Member, QueryClientConfig, UUID } from '../types'; import { + DEFAULT_THUMBNAIL_SIZES, FALLBACK_TO_PUBLIC_FOR_STATUS_CODES, SIGNED_OUT_USER, } from '../config/constants'; @@ -107,3 +111,61 @@ export const editMember = async ( return res.json(); }; + +export const uploadAvatar = async ( + { + itemId, + filename, + contentType, + }: { itemId: UUID; filename: string; contentType: string }, + { API_HOST }: QueryClientConfig, +) => { + const response = await fetch( + `${API_HOST}/${buildUploadAvatarRoute(itemId)}`, + { + // Send and receive JSON. + ...DEFAULT_POST, + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + filename, + contentType, + }), + }, + ).then(failOnError); + + return response.json(); +}; + +export const downloadAvatar = async ( + { id, size = DEFAULT_THUMBNAIL_SIZES }: { id: UUID; size?: string }, + { API_HOST }: QueryClientConfig, +) => { + let res = await fetch( + `${API_HOST}/${buildDownloadAvatarRoute({ id, size })}`, + { + ...DEFAULT_GET, + headers: {}, + }, + ) + + if (FALLBACK_TO_PUBLIC_FOR_STATUS_CODES.includes(res.status)) { + res = await fetch( + `${API_HOST}/${buildDownloadPublicAvatarRoute({ id, size })}`, + { + ...DEFAULT_GET, + headers: {}, + }, + ).then(failOnError); + } + + if (!res.ok) { + // TODO: wrong way to pass error + // should use axios + throw new Error(res.statusText) + } + + return res; +}; diff --git a/src/api/routes.ts b/src/api/routes.ts index 7784c0f0..f9589f5e 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -1,4 +1,5 @@ import qs from 'qs'; +import { DEFAULT_THUMBNAIL_SIZES } from '../config/constants'; import { UUID } from '../types'; export const APPS_ROUTE = 'app-items'; @@ -77,6 +78,48 @@ export const buildUploadFilesRoute = (parentId: UUID) => : `${ITEMS_ROUTE}/upload`; export const buildDownloadFilesRoute = (id: UUID) => `${ITEMS_ROUTE}/${id}/download`; +export const buildUploadAvatarRoute = (id: UUID) => + `${MEMBERS_ROUTE}/avatars/${id}`; +export const buildDownloadAvatarRoute = ({ + id, + size = DEFAULT_THUMBNAIL_SIZES, +}: { + id: UUID; + size?: string; +}) => + `${MEMBERS_ROUTE}/avatars/${id}${qs.stringify( + { size }, + { addQueryPrefix: true }, + )}`; +export const buildDownloadPublicAvatarRoute = ({ + id, + size = DEFAULT_THUMBNAIL_SIZES, +}: { + id: UUID; + size?: string; +}) => + `p/${buildDownloadAvatarRoute({ id, size })}`; +export const buildUploadItemThumbnailRoute = (id: UUID) => + `${ITEMS_ROUTE}/thumbnails/${id}`; +export const buildDownloadItemThumbnailRoute = ({ + id, + size = DEFAULT_THUMBNAIL_SIZES, +}: { + id: UUID; + size?: string; +}) => + `${ITEMS_ROUTE}/thumbnails/${id}${qs.stringify( + { size }, + { addQueryPrefix: true }, + )}`; +export const buildDownloadPublicItemThumbnailRoute = ({ + id, + size = DEFAULT_THUMBNAIL_SIZES, +}: { + id: UUID; + size?: string; +}) => + `p/${buildDownloadItemThumbnailRoute({ id, size })}`; export const buildPublicDownloadFilesRoute = (id: UUID) => `p/${buildDownloadFilesRoute(id)}`; export const buildS3UploadFileRoute = (parentId: UUID) => @@ -141,21 +184,21 @@ export const buildRestoreItemsRoute = (ids: UUID[]) => )}`; export const GET_CATEGORY_TYPES_ROUTE = `${ITEMS_ROUTE}/category-types` -export const buildGetCategoriesRoute = (ids?: UUID[]) => +export const buildGetCategoriesRoute = (ids?: UUID[]) => `${CATEGORIES_ROUTE}?${qs.stringify( { type: ids }, { arrayFormat: 'repeat' } )}`; export const buildGetCategoryInfoRoute = (id: UUID) => `${CATEGORIES_ROUTE}/${id}`; export const buildGetItemCategoriesRoute = (id: UUID) => `${ITEMS_ROUTE}/${id}/categories`; -export const buildGetItemsInCategoryRoute = (ids: UUID[]) => +export const buildGetItemsInCategoryRoute = (ids: UUID[]) => `${ITEMS_ROUTE}/withCategories?${qs.stringify( { category: ids }, { arrayFormat: 'repeat' } )}`; export const buildPostItemCategoryRoute = (id: UUID) => `${ITEMS_ROUTE}/${id}/categories`; - export const buildDeleteItemCategoryRoute = (id: UUID) => +export const buildDeleteItemCategoryRoute = (id: UUID) => `${ITEMS_ROUTE}/item-category/${id}`; export const API_ROUTES = { @@ -217,4 +260,10 @@ export const API_ROUTES = { buildGetItemCategoriesRoute, buildPostItemCategoryRoute, buildDeleteItemCategoryRoute, + buildUploadItemThumbnailRoute, + buildDownloadItemThumbnailRoute, + buildDownloadPublicItemThumbnailRoute, + buildUploadAvatarRoute, + buildDownloadAvatarRoute, + buildDownloadPublicAvatarRoute, }; diff --git a/src/config/constants.ts b/src/config/constants.ts index 05d60e92..d33f04a0 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -13,3 +13,11 @@ export const FALLBACK_TO_PUBLIC_FOR_STATUS_CODES = [ StatusCodes.UNAUTHORIZED, StatusCodes.FORBIDDEN, ]; + +export const THUMBNAIL_SIZES = { + SMALL: 'small', + MEDIUM: 'medium', + LARGE: 'large', + ORIGINAL: 'original', +}; +export const DEFAULT_THUMBNAIL_SIZES = THUMBNAIL_SIZES.SMALL; diff --git a/src/config/keys.ts b/src/config/keys.ts index be2025d9..f0d2612a 100644 --- a/src/config/keys.ts +++ b/src/config/keys.ts @@ -1,5 +1,6 @@ import type { UUID } from '../types'; import { hashItemsIds } from '../utils/item'; +import { DEFAULT_THUMBNAIL_SIZES } from './constants'; export const APPS_KEY = 'apps'; export const ITEMS_KEY = 'items'; @@ -59,6 +60,20 @@ export const buildPublicItemsWithTagKey = (id?: UUID) => [ id, ]; export const RECYCLED_ITEMS_KEY = 'recycledItems'; +export const buildItemThumbnailKey = ({ + id, + size = DEFAULT_THUMBNAIL_SIZES, +}: { + id?: UUID; + size?: string; +}) => [ITEMS_KEY, id, 'thumbnails', size]; +export const buildAvatarKey = ({ + id, + size = DEFAULT_THUMBNAIL_SIZES, +}: { + id?: UUID; + size?: string; +}) => [MEMBERS_KEY, id, 'avatars', size]; export const MUTATION_KEYS = { POST_ITEM: 'postItem', @@ -88,4 +103,6 @@ export const MUTATION_KEYS = { RESTORE_ITEMS: 'restoreItems', POST_ITEM_CATEGORY: 'postItemCategory', DELETE_ITEM_CATEGORY: 'deleteItemCategory', + UPLOAD_ITEM_THUMBNAIL: 'uploadItemThumbnail', + UPLOAD_AVATAR: 'uploadAvatar', }; diff --git a/src/hooks/item.test.ts b/src/hooks/item.test.ts index 30d2915c..bb9bdac6 100644 --- a/src/hooks/item.test.ts +++ b/src/hooks/item.test.ts @@ -3,6 +3,7 @@ import { StatusCodes } from 'http-status-codes'; import { Record, Map, List } from 'immutable'; import { buildDownloadFilesRoute, + buildDownloadItemThumbnailRoute, buildGetChildrenRoute, buildGetItemLoginRoute, buildGetItemMembershipsForItemsRoute, @@ -25,6 +26,7 @@ import { S3_FILE_BLOB_RESPONSE, S3_FILE_RESPONSE, TAGS, + THUMBNAIL_BLOB_RESPONSE, UNAUTHORIZED_RESPONSE, } from '../../test/constants'; import { @@ -38,11 +40,13 @@ import { buildManyItemMembershipsKey, buildPublicItemsWithTagKey, buildS3FileContentKey, + buildItemThumbnailKey, OWN_ITEMS_KEY, RECYCLED_ITEMS_KEY, SHARED_ITEMS_KEY, } from '../config/keys'; import type { Item, ItemLogin, Membership } from '../types'; +import { THUMBNAIL_SIZES } from '../config/constants'; const { hooks, wrapper, queryClient } = setUpTest(); describe('Items Hooks', () => { @@ -996,4 +1000,130 @@ describe('Items Hooks', () => { expect(queryClient.getQueryData(key)).toBeFalsy(); }); }); + + describe('useItemThumbnail', () => { + const item = ITEMS[0]; + const key = buildItemThumbnailKey({ id: item.id }); + + describe('Default', () => { + const response = THUMBNAIL_BLOB_RESPONSE; + const route = `/${buildDownloadItemThumbnailRoute({ id: item.id })}`; + const hook = () => hooks.useItemThumbnail({ id: item.id }); + + it(`Receive default thumbnail`, async () => { + const endpoints = [{ + route, response, + headers: { + "Content-Type": "image/jpeg" + } + }, + ]; + const { data } = await mockHook({ endpoints, hook, wrapper }); + + expect((data as Blob).text()).toBeTruthy(); + // verify cache keys + expect(queryClient.getQueryData(key)).toBeTruthy(); + }); + + it(`Receive large thumbnail`, async () => { + const size = THUMBNAIL_SIZES.LARGE; + const routeLarge = `/${buildDownloadItemThumbnailRoute({ + id: item.id, + size, + })}`; + const hookLarge = () => hooks.useItemThumbnail({ id: item.id, size }); + const keyLarge = buildItemThumbnailKey({ id: item.id, size }); + + const endpoints = [{ + route: routeLarge, response, + headers: { + "Content-Type": "image/jpeg" + } + }]; + const { data } = await mockHook({ endpoints, hook: hookLarge, wrapper }); + + expect((data as Blob).text()).toBeTruthy(); + // verify cache keys + expect(queryClient.getQueryData(keyLarge)).toBeTruthy(); + }); + }) + + describe('S3', () => { + const response = S3_FILE_RESPONSE; + + const route = `/${buildDownloadItemThumbnailRoute({ id: item.id })}`; + const hook = () => hooks.useItemThumbnail({ id: item.id }); + + it(`Receive default thumbnail`, async () => { + const endpoints = [{ route, response }, + { + route: `/${response.key}`, + response: S3_FILE_BLOB_RESPONSE, + }]; + const { data } = await mockHook({ endpoints, hook, wrapper }); + + expect((data as Blob).text()).toBeTruthy(); + // verify cache keys + expect(queryClient.getQueryData(key)).toBeTruthy(); + }); + + it(`Receive large thumbnail`, async () => { + const size = THUMBNAIL_SIZES.LARGE; + const routeLarge = `/${buildDownloadItemThumbnailRoute({ + id: item.id, + size, + })}`; + const hookLarge = () => hooks.useItemThumbnail({ id: item.id, size }); + const keyLarge = buildItemThumbnailKey({ id: item.id, size }); + + const endpoints = [{ route: routeLarge, response }, + { + route: `/${response.key}`, + response: S3_FILE_BLOB_RESPONSE, + }]; + const { data } = await mockHook({ endpoints, hook: hookLarge, wrapper }); + + expect((data as Blob).text()).toBeTruthy(); + // verify cache keys + expect(queryClient.getQueryData(keyLarge)).toBeTruthy(); + }); + + it(`Undefined id does not fetch`, async () => { + const endpoints = [ + { + route, + response, + }, + ]; + const { data, isFetched } = await mockHook({ + endpoints, + hook: () => hooks.useItemThumbnail({ id: undefined }), + wrapper, + enabled: false, + }); + + expect(data).toBeFalsy(); + expect(isFetched).toBeFalsy(); + // verify cache keys + expect(queryClient.getQueryData(key)).toBeFalsy(); + }); + + it(`Unauthorized`, async () => { + const endpoints = [ + { + route, + response: UNAUTHORIZED_RESPONSE, + statusCode: StatusCodes.UNAUTHORIZED, + }, + ]; + const { data, isError } = await mockHook({ endpoints, hook, wrapper }); + + expect(data).toBeFalsy(); + expect(isError).toBeTruthy(); + // verify cache keys + expect(queryClient.getQueryData(key)).toBeFalsy(); + }); + }); + }) + }); diff --git a/src/hooks/item.ts b/src/hooks/item.ts index ae88a73f..6f623dc5 100644 --- a/src/hooks/item.ts +++ b/src/hooks/item.ts @@ -1,6 +1,7 @@ import { List, Map } from 'immutable'; import { QueryClient, useQuery, UseQueryResult } from 'react-query'; import * as Api from '../api'; +import { DEFAULT_THUMBNAIL_SIZES } from '../config/constants'; import { buildFileContentKey, buildItemChildrenKey, @@ -13,11 +14,13 @@ import { buildManyItemMembershipsKey, buildPublicItemsWithTagKey, buildS3FileContentKey, + buildItemThumbnailKey, OWN_ITEMS_KEY, RECYCLED_ITEMS_KEY, SHARED_ITEMS_KEY, } from '../config/keys'; import { Item, QueryClientConfig, UndefinedArgument, UUID } from '../types'; +import { getRequestBlob } from '../utils/thumbnails'; import { configureWsItemHooks, configureWsMembershipHooks } from '../ws'; import { WebsocketClient } from '../ws/ws-client'; @@ -27,7 +30,13 @@ export default ( useCurrentMember: Function, websocketClient?: WebsocketClient, ) => { - const { retry, cacheTime, staleTime, enableWebsocket } = queryConfig; + const { + retry, + cacheTime, + staleTime, + enableWebsocket, + S3_FILES_HOST, + } = queryConfig; const defaultOptions = { retry, cacheTime, @@ -393,5 +402,25 @@ export default ( enabled: Boolean(tagId), }); }, + + useItemThumbnail: ({ + id, + size = DEFAULT_THUMBNAIL_SIZES, + }: { + id?: UUID; + size?: string; + }) => + useQuery({ + queryKey: buildItemThumbnailKey({ id, size }), + queryFn: async () => { + if (!id) { + throw new UndefinedArgument(); + } + const data = await Api.downloadItemThumbnail({ id, size }, queryConfig) + return getRequestBlob(data, S3_FILES_HOST) + }, + ...defaultOptions, + enabled: Boolean(id), + }), }; }; diff --git a/src/hooks/member.test.ts b/src/hooks/member.test.ts index 6241e6e3..f8a0b63b 100644 --- a/src/hooks/member.test.ts +++ b/src/hooks/member.test.ts @@ -2,6 +2,7 @@ import { StatusCodes } from 'http-status-codes'; import { Record, List } from 'immutable'; import nock from 'nock'; import { + buildDownloadAvatarRoute, buildGetMember, buildGetMembersRoute, buildGetPublicMember, @@ -10,17 +11,21 @@ import { } from '../api/routes'; import { mockHook, setUpTest } from '../../test/utils'; import { + AVATAR_BLOB_RESPONSE, MEMBERS_RESPONSE, MEMBER_RESPONSE, + S3_FILE_BLOB_RESPONSE, + S3_FILE_RESPONSE, UNAUTHORIZED_RESPONSE, } from '../../test/constants'; import { + buildAvatarKey, buildMemberKey, buildMembersKey, CURRENT_MEMBER_KEY, } from '../config/keys'; import type { Member, UUID } from '../types'; -import { SIGNED_OUT_USER } from '../config/constants'; +import { SIGNED_OUT_USER, THUMBNAIL_SIZES } from '../config/constants'; const { hooks, wrapper, queryClient } = setUpTest(); describe('Member Hooks', () => { @@ -269,4 +274,120 @@ describe('Member Hooks', () => { } }); }); + + describe('useAvatar', () => { + const member = MEMBER_RESPONSE; + + describe(`Default`, () => { + const response = AVATAR_BLOB_RESPONSE; + const route = `/${buildDownloadAvatarRoute({ id: member.id })}`; + const hook = () => hooks.useAvatar({ id: member.id }); + const key = buildAvatarKey({ id: member.id }); + + it(`Receive default avatar`, async () => { + const endpoints = [{ route, response, headers: { "Content-Type": "image/jpeg" } }]; + const { data } = await mockHook({ endpoints, hook, wrapper }); + + expect((data as Blob).text()).toBeTruthy(); + // verify cache keys + expect(queryClient.getQueryData(key)).toBeTruthy(); + }); + + it(`Receive large avatar`, async () => { + const size = THUMBNAIL_SIZES.LARGE; + const routeLarge = `/${buildDownloadAvatarRoute({ + id: member.id, + size, + })}`; + const hookLarge = () => hooks.useAvatar({ id: member.id, size }); + const keyLarge = buildAvatarKey({ id: member.id, size }); + + const endpoints = [{ route: routeLarge, response, headers: { "Content-Type": "image/jpeg" } }]; + const { data } = await mockHook({ endpoints, hook: hookLarge, wrapper }); + + expect((data as Blob).text()).toBeTruthy(); + // verify cache keys + expect(queryClient.getQueryData(keyLarge)).toBeTruthy(); + }); + }); + + describe(`S3`, () => { + const response = S3_FILE_RESPONSE; + const route = `/${buildDownloadAvatarRoute({ id: member.id })}`; + const hook = () => hooks.useAvatar({ id: member.id }); + const key = buildAvatarKey({ id: member.id }); + + it(`Receive default avatar`, async () => { + const endpoints = [ + { route, response }, + { + route: `/${response.key}`, + response: S3_FILE_BLOB_RESPONSE, + },]; + const { data } = await mockHook({ endpoints, hook, wrapper }); + + expect((data as Blob).text()).toBeTruthy(); + // verify cache keys + expect(queryClient.getQueryData(key)).toBeTruthy(); + }); + + it(`Receive large avatar`, async () => { + const size = THUMBNAIL_SIZES.LARGE; + const routeLarge = `/${buildDownloadAvatarRoute({ + id: member.id, + size, + })}`; + const hookLarge = () => hooks.useAvatar({ id: member.id, size }); + const keyLarge = buildAvatarKey({ id: member.id, size }); + + const endpoints = [{ route: routeLarge, response }, + { + route: `/${response.key}`, + response: S3_FILE_BLOB_RESPONSE, + }]; + const { data } = await mockHook({ endpoints, hook: hookLarge, wrapper }); + + expect((data as Blob).text()).toBeTruthy(); + // verify cache keys + expect(queryClient.getQueryData(keyLarge)).toBeTruthy(); + }); + + it(`Undefined id does not fetch`, async () => { + const endpoints = [ + { + route, + response, + }, + ]; + const { data, isFetched } = await mockHook({ + endpoints, + hook: () => hooks.useAvatar({ id: undefined }), + wrapper, + enabled: false, + }); + + expect(data).toBeFalsy(); + expect(isFetched).toBeFalsy(); + // verify cache keys + expect(queryClient.getQueryData(key)).toBeFalsy(); + }); + + it(`Unauthorized`, async () => { + const endpoints = [ + { + route, + response: UNAUTHORIZED_RESPONSE, + statusCode: StatusCodes.UNAUTHORIZED, + }, + ]; + const { data, isError } = await mockHook({ endpoints, hook, wrapper }); + + expect(data).toBeFalsy(); + expect(isError).toBeTruthy(); + // verify cache keys + expect(queryClient.getQueryData(key)).toBeFalsy(); + }); + }); + + }); }); diff --git a/src/hooks/member.ts b/src/hooks/member.ts index 033d4f4a..5d85f50a 100644 --- a/src/hooks/member.ts +++ b/src/hooks/member.ts @@ -2,14 +2,17 @@ import { QueryClient, useQuery } from 'react-query'; import { Map, List } from 'immutable'; import * as Api from '../api'; import { + buildAvatarKey, buildMemberKey, buildMembersKey, CURRENT_MEMBER_KEY, } from '../config/keys'; import { Member, QueryClientConfig, UndefinedArgument, UUID } from '../types'; +import { DEFAULT_THUMBNAIL_SIZES } from '../config/constants'; +import { getRequestBlob } from '../utils/thumbnails'; export default (queryClient: QueryClient, queryConfig: QueryClientConfig) => { - const { retry, cacheTime, staleTime } = queryConfig; + const { retry, cacheTime, staleTime, S3_FILES_HOST } = queryConfig; const defaultOptions = { retry, cacheTime, @@ -53,5 +56,27 @@ export default (queryClient: QueryClient, queryConfig: QueryClientConfig) => { ...defaultOptions, }); - return { useCurrentMember, useMember, useMembers }; + const useAvatar = ({ + id, + size = DEFAULT_THUMBNAIL_SIZES, + }: { + id?: UUID; + size?: string; + }) => + useQuery({ + queryKey: buildAvatarKey({ id, size }), + queryFn: () => { + if (!id) { + throw new UndefinedArgument(); + } + return Api.downloadAvatar({ id, size }, queryConfig).then((data) => { + // default + return getRequestBlob(data, S3_FILES_HOST) + }) + }, + ...defaultOptions, + enabled: Boolean(id), + }); + + return { useCurrentMember, useMember, useMembers, useAvatar }; }; diff --git a/src/mutations/item.test.ts b/src/mutations/item.test.ts index 0a2ddbe4..b36c80bb 100644 --- a/src/mutations/item.test.ts +++ b/src/mutations/item.test.ts @@ -17,6 +17,7 @@ import { buildRecycleItemsRoute, buildRestoreItemsRoute, buildShareItemWithRoute, + buildUploadItemThumbnailRoute, } from '../api/routes'; import { setUpTest, mockMutation, waitForMutation } from '../../test/utils'; import { REQUEST_METHODS } from '../api/utils'; @@ -26,11 +27,13 @@ import { UNAUTHORIZED_RESPONSE, MEMBER_RESPONSE, ITEM_MEMBERSHIPS_RESPONSE, + THUMBNAIL_BLOB_RESPONSE, } from '../../test/constants'; import { buildItemChildrenKey, buildItemKey, buildItemMembershipsKey, + buildItemThumbnailKey, getKeyForParentId, MUTATION_KEYS, OWN_ITEMS_KEY, @@ -42,7 +45,8 @@ import { getDirectParentId, transformIdForPath, } from '../utils/item'; -import { uploadFileRoutine } from '../routines'; +import { uploadFileRoutine, uploadItemThumbnailRoutine } from '../routines'; +import { THUMBNAIL_SIZES } from '../config/constants'; const mockedNotifier = jest.fn(); const { wrapper, queryClient, useMutation } = setUpTest({ @@ -1667,4 +1671,95 @@ describe('Items Mutations', () => { ).toBeTruthy(); }); }); + + describe(MUTATION_KEYS.UPLOAD_ITEM_THUMBNAIL, () => { + const mutation = () => useMutation(MUTATION_KEYS.UPLOAD_ITEM_THUMBNAIL); + const item = ITEMS[0]; + const id = item.id; + + it('Upload thumbnail', async () => { + const route = `/${buildUploadItemThumbnailRoute(id)}`; + + // set data in cache + Object.values(THUMBNAIL_SIZES).forEach((size) => { + const key = buildItemThumbnailKey({ id, size }); + queryClient.setQueryData(key, Math.random()); + }); + + const response = THUMBNAIL_BLOB_RESPONSE; + + const endpoints = [ + { + response, + method: REQUEST_METHODS.POST, + route, + }, + ]; + + const mockedMutation = await mockMutation({ + endpoints, + mutation, + wrapper, + }); + + await act(async () => { + await mockedMutation.mutate({ id }); + await waitForMutation(); + }); + + // verify item is still available + // in real cases, the path should be different + for (const size of Object.values(THUMBNAIL_SIZES)) { + const key = buildItemThumbnailKey({ id, size }); + const state = queryClient.getQueryState(key); + expect(state?.isInvalidated).toBeTruthy(); + } + expect(mockedNotifier).toHaveBeenCalledWith({ + type: uploadItemThumbnailRoutine.SUCCESS, + }); + }); + + it('Unauthorized to upload a thumbnail', async () => { + const route = `/${buildUploadItemThumbnailRoute(id)}`; + // set data in cache + Object.values(THUMBNAIL_SIZES).forEach((size) => { + const key = buildItemThumbnailKey({ id, size }); + queryClient.setQueryData(key, Math.random()); + }); + + const response = UNAUTHORIZED_RESPONSE; + + const endpoints = [ + { + response, + statusCode: StatusCodes.UNAUTHORIZED, + method: REQUEST_METHODS.POST, + route, + }, + ]; + + const mockedMutation = await mockMutation({ + endpoints, + mutation, + wrapper, + }); + + await act(async () => { + await mockedMutation.mutate({ id, error: StatusCodes.UNAUTHORIZED }); + await waitForMutation(); + }); + + // verify item is still available + // in real cases, the path should be different + for (const size of Object.values(THUMBNAIL_SIZES)) { + const key = buildItemThumbnailKey({ id, size }); + const state = queryClient.getQueryState(key); + expect(state?.isInvalidated).toBeTruthy(); + } + expect(mockedNotifier).toHaveBeenCalledWith({ + type: uploadItemThumbnailRoutine.FAILURE, + payload: { error: StatusCodes.UNAUTHORIZED }, + }); + }); + }); }); diff --git a/src/mutations/item.ts b/src/mutations/item.ts index 21309f00..6ddc2f30 100644 --- a/src/mutations/item.ts +++ b/src/mutations/item.ts @@ -14,6 +14,7 @@ import { uploadFileRoutine, recycleItemsRoutine, restoreItemsRoutine, + uploadItemThumbnailRoutine, } from '../routines'; import { buildItemChildrenKey, @@ -24,9 +25,11 @@ import { buildItemMembershipsKey, RECYCLED_ITEMS_KEY, buildManyItemMembershipsKey, + buildItemThumbnailKey, } from '../config/keys'; import { buildPath, getDirectParentId } from '../utils/item'; import type { Item, QueryClientConfig, UUID } from '../types'; +import { THUMBNAIL_SIZES } from '../config/constants'; const { POST_ITEM, @@ -42,6 +45,7 @@ const { RECYCLE_ITEM, RECYCLE_ITEMS, RESTORE_ITEMS, + UPLOAD_ITEM_THUMBNAIL, COPY_PUBLIC_ITEM, } = MUTATION_KEYS; @@ -613,14 +617,14 @@ export default (queryClient: QueryClient, queryConfig: QueryClientConfig) => { }, }); - // this mutation is used for its callback + // this mutation is used for its callback and invalidate the keys /** * @param {UUID} id parent item id wher the file is uploaded in * @param {error} [error] error occured during the file uploading */ queryClient.setMutationDefaults(FILE_UPLOAD, { mutationFn: async ({ error }) => { - if (error) throw new Error(error); + if (error) throw new Error(JSON.stringify(error)); }, onSuccess: () => { notifier?.({ type: uploadFileRoutine.SUCCESS }); @@ -634,6 +638,32 @@ export default (queryClient: QueryClient, queryConfig: QueryClientConfig) => { }, }); + // this mutation is used for its callback and invalidate the keys + /** + * @param {UUID} id parent item id wher the file is uploaded in + * @param {error} [error] error occured during the file uploading + */ + queryClient.setMutationDefaults(UPLOAD_ITEM_THUMBNAIL, { + mutationFn: async ({ error } = {}) => { + if (error) throw new Error(JSON.stringify(error)); + }, + onSuccess: () => { + notifier?.({ type: uploadItemThumbnailRoutine.SUCCESS }); + }, + onError: (_error, { error }) => { + notifier?.({ + type: uploadItemThumbnailRoutine.FAILURE, + payload: { error }, + }); + }, + onSettled: (_data, _error, { id }) => { + Object.values(THUMBNAIL_SIZES).forEach((size) => { + const key = buildItemThumbnailKey({ id, size }); + queryClient.invalidateQueries(key); + }); + }, + }); + queryClient.setMutationDefaults(RESTORE_ITEMS, { mutationFn: (itemIds) => Api.restoreItems(itemIds, queryConfig).then(() => true), diff --git a/src/mutations/member.test.ts b/src/mutations/member.test.ts index abd4ea0b..643200d5 100644 --- a/src/mutations/member.test.ts +++ b/src/mutations/member.test.ts @@ -2,18 +2,32 @@ import { StatusCodes } from 'http-status-codes'; import { act } from '@testing-library/react-hooks'; import { Map, Record } from 'immutable'; import nock from 'nock'; -import { buildPatchMember, SIGN_OUT_ROUTE } from '../api/routes'; +import { + buildPatchMember, + buildUploadAvatarRoute, + SIGN_OUT_ROUTE, +} from '../api/routes'; import { setUpTest, mockMutation, waitForMutation } from '../../test/utils'; import { + AVATAR_BLOB_RESPONSE, MEMBER_RESPONSE, OK_RESPONSE, UNAUTHORIZED_RESPONSE, } from '../../test/constants'; -import { CURRENT_MEMBER_KEY, MUTATION_KEYS } from '../config/keys'; +import { + buildAvatarKey, + CURRENT_MEMBER_KEY, + MUTATION_KEYS, +} from '../config/keys'; import { Member } from '../types'; import { REQUEST_METHODS } from '../api/utils'; +import { THUMBNAIL_SIZES } from '../config/constants'; +import { uploadAvatarRoutine } from '../routines'; -const { wrapper, queryClient, useMutation } = setUpTest(); +const mockedNotifier = jest.fn(); +const { wrapper, queryClient, useMutation } = setUpTest({ + notifier: mockedNotifier, +}); describe('Member Mutations', () => { afterEach(() => { queryClient.clear(); @@ -137,4 +151,95 @@ describe('Member Mutations', () => { expect(oldData.toJS()).toEqual(MEMBER_RESPONSE); }); }); + + describe(MUTATION_KEYS.UPLOAD_AVATAR, () => { + const mutation = () => useMutation(MUTATION_KEYS.UPLOAD_AVATAR); + const member = MEMBER_RESPONSE; + const id = member.id; + + it('Upload avatar', async () => { + const route = `/${buildUploadAvatarRoute(id)}`; + + // set data in cache + Object.values(THUMBNAIL_SIZES).forEach((size) => { + const key = buildAvatarKey({ id, size }); + queryClient.setQueryData(key, Math.random()); + }); + + const response = AVATAR_BLOB_RESPONSE; + + const endpoints = [ + { + response, + method: REQUEST_METHODS.POST, + route, + }, + ]; + + const mockedMutation = await mockMutation({ + endpoints, + mutation, + wrapper, + }); + + await act(async () => { + await mockedMutation.mutate({ id }); + await waitForMutation(); + }); + + // verify member is still available + // in real cases, the path should be different + for (const size of Object.values(THUMBNAIL_SIZES)) { + const key = buildAvatarKey({ id, size }); + const state = queryClient.getQueryState(key); + expect(state?.isInvalidated).toBeTruthy(); + } + expect(mockedNotifier).toHaveBeenCalledWith({ + type: uploadAvatarRoutine.SUCCESS, + }); + }); + + it('Unauthorized to upload an avatar', async () => { + const route = `/${buildUploadAvatarRoute(id)}`; + // set data in cache + Object.values(THUMBNAIL_SIZES).forEach((size) => { + const key = buildAvatarKey({ id, size }); + queryClient.setQueryData(key, Math.random()); + }); + + const response = UNAUTHORIZED_RESPONSE; + + const endpoints = [ + { + response, + statusCode: StatusCodes.UNAUTHORIZED, + method: REQUEST_METHODS.POST, + route, + }, + ]; + + const mockedMutation = await mockMutation({ + endpoints, + mutation, + wrapper, + }); + + await act(async () => { + await mockedMutation.mutate({ id, error: StatusCodes.UNAUTHORIZED }); + await waitForMutation(); + }); + + // verify member is still available + // in real cases, the path should be different + for (const size of Object.values(THUMBNAIL_SIZES)) { + const key = buildAvatarKey({ id, size }); + const state = queryClient.getQueryState(key); + expect(state?.isInvalidated).toBeTruthy(); + } + expect(mockedNotifier).toHaveBeenCalledWith({ + type: uploadAvatarRoutine.FAILURE, + payload: { error: StatusCodes.UNAUTHORIZED }, + }); + }); + }); }); diff --git a/src/mutations/member.ts b/src/mutations/member.ts index eeb74695..9b5d73cc 100644 --- a/src/mutations/member.ts +++ b/src/mutations/member.ts @@ -2,10 +2,18 @@ import { QueryClient } from 'react-query'; import { Map, Record } from 'immutable'; import Cookies from 'js-cookie'; import * as Api from '../api'; -import { editMemberRoutine, signOutRoutine } from '../routines'; -import { CURRENT_MEMBER_KEY, MUTATION_KEYS } from '../config/keys'; +import { + editMemberRoutine, + signOutRoutine, + uploadAvatarRoutine, +} from '../routines'; +import { + buildAvatarKey, + CURRENT_MEMBER_KEY, + MUTATION_KEYS, +} from '../config/keys'; import { Member, QueryClientConfig } from '../types'; -import { COOKIE_SESSION_NAME } from '../config/constants'; +import { COOKIE_SESSION_NAME, THUMBNAIL_SIZES } from '../config/constants'; export default (queryClient: QueryClient, queryConfig: QueryClientConfig) => { const { notifier } = queryConfig; @@ -65,4 +73,27 @@ export default (queryClient: QueryClient, queryConfig: QueryClientConfig) => { queryClient.invalidateQueries(CURRENT_MEMBER_KEY); }, }); + + // this mutation is used for its callback and invalidate the keys + /** + * @param {UUID} id parent item id wher the file is uploaded in + * @param {error} [error] error occured during the file uploading + */ + queryClient.setMutationDefaults(MUTATION_KEYS.UPLOAD_AVATAR, { + mutationFn: async ({ error } = {}) => { + if (error) throw new Error(JSON.stringify(error)); + }, + onSuccess: () => { + notifier?.({ type: uploadAvatarRoutine.SUCCESS }); + }, + onError: (_error, { error }) => { + notifier?.({ type: uploadAvatarRoutine.FAILURE, payload: { error } }); + }, + onSettled: (_data, _error, { id }) => { + Object.values(THUMBNAIL_SIZES).forEach((size) => { + const key = buildAvatarKey({ id, size }); + queryClient.invalidateQueries(key); + }); + }, + }); }; diff --git a/src/mutations/thumbnails.ts b/src/mutations/thumbnails.ts new file mode 100644 index 00000000..4e09f180 --- /dev/null +++ b/src/mutations/thumbnails.ts @@ -0,0 +1,32 @@ +import { QueryClient } from 'react-query'; +import * as Api from '../api'; +import { editItemMembershipRoutine } from '../routines'; +import { buildItemMembershipsKey, MUTATION_KEYS } from '../config/keys'; +import { QueryClientConfig, UUID } from '../types'; + +export default (queryClient: QueryClient, queryConfig: QueryClientConfig) => { + const { notifier } = queryConfig; + + /** + * @param {UUID} id membership id to edit + * @param {UUID} itemId corresponding item id + * @param {PERMISSION_LEVELS} permission permission level to apply + */ + queryClient.setMutationDefaults(MUTATION_KEYS.UPLOAD_ITEM_THUMBNAIL, { + mutationFn: ({ id }: { id: UUID }) => + Api.editItemMembership({ id, permission }, queryConfig), + onSuccess: () => { + notifier?.({ type: editItemMembershipRoutine.SUCCESS }); + }, + onError: (error) => { + notifier?.({ + type: editItemMembershipRoutine.FAILURE, + payload: { error }, + }); + }, + // Always refetch after error or success: + onSettled: (_data, _error, { itemId }) => { + queryClient.invalidateQueries(buildItemMembershipsKey(itemId)); + }, + }); +}; diff --git a/src/queryClient.ts b/src/queryClient.ts index e15785eb..e7192823 100644 --- a/src/queryClient.ts +++ b/src/queryClient.ts @@ -22,7 +22,16 @@ export type Notifier = (e: any) => any; const retry = (failureCount: number, error: Error) => { // do not retry if the request was not authorized // the user is probably not signed in - if (error.name === getReasonPhrase(StatusCodes.UNAUTHORIZED)) { + const codes = [ + StatusCodes.UNAUTHORIZED, + StatusCodes.NOT_FOUND, + StatusCodes.BAD_REQUEST, + StatusCodes.FORBIDDEN] + const reasons = codes.map(code => + getReasonPhrase(code) + ); + + if (reasons.includes(error.message) || reasons.includes(error.name)) { return false; } return failureCount < 3; @@ -33,7 +42,7 @@ export default (config: Partial) => { API_HOST: config?.API_HOST || process.env.REACT_APP_API_HOST || - 'http://localhost:3111', + 'http://localhost:3000', S3_FILES_HOST: config?.S3_FILES_HOST || process.env.REACT_APP_S3_FILES_HOST || diff --git a/src/routines/item.ts b/src/routines/item.ts index cc8e8149..41902f93 100644 --- a/src/routines/item.ts +++ b/src/routines/item.ts @@ -13,3 +13,6 @@ export const deleteItemsRoutine = createRoutine('DELETE_ITEMS'); export const uploadFileRoutine = createRoutine('UPLOAD_FILE'); export const recycleItemsRoutine = createRoutine('RECYCLE_ITEMS'); export const restoreItemsRoutine = createRoutine('RESTORE_ITEMS'); +export const uploadItemThumbnailRoutine = createRoutine( + 'UPLOAD_ITEM_THUMBNAIL', +); diff --git a/src/routines/member.ts b/src/routines/member.ts index 0c03bd28..b1901cef 100644 --- a/src/routines/member.ts +++ b/src/routines/member.ts @@ -2,3 +2,4 @@ import createRoutine from './utils'; export const signOutRoutine = createRoutine('SIGN_OUT'); export const editMemberRoutine = createRoutine('EDIT_MEMBER'); +export const uploadAvatarRoutine = createRoutine('UPLOAD_AVATAR'); diff --git a/src/types.ts b/src/types.ts index 3da5cb10..1603a7a6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -120,3 +120,12 @@ export interface Chat { id: string; messages: Array; } + +// todo: get from graasp types +export type GraaspError = { + name: string; + code: string; + statusCode?: number; + message: string; + data?: unknown; +} diff --git a/src/utils/thumbnails.ts b/src/utils/thumbnails.ts new file mode 100644 index 00000000..ef58e607 --- /dev/null +++ b/src/utils/thumbnails.ts @@ -0,0 +1,15 @@ +import { buildS3FileUrl } from "../api/routes"; +import { failOnError } from "../api/utils"; + +// eslint-disable-next-line import/prefer-default-export +export const getRequestBlob = async (data: Response, S3_FILES_HOST: string) => { + if (data.headers.get("Content-Type")?.includes('application/json')) { + const json = await data.json() + const s3FileUrl = buildS3FileUrl(S3_FILES_HOST, json.key); + const img = await fetch(s3FileUrl).then(failOnError) + return img.blob() + } + + // default + return data.blob(); +} diff --git a/test/constants.ts b/test/constants.ts index f26ec525..e50b604f 100644 --- a/test/constants.ts +++ b/test/constants.ts @@ -162,6 +162,8 @@ export const S3_FILE_RESPONSE = { key: 'someurl', }; export const S3_FILE_BLOB_RESPONSE = BlobMock; +export const THUMBNAIL_BLOB_RESPONSE = BlobMock; +export const AVATAR_BLOB_RESPONSE = BlobMock; export const APPS = [ { diff --git a/test/utils.tsx b/test/utils.tsx index ad621881..9972d825 100644 --- a/test/utils.tsx +++ b/test/utils.tsx @@ -50,6 +50,7 @@ export type Endpoint = { response: any; method?: REQUEST_METHODS; statusCode?: number; + headers?: unknown; }; interface MockArguments { @@ -68,10 +69,11 @@ interface MockMutationArguments extends MockArguments { export const mockEndpoints = (endpoints: Endpoint[]) => { // mock endpoint with given response const server = nock(API_HOST); - endpoints.forEach(({ route, method, statusCode, response }) => { + endpoints.forEach(({ route, method, statusCode, response, headers }) => { server[(method || REQUEST_METHODS.GET).toLowerCase()](route).reply( statusCode || StatusCodes.OK, response, + headers, ); }); return server;