Skip to content

Commit

Permalink
fix: update upload avatar mutation (#956)
Browse files Browse the repository at this point in the history
  • Loading branch information
spaenleh authored Oct 9, 2024
1 parent 5178f67 commit a2a296f
Show file tree
Hide file tree
Showing 3 changed files with 70 additions and 58 deletions.
41 changes: 23 additions & 18 deletions src/member/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
UUID,
} from '@graasp/sdk';

import { AxiosProgressEvent } from 'axios';
import { StatusCodes } from 'http-status-codes';

import { verifyAuthentication } from '../api/axios.js';
Expand Down Expand Up @@ -159,25 +160,29 @@ export const updatePassword = async (
.then((data) => data);

export const uploadAvatar = async (
{
filename,
contentType,
}: { itemId: UUID; filename: string; contentType: string },
args: {
file: Blob;
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
},
{ API_HOST, axios }: PartialQueryConfigForApi,
) =>
verifyAuthentication(() =>
axios
.post(`${API_HOST}/${buildUploadAvatarRoute()}`, {
// Send and receive JSON.
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
filename,
contentType,
})
.then(({ data }) => data),
);
) => {
const { file } = args;
const itemPayload = new FormData();

/* WARNING: this file field needs to be the last one,
* otherwise the normal fields can not be read
* https://github.com/fastify/fastify-multipart?tab=readme-ov-file#usage
*/
itemPayload.append('file', file);
return axios
.post<void>(`${API_HOST}/${buildUploadAvatarRoute()}`, itemPayload, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => {
args.onUploadProgress?.(progressEvent);
},
})
.then(({ data }) => data);
};

export const downloadAvatar = async (
{ id, size = DEFAULT_THUMBNAIL_SIZE }: { id: UUID; size?: string },
Expand Down
39 changes: 17 additions & 22 deletions src/member/mutations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,7 @@ import { ReasonPhrases, StatusCodes } from 'http-status-codes';
import nock from 'nock';
import { afterEach, describe, expect, it, vi } from 'vitest';

import {
AVATAR_BLOB_RESPONSE,
OK_RESPONSE,
UNAUTHORIZED_RESPONSE,
} from '../../test/constants.js';
import { OK_RESPONSE, UNAUTHORIZED_RESPONSE } from '../../test/constants.js';
import { mockMutation, setUpTest, waitForMutation } from '../../test/utils.js';
import { memberKeys } from '../keys.js';
import { SIGN_OUT_ROUTE } from '../routes.js';
Expand Down Expand Up @@ -254,23 +250,25 @@ describe('Member Mutations', () => {
const member = MemberFactory();
const replyUrl = true;
const { id } = member;
const file = new Blob();

it('Upload avatar', async () => {
const route = `/${buildUploadAvatarRoute()}`;

// set data in cache
// set data in cache for current member (necessary for query invalidation)
queryClient.setQueryData(memberKeys.current().content, member);
// set query in cache for thumbnail from currentMember
Object.values(ThumbnailSize).forEach((size) => {
const key = memberKeys.single(id).avatar({ size, replyUrl });
queryClient.setQueryData(key, 'thumbnail');
});

const response = AVATAR_BLOB_RESPONSE;

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

Expand All @@ -281,14 +279,12 @@ describe('Member Mutations', () => {
});

await act(async () => {
mockedMutation.mutate({ id, data: {} });
mockedMutation.mutate({ file });
await waitForMutation();
});

// verify member is still available
// in real cases, the path should be different
for (const size of Object.values(ThumbnailSize)) {
const key = memberKeys.single(id).avatar({ size, replyUrl });
const key = memberKeys.single(id).avatar({ size, replyUrl: true });
const state = queryClient.getQueryState(key);
expect(state?.isInvalidated).toBeTruthy();
}
Expand All @@ -300,7 +296,8 @@ describe('Member Mutations', () => {

it('Unauthorized to upload an avatar', async () => {
const route = `/${buildUploadAvatarRoute()}`;
// set data in cache
// set data in cache for current member (necessary for query invalidation)
queryClient.setQueryData(memberKeys.current().content, member);
Object.values(ThumbnailSize).forEach((size) => {
const key = memberKeys.single(id).avatar({ size, replyUrl });
queryClient.setQueryData(key, 'thumbnail');
Expand All @@ -324,21 +321,19 @@ describe('Member Mutations', () => {
});

await act(async () => {
mockedMutation.mutate({ id, error: StatusCodes.UNAUTHORIZED });
mockedMutation.mutate({ file });
await waitForMutation();
});

// verify member is still available
// in real cases, the path should be different
for (const size of Object.values(ThumbnailSize)) {
const key = memberKeys.single(id).avatar({ size, replyUrl });
const state = queryClient.getQueryState(key);
expect(state?.isInvalidated).toBeTruthy();
expect(state?.isInvalidated).toBeFalsy();
}
expect(mockedNotifier).toHaveBeenCalledWith({
type: uploadAvatarRoutine.FAILURE,
payload: { error: StatusCodes.UNAUTHORIZED },
});

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

Expand Down
48 changes: 30 additions & 18 deletions src/member/mutations.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { CompleteMember, Password, UUID } from '@graasp/sdk';
import { SUCCESS_MESSAGES } from '@graasp/translations';
import {
CompleteMember,
CurrentAccount,
MAX_THUMBNAIL_SIZE,
Password,
} from '@graasp/sdk';
import { FAILURE_MESSAGES, SUCCESS_MESSAGES } from '@graasp/translations';

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

import { throwIfArrayContainsErrorOrReturn } from '../api/axios.js';
import { memberKeys } from '../keys.js';
import { QueryClientConfig } from '../types.js';
import * as Api from './api.js';
Expand Down Expand Up @@ -107,34 +112,41 @@ export default (queryConfig: QueryClientConfig) => {
});
};

// this mutation is used for its callback and invalidate the keys
/**
* @param {UUID} id parent item id where the file is uploaded in
* @param {error} [error] error occurred during the file uploading
* Uploads the member profile picture
*/
const useUploadAvatar = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async ({ error, data }: { error?: any; data?: any; id: UUID }) => {
throwIfArrayContainsErrorOrReturn(data);
if (error) throw new Error(JSON.stringify(error));
},
mutationFn: (args: {
file: Blob;
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
}) => {
if (args.file.size > MAX_THUMBNAIL_SIZE) {
throw new Error(FAILURE_MESSAGES.UPLOAD_BIG_FILES);
}

return Api.uploadAvatar(args, queryConfig);
},
onSuccess: () => {
// get memberId from query data
const memberId = queryClient.getQueryData<CurrentAccount>(
memberKeys.current().content,
)?.id;
if (memberId) {
// if we know the memberId we invalidate the avatars to refresh the queries
queryClient.invalidateQueries({
queryKey: memberKeys.single(memberId).allAvatars,
});
}
notifier?.({
type: uploadAvatarRoutine.SUCCESS,
payload: { message: SUCCESS_MESSAGES.UPLOAD_AVATAR },
});
},
onError: (_error, { error }) => {
onError: (error) => {
notifier?.({ type: uploadAvatarRoutine.FAILURE, payload: { error } });
},
onSettled: (_data, _error, { id }) => {
queryClient.invalidateQueries({
queryKey: memberKeys.single(id).allAvatars,
});
},
});
};

Expand Down

0 comments on commit a2a296f

Please sign in to comment.