Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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: 2 additions & 0 deletions SparkyFitnessFrontend/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1075,6 +1075,8 @@
"logError": "Failed to log workout preset \"{{presetName}}\" to diary.",
"exercises": "Exercises",
"logToDiary": "Log to Diary",
"duplicate": "Duplicate",
"duplicateNameSuffix": "{{name}} (Copy)",
"name": "Name",
"presets": "presets",
"startWorkout": "Start Workout",
Expand Down
95 changes: 81 additions & 14 deletions SparkyFitnessFrontend/src/hooks/Exercises/useWorkoutPresetForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,35 +56,99 @@ export function useWorkoutPresetForm({
);
});
const [isAddExerciseDialogOpen, setIsAddExerciseDialogOpen] = useState(false);
// While set, the next AddExerciseDialog selection swaps this entry's
// exercise identity in place instead of appending a new one. Cleared on
// every plain "Add Exercise" open so a cancelled replace can't misroute a
// later add; overwritten (not accumulated) by opening Replace on another row.
const [replaceTargetIndex, setReplaceTargetIndex] = useState<number | null>(
null
);

const handleAddExercise = (exercise: Exercise | undefined) => {
if (exercise) {
const modality = resolveExerciseModality(
exercise.modality,
exercise.category
);
const newExercise: WorkoutPresetExercise = {
id: generateClientId(), // Stable ID for DND
exercise_id: exercise.id,
exercise_name: exercise.name,
image_url:
exercise.images && exercise.images.length > 0
? exercise.images[0]
: '',
exercise: exercise,
sets: [{ ...defaultSetForModality(modality), id: generateClientId() }],
category: exercise.category ?? '',
modality,
};
setExercises((prev) => [...prev, newExercise]);
const imageUrl =
exercise.images && exercise.images.length > 0 ? exercise.images[0] : '';
if (replaceTargetIndex !== null) {
// Swap the exercise identity in place, keeping the entry's already
// configured sets — the whole point of replace over remove-then-add.
setExercises((prev) =>
prev.map((ex, index) => {
if (index !== replaceTargetIndex) {
return ex;
}
return {
...ex,
exercise_id: exercise.id,
exercise_name: exercise.name,
image_url: imageUrl,
exercise,
category: exercise.category ?? '',
modality,
};
})
);
} else {
const newExercise: WorkoutPresetExercise = {
id: generateClientId(), // Stable ID for DND
exercise_id: exercise.id,
exercise_name: exercise.name,
image_url: imageUrl,
exercise: exercise,
sets: [
{ ...defaultSetForModality(modality), id: generateClientId() },
],
category: exercise.category ?? '',
modality,
};
setExercises((prev) => [...prev, newExercise]);
}
}
setReplaceTargetIndex(null);
setIsAddExerciseDialogOpen(false);
};

const handleOpenAddExercise = () => {
setReplaceTargetIndex(null);
setIsAddExerciseDialogOpen(true);
};

const handleOpenReplaceExercise = (exerciseIndex: number) => {
setReplaceTargetIndex(exerciseIndex);
setIsAddExerciseDialogOpen(true);
};

const handleRemoveExercise = (index: number) => {
setExercises((prev) => prev.filter((_, i) => i !== index));
};

const handleDuplicateExercise = useCallback((exerciseIndex: number) => {
setExercises((prev) => {
const exerciseToDuplicate = prev[exerciseIndex];
if (!exerciseToDuplicate) {
return prev;
}
const duplicate: WorkoutPresetExercise = {
...exerciseToDuplicate,
id: generateClientId(),
sets: exerciseToDuplicate.sets.map((set) => ({
...set,
id: generateClientId(),
completed_at: null,
is_pr: false,
})),
};
return [
...prev.slice(0, exerciseIndex + 1),
duplicate,
...prev.slice(exerciseIndex + 1),
];
});
}, []);

const handleSetChange = useCallback(
(
exerciseIndex: number,
Expand Down Expand Up @@ -324,7 +388,10 @@ export function useWorkoutPresetForm({
setExercises,
setIsAddExerciseDialogOpen,
handleAddExercise,
handleOpenAddExercise,
handleOpenReplaceExercise,
handleRemoveExercise,
handleDuplicateExercise,
handleSetChange,
handleAddSet,
handleDuplicateSet,
Expand Down
30 changes: 30 additions & 0 deletions SparkyFitnessFrontend/src/pages/Exercises/SortableExerciseItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
ChevronDown,
ChevronUp,
Copy,
CopyPlus,
Repeat,
Book,
Dumbbell,
HeartPulse,
Expand Down Expand Up @@ -59,6 +61,10 @@ interface SortableExerciseItemProps {
onRemoveSet: (exerciseIndex: number, setIndex: number) => void;
onAddSet?: (exerciseIndex: number) => void;
onCopyExercise?: (ex: SortableExerciseItemData) => void;
/** Swap which exercise this entry points to, keeping its configured sets. */
onReplaceExercise?: (exerciseIndex: number) => void;
/** Add an independent copy of this entry (same sets) right after it. */
onDuplicateExercise?: (exerciseIndex: number) => void;
onReorderSets?: (
exerciseIndex: number,
oldIndex: number,
Expand All @@ -78,6 +84,8 @@ export const SortableExerciseItem = ({
onRemoveSet,
onAddSet,
onCopyExercise,
onReplaceExercise,
onDuplicateExercise,
onReorderSets,
weightUnit,
workoutPresets,
Expand Down Expand Up @@ -229,6 +237,28 @@ export const SortableExerciseItem = ({
)}
</Button>
)}
{onReplaceExercise && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Replace exercise"
onClick={() => onReplaceExercise(exerciseIndex)}
>
<Repeat className="h-4 w-4" />
</Button>
)}
{onDuplicateExercise && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Duplicate exercise"
onClick={() => onDuplicateExercise(exerciseIndex)}
>
<CopyPlus className="h-4 w-4" />
</Button>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{onCopyExercise && (
<Button
variant="ghost"
Expand Down
11 changes: 6 additions & 5 deletions SparkyFitnessFrontend/src/pages/Exercises/WorkoutPresetForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ const WorkoutPresetForm: React.FC<WorkoutPresetFormProps> = ({
setIsPublic,
setIsAddExerciseDialogOpen,
handleAddExercise,
handleOpenAddExercise,
handleOpenReplaceExercise,
handleRemoveExercise,
handleDuplicateExercise,
handleSetChange,
handleAddSet,
handleDuplicateSet,
Expand Down Expand Up @@ -118,11 +121,7 @@ const WorkoutPresetForm: React.FC<WorkoutPresetFormProps> = ({
<h3 className="text-lg font-semibold">
{t('workoutPresetForm.exercisesLabel', 'Exercises')}
</h3>
<Button
type="button"
size="sm"
onClick={() => setIsAddExerciseDialogOpen(true)}
>
<Button type="button" size="sm" onClick={handleOpenAddExercise}>
<Plus className="h-4 w-4 mr-2" />
{t('workoutPresetForm.addExerciseButton', 'Add Exercise')}
</Button>
Expand Down Expand Up @@ -166,6 +165,8 @@ const WorkoutPresetForm: React.FC<WorkoutPresetFormProps> = ({
exerciseIndex={exerciseIndex}
weightUnit={weightUnit}
onRemoveExercise={handleRemoveExercise}
onReplaceExercise={handleOpenReplaceExercise}
onDuplicateExercise={handleDuplicateExercise}
onSetChange={handleSetChange}
onDuplicateSet={handleDuplicateSet}
onRemoveSet={handleRemoveSet}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
Play,
X,
MoreHorizontal,
CopyPlus,
} from 'lucide-react';
import {
DropdownMenu,
Expand Down Expand Up @@ -132,6 +133,27 @@ const WorkoutPresetsManager = () => {
setIsAddPresetDialogOpen(false);
};

const handleDuplicatePreset = React.useCallback(
async (preset: WorkoutPreset) => {
if (!user?.id) return;
// The server always inserts fresh rows for exercises/sets on create and
// ignores any incoming id (see workoutPresetRepository.createWorkoutPreset),
// so the original's exercises/sets can be sent as-is. Defaults to
// private regardless of the source's visibility — duplicating someone
// else's public preset shouldn't silently re-share it under this user.
await createPreset({
user_id: user.id,
name: t('workoutPresetsManager.duplicateNameSuffix', {
name: preset.name,
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
description: preset.description,
is_public: false,
exercises: preset.exercises,
});
},
[createPreset, user?.id, t]
);

const handleUpdatePreset = async (
presetId: string,
updatedPresetData: Partial<WorkoutPreset>
Expand Down Expand Up @@ -305,6 +327,10 @@ const WorkoutPresetsManager = () => {
<CalendarPlus className="mr-2 h-4 w-4" />
{t('workoutPresetsManager.logToDiary', 'Log to Diary')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDuplicatePreset(preset)}>
<CopyPlus className="mr-2 h-4 w-4" />
{t('workoutPresetsManager.duplicate', 'Duplicate')}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!isOwned}
onClick={() => {
Expand Down Expand Up @@ -335,6 +361,7 @@ const WorkoutPresetsManager = () => {
user?.id,
weightUnit,
handleLogPresetToDiary,
handleDuplicatePreset,
handleDeletePreset,
handleStartWorkoutPlayback,
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,46 @@ describe('SortableExerciseItem set table columns', () => {
expect(container.querySelector('input[step="1"]')).toHaveValue(45);
});
});

describe('SortableExerciseItem replace/duplicate exercise actions', () => {
it('fires the replace and duplicate handlers with the entry index when clicked', () => {
const onReplaceExercise = jest.fn();
const onDuplicateExercise = jest.fn();
render(
<SortableExerciseItem
ex={createExercise({
category: 'strength',
modality: 'weight_reps',
sets: [{ set_number: 1, reps: 10, weight: 60 }],
})}
exerciseIndex={2}
onRemoveExercise={() => {}}
onSetChange={() => {}}
onDuplicateSet={() => {}}
onRemoveSet={() => {}}
onReplaceExercise={onReplaceExercise}
onDuplicateExercise={onDuplicateExercise}
weightUnit="kg"
/>
);

fireEvent.click(screen.getByTitle('Replace exercise'));
fireEvent.click(screen.getByTitle('Duplicate exercise'));

expect(onReplaceExercise).toHaveBeenCalledWith(2);
expect(onDuplicateExercise).toHaveBeenCalledWith(2);
});

it('omits the replace and duplicate buttons when their handlers are not provided', () => {
renderItem(
createExercise({
category: 'strength',
modality: 'weight_reps',
sets: [{ set_number: 1, reps: 10, weight: 60 }],
})
);

expect(screen.queryByTitle('Replace exercise')).not.toBeInTheDocument();
expect(screen.queryByTitle('Duplicate exercise')).not.toBeInTheDocument();
});
});
Loading
Loading