diff --git a/app/Http/Controllers/TicketController.php b/app/Http/Controllers/TicketController.php index 9f4ae075..96fef026 100644 --- a/app/Http/Controllers/TicketController.php +++ b/app/Http/Controllers/TicketController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Http\Requests\TicketIndexRequest; use App\Http\Requests\TicketTransferRequest; use App\Models\Setting; use App\Models\Ticket; @@ -9,8 +10,11 @@ class TicketController extends Controller { - public function index(Request $request) + public function index(TicketIndexRequest $request) { + $order = $request->input('order', 'event'); + $direction = $request->input('order_direction', $order === 'event' ? 'desc' : 'asc'); + $query = $request ->user() ->tickets(); @@ -21,13 +25,40 @@ public function index(Request $request) }); } - $tickets = $query->with(['event' => function ($query) { - $query->orderBy('starts_at', 'DESC'); - }, 'type', 'seat']) - ->paginate(); + switch ($order) { + case 'reference': + $query->orderBy('reference', $direction); + break; + case 'type': + $query->withAggregate('type', 'name') + ->orderBy('type_name', $direction); + break; + case 'seat': + $query->withAggregate('seat', 'row') + ->withAggregate('seat', 'number') + ->orderBy('seat_row', $direction) + ->orderBy('seat_number', $direction); + break; + case 'event': + default: + $query->withAggregate('event', 'starts_at') + ->orderBy('event_starts_at', $direction); + break; + } + + $params = [ + 'order' => $order, + 'order_direction' => $direction, + ]; + + $tickets = $query->with(['event', 'type', 'seat']) + ->orderBy('id') + ->paginate() + ->appends($params); return view('tickets.index', [ 'tickets' => $tickets, + 'params' => $params, ]); } diff --git a/app/Http/Requests/TicketIndexRequest.php b/app/Http/Requests/TicketIndexRequest.php new file mode 100644 index 00000000..0afd21ff --- /dev/null +++ b/app/Http/Requests/TicketIndexRequest.php @@ -0,0 +1,38 @@ + + */ + public const SORTABLE = ['reference', 'type', 'seat', 'event']; + + /** + * Determine if the user is authorized to make this request. + */ + public function authorize(): bool + { + return true; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'order' => ['nullable', Rule::in(self::SORTABLE)], + 'order_direction' => ['nullable', Rule::in(['asc', 'desc'])], + ]; + } +} 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..45960fa4 --- /dev/null +++ b/tests/Feature/TicketSortingTest.php @@ -0,0 +1,64 @@ +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']); + } + + public function testTicketsPageRejectsUnknownOrderColumn() + { + $user = User::factory()->create(['first_login' => false]); + + $response = $this->actingAs($user)->get(route('tickets.index', ['order' => 'bogus'])); + + $response->assertSessionHasErrors('order'); + } + + public function testTicketsPageRejectsUnknownOrderDirection() + { + $user = User::factory()->create(['first_login' => false]); + + $response = $this->actingAs($user)->get(route('tickets.index', ['order_direction' => 'sideways'])); + + $response->assertSessionHasErrors('order_direction'); + } +} diff --git a/tests/Unit/app/Http/Controllers/TicketControllerTest.php b/tests/Unit/app/Http/Controllers/TicketControllerTest.php index 0cafb240..780f39d7 100644 --- a/tests/Unit/app/Http/Controllers/TicketControllerTest.php +++ b/tests/Unit/app/Http/Controllers/TicketControllerTest.php @@ -3,10 +3,13 @@ namespace Tests\Unit\app\Http\Controllers; use App\Http\Controllers\TicketController; +use App\Http\Requests\TicketIndexRequest; 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; @@ -32,14 +35,7 @@ public function testIndexFiltersDraftEventsForNonAdmin() $ticketForDraft = Ticket::factory()->create(['event_id' => Event::factory()->create(['draft' => true, 'starts_at' => now(), 'ends_at' => now()->addHour()])->id, 'user_id' => $user->id]); $ticketForLive = Ticket::factory()->create(['event_id' => Event::factory()->create(['draft' => false, 'starts_at' => now(), 'ends_at' => now()->addHour()])->id, 'user_id' => $user->id]); - $this->actingAs($user); - $controller = new TicketController(); - $request = Request::create('/', 'GET'); - $request->setUserResolver(function () use ($user) { - return $user; - }); - - $response = $controller->index($request); + $response = $this->indexResponse($user); $this->assertInstanceOf(View::class, $response); $data = $response->getData(); $tickets = $data['tickets']; @@ -136,4 +132,132 @@ 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 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(); + $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 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); + + $request = TicketIndexRequest::create('/', 'GET', $query); + $request->setUserResolver(function () use ($user) { + return $user; + }); + + return (new TicketController())->index($request); + } }
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'])