feat(ui): add shared table controls and multi-select filters

Introduce x-table toolbar/search/filter/sort/loading components and
multi-select deployment history filters with OR within groups and AND
across groups. Add live database/service status for breadcrumbs, shared
variables layout controls, logs viewer toolbar polish, and infrastructure
list filter consistency. Document patterns in UI_REDESIGN.md and cover
with feature tests.
This commit is contained in:
Andras Bacsai committed 2026-08-06 15:37:30 +02:00
1 parent 66b202adfe
commit 2a5197fb30
57 files changed
+1415 -579

No files matched your search

@@ -12,13 +12,6 @@
@php
$items = match ($section) {
'shared-variables' => [
['label' => 'Overview', 'route' => 'shared-variables.index', 'active' => request()->routeIs('shared-variables.index')],
['label' => 'Team', 'route' => 'shared-variables.team.index', 'active' => request()->routeIs('shared-variables.team.*')],
['label' => 'Projects', 'route' => 'shared-variables.project.index', 'active' => request()->routeIs('shared-variables.project.*')],
['label' => 'Environments', 'route' => 'shared-variables.environment.index', 'active' => request()->routeIs('shared-variables.environment.*')],
['label' => 'Servers', 'route' => 'shared-variables.server.index', 'active' => request()->routeIs('shared-variables.server.*')],
],
'team' => [
['label' => 'General', 'route' => 'team.index', 'active' => request()->routeIs('team.index', 'team.member.index', 'team.admin-view', 'team.danger-zone')],
],
@@ -79,14 +72,6 @@
$hasTitle = filled($title);
$hasActions = isset($actions);
$showNav = $showTabs || $hasActions;
$stackTabsOnMobile = $section === 'shared-variables';
$sharedVariableIcons = [
'Overview' => 'dashboard',
'Team' => 'teams',
'Projects' => 'projects',
'Environments' => 'layers',
'Servers' => 'servers',
];
@endphp
@if ($hasTitle)
@@ -131,24 +116,7 @@
<div
class="flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3 lg:h-full lg:gap-4">
@if ($showTabs)
@if ($stackTabsOnMobile)
<div
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:hidden dark:border-white/[0.06]">
@foreach ($items as $item)
<a @class([
'menu-item',
'menu-item-active' => $item['active'],
]) {{ wireNavigate() }} href="{{ route($item['route'], $parameters) }}">
<x-reicon :name="$sharedVariableIcons[$item['label']]" class="menu-item-icon" />
<span class="menu-item-label">{{ $item['label'] }}</span>
</a>
@endforeach
</div>
@endif
<div @class([
'flex min-w-0 w-full items-center gap-0.5 overflow-x-auto rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 sm:flex-1 dark:border-white/[0.07] dark:bg-white/[0.035]',
'hidden lg:flex' => $stackTabsOnMobile,
])>
<div class="flex min-w-0 w-full items-center gap-0.5 overflow-x-auto rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 sm:flex-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
@foreach ($items as $item)
<a @class([
'app-tab shrink-0',
@@ -1,3 +1,5 @@
@props(['server', 'activeMenu', 'activeSubMenu' => null])
@php
$serverRouteParameters = ['server_uuid' => $server->uuid];
$serverMenuItems = [
@@ -49,14 +51,14 @@
[
'label' => 'Proxy',
'route' => 'server.proxy',
'active' => request()->routeIs('server.proxy', 'server.proxy.*'),
'active' => $activeMenu === 'proxy',
'icon' => 'network',
'group' => 'Platform',
'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server,
'children' => [
['label' => 'Configuration', 'route' => 'server.proxy', 'active' => request()->routeIs('server.proxy'), 'icon' => 'settings'],
['label' => 'Dynamic Configurations', 'route' => 'server.proxy.dynamic-confs', 'active' => request()->routeIs('server.proxy.dynamic-confs'), 'icon' => 'sliders', 'visible' => $server->proxySet()],
['label' => 'Logs', 'route' => 'server.proxy.logs', 'active' => request()->routeIs('server.proxy.logs'), 'icon' => 'file-content', 'visible' => $server->proxySet(), 'navigate' => false],
['label' => 'Configuration', 'route' => 'server.proxy', 'active' => $activeSubMenu === 'configuration', 'icon' => 'settings'],
['label' => 'Dynamic Configurations', 'route' => 'server.proxy.dynamic-confs', 'active' => $activeSubMenu === 'dynamic-confs', 'icon' => 'sliders', 'visible' => $server->proxySet()],
['label' => 'Logs', 'route' => 'server.proxy.logs', 'active' => $activeSubMenu === 'logs', 'icon' => 'file-content', 'visible' => $server->proxySet(), 'navigate' => false],
],
],
[
@@ -45,7 +45,8 @@
<span>{{ $serverReady ? 'Ready' : 'Unavailable' }}</span>
</div>
@if ($server->proxySet())
<div class="listbox-option cursor-default! gap-2.5!">
<a href="{{ route('server.proxy', ['server_uuid' => $server->uuid]) }}" {{ wireNavigate() }}
class="listbox-option gap-2.5!" @click="open = false" role="menuitem">
<span @class([
'size-1.5 shrink-0 rounded-full',
'bg-success' => $proxyStatus === 'running',
@@ -54,14 +55,15 @@
])></span>
<span class="flex-1">Proxy</span>
<span>{{ str($proxyStatus ?: 'unknown')->headline() }}</span>
</div>
</a>
@endif
@if ($showSentinelStatus)
<div class="listbox-option cursor-default! gap-2.5!">
<a href="{{ route('server.sentinel', ['server_uuid' => $server->uuid]) }}" {{ wireNavigate() }}
class="listbox-option gap-2.5!" @click="open = false" role="menuitem">
<span class="size-1.5 shrink-0 rounded-full {{ $server->isSentinelLive() ? 'bg-success' : 'bg-error' }}"></span>
<span class="flex-1">Sentinel</span>
<span>{{ $server->isSentinelLive() ? 'In sync' : 'Out of sync' }}</span>
</div>
</a>
@endif
@if ($server->proxySet())
<button type="button"
@@ -18,6 +18,7 @@
->values();
@endphp
<x-shared-variables.layout>
<div class="application-settings-form w-full" x-data="{
sharedSearch: '',
sharedSort: 'alphabetical',
@@ -29,9 +30,6 @@
return this.rows.filter(row => [row.key, row.comment, row.scope].some(value => value.includes(query))).length;
}
}">
<x-dashboard.navbar section="shared-variables" title="Shared variables"
subtitle="Reusable environment variables across resources" :titleOnDesktop="false" />
<x-application.settings-section :title="$title" flush>
<x-slot:actions>
<x-forms.button type="button" wire:click="switch" class="whitespace-nowrap">
@@ -136,3 +134,4 @@
@endif
</x-application.settings-section>
</div>
</x-shared-variables.layout>
@@ -0,0 +1,36 @@
@php
$sharedVariablesMenuItems = [
['label' => 'Overview', 'route' => 'shared-variables.index', 'icon' => 'dashboard', 'active' => request()->routeIs('shared-variables.index')],
['label' => 'Team', 'route' => 'shared-variables.team.index', 'icon' => 'teams', 'active' => request()->routeIs('shared-variables.team.*')],
['label' => 'Projects', 'route' => 'shared-variables.project.index', 'icon' => 'projects', 'active' => request()->routeIs('shared-variables.project.*')],
['label' => 'Environments', 'route' => 'shared-variables.environment.index', 'icon' => 'layers', 'active' => request()->routeIs('shared-variables.environment.*')],
['label' => 'Servers', 'route' => 'shared-variables.server.index', 'icon' => 'servers', 'active' => request()->routeIs('shared-variables.server.*')],
];
@endphp
<section class="w-full max-w-[1180px]">
<header class="mb-6 xl:hidden">
<h1 class="text-[24px]! leading-7! font-semibold! tracking-tight!">Shared variables</h1>
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">Reusable environment variables across resources</p>
</header>
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<aside class="min-w-0 xl:self-start">
<nav aria-label="Shared variables"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($sharedVariablesMenuItems as $menuItem)
<a wire:key="shared-variables-{{ str($menuItem['label'])->slug() }}"
@class(['menu-item', 'menu-item-active' => $menuItem['active']])
{{ wireNavigate() }} href="{{ route($menuItem['route']) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
</nav>
</aside>
<div class="min-w-0">
{{ $slot }}
</div>
</div>
</section>
@@ -0,0 +1,31 @@
@props(['label', 'storageKey'])
<div class="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="relative w-full sm:max-w-sm">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input x-model.debounce.150ms="search" type="search" placeholder="Search {{ strtolower($label) }}"
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint">
<button x-cloak x-show="search" @click="search = ''" type="button"
class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
aria-label="Clear search">
<x-reicon name="x" class="size-3" />
</button>
</div>
<div
class="flex h-8 w-fit items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
<button type="button" @click="viewMode = 'list'; localStorage.setItem('{{ $storageKey }}', 'list')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'list' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
aria-label="List view" title="List view">
<x-reicon name="unordered-list" class="size-3.5" />
</button>
<button type="button" @click="viewMode = 'grid'; localStorage.setItem('{{ $storageKey }}', 'grid')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'grid' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
aria-label="Grid view" title="Grid view">
<x-reicon name="grid" class="size-3.5" />
</button>
</div>
</div>
@@ -0,0 +1,24 @@
@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="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)>
<span>{{ $resetLabel }}</span>
<x-reicon name="x" class="size-3.5" />
</button>
</div>
</div>
</div>
@@ -0,0 +1,6 @@
@props(['target', 'text' => 'Loading records...'])
<div wire:loading.flex wire:target="{{ $target }}"
{{ $attributes->class(['table-loading-overlay absolute inset-0 z-30 hidden items-center justify-center bg-white/70 backdrop-blur-[1px] dark:bg-black/20']) }}>
<x-loading aria-label="{{ $text }}" class="[&_.loading-indicator]:size-5" />
</div>
@@ -0,0 +1,33 @@
@props([
'placeholder' => 'Search',
'loadingTarget' => null,
'disabled' => false,
'clearAction' => null,
'clearWhen' => null,
])
<div class="table-search relative min-w-0 w-full">
<input type="search" placeholder="{{ $placeholder }}" aria-label="{{ $placeholder }}"
{{ $attributes->class(['input w-full pl-8!'])->except(['loading-target']) }} @disabled($disabled) />
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5">
@if ($loadingTarget)
<x-reicon name="search" class="size-3.5 text-neutral-400 dark:text-fg-faint"
wire:loading.remove wire:target="{{ $loadingTarget }}" />
<svg wire:loading wire:target="{{ $loadingTarget }}" aria-hidden="true"
class="size-3.5 animate-spin text-neutral-400 dark:text-fg-dim" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<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" />
</svg>
@else
<x-reicon name="search" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
@endif
</div>
@if ($clearAction && $clearWhen)
<button x-cloak x-show="{{ $clearWhen }}" x-on:click="{{ $clearAction }}" type="button"
class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
aria-label="Clear search">
<x-reicon name="x" class="size-3" />
</button>
@endif
</div>
@@ -0,0 +1,10 @@
<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">
{{ $slot }}
</div>
</div>
@@ -0,0 +1,6 @@
<div {{ $attributes->class(['table-toolbar flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center']) }}>
@isset($search)
<div class="min-w-0 w-full flex-1 sm:max-w-md">{{ $search }}</div>
@endisset
<div class="flex flex-wrap items-center gap-2 sm:ml-auto">{{ $slot }}</div>
</div>
@@ -13,6 +13,14 @@
$currentApplication = $currentEnvironment && $applicationUuid
? $currentEnvironment->applications()->where('uuid', $applicationUuid)->first()
: null;
$databaseUuid = request()->route('database_uuid');
$currentDatabase = $currentEnvironment && $databaseUuid
? $currentEnvironment->databases()->firstWhere('uuid', $databaseUuid)
: null;
$serviceUuid = request()->route('service_uuid');
$currentService = $currentEnvironment && $serviceUuid
? $currentEnvironment->services()->where('uuid', $serviceUuid)->first()
: null;
$storageUuid = request()->route('storage_uuid');
$storages = $storageUuid && $team ? \App\Models\S3Storage::ownedByCurrentTeam()->orderBy('name')->get() : collect();
$currentStorage = $storages->firstWhere('uuid', $storageUuid);
@@ -79,7 +87,7 @@
])->filter()
: collect();
@endphp
<div class="flex items-center gap-0.5 min-w-0 text-[13px]">
<div class="flex min-w-0 items-center gap-0.5 text-[13px]">
{{-- Team --}}
<div class="shrink-0" x-data="{ collapsed: false }">
<livewire:switch-team />
@@ -251,4 +259,22 @@
:wire:key="'application-status-'.$currentApplication->uuid" />
</span>
@endif
@if ($currentDatabase)
<span class="shrink-0 text-neutral-300 dark:text-fg-faint px-0.5">/</span>
<span class="flex min-w-0 shrink items-center gap-2 h-8 px-2">
<span class="min-w-0 truncate font-semibold text-black dark:text-fg">{{ $currentDatabase->name }}</span>
<livewire:project.database.status :database="$currentDatabase"
:wire:key="'database-status-'.$currentDatabase->uuid" />
</span>
@endif
@if ($currentService)
<span class="shrink-0 text-neutral-300 dark:text-fg-faint px-0.5">/</span>
<span class="flex min-w-0 shrink items-center gap-2 h-8 px-2">
<span class="min-w-0 truncate font-semibold text-black dark:text-fg">{{ $currentService->name }}</span>
<livewire:project.service.status :service="$currentService"
:wire:key="'service-status-'.$currentService->uuid" />
</span>
@endif
</div>