feat(ui): standardize paginated tables and responsive dropdowns

Add reusable pagination and page-size controls, improve mobile dropdown positioning, and migrate paginated views to consistent table components.
This commit is contained in:
Andras Bacsai committed 2026-08-16 00:58:23 +02:00
1 parent 7bcdc23086
commit 0a35909ddf
32 files changed
+610 -624

No files matched your search

@@ -22,6 +22,14 @@ class Index extends Component
public int $defaultTake = 10;
public function updatedDefaultTake(): void
{
$this->defaultTake = max(1, min(100, $this->defaultTake));
$this->skip = 0;
$this->loadDeployments();
}
public bool $showNext = false;
public bool $showPrev = false;
@@ -44,6 +44,14 @@ class All extends Component
public int $perPage = 10;
public function updatedPerPage(): void
{
$this->perPage = max(1, min(100, $this->perPage));
$this->page = 1;
$this->clearEnvironmentVariableCaches();
}
public bool $is_env_sorting_enabled = false;
public bool $use_build_secrets = false;
@@ -58,6 +58,15 @@ class VolumeBackups extends Component
public int $timeout = 3600;
public int $perPage = 10;
public function updatedPerPage(): void
{
$this->perPage = max(1, min(100, $this->perPage));
$this->resetPage();
}
public bool $delete_backup_s3 = false;
public Collection $availableS3Storages;
@@ -316,7 +325,7 @@ class VolumeBackups extends Component
public function render()
{
$executions = $this->backup?->executions()->paginate(10);
$executions = $this->backup?->executions()->paginate($this->perPage);
return view('livewire.project.shared.storages.volume-backups', [
'executions' => $executions ?? collect(),
+10 -1
View File
@@ -16,6 +16,8 @@ class AdminView extends Component
public string $sort = 'name_asc';
public int $perPage = 10;
public function mount()
{
if (! isInstanceAdmin()) {
@@ -42,6 +44,13 @@ class AdminView extends Component
$this->resetPage();
}
public function updatedPerPage(): void
{
$this->perPage = max(1, min(100, $this->perPage));
$this->resetPage();
}
public function submitSearch(): void
{
if (! isInstanceAdmin()) {
@@ -103,7 +112,7 @@ class AdminView extends Component
->when($this->sort === 'email_desc', fn ($query) => $query->orderByDesc('email'))
->when($this->sort === 'name_asc', fn ($query) => $query->orderBy('name'))
->orderBy('id')
->paginate(10);
->paginate($this->perPage);
return view('livewire.team.admin-view', [
'users' => $users,
+22
View File
@@ -1939,6 +1939,28 @@ html[data-theme="custom"] textarea:disabled {
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
}
.floating-dropdown-panel {
min-width: 0;
}
/* Older menus still render inside their page container. On mobile, keep
those panels fully visible until they are migrated to the portaled
dropdown component. Portaled panels carry their own fixed coordinates. */
@media (max-width: 767px) {
.listbox-panel:not([style*="position: fixed"]) {
position: fixed !important;
top: 50% !important;
right: auto !important;
left: 50% !important;
width: max-content;
max-width: calc(100vw - 1.5rem) !important;
max-height: calc(100dvh - 1.5rem) !important;
box-sizing: border-box;
transform: translate(-50%, -50%) !important;
z-index: 9999 !important;
}
}
.listbox-option {
display: flex;
align-items: center;
@@ -0,0 +1,29 @@
@props([
'summary',
'pageSizeModel',
'storageKey',
'options' => [10, 25, 50, 100],
'previousAction' => 'page = Math.max(1, page - 1)',
'nextAction' => 'page = Math.min(totalPages, page + 1)',
'previousDisabled' => 'page === 1',
'nextDisabled' => 'page >= totalPages',
])
<footer {{ $attributes->class('flex min-h-11 items-center justify-between border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint') }}>
<div class="flex items-center gap-3">
<span class="inline-flex h-7 items-center whitespace-nowrap tabular-nums" x-text="{{ $summary }}"></span>
<x-page-size-select :model="$pageSizeModel" :storage-key="$storageKey" :options="$options" />
</div>
<div class="flex items-center gap-1">
<button type="button" x-on:click="{{ $previousAction }}" x-bind:disabled="{{ $previousDisabled }}"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Previous page">
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
</button>
<button type="button" x-on:click="{{ $nextAction }}" x-bind:disabled="{{ $nextDisabled }}"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Next page">
<x-reicon name="arrow-right" class="size-3.5" />
</button>
</div>
</footer>
@@ -25,13 +25,10 @@
const triggerRect = this.$refs.trigger.getBoundingClientRect();
const panelRect = this.$refs.panel.getBoundingClientRect();
const viewportPadding = 8;
let left = triggerRect.left;
if ((left + panelRect.width + viewportPadding) > window.innerWidth) {
left = window.innerWidth - panelRect.width - viewportPadding;
}
left = Math.max(viewportPadding, left);
const left = Math.max(
viewportPadding,
(window.innerWidth - panelRect.width) / 2,
);
let top = triggerRect.bottom + 4;
const maxTop = window.innerHeight - panelRect.height - viewportPadding;
@@ -14,7 +14,7 @@
'value' => null, // initial value when wire=false
'disabled' => false,
'tooltip' => true,
'portal' => false,
'portal' => true,
'preserveValue' => false,
])
@@ -90,19 +90,26 @@
const gap = 4;
const edge = 12;
const triggerRect = trigger.getBoundingClientRect();
const panelWidth = Math.max(triggerRect.width, panel.offsetWidth);
const panelWidth = Math.min(
Math.max(triggerRect.width, panel.offsetWidth),
window.innerWidth - (edge * 2),
);
const panelHeight = Math.min(panel.scrollHeight, 256);
const fitsBelow = window.innerHeight - triggerRect.bottom - gap >= panelHeight;
const top = fitsBelow
? triggerRect.bottom + gap
: Math.max(edge, triggerRect.top - gap - panelHeight);
const left = Math.min(
Math.max(edge, triggerRect.left),
window.innerWidth - panelWidth - edge,
);
const left = window.innerWidth < 768
? (window.innerWidth - panelWidth) / 2
: Math.min(
Math.max(edge, triggerRect.left),
window.innerWidth - panelWidth - edge,
);
panel.style.top = `${top}px`;
panel.style.left = `${left}px`;
panel.style.width = `${panelWidth}px`;
panel.style.maxWidth = `${window.innerWidth - (edge * 2)}px`;
panel.style.minWidth = `${triggerRect.width}px`;
this.positioned = true;
}
@@ -123,7 +130,7 @@
@if ($portal)
<template x-teleport="body">
<div id="{{ $panelId }}" class="listbox-panel"
style="position: fixed; z-index: 9999; visibility: hidden" x-show="open && positioned"
style="position: fixed; z-index: 9999; visibility: hidden" x-show="open"
x-cloak :style="{ visibility: positioned ? 'visible' : 'hidden' }"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-1 scale-[0.98]"
@@ -0,0 +1,75 @@
@props([
'model',
'livewire' => false,
'options' => [10, 25, 50, 100],
'storageKey' => null,
])
<label class="mb-0! flex h-7 items-center gap-1.5 text-[11px] text-neutral-500 dark:text-fg-dim"
x-data="{
selectedPageSize: @js((int) $options[0]),
customPageSize: @js((int) $options[0]),
customizingPageSize: false,
syncCustomOption(value) {
const select = this.$refs.pageSizePreset;
let customOption = select?.querySelector('[data-custom-page-size]');
if (@js(array_map('intval', $options)).includes(Number(value))) {
customOption?.remove();
} else if (select) {
if (!customOption) {
customOption = document.createElement('option');
customOption.dataset.customPageSize = '';
select.insertBefore(customOption, select.lastElementChild);
}
customOption.value = String(value);
customOption.textContent = String(value);
}
if (select) select.value = String(value);
},
applyPageSize(value) {
const pageSizeValue = Math.min(100, Math.max(1, Number(value) || 1));
this.selectedPageSize = pageSizeValue;
this.customPageSize = pageSizeValue;
this.customizingPageSize = false;
this.$nextTick(() => {
this.syncCustomOption(pageSizeValue);
});
@if ($livewire)
$wire.set(@js($model), pageSizeValue);
@else
{{ $model }} = pageSizeValue;
page = 1;
@endif
@if (filled($storageKey))
localStorage.setItem(@js($storageKey), pageSizeValue);
@endif
}
}"
x-init="
@if (filled($storageKey))
const savedPageSize = Number(localStorage.getItem(@js($storageKey)));
if (savedPageSize >= 1 && savedPageSize <= 100) applyPageSize(savedPageSize);
@endif
">
<span class="hidden sm:inline">Rows</span>
<span x-show="!customizingPageSize" class="relative inline-flex h-7 w-12 items-center">
<select x-ref="pageSizePreset" aria-label="Items per page"
x-on:change="$el.value === 'custom' ? (customizingPageSize = true, $nextTick(() => $refs.customPageSize.focus())) : applyPageSize($el.value)"
class="peer absolute inset-0 z-10 h-7! w-12! cursor-pointer opacity-0">
@foreach ($options as $option)
<option value="{{ $option }}">{{ $option }}</option>
@endforeach
<option value="custom">Custom…</option>
</select>
<span
class="pointer-events-none inline-flex h-7 w-12 items-center justify-between border-0 px-1 text-[11px]! leading-none! tabular-nums text-neutral-500 transition-colors peer-hover:text-black dark:text-fg-dim dark:peer-hover:text-fg">
<span x-text="selectedPageSize"></span>
<x-reicon name="chevron-down" class="size-3 text-neutral-400 dark:text-fg-faint" />
</span>
</span>
<input x-cloak x-show="customizingPageSize" x-ref="customPageSize" x-model.number="customPageSize"
x-on:keydown.enter.prevent="applyPageSize(customPageSize)" x-on:keydown.escape.prevent="customizingPageSize = false"
x-on:blur="if (customizingPageSize) applyPageSize(customPageSize)" type="number" min="1" max="100" inputmode="numeric"
aria-label="Custom items per page"
class="mb-0! h-7! w-14! rounded-md! border-neutral-200! bg-transparent! px-1.5! py-0! text-[11px]! tabular-nums shadow-none! focus:border-neutral-300! focus:ring-0! dark:border-white/[0.08]! dark:text-fg-dim!" />
</label>
@@ -54,15 +54,13 @@
</div>
<div class="flex items-center gap-2">
<div class="relative" @click.outside="sortOpen = false" @keydown.escape.window="sortOpen = false">
<button type="button" class="button" @click="sortOpen = !sortOpen">
<x-table.dropdown panel-class="w-48!">
<x-slot:trigger><button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
<x-reicon name="sort-direction" class="size-3.5" />
Sort
</button>
<div x-show="sortOpen" x-cloak x-transition.opacity.duration.120ms
class="listbox-panel left-auto! right-0! z-[90]! min-w-48!">
</button></x-slot:trigger>
<button type="button" class="listbox-option"
@click="sharedSort = 'alphabetical'; sortOpen = false">
@click="sharedSort = 'alphabetical'; close()">
<span>Alphabetical</span>
<svg x-show="sharedSort === 'alphabetical'" class="size-3.5 shrink-0" viewBox="0 0 24 24"
fill="none">
@@ -71,7 +69,7 @@
</svg>
</button>
<button type="button" class="listbox-option"
@click="sharedSort = 'creation'; sortOpen = false">
@click="sharedSort = 'creation'; close()">
<span>Creation order</span>
<svg x-show="sharedSort === 'creation'" class="size-3.5 shrink-0" viewBox="0 0 24 24"
fill="none">
@@ -79,8 +77,7 @@
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</div>
</x-table.dropdown>
@can('update', $resource)
<x-modal-input title="New Shared Variable">
@@ -15,74 +15,43 @@
@php
$onFirstPage = (int) $currentPage <= 1;
$onLastPage = (int) $currentPage >= (int) $lastPage;
$navButtonClass =
'flex w-10 items-center justify-center border-r border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-35 dark:border-white/[0.10] dark:text-fg-dim dark:hover:bg-white/[0.06]';
$lastNavButtonClass =
'flex w-10 items-center justify-center text-neutral-500 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-35 dark:text-fg-dim dark:hover:bg-white/[0.06]';
$buttonClass =
'flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg';
$hasLoading = filled($wireTarget);
@endphp
<div {{ $attributes->class('flex items-center justify-between gap-3 px-4 py-3') }}>
<p class="shrink-0 whitespace-nowrap text-[13px] text-neutral-500 dark:text-fg-dim">
Showing <span class="tabular-nums text-black dark:text-fg">{{ $from }}–{{ $to }}</span>
of <span class="tabular-nums text-black dark:text-fg">{{ $total }}</span>
</p>
<div class="inline-flex h-8 overflow-hidden rounded-lg border border-neutral-200 dark:border-white/[0.10]">
<button type="button" class="{{ $navButtonClass }}" aria-label="First page" title="First page"
@if (filled($firstAction)) wire:click="{{ $firstAction }}" @endif
@if ($hasLoading) wire:loading.attr="disabled" wire:target="{{ $wireTarget }}" @endif
@disabled($onFirstPage)>
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M18 6L12 12L18 18M11 6L5 12L11 18" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button" class="{{ $navButtonClass }}" aria-label="Previous page" title="Previous page"
<footer {{ $attributes->class('flex min-h-11 items-center justify-between border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint') }}>
<div class="flex items-center gap-3">
<span class="inline-flex h-7 items-center whitespace-nowrap tabular-nums">{{ $from }}–{{ $to }} of {{ $total }}</span>
@isset($pageSize)
{{ $pageSize }}
@endisset
@if ($hasLoading)
<span wire:loading.inline-flex wire:target="{{ $wireTarget }}" class="inline-flex items-center"
aria-live="polite">
<svg class="loading-indicator size-3.5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
<span class="sr-only">Loading page…</span>
</span>
@endif
</div>
<div class="flex items-center gap-1">
<button type="button" class="{{ $buttonClass }}" aria-label="Previous page"
@if (filled($previousAction)) wire:click="{{ $previousAction }}" @endif
@if ($hasLoading) wire:loading.attr="disabled" wire:target="{{ $wireTarget }}" @endif
@disabled($onFirstPage)>
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M15 6L9 12L15 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
</button>
<span
class="relative flex min-w-12 items-center justify-center border-r border-neutral-200 px-3 text-[13px] tabular-nums text-black dark:border-white/[0.10] dark:text-fg">
@if ($hasLoading)
<span wire:loading.remove wire:target="{{ $wireTarget }}">{{ $currentPage }}</span>
<span wire:loading.inline-flex wire:target="{{ $wireTarget }}" class="inline-flex items-center"
aria-live="polite">
<svg class="loading-indicator size-3.5 animate-spin"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
<span class="sr-only">Loading page…</span>
</span>
@else
{{ $currentPage }}
@endif
</span>
<button type="button" class="{{ $navButtonClass }}" aria-label="Next page" title="Next page"
<button type="button" class="{{ $buttonClass }}" aria-label="Next page"
@if (filled($nextAction)) wire:click="{{ $nextAction }}" @endif
@if ($hasLoading) wire:loading.attr="disabled" wire:target="{{ $wireTarget }}" @endif
@disabled($onLastPage)>
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M9 6L15 12L9 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</button>
<button type="button" class="{{ $lastNavButtonClass }}" aria-label="Last page" title="Last page"
@if (filled($lastAction)) wire:click="{{ $lastAction }}" @endif
@if ($hasLoading) wire:loading.attr="disabled" wire:target="{{ $wireTarget }}" @endif
@disabled($onLastPage)>
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6L12 12L6 18M13 6L19 12L13 18" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
<x-reicon name="arrow-right" class="size-3.5" />
</button>
</div>
</div>
</footer>
@@ -0,0 +1,68 @@
@props([
'panelClass' => '',
'role' => 'listbox',
'multiselectable' => false,
])
@php($panelId = 'table-dropdown-'.uniqid())
<div class="relative" x-data="{
open: false,
positioned: false,
toggle() {
this.open = !this.open;
this.positioned = false;
if (this.open) {
this.$nextTick(() => requestAnimationFrame(() => this.positionPanel()));
}
},
close() {
this.open = false;
this.positioned = false;
},
positionPanel() {
const trigger = this.$refs.trigger;
const panel = document.getElementById(@js($panelId));
if (!trigger || !panel) return;
const gap = 4;
const edge = 12;
const triggerRect = trigger.getBoundingClientRect();
const panelWidth = Math.min(panel.offsetWidth, window.innerWidth - (edge * 2));
const panelHeight = Math.min(panel.scrollHeight, window.innerHeight - (edge * 2));
const fitsBelow = window.innerHeight - triggerRect.bottom - gap >= panelHeight;
const top = fitsBelow
? triggerRect.bottom + gap
: Math.max(edge, triggerRect.top - gap - panelHeight);
const left = window.innerWidth < 768
? (window.innerWidth - panelWidth) / 2
: Math.min(
Math.max(edge, triggerRect.right - panelWidth),
window.innerWidth - panelWidth - edge,
);
panel.style.top = `${top}px`;
panel.style.left = `${left}px`;
panel.style.width = `${panelWidth}px`;
panel.style.maxWidth = `${window.innerWidth - (edge * 2)}px`;
panel.style.maxHeight = `${window.innerHeight - (edge * 2)}px`;
this.positioned = true;
}
}" @click.outside="close()" @keydown.escape.window="close()"
@resize.window="open && positionPanel()" @scroll.window="open && positionPanel()">
<div x-ref="trigger" @click="toggle()">
{{ $trigger }}
</div>
<template x-teleport="body">
<div id="{{ $panelId }}" x-show="open" x-cloak
class="listbox-panel floating-dropdown-panel z-[9999]! {{ $panelClass }}" style="position: fixed; visibility: hidden"
:style="{ visibility: positioned ? 'visible' : 'hidden' }"
x-effect="if (open) requestAnimationFrame(() => positionPanel())" role="{{ $role }}"
@if ($multiselectable) aria-multiselectable="true" @endif>
{{ $slot }}
</div>
</template>
</div>
@@ -1,24 +1,25 @@
@props(['activeCount' => 0, 'activeText' => null, 'resetAction', 'resetLabel' => 'Reset filters'])
<div class="table-filter relative" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" aria-haspopup="listbox"
:aria-expanded="open" @if ($activeText) title="{{ $activeText }}" @endif
@class(['button max-w-80 min-w-0', 'button-highlighted' => $activeCount > 0])>
<x-reicon name="filter" class="size-3.5 shrink-0" />
<span class="truncate">Filter</span>
@if ($activeCount > 0)
<span class="shrink-0 rounded-full bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 dark:bg-white/[0.07] dark:text-fg-dim">{{ $activeCount }}</span>
@endif
</button>
<div class="listbox-panel left-auto! right-0! z-[90]! min-w-44! overflow-hidden! p-0!" x-show="open"
x-cloak role="listbox" aria-multiselectable="true">
<div class="table-filter">
<x-table.dropdown panel-class="w-44! overflow-hidden! p-0!" :multiselectable="true">
<x-slot:trigger>
<button type="button" aria-haspopup="listbox" :aria-expanded="open"
@if ($activeText) title="{{ $activeText }}" @endif
@class(['button max-w-80 min-w-0', 'button-highlighted' => $activeCount > 0])>
<x-reicon name="filter" class="size-3.5 shrink-0" />
<span class="truncate">Filter</span>
@if ($activeCount > 0)
<span class="shrink-0 rounded-full bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 dark:bg-white/[0.07] dark:text-fg-dim">{{ $activeCount }}</span>
@endif
</button>
</x-slot:trigger>
<div class="max-h-80 overflow-y-auto p-1">{{ $slot }}</div>
<div class="border-t border-neutral-200 bg-white p-1 dark:border-white/10 dark:bg-raised">
<button type="button" class="listbox-option text-neutral-500 dark:text-fg-dim"
wire:click="{{ $resetAction }}" @click="open = false" @disabled($activeCount === 0)>
wire:click="{{ $resetAction }}" @click="close()" @disabled($activeCount === 0)>
<span>{{ $resetLabel }}</span>
<x-reicon name="x" class="size-3.5" />
</button>
</div>
</div>
</x-table.dropdown>
</div>
@@ -1,10 +1,11 @@
<div class="table-sort relative" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" @click.outside="open = false"
aria-haspopup="listbox" :aria-expanded="open">
<x-reicon name="sort-direction" class="size-3.5" />
Sort
</button>
<div class="listbox-panel left-auto! right-0! z-[90]! min-w-44!" x-show="open" x-cloak role="listbox">
<div class="table-sort">
<x-table.dropdown panel-class="w-44!">
<x-slot:trigger>
<button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
<x-reicon name="sort-direction" class="size-3.5" />
Sort
</button>
</x-slot:trigger>
{{ $slot }}
</div>
</x-table.dropdown>
</div>
@@ -111,20 +111,18 @@
</div>
<div class="flex items-center gap-2">
<div class="relative" x-on:click.outside="filterOpen = false">
<button type="button" class="button" x-on:click="filterOpen = !filterOpen; sortOpen = false">
<x-table.dropdown panel-class="w-48!">
<x-slot:trigger><button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" />
</svg>
Filter
</button>
<div x-cloak x-show="filterOpen" x-transition.origin.top.right
class="absolute top-9 right-0 z-50 min-w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
</button></x-slot:trigger>
<template x-for="option in filterOptions" :key="option.value">
<button type="button"
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
x-on:click="typeFilter = option.value; filterOpen = false">
x-on:click="typeFilter = option.value; close()">
<span class="flex-1" x-text="option.label"></span>
<svg x-show="typeFilter === option.value" class="size-3.5 text-warning"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
@@ -133,23 +131,20 @@
</svg>
</button>
</template>
</div>
</div>
</x-table.dropdown>
<div class="relative" x-on:click.outside="sortOpen = false">
<button type="button" class="button" x-on:click="sortOpen = !sortOpen; filterOpen = false">
<x-table.dropdown panel-class="w-48!">
<x-slot:trigger><button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M8 5v14m0 0-3-3m3 3 3-3M16 19V5m0 0-3 3m3-3 3 3" stroke="currentColor"
stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
Sort
</button>
<div x-cloak x-show="sortOpen" x-transition.origin.top.right
class="absolute top-9 right-0 z-50 min-w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
</button></x-slot:trigger>
<template x-for="option in sortOptions" :key="option.value">
<button type="button"
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
x-on:click="sortBy = option.value; sortOpen = false">
x-on:click="sortBy = option.value; close()">
<span class="flex-1" x-text="option.label"></span>
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
@@ -158,8 +153,7 @@
</svg>
</button>
</template>
</div>
</div>
</x-table.dropdown>
</div>
</div>
@@ -253,7 +253,13 @@
:current-page="$currentPage" :last-page="$lastPage"
wire-target="goToPage,previousPage,nextPage" first-action="goToPage(1)"
previous-action="previousPage" next-action="nextPage"
last-action="goToPage({{ $lastPage }})" />
last-action="goToPage({{ $lastPage }})">
@unless ($embedded)
<x-slot:pageSize>
<x-page-size-select model="defaultTake" livewire storage-key="coolify.page-size.deployments" />
</x-slot:pageSize>
@endunless
</x-table-pagination>
</div>
@else
<x-empty size="sm" title="No deployments found"
@@ -49,22 +49,22 @@
</div>
<div class="flex items-center gap-2">
<div class="relative" x-on:click.outside="sortOpen = false">
<button type="button" class="button" x-on:click="sortOpen = !sortOpen">
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none"
aria-hidden="true">
<path d="M8 5v14m0 0-3-3m3 3 3-3M16 19V5m0 0-3 3m3-3 3 3"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
Sort
</button>
<div x-cloak x-show="sortOpen" x-transition.origin.top.right
class="absolute top-9 right-0 z-50 w-52 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
<x-table.dropdown panel-class="w-52!">
<x-slot:trigger>
<button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none"
aria-hidden="true">
<path d="M8 5v14m0 0-3-3m3 3 3-3M16 19V5m0 0-3 3m3-3 3 3"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
Sort
</button>
</x-slot:trigger>
<template x-for="option in sortOptions" :key="option.value">
<button type="button"
class="flex h-9 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
x-on:click="sortBy = option.value; sortOpen = false; page = 1">
x-on:click="sortBy = option.value; close(); page = 1">
<span class="flex-1" x-text="option.label"></span>
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
@@ -73,8 +73,7 @@
</svg>
</button>
</template>
</div>
</div>
</x-table.dropdown>
<div
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.06]">
@@ -152,31 +151,9 @@
</article>
</template>
</div>
<footer x-show="totalPages > 1"
class="mt-3 flex min-h-11 items-center justify-between rounded-xl border border-neutral-200 bg-white px-4 text-[11px] text-neutral-500 shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
<span x-text="`${rangeStart}-${rangeEnd} of ${filteredProjects.length}`"></span>
<div class="flex items-center gap-1">
<button type="button" x-on:click="previousPage" :disabled="page === 1"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Previous page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none"
aria-hidden="true">
<path d="m15 5-7 7 7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button" x-on:click="nextPage" :disabled="page >= totalPages"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Next page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none"
aria-hidden="true">
<path d="m9 5 7 7-7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</footer>
<x-client-pagination x-show="filteredProjects.length > 0" class="mt-3 rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]"
summary="`${rangeStart}-${rangeEnd} of ${filteredProjects.length}`" page-size-model="pageSize"
storage-key="coolify.page-size.projects" :options="[12, 24, 48, 96]" />
</div>
<div x-show="viewMode === 'table'"
@@ -229,31 +206,9 @@
</div>
</div>
</template>
<footer x-show="totalPages > 1"
class="flex min-h-11 items-center justify-between border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint">
<span x-text="`${rangeStart}-${rangeEnd} of ${filteredProjects.length}`"></span>
<div class="flex items-center gap-1">
<button type="button" x-on:click="previousPage" :disabled="page === 1"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Previous page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none"
aria-hidden="true">
<path d="m15 5-7 7 7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button" x-on:click="nextPage" :disabled="page >= totalPages"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Next page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none"
aria-hidden="true">
<path d="m9 5 7 7-7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</footer>
<x-client-pagination x-show="filteredProjects.length > 0"
summary="`${rangeStart}-${rangeEnd} of ${filteredProjects.length}`" page-size-model="pageSize"
storage-key="coolify.page-size.projects" :options="[12, 24, 48, 96]" />
</div>
<div x-show="filteredProjects.length === 0"
@@ -20,17 +20,12 @@
</div>
<div class="flex shrink-0 items-center gap-2">
<div class="relative" @click.outside="filterOpen = false">
<button type="button" class="button" @click="filterOpen = !filterOpen"
aria-haspopup="listbox" aria-controls="resource-type-filter-options"
:aria-expanded="filterOpen">
<x-table.dropdown panel-class="w-48!">
<x-slot:trigger><button type="button" class="button"
aria-haspopup="listbox" :aria-expanded="open">
<x-reicon name="filter" class="size-3.5" />
Filter
</button>
<div id="resource-type-filter-options" x-show="filterOpen" x-cloak
x-transition.opacity.duration.120ms role="listbox" aria-label="Resource type"
@keydown.escape.stop="filterOpen = false; $el.previousElementSibling.focus()"
class="listbox-panel left-auto! right-0! z-[90]! min-w-48!">
</button></x-slot:trigger>
<div
class="px-2 py-1 text-[10px] font-semibold tracking-wide text-neutral-400 uppercase dark:text-fg-faint">
Resource type
@@ -38,14 +33,13 @@
<template x-for="option in resourceTypeOptions" :key="option.value">
<button type="button" class="listbox-option" role="option"
:aria-selected="resourceType === option.value"
@click="resourceType = option.value; filterOpen = false">
@click="resourceType = option.value; close()">
<span x-text="option.label"></span>
<x-reicon name="check-circle" class="size-3.5 text-accent"
x-show="resourceType === option.value" />
</button>
</template>
</div>
</div>
</x-table.dropdown>
<div class="relative w-48" @click.outside="closeCategoryFilter()">
<button type="button" class="listbox-trigger"
@@ -67,10 +67,12 @@
</div>
<div class="flex items-center gap-2">
<div class="relative" x-on:click.outside="filterOpen = false">
<button type="button" class="button max-w-64"
:class="activeFilterCount > 0 && 'button-highlighted'"
x-on:click="filterOpen = !filterOpen" :title="activeFilterCount > 0 ? filterButtonText : 'Filter'">
<x-table.dropdown panel-class="w-64! overflow-hidden! p-0!" :multiselectable="true">
<x-slot:trigger>
<button type="button" class="button max-w-64"
:class="activeFilterCount > 0 && 'button-highlighted'"
:title="activeFilterCount > 0 ? filterButtonText : 'Filter'"
aria-haspopup="listbox" :aria-expanded="open">
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" />
@@ -79,9 +81,8 @@
<span x-show="activeFilterCount > 0"
class="shrink-0 rounded-full bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 dark:bg-white/[0.07] dark:text-fg-dim"
x-text="activeFilterCount"></span>
</button>
<div x-cloak x-show="filterOpen" x-transition.origin.top.right
class="absolute top-9 right-0 z-50 flex w-64 flex-col overflow-hidden rounded-lg border border-neutral-200 bg-white shadow-modal dark:border-white/[0.1] dark:bg-raised">
</button>
</x-slot:trigger>
<div class="max-h-80 overflow-y-auto p-1">
<template x-for="group in filterGroups" :key="group.key">
<div x-show="group.options.length > 0">
@@ -113,24 +114,23 @@
Clear filters
</button>
</div>
</div>
</div>
</x-table.dropdown>
<div class="relative" x-on:click.outside="sortOpen = false">
<button type="button" class="button" x-on:click="sortOpen = !sortOpen">
<x-table.dropdown panel-class="w-48!">
<x-slot:trigger>
<button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M8 5v14m0 0-3-3m3 3 3-3M16 19V5m0 0-3 3m3-3 3 3"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
Sort
</button>
<div x-cloak x-show="sortOpen" x-transition.origin.top.right
class="absolute top-9 right-0 z-50 w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
</button>
</x-slot:trigger>
<template x-for="option in sortOptions" :key="option.value">
<button type="button"
class="flex h-9 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
x-on:click="sortBy = option.value; sortOpen = false; page = 1">
x-on:click="sortBy = option.value; close(); page = 1">
<span class="flex-1" x-text="option.label"></span>
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
@@ -139,8 +139,7 @@
</svg>
</button>
</template>
</div>
</div>
</x-table.dropdown>
<div
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.06]">
@@ -263,30 +262,9 @@
Try a different search or filter.
</p>
</div>
<footer x-show="totalPages > 1"
class="flex min-h-11 items-center justify-between border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint">
<span
x-text="filteredResources.length === 0 ? '0 resources' : `${rangeStart}-${rangeEnd} of ${filteredResources.length}`"></span>
<div class="flex items-center gap-1">
<button type="button" x-on:click="previousPage" :disabled="page === 1"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Previous page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m15 5-7 7 7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button" x-on:click="nextPage" :disabled="page >= totalPages"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Next page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m9 5 7 7-7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</footer>
<x-client-pagination x-show="filteredResources.length > 0"
summary="filteredResources.length === 0 ? '0 resources' : `${rangeStart}-${rangeEnd} of ${filteredResources.length}`"
page-size-model="pageSize" storage-key="coolify.page-size.environment-resources" />
</div>
<div x-cloak x-show="viewMode === 'grid'">
@@ -351,32 +329,10 @@
Try a different search or filter.
</p>
</div>
<footer x-show="totalPages > 1"
class="mt-3 flex min-h-11 items-center justify-between rounded-xl border border-neutral-200 bg-white px-4 text-[11px] text-neutral-500 shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
<span
x-text="filteredResources.length === 0 ? '0 resources' : `${rangeStart}-${rangeEnd} of ${filteredResources.length}`"></span>
<div class="flex items-center gap-1">
<button type="button" x-on:click="previousPage" :disabled="page === 1"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Previous page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none"
aria-hidden="true">
<path d="m15 5-7 7 7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button" x-on:click="nextPage" :disabled="page >= totalPages"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Next page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none"
aria-hidden="true">
<path d="m9 5 7 7-7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</footer>
<x-client-pagination x-show="filteredResources.length > 0"
class="mt-3 rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]"
summary="filteredResources.length === 0 ? '0 resources' : `${rangeStart}-${rangeEnd} of ${filteredResources.length}`"
page-size-model="pageSize" storage-key="coolify.page-size.environment-resources" />
</div>
@endif
</div>
@@ -143,20 +143,18 @@
</div>
<div class="flex items-center gap-2">
<div class="relative" x-on:click.outside="filterOpen = false">
<button type="button" class="button" x-on:click="filterOpen = !filterOpen; sortOpen = false">
<x-table.dropdown panel-class="w-48!">
<x-slot:trigger><button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" />
</svg>
Filter
</button>
<div x-cloak x-show="filterOpen" x-transition.origin.top.right
class="absolute top-9 right-0 z-50 min-w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
</button></x-slot:trigger>
<template x-for="option in filterOptions" :key="option.value">
<button type="button"
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
x-on:click="typeFilter = option.value; filterOpen = false">
x-on:click="typeFilter = option.value; close()">
<span class="flex-1" x-text="option.label"></span>
<svg x-show="typeFilter === option.value" class="size-3.5 text-warning"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
@@ -165,23 +163,20 @@
</svg>
</button>
</template>
</div>
</div>
</x-table.dropdown>
<div class="relative" x-on:click.outside="sortOpen = false">
<button type="button" class="button" x-on:click="sortOpen = !sortOpen; filterOpen = false">
<x-table.dropdown panel-class="w-48!">
<x-slot:trigger><button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M8 5v14m0 0-3-3m3 3 3-3M16 19V5m0 0-3 3m3-3 3 3" stroke="currentColor"
stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
Sort
</button>
<div x-cloak x-show="sortOpen" x-transition.origin.top.right
class="absolute top-9 right-0 z-50 min-w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
</button></x-slot:trigger>
<template x-for="option in sortOptions" :key="option.value">
<button type="button"
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
x-on:click="sortBy = option.value; sortOpen = false">
x-on:click="sortBy = option.value; close()">
<span class="flex-1" x-text="option.label"></span>
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
@@ -190,8 +185,7 @@
</svg>
</button>
</template>
</div>
</div>
</x-table.dropdown>
</div>
</div>
@@ -233,7 +233,11 @@
first-action="setEnvironmentVariablePage(1)"
previous-action="previousEnvironmentVariablePage"
next-action="nextEnvironmentVariablePage"
last-action="setEnvironmentVariablePage({{ $lastPage }})" />
last-action="setEnvironmentVariablePage({{ $lastPage }})">
<x-slot:pageSize>
<x-page-size-select model="perPage" livewire storage-key="coolify.page-size.environment-variables" />
</x-slot:pageSize>
</x-table-pagination>
</div>
@else
<div class="relative min-h-40">
@@ -405,24 +405,16 @@
d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" />
</svg>
</button>
<div x-data="{ filterOpen: false }" class="relative order-4 shrink-0">
<button x-on:click="filterOpen = !filterOpen" title="Filter Log Levels"
<x-table.dropdown panel-class="runtime-log-menu min-w-40!">
<x-slot:trigger><button type="button" title="Filter Log Levels"
:class="Object.values(logFilters).some(v => !v) ? 'runtime-log-icon-button-active' : ''"
class="runtime-log-icon-button">
class="runtime-log-icon-button" aria-haspopup="listbox" :aria-expanded="open">
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
</svg>
</button>
<div x-show="filterOpen" x-on:click.away="filterOpen = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="runtime-log-menu listbox-panel left-auto! right-0! z-[90]! min-w-40!">
</button></x-slot:trigger>
<div>
<button type="button" class="listbox-option" x-on:click="toggleLogFilter('error')">
<span class="w-2.5 h-2.5 rounded-full bg-red-500"></span>
@@ -445,8 +437,7 @@
<span x-show="logFilters.info">✓</span>
</button>
</div>
</div>
</div>
</x-table.dropdown>
<button title="Follow Logs" :class="alwaysScroll ? 'runtime-log-icon-button-active' : ''"
x-on:click="toggleScroll"
class="runtime-log-icon-button order-2">
@@ -133,30 +133,14 @@
@endif
</div>
@endforeach
<footer
class="flex min-h-11 items-center justify-between border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint">
<span>{{ $executionCount }} {{ Str::plural('execution', $executionCount) }}</span>
<div class="flex items-center gap-1">
<button type="button" wire:click="previousPage" @disabled($currentPage === 1)
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06]"
aria-label="Previous page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none">
<path d="m15 5-7 7 7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<span class="min-w-14 text-center tabular-nums">{{ $currentPage }} / {{ $lastPage }}</span>
<button type="button" wire:click="nextPage" @disabled($currentPage === $lastPage)
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06]"
aria-label="Next page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none">
<path d="m9 5 7 7-7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</footer>
<x-table-pagination :from="$executions->firstItem() ?? 0" :to="$executions->lastItem() ?? 0"
:total="$executionCount" :current-page="$currentPage" :last-page="$lastPage"
wire-target="previousPage,nextPage" previous-action="previousPage" next-action="nextPage">
<x-slot:pageSize>
<x-page-size-select model="perPage" livewire
storage-key="coolify.page-size.volume-backup-executions" />
</x-slot:pageSize>
</x-table-pagination>
</div>
@else
<x-empty size="sm" title="No executions"
+13 -58
View File
@@ -69,8 +69,9 @@
</div>
<div class="flex items-center gap-2">
<div class="relative" x-on:click.outside="sortOpen = false">
<button type="button" class="button" x-on:click="sortOpen = !sortOpen">
<x-table.dropdown panel-class="w-48!">
<x-slot:trigger>
<button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none"
aria-hidden="true">
<path d="M8 5v14m0 0-3-3m3 3 3-3M16 19V5m0 0-3 3m3-3 3 3"
@@ -78,13 +79,12 @@
stroke-linejoin="round" />
</svg>
Sort
</button>
<div x-cloak x-show="sortOpen" x-transition.origin.top.right
class="absolute top-9 right-0 z-50 w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
</button>
</x-slot:trigger>
<template x-for="option in sortOptions" :key="option.value">
<button type="button"
class="flex h-9 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
x-on:click="sortBy = option.value; sortOpen = false; page = 1">
x-on:click="sortBy = option.value; close(); page = 1">
<span class="flex-1" x-text="option.label"></span>
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
@@ -93,8 +93,7 @@
</svg>
</button>
</template>
</div>
</div>
</x-table.dropdown>
<div
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.06]">
@@ -166,31 +165,9 @@
</article>
</template>
</div>
<footer x-show="totalPages > 1"
class="mt-3 flex min-h-11 items-center justify-between rounded-xl border border-neutral-200 bg-white px-4 text-[11px] text-neutral-500 shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
<span x-text="`${rangeStart}-${rangeEnd} of ${filteredEnvironments.length}`"></span>
<div class="flex items-center gap-1">
<button type="button" x-on:click="previousPage" :disabled="page === 1"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Previous page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none"
aria-hidden="true">
<path d="m15 5-7 7 7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button" x-on:click="nextPage" :disabled="page >= totalPages"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Next page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none"
aria-hidden="true">
<path d="m9 5 7 7-7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</footer>
<x-client-pagination x-show="filteredEnvironments.length > 0" class="mt-3 rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]"
summary="`${rangeStart}-${rangeEnd} of ${filteredEnvironments.length}`" page-size-model="pageSize"
storage-key="coolify.page-size.project-environments" :options="[12, 24, 48, 96]" />
</div>
<div x-show="viewMode === 'table'"
@@ -239,31 +216,9 @@
</div>
</div>
</template>
<footer x-show="totalPages > 1"
class="flex min-h-11 items-center justify-between border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint">
<span x-text="`${rangeStart}-${rangeEnd} of ${filteredEnvironments.length}`"></span>
<div class="flex items-center gap-1">
<button type="button" x-on:click="previousPage" :disabled="page === 1"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Previous page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none"
aria-hidden="true">
<path d="m15 5-7 7 7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button" x-on:click="nextPage" :disabled="page >= totalPages"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Next page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none"
aria-hidden="true">
<path d="m9 5 7 7-7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</footer>
<x-client-pagination x-show="filteredEnvironments.length > 0"
summary="`${rangeStart}-${rangeEnd} of ${filteredEnvironments.length}`" page-size-model="pageSize"
storage-key="coolify.page-size.project-environments" :options="[12, 24, 48, 96]" />
</div>
<div x-show="filteredEnvironments.length === 0"
@@ -253,53 +253,11 @@
description="Try a different description or permission." />
</div>
<div x-cloak x-show="filteredTokens.length > 0"
class="flex min-h-14 items-center justify-between gap-3 border-t border-neutral-200 px-4 py-3 dark:border-white/[0.08]">
<p class="text-[13px] text-neutral-500 dark:text-fg-dim">
Showing
<span class="tabular-nums text-black dark:text-fg"
x-text="`${firstVisibleRow}–${lastVisibleRow}`"></span>
of
<span class="tabular-nums text-black dark:text-fg"
x-text="filteredTokens.length"></span>
</p>
<div
class="inline-flex h-8 overflow-hidden rounded-lg border border-neutral-200 dark:border-white/[0.10]">
<button type="button"
class="flex w-10 items-center justify-center border-r border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-35 dark:border-white/[0.10] dark:text-fg-dim dark:hover:bg-white/[0.06]"
aria-label="First page" title="First page" x-on:click="goToPage(1)"
x-bind:disabled="page === 1">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M18 6L12 12L18 18M11 6L5 12L11 18" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button"
class="flex w-10 items-center justify-center border-r border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-35 dark:border-white/[0.10] dark:text-fg-dim dark:hover:bg-white/[0.06]"
aria-label="Previous page" title="Previous page"
x-on:click="goToPage(page - 1)" x-bind:disabled="page === 1">
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
</button>
<span
class="flex min-w-10 items-center justify-center border-r border-neutral-200 px-3 text-[12px] font-medium tabular-nums text-black dark:border-white/[0.10] dark:text-fg"
x-text="page"></span>
<button type="button"
class="flex w-10 items-center justify-center border-r border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-35 dark:border-white/[0.10] dark:text-fg-dim dark:hover:bg-white/[0.06]"
aria-label="Next page" title="Next page" x-on:click="goToPage(page + 1)"
x-bind:disabled="page === lastPage">
<x-reicon name="arrow-right" class="size-3.5" />
</button>
<button type="button"
class="flex w-10 items-center justify-center text-neutral-500 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-35 dark:text-fg-dim dark:hover:bg-white/[0.06]"
aria-label="Last page" title="Last page" x-on:click="goToPage(lastPage)"
x-bind:disabled="page === lastPage">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6L12 12L6 18M13 6L19 12L13 18" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</div>
<x-client-pagination x-cloak x-show="filteredTokens.length > 0"
summary="`${firstVisibleRow}-${lastVisibleRow} of ${filteredTokens.length}`"
page-size-model="perPage" storage-key="coolify.page-size.api-tokens"
previous-action="goToPage(page - 1)" next-action="goToPage(page + 1)"
next-disabled="page >= lastPage" />
@endif
</div>
</x-application.settings-section>
@@ -56,14 +56,11 @@
</div>
<div class="flex items-center gap-2">
<div class="relative" @click.outside="filterOpen = false"
@keydown.escape.window="filterOpen = false">
<button type="button" class="button" @click="filterOpen = !filterOpen">
<x-table.dropdown panel-class="w-52!">
<x-slot:trigger><button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
<x-reicon name="filter" class="size-3.5" />
Filter
</button>
<div x-show="filterOpen" x-cloak x-transition.opacity.duration.120ms
class="listbox-panel left-auto! right-0! z-[90]! min-w-52!">
</button></x-slot:trigger>
<div
class="px-2 py-1 text-[10px] font-semibold tracking-wide text-neutral-400 uppercase dark:text-fg-faint">
Type
@@ -71,7 +68,7 @@
@foreach (['all' => 'All types', 'backup' => 'Backups', 'task' => 'Tasks', 'cleanup' => 'Docker cleanup'] as $value => $label)
<button type="button" class="listbox-option"
wire:click="$set('filterType', '{{ $value }}')"
@click="filterOpen = false">
@click="close()">
<span>{{ $label }}</span>
@if ($filterType === $value)
<span class="text-accent">✓</span>
@@ -85,36 +82,31 @@
@foreach (['last_24h' => 'Last 24 hours', 'last_7d' => 'Last 7 days', 'last_30d' => 'Last 30 days', 'all' => 'All time'] as $value => $label)
<button type="button" class="listbox-option"
wire:click="$set('filterDate', '{{ $value }}')"
@click="filterOpen = false">
@click="close()">
<span>{{ $label }}</span>
@if ($filterDate === $value)
<span class="text-accent">✓</span>
@endif
</button>
@endforeach
</div>
</div>
</x-table.dropdown>
<div class="relative" @click.outside="sortOpen = false"
@keydown.escape.window="sortOpen = false">
<button type="button" class="button" @click="sortOpen = !sortOpen">
<x-table.dropdown panel-class="w-44!">
<x-slot:trigger><button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
<x-reicon name="sort-direction" class="size-3.5" />
Sort
</button>
<div x-show="sortOpen" x-cloak x-transition.opacity.duration.120ms
class="listbox-panel left-auto! right-0! z-[90]! min-w-44!">
</button></x-slot:trigger>
@foreach (['newest' => 'Newest first', 'oldest' => 'Oldest first'] as $value => $label)
<button type="button" class="listbox-option"
wire:click="$set('sortOrder', '{{ $value }}')"
@click="sortOpen = false">
@click="close()">
<span>{{ $label }}</span>
@if ($sortOrder === $value)
<span class="text-accent">✓</span>
@endif
</button>
@endforeach
</div>
</div>
</x-table.dropdown>
</div>
</div>
</div>
@@ -191,26 +183,11 @@
</div>
@endforeach
</div>
<div
class="flex min-h-12 items-center justify-between gap-3 border-t border-neutral-200 px-4 dark:border-white/[0.08]">
<span class="whitespace-nowrap text-[11px] text-neutral-500 dark:text-fg-faint">
Showing <span x-text="`${firstVisibleRow}–${lastVisibleRow}`"></span> of
{{ $executions->count() }}
</span>
<div class="flex items-center gap-2">
<span class="text-[11px] text-neutral-500 dark:text-fg-dim">
Page <span x-text="page"></span> of <span x-text="lastPage"></span>
</span>
<button type="button" class="button size-8! px-0!" x-on:click="goToPage(page - 1)"
x-bind:disabled="page === 1" aria-label="Previous page">
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
</button>
<button type="button" class="button size-8! px-0!" x-on:click="goToPage(page + 1)"
x-bind:disabled="page === lastPage" aria-label="Next page">
<x-reicon name="arrow-right" class="size-3.5" />
</button>
</div>
</div>
<x-client-pagination
summary="`${firstVisibleRow}-${lastVisibleRow} of ${total}`"
page-size-model="perPage" storage-key="coolify.page-size.scheduled-jobs"
previous-action="goToPage(page - 1)" next-action="goToPage(page + 1)"
next-disabled="page >= lastPage" />
</div>
@endif
</div>
@@ -260,26 +237,11 @@
</div>
@endforeach
</div>
<div
class="flex min-h-12 items-center justify-between gap-3 border-t border-neutral-200 px-4 dark:border-white/[0.08]">
<span class="whitespace-nowrap text-[11px] text-neutral-500 dark:text-fg-faint">
Showing <span x-text="`${firstVisibleRow}–${lastVisibleRow}`"></span> of
{{ $managerRuns->count() }}
</span>
<div class="flex items-center gap-2">
<span class="text-[11px] text-neutral-500 dark:text-fg-dim">
Page <span x-text="page"></span> of <span x-text="lastPage"></span>
</span>
<button type="button" class="button size-8! px-0!" x-on:click="goToPage(page - 1)"
x-bind:disabled="page === 1" aria-label="Previous page">
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
</button>
<button type="button" class="button size-8! px-0!" x-on:click="goToPage(page + 1)"
x-bind:disabled="page === lastPage" aria-label="Next page">
<x-reicon name="arrow-right" class="size-3.5" />
</button>
</div>
</div>
<x-client-pagination
summary="`${firstVisibleRow}-${lastVisibleRow} of ${total}`"
page-size-model="perPage" storage-key="coolify.page-size.scheduled-jobs"
previous-action="goToPage(page - 1)" next-action="goToPage(page + 1)"
next-disabled="page >= lastPage" />
</div>
@endif
</div>
@@ -337,27 +299,12 @@
</div>
@endforeach
</div>
<div
class="flex min-h-12 items-center justify-between gap-3 border-t border-neutral-200 px-4 dark:border-white/[0.08]">
<span class="whitespace-nowrap text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $skipTotalCount }} {{ Str::plural('skipped job', $skipTotalCount) }}
</span>
@if ($skipTotalCount > $skipDefaultTake)
<div class="flex items-center gap-2">
<span class="text-[11px] text-neutral-500 dark:text-fg-dim">
Page {{ $skipCurrentPage }} of {{ ceil($skipTotalCount / $skipDefaultTake) }}
</span>
<button type="button" class="button size-8! px-0!" wire:click="skipPreviousPage"
@disabled(!$showSkipPrev) aria-label="Previous page">
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
</button>
<button type="button" class="button size-8! px-0!" wire:click="skipNextPage"
@disabled(!$showSkipNext) aria-label="Next page">
<x-reicon name="arrow-right" class="size-3.5" />
</button>
</div>
@endif
</div>
<x-table-pagination :from="$skipTotalCount === 0 ? 0 : $skipPage + 1"
:to="min($skipPage + $skipLogs->count(), $skipTotalCount)" :total="$skipTotalCount"
:current-page="$skipCurrentPage"
:last-page="max(1, (int) ceil($skipTotalCount / $skipDefaultTake))"
wire-target="skipPreviousPage,skipNextPage" previous-action="skipPreviousPage"
next-action="skipNextPage" />
@endif
</div>
</x-application.settings-section>
+13 -54
View File
@@ -42,8 +42,9 @@
</div>
<div class="flex items-center gap-2">
<div class="relative" x-on:click.outside="sortOpen = false">
<button type="button" class="button" x-on:click="sortOpen = !sortOpen">
<x-table.dropdown panel-class="w-52!">
<x-slot:trigger>
<button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none"
aria-hidden="true">
<path d="M8 5v14m0 0-3-3m3 3 3-3M16 19V5m0 0-3 3m3-3 3 3"
@@ -51,13 +52,12 @@
stroke-linejoin="round" />
</svg>
Sort
</button>
<div x-cloak x-show="sortOpen" x-transition.origin.top.right
class="absolute top-9 right-0 z-50 w-52 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
</button>
</x-slot:trigger>
<template x-for="option in sortOptions" :key="option.value">
<button type="button"
class="flex h-9 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
x-on:click="sortBy = option.value; sortOpen = false; page = 1">
x-on:click="sortBy = option.value; close(); page = 1">
<span class="flex-1" x-text="option.label"></span>
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
@@ -66,8 +66,7 @@
</svg>
</button>
</template>
</div>
</div>
</x-table.dropdown>
<div
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
@@ -122,29 +121,9 @@
</article>
</template>
</div>
<footer x-show="totalPages > 1"
class="mt-3 flex min-h-11 items-center justify-between rounded-xl border border-neutral-200 bg-white px-4 text-[11px] text-neutral-500 shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
<span x-text="`${rangeStart}-${rangeEnd} of ${filteredTags.length}`"></span>
<div class="flex items-center gap-1">
<button type="button" x-on:click="previousPage" :disabled="page === 1"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Previous page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m15 5-7 7 7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button" x-on:click="nextPage" :disabled="page >= totalPages"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Next page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m9 5 7 7-7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</footer>
<x-client-pagination x-show="filteredTags.length > 0" class="mt-3 rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]"
summary="`${rangeStart}-${rangeEnd} of ${filteredTags.length}`" page-size-model="pageSize"
storage-key="coolify.page-size.tags" :options="[12, 24, 48, 96]" />
</div>
<div x-show="viewMode === 'table'"
@@ -176,29 +155,9 @@
x-text="tag.servicesCount"></div>
</a>
</template>
<footer x-show="totalPages > 1"
class="flex min-h-11 items-center justify-between border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint">
<span x-text="`${rangeStart}-${rangeEnd} of ${filteredTags.length}`"></span>
<div class="flex items-center gap-1">
<button type="button" x-on:click="previousPage" :disabled="page === 1"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Previous page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m15 5-7 7 7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button" x-on:click="nextPage" :disabled="page >= totalPages"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Next page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m9 5 7 7-7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</footer>
<x-client-pagination x-show="filteredTags.length > 0"
summary="`${rangeStart}-${rangeEnd} of ${filteredTags.length}`" page-size-model="pageSize"
storage-key="coolify.page-size.tags" :options="[12, 24, 48, 96]" />
</div>
<div x-show="filteredTags.length === 0"
@@ -89,7 +89,11 @@
:current-page="$users->currentPage()" :last-page="$users->lastPage()"
wire-target="setPage,previousPage,nextPage" first-action="setPage(1)"
previous-action="previousPage" next-action="nextPage"
last-action="setPage({{ $users->lastPage() }})" />
last-action="setPage({{ $users->lastPage() }})">
<x-slot:pageSize>
<x-page-size-select model="perPage" livewire storage-key="coolify.page-size.team-admin" />
</x-slot:pageSize>
</x-table-pagination>
@else
<x-empty title="No users found" description="Try a different name or email address."
icon-name="teams" size="sm" />
@@ -129,58 +129,11 @@
description="Try a different name, email address, or role." />
</div>
<div x-cloak x-show="filteredMembers.length > 0"
class="flex min-h-14 items-center justify-between gap-3 border-t border-neutral-200 px-4 py-3 dark:border-white/[0.08]">
<p class="text-[13px] text-neutral-500 dark:text-fg-dim">
Showing
<span class="tabular-nums text-black dark:text-fg"
x-text="`${firstVisibleRow}–${lastVisibleRow}`"></span>
of
<span class="tabular-nums text-black dark:text-fg" x-text="filteredMembers.length"></span>
</p>
<div
class="inline-flex h-8 overflow-hidden rounded-lg border border-neutral-200 dark:border-white/[0.10]">
<button type="button"
class="flex w-10 items-center justify-center border-r border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-35 dark:border-white/[0.10] dark:text-fg-dim dark:hover:bg-white/[0.06]"
aria-label="First page" title="First page" x-on:click="goToPage(1)"
x-bind:disabled="page === 1">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M18 6L12 12L18 18M11 6L5 12L11 18" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button"
class="flex w-10 items-center justify-center border-r border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-35 dark:border-white/[0.10] dark:text-fg-dim dark:hover:bg-white/[0.06]"
aria-label="Previous page" title="Previous page" x-on:click="goToPage(page - 1)"
x-bind:disabled="page === 1">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M15 6L9 12L15 18" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<span
class="flex min-w-12 items-center justify-center border-r border-neutral-200 px-3 text-[13px] tabular-nums text-black dark:border-white/[0.10] dark:text-fg"
x-text="page"></span>
<button type="button"
class="flex w-10 items-center justify-center border-r border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-35 dark:border-white/[0.10] dark:text-fg-dim dark:hover:bg-white/[0.06]"
aria-label="Next page" title="Next page" x-on:click="goToPage(page + 1)"
x-bind:disabled="page === lastPage">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M9 6L15 12L9 18" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button"
class="flex w-10 items-center justify-center text-neutral-500 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-35 dark:text-fg-dim dark:hover:bg-white/[0.06]"
aria-label="Last page" title="Last page" x-on:click="goToPage(lastPage)"
x-bind:disabled="page === lastPage">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6L12 12L6 18M13 6L19 12L13 18" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</div>
<x-client-pagination x-cloak x-show="filteredMembers.length > 0"
summary="`${firstVisibleRow}-${lastVisibleRow} of ${filteredMembers.length}`"
page-size-model="perPage" storage-key="coolify.page-size.team-members"
previous-action="goToPage(page - 1)" next-action="goToPage(page + 1)"
next-disabled="page >= lastPage" />
</x-application.settings-section>
@can('manageInvitations', currentTeam())
@@ -39,6 +39,29 @@ test('listbox component uses shared trigger label truncation', function () {
->not->toContain('class="truncate" x-text="current"');
});
test('portaled listboxes center in the mobile viewport', function () {
$html = Blade::render('<x-forms.listbox id="region" :options="[]" :wire="false" />');
expect($html)
->toContain('window.innerWidth < 768')
->toContain('(window.innerWidth - panelWidth) / 2')
->toContain('panel.style.width = `${panelWidth}px`')
->toContain('panel.style.maxWidth = `${window.innerWidth - (edge * 2)}px`')
->toContain('x-show="open"')
->not->toContain('x-show="open && positioned"');
});
test('legacy floating panels have a centered mobile fallback', function () {
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toContain('@media (max-width: 767px)')
->toContain('.listbox-panel:not([style*="position: fixed"])')
->toContain('position: fixed !important;')
->toContain('left: 50% !important;')
->toContain('transform: translate(-50%, -50%) !important;');
});
test('searchable listbox component uses shared trigger label truncation', function () {
$html = Blade::render(<<<'BLADE'
<x-forms.searchable-listbox id="tz" label="Timezone"
@@ -1,6 +1,7 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\File;
it('renders the standard table toolbar controls', function () {
$html = Blade::render(<<<'BLADE'
@@ -21,6 +22,16 @@ it('renders the standard table toolbar controls', function () {
->toContain('table-toolbar')
->toContain('table-search')
->toContain('wire:model.live="search"')
->toContain('x-teleport="body"')
->toContain('positionPanel')
->toContain('position: fixed')
->toContain('floating-dropdown-panel')
->toContain('window.innerWidth < 768')
->toContain('(window.innerWidth - panelWidth) / 2')
->toContain('panel.style.width = `${panelWidth}px`')
->toContain('panel.style.maxWidth = `${window.innerWidth - (edge * 2)}px`')
->toContain('x-show="open"')
->not->toContain('x-show="open && positioned"')
->toContain('aria-multiselectable="true"')
->toContain('Reset filters')
->toContain('wire:click="clearFilters"')
@@ -61,3 +72,30 @@ it('uses the standard search control for frontend filtered infrastructure tables
->toContain('clear-when="search"')
->toContain("clear-action=\"search = ''\"");
});
it('uses the collision aware dropdown on the projects page', function () {
$projects = file_get_contents(resource_path('views/livewire/project/index.blade.php'));
expect($projects)
->toContain('<x-table.dropdown')
->not->toContain('x-show="sortOpen"');
});
it('uses collision aware filter and sort dropdowns on the resources page', function () {
$resources = file_get_contents(resource_path('views/livewire/project/resource/index.blade.php'));
expect(substr_count($resources, '<x-table.dropdown'))->toBeGreaterThanOrEqual(2)
->and($resources)
->not->toContain('x-show="filterOpen"')
->not->toContain('x-show="sortOpen"');
});
it('does not use legacy floating sort or filter panels', function () {
$views = collect(File::allFiles(resource_path('views')))
->filter(fn (SplFileInfo $file): bool => $file->getExtension() === 'php')
->map(fn (SplFileInfo $file): string => $file->getContents())
->implode("\n");
expect($views)
->not->toMatch('/x-show="(?:sortOpen|filterOpen|filtersOpen)"/');
});
+81 -10
View File
@@ -2,6 +2,47 @@
use Illuminate\Support\Facades\Blade;
it('renders the shared page size selector', function () {
$html = Blade::render('<x-page-size-select model="perPage" livewire storage-key="tests.page-size" />');
expect($html)
->toContain('aria-label="Items per page"')
->toContain("\$wire.set('perPage'")
->toContain('h-7!')
->toContain('w-12!')
->toContain('opacity-0')
->toContain('selectedPageSize')
->toContain('border-0')
->toContain('text-[11px]!')
->toContain('mb-0!')
->toContain("localStorage.getItem('tests.page-size')")
->toContain("localStorage.setItem('tests.page-size'")
->toContain('value="10"')
->toContain('value="25"')
->toContain('value="50"')
->toContain('value="100"');
expect($html)
->toContain('value="custom"')
->toContain('data-custom-page-size')
->toContain('customOption?.remove()')
->not->toContain('<option value="" disabled selected>')
->toContain('min="1"')
->toContain('max="100"');
});
it('renders compact client-side pagination', function () {
$html = Blade::render('<x-client-pagination summary="1-10 of 20" page-size-model="pageSize" storage-key="tests.page-size" />');
expect($html)
->toContain('1-10 of 20')
->toContain('pageSize = pageSizeValue')
->toContain("localStorage.getItem('tests.page-size')")
->toContain('aria-label="Previous page"')
->toContain('aria-label="Next page"')
->not->toContain('aria-label="First page"')
->not->toContain('aria-label="Last page"');
});
it('renders loading indicators for livewire page navigation', function () {
$html = Blade::render(
'<x-table-pagination
@@ -19,24 +60,21 @@ it('renders loading indicators for livewire page navigation', function () {
);
expect($html)
->toContain('Showing')
->toContain('1–10')
->toContain('13')
->toContain('wire:target="goToPage,previousPage,nextPage"')
->toContain('wire:loading')
->toContain('wire:loading.remove')
->toContain('wire:loading.attr="disabled"')
->toContain('wire:loading.inline-flex')
->toContain('animate-spin')
->toContain('Loading page…')
->toContain('wire:click="goToPage(1)"')
->toContain('wire:click="previousPage"')
->toContain('wire:click="nextPage"')
->toContain('wire:click="goToPage(2)"')
->toContain('aria-label="First page"')
->toContain('aria-label="Next page"');
->toContain('aria-label="Next page"')
->not->toContain('aria-label="First page"')
->not->toContain('aria-label="Last page"');
// Single spinner only (page number slot), not duplicated next to the "Showing" label.
// A single spinner appears next to the range while Livewire loads the next page.
expect(substr_count($html, 'animate-spin'))->toBe(1)
->and(substr_count($html, 'Loading page…'))->toBe(1);
});
@@ -53,7 +91,6 @@ it('omits livewire loading markup when no wire target is provided', function ()
);
expect($html)
->toContain('Showing')
->toContain('1–5')
->not->toContain('wire:loading')
->not->toContain('wire:target')
@@ -65,9 +102,10 @@ it('uses the shared pagination loading component on deployment history', functio
expect($view)
->toContain('<x-table-pagination')
->toContain('<x-page-size-select')
->toContain('wire-target="goToPage,previousPage,nextPage"')
->toContain('wire:loading.class="opacity-50 pointer-events-none"')
->toContain('wire:target="goToPage,previousPage,nextPage"');
->toContain('wire:target="goToPage,previousPage,nextPage,toggleDeploymentFilter,clearFilter,setPullRequestFilter"');
});
it('uses the shared pagination loading component on environment variables', function () {
@@ -75,8 +113,9 @@ it('uses the shared pagination loading component on environment variables', func
expect($view)
->toContain('<x-table-pagination')
->toContain('<x-page-size-select')
->toContain('wire-target="setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage"')
->toContain('wire:loading.class="opacity-50 pointer-events-none"');
->toContain('wire:loading.class="pointer-events-none opacity-40 blur-[2px]"');
});
it('uses the shared pagination loading component on team admin view', function () {
@@ -84,5 +123,37 @@ it('uses the shared pagination loading component on team admin view', function (
expect($view)
->toContain('<x-table-pagination')
->toContain('<x-page-size-select')
->toContain('wire-target="setPage,previousPage,nextPage"');
});
it('offers page size selection on client-side paginated tables', function (string $view) {
$contents = file_get_contents(resource_path("views/{$view}"));
expect($contents)
->toContain('storage-key="coolify.page-size.')
->toMatch('/<x-(page-size-select|client-pagination)/');
})->with([
'team members' => 'livewire/team/member/index.blade.php',
'api tokens' => 'livewire/security/api-tokens.blade.php',
'scheduled executions' => 'livewire/settings/scheduled-jobs.blade.php',
'volume backup executions' => 'livewire/project/shared/storages/volume-backups/executions.blade.php',
'environment resources' => 'livewire/project/resource/index.blade.php',
'projects' => 'livewire/project/index.blade.php',
'project environments' => 'livewire/project/show.blade.php',
'tagged resources' => 'livewire/tags/show.blade.php',
]);
it('uses compact client pagination on collection views', function (string $view) {
$contents = file_get_contents(resource_path("views/{$view}"));
expect($contents)->toContain('<x-client-pagination');
})->with([
'team members' => 'livewire/team/member/index.blade.php',
'api tokens' => 'livewire/security/api-tokens.blade.php',
'scheduled jobs' => 'livewire/settings/scheduled-jobs.blade.php',
'environment resources' => 'livewire/project/resource/index.blade.php',
'projects' => 'livewire/project/index.blade.php',
'project environments' => 'livewire/project/show.blade.php',
'tags' => 'livewire/tags/show.blade.php',
]);