mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
feat(ui): create InputWithSelect component and enhance resource limits page
- Replace standard input fields with input-select components for Soft Memory Limit, Maximum Memory Limit, and Maximum Swap Limit. - Update helper texts to provide clearer guidance on memory settings and link to Docker documentation. - Ensure all memory inputs support unit selection (B, KiB, MiB, GiB) for better user experience. - Add unit tests for new component
This commit is contained in:
parent
51301fd12e
commit
ef0e33b9b9
4 changed files with 349 additions and 9 deletions
82
app/View/Components/Forms/InputWithSelect.php
Normal file
82
app/View/Components/Forms/InputWithSelect.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
namespace App\View\Components\Forms;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\View\Component;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
class InputWithSelect extends Component
|
||||
{
|
||||
public ?string $modelBinding = null;
|
||||
|
||||
public ?string $htmlId = null;
|
||||
|
||||
public function __construct(
|
||||
public ?string $id = null,
|
||||
public ?string $name = null,
|
||||
public ?string $type = 'text',
|
||||
public ?string $value = null,
|
||||
public ?string $label = null,
|
||||
public array $options = [],
|
||||
public ?string $defaultOption = null,
|
||||
public bool $required = false,
|
||||
public bool $disabled = false,
|
||||
public bool $readonly = false,
|
||||
public ?string $helper = null,
|
||||
public ?string $placeholder = null,
|
||||
public string $defaultClass = 'input',
|
||||
public string $autocomplete = 'off',
|
||||
public ?int $minlength = null,
|
||||
public ?int $maxlength = null,
|
||||
public ?float $min = null,
|
||||
public ?float $max = null,
|
||||
public bool $autofocus = false,
|
||||
public ?string $canGate = null,
|
||||
public mixed $canResource = null,
|
||||
public bool $autoDisable = true,
|
||||
) {
|
||||
// Handle authorization-based disabling
|
||||
if ($this->canGate && $this->canResource && $this->autoDisable) {
|
||||
$hasPermission = Gate::allows($this->canGate, $this->canResource);
|
||||
|
||||
if (!$hasPermission) {
|
||||
$this->disabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
// Store original ID for wire:model binding (property name)
|
||||
$this->modelBinding = $this->id;
|
||||
|
||||
if (is_null($this->id)) {
|
||||
$this->id = new Cuid2;
|
||||
// Don't create wire:model binding for auto-generated IDs
|
||||
$this->modelBinding = 'null';
|
||||
}
|
||||
// Generate unique HTML ID by adding random suffix
|
||||
// This prevents duplicate IDs when multiple forms are on the same page
|
||||
if ($this->modelBinding && $this->modelBinding !== 'null') {
|
||||
// Use original ID with random suffix for uniqueness
|
||||
$uniqueSuffix = new Cuid2;
|
||||
$this->htmlId = $this->modelBinding . '-' . $uniqueSuffix;
|
||||
} else {
|
||||
$this->htmlId = (string) $this->id;
|
||||
}
|
||||
|
||||
if (is_null($this->name)) {
|
||||
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
|
||||
}
|
||||
|
||||
// Set default option if not provided and options exist
|
||||
if (is_null($this->defaultOption) && !empty($this->options)) {
|
||||
$this->defaultOption = array_key_first($this->options);
|
||||
}
|
||||
|
||||
return view('components.forms.input-with-select');
|
||||
}
|
||||
}
|
||||
169
resources/views/components/forms/input-with-select.blade.php
Normal file
169
resources/views/components/forms/input-with-select.blade.php
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
@php
|
||||
$inputId = $htmlId !== 'null' ? $htmlId . '-input' : null;
|
||||
$selectId = $htmlId !== 'null' ? $htmlId . '-select' : null;
|
||||
@endphp
|
||||
|
||||
<div class="w-full"
|
||||
x-data="inputWithSelect({
|
||||
defaultUnit: @js($defaultOption ?? ''),
|
||||
min: @js($min),
|
||||
max: @js($max),
|
||||
@if ($modelBinding !== 'null')
|
||||
combinedValue: @entangle($modelBinding),
|
||||
@else
|
||||
combinedValue: @js($value ?? '0'),
|
||||
@endif
|
||||
})"
|
||||
x-ref="container">
|
||||
@if ($label)
|
||||
<label @if ($inputId) for="{{ $inputId }}" @endif class="flex gap-1 items-center mb-1 text-sm font-medium">
|
||||
{{ $label }}
|
||||
@if ($required)
|
||||
<x-highlighted text="*" />
|
||||
@endif
|
||||
@if ($helper)
|
||||
<x-helper :helper="$helper" />
|
||||
@endif
|
||||
</label>
|
||||
@endif
|
||||
|
||||
<div class="flex">
|
||||
{{-- Input --}}
|
||||
<input
|
||||
type="{{ $type }}"
|
||||
@if ($inputId) id="{{ $inputId }}" @endif
|
||||
x-model="inputValue"
|
||||
@input="handleInputChange()"
|
||||
@blur="handleInputBlur($event)"
|
||||
@disabled($disabled)
|
||||
@readonly($readonly)
|
||||
placeholder="{{ $placeholder }}"
|
||||
autocomplete="{{ $autocomplete }}"
|
||||
name="{{ $name }}-input"
|
||||
@if ($min !== null) min="{{ $min }}" @endif
|
||||
@if ($max !== null) max="{{ $max }}" @endif
|
||||
minlength="{{ $minlength }}"
|
||||
maxlength="{{ $maxlength }}"
|
||||
class="{{ $defaultClass }} rounded-r-none flex-1 border-r-0"
|
||||
@if ($autofocus) x-ref="autofocusInput" @endif
|
||||
aria-label="{{ $label }}"
|
||||
x-ref="input"
|
||||
/>
|
||||
|
||||
{{-- Select --}}
|
||||
<select
|
||||
@if ($selectId) id="{{ $selectId }}" @endif
|
||||
x-model="selectValue"
|
||||
@change="updateCombined()"
|
||||
@disabled($disabled)
|
||||
name="{{ $name }}-select"
|
||||
class="select rounded-l-none w-auto min-w-[70px] border-l-0"
|
||||
aria-label="{{ $label }} unit"
|
||||
>
|
||||
@foreach($options as $key => $display)
|
||||
<option value="{{ $key }}">{{ $display }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (!$label && $helper)
|
||||
<x-helper :helper="$helper" />
|
||||
@endif
|
||||
@error($modelBinding)
|
||||
<label class="label">
|
||||
<span class="text-red-500 label-text-alt">{{ $message }}</span>
|
||||
</label>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('inputWithSelect', (config) => ({
|
||||
inputValue: '',
|
||||
selectValue: config.defaultUnit,
|
||||
combinedValue: config.combinedValue,
|
||||
pendingSync: null,
|
||||
|
||||
init() {
|
||||
this.parseValue(this.combinedValue);
|
||||
|
||||
// Watch for external changes to combinedValue (from Livewire)
|
||||
this.$watch('combinedValue', (newVal) => {
|
||||
if (newVal !== this.combined()) {
|
||||
this.parseValue(newVal);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.pendingSync) {
|
||||
clearTimeout(this.pendingSync);
|
||||
}
|
||||
},
|
||||
|
||||
parseValue(val) {
|
||||
if (!val || val === '0') {
|
||||
this.inputValue = '';
|
||||
this.selectValue = config.defaultUnit;
|
||||
return;
|
||||
}
|
||||
const match = String(val).match(/^([\d.]+)\s*(.*)$/);
|
||||
if (match) {
|
||||
this.inputValue = match[1];
|
||||
this.selectValue = match[2] || config.defaultUnit;
|
||||
} else {
|
||||
this.inputValue = val;
|
||||
}
|
||||
},
|
||||
|
||||
validateAndClamp() {
|
||||
const numValue = parseFloat(this.inputValue);
|
||||
if (isNaN(numValue)) return;
|
||||
if (config.min !== null && numValue < config.min) {
|
||||
this.inputValue = String(config.min);
|
||||
} else if (config.max !== null && numValue > config.max) {
|
||||
this.inputValue = String(config.max);
|
||||
}
|
||||
},
|
||||
|
||||
combined() {
|
||||
if (!this.inputValue) return '0';
|
||||
return this.inputValue + this.selectValue;
|
||||
},
|
||||
|
||||
updateCombined() {
|
||||
this.validateAndClamp();
|
||||
this.combinedValue = this.combined();
|
||||
},
|
||||
|
||||
handleInputChange() {
|
||||
// Validate/clamp immediately as user types
|
||||
this.validateAndClamp();
|
||||
|
||||
// Debounce the combined value update
|
||||
if (this.pendingSync) {
|
||||
clearTimeout(this.pendingSync);
|
||||
}
|
||||
this.pendingSync = setTimeout(() => {
|
||||
if (this.pendingSync !== null && document.activeElement === this.$refs.input) {
|
||||
this.combinedValue = this.combined();
|
||||
}
|
||||
this.pendingSync = null;
|
||||
}, 500);
|
||||
},
|
||||
|
||||
handleInputBlur(event) {
|
||||
if (this.pendingSync) {
|
||||
clearTimeout(this.pendingSync);
|
||||
this.pendingSync = null;
|
||||
}
|
||||
const relatedTarget = event.relatedTarget;
|
||||
const container = this.$refs.container;
|
||||
if (relatedTarget && container && container.contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
this.updateCombined();
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
|
@ -20,21 +20,33 @@
|
|||
<h3 class="pt-4">Limit Memory</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$resource"
|
||||
helper="Examples: 69b (byte) or 420k (kilobyte) or 1337m (megabyte) or 1g (gigabyte).<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/compose/compose-file/05-services/#mem_reservation'>here</a>."
|
||||
label="Soft Memory Limit" id="limitsMemoryReservation" />
|
||||
<x-forms.input-with-select canGate="update" :canResource="$resource"
|
||||
type="number"
|
||||
min="0"
|
||||
helper="Guaranteed memory reservation for the container. Docker attempts to ensure this amount is always available.<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/compose/compose-file/05-services/#mem_reservation'>here</a>."
|
||||
label="Soft Memory Limit" id="limitsMemoryReservation"
|
||||
:options="['b' => 'B', 'k' => 'KiB', 'm' => 'MiB', 'g' => 'GiB']"
|
||||
defaultOption="m" />
|
||||
<x-forms.input canGate="update" :canResource="$resource"
|
||||
helper="0-100.<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/compose/compose-file/05-services/#mem_swappiness'>here</a>."
|
||||
type="number" min="0" max="100" label="Swappiness"
|
||||
id="limitsMemorySwappiness" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$resource"
|
||||
helper="Examples: 69b (byte) or 420k (kilobyte) or 1337m (megabyte) or 1g (gigabyte).<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/compose/compose-file/05-services/#mem_limit'>here</a>."
|
||||
label="Maximum Memory Limit" id="limitsMemory" />
|
||||
<x-forms.input canGate="update" :canResource="$resource"
|
||||
helper="Examples:69b (byte) or 420k (kilobyte) or 1337m (megabyte) or 1g (gigabyte).<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/compose/compose-file/05-services/#memswap_limit'>here</a>."
|
||||
label="Maximum Swap Limit" id="limitsMemorySwap" />
|
||||
<x-forms.input-with-select canGate="update" :canResource="$resource"
|
||||
type="number"
|
||||
min="0"
|
||||
helper="Hard limit on container memory usage. The container will be killed if it exceeds this limit.<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/compose/compose-file/05-services/#mem_limit'>here</a>."
|
||||
label="Maximum Memory Limit" id="limitsMemory"
|
||||
:options="['b' => 'B', 'k' => 'KiB', 'm' => 'MiB', 'g' => 'GiB']"
|
||||
defaultOption="m" />
|
||||
<x-forms.input-with-select canGate="update" :canResource="$resource"
|
||||
type="number"
|
||||
min="0"
|
||||
helper="Total limit for memory plus swap space. Combined limit for both RAM and swap usage.<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/compose/compose-file/05-services/#memswap_limit'>here</a>."
|
||||
label="Maximum Swap Limit" id="limitsMemorySwap"
|
||||
:options="['b' => 'B', 'k' => 'KiB', 'm' => 'MiB', 'g' => 'GiB']"
|
||||
defaultOption="m" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
77
tests/Unit/InputWithSelectComponentTest.php
Normal file
77
tests/Unit/InputWithSelectComponentTest.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
use App\View\Components\Forms\InputWithSelect;
|
||||
|
||||
it('renders with default properties', function () {
|
||||
$component = new InputWithSelect;
|
||||
|
||||
expect($component->required)->toBeFalse()
|
||||
->and($component->disabled)->toBeFalse()
|
||||
->and($component->readonly)->toBeFalse()
|
||||
->and($component->defaultClass)->toBe('input')
|
||||
->and($component->type)->toBe('text')
|
||||
->and($component->options)->toBe([]);
|
||||
});
|
||||
|
||||
it('uses provided id', function () {
|
||||
$component = new InputWithSelect(id: 'test-input-select');
|
||||
|
||||
expect($component->id)->toBe('test-input-select');
|
||||
});
|
||||
|
||||
it('accepts options array', function () {
|
||||
$options = ['b' => 'B', 'k' => 'KiB', 'm' => 'MiB', 'g' => 'GiB'];
|
||||
$component = new InputWithSelect(options: $options);
|
||||
|
||||
expect($component->options)->toBe($options);
|
||||
});
|
||||
|
||||
it('sets default option to first option when not provided', function () {
|
||||
$options = ['b' => 'B', 'k' => 'KiB', 'm' => 'MiB'];
|
||||
$component = new InputWithSelect(options: $options);
|
||||
|
||||
// defaultOption is set in render(), so we test the logic directly
|
||||
if (is_null($component->defaultOption) && !empty($component->options)) {
|
||||
$component->defaultOption = array_key_first($component->options);
|
||||
}
|
||||
|
||||
expect($component->defaultOption)->toBe('b');
|
||||
});
|
||||
|
||||
it('uses provided default option', function () {
|
||||
$options = ['b' => 'B', 'k' => 'KiB', 'm' => 'MiB'];
|
||||
$component = new InputWithSelect(options: $options, defaultOption: 'm');
|
||||
|
||||
expect($component->defaultOption)->toBe('m');
|
||||
});
|
||||
|
||||
it('accepts min and max values', function () {
|
||||
$component = new InputWithSelect(min: 0, max: 100);
|
||||
|
||||
expect($component->min)->toBe(0.0)
|
||||
->and($component->max)->toBe(100.0);
|
||||
});
|
||||
|
||||
it('accepts type parameter', function () {
|
||||
$component = new InputWithSelect(type: 'number');
|
||||
|
||||
expect($component->type)->toBe('number');
|
||||
});
|
||||
|
||||
it('accepts authorization properties', function () {
|
||||
$component = new InputWithSelect(
|
||||
canGate: 'update',
|
||||
canResource: 'resource',
|
||||
autoDisable: false
|
||||
);
|
||||
|
||||
expect($component->canGate)->toBe('update')
|
||||
->and($component->canResource)->toBe('resource')
|
||||
->and($component->autoDisable)->toBeFalse();
|
||||
});
|
||||
|
||||
it('can be manually disabled', function () {
|
||||
$component = new InputWithSelect(disabled: true);
|
||||
|
||||
expect($component->disabled)->toBeTrue();
|
||||
});
|
||||
Loading…
Reference in a new issue