fix(ui): polish mobile layouts, error pages, and volume backups

Keep error pages full-viewport, lift the unsaved bar above the
mobile keyboard, make status/heading layouts stack cleanly on small
screens, route service DB backups to volume-backups, and tighten
volume backup grid/forms. Cover with feature tests.
This commit is contained in:
Andras Bacsai
2026-08-06 21:29:26 +02:00
parent 262ad8981a
commit e4fa2b6964
31 changed files with 172 additions and 83 deletions
+12 -1
View File
@@ -1016,8 +1016,14 @@ html[data-theme="custom"] textarea:disabled {
}
/* Public error surfaces (401/404/500/…). Match auth shell canvas + accent. */
.error-page-body {
width: 100vw;
height: 100dvh;
overflow: hidden;
}
.error-shell {
min-height: 100dvh;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
@@ -2375,6 +2381,11 @@ input[type="search"]::-webkit-search-results-decoration {
grid-template-columns: 7.5rem minmax(9rem, 1fr) 8rem 6rem 5rem minmax(9rem, 1fr) minmax(9rem, auto);
}
.volume-backup-executions-grid {
grid-template-columns: 7.5rem minmax(0, 1fr) 8.5rem 7rem 9rem minmax(9rem, 0.7fr);
min-width: 50rem;
}
.clone-destinations-table-grid {
grid-template-columns: 2rem minmax(10rem, 1fr) minmax(10rem, 1fr);
}
@@ -5,6 +5,7 @@
])
@php
$serviceParameters = \Illuminate\Support\Arr::except($parameters, ['stack_service_uuid']);
$items = [
[
'label' => 'General',
@@ -20,7 +21,8 @@
],
[
'label' => 'Backups',
'route' => 'project.service.database.backups',
'route' => 'project.service.volume-backups.index',
'parameters' => $serviceParameters,
'icon' => 'storages',
'active' => request()->routeIs('project.service.database.backup*'),
'visible' => $serviceDatabase?->isBackupSolutionAvailable() || $serviceDatabase?->is_migrated,
@@ -54,7 +56,7 @@
'menu-item-active' => $item['active'],
])
@if ($item['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($item['route'], $parameters) }}">
href="{{ route($item['route'], $item['parameters'] ?? $parameters) }}">
<x-reicon :name="$item['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $item['label'] }}</span>
</a>
@@ -62,7 +62,7 @@
</x-status-badge>
<div x-cloak x-show="open" x-transition.origin.top.left
class="listbox-panel top-8! right-auto! left-0! z-[90]! w-64! min-w-64!" role="menu">
class="listbox-panel top-8! right-auto! left-0! z-[90]! w-[min(16rem,calc(100vw-1.5rem))]! min-w-0! sm:w-64! sm:min-w-64!" role="menu">
<div class="px-3 py-2 text-[11px] font-medium text-neutral-400 dark:text-fg-faint">{{ $title }}</div>
<div class="listbox-option cursor-default! gap-2.5!">
<span @class([
@@ -19,7 +19,28 @@
Mobile: stacked layout (full label, buttons on the next line) inset from
the viewport edges so body overflow-x-clip cannot clip it.
Desktop: compact single-row centered pill. --}}
<div wire:dirty.class="is-dirty"
<div x-data="{
keyboardInset: 0,
updateKeyboardInset: null,
init() {
this.updateKeyboardInset = () => {
const viewport = window.visualViewport;
this.keyboardInset = window.innerWidth < 640 && viewport
? Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop)
: 0;
};
this.updateKeyboardInset();
window.visualViewport?.addEventListener('resize', this.updateKeyboardInset);
window.visualViewport?.addEventListener('scroll', this.updateKeyboardInset);
window.addEventListener('resize', this.updateKeyboardInset);
},
destroy() {
window.visualViewport?.removeEventListener('resize', this.updateKeyboardInset);
window.visualViewport?.removeEventListener('scroll', this.updateKeyboardInset);
window.removeEventListener('resize', this.updateKeyboardInset);
},
}" x-bind:style="`--keyboard-inset: ${keyboardInset}px`" wire:dirty.class="is-dirty"
wire:loading.class="!opacity-0 !translate-y-6 !pointer-events-none"
@keydown.enter.window="
if ($el.classList.contains('is-dirty') &&
@@ -35,7 +56,7 @@
}
"
@if ($targets) wire:target="{{ $targets }}" @endif
class="pointer-events-none fixed inset-x-3 bottom-[max(1.5rem,env(safe-area-inset-bottom,0px)+0.75rem)] z-[1000] flex max-w-full translate-y-6 flex-col items-stretch gap-2 rounded-2xl border border-white/10 bg-surface py-2.5 pr-2.5 pl-4 opacity-0 shadow-modal transition-[opacity,transform] duration-200 ease-out delay-0 [&.is-dirty]:pointer-events-auto [&.is-dirty]:translate-y-0 [&.is-dirty]:opacity-100 [&.is-dirty]:delay-300 sm:inset-x-auto sm:left-1/2 sm:bottom-6 sm:w-max sm:max-w-none sm:-translate-x-1/2 sm:flex-row sm:items-center sm:gap-8 sm:py-2 sm:pl-5 sm:pr-2">
class="pointer-events-none fixed inset-x-3 bottom-[calc(var(--keyboard-inset,0px)+max(1.5rem,env(safe-area-inset-bottom,0px)+0.75rem))] z-[1000] flex max-w-full translate-y-6 flex-col items-stretch gap-2 rounded-2xl border border-white/10 bg-surface py-2.5 pr-2.5 pl-4 opacity-0 shadow-modal transition-[opacity,transform] duration-200 ease-out delay-0 [&.is-dirty]:pointer-events-auto [&.is-dirty]:translate-y-0 [&.is-dirty]:opacity-100 [&.is-dirty]:delay-300 sm:inset-x-auto sm:left-1/2 sm:bottom-6 sm:w-max sm:max-w-none sm:-translate-x-1/2 sm:flex-row sm:items-center sm:gap-8 sm:py-2 sm:pl-5 sm:pr-2">
<span class="text-[13px] font-semibold leading-snug text-fg sm:whitespace-nowrap">{{ $label }}</span>
<div class="flex shrink-0 items-center justify-end gap-2">
<button type="button" onclick="window.location.reload()"
+1 -1
View File
@@ -1,7 +1,7 @@
@extends('layouts.base')
@section('body')
<body class="text-black dark:text-inherit">
<body class="error-page-body text-black dark:text-inherit">
<x-toast />
<x-error-page
code="400"
+1 -1
View File
@@ -1,7 +1,7 @@
@extends('layouts.base')
@section('body')
<body class="text-black dark:text-inherit">
<body class="error-page-body text-black dark:text-inherit">
<x-toast />
<x-error-page
code="401"
+1 -1
View File
@@ -1,7 +1,7 @@
@extends('layouts.base')
@section('body')
<body class="text-black dark:text-inherit">
<body class="error-page-body text-black dark:text-inherit">
<x-toast />
<x-error-page
code="402"
+1 -1
View File
@@ -1,7 +1,7 @@
@extends('layouts.base')
@section('body')
<body class="text-black dark:text-inherit">
<body class="error-page-body text-black dark:text-inherit">
<x-toast />
<x-error-page
code="403"
+1 -1
View File
@@ -1,7 +1,7 @@
@extends('layouts.base')
@section('body')
<body class="text-black dark:text-inherit">
<body class="error-page-body text-black dark:text-inherit">
<x-toast />
<x-error-page
code="404"
+1 -1
View File
@@ -1,7 +1,7 @@
@extends('layouts.base')
@section('body')
<body class="text-black dark:text-inherit">
<body class="error-page-body text-black dark:text-inherit">
<x-toast />
<x-error-page
code="419"
+1 -1
View File
@@ -1,7 +1,7 @@
@extends('layouts.base')
@section('body')
<body class="text-black dark:text-inherit">
<body class="error-page-body text-black dark:text-inherit">
<x-toast />
<x-error-page
code="429"
+1 -1
View File
@@ -1,7 +1,7 @@
@extends('layouts.base')
@section('body')
<body class="text-black dark:text-inherit">
<body class="error-page-body text-black dark:text-inherit">
<x-toast />
<x-error-page
code="500"
+1 -1
View File
@@ -1,7 +1,7 @@
@extends('layouts.base')
@section('body')
<body class="text-black dark:text-inherit">
<body class="error-page-body text-black dark:text-inherit">
<x-toast />
<x-error-page
code="503"
@@ -49,7 +49,7 @@
$domainCount = countDomains($fqdn);
}
@endphp
<div class="flex flex-col gap-3 rounded-lg border border-neutral-200 bg-neutral-50/60 px-4 py-3 sm:flex-row sm:items-center dark:border-white/[0.07] dark:bg-white/[0.025]">
<div class="flex items-center gap-3 rounded-lg border border-neutral-200 bg-neutral-50/60 px-4 py-3 dark:border-white/[0.07] dark:bg-white/[0.025]">
<div class="flex min-w-0 flex-1 items-center gap-3">
<div class="flex size-9 shrink-0 items-center justify-center rounded-md bg-neutral-200/70 text-neutral-600 dark:bg-white/[0.07] dark:text-fg-dim">
<x-reicon name="globe" class="size-4" />
@@ -63,7 +63,7 @@
</p>
</div>
</div>
<a class="icon-button shrink-0 self-start sm:self-auto" title="Manage domains"
<a class="icon-button ml-auto shrink-0" title="Manage domains"
aria-label="Manage domains"
href="{{ route('project.application.domains', [
'project_uuid' => $application->environment->project->uuid,
@@ -23,23 +23,14 @@
$applicationMenuItems,
fn (array $item): bool => $item['visible'] ?? true,
));
$applicationStatus = str($application->status ?? 'exited');
[$applicationStatusLabel, $applicationStatusType] = match (true) {
$applicationStatus->startsWith('running') => ['Running', 'success'],
$applicationStatus->startsWith('degraded') => ['Degraded', 'warning'],
$applicationStatus->startsWith('restarting'),
$applicationStatus->startsWith('starting') => ['Starting', 'warning'],
$applicationStatus->startsWith('exited') => ['Stopped', 'neutral'],
default => ['Deploying', 'warning'],
};
@endphp
<div>
<div class="mb-3 w-full xl:hidden">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<h1 class="min-w-0 truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
<div class="flex min-w-0 flex-col items-start gap-2">
<h1 class="min-w-0 max-w-full truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
{{ $application->name }}
</h1>
<x-status-badge :status="$applicationStatusLabel" :type="$applicationStatusType" />
<x-status-summary :status="$application->status" />
</div>
</div>
@@ -44,13 +44,6 @@
));
$databaseStatus = str($database->status ?? 'exited');
[$databaseStatusLabel, $databaseStatusType] = match (true) {
$databaseStatus->startsWith('running') => ['Running', 'success'],
$databaseStatus->startsWith('degraded') => ['Degraded', 'warning'],
$databaseStatus->startsWith('restarting'),
$databaseStatus->startsWith('starting') => ['Starting', 'warning'],
default => ['Stopped', 'neutral'],
};
@endphp
<livewire:project.shared.configuration-checker :resource="$database" />
@@ -66,11 +59,11 @@
<div x-data>
<div class="mb-3 w-full xl:hidden">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<h1 class="min-w-0 truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
<div class="flex min-w-0 flex-col items-start gap-2">
<h1 class="min-w-0 max-w-full truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
{{ $database->name }}
</h1>
<x-status-badge :status="$databaseStatusLabel" :type="$databaseStatusType" />
<x-status-summary :status="$database->status" title="Database status" />
</div>
</div>
@@ -42,14 +42,6 @@
));
$serviceStatus = str($service->status ?? 'exited');
[$serviceStatusLabel, $serviceStatusType] = match (true) {
$serviceStatus->contains('running') => ['Running', 'success'],
$serviceStatus->contains('degraded') => ['Degraded', 'warning'],
$serviceStatus->contains('restarting'),
$serviceStatus->contains('starting') => ['Starting', 'warning'],
$serviceStatus->contains('exited') => ['Stopped', 'neutral'],
default => ['Deploying', 'warning'],
};
@endphp
<livewire:project.shared.configuration-checker :resource="$service" />
@@ -63,11 +55,11 @@
<div x-data="{ deploying: false }" @service-deploy-finished.window="deploying = false">
<div class="mb-3 w-full xl:hidden">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<h1 class="min-w-0 truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
<div class="flex min-w-0 flex-col items-start gap-2">
<h1 class="min-w-0 max-w-full truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
{{ $service->name }}
</h1>
<x-status-badge :status="$serviceStatusLabel" :type="$serviceStatusType" />
<x-status-summary :status="$service->status" title="Service status" container-name="Containers" />
</div>
</div>
@@ -127,7 +127,6 @@
@else
{{ $domainCount }} domains set.
@endif
Manage domains, DNS checks, and redirects on the parent service's Domains page.
</p>
<a class="button shrink-0" href="{{ route('project.service.domains', $parameters) }}"
{{ wireNavigate() }}>
@@ -58,8 +58,8 @@
<div
class="flex items-center justify-end gap-1 border-t border-neutral-200 bg-neutral-50 px-3 py-2 dark:border-white/[0.06] dark:bg-white/[0.02]">
@if ($isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated))
<a class="icon-button" title="Database backups" aria-label="Database backups" {{ wireNavigate() }}
href="{{ route('project.service.database.backups', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
<a class="icon-button" title="Service backups" aria-label="Service backups" {{ wireNavigate() }}
href="{{ route('project.service.volume-backups.index', $parameters) }}">
<x-reicon name="database" class="size-4" />
</a>
@endif
@@ -98,20 +98,22 @@
<div class="hidden truncate font-mono text-xs text-neutral-500 sm:block dark:text-fg-faint">
{{ $resource->image }}
</div>
<div class="justify-self-start">
<x-status-badge :status="$statusLabel" :type="$statusType" />
</div>
<div class="flex items-center justify-end gap-1">
@if ($isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated))
<a class="icon-button" title="Database backups" aria-label="Database backups" {{ wireNavigate() }}
href="{{ route('project.service.database.backups', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
<x-reicon name="database" class="size-4" />
<div class="flex flex-wrap items-center justify-end gap-1 sm:contents">
<div class="justify-self-start">
<x-status-badge :status="$statusLabel" :type="$statusType" />
</div>
<div class="flex items-center justify-end gap-1">
@if ($isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated))
<a class="icon-button" title="Service backups" aria-label="Service backups" {{ wireNavigate() }}
href="{{ route('project.service.volume-backups.index', $parameters) }}">
<x-reicon name="database" class="size-4" />
</a>
@endif
<a class="icon-button" title="Resource settings" aria-label="Resource settings" {{ wireNavigate() }}
href="{{ route('project.service.index', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
<x-reicon name="settings" class="size-4" />
</a>
@endif
<a class="icon-button" title="Resource settings" aria-label="Resource settings" {{ wireNavigate() }}
href="{{ route('project.service.index', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
<x-reicon name="settings" class="size-4" />
</a>
</div>
</div>
</div>
</div>
@@ -23,9 +23,9 @@
</x-slot:actions>
@if ($executionCount > 0)
<div class="data-table w-full">
<div class="data-table w-full overflow-x-auto">
<div
class="grid grid-cols-[7.5rem_minmax(0,1fr)_8.5rem_7rem_9rem_minmax(9rem,.7fr)] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
class="data-table-header volume-backup-executions-grid border-b border-neutral-200 bg-neutral-50 dark:border-white/[0.08] dark:bg-white/[0.025]">
<span>Status</span>
<span>Archive</span>
<span>Time</span>
@@ -65,7 +65,7 @@
@endphp
<div wire:key="volume-backup-execution-{{ $execution->id }}"
class="grid min-h-16 grid-cols-[7.5rem_minmax(0,1fr)_8.5rem_7rem_9rem_minmax(9rem,.7fr)] items-center gap-x-3 border-b border-neutral-200 px-4 py-2.5 text-[12px] last:border-b-0 dark:border-white/[0.07]">
class="data-table-row volume-backup-executions-grid min-h-16 items-center gap-x-3 border-b border-neutral-200 text-[12px] last:border-b-0 dark:border-white/[0.07]">
<span>
<x-status-badge :status="$statusLabel" :type="$statusType" />
</span>
@@ -124,7 +124,7 @@
@if ($execution->message)
<pre
class="col-span-6 mt-2 max-h-32 overflow-auto rounded-lg border border-neutral-200 bg-neutral-50 p-2 text-[11px] whitespace-pre-wrap text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">{{ $execution->message }}</pre>
class="volume-backup-execution-message col-span-6 mt-2 max-h-32 overflow-auto rounded-lg border border-neutral-200 bg-neutral-50 p-2 text-[11px] whitespace-pre-wrap text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">{{ $execution->message }}</pre>
@endif
</div>
@endforeach
@@ -7,13 +7,13 @@
<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">Local backups</h3>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<x-forms.input label="Backups to keep" id="retentionAmountLocally" type="number"
min="0" canGate="update" :canResource="$backup"
min="0" canGate="update" :canResource="$resource"
helper="Maximum number of recent local backups." required />
<x-forms.input label="Days to keep" id="retentionDaysLocally" type="number"
min="0" canGate="update" :canResource="$backup"
min="0" canGate="update" :canResource="$resource"
helper="Remove local backups older than this many days." required />
<x-forms.input label="Maximum storage (GB)" id="retentionMaxStorageLocally" type="number"
min="0" step="any" canGate="update" :canResource="$backup"
min="0" step="any" canGate="update" :canResource="$resource"
helper="Remove the oldest local backups after this total size is reached." required />
</div>
</div>
@@ -22,12 +22,12 @@
<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">S3 backups</h3>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<x-forms.input label="Backups to keep" id="retentionAmountS3" type="number" min="0"
canGate="update" :canResource="$backup" helper="Maximum number of recent S3 backups." required />
canGate="update" :canResource="$resource" helper="Maximum number of recent S3 backups." required />
<x-forms.input label="Days to keep" id="retentionDaysS3" type="number" min="0"
canGate="update" :canResource="$backup"
canGate="update" :canResource="$resource"
helper="Remove S3 backups older than this many days." required />
<x-forms.input label="Maximum storage (GB)" id="retentionMaxStorageS3" type="number"
min="0" step="any" canGate="update" :canResource="$backup"
min="0" step="any" canGate="update" :canResource="$resource"
helper="Remove the oldest S3 backups after this total size is reached." required />
</div>
</div>
@@ -28,25 +28,25 @@
</div>
@if (! $saveToS3)
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
wire:target="toggleS3" isHighlighted canGate="update" :canResource="$backup">
wire:target="toggleS3" isHighlighted canGate="update" :canResource="$resource">
Enable S3
</x-forms.button>
@else
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
wire:target="toggleS3" canGate="update" :canResource="$backup">
wire:target="toggleS3" canGate="update" :canResource="$resource">
Disable S3
</x-forms.button>
@endif
</div>
<div class="application-settings-section-body grid gap-4 sm:grid-cols-2">
<x-forms.listbox id="s3StorageId" label="S3 storage" :required="$saveToS3"
:disabled="! auth()->user()?->can('update', $backup)"
:disabled="! auth()->user()?->can('update', $resource)"
:options="$availableS3Storages->map(fn ($s3Storage) => [
'value' => $s3Storage->id,
'label' => $s3Storage->name,
])->values()->all()" />
<x-forms.listbox id="disableLocalBackup" label="Local copy"
:disabled="! $saveToS3 || ! auth()->user()?->can('update', $backup)" :options="[
:disabled="! $saveToS3 || ! auth()->user()?->can('update', $resource)" :options="[
['value' => false, 'label' => 'Keep local backup'],
['value' => true, 'label' => 'Delete after S3 upload'],
]" />
@@ -12,7 +12,8 @@ it('shows internal Docker access details in application general settings', funct
->toContain('<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">Public access</h3>')
->toContain("Str::plural('domain', \$domainCount)")
->toContain('Domains, DNS checks, and redirect settings')
->toContain('class="icon-button shrink-0 self-start sm:self-auto"')
->toContain('class="flex items-center gap-3 rounded-lg')
->toContain('class="icon-button ml-auto shrink-0"')
->toContain('aria-label="Manage domains"')
->toContain('<x-reicon name="settings" class="size-4" />')
->not->toContain('<x-reicon name="arrow-right" class="size-3.5" />')
+1
View File
@@ -16,6 +16,7 @@ it('renders redesigned error pages with shared shell and actions', function (str
$html = view($view, ['exception' => $exception])->render();
expect($html)
->toContain('error-page-body')
->toContain('error-shell')
->toContain('error-code')
->toContain('error-title')
@@ -1,5 +1,18 @@
<?php
it('keeps the volume backup executions table horizontally scrollable on mobile', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/executions.blade.php'));
$css = file_get_contents(resource_path('css/app.css'));
expect($view)
->toContain('volume-backup-executions-grid')
->toContain('data-table w-full overflow-x-auto')
->and($css)
->toContain('.volume-backup-executions-grid')
->toContain('min-width: 50rem;')
->not->toContain('.data-table-header.volume-backup-executions-grid');
});
use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup;
use App\Livewire\Project\Shared\Storages\All;
use App\Models\Application;
+3 -1
View File
@@ -13,7 +13,9 @@ it('aggregates preview container and health check status', function () {
->toContain('Not configured')
->toContain('aria-label="About unconfigured health checks"')
->toContain('Traffic can still be routed to the container')
->toContain('aria-haspopup="menu"');
->toContain('aria-haspopup="menu"')
->toContain('right-auto! left-0!')
->toContain('w-[min(16rem,calc(100vw-1.5rem))]!');
});
it('uses the aggregated preview status in the previews list', function () {
@@ -33,6 +33,27 @@ it('uses a single unified navbar for application, service, database, and server
->not->toContain('border-l border-neutral-200 pl-1');
});
it('uses interactive status summaries in mobile resource headings', function () {
$headings = [
resource_path('views/livewire/project/application/heading.blade.php') => '<x-status-summary :status="$application->status" />',
resource_path('views/livewire/project/database/heading.blade.php') => '<x-status-summary :status="$database->status" title="Database status" />',
resource_path('views/livewire/project/service/heading.blade.php') => '<x-status-summary :status="$service->status" title="Service status" container-name="Containers" />',
];
foreach ($headings as $path => $statusSummary) {
$mobileHeading = str(file_get_contents($path))
->after('<div class="mb-3 w-full xl:hidden">')
->before('<div class="w-full xl:hidden">')
->toString();
expect($mobileHeading)
->toContain('flex min-w-0 flex-col items-start gap-2')
->toContain('min-w-0 max-w-full truncate')
->toContain($statusSummary)
->not->toContain('<x-status-badge');
}
});
it('docks desktop resource actions in the top bar instead of floating over content', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
$files = [
@@ -56,6 +56,16 @@ test('unsaved bar stays above floating notifications so save actions remain acce
->and($popup)->toContain('z-999');
});
test('unsaved bar stays above the mobile keyboard', function () {
$contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php'));
expect($contents)
->toContain('window.visualViewport')
->toContain('keyboardInset')
->toContain('--keyboard-inset')
->toContain('removeEventListener');
});
test('settings general unsaved bar excludes auto-saving instance timezone', function () {
$path = resource_path('views/livewire/settings/index.blade.php');
$contents = file_get_contents($path);
@@ -36,8 +36,11 @@ it('aligns compose resource columns and uses icon actions', function () {
expect($configuration)->toContain($columns)
->and($resourceCard)->toContain($columns)
->toContain('flex flex-wrap items-center justify-end gap-1 sm:contents')
->toContain('aria-label="Resource settings"')
->toContain('aria-label="Database backups"')
->toContain('aria-label="Service backups"')
->toContain("route('project.service.volume-backups.index', \$parameters)")
->not->toContain("route('project.service.database.backups'")
->not->toContain('>Settings</a>')
->not->toContain('>Backups</a>');
});
@@ -74,6 +74,16 @@ it('shows the service sidebar on the storage backups page', function () {
->toContain('xl:grid-cols-[210px_minmax(0,1fr)]');
});
it('links compose database backups to the parent service backups page', function () {
$sidebar = file_get_contents(resource_path('views/components/service-database/sidebar.blade.php'));
expect($sidebar)
->toContain("'route' => 'project.service.volume-backups.index'")
->toContain("'parameters' => \$serviceParameters")
->toContain("\$item['parameters'] ?? \$parameters")
->not->toContain("'route' => 'project.service.database.backups'");
});
it('uses a distinct backup icon across resource sidebars', function () {
$sidebars = [
resource_path('views/components/application/configuration-sidebar.blade.php'),
+17
View File
@@ -826,6 +826,7 @@ it('enables and disables volume S3 backups from the S3 title action', function (
'section' => 's3',
])
->assertSee('Enable S3')
->assertDontSee('You do not have permission to perform this action.')
->call('toggleS3')
->assertSet('saveToS3', true)
->assertSee('Disable S3');
@@ -865,6 +866,22 @@ it('shows and saves volume S3 retention while S3 backups are disabled', function
->and($backup->retention_max_storage_s3)->toBe(4.5);
});
it('allows team owners to edit volume backup retention settings', function () {
$team = Team::factory()->create();
signInForVolumeBackups($this, $team);
[$application, $volume] = createVolumeBackupApplication($team);
$volume->scheduledBackups()->create([
'team_id' => $team->id,
'frequency' => 'daily',
]);
Livewire::test(VolumeBackups::class, [
'storage' => $volume,
'resource' => $application,
'section' => 'retention',
])->assertDontSee('You do not have permission to perform this action.');
});
it('only updates S3 fields when toggling volume S3 backups', function () {
$team = Team::factory()->create();
signInForVolumeBackups($this, $team);