From 00173c0eed17cf4310ed940f0ac012458b005259 Mon Sep 17 00:00:00 2001 From: Tyde Hashimoto Date: Thu, 9 Oct 2025 18:08:12 -0700 Subject: [PATCH 1/3] Added query mutation for uploading an exercise video --- src/components/Exercises/forms/VideoCard.tsx | 64 +++++++++++--------- src/components/Exercises/queries/index.ts | 17 ++++++ src/services/video.ts | 17 +++++- 3 files changed, 69 insertions(+), 29 deletions(-) diff --git a/src/components/Exercises/forms/VideoCard.tsx b/src/components/Exercises/forms/VideoCard.tsx index 8251d71fc..bb371e536 100644 --- a/src/components/Exercises/forms/VideoCard.tsx +++ b/src/components/Exercises/forms/VideoCard.tsx @@ -5,6 +5,7 @@ import { useProfileQuery } from "components/User/queries/profile"; import React from "react"; import { useTranslation } from "react-i18next"; import { deleteExerciseVideo, postExerciseVideo } from "services"; +import { useAddExerciseVideoQuery } from "../queries"; type VideoCardProps = { video: ExerciseVideo; @@ -22,16 +23,16 @@ export const VideoEditCard = ({ video, canDelete }: VideoCardProps) => { controls preload="metadata" /> - + {canDelete && + } - + ; }; @@ -44,6 +45,7 @@ export const AddVideoCard = ({ exerciseId }: AddVideoCardProps) => { const [t] = useTranslation(); const profileQuery = useProfileQuery(); + const addVideoQuery = useAddExerciseVideoQuery(exerciseId); const handleFileInputChange = async (e: React.ChangeEvent) => { if (!e.target.files?.length) { @@ -51,31 +53,39 @@ export const AddVideoCard = ({ exerciseId }: AddVideoCardProps) => { } const [uploadedFile] = e.target.files; if (profileQuery.isSuccess) { - await postExerciseVideo(exerciseId, profileQuery.data!.username, uploadedFile); + addVideoQuery.mutate({ + exerciseId: exerciseId, + video: uploadedFile, + author: profileQuery.data!.username, + }); } }; - return - - - - - - - - - ; + return ( + + + + + + + + + + + ); }; diff --git a/src/components/Exercises/queries/index.ts b/src/components/Exercises/queries/index.ts index ff1ac4b37..441cfb09c 100644 --- a/src/components/Exercises/queries/index.ts +++ b/src/components/Exercises/queries/index.ts @@ -10,6 +10,7 @@ import { } from "services"; import { AddTranslationParams, EditTranslationParams } from "services/exerciseTranslation"; import { deleteExerciseImage, PostExerciseImageParams } from "services/image"; +import { postExerciseVideo, PostExerciseVideoParams } from "services/video"; import { QueryKey } from "utils/consts"; export { useExercisesQuery, useExerciseQuery, useAddExerciseFullQuery } from "./exercises"; @@ -63,6 +64,22 @@ export function useAddExerciseImageQuery(exerciseId: number) { }); } +/** + * A query hook to add a new exercise video + * @param exerciseId {number} - Exercise ID to which the uploaded video will be added to + * @returns {useMutation} A mutation object to manage the video upload process + */ +export function useAddExerciseVideoQuery(exerciseId: number) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: PostExerciseVideoParams) => postExerciseVideo(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISES] }); + queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISE_DETAIL, exerciseId] }); + }, + }); +} export function useCategoriesQuery() { return useQuery({ diff --git a/src/services/video.ts b/src/services/video.ts index 26c5efbb7..d495ff6fd 100644 --- a/src/services/video.ts +++ b/src/services/video.ts @@ -4,11 +4,24 @@ import { makeHeader, makeUrl } from "utils/url"; export const VIDEO_PATH = 'video'; +export type PostExerciseVideoParams = { + exerciseId: number; + author: string; + video: File; +}; -/* +/** * Post a new exercise video + * @param {number} exerciseId - ID of the exercise to which the video is linked + * @param {string} author - Name of the video's author (for license attribution) + * @param {File} video - Video file to upload + * @returns {Promise} - A promise that resolves to the uploaded ExerciseVideo object */ -export const postExerciseVideo = async (exerciseId: number, author: string, video: File): Promise => { +export const postExerciseVideo = async ({ + exerciseId, + author, + video, +}: PostExerciseVideoParams): Promise => { const url = makeUrl(VIDEO_PATH); const headers = makeHeader(); headers['Content-Type'] = 'multipart/form-data'; From 6ed9d9d3fc3da642cdefc4ff10f8650c2658570a Mon Sep 17 00:00:00 2001 From: Tyde Hashimoto Date: Fri, 10 Oct 2025 13:42:47 -0700 Subject: [PATCH 2/3] Added delete mutation Added isLoading states to buttons Added toast error popups --- .../Exercises/Detail/ExerciseDetailEdit.tsx | 2 +- src/components/Exercises/forms/VideoCard.tsx | 118 ++++++++++-------- src/components/Exercises/queries/index.ts | 40 ++++-- 3 files changed, 97 insertions(+), 63 deletions(-) diff --git a/src/components/Exercises/Detail/ExerciseDetailEdit.tsx b/src/components/Exercises/Detail/ExerciseDetailEdit.tsx index ee695fd63..9cc1925c7 100644 --- a/src/components/Exercises/Detail/ExerciseDetailEdit.tsx +++ b/src/components/Exercises/Detail/ExerciseDetailEdit.tsx @@ -285,7 +285,7 @@ export const ExerciseDetailEdit = ({ exerciseId, language }: ViewProps) => { {exercise.videos.map(video => ( - + ))} diff --git a/src/components/Exercises/forms/VideoCard.tsx b/src/components/Exercises/forms/VideoCard.tsx index bb371e536..1e98a379d 100644 --- a/src/components/Exercises/forms/VideoCard.tsx +++ b/src/components/Exercises/forms/VideoCard.tsx @@ -1,48 +1,63 @@ -import AddCircleIcon from '@mui/icons-material/AddCircle'; +import AddCircleIcon from "@mui/icons-material/AddCircle"; import { Box, Button, Card, CardActions, CardMedia } from "@mui/material"; import { ExerciseVideo } from "components/Exercises/models/video"; +import { FormQueryErrorsSnackbar } from "components/Core/Widgets/FormError"; import { useProfileQuery } from "components/User/queries/profile"; import React from "react"; import { useTranslation } from "react-i18next"; -import { deleteExerciseVideo, postExerciseVideo } from "services"; -import { useAddExerciseVideoQuery } from "../queries"; +import { useAddExerciseVideoQuery, useDeleteExerciseVideoQuery } from "../queries"; type VideoCardProps = { + exerciseId: number; video: ExerciseVideo; canDelete: boolean; }; -export const VideoEditCard = ({ video, canDelete }: VideoCardProps) => { +/** + * A card component to edit (delete) an existing exercise video + * Will render MUI's isLoading in the button state while any actions are in progress + * @param {number} exerciseId - Exercise ID to which the uploaded video will be added to + * @param {ExerciseVideo} video - The ExerciseVideo object to display + * @param {boolean} canDelete - Whether the delete button should be shown + * @returns {JSX.Element} The rendered AddVideoCard component + */ +export const VideoEditCard = ({ exerciseId, video, canDelete }: VideoCardProps) => { const [t] = useTranslation(); + const deleteExerciseVideoQuery = useDeleteExerciseVideoQuery(exerciseId); - return - - - {canDelete && - - } - - ; -}; + const onDeleteClick = () => { + deleteExerciseVideoQuery.mutate(video.id); + }; + return ( + <> + + + + {canDelete && ( + + )} + + + {deleteExerciseVideoQuery.isError && } + + ); +}; type AddVideoCardProps = { exerciseId: number; }; +/** + * A card component to add a new exercise video + * Uses the query to handle uploading the video file + * Will render MUI's isLoading in the button state while the upload is in progress + * @param {number} exerciseId - Exercise ID to which the uploaded video will be added to + * @returns {JSX.Element} The rendered AddVideoCard component + */ export const AddVideoCard = ({ exerciseId }: AddVideoCardProps) => { - const [t] = useTranslation(); const profileQuery = useProfileQuery(); const addVideoQuery = useAddExerciseVideoQuery(exerciseId); @@ -62,30 +77,33 @@ export const AddVideoCard = ({ exerciseId }: AddVideoCardProps) => { }; return ( - - - - - - - - - - + <> + + + + + + + + + + + {addVideoQuery.isError && } + ); }; diff --git a/src/components/Exercises/queries/index.ts b/src/components/Exercises/queries/index.ts index 441cfb09c..a9fbd79c0 100644 --- a/src/components/Exercises/queries/index.ts +++ b/src/components/Exercises/queries/index.ts @@ -6,16 +6,15 @@ import { getEquipment, getLanguages, getMuscles, - postExerciseImage + postExerciseImage, } from "services"; import { AddTranslationParams, EditTranslationParams } from "services/exerciseTranslation"; import { deleteExerciseImage, PostExerciseImageParams } from "services/image"; -import { postExerciseVideo, PostExerciseVideoParams } from "services/video"; +import { deleteExerciseVideo, postExerciseVideo, PostExerciseVideoParams } from "services/video"; import { QueryKey } from "utils/consts"; export { useExercisesQuery, useExerciseQuery, useAddExerciseFullQuery } from "./exercises"; - export function useAddTranslationQuery(exerciseId: number) { const queryClient = useQueryClient(); @@ -24,7 +23,7 @@ export function useAddTranslationQuery(exerciseId: number) { onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISES] }); queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISE_DETAIL, exerciseId] }); - } + }, }); } @@ -36,7 +35,7 @@ export function useEditTranslationQuery(exerciseId: number) { onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISES] }); queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISE_DETAIL, exerciseId] }); - } + }, }); } @@ -48,7 +47,7 @@ export function useDeleteExerciseImageQuery(exerciseId: number) { onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISES] }); queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISE_DETAIL, exerciseId] }); - } + }, }); } @@ -60,7 +59,7 @@ export function useAddExerciseImageQuery(exerciseId: number) { onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISES] }); queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISE_DETAIL, exerciseId] }); - } + }, }); } @@ -81,37 +80,54 @@ export function useAddExerciseVideoQuery(exerciseId: number) { }); } +/** + * A query hook to delete a exercise video + * @param exerciseId {number} - Exercise ID to which the video is linked + * @returns {useMutation} A mutation object to manage the video deletion process + */ +export function useDeleteExerciseVideoQuery(exerciseId: number) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => deleteExerciseVideo(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISES] }); + queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISE_DETAIL, exerciseId] }); + }, + }); +} + export function useCategoriesQuery() { return useQuery({ queryKey: [QueryKey.CATEGORIES], - queryFn: getCategories + queryFn: getCategories, }); } export function useMusclesQuery() { return useQuery({ queryKey: [QueryKey.MUSCLES], - queryFn: getMuscles + queryFn: getMuscles, }); } export function useEquipmentQuery() { return useQuery({ queryKey: [QueryKey.EQUIPMENT], - queryFn: getEquipment + queryFn: getEquipment, }); } export function useLanguageQuery() { return useQuery({ queryKey: [QueryKey.LANGUAGES], - queryFn: getLanguages + queryFn: getLanguages, }); } export function useNotesQuery(translationId: number) { return useQuery({ queryKey: [QueryKey.LANGUAGES, translationId], - queryFn: getLanguages + queryFn: getLanguages, }); } From 69eba2339719486601daf325fcec2fcadd29bdc6 Mon Sep 17 00:00:00 2001 From: Tyde Hashimoto Date: Sat, 11 Oct 2025 09:03:36 -0700 Subject: [PATCH 3/3] Updated video test --- src/services/video.test.ts | 56 ++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/src/services/video.test.ts b/src/services/video.test.ts index 77ece3701..3f9d20b29 100644 --- a/src/services/video.test.ts +++ b/src/services/video.test.ts @@ -4,28 +4,24 @@ import { deleteExerciseVideo, postExerciseVideo } from "services"; jest.mock("axios"); - describe("Exercise video service API tests", () => { - - - test('POST a new video', async () => { - + test("POST a new video", async () => { // Arrange const response = { - "id": 1, - "uuid": "b1c934fa-c4f8-4d84-8cb4-7802be0d284c", - "exercise": 258, - "exercise_uuid": "6260e3aa-e46b-4b4b-8ada-58bfd0922d3a", - "video": "http://localhost:8000/media/exercise-video/258/b1c934fa-c4f8-4d84-8cb4-7802be0d284c.mp4", - "is_main": false, - "size": 0, - "duration": "0.00", - "width": 0, - "height": 0, - "codec": "", - "codec_long": "", - "license": 2, - "license_author": null + id: 1, + uuid: "b1c934fa-c4f8-4d84-8cb4-7802be0d284c", + exercise: 258, + exercise_uuid: "6260e3aa-e46b-4b4b-8ada-58bfd0922d3a", + video: "http://localhost:8000/media/exercise-video/258/b1c934fa-c4f8-4d84-8cb4-7802be0d284c.mp4", + is_main: false, + size: 0, + duration: "0.00", + width: 0, + height: 0, + codec: "", + codec_long: "", + license: 2, + license_author: null, }; const video = new ExerciseVideo( @@ -38,24 +34,23 @@ describe("Exercise video service API tests", () => { (axios.post as jest.Mock).mockImplementation(() => Promise.resolve({ data: response })); // Act - const result = await postExerciseVideo( - 42, - 'Prostetnic Vogon Jeltz', - new File([], "test.mp4") - ); + const result = await postExerciseVideo({ + exerciseId: 42, + author: "Prostetnic Vogon Jeltz", + video: new File([], "test.mp4"), + }); // Assert expect(axios.post).toHaveBeenCalled(); expect(axios.post).toHaveBeenCalledWith( - 'https://example.com/api/v2/video/', - expect.objectContaining({ "exercise": 42 }), + "https://example.com/api/v2/video/", + expect.objectContaining({ exercise: 42 }), expect.anything() ); expect(result).toEqual(video); }); - test('DELETE an existing video', async () => { - + test("DELETE an existing video", async () => { // Arrange (axios.delete as jest.Mock).mockImplementation(() => Promise.resolve({ status: 204 })); @@ -64,10 +59,7 @@ describe("Exercise video service API tests", () => { // Assert expect(axios.delete).toHaveBeenCalled(); - expect(axios.delete).toHaveBeenCalledWith( - 'https://example.com/api/v2/video/99/', - expect.anything() - ); + expect(axios.delete).toHaveBeenCalledWith("https://example.com/api/v2/video/99/", expect.anything()); expect(result).toEqual(204); }); });