feat(ui): polish env editor, error buttons, and modal layout

This commit is contained in:
Andras Bacsai 2026-09-08 11:49:02 +02:00
parent abc701ac65
commit 4a3dc11958
28 changed files with 256 additions and 103 deletions

View file

@ -299,6 +299,7 @@ public function submit()
$this->syncData(true);
$this->syncData(false);
$this->dispatch('success', 'Environment variable updated.');
$this->dispatch('environment-variable-updated', envId: $this->env->id);
$this->dispatch('envsUpdated');
$this->dispatch('configurationChanged');

View file

@ -23,6 +23,7 @@ public function __construct(
public mixed $canResource = null,
public bool $autoDisable = true,
public bool $isHighlighted = false,
public bool $isError = false,
public ?string $tooltip = null,
) {
// Handle authorization-based disabling
@ -37,6 +38,8 @@ public function __construct(
if ($this->noStyle) {
$this->defaultClass = '';
} elseif ($this->isError) {
$this->defaultClass .= ' button-error';
}
}

View file

@ -2,7 +2,7 @@
return [
'coolify' => [
'version' => env('COOLIFY_VERSION') ?: '4.3.18',
'version' => env('COOLIFY_VERSION') ?: '4.3.19',
'helper_version' => '1.0.16',
'realtime_version' => '1.0.19',
'railpack_version' => '0.23.0',

View file

@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
"version": "4.3.18"
"version": "4.3.19"
},
"nightly": {
"version": "4.4-rc.1"

View file

@ -101,10 +101,18 @@ .button[isHighlighted]:not(:disabled) {
--button-depth-color: color-mix(in oklab, var(--color-coollabs) 52%, black);
}
.dark .button:not(.button-highlighted):not([isHighlighted]):not(:disabled) {
.dark .button:not(.button-highlighted):not(.button-error):not([isHighlighted]):not(:disabled) {
--button-depth-color: rgb(255 255 255 / 0.08);
}
.button.button-error:not(:disabled) {
--button-depth-color: var(--color-red-300);
}
.dark .button.button-error:not(:disabled) {
--button-depth-color: var(--color-red-800);
}
/* Keep the shared focus indicator when the depth shadow owns box-shadow. */
.button:not(:disabled):focus-visible {
box-shadow: var(--button-depth), 0 0 0 1px var(--color-accent);
@ -377,8 +385,16 @@ option {
@apply dark:text-white dark:bg-coolgray-100;
}
button[isError]:not(:disabled) {
@apply text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-800 hover:bg-red-300 hover:text-white dark:hover:bg-red-800 dark:hover:text-white;
.button.button-error {
@apply text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-800;
}
.button.button-error:disabled {
@apply text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-800;
}
.button-error:not(:disabled) {
@apply hover:bg-red-100 hover:text-red-900 dark:hover:bg-red-700 dark:hover:text-white;
}
button[isHighlighted]:not(:disabled) {

View file

@ -13,6 +13,7 @@
'wireOpen' => null,
'contentClicks' => true,
'isLarge' => false,
'fixedHeight' => false,
])
<div x-data="{ modalOpen: @if ($wireOpen) $wire.entangle(@js($wireOpen)) @else false @endif }"
@ -38,7 +39,7 @@
<template x-teleport="body">
<div x-show="modalOpen"
x-init="$watch('modalOpen', value => { if(value) { $nextTick(() => { const firstInput = $el.querySelector('input, textarea, select'); firstInput?.focus(); }) } })"
class="fixed inset-0 z-99 overflow-y-auto">
class="fixed inset-0 z-99 overflow-hidden">
<div x-show="modalOpen" x-transition:enter="transition-opacity ease-out duration-200" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="transition-opacity ease-in duration-150"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
@ -55,6 +56,7 @@ class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-[2px]"></div>
'application-settings-form application-settings-section relative flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden',
'lg:w-[95vw]! lg:max-w-7xl!' => $isLarge,
'lg:w-auto lg:min-w-2xl lg:max-w-4xl' => ! $isLarge,
'sm:h-[40rem]' => $fixedHeight,
])
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-wrap! sm:flex-nowrap!">

View file

@ -184,7 +184,7 @@
</head>
@section('body')
<body class="overflow-y-scroll dark:text-inherit text-black">
<body class="dark:text-inherit text-black">
<x-toast />
<x-icon-tooltip />
<script data-navigate-once>

View file

@ -47,7 +47,7 @@ class="rounded-lg border border-red-300 bg-red-50 p-4 ring-1 ring-inset ring-red
step2ButtonText="Permanently Delete" canGate="delete"
:canResource="$destination" />
@else
<x-forms.button disabled tooltip="The default Coolify destination cannot be deleted.">
<x-forms.button isError disabled tooltip="The default Coolify destination cannot be deleted.">
Delete destination
</x-forms.button>
@endif

View file

@ -1,31 +1,21 @@
@if ($availableS3Storages->isEmpty())
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>S3 storage</h2>
<p>Send backup archives to a validated object storage destination.</p>
</div>
</div>
<div class="application-settings-section-body is-flush">
<x-empty title="No validated S3 storage"
description="Add and validate an S3 storage destination before enabling remote backups."
icon-name="storages">
<x-slot:contents>
<a class="button" {{ wireNavigate() }} href="{{ route('storage.index') }}">Open S3 storage</a>
</x-slot:contents>
</x-empty>
</div>
</section>
<x-application.settings-section title="S3 storage"
description="Send backup archives to a validated object storage destination." flush>
<x-empty title="No validated S3 storage"
description="Add and validate an S3 storage destination before enabling remote backups."
icon-name="storages">
<x-slot:contents>
<a class="button" {{ wireNavigate() }} href="{{ route('storage.index') }}">Open S3 storage</a>
</x-slot:contents>
</x-empty>
</x-application.settings-section>
@else
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>S3 storage</h2>
<p>Choose where remote backups are stored and whether local copies are retained.</p>
</div>
<x-application.settings-section title="S3 storage"
description="Choose where remote backups are stored and whether local copies are retained.">
<x-slot:actions>
@if (! $saveS3)
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
wire:target="toggleS3" isHighlighted>Enable S3</x-forms.button>
@ -33,8 +23,8 @@
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
wire:target="toggleS3">Disable S3</x-forms.button>
@endif
</div>
<div class="application-settings-section-body grid gap-4 sm:grid-cols-2">
</x-slot:actions>
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.listbox id="s3StorageId" label="S3 storage" portal :required="$saveS3"
:options="$availableS3Storages->map(fn ($s3) => [
'value' => $s3->id,
@ -46,6 +36,6 @@
['value' => true, 'label' => 'Delete after S3 upload'],
]" />
</div>
</section>
</x-application.settings-section>
</form>
@endif

View file

@ -160,15 +160,6 @@ class="button button-highlighted">
@else
<div wire:key="service-domains-list"
class="application-settings-section-body is-flush mt-1 w-full scroll-mt-28 overflow-visible">
<div class="data-table-header service-domains-overview-grid">
<span>Domain</span>
<span>Protocol redirect</span>
<span>Domain redirect</span>
<span>Internal port</span>
<span>Search indexing</span>
<span>DNS status</span>
<span class="text-right">Actions</span>
</div>
@foreach ($domainGroups as $appId => $rows)
@php
$app = collect($serviceApps)->firstWhere('id', (int) $appId);
@ -204,7 +195,7 @@ class="border-b border-neutral-200 last:border-b-0 dark:border-white/10">
'domainRows' => $domainRows,
'service' => $service,
'showServiceColumn' => false,
'showHeader' => false,
'showHeader' => true,
])
</div>
</section>

View file

@ -60,7 +60,7 @@
@php
$selectedSchedule = $selectedDatabaseBackup ?: $selectedVolumeBackup;
@endphp
<x-modal-input :title="'Edit backup schedule'" wireOpen="scheduleModalOpen" :wireIgnore="false" isLarge
<x-modal-input :title="'Edit backup schedule'" wireOpen="scheduleModalOpen" :wireIgnore="false" isLarge fixedHeight
canGate="update" :canResource="$service">
<x-slot:content><span></span></x-slot:content>

View file

@ -40,7 +40,7 @@ class="rounded-lg border border-red-300 bg-red-50 p-4 ring-1 ring-inset ring-red
confirmationLabel="Enter the resource name to confirm permanent deletion"
shortConfirmationLabel="Resource name" />
@else
<x-forms.button disabled tooltip="You do not have permission to delete this resource.">
<x-forms.button isError disabled tooltip="You do not have permission to delete this resource.">
Delete {{ $resourceLabel }}
</x-forms.button>
@endif

View file

@ -86,7 +86,8 @@ class="env-key-label min-w-0 truncate text-left font-mono text-[13px] text-black
<div class="justify-self-end">
{{-- Open modal immediately (Alpine); decrypt value in a follow-up Livewire request. --}}
<x-modal-input title="Edit environment variable" :closeOutside="false" :wireIgnore="false"
wireOpen="editorOpen">
wireOpen="editorOpen"
@environment-variable-updated.window="if ($event.detail.envId === @js($env->id)) modalOpen = false">
<x-slot:content>
<button type="button" wire:click="loadValues" data-env-settings-trigger class="icon-button shrink-0"
title="Edit environment variable" aria-label="Edit environment variable">
@ -219,26 +220,32 @@ class="grid gap-4 border-t border-neutral-200 pt-4 dark:border-white/[0.07] sm:g
@if ($canUpdate || auth()->user()?->can('delete', $this->env))
<div
class="flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.07]">
@if ($canUpdate && !$isLocked && !$isMagicVariable)
<x-forms.button type="button" wire:click="lock">Lock</x-forms.button>
@endif
@can('delete', $this->env)
@if (!$isMagicVariable)
<x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton
buttonTitle="Delete" submitAction="delete"
:actions="['The selected environment variable will be permanently deleted.']"
confirmationText="{{ $key }}"
confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below"
shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
class="flex flex-wrap items-center justify-between gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.07]">
<div data-environment-variable-delete-action>
@can('delete', $this->env)
@if (!$isMagicVariable)
<x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton
buttonTitle="Delete" submitAction="delete"
:actions="['The selected environment variable will be permanently deleted.']"
confirmationText="{{ $key }}"
confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below"
shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@endif
@endcan
</div>
<div class="ml-auto flex flex-wrap gap-2" data-environment-variable-update-actions>
@if ($canUpdate && !$isLocked && !$isMagicVariable)
<x-forms.button type="button" wire:click="lock">Lock</x-forms.button>
@endif
@endcan
@if ($canUpdate)
<x-forms.button type="submit" :disabled="$isDisabled" @click="modalOpen = false">
Update variable
</x-forms.button>
@endif
@if ($canUpdate && !$isDisabled)
<fieldset disabled wire:dirty.attr.remove="disabled">
<x-forms.button type="submit" isHighlighted wire:target="submit">
Update variable
</x-forms.button>
</fieldset>
@endif
</div>
</div>
@endif
</form>

View file

@ -1,31 +1,21 @@
@if ($availableS3Storages->isEmpty())
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>S3 storage</h2>
<p>Send volume backup archives to a validated object storage destination.</p>
</div>
</div>
<div class="application-settings-section-body is-flush">
<x-empty title="No validated S3 storage"
description="Add and validate an S3 storage destination before enabling remote backups."
icon-name="storages">
<x-slot:contents>
<a class="button" {{ wireNavigate() }} href="{{ route('storage.index') }}">Open S3 storage</a>
</x-slot:contents>
</x-empty>
</div>
</section>
<x-application.settings-section title="S3 storage"
description="Send volume backup archives to a validated object storage destination." flush>
<x-empty title="No validated S3 storage"
description="Add and validate an S3 storage destination before enabling remote backups."
icon-name="storages">
<x-slot:contents>
<a class="button" {{ wireNavigate() }} href="{{ route('storage.index') }}">Open S3 storage</a>
</x-slot:contents>
</x-empty>
</x-application.settings-section>
@else
<form wire:submit="save">
<x-unsaved-bar action="save" />
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>S3 storage</h2>
<p>Choose where remote copies are stored and whether local archives are retained.</p>
</div>
<x-application.settings-section title="S3 storage"
description="Choose where remote copies are stored and whether local archives are retained.">
<x-slot:actions>
@if (! $saveToS3)
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
wire:target="toggleS3" isHighlighted canGate="update" :canResource="$resource">
@ -37,8 +27,8 @@
Disable S3
</x-forms.button>
@endif
</div>
<div class="application-settings-section-body grid gap-4 sm:grid-cols-2">
</x-slot:actions>
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.listbox canGate="update" :canResource="$resource" id="s3StorageId" label="S3 storage" :required="$saveToS3"
:disabled="! auth()->user()?->can('update', $resource)"
:options="$availableS3Storages->map(fn ($s3Storage) => [
@ -51,6 +41,6 @@
['value' => true, 'label' => 'Delete after S3 upload'],
]" />
</div>
</section>
</x-application.settings-section>
</form>
@endif

View file

@ -33,8 +33,7 @@ class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-neutral-10
{{ $tag->name }}
</span>
@can('update', $resource)
<x-forms.button wire:click="deleteTag('{{ $tag->id }}')"
class="h-7! text-neutral-500 dark:text-fg-dim">
<x-forms.button isError wire:click="deleteTag('{{ $tag->id }}')" class="h-7!">
Remove
</x-forms.button>
@endcan

View file

@ -200,7 +200,7 @@ class="rounded-lg border border-red-300 bg-red-50 p-4 ring-1 ring-inset ring-red
shortConfirmationLabel="GitHub App Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@else
<x-forms.button disabled tooltip="You do not have permission to delete this GitHub App.">
<x-forms.button isError disabled tooltip="You do not have permission to delete this GitHub App.">
Delete
</x-forms.button>
@endcan

View file

@ -102,7 +102,7 @@ class="rounded-lg border border-red-300 bg-red-50 p-4 ring-1 ring-inset ring-red
shortConfirmationLabel="Storage Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@else
<x-forms.button disabled tooltip="You do not have permission to delete this storage.">
<x-forms.button isError disabled tooltip="You do not have permission to delete this storage.">
Delete
</x-forms.button>
@endcan

View file

@ -88,7 +88,7 @@ class="rounded-lg border border-red-300 bg-red-50 p-4 ring-1 ring-inset ring-red
step2ButtonText="Permanently Delete" canGate="delete"
:canResource="$team" />
@else
<x-forms.button disabled tooltip="Resolve the requirements shown before deleting this team.">
<x-forms.button isError disabled tooltip="Resolve the requirements shown before deleting this team.">
Delete team
</x-forms.button>
@endif

View file

@ -9,9 +9,20 @@
->toContain('--button-depth-hover: 0 3px 0 var(--button-depth-color);')
->toContain('.button-highlighted:not(:disabled)')
->toContain('--button-depth-color: color-mix(in oklab, var(--color-coollabs) 52%, black);')
->toContain('.dark .button:not(.button-highlighted):not([isHighlighted]):not(:disabled)')
->toContain('.dark .button:not(.button-highlighted):not(.button-error):not([isHighlighted]):not(:disabled)')
->not->toContain('.dark .button:not(.button-highlighted):not([isHighlighted]):not(:disabled)')
->toContain('--button-depth-color: rgb(255 255 255 / 0.08);')
->not->toContain('.dark .button.button-highlighted:not(:disabled)')
->toContain('.button.button-error:not(:disabled)')
->toContain('--button-depth-color: var(--color-red-300);')
->toContain('.dark .button.button-error:not(:disabled)')
->toContain('--button-depth-color: var(--color-red-800);')
->toContain('.button-error:not(:disabled)')
->toContain('.button.button-error {')
->toContain('.button.button-error:disabled {')
->toContain('hover:bg-red-100 hover:text-red-900 dark:hover:bg-red-700 dark:hover:text-white')
->not->toContain('hover:bg-red-300 hover:text-white dark:hover:bg-red-800')
->not->toContain('button[isError]:not(:disabled)')
->toContain('.button:not(:disabled):hover')
->toContain('transform: translateY(-1px);')
->toContain('transition-duration: 120ms, 120ms, 120ms, 80ms, 80ms;')

View file

@ -0,0 +1,49 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->user = User::factory()->create();
$team = Team::factory()->create();
$team->members()->attach($this->user, ['role' => 'owner']);
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create(['environment_id' => $environment->id]);
$this->environmentVariable = EnvironmentVariable::create([
'key' => 'TEST_VAR',
'value' => 'test_value',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$this->actingAs($this->user);
});
test('environment variable editor closes only after a successful update', function () {
Livewire::test(Show::class, ['env' => $this->environmentVariable, 'type' => 'application'])
->set('comment', 'Updated in the editor')
->call('submit')
->assertHasNoErrors()
->assertDispatched('environment-variable-updated', envId: $this->environmentVariable->id);
});
test('environment variable editor stays open when validation fails', function () {
Livewire::test(Show::class, ['env' => $this->environmentVariable, 'type' => 'application'])
->set('valuesLoaded', true)
->set('is_required', true)
->set('value', '')
->call('submit')
->assertNotDispatched('environment-variable-updated');
});

View file

@ -149,3 +149,16 @@
->not->toMatch('/<x-table\.toolbar[^>]*@if/')
->toContain('aria-busy="{{ ! $readyToLoad ? \'true\' : \'false\' }}"');
});
test('environment variable editor places delete left and update actions right', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
expect($view)
->toContain('class="flex flex-wrap items-center justify-between gap-2 border-t')
->toMatch('/data-environment-variable-delete-action[\s\S]+buttonTitle="Delete"[\s\S]+data-environment-variable-update-actions[\s\S]+Lock[\s\S]+Update variable/')
->toContain('@environment-variable-updated.window="if ($event.detail.envId === @js($env->id)) modalOpen = false"')
->toContain('<fieldset disabled wire:dirty.attr.remove="disabled">')
->toContain('type="submit" isHighlighted wire:target="submit"')
->toContain('wire:dirty.attr.remove="disabled"')
->not->toContain('@click="modalOpen = false"');
});

View file

@ -2,6 +2,33 @@
use Illuminate\Support\Facades\Blade;
it('marks error buttons with the shared error class', function () {
$html = Blade::render('<x-forms.button isError>Delete service</x-forms.button>');
expect($html)
->toContain('class="button button-error"')
->not->toContain('isError');
});
it('keeps unavailable destructive actions styled as error buttons', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/danger.blade.php'));
expect($view)->toContain('<x-forms.button isError disabled tooltip="You do not have permission to delete this resource.">');
});
it('uses the shared error style for destructive form buttons', function (string $view, string $button) {
expect(file_get_contents(resource_path("views/{$view}")))->toContain($button);
})->with([
'unavailable team deletion' => [
'livewire/team/danger-zone.blade.php',
'<x-forms.button isError disabled tooltip="Resolve the requirements shown before deleting this team.">',
],
'tag removal' => [
'livewire/project/shared/tags.blade.php',
'<x-forms.button isError wire:click="deleteTag',
],
]);
it('disables the button and targets the wire click action while loading', function () {
$html = Blade::render(
'<x-forms.button wire:click.prevent="checkConnection"><x-reicon name="refresh" class="size-3.5" />Check connection</x-forms.button>'

View file

@ -8,6 +8,15 @@
expect($html)->toContain('Modal content');
});
test('input modal overlay is fixed to the viewport without its own page scrollbar', function () {
$html = Blade::render('<x-modal-input>Modal content</x-modal-input>');
expect($html)
->toContain('class="fixed inset-0 z-99 overflow-hidden"')
->not->toContain('w-screen overflow-hidden')
->not->toContain('class="fixed inset-0 z-99 overflow-y-auto"');
});
test('confirmation modal closes before dispatching an event that can open another modal', function () {
$modal = file_get_contents(resource_path('views/components/modal-confirmation.blade.php'));

View file

@ -209,6 +209,27 @@
->not->toContain('wire:click="selectScheduleSection');
});
it('keeps the backup schedule modal height stable while switching sections', function () {
$modal = file_get_contents(resource_path('views/components/modal-input.blade.php'));
$index = file_get_contents(resource_path('views/livewire/project/service/volume-backup/index.blade.php'));
expect($index)->toContain('isLarge fixedHeight')
->and($modal)
->toContain("'sm:h-[40rem]' => \$fixedHeight");
});
it('shows S3 section descriptions from the title helper', function () {
$volumeS3 = file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/s3.blade.php'));
$databaseS3 = file_get_contents(resource_path('views/livewire/project/database/backup-edit/s3.blade.php'));
foreach ([$volumeS3, $databaseS3] as $view) {
expect($view)
->toContain('<x-application.settings-section title="S3 storage"')
->not->toContain('<div class="application-settings-section-header">')
->not->toContain('<h2>S3 storage</h2>');
}
});
it('adds a back up now action to every service backup schedule row', function () {
$index = file_get_contents(resource_path('views/livewire/project/service/volume-backup/index.blade.php'));
$styles = file_get_contents(resource_path('css/app.css'));

View file

@ -1114,6 +1114,18 @@
->not->toContain('gap-x-3 gap-y-1');
});
it('renders each domain table header below its service heading', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
$serviceHeadingPosition = strpos($view, 'service-domain-group-{{ $appId }}');
$domainTablePosition = strpos($view, "'showHeader' => true");
expect($view)
->not->toContain('<div class="data-table-header service-domains-overview-grid">')
->and($serviceHeadingPosition)->not->toBeFalse()
->and($domainTablePosition)->not->toBeFalse()
->and($domainTablePosition)->toBeGreaterThan($serviceHeadingPosition);
});
it('lays out the domain settings dropdowns in responsive columns', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
expect($view)->toContain('mt-4 grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2')

View file

@ -1,7 +1,19 @@
<?php
test('app layouts always reserve space for the vertical scrollbar', function (string $layout) {
test('app layouts let the root element own vertical scrolling', function (string $layout) {
$contents = file_get_contents(dirname(__DIR__, 2).'/'.$layout);
expect($contents)->toMatch('/<body(?:\s+[^>]*)?class="[^"]*overflow-y-scroll[^"]*"[^>]*>/');
expect($contents)->not->toMatch('/<body(?:\s+[^>]*)?class="[^"]*overflow-y-scroll[^"]*"[^>]*>/');
})->with(['resources/views/layouts/base.blade.php']);
test('scroll locking is left to Alpine scrollbar compensation', function () {
$styles = file_get_contents(dirname(__DIR__, 2).'/resources/css/app.css');
expect($styles)->not->toMatch('/html\s*\{[^}]*scrollbar-gutter:\s*stable;/s');
});
test('desktop navbar uses the document width', function () {
$layout = file_get_contents(dirname(__DIR__, 2).'/resources/views/layouts/app.blade.php');
expect($layout)->not->toMatch('/class="[^"]*hidden lg:flex fixed top-0 inset-x-0[^"]*w-screen[^"]*"/');
});

View file

@ -23,8 +23,8 @@
->toContain('ARG COOLIFY_VERSION')
->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}')
->and($constants)
->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.18'")
->and($versions['coolify']['v4']['version'])->toBe('4.3.18')
->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.19'")
->and($versions['coolify']['v4']['version'])->toBe('4.3.19')
->and($versions['coolify']['nightly']['version'])->toBe('4.4-rc.1')
->and($nightlyVersions)->toBe($versions);
});

View file

@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
"version": "4.3.18"
"version": "4.3.19"
},
"nightly": {
"version": "4.4-rc.1"