From e2c758471eac041452e03fd7aef08dd6e0ab4289 Mon Sep 17 00:00:00 2001 From: Emily Rowan Date: Sun, 5 Jul 2026 21:22:01 +0100 Subject: [PATCH 1/3] feat: add server-side sorting to tickets index controller Adds whitelisted order/order_direction query param handling to TicketController@index, mirroring the existing admin tickets sort convention (default: newest event first). Covers reference, type, seat, and event columns with join-based sorting and a stable tickets.id tiebreaker; invalid inputs fall back to the default. --- app/Http/Controllers/TicketController.php | 41 +++++- .../Http/Controllers/TicketControllerTest.php | 132 ++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/TicketController.php b/app/Http/Controllers/TicketController.php index 9f4ae075..4d7f403f 100644 --- a/app/Http/Controllers/TicketController.php +++ b/app/Http/Controllers/TicketController.php @@ -21,13 +21,52 @@ public function index(Request $request) }); } + $sortable = ['reference', 'type', 'seat', 'event']; + $order = in_array($request->input('order'), $sortable, true) + ? $request->input('order') + : 'event'; + + $direction = strtolower((string) $request->input('order_direction')); + if (!in_array($direction, ['asc', 'desc'], true)) { + $direction = $order === 'event' ? 'desc' : 'asc'; + } + + switch ($order) { + case 'reference': + $query->orderBy('tickets.reference', $direction); + break; + case 'type': + $query->join('ticket_types', 'tickets.ticket_type_id', '=', 'ticket_types.id') + ->orderBy('ticket_types.name', $direction); + break; + case 'seat': + $query->leftJoin('seats', 'seats.ticket_id', '=', 'tickets.id') + ->orderBy('seats.row', $direction) + ->orderBy('seats.number', $direction); + break; + case 'event': + default: + $query->join('events', 'tickets.event_id', '=', 'events.id') + ->orderBy('events.starts_at', $direction); + break; + } + + $params = [ + 'order' => $order, + 'order_direction' => $direction, + ]; + $tickets = $query->with(['event' => function ($query) { $query->orderBy('starts_at', 'DESC'); }, 'type', 'seat']) - ->paginate(); + ->select('tickets.*') + ->orderBy('tickets.id') + ->paginate() + ->appends($params); return view('tickets.index', [ 'tickets' => $tickets, + 'params' => $params, ]); } diff --git a/tests/Unit/app/Http/Controllers/TicketControllerTest.php b/tests/Unit/app/Http/Controllers/TicketControllerTest.php index 0cafb240..3ae81886 100644 --- a/tests/Unit/app/Http/Controllers/TicketControllerTest.php +++ b/tests/Unit/app/Http/Controllers/TicketControllerTest.php @@ -5,8 +5,10 @@ use App\Http\Controllers\TicketController; use App\Http\Requests\TicketTransferRequest; use App\Models\Event; +use App\Models\Seat; use App\Models\Setting; use App\Models\Ticket; +use App\Models\TicketType; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\Request; @@ -136,4 +138,134 @@ public function testTransferWhenTransfersDisabledReturnsError() $this->assertStringContainsString('/tickets', $response->getTargetUrl()); $this->assertNotEmpty($response->getSession()->get('errorMessage')); } + + public function testIndexDefaultsToNewestEventFirst() + { + $user = User::factory()->create(); + $oldEvent = Event::factory()->create(['draft' => false, 'starts_at' => now()->subDays(5), 'ends_at' => now()->subDays(5)->addHour()]); + $newEvent = Event::factory()->create(['draft' => false, 'starts_at' => now()->addDays(5), 'ends_at' => now()->addDays(5)->addHour()]); + $oldTicket = Ticket::factory()->create(['event_id' => $oldEvent->id, 'user_id' => $user->id]); + $newTicket = Ticket::factory()->create(['event_id' => $newEvent->id, 'user_id' => $user->id]); + + $tickets = $this->indexResponse($user)->getData()['tickets']; + + $this->assertSame([$newTicket->id, $oldTicket->id], $tickets->pluck('id')->all()); + } + + public function testIndexPassesResolvedSortParamsToView() + { + $user = User::factory()->create(); + + $data = $this->indexResponse($user)->getData(); + + $this->assertSame(['order' => 'event', 'order_direction' => 'desc'], $data['params']); + } + + public function testIndexSortsByReference() + { + $user = User::factory()->create(); + $event = Event::factory()->create(['draft' => false, 'starts_at' => now(), 'ends_at' => now()->addHour()]); + $a = Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id, 'reference' => 'AAA-001']); + $z = Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id, 'reference' => 'ZZZ-999']); + + $asc = $this->indexResponse($user, ['order' => 'reference', 'order_direction' => 'asc'])->getData()['tickets']; + $this->assertSame([$a->id, $z->id], $asc->pluck('id')->all()); + + $desc = $this->indexResponse($user, ['order' => 'reference', 'order_direction' => 'desc'])->getData()['tickets']; + $this->assertSame([$z->id, $a->id], $desc->pluck('id')->all()); + } + + public function testIndexSortsByType() + { + $user = User::factory()->create(); + $event = Event::factory()->create(['draft' => false, 'starts_at' => now(), 'ends_at' => now()->addHour()]); + $typeA = TicketType::factory()->create(['name' => 'AAA Type']); + $typeZ = TicketType::factory()->create(['name' => 'ZZZ Type']); + $ticketA = Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id, 'ticket_type_id' => $typeA->id]); + $ticketZ = Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id, 'ticket_type_id' => $typeZ->id]); + + $asc = $this->indexResponse($user, ['order' => 'type', 'order_direction' => 'asc'])->getData()['tickets']; + $this->assertSame([$ticketA->id, $ticketZ->id], $asc->pluck('id')->all()); + + $desc = $this->indexResponse($user, ['order' => 'type', 'order_direction' => 'desc'])->getData()['tickets']; + $this->assertSame([$ticketZ->id, $ticketA->id], $desc->pluck('id')->all()); + } + + public function testIndexSortsBySeat() + { + $user = User::factory()->create(); + $event = Event::factory()->create(['draft' => false, 'starts_at' => now(), 'ends_at' => now()->addHour()]); + $front = Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id]); + $back = Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id]); + Seat::factory()->create(['ticket_id' => $front->id, 'row' => 1, 'number' => 1]); + Seat::factory()->create(['ticket_id' => $back->id, 'row' => 9, 'number' => 9]); + + $asc = $this->indexResponse($user, ['order' => 'seat', 'order_direction' => 'asc'])->getData()['tickets']; + $this->assertSame([$front->id, $back->id], $asc->pluck('id')->all()); + + $desc = $this->indexResponse($user, ['order' => 'seat', 'order_direction' => 'desc'])->getData()['tickets']; + $this->assertSame([$back->id, $front->id], $desc->pluck('id')->all()); + } + + public function testIndexSortsByEventStartsAt() + { + $user = User::factory()->create(); + $oldEvent = Event::factory()->create(['draft' => false, 'starts_at' => now()->subDays(5), 'ends_at' => now()->subDays(5)->addHour()]); + $newEvent = Event::factory()->create(['draft' => false, 'starts_at' => now()->addDays(5), 'ends_at' => now()->addDays(5)->addHour()]); + $oldTicket = Ticket::factory()->create(['event_id' => $oldEvent->id, 'user_id' => $user->id]); + $newTicket = Ticket::factory()->create(['event_id' => $newEvent->id, 'user_id' => $user->id]); + + $asc = $this->indexResponse($user, ['order' => 'event', 'order_direction' => 'asc'])->getData()['tickets']; + $this->assertSame([$oldTicket->id, $newTicket->id], $asc->pluck('id')->all()); + } + + public function testIndexInvalidOrderFallsBackToNewestEventFirst() + { + $user = User::factory()->create(); + $oldEvent = Event::factory()->create(['draft' => false, 'starts_at' => now()->subDays(5), 'ends_at' => now()->subDays(5)->addHour()]); + $newEvent = Event::factory()->create(['draft' => false, 'starts_at' => now()->addDays(5), 'ends_at' => now()->addDays(5)->addHour()]); + $oldTicket = Ticket::factory()->create(['event_id' => $oldEvent->id, 'user_id' => $user->id]); + $newTicket = Ticket::factory()->create(['event_id' => $newEvent->id, 'user_id' => $user->id]); + + $tickets = $this->indexResponse($user, ['order' => 'bogus', 'order_direction' => 'desc'])->getData()['tickets']; + + $this->assertSame([$newTicket->id, $oldTicket->id], $tickets->pluck('id')->all()); + } + + public function testIndexInvalidDirectionFallsBackToAscending() + { + $user = User::factory()->create(); + $event = Event::factory()->create(['draft' => false, 'starts_at' => now(), 'ends_at' => now()->addHour()]); + $a = Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id, 'reference' => 'AAA-001']); + $z = Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id, 'reference' => 'ZZZ-999']); + + $tickets = $this->indexResponse($user, ['order' => 'reference', 'order_direction' => 'sideways'])->getData()['tickets']; + + $this->assertSame([$a->id, $z->id], $tickets->pluck('id')->all()); + } + + public function testIndexPaginationPreservesSort() + { + $user = User::factory()->create(); + $event = Event::factory()->create(['draft' => false, 'starts_at' => now(), 'ends_at' => now()->addHour()]); + Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id, 'reference' => 'AAA-001']); + + $tickets = $this->indexResponse($user, ['order' => 'reference', 'order_direction' => 'asc'])->getData()['tickets']; + $url = $tickets->url(1); + + $this->assertStringContainsString('order=reference', $url); + $this->assertStringContainsString('order_direction=asc', $url); + } + + private function indexResponse(User $user, array $query = []): View + { + $this->actingAs($user); + $controller = new TicketController(); + $request = Request::create('/', 'GET', $query); + $request->setUserResolver(function () use ($user) { + return $user; + }); + + return $controller->index($request); + } } From 89d4d8d59374b9ace3af8861349bc425befb7f16 Mon Sep 17 00:00:00 2001 From: Emily Rowan Date: Sun, 5 Jul 2026 21:40:02 +0100 Subject: [PATCH 2/3] feat: make tickets index column headers sortable Reuse the existing partials/_sortheader partial for the Reference, Type, Seat, and Event column headers so they become clickable sort links (Event defaults to desc on first click for newest-first). Adds an HTTP feature test proving the headers render sort links and that rows are ordered accordingly. --- resources/views/tickets/index.blade.php | 8 ++--- tests/Feature/TicketSortingTest.php | 46 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/TicketSortingTest.php diff --git a/resources/views/tickets/index.blade.php b/resources/views/tickets/index.blade.php index 313866b0..15867162 100644 --- a/resources/views/tickets/index.blade.php +++ b/resources/views/tickets/index.blade.php @@ -27,10 +27,10 @@ - - - - + + + + diff --git a/tests/Feature/TicketSortingTest.php b/tests/Feature/TicketSortingTest.php new file mode 100644 index 00000000..5dd75d61 --- /dev/null +++ b/tests/Feature/TicketSortingTest.php @@ -0,0 +1,46 @@ +create(['first_login' => false]); + $event = Event::factory()->create(['draft' => false, 'starts_at' => now(), 'ends_at' => now()->addHour()]); + Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id, 'reference' => 'AAA-001']); + + $response = $this->actingAs($user)->get(route('tickets.index')); + + // assertOk should hold even BEFORE the view change (plain-text headers still render 200). + // If this fails before you touch the view, it is an environment/layout problem to resolve first, + // not part of this feature. + $response->assertOk(); + + // This is the real red -> green assertion for this task: the Reference header must link with order=reference. + // On the default request the pagination links use order=event, so this string only appears once the + // Reference sort header exists. + $response->assertSee('order=reference', false); + } + + public function testTicketsPageOrdersRowsByReferenceAscending() + { + $user = User::factory()->create(['first_login' => false]); + $event = Event::factory()->create(['draft' => false, 'starts_at' => now(), 'ends_at' => now()->addHour()]); + Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id, 'reference' => 'AAA-001']); + Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id, 'reference' => 'ZZZ-999']); + + $response = $this->actingAs($user)->get(route('tickets.index', ['order' => 'reference', 'order_direction' => 'asc'])); + + $response->assertOk(); + $response->assertSeeInOrder(['AAA-001', 'ZZZ-999']); + } +} From 7b8dfd8c85d1ab9fb07b56b624a3d92f4c7f7488 Mon Sep 17 00:00:00 2001 From: Emily Rowan Date: Sun, 5 Jul 2026 21:48:32 +0100 Subject: [PATCH 3/3] test: guard seat sort keeps unseated tickets --- .../Http/Controllers/TicketControllerTest.php | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/Unit/app/Http/Controllers/TicketControllerTest.php b/tests/Unit/app/Http/Controllers/TicketControllerTest.php index 3ae81886..441cca67 100644 --- a/tests/Unit/app/Http/Controllers/TicketControllerTest.php +++ b/tests/Unit/app/Http/Controllers/TicketControllerTest.php @@ -207,6 +207,29 @@ public function testIndexSortsBySeat() $this->assertSame([$back->id, $front->id], $desc->pluck('id')->all()); } + public function testIndexSeatSortKeepsUnseatedTickets() + { + $user = User::factory()->create(); + $event = Event::factory()->create(['draft' => false, 'starts_at' => now(), 'ends_at' => now()->addHour()]); + + $frontRow = Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id]); + $backRow = Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id]); + $unseated = Ticket::factory()->create(['event_id' => $event->id, 'user_id' => $user->id]); + + Seat::factory()->create(['ticket_id' => $frontRow->id, 'row' => 1, 'number' => 1, 'label' => '1-1']); + Seat::factory()->create(['ticket_id' => $backRow->id, 'row' => 2, 'number' => 5, 'label' => '2-5']); + + $tickets = $this->indexResponse($user, ['order' => 'seat', 'order_direction' => 'asc'])->getData()['tickets']; + $ids = $tickets->pluck('id')->all(); + + $this->assertCount(3, $tickets); + $this->assertContains($unseated->id, $ids); + + $frontPosition = array_search($frontRow->id, $ids); + $backPosition = array_search($backRow->id, $ids); + $this->assertLessThan($backPosition, $frontPosition); + } + public function testIndexSortsByEventStartsAt() { $user = User::factory()->create();
ReferenceTypeSeatEvent@include('partials._sortheader', ['title' => 'Reference', 'field' => 'reference'])@include('partials._sortheader', ['title' => 'Type', 'field' => 'type'])@include('partials._sortheader', ['title' => 'Seat', 'field' => 'seat'])@include('partials._sortheader', ['title' => 'Event', 'field' => 'event', 'direction' => 'desc'])