From 46c146d120dd0b9ddb063795999d20cadcb0cdab Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 11 Feb 2026 12:03:33 +0100 Subject: [PATCH] feat: add sort and search dropdown to board columns on project page Replace inline board rendering with a BoardColumn Livewire component that supports sorting (newest, most voted, last commented) and searching items per board. Pinned items always stay at the top regardless of sort. Co-Authored-By: Claude Opus 4.6 --- app/Http/Controllers/ProjectController.php | 11 +- app/Livewire/Project/BoardColumn.php | 55 ++++++ lang/en/general.php | 6 + lang/es/general.php | 6 + lang/hu/general.php | 6 + lang/it/general.php | 6 + lang/nl/general.php | 6 + .../livewire/project/board-column.blade.php | 128 +++++++++++++ resources/views/project.blade.php | 62 +------ .../Controllers/ProjectControllerTest.php | 14 +- .../Livewire/Project/BoardColumnTest.php | 174 ++++++++++++++++++ 11 files changed, 397 insertions(+), 77 deletions(-) create mode 100644 app/Livewire/Project/BoardColumn.php create mode 100644 resources/views/livewire/project/board-column.blade.php create mode 100644 tests/Feature/Livewire/Project/BoardColumnTest.php diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index af37118e..01f770ee 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -12,16 +12,7 @@ public function show($id) return view('project', [ 'project' => $project, - 'boards' => $project->boards() - ->visible() - ->with(['items' => function ($query) { - return $query - ->orderBy('pinned', 'desc') - ->visibleForCurrentUser() - ->popular() // TODO: This needs to be fixed to respect the sorting setting from the board itself (sort_items_by) - ->withCount('votes'); - }]) - ->get(), + 'boards' => $project->boards()->visible()->get(), ]); } } diff --git a/app/Livewire/Project/BoardColumn.php b/app/Livewire/Project/BoardColumn.php new file mode 100644 index 00000000..a323a649 --- /dev/null +++ b/app/Livewire/Project/BoardColumn.php @@ -0,0 +1,55 @@ + '$refresh', + ]; + + public function mount(): void + { + $this->sortBy = 'created_at'; + } + + public function setSortBy(string $sort): void + { + if (! in_array($sort, ['created_at', 'total_votes', 'last_commented'])) { + return; + } + + $this->sortBy = $sort; + } + + public function render() + { + $query = $this->board->items() + ->visibleForCurrentUser() + ->when($this->search, fn ($q) => $q->where('title', 'like', '%' . $this->search . '%')); + + if ($this->sortBy === 'last_commented') { + $query->withMax('comments', 'created_at') + ->orderBy('pinned', 'desc') + ->orderByDesc('comments_max_created_at'); + } else { + $query->orderBy('pinned', 'desc') + ->orderByDesc($this->sortBy); + } + + $items = $query->get(); + + return view('livewire.project.board-column', [ + 'items' => $items, + ]); + } +} diff --git a/lang/en/general.php b/lang/en/general.php index 239e27ec..f222d603 100644 --- a/lang/en/general.php +++ b/lang/en/general.php @@ -30,4 +30,10 @@ 'close' => 'Close', 'save' => 'Save', + + 'search-items' => 'Search items...', + 'sort-by' => 'Sort by', + 'sort-newest' => 'Newest', + 'sort-most-voted' => 'Most voted', + 'sort-last-commented' => 'Last commented', ]; diff --git a/lang/es/general.php b/lang/es/general.php index 63fd5082..8c86da15 100644 --- a/lang/es/general.php +++ b/lang/es/general.php @@ -9,4 +9,10 @@ 'close' => 'Cerrar', 'save' => 'Guardar', + + 'search-items' => 'Buscar elementos...', + 'sort-by' => 'Ordenar por', + 'sort-newest' => 'Más recientes', + 'sort-most-voted' => 'Más votados', + 'sort-last-commented' => 'Último comentado', ]; diff --git a/lang/hu/general.php b/lang/hu/general.php index 845541e7..d6209da8 100644 --- a/lang/hu/general.php +++ b/lang/hu/general.php @@ -31,5 +31,11 @@ 'close' => 'Bezárás', 'save' => 'Mentés', + 'search-items' => 'Elemek keresése...', + 'sort-by' => 'Rendezés', + 'sort-newest' => 'Legújabb', + 'sort-most-voted' => 'Legtöbb szavazat', + 'sort-last-commented' => 'Utoljára hozzászólt', + 'public-user' => 'Nyilvános felhasználó', ]; diff --git a/lang/it/general.php b/lang/it/general.php index c7d5b25f..c2517ca0 100644 --- a/lang/it/general.php +++ b/lang/it/general.php @@ -10,5 +10,11 @@ 'close' => 'Chiudi', 'save' => 'Salva', + 'search-items' => 'Cerca elementi...', + 'sort-by' => 'Ordina per', + 'sort-newest' => 'Più recenti', + 'sort-most-voted' => 'Più votati', + 'sort-last-commented' => 'Ultimo commento', + 'public-user' => 'Utente pubblico', ]; diff --git a/lang/nl/general.php b/lang/nl/general.php index c9868151..aa6bb38c 100644 --- a/lang/nl/general.php +++ b/lang/nl/general.php @@ -30,4 +30,10 @@ 'close' => 'Sluiten', 'save' => 'Opslaan', + + 'search-items' => 'Zoek items...', + 'sort-by' => 'Sorteren op', + 'sort-newest' => 'Nieuwste', + 'sort-most-voted' => 'Meeste stemmen', + 'sort-last-commented' => 'Laatst becommentarieerd', ]; diff --git a/resources/views/livewire/project/board-column.blade.php b/resources/views/livewire/project/board-column.blade.php new file mode 100644 index 00000000..81c36309 --- /dev/null +++ b/resources/views/livewire/project/board-column.blade.php @@ -0,0 +1,128 @@ +
+
+
+
+ + {{ $board->title }} + + +
+ + +
+
+ +
+
+
+ + + +
+
+
+
+
+
+ +
+
diff --git a/resources/views/project.blade.php b/resources/views/project.blade.php index 71f1dad6..58ebb720 100644 --- a/resources/views/project.blade.php +++ b/resources/views/project.blade.php @@ -18,67 +18,7 @@ ]) > @forelse($boards as $board) -
- -
+ @empty
has( - Board::factory(5) - ->has(Item::factory(10)) + Board::factory() + ->has(Item::factory(3)) ) ->create(); - get(route('projects.show', $project))->assertSeeInOrder(Item::all()->pluck('title')->toArray()); + $response = get(route('projects.show', $project)); + + Item::all()->each(fn (Item $item) => $response->assertSeeText($item->title)); }); it('includes votes for items', function () { @@ -71,14 +73,14 @@ get(route('projects.show', $project))->assertSeeInOrder(['item 2' , 'item 1']); }); -test('items are sorted by vote count', function () { +test('items are sorted by newest by default', function () { $project = Project::factory() ->has( Board::factory() ->has( Item::factory(2)->state(new Sequence( - ['title' => 'item 1', 'total_votes' => 1], - ['title' => 'item 2', 'total_votes' => 10] + ['title' => 'item 1', 'created_at' => now()->subDay()], + ['title' => 'item 2', 'created_at' => now()] )) ) )->create(); diff --git a/tests/Feature/Livewire/Project/BoardColumnTest.php b/tests/Feature/Livewire/Project/BoardColumnTest.php new file mode 100644 index 00000000..b37caa22 --- /dev/null +++ b/tests/Feature/Livewire/Project/BoardColumnTest.php @@ -0,0 +1,174 @@ +has(Board::factory())->create(); + $board = $project->boards->first(); + + Livewire::test(BoardColumn::class, ['project' => $project, 'board' => $board]) + ->assertStatus(200); +}); + +it('displays items', function () { + $project = Project::factory() + ->has(Board::factory()->has(Item::factory(3))) + ->create(); + $board = $project->boards->first(); + + Livewire::test(BoardColumn::class, ['project' => $project, 'board' => $board]) + ->assertSee($board->items->pluck('title')->toArray()); +}); + +it('defaults to created_at sort', function () { + $project = Project::factory() + ->has(Board::factory()->state(['sort_items_by' => 'popular'])) + ->create(); + $board = $project->boards->first(); + + Livewire::test(BoardColumn::class, ['project' => $project, 'board' => $board]) + ->assertSet('sortBy', 'created_at'); +}); + +it('sorts by newest when setSortBy is called with created_at', function () { + $project = Project::factory() + ->has( + Board::factory()->has( + Item::factory(2)->state(new Sequence( + ['title' => 'older item', 'created_at' => now()->subDay()], + ['title' => 'newer item', 'created_at' => now()], + )) + ) + ) + ->create(); + $board = $project->boards->first(); + + Livewire::test(BoardColumn::class, ['project' => $project, 'board' => $board]) + ->call('setSortBy', 'created_at') + ->assertSeeInOrder(['newer item', 'older item']); +}); + +it('sorts by most voted when setSortBy is called with total_votes', function () { + $project = Project::factory() + ->has( + Board::factory()->has( + Item::factory(2)->state(new Sequence( + ['title' => 'low votes', 'total_votes' => 1], + ['title' => 'high votes', 'total_votes' => 10], + )) + ) + ) + ->create(); + $board = $project->boards->first(); + + Livewire::test(BoardColumn::class, ['project' => $project, 'board' => $board]) + ->call('setSortBy', 'total_votes') + ->assertSeeInOrder(['high votes', 'low votes']); +}); + +it('sorts by last commented', function () { + $project = Project::factory() + ->has(Board::factory()->has(Item::factory(2)->state(new Sequence( + ['title' => 'old comment item'], + ['title' => 'new comment item'], + )))) + ->create(); + $board = $project->boards->first(); + $items = $board->items; + + Comment::factory()->create([ + 'item_id' => $items[0]->id, + 'created_at' => now()->subDay(), + ]); + Comment::factory()->create([ + 'item_id' => $items[1]->id, + 'created_at' => now(), + ]); + + Livewire::test(BoardColumn::class, ['project' => $project, 'board' => $board]) + ->call('setSortBy', 'last_commented') + ->assertSeeInOrder(['new comment item', 'old comment item']); +}); + +it('keeps pinned items at the top regardless of sort', function () { + $project = Project::factory() + ->has( + Board::factory()->has( + Item::factory(2)->state(new Sequence( + ['title' => 'unpinned high votes', 'pinned' => false, 'total_votes' => 100], + ['title' => 'pinned low votes', 'pinned' => true, 'total_votes' => 1], + )) + ) + ) + ->create(); + $board = $project->boards->first(); + + Livewire::test(BoardColumn::class, ['project' => $project, 'board' => $board]) + ->call('setSortBy', 'total_votes') + ->assertSeeInOrder(['pinned low votes', 'unpinned high votes']); +}); + +it('shows empty state when board has no items', function () { + $project = Project::factory()->has(Board::factory())->create(); + $board = $project->boards->first(); + + Livewire::test(BoardColumn::class, ['project' => $project, 'board' => $board]) + ->assertSee(trans('items.no-items')); +}); + +it('filters items by search query', function () { + $project = Project::factory() + ->has( + Board::factory()->has( + Item::factory(2)->state(new Sequence( + ['title' => 'Add dark mode support'], + ['title' => 'Fix login bug'], + )) + ) + ) + ->create(); + $board = $project->boards->first(); + + Livewire::test(BoardColumn::class, ['project' => $project, 'board' => $board]) + ->set('search', 'dark mode') + ->assertSeeText('Add dark mode support') + ->assertDontSeeText('Fix login bug'); +}); + +it('shows all items when search is cleared', function () { + $project = Project::factory() + ->has( + Board::factory()->has( + Item::factory(2)->state(new Sequence( + ['title' => 'Add dark mode support'], + ['title' => 'Fix login bug'], + )) + ) + ) + ->create(); + $board = $project->boards->first(); + + Livewire::test(BoardColumn::class, ['project' => $project, 'board' => $board]) + ->set('search', 'dark mode') + ->assertDontSeeText('Fix login bug') + ->set('search', '') + ->assertSeeText('Add dark mode support') + ->assertSeeText('Fix login bug'); +}); + +it('ignores invalid sort values', function () { + $project = Project::factory() + ->has(Board::factory()) + ->create(); + $board = $project->boards->first(); + + Livewire::test(BoardColumn::class, ['project' => $project, 'board' => $board]) + ->call('setSortBy', 'invalid_sort') + ->assertSet('sortBy', 'created_at'); +});