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:
Dominic 2026-01-11 03:48:05 +01:00 committed by Dominic Schmid
parent 51301fd12e
commit ef0e33b9b9
4 changed files with 349 additions and 9 deletions

View 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');
}
}

View 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>

View file

@ -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>

View 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();
});