feat(tables): sort tables by clicking a column header

This commit is contained in:
Andras Bacsai
2026-10-29 10:53:54 +01:00
parent cd992e72e1
commit af28ba8bbc
28 changed files with 850 additions and 153 deletions
+55
View File
@@ -32,6 +32,11 @@ class DockerImages extends Component
public int $perPage = 10;
/**
* Column sort such as "size_desc". Empty keeps the order of `docker image ls` (newest first).
*/
public string $sort = '';
public function updatedSearch(): void
{
$this->resetPage();
@@ -43,6 +48,32 @@ class DockerImages extends Component
$this->resetPage();
}
public function setSort(string $sort): void
{
if (! array_key_exists(str($sort)->beforeLast('_')->value(), $this->sortColumns())
|| ! in_array(str($sort)->afterLast('_')->value(), ['asc', 'desc'], true)) {
return;
}
$this->sort = $sort;
$this->resetPage();
}
/**
* @return array<string, \Closure(array): mixed>
*/
private function sortColumns(): array
{
return [
'repository' => fn (array $image) => $image['repository'].':'.$image['tag'],
'tag' => fn (array $image) => $image['tag'],
'id' => fn (array $image) => $image['short_id'],
'created' => fn (array $image) => $image['created_at'] ?? 0,
'size' => fn (array $image) => $image['size_bytes'] ?? 0,
'used' => fn (array $image) => implode(', ', $image['containers']),
];
}
public function mount(string $server_uuid)
{
try {
@@ -161,11 +192,27 @@ class DockerImages extends Component
// removed by ID, Docker refuses with "image is referenced in multiple repositories".
'reference' => $isDangling ? $id : "{$repository}:{$tag}",
'size' => data_get($image, 'Size'),
'size_bytes' => $this->sizeToBytes((string) data_get($image, 'Size')),
'created' => data_get($image, 'CreatedSince'),
// Docker prints "2024-05-01 10:00:00 +0000 UTC"; the trailing zone name breaks strtotime().
'created_at' => strtotime(str((string) data_get($image, 'CreatedAt'))->replaceMatches('/ [A-Z]+$/', '')->value()) ?: 0,
'containers' => $containersByImage[$id] ?? [],
];
}
/**
* Docker prints sizes with decimal units, for example "512kB", "52.5MB" or "1.2GB".
*/
private function sizeToBytes(string $size): int
{
if (! preg_match('/^([\d.]+)\s*([kKMGTP]?)B$/', trim($size), $matches)) {
return 0;
}
$exponent = strpos('KMGTP', strtoupper($matches[2]));
return (int) round((float) $matches[1] * 1000 ** ($matches[2] === '' ? 0 : $exponent + 1));
}
public function render()
{
$search = trim($this->search);
@@ -175,6 +222,14 @@ class DockerImages extends Component
->contains($search, ignoreCase: true));
}
$column = $this->sortColumns()[str($this->sort)->beforeLast('_')->value()] ?? null;
if ($column) {
$images = $images->sortBy(
fn (array $image) => [$column($image), $image['reference']],
descending: str($this->sort)->endsWith('_desc'),
);
}
$this->perPage = max(1, min(100, $this->perPage));
$lastPage = max(1, (int) ceil($images->count() / $this->perPage));
$page = max(1, min((int) $this->getPage(), $lastPage));
+44 -3
View File
@@ -22,6 +22,8 @@ class Resources extends Component
public string $search = '';
public string $sort = 'name_asc';
public function updatedSearch(): void
{
$this->resetPage();
@@ -33,6 +35,38 @@ class Resources extends Component
$this->resetPage();
}
public function setSort(string $sort): void
{
if (! preg_match('/^(\w+)_(asc|desc)$/', $sort, $matches) || ! array_key_exists($matches[1], $this->sortColumns())) {
return;
}
$this->sort = $sort;
$this->resetPage();
}
/**
* @return array<string, \Closure(mixed): string>
*/
private function sortColumns(): array
{
if ($this->activeTab === 'managed') {
return [
'name' => fn ($resource): string => (string) data_get($resource, 'name'),
'project' => fn ($resource): string => (string) data_get($resource->project(), 'name'),
'environment' => fn ($resource): string => (string) data_get($resource, 'environment.name'),
'type' => fn ($resource): string => (string) $resource->type(),
'status' => fn ($resource): string => (string) data_get($resource, 'status'),
];
}
return [
'name' => fn ($container): string => (string) data_get($container, 'Names'),
'image' => fn ($container): string => (string) data_get($container, 'Image'),
'status' => fn ($container): string => (string) data_get($container, 'State'),
];
}
public ?Server $server = null;
public $parameters = [];
@@ -115,6 +149,7 @@ class Resources extends Component
try {
if ($this->activeTab !== 'managed') {
$this->search = '';
$this->sort = 'name_asc';
$this->resetPage();
}
$this->activeTab = 'managed';
@@ -128,6 +163,7 @@ class Resources extends Component
{
if ($this->activeTab !== 'unmanaged') {
$this->search = '';
$this->sort = 'name_asc';
$this->resetPage();
}
$this->activeTab = 'unmanaged';
@@ -156,9 +192,14 @@ class Resources extends Component
public function render()
{
$resources = $this->activeTab === 'managed'
? $this->server->definedResources()->sortBy('name', SORT_NATURAL)
: collect($this->unmanagedContainers)->sortBy('Names', SORT_NATURAL);
$columns = $this->sortColumns();
[$column, $direction] = explode('_', $this->sort, 2) + [1 => 'asc'];
$sortValue = $columns[$column] ?? $columns['name'];
$resources = ($this->activeTab === 'managed'
? $this->server->definedResources()
: collect($this->unmanagedContainers))
->sortBy($columns['name'], SORT_NATURAL | SORT_FLAG_CASE)
->sortBy($sortValue, SORT_NATURAL | SORT_FLAG_CASE, $direction === 'desc');
$search = trim($this->search);
if ($search !== '') {
$nameKey = $this->activeTab === 'managed' ? 'name' : 'Names';
+36
View File
@@ -42,6 +42,10 @@ class Show extends Component
#[Locked]
public ?array $deploymentsPerTagPerServer = null;
/** Sort of the active deployments table, such as `resource-asc`; null keeps them grouped by server. */
#[Locked]
public ?string $deploymentSort = null;
/** The "Connect to resources" modal on small screens, where the tags canvas is not shown. */
public bool $connectResourcesOpen = false;
@@ -103,6 +107,37 @@ class Show extends Component
}
}
public function setDeploymentSort(string $sort): void
{
if (! in_array($sort, ['resource-asc', 'resource-desc', 'server-asc', 'server-desc', 'status-asc', 'status-desc'], true)) {
return;
}
$this->deploymentSort = $sort;
}
/**
* The active deployments as one list, in the order of the chosen table sort.
*
* @return Collection<int, array<string, mixed>>
*/
private function activeDeployments(): Collection
{
$deployments = collect($this->deploymentsPerTagPerServer ?? [])->flatten(1);
if ($this->deploymentSort === null) {
return $deployments->values();
}
[$column, $direction] = explode('-', $this->deploymentSort, 2);
$field = ['resource' => 'application_name', 'server' => 'server_name', 'status' => 'status'][$column];
$order = $direction === 'desc' ? -1 : 1;
return $deployments
->sort(fn (array $left, array $right): int => ($order * strcasecmp((string) $left[$field], (string) $right[$field]))
?: $left['id'] <=> $right['id'])
->values();
}
public function redeployAll()
{
try {
@@ -193,6 +228,7 @@ class Show extends Component
];
})->values()->toArray(),
'connectableResources' => $this->connectableResources(),
'activeDeployments' => $this->activeDeployments(),
]);
}
+28
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Team;
use App\Models\AuditEvent;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Str;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Livewire\WithPagination;
@@ -20,6 +21,17 @@ class AuditLog extends Component
public int $perPage = 25;
#[Locked]
public string $sort = 'time-desc';
/** @var array<string, string> sort value prefix => audit_events column */
private const SORT_COLUMNS = [
'time' => 'created_at',
'actor' => 'actor_name',
'activity' => 'description',
'source' => 'source',
];
public function boot(): void
{
$team = currentTeam();
@@ -42,6 +54,18 @@ class AuditLog extends Component
$this->resetPage();
}
public function setSort(string $sort): void
{
[$column, $direction] = array_pad(explode('-', $sort, 2), 2, null);
if (! array_key_exists($column, self::SORT_COLUMNS) || ! in_array($direction, ['asc', 'desc'], true)) {
return;
}
$this->sort = $sort;
$this->resetPage();
}
public function updatedPerPage(): void
{
$this->perPage = max(10, min(100, $this->perPage));
@@ -67,6 +91,10 @@ class AuditLog extends Component
$events = AuditEvent::query()
->visibleToTeam($teamId, $canViewInstanceEvents)
->filtered($search, $this->action, $this->source)
->when($this->sort !== 'time-desc', function ($query): void {
[$column, $direction] = explode('-', $this->sort, 2);
$query->orderBy(self::SORT_COLUMNS[$column], $direction);
})
->latestFirst()
->paginate($this->perPage);
+43
View File
@@ -2,10 +2,13 @@
namespace App\Livewire\Team;
use App\Enums\Role;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Locked;
use Livewire\Component;
class Invitations extends Component
@@ -14,6 +17,10 @@ class Invitations extends Component
public $invitations;
/** Sort value such as `email-asc`; null keeps the loaded order. */
#[Locked]
public ?string $sort = null;
protected $listeners = ['refreshInvitations'];
public function deleteInvitation(int $invitation_id)
@@ -43,6 +50,42 @@ class Invitations extends Component
}
}
public function setSort(string $sort): void
{
if (! in_array($sort, ['email-asc', 'email-desc', 'via-asc', 'via-desc', 'role-asc', 'role-desc'], true)) {
return;
}
$this->sort = $sort;
}
public function render()
{
return view('livewire.team.invitations', [
'sortedInvitations' => $this->sortedInvitations(),
]);
}
private function sortedInvitations(): Collection
{
$invitations = collect($this->invitations);
if ($this->sort === null) {
return $invitations;
}
[$column, $direction] = explode('-', $this->sort, 2);
$sortValue = fn (TeamInvitation $invitation): int|string => $column === 'role'
? (Role::tryFrom((string) $invitation->role)?->rank() ?? 0)
: strtolower((string) $invitation->{$column});
$order = $direction === 'desc' ? -1 : 1;
return $invitations
->sort(fn (TeamInvitation $left, TeamInvitation $right): int => ($order * ($sortValue($left) <=> $sortValue($right)))
?: strcasecmp((string) $left->email, (string) $right->email))
->values();
}
public function refreshInvitations()
{
$this->invitations = TeamInvitation::ownedByCurrentTeam()->get();
+48 -1
View File
@@ -3045,6 +3045,53 @@ input[type="search"]::-webkit-search-results-decoration {
color: var(--color-fg-faint);
}
.table-sort-header {
display: inline-flex;
min-width: 0;
max-width: 100%;
align-items: center;
justify-self: start;
gap: 0.25rem;
border-radius: 4px;
font: inherit;
color: inherit;
text-align: left;
white-space: nowrap;
cursor: pointer;
transition: color 150ms;
}
.table-sort-header:hover,
.table-sort-header.is-sorted {
color: #000;
}
.dark .table-sort-header:hover,
.dark .table-sort-header.is-sorted {
color: var(--color-fg);
}
.table-sort-header:focus-visible {
outline: none;
box-shadow: 0 0 0 1px var(--color-accent, currentColor);
}
.table-sort-header-icon {
width: 0.75rem;
height: 0.75rem;
flex-shrink: 0;
opacity: 0;
transition: opacity 150ms, transform 150ms;
}
.table-sort-header:hover .table-sort-header-icon {
opacity: 0.5;
}
.table-sort-header-icon.is-visible {
opacity: 1;
}
.data-table > .env-table-item {
border-bottom: 1px solid var(--coollabs-fill);
}
@@ -3453,7 +3500,7 @@ input[type="search"]::-webkit-search-results-decoration {
}
.backup-table-grid {
grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 5rem;
grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 6rem;
min-width: 50rem;
}
@@ -0,0 +1,63 @@
{{--
A column header that sorts its table. The first click sorts by `first`, the next
click flips the direction. An arrow shows the active direction.
Alpine tables set `model` (the sort variable in the enclosing x-data scope) and an
optional `then` expression that runs after the change (for example `page = 1`).
Livewire tables set `action` (a component method that takes the sort value) and
`current` (the current server-side sort value).
@param string $label
@param string $asc sort value for ascending order
@param string $desc sort value for descending order
@param string $first 'asc' or 'desc': the direction of the first click
--}}
@props([
'label',
'asc',
'desc',
'first' => 'asc',
'model' => 'sortBy',
'then' => null,
'action' => null,
'current' => null,
])
@php
$firstValue = $first === 'desc' ? $desc : $asc;
$otherValue = $first === 'desc' ? $asc : $desc;
$ariaBase = 'Sort by '.strtolower($label);
@endphp
@if ($action)
@php
$direction = match ($current) {
$asc => 'asc',
$desc => 'desc',
default => null,
};
$nextValue = $current === $firstValue ? $otherValue : $firstValue;
@endphp
<button type="button" wire:click="{{ $action }}(@js($nextValue))"
aria-label="{{ $ariaBase }}{{ $direction ? ', sorted '.($direction === 'asc' ? 'ascending' : 'descending') : '' }}"
{{ $attributes->class(['table-sort-header group', 'is-sorted' => $direction]) }}>
<span class="truncate">{{ $label }}</span>
<svg @class(['table-sort-header-icon', 'rotate-180' => $direction === 'desc', 'is-visible' => $direction]) viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 15l6-6 6 6" />
</svg>
</button>
@else
<button type="button"
x-on:click="{{ $model }} = ({{ $model }} === @js($firstValue) ? @js($otherValue) : @js($firstValue)){{ $then ? '; '.$then : '' }}"
x-bind:aria-label="@js($ariaBase) + ({{ $model }} === @js($asc) ? ', sorted ascending' : ({{ $model }} === @js($desc) ? ', sorted descending' : ''))"
x-bind:class="{ 'is-sorted': [@js($asc), @js($desc)].includes({{ $model }}) }"
{{ $attributes->class(['table-sort-header group']) }}>
<span class="truncate">{{ $label }}</span>
<svg class="table-sort-header-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
aria-hidden="true"
x-bind:class="{ 'is-visible': [@js($asc), @js($desc)].includes({{ $model }}), 'rotate-180': {{ $model }} === @js($desc) }">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 15l6-6 6 6" />
</svg>
</button>
@endif
@@ -9,6 +9,15 @@
'name' => strtolower($backup->targetName()),
'type' => strtolower($backup->targetType()),
'frequency' => strtolower($backup->frequency),
'status' => match ($backup->latestExecution?->status) {
'running' => 'in progress',
'success' => 'success',
'failed' => 'failed',
default => $backup->enabled ? 'waiting' : 'disabled',
},
's3' => $backup->save_s3 ? ($backup->s3Storages->isNotEmpty() ? $backup->s3Storages : collect([$backup->s3])->filter())->count() : -1,
'lastRunAt' => $backup->latestExecution?->finished_at?->timestamp ?? ($backup->latestExecution?->status === 'running' ? now()->timestamp : 0),
'executions' => $backup->executions_count,
'createdAt' => $backup->created_at?->timestamp ?? 0,
])->values()),
filterOptions: @js(collect([['value' => 'all', 'label' => 'All targets']])->merge(
@@ -30,11 +39,25 @@
const matchesType = this.typeFilter === 'all' || backup.type === this.typeFilter;
return matchesSearch && matchesType;
});
const [column, direction] = this.sortBy.split('_');
const sortValues = {
target: (backup) => backup.name,
type: (backup) => backup.type,
schedule: (backup) => backup.frequency,
status: (backup) => backup.status,
s3: (backup) => backup.s3,
lastrun: (backup) => backup.lastRunAt,
executions: (backup) => backup.executions,
};
const sortValue = sortValues[column] || sortValues.target;
const order = direction === 'desc' ? -1 : 1;
return filtered.sort((left, right) => {
if (this.sortBy === 'target_desc') return right.name.localeCompare(left.name);
if (this.sortBy === 'newest') return right.createdAt - left.createdAt;
if (this.sortBy === 'oldest') return left.createdAt - right.createdAt;
return left.name.localeCompare(right.name);
const leftValue = sortValue(left);
const rightValue = sortValue(right);
return order * (typeof leftValue === 'number' ? leftValue - rightValue : leftValue.localeCompare(rightValue)) ||
left.name.localeCompare(right.name);
});
},
isVisible(id) {
@@ -168,13 +191,14 @@
@if ($backups->isNotEmpty())
<div class="data-table flex w-full flex-col overflow-x-auto" x-show="filteredBackups.length > 0">
<div class="data-table-header backup-table-grid">
<span>Target</span>
<span>Type</span>
<span>Schedule</span>
<span>Status</span>
<span>S3</span>
<span>Last run</span>
<span class="text-right">Executions</span>
<x-table.sort-header label="Target" asc="target_asc" desc="target_desc" />
<x-table.sort-header label="Type" asc="type_asc" desc="type_desc" />
<x-table.sort-header label="Schedule" asc="schedule_asc" desc="schedule_desc" />
<x-table.sort-header label="Status" asc="status_asc" desc="status_desc" />
<x-table.sort-header label="S3" asc="s3_asc" desc="s3_desc" />
<x-table.sort-header label="Last run" asc="lastrun_asc" desc="lastrun_desc" first="desc" />
<x-table.sort-header label="Executions" asc="executions_asc" desc="executions_desc" first="desc"
class="justify-self-end" />
</div>
@foreach ($backups as $backup)
@@ -202,7 +202,8 @@
<span>Source</span>
<span>Commit</span>
@unless ($embedded)
<span>Started</span>
<x-table.sort-header label="Started" asc="oldest" desc="newest" first="desc"
action="setDeploymentSort" :current="$deploymentSort" />
@endunless
<span>Duration</span>
<span>Server</span>
@@ -142,10 +142,11 @@
class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
<div
class="projects-table-grid border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div>Project</div>
<div>Contents</div>
<div>Created</div>
<div class="project-description">Description</div>
<x-table.sort-header label="Project" asc="name-asc" desc="name-desc" then="page = 1" />
<x-table.sort-header label="Contents" asc="resources-asc" desc="resources-desc" first="desc" then="page = 1" />
<x-table.sort-header label="Created" asc="created-asc" desc="created-desc" first="desc" then="page = 1" />
<x-table.sort-header label="Description" asc="description-asc" desc="description-desc" then="page = 1"
class="project-description" />
<div></div>
</div>
@@ -233,11 +234,11 @@
label: 'Name Z–A'
},
{
value: 'resources',
value: 'resources-desc',
label: 'Most resources'
},
{
value: 'environments',
value: 'environments-desc',
label: 'Most environments'
},
],
@@ -252,21 +253,23 @@
return !query || searchable.includes(query);
});
return projects.sort((first, second) => {
if (this.sortBy === 'name-desc') {
return second.name.localeCompare(first.name);
}
if (this.sortBy === 'resources') {
return second.resourceCount - first.resourceCount ||
first.name.localeCompare(second.name);
}
if (this.sortBy === 'environments') {
return second.environmentCount - first.environmentCount ||
first.name.localeCompare(second.name);
}
const [column, direction] = this.sortBy.split('-');
const sortValues = {
name: (project) => project.name,
resources: (project) => Number(project.resourceCount),
environments: (project) => Number(project.environmentCount),
created: (project) => Date.parse(project.createdAt) || 0,
description: (project) => project.description || '',
};
const sortValue = sortValues[column] || sortValues.name;
const order = direction === 'desc' ? -1 : 1;
const compare = (first, second) => typeof first === 'number' ?
first - second :
first.localeCompare(second);
return first.name.localeCompare(second.name);
});
return projects.sort((first, second) =>
order * compare(sortValue(first), sortValue(second)) ||
first.name.localeCompare(second.name));
},
get totalPages() {
return Math.max(1, Math.ceil(this.filteredProjects.length / this.pageSize));
@@ -167,11 +167,11 @@
class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
<div
class="environment-resource-grid border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div>Resource</div>
<div class="resource-type">Type</div>
<div>Status</div>
<div class="resource-domain">Domain</div>
<div class="resource-server">Server</div>
<x-table.sort-header label="Resource" asc="name-asc" desc="name-desc" then="page = 1" />
<x-table.sort-header label="Type" asc="type-asc" desc="type-desc" then="page = 1" class="resource-type" />
<x-table.sort-header label="Status" asc="status-asc" desc="status-desc" then="page = 1" />
<x-table.sort-header label="Domain" asc="domain-asc" desc="domain-desc" then="page = 1" class="resource-domain" />
<x-table.sort-header label="Server" asc="server-asc" desc="server-desc" then="page = 1" class="resource-server" />
<div class="resource-tags">Tags</div>
</div>
@@ -412,11 +412,11 @@
label: 'Name Z–A'
},
{
value: 'type',
value: 'type-asc',
label: 'Resource type'
},
{
value: 'status',
value: 'status-asc',
label: 'Status'
},
],
@@ -443,21 +443,20 @@
(!query || searchable.includes(query));
});
return items.sort((first, second) => {
if (this.sortBy === 'name-desc') {
return second.name.localeCompare(first.name);
}
if (this.sortBy === 'type') {
return first.typeLabel.localeCompare(second.typeLabel) ||
first.name.localeCompare(second.name);
}
if (this.sortBy === 'status') {
return this.statusLabel(first).localeCompare(this.statusLabel(second)) ||
first.name.localeCompare(second.name);
}
const [column, direction] = this.sortBy.split('-');
const sortValues = {
name: (item) => item.name,
type: (item) => item.typeLabel,
status: (item) => this.statusLabel(item),
domain: (item) => this.displayDomain(item.fqdn) || '',
server: (item) => item.destination?.server?.name || 'Unknown',
};
const sortValue = sortValues[column] || sortValues.name;
const order = direction === 'desc' ? -1 : 1;
return first.name.localeCompare(second.name);
});
return items.sort((first, second) =>
order * (sortValue(first).localeCompare(sortValue(second)) ||
first.name.localeCompare(second.name)));
},
uniqueOptions(options) {
return [...new Map(options
@@ -9,12 +9,28 @@
'name' => strtolower($backup->targetName()),
'type' => strtolower($backup->targetType()),
'frequency' => strtolower($backup->frequency),
'status' => match ($backup->latestExecution?->status) {
'running' => 'in progress',
'success' => 'success',
'failed' => 'failed',
default => $backup->enabled ? 'waiting' : 'disabled',
},
's3' => $backup->save_s3 ? ($backup->s3Storages->isNotEmpty() ? $backup->s3Storages : collect([$backup->s3])->filter())->where('team_id', $serviceTeamId)->count() : -1,
'lastRunAt' => $backup->latestExecution?->finished_at?->timestamp ?? ($backup->latestExecution?->status === 'running' ? now()->timestamp : 0),
'createdAt' => $backup->created_at?->timestamp ?? 0,
])->concat($databaseBackups->map(fn ($backup) => [
'id' => 'database:'.$backup->id,
'name' => strtolower($backup->database->human_name ?: $backup->database->name),
'type' => 'database',
'frequency' => strtolower($backup->frequency),
'status' => match ($backup->latest_log?->status) {
'running' => 'in progress',
'success' => 'success',
'failed' => 'failed',
default => $backup->enabled ? 'waiting' : 'disabled',
},
's3' => $backup->save_s3 ? ($backup->s3Storages->isNotEmpty() ? $backup->s3Storages : collect([$backup->s3])->filter())->where('team_id', $serviceTeamId)->count() : -1,
'lastRunAt' => $backup->latest_log?->finished_at?->timestamp ?? ($backup->latest_log?->status === 'running' ? now()->timestamp : 0),
'createdAt' => $backup->created_at?->timestamp ?? 0,
]))->values()),
filterOptions: @js(collect([['value' => 'all', 'label' => 'All targets']])->merge(
@@ -36,11 +52,24 @@
const matchesType = this.typeFilter === 'all' || backup.type === this.typeFilter;
return matchesSearch && matchesType;
});
const [column, direction] = this.sortBy.split('_');
const sortValues = {
target: (backup) => backup.name,
type: (backup) => backup.type,
schedule: (backup) => backup.frequency,
status: (backup) => backup.status,
s3: (backup) => backup.s3,
lastrun: (backup) => backup.lastRunAt,
};
const sortValue = sortValues[column] || sortValues.target;
const order = direction === 'desc' ? -1 : 1;
return filtered.sort((left, right) => {
if (this.sortBy === 'target_desc') return right.name.localeCompare(left.name);
if (this.sortBy === 'newest') return right.createdAt - left.createdAt;
if (this.sortBy === 'oldest') return left.createdAt - right.createdAt;
return left.name.localeCompare(right.name);
const leftValue = sortValue(left);
const rightValue = sortValue(right);
return order * (typeof leftValue === 'number' ? leftValue - rightValue : leftValue.localeCompare(rightValue)) ||
left.name.localeCompare(right.name);
});
},
isVisible(id) {
@@ -241,12 +270,12 @@
<div class="data-table w-full overflow-x-auto" x-show="filteredBackups.length > 0">
<div class="flex min-w-[64rem] flex-col">
<div class="data-table-header backup-table-grid service-backup-table-grid">
<span>Target</span>
<span>Type</span>
<span>Schedule</span>
<span>Status</span>
<span>S3</span>
<span>Last run</span>
<x-table.sort-header label="Target" asc="target_asc" desc="target_desc" />
<x-table.sort-header label="Type" asc="type_asc" desc="type_desc" />
<x-table.sort-header label="Schedule" asc="schedule_asc" desc="schedule_desc" />
<x-table.sort-header label="Status" asc="status_asc" desc="status_desc" />
<x-table.sort-header label="S3" asc="s3_asc" desc="s3_desc" />
<x-table.sort-header label="Last run" asc="lastrun_asc" desc="lastrun_desc" first="desc" />
<span class="text-right">Actions</span>
</div>
@@ -222,7 +222,8 @@
wire:loading.class="pointer-events-none opacity-40 blur-[2px]"
wire:target="toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter,setTableSort,setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage">
<div class="data-table-header env-table-grid {{ $showEnvironmentType ? '' : 'env-table-grid-no-type' }}">
<span>Name</span>
<x-table.sort-header label="Name" asc="name_asc" desc="name_desc"
action="setTableSort" :current="$tableSort" />
<span class="text-center">Managed</span>
@if ($showEnvironmentType)
<span>Type</span>
+26 -18
View File
@@ -148,9 +148,11 @@
class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
<div
class="environments-table-grid border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div>Environment</div>
<div class="environment-resource-count">Resources</div>
<div class="environment-description">Description</div>
<x-table.sort-header label="Environment" asc="name-asc" desc="name-desc" then="page = 1" />
<x-table.sort-header label="Resources" asc="resources-asc" desc="resources-desc" first="desc" then="page = 1"
class="environment-resource-count" />
<x-table.sort-header label="Description" asc="description-asc" desc="description-desc" then="page = 1"
class="environment-description" />
<div></div>
</div>
@@ -230,7 +232,7 @@
label: 'Name Z–A'
},
{
value: 'resources',
value: 'resources-desc',
label: 'Most resources'
},
],
@@ -245,21 +247,27 @@
return !query || searchable.includes(query);
});
return environments.sort((first, second) => {
if (this.sortBy === 'name-desc') {
return second.name.localeCompare(first.name);
}
if (this.sortBy === 'resources') {
return second.resourceCount - first.resourceCount ||
first.name.localeCompare(second.name);
}
if (this.sortBy === 'name-asc') {
return first.name.localeCompare(second.name);
}
const [column, direction] = this.sortBy.split('-');
const sortValues = {
name: (environment) => environment.name,
resources: (environment) => Number(environment.resourceCount),
description: (environment) => environment.description || '',
};
const sortValue = sortValues[column];
// Default: the server order (production, staging, development, then by name).
return 0;
});
// Default: the server order (production, staging, development, then by name).
if (!sortValue) {
return environments;
}
const order = direction === 'desc' ? -1 : 1;
const compare = (first, second) => typeof first === 'number' ?
first - second :
first.localeCompare(second);
return environments.sort((first, second) =>
order * compare(sortValue(first), sortValue(second)) ||
first.name.localeCompare(second.name));
},
get totalPages() {
return Math.max(1, Math.ceil(this.filteredEnvironments.length / this.pageSize));
@@ -50,12 +50,18 @@
@else
<div class="data-table">
<div class="data-table-header server-images-table-grid">
<span>Repository</span>
<span>Tag</span>
<span>Image ID</span>
<span>Created</span>
<span>Size</span>
<span>Used by</span>
<x-table.sort-header label="Repository" asc="repository_asc" desc="repository_desc"
action="setSort" :current="$sort" />
<x-table.sort-header label="Tag" asc="tag_asc" desc="tag_desc" action="setSort"
:current="$sort" />
<x-table.sort-header label="Image ID" asc="id_asc" desc="id_desc" action="setSort"
:current="$sort" />
<x-table.sort-header label="Created" asc="created_asc" desc="created_desc" first="desc"
action="setSort" :current="$sort" />
<x-table.sort-header label="Size" asc="size_asc" desc="size_desc" first="desc"
action="setSort" :current="$sort" />
<x-table.sort-header label="Used by" asc="used_asc" desc="used_desc" action="setSort"
:current="$sort" />
<span>Actions</span>
</div>
@foreach ($visibleImages as $image)
@@ -56,11 +56,11 @@
@if ($resources->total() > 0)
<div class="data-table">
<div class="data-table-header server-resources-managed-table-grid">
<span>Name</span>
<span>Project</span>
<span>Environment</span>
<span>Type</span>
<span>Status</span>
<x-table.sort-header label="Name" asc="name_asc" desc="name_desc" action="setSort" :current="$sort" />
<x-table.sort-header label="Project" asc="project_asc" desc="project_desc" action="setSort" :current="$sort" />
<x-table.sort-header label="Environment" asc="environment_asc" desc="environment_desc" action="setSort" :current="$sort" />
<x-table.sort-header label="Type" asc="type_asc" desc="type_desc" action="setSort" :current="$sort" />
<x-table.sort-header label="Status" asc="status_asc" desc="status_desc" action="setSort" :current="$sort" />
</div>
@foreach ($resources as $resource)
@php($resourceStatus = (string) data_get($resource, 'status', 'unknown'))
@@ -109,9 +109,9 @@
@if ($resources->total() > 0)
<div class="data-table">
<div class="data-table-header server-resources-unmanaged-table-grid">
<span>Name</span>
<span>Image</span>
<span>Status</span>
<x-table.sort-header label="Name" asc="name_asc" desc="name_desc" action="setSort" :current="$sort" />
<x-table.sort-header label="Image" asc="image_asc" desc="image_desc" action="setSort" :current="$sort" />
<x-table.sort-header label="Status" asc="status_asc" desc="status_desc" action="setSort" :current="$sort" />
<span>Actions</span>
</div>
@foreach ($resources as $resource)
+40 -32
View File
@@ -126,10 +126,12 @@
class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
<div
class="tags-table-grid border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div>Tag</div>
<div>Resources</div>
<div>Applications</div>
<div class="tag-services">Services</div>
<x-table.sort-header label="Tag" asc="name-asc" desc="name-desc" then="page = 1" />
<x-table.sort-header label="Resources" asc="resources-asc" desc="resources-desc" first="desc" then="page = 1" />
<x-table.sort-header label="Applications" asc="applications-asc" desc="applications-desc" first="desc"
then="page = 1" />
<x-table.sort-header label="Services" asc="services-asc" desc="services-desc" first="desc" then="page = 1"
class="tag-services" />
</div>
<template x-for="tag in paginatedTags" :key="tag.id">
@@ -185,7 +187,7 @@
label: 'Name Z–A'
},
{
value: 'resources',
value: 'resources-desc',
label: 'Most resources'
},
],
@@ -195,17 +197,22 @@
return !query || tag.name.toLowerCase().includes(query);
});
return tags.sort((first, second) => {
if (this.sortBy === 'name-desc') {
return second.name.localeCompare(first.name);
}
if (this.sortBy === 'resources') {
return second.resourceCount - first.resourceCount ||
first.name.localeCompare(second.name);
}
const [column, direction] = this.sortBy.split('-');
const sortValues = {
name: (tag) => tag.name,
resources: (tag) => Number(tag.resourceCount),
applications: (tag) => Number(tag.applicationsCount),
services: (tag) => Number(tag.servicesCount),
};
const sortValue = sortValues[column] || sortValues.name;
const order = direction === 'desc' ? -1 : 1;
const compare = (first, second) => typeof first === 'number' ?
first - second :
first.localeCompare(second);
return first.name.localeCompare(second.name);
});
return tags.sort((first, second) =>
order * compare(sortValue(first), sortValue(second)) ||
first.name.localeCompare(second.name));
},
get totalPages() {
return Math.max(1, Math.ceil(this.filteredTags.length / this.pageSize));
@@ -421,25 +428,26 @@
@else
<div
class="grid min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.55fr)_7rem] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div>Resource</div>
<div>Server</div>
<div>Status</div>
<x-table.sort-header label="Resource" asc="resource-asc" desc="resource-desc"
action="setDeploymentSort" :current="$deploymentSort" />
<x-table.sort-header label="Server" asc="server-asc" desc="server-desc"
action="setDeploymentSort" :current="$deploymentSort" />
<x-table.sort-header label="Status" asc="status-asc" desc="status-desc"
action="setDeploymentSort" :current="$deploymentSort" />
</div>
@foreach ($deploymentsPerTagPerServer as $serverName => $deployments)
@foreach ($deployments as $deployment)
<a {{ wireNavigate() }} href="{{ data_get($deployment, 'deployment_url') }}"
class="grid min-h-13 min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.55fr)_7rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
<div class="truncate font-medium text-black dark:text-fg">
{{ data_get($deployment, 'application_name') }}
</div>
<div class="truncate text-neutral-500 dark:text-fg-dim">{{ $serverName }}</div>
<div>
<x-status-badge :status="str(data_get($deployment, 'status'))->headline()"
:type="data_get($deployment, 'status') === 'in_progress' ? 'warning' : 'neutral'" />
</div>
</a>
@endforeach
@foreach ($activeDeployments as $deployment)
<a {{ wireNavigate() }} href="{{ data_get($deployment, 'deployment_url') }}"
class="grid min-h-13 min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.55fr)_7rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
<div class="truncate font-medium text-black dark:text-fg">
{{ data_get($deployment, 'application_name') }}
</div>
<div class="truncate text-neutral-500 dark:text-fg-dim">{{ data_get($deployment, 'server_name') }}</div>
<div>
<x-status-badge :status="str(data_get($deployment, 'status'))->headline()"
:type="data_get($deployment, 'status') === 'in_progress' ? 'warning' : 'neutral'" />
</div>
</a>
@endforeach
@endif
</div>
@@ -36,12 +36,16 @@
@if ($events->isNotEmpty())
<div class="overflow-x-auto">
<div class="data-table transition-opacity" wire:loading.class="opacity-50 pointer-events-none"
wire:target="search,action,source,setPage,previousPage,nextPage">
wire:target="search,action,source,setSort,setPage,previousPage,nextPage">
<div class="grid min-w-[760px] grid-cols-[14rem_minmax(0,1fr)_12rem_9rem] gap-4 border-b border-neutral-200 px-4 py-2 text-[11px] font-medium uppercase tracking-wide text-neutral-500 dark:border-white/[0.07] dark:text-fg-faint">
<span>Actor</span>
<span>Activity</span>
<span>Source</span>
<span class="text-right">Time</span>
<x-table.sort-header label="Actor" asc="actor-asc" desc="actor-desc" action="setSort"
:current="$sort" />
<x-table.sort-header label="Activity" asc="activity-asc" desc="activity-desc"
action="setSort" :current="$sort" />
<x-table.sort-header label="Source" asc="source-asc" desc="source-desc" action="setSort"
:current="$sort" />
<x-table.sort-header label="Time" asc="time-asc" desc="time-desc" first="desc"
action="setSort" :current="$sort" class="justify-self-end" />
</div>
@foreach ($events as $event)
<div wire:key="audit-event-{{ $event->id }}" x-data="{ expanded: false }"
@@ -7,13 +7,16 @@
<div class="data-table min-w-[760px]">
<div
class="data-table-header grid-cols-[minmax(13rem,1.2fr)_7rem_7rem_minmax(15rem,1.5fr)_6rem]">
<span>Email</span>
<span>Method</span>
<span>Role</span>
<x-table.sort-header label="Email" asc="email-asc" desc="email-desc" action="setSort"
:current="$sort" />
<x-table.sort-header label="Method" asc="via-asc" desc="via-desc" action="setSort"
:current="$sort" />
<x-table.sort-header label="Role" asc="role-asc" desc="role-desc" first="desc"
action="setSort" :current="$sort" />
<span>Invitation link</span>
<span class="text-right">Actions</span>
</div>
@foreach ($invitations as $invite)
@foreach ($sortedInvitations as $invite)
<div wire:key="team-invitation-{{ $invite->id }}"
class="data-table-row grid-cols-[minmax(13rem,1.2fr)_7rem_7rem_minmax(15rem,1.5fr)_6rem] border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
<div class="truncate text-[12px] font-medium text-black dark:text-fg">
@@ -1,7 +1,7 @@
<div x-data="{
search: '',
roleFilter: 'all',
sortBy: 'name_asc',
sortBy: 'name-asc',
page: 1,
perPage: 10,
members: @js($members->map(fn ($member) => [
@@ -9,6 +9,8 @@
'name' => $member->name,
'email' => $member->email,
'role' => data_get($member, 'pivot.role'),
'roleRank' => \App\Enums\Role::tryFrom((string) data_get($member, 'pivot.role'))?->rank() ?? 0,
'twoFactor' => auth()->user()?->can('manageMembers', currentTeam()) ? filled($member->two_factor_confirmed_at) : null,
])->values()),
get filteredMembers() {
const query = this.search.trim().toLowerCase();
@@ -21,13 +23,17 @@
return matchesSearch && matchesRole;
});
return filtered.sort((left, right) => {
if (this.sortBy === 'name_desc') return right.name.localeCompare(left.name);
if (this.sortBy === 'email_asc') return left.email.localeCompare(right.email);
if (this.sortBy === 'role') return left.role.localeCompare(right.role);
const [column, direction] = this.sortBy.split('-');
const order = direction === 'desc' ? -1 : 1;
const byName = (left, right) => String(left.name || '').localeCompare(String(right.name || ''));
const compare = {
name: byName,
email: (left, right) => String(left.email || '').localeCompare(String(right.email || '')),
role: (left, right) => left.roleRank - right.roleRank,
twofa: (left, right) => Number(left.twoFactor) - Number(right.twoFactor),
}[column] || byName;
return left.name.localeCompare(right.name);
});
return filtered.sort((left, right) => order * compare(left, right) || byName(left, right));
},
get lastPage() {
return Math.max(1, Math.ceil(this.filteredMembers.length / this.perPage));
@@ -85,11 +91,12 @@
</div>
<div class="w-full sm:w-40">
<x-forms.listbox id="member-sort" :wire="false" x-model="sortBy"
x-effect="page = 1" :value="'name_asc'" :options="[
['value' => 'name_asc', 'label' => 'Name A–Z'],
['value' => 'name_desc', 'label' => 'Name Z–A'],
['value' => 'email_asc', 'label' => 'Email A–Z'],
['value' => 'role', 'label' => 'Role'],
x-effect="page = 1" :value="'name-asc'" :options="[
['value' => 'name-asc', 'label' => 'Name A–Z'],
['value' => 'name-desc', 'label' => 'Name Z–A'],
['value' => 'email-asc', 'label' => 'Email A–Z'],
['value' => 'email-desc', 'label' => 'Email Z–A'],
['value' => 'role-desc', 'label' => 'Role'],
]" />
</div>
</div>
@@ -111,11 +118,11 @@
'data-table-header team-members-table-grid',
'team-members-table-grid-2fa' => auth()->user()?->can('manageMembers', currentTeam()),
])>
<span>Name</span>
<span>Email</span>
<span>Role</span>
<x-table.sort-header label="Name" asc="name-asc" desc="name-desc" then="page = 1" />
<x-table.sort-header label="Email" asc="email-asc" desc="email-desc" then="page = 1" />
<x-table.sort-header label="Role" asc="role-asc" desc="role-desc" first="desc" then="page = 1" />
@can('manageMembers', currentTeam())
<span>2FA</span>
<x-table.sort-header label="2FA" asc="twofa-asc" desc="twofa-desc" then="page = 1" />
@endcan
<span class="text-right">Actions</span>
</div>
+39
View File
@@ -872,6 +872,45 @@ test('audit log page only shows events for the current team', function () {
->assertDontSee('Private app deleted');
});
test('audit log sorts by the chosen column and ignores unknown sort values', function () {
AuditEvent::factory()->create([
'team_id' => $this->team->id,
'actor_name' => 'Zoe',
'description' => 'Older event by Zoe',
'created_at' => now()->subHour(),
]);
AuditEvent::factory()->create([
'team_id' => $this->team->id,
'actor_name' => 'Adam',
'description' => 'Newer event by Adam',
'created_at' => now(),
]);
Livewire::test(AuditLog::class)
->assertSeeInOrder(['Newer event by Adam', 'Older event by Zoe'])
->call('setSort', 'time-asc')
->assertSet('sort', 'time-asc')
->assertSeeInOrder(['Older event by Zoe', 'Newer event by Adam'])
->call('setSort', 'actor-desc')
->assertSeeInOrder(['Older event by Zoe', 'Newer event by Adam'])
->call('setSort', 'activity-asc')
->assertSeeInOrder(['Newer event by Adam', 'Older event by Zoe'])
->call('setSort', 'resource_uuid-asc')
->assertSet('sort', 'activity-asc')
->call('setSort', 'time-sideways')
->assertSet('sort', 'activity-asc');
});
test('changing the audit log sort returns to the first page', function () {
AuditEvent::factory()->count(11)->create(['team_id' => $this->team->id]);
Livewire::test(AuditLog::class)
->set('perPage', 10)
->call('setPage', 2)
->call('setSort', 'time-asc')
->assertSet('paginators.page', 1);
});
test('audit log expands encrypted model changes for team admins and owners', function () {
AuditEvent::factory()->create([
'team_id' => $this->team->id,
@@ -164,6 +164,23 @@ it('does not load deployment payload columns for the history list on any Livewir
($this->assertNoPayloadQueries)();
});
it('flips the deployment sort from the started column header', function () {
foreach (range(1, 3) as $number) {
($this->createDeployment)("header-d{$number}", "2026-10-0{$number} 10:00:00");
}
Livewire::test(Index::class, [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'application_uuid' => $this->application->uuid,
])
->assertSeeHtml('aria-label="Sort by started, sorted descending"')
->assertSeeInOrder(['header-d3', 'header-d2', 'header-d1'])
->call('setDeploymentSort', 'oldest')
->assertSeeHtml('aria-label="Sort by started, sorted ascending"')
->assertSeeInOrder(['header-d1', 'header-d2', 'header-d3']);
});
it('does not show the deployment history of another team', function () {
$otherTeam = otherTeamDeployment();
unset($otherTeam['deployment_uuid']);
+49
View File
@@ -130,3 +130,52 @@ it('paginates images and returns to the first page when the search changes', fun
->assertSee('1–5 of 9')
->assertDontSee('app-06');
});
it('sorts images by the chosen column and ignores unknown sort values', function () {
fakeRemoteProcesses([
'*docker image ls*' => Process::result(output: implode("\n", [
json_encode(['Repository' => 'beta', 'Tag' => 'latest', 'ID' => 'sha256:'.str_repeat('1', 64), 'Size' => '9MB', 'CreatedAt' => '2024-03-01 10:00:00 +0000 UTC', 'CreatedSince' => '7 months ago']),
json_encode(['Repository' => 'alpha', 'Tag' => 'latest', 'ID' => 'sha256:'.str_repeat('2', 64), 'Size' => '100MB', 'CreatedAt' => '2024-01-01 10:00:00 +0000 UTC', 'CreatedSince' => '9 months ago']),
json_encode(['Repository' => 'gamma', 'Tag' => 'latest', 'ID' => 'sha256:'.str_repeat('3', 64), 'Size' => '1.2GB', 'CreatedAt' => '2024-02-01 10:00:00 +0000 UTC', 'CreatedSince' => '8 months ago']),
])),
'*docker ps -a*' => Process::result(output: ''),
'*docker system df*' => Process::result(output: ''),
]);
Livewire::test(DockerImages::class, ['server_uuid' => $this->server->uuid])
->call('load')
->assertSeeInOrder(['beta', 'alpha', 'gamma'])
->call('setSort', 'repository_asc')
->assertSet('sort', 'repository_asc')
->assertSeeInOrder(['alpha', 'beta', 'gamma'])
->call('setSort', 'size_asc')
->assertSeeInOrder(['9MB', '100MB', '1.2GB'])
->call('setSort', 'size_desc')
->assertSeeInOrder(['1.2GB', '100MB', '9MB'])
->call('setSort', 'created_desc')
->assertSeeInOrder(['beta', 'gamma', 'alpha'])
->call('setSort', 'containers_desc')
->assertSet('sort', 'created_desc')
->assertSeeInOrder(['beta', 'gamma', 'alpha']);
});
it('returns to the first page when the sort changes', function () {
fakeRemoteProcesses([
'*docker image ls*' => Process::result(output: collect(range(1, 12))
->map(fn (int $i) => json_encode([
'Repository' => sprintf('app-%02d', $i), 'Tag' => 'latest', 'ID' => 'sha256:'.str_repeat(dechex($i), 64),
'Size' => '1MB', 'CreatedSince' => '1 day ago',
]))
->implode("\n")),
'*docker ps -a*' => Process::result(output: ''),
'*docker system df*' => Process::result(output: ''),
]);
Livewire::test(DockerImages::class, ['server_uuid' => $this->server->uuid])
->call('load')
->call('nextPage')
->call('setSort', 'repository_desc')
->assertSee('1–10 of 12')
->assertSee('app-12')
->assertDontSee('app-02');
});
+97
View File
@@ -0,0 +1,97 @@
<?php
use App\Livewire\Server\Resources;
use App\Models\Server;
function sortableManagedResource(string $name, string $project, string $environment, string $type, string $status): object
{
return new class($name, $project, $environment, $type, $status)
{
public array $environment;
public function __construct(public string $name, public string $projectName, string $environment, public string $resourceType, public string $status)
{
$this->environment = ['name' => $environment];
}
public function project(): array
{
return ['name' => $this->projectName];
}
public function type(): string
{
return $this->resourceType;
}
};
}
beforeEach(function () {
$this->component = new Resources;
$this->component->server = Mockery::mock(Server::class)->makePartial();
});
function sortedNames(Resources $component, string $nameKey = 'name'): array
{
return $component->render()->getData()['resources']->pluck($nameKey)->all();
}
it('sorts managed resources by each column in both directions with name as tie-breaker', function () {
$this->component->server->shouldReceive('definedResources')->andReturn(collect([
sortableManagedResource('web 10', 'Beta', 'production', 'application', 'running:healthy'),
sortableManagedResource('Web 2', 'alpha', 'staging', 'service', 'exited:unhealthy'),
sortableManagedResource('db', 'Beta', 'dev', 'standalone-postgresql', 'running:healthy'),
]));
expect(sortedNames($this->component))->toBe(['db', 'Web 2', 'web 10']);
$this->component->setSort('name_desc');
expect(sortedNames($this->component))->toBe(['web 10', 'Web 2', 'db']);
$this->component->setSort('project_asc');
expect(sortedNames($this->component))->toBe(['Web 2', 'db', 'web 10']);
$this->component->setSort('environment_desc');
expect(sortedNames($this->component))->toBe(['Web 2', 'web 10', 'db']);
$this->component->setSort('type_desc');
expect(sortedNames($this->component))->toBe(['db', 'Web 2', 'web 10']);
$this->component->setSort('status_desc');
expect(sortedNames($this->component))->toBe(['db', 'web 10', 'Web 2']);
});
it('sorts unmanaged containers by image and status', function () {
$this->component->activeTab = 'unmanaged';
$this->component->unmanagedContainers = [
['Names' => 'cache', 'Image' => 'redis:7', 'State' => 'running'],
['Names' => 'proxy', 'Image' => 'nginx:1', 'State' => 'exited'],
['Names' => 'app', 'Image' => 'redis:7', 'State' => 'running'],
];
$this->component->setSort('image_asc');
expect(sortedNames($this->component, 'Names'))->toBe(['proxy', 'app', 'cache']);
$this->component->setSort('status_asc');
expect(sortedNames($this->component, 'Names'))->toBe(['proxy', 'app', 'cache']);
});
it('ignores unknown sort values and resets the page on a valid sort', function () {
$this->component->setPage(3);
foreach (['bogus', 'name_sideways', 'image_asc', 'name_asc; drop'] as $invalid) {
$this->component->setSort($invalid);
}
expect($this->component->sort)->toBe('name_asc')
->and($this->component->getPage())->toBe(3);
$this->component->setSort('status_desc');
expect($this->component->sort)->toBe('status_desc')
->and($this->component->getPage())->toBe(1);
});
it('renders livewire sort headers for both tabs', function () {
$view = file_get_contents(resource_path('views/livewire/server/resources.blade.php'));
foreach (['name', 'project', 'environment', 'type', 'status', 'image'] as $column) {
expect($view)->toContain("asc=\"{$column}_asc\" desc=\"{$column}_desc\" action=\"setSort\"");
}
});
+38
View File
@@ -2,6 +2,7 @@
use App\Livewire\Tags\Show;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
@@ -167,3 +168,40 @@ it('does not let a member change tags from the connect modal', function () {
expect($application->tags()->exists())->toBeFalse();
});
it('sorts active deployments by the chosen column and ignores unknown sort values', function () {
$tag = Tag::create(['name' => 'hello', 'team_id' => $this->team->id]);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
foreach ([['zeta-app', 'alpha-server', 'queued'], ['alpha-app', 'zeta-server', 'in_progress'], ['mid-app', 'alpha-server', 'in_progress']] as [$name, $serverName, $status]) {
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'name' => $name,
]);
$application->tags()->attach($tag->id);
ApplicationDeploymentQueue::create([
'application_id' => $application->id,
'deployment_uuid' => $name,
'application_name' => $name,
'server_id' => $server->id,
'server_name' => $serverName,
'status' => $status,
]);
}
Livewire::test(Show::class, ['tagName' => 'hello'])
->assertSeeInOrder(['Active deployments', 'zeta-app', 'mid-app', 'alpha-app'])
->call('setDeploymentSort', 'resource-asc')
->assertSeeInOrder(['Active deployments', 'alpha-app', 'mid-app', 'zeta-app'])
->call('setDeploymentSort', 'server-desc')
->assertSeeInOrder(['Active deployments', 'alpha-app', 'zeta-app', 'mid-app'])
->call('setDeploymentSort', 'status-desc')
->assertSeeInOrder(['Active deployments', 'zeta-app', 'alpha-app', 'mid-app'])
->call('setDeploymentSort', 'deployment_url-asc')
->assertSet('deploymentSort', 'status-desc');
});
+29
View File
@@ -49,3 +49,32 @@ it('preserves a provisional user when revoking their invitation fails', function
$this->assertDatabaseHas('users', ['id' => $provisionalUser->id]);
$this->assertDatabaseHas('team_invitations', ['id' => $invitation->id]);
});
it('sorts pending invitations by the chosen column and ignores unknown sort values', function () {
$invite = fn (string $email, string $role, string $via) => TeamInvitation::create([
'team_id' => $this->team->id,
'uuid' => $email,
'email' => $email,
'role' => $role,
'link' => "http://example.test/invitations/{$email}",
'via' => $via,
]);
$invitations = collect([
$invite('carol@example.com', 'member', 'email'),
$invite('alice@example.com', 'owner', 'link'),
$invite('bob@example.com', 'admin', 'email'),
]);
Livewire::test(Invitations::class, ['invitations' => $invitations])
->assertSeeInOrder(['carol@example.com', 'alice@example.com', 'bob@example.com'])
->call('setSort', 'email-asc')
->assertSeeInOrder(['alice@example.com', 'bob@example.com', 'carol@example.com'])
->call('setSort', 'role-desc')
->assertSeeInOrder(['alice@example.com', 'bob@example.com', 'carol@example.com'])
->call('setSort', 'role-asc')
->assertSeeInOrder(['carol@example.com', 'bob@example.com', 'alice@example.com'])
->call('setSort', 'via-desc')
->assertSeeInOrder(['alice@example.com', 'bob@example.com', 'carol@example.com'])
->call('setSort', 'link-asc')
->assertSet('sort', 'via-desc');
});
@@ -143,3 +143,21 @@ test('the summary confirms when every member has two factor authentication', fun
->assertSee('All members have two-factor authentication enabled')
->assertDontSee('do not have two-factor authentication enabled');
});
test('the members table sorts by role rank and only sends two factor status to member managers', function () {
$owner = createTeamMember($this->team, 'owner', twoFactorEnabled: true);
$member = createTeamMember($this->team, 'member');
actAsTeamMember($owner, $this->team);
Livewire::test(Index::class)
->assertSee('Sort by role', false)
->assertSee('Sort by 2fa', false)
->assertSee('\\u0022roleRank\\u0022:3', false)
->assertSee('\\u0022twoFactor\\u0022:true', false);
actAsTeamMember($member, $this->team);
Livewire::test(Index::class)
->assertDontSee('Sort by 2fa', false)
->assertSee('\\u0022twoFactor\\u0022:null', false)
->assertDontSee('\\u0022twoFactor\\u0022:true', false);
});
+4
View File
@@ -65,6 +65,10 @@ function assertBackupSortOrders(mixed $page, string $screenshot): void
->click('Sort')
->click('Newest first')
->assertScript(visibleBackupOrderScript(), 'charlie-data,alpha-data,bravo-data')
->click('[aria-label^="Sort by target"]')
->assertScript(visibleBackupOrderScript(), 'alpha-data,bravo-data,charlie-data')
->click('[aria-label^="Sort by target"]')
->assertScript(visibleBackupOrderScript(), 'charlie-data,bravo-data,alpha-data')
->screenshot(filename: $screenshot);
}