feat(ui): polish env editor, error buttons, and modal layout
This commit is contained in:
parent
abc701ac65
commit
4a3dc11958
28 changed files with 256 additions and 103 deletions
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.3.18"
|
||||
"version": "4.3.19"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.4-rc.1"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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!">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,12 +1,6 @@
|
|||
@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-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">
|
||||
|
|
@ -14,18 +8,14 @@
|
|||
<a class="button" {{ wireNavigate() }} href="{{ route('storage.index') }}">Open S3 storage</a>
|
||||
</x-slot:contents>
|
||||
</x-empty>
|
||||
</div>
|
||||
</section>
|
||||
</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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,10 +220,8 @@ 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
|
||||
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
|
||||
|
|
@ -234,12 +233,20 @@ class="flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:bo
|
|||
step2ButtonText="Permanently Delete" />
|
||||
@endif
|
||||
@endcan
|
||||
@if ($canUpdate)
|
||||
<x-forms.button type="submit" :disabled="$isDisabled" @click="modalOpen = false">
|
||||
</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
|
||||
@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>
|
||||
</x-modal-input>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,6 @@
|
|||
@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-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">
|
||||
|
|
@ -14,18 +8,14 @@
|
|||
<a class="button" {{ wireNavigate() }} href="{{ route('storage.index') }}">Open S3 storage</a>
|
||||
</x-slot:contents>
|
||||
</x-empty>
|
||||
</div>
|
||||
</section>
|
||||
</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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;')
|
||||
|
|
|
|||
49
tests/Feature/EnvironmentVariableEditorTest.php
Normal file
49
tests/Feature/EnvironmentVariableEditorTest.php
Normal 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');
|
||||
});
|
||||
|
|
@ -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"');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>'
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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[^"]*"/');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.3.18"
|
||||
"version": "4.3.19"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.4-rc.1"
|
||||
|
|
|
|||
Loading…
Reference in a new issue