From 103af830e5fa9b4b5cb50905c3db6a0338a9419c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:05:39 +0100 Subject: [PATCH] feat(dashboard): sort projects and servers by clicking a column header --- app/Livewire/Dashboard.php | 110 +++++++++++++++++++ resources/views/livewire/dashboard.blade.php | 52 +++------ tests/Feature/DashboardSortTest.php | 70 ++++++++++++ 3 files changed, 197 insertions(+), 35 deletions(-) create mode 100644 tests/Feature/DashboardSortTest.php diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index aa32fe6770..0e27d87889 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -8,18 +8,32 @@ use App\Models\Project; use App\Models\Server; use App\Models\TeamInvitation; use Illuminate\Support\Collection; +use Livewire\Attributes\Locked; use Livewire\Component; class Dashboard extends Component { use RemembersCanvasView; + /** The dashboard shows only the first projects and servers of the sorted lists. */ + public const ITEM_LIMIT = 8; + + private const PROJECT_SORT_COLUMNS = ['name', 'resources', 'description']; + + private const SERVER_SORT_COLUMNS = ['name', 'description', 'status']; + public Collection $projects; public Collection $servers; public Collection $privateKeys; + #[Locked] + public string $projectSort = 'name-asc'; + + #[Locked] + public string $serverSort = 'name-asc'; + public function mount() { if ($this->prefersCanvasView('dashboard')) { @@ -50,11 +64,107 @@ class Dashboard extends Component public function render() { + $dashboardProjects = $this->sortedProjects()->take(self::ITEM_LIMIT); + $dashboardServers = $this->sortedServers()->take(self::ITEM_LIMIT); + return view('livewire.dashboard', [ 'pendingInvitations' => $this->pendingInvitations(), + 'dashboardProjects' => $dashboardProjects, + 'dashboardServers' => $dashboardServers, + 'projectResourceCounts' => $dashboardProjects->mapWithKeys(fn (Project $project) => [$project->id => $this->projectResourceCount($project)]), + 'serverStatuses' => $dashboardServers->mapWithKeys(fn (Server $server) => [$server->id => $this->serverStatus($server)]), ]); } + public function setProjectSort(string $sort): void + { + if ($this->isValidSort($sort, self::PROJECT_SORT_COLUMNS)) { + $this->projectSort = $sort; + } + } + + public function setServerSort(string $sort): void + { + if ($this->isValidSort($sort, self::SERVER_SORT_COLUMNS)) { + $this->serverSort = $sort; + } + } + + private function projectResourceCount(Project $project): int + { + return $project->applications_count + $project->services_count + $project->postgresqls_count + + $project->redis_count + $project->keydbs_count + $project->dragonflies_count + + $project->clickhouses_count + $project->mongodbs_count + $project->mysqls_count + + $project->mariadbs_count + $project->sqlites_count; + } + + /** + * @return array{0: string, 1: 'success'|'warning'|'error'} label and badge type + */ + private function serverStatus(Server $server): array + { + $proxyNeedsAttention = $server->proxySet() && ($server->proxy->status !== 'running' || $server->hasCurrentTraefikOutdatedInfo()); + $sentinelNeedsAttention = $server->isSentinelEnabled() && $server->sentinelStatus() === 'out_of_sync'; + + return match (true) { + $server->settings->force_disabled => ['Disabled', 'error'], + ! $server->settings->is_reachable && ! $server->settings->is_usable => ['Unavailable', 'error'], + ! $server->settings->is_reachable => ['Unreachable', 'error'], + ! $server->settings->is_usable => ['Not ready', 'warning'], + $proxyNeedsAttention || $sentinelNeedsAttention => ['Attention required', 'warning'], + default => ['Ready', 'success'], + }; + } + + /** + * @return Collection + */ + private function sortedProjects(): Collection + { + return $this->sortItems($this->projects, $this->projectSort, fn (Project $project, string $column): int|string => match ($column) { + 'resources' => $this->projectResourceCount($project), + 'description' => (string) $project->description, + default => $project->name, + }); + } + + /** + * @return Collection + */ + private function sortedServers(): Collection + { + return $this->sortItems($this->servers, $this->serverSort, fn (Server $server, string $column): string => match ($column) { + 'description' => (string) $server->description, + 'status' => $this->serverStatus($server)[0], + default => $server->name, + }); + } + + /** + * Sorts by the column of a `column-direction` sort value, with the name as tie-breaker. + * + * @param callable(mixed, string): (int|string) $value + */ + private function sortItems(Collection $items, string $sort, callable $value): Collection + { + [$column, $direction] = explode('-', $sort, 2); + + return $items + ->sortBy(fn ($item) => $item->name, SORT_NATURAL | SORT_FLAG_CASE) + ->sortBy(fn ($item) => $value($item, $column), SORT_NATURAL | SORT_FLAG_CASE, $direction === 'desc') + ->values(); + } + + /** + * @param list $columns + */ + private function isValidSort(string $sort, array $columns): bool + { + [$column, $direction] = array_pad(explode('-', $sort, 2), 2, null); + + return in_array($column, $columns, true) && in_array($direction, ['asc', 'desc'], true); + } + /** * Unexpired invitations for the user's email to teams the user has not joined yet. * diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 617d8d4767..a591142002 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -8,36 +8,8 @@ @endif @php - $dashboardItemLimit = 8; - $dashboardProjects = $projects->sortBy('name', SORT_NATURAL)->take($dashboardItemLimit); - $dashboardServers = $servers->sortBy('name', SORT_NATURAL)->take($dashboardItemLimit); + $dashboardItemLimit = \App\Livewire\Dashboard::ITEM_LIMIT; $hasTrafficAnalytics = $servers->contains(fn ($server) => $server->isTrafficAnalyticsEnabled()); - $projectResourceCounts = $dashboardProjects->mapWithKeys(fn ($project) => [$project->id => collect([ - $project->applications_count, - $project->services_count, - $project->postgresqls_count, - $project->redis_count, - $project->keydbs_count, - $project->dragonflies_count, - $project->clickhouses_count, - $project->mongodbs_count, - $project->mysqls_count, - $project->mariadbs_count, - $project->sqlites_count, - ])->sum()]); - $serverStatuses = $dashboardServers->mapWithKeys(function ($server) { - $proxyNeedsAttention = $server->proxySet() && ($server->proxy->status !== 'running' || $server->hasCurrentTraefikOutdatedInfo()); - $sentinelNeedsAttention = $server->isSentinelEnabled() && $server->sentinelStatus() === 'out_of_sync'; - - return [$server->id => match (true) { - $server->settings->force_disabled => ['Disabled', 'error'], - ! $server->settings->is_reachable && ! $server->settings->is_usable => ['Unavailable', 'error'], - ! $server->settings->is_reachable => ['Unreachable', 'error'], - ! $server->settings->is_usable => ['Not ready', 'warning'], - $proxyNeedsAttention || $sentinelNeedsAttention => ['Attention required', 'warning'], - default => ['Ready', 'success'], - }]; - }); $iconButton = 'card-icon-button flex size-6.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black'; // Same look as $iconButton, sized to the 20px section title it sits next to. $titleIconButton = 'flex size-5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'; @@ -190,9 +162,14 @@
-
Project
-
Contents
- + + +
@foreach ($dashboardProjects as $project) @@ -369,12 +346,17 @@ @endphp
-
Server
- + + @if ($showServerUsage) @endif -
Status
+
@foreach ($dashboardServers as $server) @php diff --git a/tests/Feature/DashboardSortTest.php b/tests/Feature/DashboardSortTest.php new file mode 100644 index 0000000000..8b80090271 --- /dev/null +++ b/tests/Feature/DashboardSortTest.php @@ -0,0 +1,70 @@ + InstanceSettings::query()->create(['id' => 0])); + + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('sorts all projects before it limits the dashboard list', function () { + foreach (range(1, Dashboard::ITEM_LIMIT + 1) as $number) { + Project::factory()->create([ + 'team_id' => $this->team->id, + 'name' => sprintf('sort-project-%02d', $number), + 'description' => 'description '.(10 - $number), + ]); + } + + Livewire::test(Dashboard::class) + ->assertDontSee('sort-project-09') + ->call('setProjectSort', 'name-desc') + ->assertSet('projectSort', 'name-desc') + ->assertSeeInOrder(['sort-project-09', 'sort-project-08', 'sort-project-02']) + ->assertDontSee('sort-project-01') + ->call('setProjectSort', 'description-asc') + ->assertSeeInOrder(['sort-project-09', 'sort-project-08']) + ->call('setProjectSort', 'environments-desc') + ->assertSet('projectSort', 'description-asc'); +}); + +it('sorts servers by name and status', function () { + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $servers = collect(['alpha-host', 'bravo-host', 'charlie-host'])->map(fn (string $name) => Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + 'name' => $name, + ])); + $servers[1]->settings->update(['is_reachable' => true, 'is_usable' => true]); + $servers[2]->settings->update(['force_disabled' => true]); + + Livewire::test(Dashboard::class) + ->assertSeeInOrder(['alpha-host', 'bravo-host', 'charlie-host']) + ->call('setServerSort', 'name-desc') + ->assertSeeInOrder(['charlie-host', 'bravo-host', 'alpha-host']) + ->call('setServerSort', 'status-asc') + ->assertSeeHtml('aria-label="Sort by status, sorted ascending"') + // Disabled, Ready, Unavailable + ->assertSeeInOrder(['charlie-host', 'bravo-host', 'alpha-host']) + ->call('setServerSort', 'status') + ->assertSet('serverSort', 'status-asc'); +});