feat(ui): add collapsible advanced settings and enlarge compose modal

Extract a shared forms collapsible for advanced SSH/build settings
across boarding, server creation, and application general. Support
larger modals with header actions, close the compose editor after
save via compose-save-finished, and cover the layout in tests.
This commit is contained in:
Andras Bacsai
2026-08-07 16:22:11 +02:00
parent 4926cdba0e
commit ada3ea354a
12 changed files with 207 additions and 144 deletions
@@ -77,7 +77,6 @@ class EditCompose extends Component
{
try {
$this->authorize('update', $this->service);
$this->dispatch('info', 'Saving new docker compose...');
$this->dispatch('saveCompose', $this->dockerComposeRaw);
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
@@ -140,6 +140,7 @@ class StackForm extends Component
{
$this->dockerComposeRaw = $raw;
$this->submit(notify: true);
$this->dispatch('compose-save-finished');
}
public function instantSave()
@@ -0,0 +1,22 @@
@props([
'title' => 'Advanced settings',
'contentClass' => '',
])
<div x-data="{ open: false }" {{ $attributes->class(['flex flex-col gap-4']) }}>
<button type="button" x-on:click="open = !open"
class="flex items-center gap-2 text-left text-sm font-medium hover:underline" :aria-expanded="open">
<svg class="size-4 transition-transform" x-bind:class="open && 'rotate-90'" viewBox="0 0 20 20"
fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 0 1 .02-1.06L11.168 10 7.23 6.29a.75.75 0 1 1 1.04-1.08l4.5 4.25a.75.75 0 0 1 0 1.08l-4.5 4.25a.75.75 0 0 1-1.06-.02Z"
clip-rule="evenodd" />
</svg>
{{ $title }}
</button>
<div x-show="open" x-cloak
class="rounded-lg border border-neutral-200 p-4 dark:border-coolgray-400 {{ $contentClass }}">
{{ $slot }}
</div>
</div>
@@ -12,6 +12,7 @@
// Optional Livewire bool property to entangle open state (survives Livewire re-renders).
'wireOpen' => null,
'contentClicks' => true,
'isLarge' => false,
])
@php
@@ -54,10 +55,19 @@
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="application-settings-form application-settings-section relative max-h-[calc(100dvh-2rem)] w-full lg:w-auto lg:min-w-2xl lg:max-w-4xl"
@class([
'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,
])
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-nowrap!">
<h3 class="min-w-0 flex-1 truncate">{{ $title }}</h3>
@isset($headerActions)
<div class="flex shrink-0 items-center gap-2">
{{ $headerActions }}
</div>
@endisset
<button type="button" @click="modalOpen=false"
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-neutral-500 outline-0 transition-colors hover:bg-neutral-100 hover:text-black focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
<x-reicon name="x" class="size-4" />
@@ -421,39 +421,21 @@
<x-forms.input placeholder="Optional: Note what this server hosts" label="Description"
id="remoteServerDescription" wire:model="remoteServerDescription" />
<div x-data="{ showAdvanced: false }" class="flex flex-col gap-4">
<button @click="showAdvanced = !showAdvanced" type="button"
class="flex items-center gap-2 text-left text-sm font-medium hover:underline">
<svg x-show="!showAdvanced" class="size-4" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
clip-rule="evenodd" />
</svg>
<svg x-show="showAdvanced" class="size-4" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd" />
</svg>
Advanced Connection Settings
</button>
<div x-show="showAdvanced" x-cloak
class="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4 rounded-lg border border-neutral-200 dark:border-coolgray-400">
<x-forms.input placeholder="Default: 22" label="SSH Port" type="number"
id="remoteServerPort" wire:model="remoteServerPort" />
<div>
<x-forms.input placeholder="Default: root" label="SSH User" id="remoteServerUser"
wire:model="remoteServerUser" />
<p class="mt-1 text-xs dark:text-white text-black">
Non-root user support is experimental.
<a class="font-bold underline hover:text-coollabs" target="_blank"
href="https://coolify.io/docs/knowledge-base/server/non-root-user">Learn
more</a>
</p>
</div>
<x-forms.collapsible title="Advanced Connection Settings"
content-class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<x-forms.input placeholder="Default: 22" label="SSH Port" type="number"
id="remoteServerPort" wire:model="remoteServerPort" />
<div>
<x-forms.input placeholder="Default: root" label="SSH User" id="remoteServerUser"
wire:model="remoteServerUser" />
<p class="mt-1 text-xs text-black dark:text-white">
Non-root user support is experimental.
<a class="font-bold underline hover:text-coollabs" target="_blank"
href="https://coolify.io/docs/knowledge-base/server/non-root-user">Learn
more</a>
</p>
</div>
</div>
</x-forms.collapsible>
<x-forms.button type="submit" class="w-full lg:w-auto">Validate Connection</x-forms.button>
</form>
</x-slot:actions>
@@ -157,35 +157,32 @@
helper="Git repository (based on the base directory settings) will be copied to the deployment directory."
x-bind:disabled="shouldDisable()" />
</div>
<div class="pt-4">The following commands are for advanced use cases.
Only
modify them if you
know what are
you doing.</div>
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input x-bind:disabled="shouldDisable()"
placeholder="docker compose build" id="dockerComposeCustomBuildCommand"
helper="The compose file path (<span class='dark:text-warning'>-f</span> flag) and environment variables (<span class='dark:text-warning'>--env-file</span> flag) are automatically injected based on your Base Directory and Docker Compose Location settings. You can override by providing your own <span class='dark:text-warning'>-f</span> or <span class='dark:text-warning'>--env-file</span> flags.<br><br>If you use this, you need to specify paths relatively and should use the same compose file in the custom command, otherwise the automatically configured labels / etc won't work.<br><br>Example usage: <span class='dark:text-warning'>docker compose build</span>"
label="Custom build command" />
<x-forms.input x-bind:disabled="shouldDisable()"
placeholder="docker compose up -d" id="dockerComposeCustomStartCommand"
helper="The compose file path (<span class='dark:text-warning'>-f</span> flag) and environment variables (<span class='dark:text-warning'>--env-file</span> flag) are automatically injected based on your Base Directory and Docker Compose Location settings. You can override by providing your own <span class='dark:text-warning'>-f</span> or <span class='dark:text-warning'>--env-file</span> flags.<br><br>If you use this, you need to specify paths relatively and should use the same compose file in the custom command, otherwise the automatically configured labels / etc won't work.<br><br>Example usage: <span class='dark:text-warning'>docker compose up -d</span>"
label="Custom start command" />
<div class="grid gap-4 pt-4">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input x-bind:disabled="shouldDisable()"
placeholder="docker compose build" id="dockerComposeCustomBuildCommand"
helper="The compose file path (<span class='dark:text-warning'>-f</span> flag) and environment variables (<span class='dark:text-warning'>--env-file</span> flag) are automatically injected based on your Base Directory and Docker Compose Location settings. You can override by providing your own <span class='dark:text-warning'>-f</span> or <span class='dark:text-warning'>--env-file</span> flags.<br><br>If you use this, you need to specify paths relatively and should use the same compose file in the custom command, otherwise the automatically configured labels / etc won't work.<br><br>Example usage: <span class='dark:text-warning'>docker compose build</span>"
label="Custom build command" />
<x-forms.input x-bind:disabled="shouldDisable()"
placeholder="docker compose up -d" id="dockerComposeCustomStartCommand"
helper="The compose file path (<span class='dark:text-warning'>-f</span> flag) and environment variables (<span class='dark:text-warning'>--env-file</span> flag) are automatically injected based on your Base Directory and Docker Compose Location settings. You can override by providing your own <span class='dark:text-warning'>-f</span> or <span class='dark:text-warning'>--env-file</span> flags.<br><br>If you use this, you need to specify paths relatively and should use the same compose file in the custom command, otherwise the automatically configured labels / etc won't work.<br><br>Example usage: <span class='dark:text-warning'>docker compose up -d</span>"
label="Custom start command" />
</div>
@if ($this->dockerComposeCustomBuildCommand)
<div wire:key="docker-compose-build-preview">
<x-forms.input readonly value="{{ $this->dockerComposeBuildCommandPreview }}"
label="Final build command (preview)"
helper="This shows the actual command that will be executed with auto-injected flags." />
</div>
@endif
@if ($this->dockerComposeCustomStartCommand)
<div wire:key="docker-compose-start-preview">
<x-forms.input readonly value="{{ $this->dockerComposeStartCommandPreview }}"
label="Final start command (preview)"
helper="This shows the actual command that will be executed with auto-injected flags." />
</div>
@endif
</div>
@if ($this->dockerComposeCustomBuildCommand)
<div wire:key="docker-compose-build-preview">
<x-forms.input readonly value="{{ $this->dockerComposeBuildCommandPreview }}"
label="Final build command (preview)"
helper="This shows the actual command that will be executed with auto-injected flags." />
</div>
@endif
@if ($this->dockerComposeCustomStartCommand)
<div wire:key="docker-compose-start-preview">
<x-forms.input readonly value="{{ $this->dockerComposeStartCommandPreview }}"
label="Final start command (preview)"
helper="This shows the actual command that will be executed with auto-injected flags." />
</div>
@endif
@if ($this->application->is_github_based() && !$this->application->is_public_repository())
<div class="pt-4">
<x-forms.textarea
@@ -306,8 +303,8 @@
</div>
@endif
@if ($buildPack === 'dockercompose')
<div x-data="{ showRaw: true }">
<div class="flex items-center gap-4">
<div x-data="{ showRaw: true }" class="mt-5">
<div class="flex items-center justify-between gap-4">
<h3>Docker Compose</h3>
<x-forms.button x-show="{{ $application->settings->is_raw_compose_deployment_enabled ? 'false' : 'true' }}"
@click.prevent="showRaw = !showRaw"
@@ -1,66 +1,34 @@
<div x-data="{
raw: true,
showNormalTextarea: false,
editorHeight: 400,
calculateEditorHeight() {
// Get viewport height
const viewportHeight = window.innerHeight;
// Modal max height is calc(100vh - 2rem) = viewport - 32px
const modalMaxHeight = viewportHeight - 32;
// Account for: modal header (~80px) + info text (~60px) + checkboxes (~80px) + buttons (~80px) + padding (~48px)
const fixedElementsHeight = 348;
// Calculate available height for editor
const availableHeight = modalMaxHeight - fixedElementsHeight;
// Set minimum height of 300px and maximum of available space
this.editorHeight = Math.max(300, Math.min(availableHeight, viewportHeight - 200));
}
}" x-init="calculateEditorHeight(); window.addEventListener('resize', () => calculateEditorHeight())">
<div class="pb-4">Volume names are updated upon save. The service UUID will be added as a prefix to all volumes, to
prevent
name collision. <br>To see the actual volume names, check the Deployable Compose file, or go to Storage
menu.</div>
<div x-data="{ raw: true, showNormalTextarea: false }"
@compose-preview-toggle.window="raw = !raw"
@compose-validate.window="$wire.validateCompose()"
@compose-save.window="$wire.saveEditedCompose()"
class="flex min-h-0 flex-col gap-3">
<x-callout type="info" title="Volume names">
Volume names are prefixed with the service UUID when you save to prevent collisions.
</x-callout>
<div class="compose-editor-container" x-bind:style="`--editor-height: ${editorHeight}px`">
<div class="compose-editor-container min-h-[24rem] overflow-hidden rounded-lg border border-neutral-200 bg-white dark:border-white/[0.10] dark:bg-[#0b0b0c]"
style="--editor-height: clamp(24rem, calc(100dvh - 25rem), 48rem)">
<div x-cloak x-show="raw" class="font-mono">
<div x-cloak x-show="showNormalTextarea">
<x-forms.textarea x-bind:style="`height: ${editorHeight}px`" id="dockerComposeRaw">
</x-forms.textarea>
<x-forms.textarea class="min-h-[24rem] font-mono" style="height: var(--editor-height)"
id="dockerComposeRaw" />
</div>
<div x-cloak x-show="!showNormalTextarea">
<x-forms.textarea allowTab useMonacoEditor monacoEditorLanguage="yaml" id="dockerComposeRaw">
</x-forms.textarea>
<x-forms.textarea allowTab useMonacoEditor monacoEditorLanguage="yaml" id="dockerComposeRaw" />
</div>
</div>
<div x-cloak x-show="raw === false" class="font-mono">
<x-forms.textarea x-bind:style="`height: ${editorHeight}px`" readonly id="dockerCompose">
</x-forms.textarea>
<x-forms.textarea class="min-h-[24rem] font-mono" style="height: var(--editor-height)" readonly
id="dockerCompose" />
</div>
</div>
<div class="pt-2 flex gap-2">
<div class="flex flex-col gap-2">
<x-forms.checkbox label="Escape special characters in labels?"
helper="By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$.<br><br>If you want to use env variables inside the labels, turn this off."
id="isContainerLabelEscapeEnabled" instantSave></x-forms.checkbox>
<x-forms.checkbox label="Show Normal Textarea" x-model="showNormalTextarea"></x-forms.checkbox>
</div>
<div
class="flex flex-wrap items-center gap-2 rounded-lg border border-neutral-200 bg-neutral-50 p-1 dark:border-white/[0.08] dark:bg-white/[0.025]">
<x-forms.checkbox label="Escape special characters in labels"
helper="By default, $ (and other characters) is escaped. A $ in a label is saved as $$. Turn this off to use environment variables inside labels."
id="isContainerLabelEscapeEnabled" instantSave />
<x-forms.checkbox label="Use plain-text editor" id="showNormalTextarea" x-model="showNormalTextarea" />
</div>
<div class="flex w-full gap-2 pt-4">
<div x-cloak x-show="raw">
<x-forms.button class="w-64" @click.prevent="raw = !raw">Show Deployable Compose</x-forms.button>
</div>
<div x-cloak x-show="raw === false">
<x-forms.button class="w-64" @click.prevent="raw = !raw">Show Source
Compose</x-forms.button>
</div>
<div class="flex-1"></div>
@if (blank($service->service_type))
<x-forms.button class="w-28" wire:click.prevent='validateCompose'>
Validate
</x-forms.button>
@endif
<x-forms.button class="w-28" wire:click.prevent='saveEditedCompose'>
Save
</x-forms.button>
</div>
</div>
</div>
@@ -9,7 +9,26 @@
<x-status-badge label="Parser {{ $service->compose_parsing_version }}" type="neutral" />
@endif
@can('update', $service)
<x-modal-input buttonTitle="Edit Compose file" title="Edit Docker Compose" :closeOutside="false">
<x-modal-input buttonTitle="Edit Compose file" title="Docker Compose" :closeOutside="false"
:isLarge="true">
<x-slot:headerActions>
<div x-data="{ preview: false, saving: false }"
@compose-save-finished.window="saving = false" class="flex items-center gap-2">
<x-forms.button
@click="preview = !preview; $dispatch('compose-preview-toggle')">
<x-reicon name="eye" class="size-3.5" />
<span x-text="preview ? 'Back to source Compose' : 'Preview generated Compose'"></span>
</x-forms.button>
@if (blank($service->service_type))
<x-forms.button @click="$dispatch('compose-validate')">Validate</x-forms.button>
@endif
<x-forms.button @click="saving = true; $dispatch('compose-save')"
x-bind:disabled="saving" isHighlighted>
<x-loading-on-button x-show="saving" x-cloak />
Save changes
</x-forms.button>
</div>
</x-slot:headerActions>
<livewire:project.service.edit-compose serviceId="{{ $service->id }}" />
</x-modal-input>
@endcan
@@ -76,27 +76,17 @@
<x-forms.input id="description" label="Description" />
</div>
<div x-data="{ advancedOpen: false }"
class="mt-5 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<button type="button" @click="advancedOpen = !advancedOpen"
class="flex w-full cursor-pointer items-center justify-between gap-3 text-left text-[13px] font-medium text-neutral-600 dark:text-fg-dim"
:aria-expanded="advancedOpen">
Advanced settings
<x-reicon name="chevron-down" class="size-3.5 transition-transform"
x-bind:class="advancedOpen && 'rotate-180'" />
</button>
<div x-show="advancedOpen" x-cloak class="mt-4 flex flex-col gap-4">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input id="user" label="User" required
helper="Non-root SSH users are experimental." />
<x-forms.input type="number" id="port" label="Port" required />
</div>
<x-forms.checkbox id="is_build_server"
helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."
label="Use as a dedicated build server" />
<x-forms.collapsible class="mt-5 border-t border-neutral-200 pt-4 dark:border-white/[0.08]"
content-class="flex flex-col gap-4">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input id="user" label="User" required
helper="Non-root SSH users are experimental." />
<x-forms.input type="number" id="port" label="Port" required />
</div>
</div>
<x-forms.checkbox id="is_build_server"
helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."
label="Use as a dedicated build server" />
</x-forms.collapsible>
</x-application.settings-section>
</form>
@endif
+15 -1
View File
@@ -16,5 +16,19 @@ test('compose actions are grouped with the application details header', function
test('docker compose heading separates its title and action', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
expect($view)->toContain('<div class="flex items-center gap-4">');
expect($view)
->toContain('<div x-data="{ showRaw: true }" class="mt-5">')
->toContain('<div class="flex items-center justify-between gap-4">');
});
test('onboarding uses the reusable advanced settings component', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
$onboarding = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
expect($view)
->toContain('id="dockerComposeCustomBuildCommand"')
->not->toContain('<x-forms.collapsible class="pt-4" content-class="grid gap-4">')
->not->toContain('The following commands are for advanced use cases.')
->and($onboarding)
->toContain('<x-forms.collapsible title="Advanced Connection Settings"');
});
+64
View File
@@ -0,0 +1,64 @@
<?php
use Illuminate\Support\Facades\Blade;
it('uses the large modal treatment for the compose editor', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/stack-form.blade.php'));
$modal = file_get_contents(resource_path('views/components/modal-input.blade.php'));
expect($view)
->toContain('title="Docker Compose"')
->toContain(':isLarge="true"')
->toContain('<x-slot:headerActions>')
->toContain("\$dispatch('compose-preview-toggle')")
->toContain("\$dispatch('compose-save')")
->toContain('@compose-save-finished.window="saving = false"')
->toContain('<x-loading-on-button x-show="saving" x-cloak />')
->toContain('x-bind:disabled="saving"')
->not->toContain('name="refresh"')
->not->toContain("saving ? 'Saving...' : 'Save changes'")
->not->toContain(' :disabled="saving"')
->toContain('Preview generated Compose')
->toContain('Back to source Compose')
->toContain('Save changes')
->toContain('isHighlighted');
expect($modal)->toContain('lg:w-[95vw]! lg:max-w-7xl!');
});
it('renders the compose editor with clear guidance settings and actions', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/edit-compose.blade.php'));
expect($view)
->toContain('<x-callout type="info" title="Volume names">')
->not->toContain('View the final names')
->toContain('Use plain-text editor')
->toContain('min-h-[24rem]')
->toContain('@compose-preview-toggle.window')
->toContain('@compose-save.window')
->not->toContain("finally(() => \$dispatch('compose-save-finished'))")
->not->toContain('sticky bottom-0')
->not->toContain('Cancel')
->not->toContain('Show Normal Textarea')
->not->toContain('Show Deployable Compose');
});
it('keeps the save button loading until the parent compose save finishes', function () {
$component = file_get_contents(app_path('Livewire/Project/Service/StackForm.php'));
expect($component)
->toContain('public function saveCompose($raw)')
->toContain("\$this->dispatch('compose-save-finished')");
});
it('does not show a saving notification for compose changes', function () {
$component = file_get_contents(app_path('Livewire/Project/Service/EditCompose.php'));
expect($component)->not->toContain("\$this->dispatch('info', 'Saving new docker compose...')");
});
it('keeps the saving state as an Alpine button binding', function () {
$html = Blade::render('<x-forms.button x-bind:disabled="saving">Save changes</x-forms.button>');
expect($html)->toContain('x-bind:disabled="saving"');
});
@@ -5,10 +5,7 @@ test('server creation keeps private key actions together and advanced options co
expect($view)
->toContain('class="flex items-end gap-3"')
->toContain('x-data="{ advancedOpen: false }"')
->toContain('x-show="advancedOpen" x-cloak')
->toContain('Advanced settings')
->toContain('class="flex w-full cursor-pointer items-center justify-between gap-3')
->toContain('<x-forms.collapsible class="mt-5 border-t border-neutral-200 pt-4 dark:border-white/[0.08]"')
->toContain('label="Use as a dedicated build server"')
->toContain('helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."');
});