From 0a44b972325c8b78f607cb73b649a95baf214966 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 10 Aug 2026 12:02:08 +0300 Subject: [PATCH] Replace capped exercise picker with a real search combobox The workout builder's "add exercise" dropdown was Exercise::orderBy ('name')->limit(50) with no search. Harmless with a handful of seeded exercises, but once the library is populated with real data (1000+), users could only ever add exercises starting around A-D - the core "build a workout" flow would be effectively broken. Replaces it with a debounced Alpine.js combobox backed by a new GET /workouts/{workout}/exercises/search endpoint (owner-only, JSON, capped at 20 results, respects each user's custom exercise aliases). Co-Authored-By: Claude Sonnet 5 --- app/Http/Controllers/WorkoutController.php | 18 ++++++- resources/views/workouts/show.blade.php | 57 +++++++++++++++++++--- routes/web.php | 1 + tests/Feature/WorkoutTest.php | 37 ++++++++++++++ 4 files changed, 104 insertions(+), 9 deletions(-) diff --git a/app/Http/Controllers/WorkoutController.php b/app/Http/Controllers/WorkoutController.php index 5af75ee..9718847 100644 --- a/app/Http/Controllers/WorkoutController.php +++ b/app/Http/Controllers/WorkoutController.php @@ -42,10 +42,26 @@ class WorkoutController extends Controller { $this->authorizeWorkout($workout); $workout->load(['items.exercise']); - $exercises = Exercise::orderBy('name')->limit(50)->get(['id','name','slug']); + $exercises = Exercise::orderBy('name')->limit(20)->get(['id','name','slug']); return view('workouts.show', compact('workout', 'exercises')); } + public function searchExercises(Request $request, Workout $workout) + { + $this->authorizeWorkout($workout); + $q = trim((string) $request->query('q', '')); + + $exercises = Exercise::query() + ->with(['aliases' => fn ($rel) => $rel->where('user_id', Auth::id())]) + ->when($q !== '', fn ($qb) => $qb->where('name', 'like', "%{$q}%")) + ->orderBy('name') + ->limit(20) + ->get(['id', 'name']) + ->map(fn ($e) => ['id' => $e->id, 'name' => $e->display_name]); + + return response()->json($exercises); + } + // Legacy builder route -> redirect to create public function builder() { diff --git a/resources/views/workouts/show.blade.php b/resources/views/workouts/show.blade.php index b0da7c5..325221a 100644 --- a/resources/views/workouts/show.blade.php +++ b/resources/views/workouts/show.blade.php @@ -113,15 +113,27 @@

Add Exercise

-
+ @csrf -
+
- + + +
+ +
+

No exercises found.

@@ -146,11 +158,40 @@
- +
+ + @push('scripts') + + @endpush diff --git a/routes/web.php b/routes/web.php index c9c6736..f85a0fa 100644 --- a/routes/web.php +++ b/routes/web.php @@ -46,6 +46,7 @@ Route::middleware('auth')->prefix('/workouts')->group(function () { Route::delete('/{workout}', [\App\Http\Controllers\WorkoutController::class, 'destroy'])->name('workouts.destroy'); // Manage exercises within a workout + Route::get('/{workout}/exercises/search', [\App\Http\Controllers\WorkoutController::class, 'searchExercises'])->name('workouts.exercises.search'); Route::post('/{workout}/exercises', [\App\Http\Controllers\WorkoutController::class, 'addExercise'])->name('workouts.exercises.add'); Route::patch('/{workout}/exercises/{item}', [\App\Http\Controllers\WorkoutController::class, 'updateExercise'])->name('workouts.exercises.update'); Route::delete('/{workout}/exercises/{item}', [\App\Http\Controllers\WorkoutController::class, 'removeExercise'])->name('workouts.exercises.remove'); diff --git a/tests/Feature/WorkoutTest.php b/tests/Feature/WorkoutTest.php index 771b473..5a05401 100644 --- a/tests/Feature/WorkoutTest.php +++ b/tests/Feature/WorkoutTest.php @@ -146,4 +146,41 @@ class WorkoutTest extends TestCase $response->assertForbidden(); $this->assertDatabaseHas('workouts', ['id' => $workout->id]); } + + public function test_owner_can_search_exercises_for_the_picker(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $user->id]); + Exercise::factory()->create(['name' => 'Barbell Bench Press']); + Exercise::factory()->create(['name' => 'Dumbbell Curl']); + + $response = $this->actingAs($user)->getJson(route('workouts.exercises.search', $workout) . '?q=Bench'); + + $response->assertOk(); + $response->assertJsonCount(1); + $response->assertJsonFragment(['name' => 'Barbell Bench Press']); + } + + public function test_search_caps_results_at_twenty(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $user->id]); + Exercise::factory()->count(25)->create(); + + $response = $this->actingAs($user)->getJson(route('workouts.exercises.search', $workout)); + + $response->assertOk(); + $this->assertCount(20, $response->json()); + } + + public function test_other_user_cannot_search_exercises_for_someone_elses_workout(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $owner->id]); + + $response = $this->actingAs($other)->getJson(route('workouts.exercises.search', $workout)); + + $response->assertForbidden(); + } }