Skip to content
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 src/components/Exercises/Detail/ExerciseDetailEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ export const ExerciseDetailEdit = ({ exerciseId, language }: ViewProps) => {

{exercise.videos.map(video => (
<Grid key={video.id} size={{ md: 3 }}>
<VideoEditCard video={video} canDelete={deleteVideoPermissionQuery.data!} />
<VideoEditCard exerciseId={exercise.id!} video={video} canDelete={deleteVideoPermissionQuery.data!} />
</Grid>
))}
</Grid>
Expand Down
124 changes: 76 additions & 48 deletions src/components/Exercises/forms/VideoCard.tsx
Original file line number Diff line number Diff line change
@@ -1,81 +1,109 @@
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, 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 <Card>
<CardMedia
component={'video'}
src={video.url}
sx={{ height: 120 }}
controls
preload="metadata"
/>
<CardActions>
{canDelete &&
<Button
color="primary"
onClick={() => deleteExerciseVideo(video.id)}
>
{t('delete')}
</Button>
}
</CardActions>
</Card>;
};
const onDeleteClick = () => {
deleteExerciseVideoQuery.mutate(video.id);
};

return (
<>
<Card>
<CardMedia component={"video"} src={video.url} sx={{ height: 120 }} controls preload="metadata" />
<CardActions>
{canDelete && (
<Button loading={deleteExerciseVideoQuery.isPending} color="primary" onClick={onDeleteClick}>
{t("delete")}
</Button>
)}
</CardActions>
</Card>
{deleteExerciseVideoQuery.isError && <FormQueryErrorsSnackbar mutationQuery={deleteExerciseVideoQuery} />}
</>
);
};

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);

const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.length) {
return;
}
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 <Card>
<CardMedia>
<Box sx={{ backgroundColor: "lightgray", height: 120 }}
display="flex"
alignItems="center"
justifyContent="center">
<AddCircleIcon sx={{ fontSize: 80, color: "gray" }} />
</Box>
</CardMedia>
<CardActions>
<Button component="label">
{t('add')}
<input
style={{ display: "none" }}
id="camera-input"
type="file"
accept="video/*"
capture="environment"
onChange={handleFileInputChange}
/>
</Button>
</CardActions>
</Card>;
return (
<>
<Card>
<CardMedia>
<Box
sx={{ backgroundColor: "lightgray", height: 120 }}
display="flex"
alignItems="center"
justifyContent="center"
>
<AddCircleIcon sx={{ fontSize: 80, color: "gray" }} />
</Box>
</CardMedia>
<CardActions>
<Button component="label" loading={addVideoQuery.isPending}>
{t("add")}
<input
style={{ display: "none" }}
id="camera-input"
type="file"
accept="video/*"
capture="environment"
onChange={handleFileInputChange}
/>
</Button>
</CardActions>
</Card>
{addVideoQuery.isError && <FormQueryErrorsSnackbar mutationQuery={addVideoQuery} />}
</>
);
};
55 changes: 44 additions & 11 deletions src/components/Exercises/queries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ import {
getEquipment,
getLanguages,
getMuscles,
postExerciseImage
postExerciseImage,
} from "services";
import { AddTranslationParams, EditTranslationParams } from "services/exerciseTranslation";
import { deleteExerciseImage, PostExerciseImageParams } from "services/image";
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();

Expand All @@ -23,7 +23,7 @@ export function useAddTranslationQuery(exerciseId: number) {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISES] });
queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISE_DETAIL, exerciseId] });
}
},
});
}

Expand All @@ -35,7 +35,7 @@ export function useEditTranslationQuery(exerciseId: number) {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISES] });
queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISE_DETAIL, exerciseId] });
}
},
});
}

Expand All @@ -47,7 +47,7 @@ export function useDeleteExerciseImageQuery(exerciseId: number) {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISES] });
queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISE_DETAIL, exerciseId] });
}
},
});
}

Expand All @@ -59,42 +59,75 @@ export function useAddExerciseImageQuery(exerciseId: number) {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISES] });
queryClient.invalidateQueries({ queryKey: [QueryKey.EXERCISE_DETAIL, exerciseId] });
}
},
});
}

/**
* 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] });
},
});
}

/**
* 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,
});
}
56 changes: 24 additions & 32 deletions src/services/video.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 }));

Expand All @@ -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);
});
});
Loading
Loading