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
194 changes: 10 additions & 184 deletions src/components/Exercises/Add/Step2Variations.tsx
Original file line number Diff line number Diff line change
@@ -1,210 +1,36 @@
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import PhotoIcon from '@mui/icons-material/Photo';
import SearchIcon from '@mui/icons-material/Search';
import {
Alert,
AlertTitle,
Avatar,
AvatarGroup,
Box,
Button,
InputAdornment,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Paper,
Switch,
TextField,
Typography
} from "@mui/material";
import Grid from '@mui/material/Grid';
import { LoadingPlaceholder } from "components/Core/LoadingWidget/LoadingWidget";
import { StepProps } from "components/Exercises/Add/AddExerciseStepper";
import { Exercise } from "components/Exercises/models/exercise";

import { useExercisesQuery } from "components/Exercises/queries";
import React, { useEffect, useState } from "react";
import { VariationSelect } from "components/Exercises/forms/VariationSelect";
import React from "react";
import { useTranslation } from "react-i18next";
import { useExerciseSubmissionStateValue } from "state";
import { setNewBaseVariationId, setVariationId } from "state/exerciseSubmissionReducer";

/*
* Groups a list of objects by a property
*/

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function groupBy(list: any[], keyGetter: Function) {
const map = new Map();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
list.forEach((item: any) => {
const key = keyGetter(item);
const collection = map.get(key);
if (!collection) {
map.set(key, [item]);
} else {
collection.push(item);
}
});
return map;
}

// New component that displays the exercise info in a ListItem
const ExerciseInfoListItem = ({ exercises }: { exercises: Exercise[] }) => {
const MAX_EXERCISE_IMAGES = 4;
const MAX_EXERCISE_NAMES = 5;
const variationId = exercises[0].variationId;
const exerciseId = exercises[0].id;

const [state, dispatch] = useExerciseSubmissionStateValue();
const [showMore, setShowMore] = useState<boolean>(false);

const [stateVariationId, setStateVariationId] = useState<number | null>(state.variationId);
const [stateNewVariationId, setStateNewVariationId] = useState<number | null>(state.newVariationExerciseId);

useEffect(() => {
dispatch(setVariationId(stateVariationId));
}, [dispatch, stateVariationId]);

useEffect(() => {
dispatch(setNewBaseVariationId(stateNewVariationId));
}, [dispatch, stateNewVariationId]);


const handleToggle = (variationId: number | null, newVariationId: number | null) => () => {

if (variationId !== null) {
newVariationId = null;
if (variationId === state.variationId) {
variationId = null;
}
} else {
variationId = null;
if (newVariationId === state.newVariationExerciseId) {
newVariationId = null;
}
}

setStateVariationId(variationId);
setStateNewVariationId(newVariationId);
};

let isChecked;
if (variationId === null) {
isChecked = state.newVariationExerciseId === exerciseId;
} else {
isChecked = variationId === state.variationId;
}

return (
<ListItem disableGutters>
<ListItemButton onClick={handleToggle(variationId, exerciseId)}>
<ListItemIcon>
<AvatarGroup max={MAX_EXERCISE_IMAGES} spacing={"small"}>
{exercises.map((base) =>
base.mainImage
? <Avatar key={base.id} src={base.mainImage.url} />
: <Avatar key={base.id} children={<PhotoIcon />} />
)}
</AvatarGroup>
</ListItemIcon>
<ListItemText
primary={exercises.slice(0, showMore ? exercises.length : MAX_EXERCISE_NAMES).map((exercise) =>
<p style={{ margin: 0 }} key={exercise.id}>{exercise.getTranslation().name}</p>
)} />

<Switch
key={`variation-${variationId}`}
edge="start"
checked={isChecked}
tabIndex={-1}
disableRipple
/>

{!showMore && exercises.length > MAX_EXERCISE_NAMES
? <ExpandMoreIcon onMouseEnter={() => setShowMore(true)} />
: null
}

</ListItemButton>
</ListItem>
);
};


export const Step2Variations = ({ onContinue, onBack }: StepProps) => {
const [t] = useTranslation();
const exercisesQuery = useExercisesQuery();
const [state, dispatch] = useExerciseSubmissionStateValue();

const [searchTerm, setSearchTerms] = useState<string>('');

// Group exercises by variationId
let exercises: Exercise[] = [];
let groupedExercises = new Map<number, Exercise[]>();
if (exercisesQuery.isSuccess) {
exercises = exercisesQuery.data;
if (searchTerm !== '') {
exercises = exercises.filter((base) => base.getTranslation().name.toLowerCase().includes(searchTerm.toLowerCase()));
}
}
groupedExercises = groupBy(exercises.filter(b => b.variationId !== null), (b: Exercise) => b.variationId);

return <>
<Grid container>
<Grid
size={{
xs: 12,
sm: 6
}}>
<Grid size={{ xs: 12, sm: 6 }}>
<Typography>{t('exercises.whatVariationsExist')}</Typography>
</Grid>
<Grid
display="flex"
justifyContent={"end"}
size={{
xs: 12,
sm: 6
}}>
<TextField
label={t('name')}
helperText={t('exercises.filterVariations')}
// defaultValue={state.nameEn}
variant="standard"
onChange={(event) => setSearchTerms(event.target.value)}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}
}}
/>
</Grid>
</Grid>

{exercisesQuery.isLoading
? <LoadingPlaceholder />
: <Paper elevation={2} sx={{ mt: 2 }}>
<List style={{ height: "400px", overflowY: "scroll" }}>
{exercises.filter(b => b.variationId === null).map(exercise =>
<ExerciseInfoListItem
exercises={[exercise]}
key={'exercise-' + exercise.id}
/>
)}
{[...groupedExercises.keys()].map(variationId =>
<ExerciseInfoListItem
exercises={groupedExercises.get(variationId)!}
key={'variation-' + variationId}
/>
)}
</List>
</Paper>
}
<VariationSelect
selectedVariationId={state.variationGroup}
selectedNewVariationExerciseId={state.newVariationExerciseId}
onChangeVariationId={(id) => dispatch(setVariationId(id))}
onChangeNewVariationExerciseId={(id) => dispatch(setNewBaseVariationId(id))}
/>

<Alert severity="info" variant="filled" sx={{ mt: 2 }}>
<AlertTitle>{t("exercises.identicalExercise")}</AlertTitle>
Expand Down Expand Up @@ -234,4 +60,4 @@ export const Step2Variations = ({ onContinue, onBack }: StepProps) => {
</Grid>
</Grid>
</>;
};
};
2 changes: 1 addition & 1 deletion src/components/Exercises/Add/Step6Overview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe("Test the add exercise step 6 component", () => {
category: 1,
muscles: [2],
musclesSecondary: [],
variationId: null,
variationGroup: null,
newVariationExerciseId: null,
languageId: 3,
equipment: [2],
Expand Down
6 changes: 3 additions & 3 deletions src/components/Exercises/Add/Step6Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const Step6Overview = ({ onBack }: StepProps) => {
secondaryMuscleIds: state.musclesSecondary,
},
author: profileQuery.data!.username,
variations: state.variationId,
variationGroup: state.variationGroup,
variationsConnectTo: state.newVariationExerciseId,
translations: [
{
Expand Down Expand Up @@ -98,8 +98,8 @@ export const Step6Overview = ({ onBack }: StepProps) => {
};

const variationText =
state.variationId !== null
? `Using variation ID ${state.variationId}`
state.variationGroup !== null
? `Using variation group ${state.variationGroup}`
: state.newVariationExerciseId !== null
? `Connecting to exercise ${state.newVariationExerciseId}`
: '';
Expand Down
4 changes: 2 additions & 2 deletions src/components/Exercises/Detail/ExerciseDetailEdit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
} from "components/Exercises/queries";
import { usePermissionQuery } from "components/User/queries/permission";
import { useProfileQuery } from "components/User/queries/profile";
import { WgerPermissions } from "permissions";
import { deleteAlias, editTranslation, postAlias } from "services";
import {
testCategories,
Expand All @@ -29,9 +30,8 @@
} from "tests/exerciseTestdata";
import { testQueryClient } from "tests/queryClient";
import { testProfileDataVerified } from "tests/userTestdata";
import { ExerciseImage } from "../models/image";
import { Exercise } from "../models/exercise";
import { WgerPermissions } from "permissions";
import { ExerciseImage } from "../models/image";

// It seems we run into a timeout when running the tests on GitHub actions
jest.setTimeout(15000);
Expand Down Expand Up @@ -101,7 +101,7 @@
mutateAsync: jest.fn(),
}));

// @ts-ignore

Check warning on line 104 in src/components/Exercises/Detail/ExerciseDetailEdit.test.tsx

View workflow job for this annotation

GitHub Actions / build

Include a description after the "@ts-ignore" directive to explain why the @ts-ignore is necessary. The description must be 10 characters or longer
// addTranslation.mockImplementation(() => Promise.resolve(
// new Translation(
// 300,
Expand Down Expand Up @@ -254,7 +254,7 @@
const user = userEvent.setup();

// Act
const { container } = render(

Check warning on line 257 in src/components/Exercises/Detail/ExerciseDetailEdit.test.tsx

View workflow job for this annotation

GitHub Actions / build

'container' is assigned a value but never used
<QueryClientProvider client={testQueryClient}>
<ExerciseDetailEdit
exerciseId={345}
Expand Down
8 changes: 8 additions & 0 deletions src/components/Exercises/Detail/ExerciseDetailEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ExerciseDescription } from "components/Exercises/forms/ExerciseDescript
import { ExerciseName } from "components/Exercises/forms/ExerciseName";
import { AddImageCard, ImageEditCard } from "components/Exercises/forms/ImageCard";
import { EditExerciseMuscle } from "components/Exercises/forms/Muscle";
import { EditExerciseVariation } from "components/Exercises/forms/Variation";
import { AddVideoCard, VideoEditCard } from "components/Exercises/forms/VideoCard";
import {
alternativeNameValidator,
Expand Down Expand Up @@ -292,6 +293,13 @@ export const ExerciseDetailEdit = ({ exerciseId, language }: ViewProps) => {
<EditExerciseEquipment exerciseId={exercise.id!}
initial={exercise.equipment.map(e => e.id)} />
</Grid>
<Grid size={12}>
<PaddingBox />
<Typography variant={'h5'}>{t('exercises.variations')}</Typography>
</Grid>
<Grid size={12}>
<EditExerciseVariation exerciseId={exercise.id!} initial={exercise.variationGroup} />
</Grid>
</>}

<Grid size={12}>
Expand Down
4 changes: 2 additions & 2 deletions src/components/Exercises/Detail/ExerciseDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ export const ExerciseDetails = () => {

// eslint-disable-next-line react-hooks/rules-of-hooks
const variationsQuery = useQuery({
queryKey: [QUERY_EXERCISE_VARIATIONS, exerciseQuery.data?.variationId],
queryFn: () => getExercisesForVariation(exerciseQuery.data?.variationId),
queryKey: [QUERY_EXERCISE_VARIATIONS, exerciseQuery.data?.variationGroup],
queryFn: () => getExercisesForVariation(exerciseQuery.data?.variationGroup),
enabled: exerciseQuery.isSuccess
});

Expand Down
74 changes: 74 additions & 0 deletions src/components/Exercises/forms/Variation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React, { useState } from "react";
import { VariationSelect } from "components/Exercises/forms/VariationSelect";
import { useProfileQuery } from "components/User/queries/profile";
import { editExercise } from "services";

export function EditExerciseVariation(props: { exerciseId: number, initial: string | null }) {
const [selectedVariationId, setSelectedVariationId] = useState<string | null>(props.initial);
const [selectedNewVariationExerciseId, setSelectedNewVariationExerciseId] = useState<number | null>(null);
const profileQuery = useProfileQuery();

const handleChangeVariationId = async (id: string | null) => {
setSelectedVariationId(id);
setSelectedNewVariationExerciseId(null);

await editExercise(props.exerciseId, {
// eslint-disable-next-line camelcase
variation_group: id,
// eslint-disable-next-line camelcase
license_author: profileQuery.data!.username
});
};

const handleChangeNewVariationExerciseId = async (id: number | null) => {
const previousVariationId = selectedVariationId;
setSelectedNewVariationExerciseId(id);
setSelectedVariationId(null);

if (id !== null) {
// Generate a new variation group UUID and assign both exercises to it
const variationGroup = crypto.randomUUID();
try {
await editExercise(props.exerciseId, {
// eslint-disable-next-line camelcase
variation_group: variationGroup,
// eslint-disable-next-line camelcase
license_author: profileQuery.data!.username
});
await editExercise(id, {
// eslint-disable-next-line camelcase
variation_group: variationGroup,
// eslint-disable-next-line camelcase
license_author: profileQuery.data!.username
});
setSelectedVariationId(variationGroup);
setSelectedNewVariationExerciseId(null);
} catch {
// Rollback on failure
setSelectedVariationId(previousVariationId);
setSelectedNewVariationExerciseId(null);
}
} else {
try {
await editExercise(props.exerciseId, {
// eslint-disable-next-line camelcase
variation_group: null,
// eslint-disable-next-line camelcase
license_author: profileQuery.data!.username
});
} catch {
setSelectedVariationId(previousVariationId);
}
}
};

return (
<VariationSelect
exerciseId={props.exerciseId}
selectedVariationId={selectedVariationId}
selectedNewVariationExerciseId={selectedNewVariationExerciseId}
onChangeVariationId={handleChangeVariationId}
onChangeNewVariationExerciseId={handleChangeNewVariationExerciseId}
/>
);
}
Loading
Loading