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 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -113,15 +113,27 @@
|
||||
<div class="lg:col-span-5">
|
||||
<div class="rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-5">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Add Exercise</h2>
|
||||
<form method="POST" action="{{ route('workouts.exercises.add', $workout) }}" class="mt-4 space-y-4">
|
||||
<form method="POST" action="{{ route('workouts.exercises.add', $workout) }}" class="mt-4 space-y-4"
|
||||
x-data="exercisePicker({
|
||||
searchUrl: '{{ route('workouts.exercises.search', $workout) }}',
|
||||
initial: {{ $exercises->map(fn($e) => ['id' => $e->id, 'name' => $e->display_name])->values()->toJson() }}
|
||||
})">
|
||||
@csrf
|
||||
<div>
|
||||
<div class="relative">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Exercise</label>
|
||||
<select name="exercise_id" required class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2">
|
||||
@foreach($exercises as $e)
|
||||
<option value="{{ $e->id }}">{{ $e->display_name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<input type="hidden" name="exercise_id" :value="selectedId" required>
|
||||
<input type="text" x-model="query" @input.debounce.250ms="search()" @focus="open = true" @keydown.escape="open = false"
|
||||
placeholder="Search exercises by name…" autocomplete="off"
|
||||
class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2">
|
||||
<div x-show="open && results.length" @click.outside="open = false" x-cloak
|
||||
class="absolute z-10 mt-1 w-full max-h-64 overflow-auto rounded-xl bg-white dark:bg-gray-900 ring-1 ring-gray-200 dark:ring-gray-700 shadow-lg">
|
||||
<template x-for="result in results" :key="result.id">
|
||||
<button type="button" @click="select(result)"
|
||||
class="block w-full text-left px-3 py-2 text-sm text-gray-900 dark:text-gray-100 hover:bg-indigo-50 dark:hover:bg-indigo-900/30"
|
||||
x-text="result.name"></button>
|
||||
</template>
|
||||
</div>
|
||||
<p x-show="open && query.length > 0 && !results.length" x-cloak class="mt-1 text-xs text-gray-500">No exercises found.</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div>
|
||||
@@ -146,11 +158,40 @@
|
||||
<input type="text" name="notes" class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-3 py-2" />
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="inline-flex items-center rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-fuchsia-500 hover:to-indigo-500">Add Exercise</button>
|
||||
<button type="submit" :disabled="!selectedId" :class="!selectedId && 'opacity-50 cursor-not-allowed'" class="inline-flex items-center rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-fuchsia-500 hover:to-indigo-500">Add Exercise</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function exercisePicker({ searchUrl, initial }) {
|
||||
return {
|
||||
query: '',
|
||||
results: initial || [],
|
||||
selectedId: '',
|
||||
open: false,
|
||||
async search() {
|
||||
this.selectedId = '';
|
||||
if (this.query.trim() === '') {
|
||||
this.results = initial || [];
|
||||
this.open = true;
|
||||
return;
|
||||
}
|
||||
const res = await fetch(searchUrl + '?q=' + encodeURIComponent(this.query));
|
||||
this.results = res.ok ? await res.json() : [];
|
||||
this.open = true;
|
||||
},
|
||||
select(result) {
|
||||
this.selectedId = result.id;
|
||||
this.query = result.name;
|
||||
this.open = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user