fix(storage): prevent PR suffix dropdown clipping (#11637)

This commit is contained in:
Andras Bacsai 2026-09-05 15:58:12 +02:00 committed by GitHub
parent 3764771293
commit 08f68016dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 223 additions and 68 deletions

View file

@ -2673,7 +2673,10 @@ @media (max-width: 768px) {
}
.volumes-col-backup {
align-items: flex-start;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
}
.volumes-cell-actions {

View file

@ -123,7 +123,8 @@
{{ $attributes->whereStartsWith('x-model') }}
{{ $attributes->whereStartsWith('x-effect') }}
@if ($preserveValue) wire:ignore @endif
@click.outside="open = false" @keydown.escape="open = false" @resize.window="open && positionPanel()">
@click.outside="open = false" @keydown.escape="open = false" @resize.window="open && positionPanel()"
@scroll.window.capture="open && positionPanel()">
<button x-ref="trigger" id="{{ $triggerId }}" type="button" class="listbox-trigger" @click="toggle()"
@disabled($disabled) {{ $attributes->whereStartsWith('x-bind:disabled') }} aria-haspopup="listbox"
:aria-expanded="open" @if ($tooltip) :title="current" @endif>
@ -134,33 +135,35 @@
</svg>
</button>
@if ($portal)
<div id="{{ $panelId }}" class="listbox-panel"
style="position: fixed; z-index: 9999; visibility: hidden" x-show="open"
x-cloak :style="{ visibility: positioned ? 'visible' : 'hidden' }"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-1 scale-[0.98]"
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 -translate-y-1 scale-[0.98]"
x-effect="if (open) requestAnimationFrame(() => positionPanel($el))" role="listbox">
<div x-show="options.length === 0"
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $emptyText }}
<template x-teleport="body">
<div id="{{ $panelId }}" class="listbox-panel"
style="position: fixed; z-index: 9999; visibility: hidden" x-show="open"
x-cloak :style="{ visibility: positioned ? 'visible' : 'hidden' }"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-1 scale-[0.98]"
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 -translate-y-1 scale-[0.98]"
x-effect="if (open) requestAnimationFrame(() => positionPanel($el))" role="listbox">
<div x-show="options.length === 0"
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $emptyText }}
</div>
<template x-for="option in options" :key="String(option.value)">
<button type="button" class="listbox-option" role="option"
:class="{ 'listbox-option-disabled': option.disabled }"
:aria-selected="String(option.value) === String(value)" @click="choose(option)">
<span class="truncate" x-text="option.label"></span>
<svg x-show="String(option.value) === String(value)" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"
class="size-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</button>
</template>
</div>
<template x-for="option in options" :key="String(option.value)">
<button type="button" class="listbox-option" role="option"
:class="{ 'listbox-option-disabled': option.disabled }"
:aria-selected="String(option.value) === String(value)" @click="choose(option)">
<span class="truncate" x-text="option.label"></span>
<svg x-show="String(option.value) === String(value)" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"
class="size-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</button>
</template>
</div>
</template>
@else
<div x-ref="panel" class="listbox-panel" x-show="open" x-cloak
x-transition:enter="transition ease-out duration-100"

View file

@ -21,10 +21,10 @@ class="border-b border-neutral-200 px-4 py-3 text-[13px] leading-5 text-amber-80
<span class="volumes-col-source">Source Path</span>
<span>Destination Path</span>
@if ($supportsPreviewSuffix)
<span class="volumes-col-pr"
title="Whether preview deployments receive an isolated -pr-N volume suffix.">
PR suffix
</span>
<div class="volumes-col-pr flex items-center gap-1.5">
<span>PR suffix</span>
<x-helper helper="Adds -pr-N to the storage name or path so each preview uses isolated data. Disabling it shares production data with previews." />
</div>
@endif
<span class="volumes-col-backup text-center">Backup</span>
@if ($showActionsColumn)
@ -74,7 +74,10 @@ class="block min-w-0 truncate text-[13px] text-neutral-950 dark:text-fg"
@if ($supportsPreviewSuffix)
<div class="volumes-col-pr min-w-0">
<span class="volumes-mobile-label volumes-field-label">PR suffix</span>
<div class="volumes-mobile-label volumes-field-label flex items-center gap-1.5">
<span>PR suffix</span>
<x-helper helper="Adds -pr-N to the storage name or path so each preview uses isolated data. Disabling it shares production data with previews." />
</div>
<span>{{ $form['isPreviewSuffixEnabled'] ? 'Add suffix' : 'Share volume' }}</span>
</div>
@endif
@ -82,16 +85,22 @@ class="block min-w-0 truncate text-[13px] text-neutral-950 dark:text-fg"
<div class="volumes-col-backup flex items-center justify-center gap-1.5">
<span class="volumes-mobile-label volumes-field-label">Backup</span>
@if ($hasEnabledBackup)
<a @if ($backupUrl) href="{{ $backupUrl }}" @endif title="Volume backup is enabled">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</a>
<span @class(['table-badge', 'table-badge-success' => $hasS3Backup])
title="{{ $hasS3Backup ? 'Backups are saved to S3' : 'Backups are stored locally only' }}">
{{ $hasS3Backup ? 'S3' : 'Local' }}
</span>
@if ($backupUrl)
<a href="{{ $backupUrl }}" @class([
'table-badge underline-offset-2 hover:underline',
'table-badge-success' => $hasS3Backup,
])
title="Volume backup is enabled"
aria-label="{{ $hasS3Backup ? 'Backups are saved to S3' : 'Backups are stored locally only' }}">
{{ $hasS3Backup ? 'S3' : 'Local' }}
</a>
@else
<span @class(['table-badge', 'table-badge-success' => $hasS3Backup])
title="Volume backup is enabled"
aria-label="{{ $hasS3Backup ? 'Backups are saved to S3' : 'Backups are stored locally only' }}">
{{ $hasS3Backup ? 'S3' : 'Local' }}
</span>
@endif
@else
<span class="data-table-cell-dash">-</span>
@endif
@ -103,10 +112,10 @@ class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center just
@if ($canUpdate)
<x-modal-input title="Configure Volume Backup" :wireIgnore="false">
<x-slot:content>
<button type="button" class="icon-button" title="Configure backup"
aria-label="Configure backup">
<x-reicon name="database" class="size-4" />
</button>
<x-forms.button type="button" class="!px-2.5 !text-xs" canGate="update"
:canResource="$resource">
Backup
</x-forms.button>
</x-slot:content>
@if ($resource instanceof \App\Models\Application)
<livewire:project.application.backup.create :application="$resource"
@ -165,27 +174,36 @@ class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center just
@if ($supportsPreviewSuffix)
<div class="volumes-col-pr min-w-0">
<span class="volumes-mobile-label volumes-field-label">PR suffix</span>
<x-forms.listbox id="forms.{{ $id }}.isPreviewSuffixEnabled" :options="[
<div class="volumes-mobile-label volumes-field-label flex items-center gap-1.5">
<span>PR suffix</span>
<x-helper helper="Adds -pr-N to the storage name or path so each preview uses isolated data. Disabling it shares production data with previews." />
</div>
<x-forms.listbox id="forms.{{ $id }}.isPreviewSuffixEnabled" portal :options="[
['value' => true, 'label' => 'Add suffix'],
['value' => false, 'label' => 'Share volume'],
]" />
]" canGate="update" :canResource="$resource" />
</div>
@endif
<div class="volumes-col-backup flex items-center justify-center gap-1.5">
<span class="volumes-mobile-label volumes-field-label">Backup</span>
@if ($hasEnabledBackup)
<a @if ($backupUrl) href="{{ $backupUrl }}" @endif title="Volume backup is enabled">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</a>
<span @class(['table-badge', 'table-badge-success' => $hasS3Backup])
title="{{ $hasS3Backup ? 'Backups are saved to S3' : 'Backups are stored locally only' }}">
{{ $hasS3Backup ? 'S3' : 'Local' }}
</span>
@if ($backupUrl)
<a href="{{ $backupUrl }}" @class([
'table-badge underline-offset-2 hover:underline',
'table-badge-success' => $hasS3Backup,
])
title="Volume backup is enabled"
aria-label="{{ $hasS3Backup ? 'Backups are saved to S3' : 'Backups are stored locally only' }}">
{{ $hasS3Backup ? 'S3' : 'Local' }}
</a>
@else
<span @class(['table-badge', 'table-badge-success' => $hasS3Backup])
title="Volume backup is enabled"
aria-label="{{ $hasS3Backup ? 'Backups are saved to S3' : 'Backups are stored locally only' }}">
{{ $hasS3Backup ? 'S3' : 'Local' }}
</span>
@endif
@else
<span class="data-table-cell-dash">-</span>
@endif
@ -200,10 +218,10 @@ class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center just
@if ($showBackupAction)
<x-modal-input title="Configure Volume Backup" :wireIgnore="false">
<x-slot:content>
<button type="button" class="icon-button" title="Configure backup"
aria-label="Configure backup">
<x-reicon name="database" class="size-4" />
</button>
<x-forms.button type="button" class="!px-2.5 !text-xs" canGate="update"
:canResource="$resource">
Backup
</x-forms.button>
</x-slot:content>
@if ($resource instanceof \App\Models\Application)
<livewire:project.application.backup.create :application="$resource"
@ -218,10 +236,10 @@ class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center just
@elseif (method_exists($resource, 'isBackupSolutionAvailable') && $resource->isBackupSolutionAvailable())
<x-modal-input title="New Scheduled Backup" :wireIgnore="false">
<x-slot:content>
<button type="button" class="icon-button" title="Configure backup"
aria-label="Configure backup">
<x-reicon name="database" class="size-4" />
</button>
<x-forms.button type="button" class="!px-2.5 !text-xs" canGate="update"
:canResource="$resource">
Backup
</x-forms.button>
</x-slot:content>
<livewire:project.database.create-scheduled-backup :database="$resource"
wire:key="configure-database-backup-{{ $id }}" />

View file

@ -78,6 +78,13 @@
->toContain('Math.max(triggerRect.width, panel.offsetWidth)');
});
test('portaled listboxes stay anchored when a scroll container moves', function () {
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
expect($listbox)
->toContain('@scroll.window.capture="open && positionPanel()"');
});
test('searchable listbox component uses shared trigger label truncation', function () {
$html = Blade::render(<<<'BLADE'
<x-forms.searchable-listbox id="tz" label="Timezone"

View file

@ -211,11 +211,79 @@ function createApplicationWithVolume(array $applicationAttributes = [], array $v
->toContain('12rem')
->not->toContain('17.5rem');
expect($allView)
->toContain("'table-badge', 'table-badge-success' => \$hasS3Backup")
->not->toContain('<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />');
expect($css)->toMatch('/@media \(max-width: 768px\)[\s\S]*?\.volumes-col-backup\s*\{[^}]*flex-direction:\s*row;[^}]*align-items:\s*center;/');
// Settings form labels are 13px (not Tailwind text-sm 14px).
expect($css)
->toMatch('/\.application-settings-form label\s*\{[^}]*font-size:\s*13px/s');
});
it('renders volume actions and PR suffix controls as valid markup', function () {
[$application] = createApplicationWithVolume();
LocalPersistentVolume::create([
'uuid' => (string) Str::uuid(),
'name' => $application->uuid.'-cache',
'mount_path' => '/cache',
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
'is_preview_suffix_enabled' => true,
]);
$html = Livewire::test(All::class, ['resource' => $application])->html();
$document = new DOMDocument;
$previousState = libxml_use_internal_errors(true);
$loaded = $document->loadHTML($html);
libxml_clear_errors();
libxml_use_internal_errors($previousState);
$xpath = new DOMXPath($document);
$helperText = 'Adds -pr-N to the storage name or path so each preview uses isolated data. Disabling it shares production data with previews.';
expect($loaded)->toBeTrue()
->and($xpath->query("//*[contains(concat(' ', normalize-space(@class), ' '), ' volumes-col-actions ')]//button[normalize-space(.)='Backup']"))->toHaveCount(2)
->and($xpath->query("//*[contains(concat(' ', normalize-space(@class), ' '), ' volumes-col-actions ')]//button[normalize-space(.)='Backup']//svg"))->toHaveCount(0)
->and($xpath->query("//button[@aria-label='More information']/following-sibling::*[@role='tooltip'][contains(normalize-space(.), '{$helperText}')]"))->toHaveCount(3)
->and($xpath->query("//template[@x-teleport='body']/*[@role='listbox']"))->toHaveCount(2);
});
it('declares explicit authorization on the changed storage controls', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
preg_match(
'/<x-forms\.listbox\s+id="forms\.\{\{ \$id \}\}\.isPreviewSuffixEnabled"[\s\S]*?\/>/',
$view,
$previewSuffixListbox
);
expect($previewSuffixListbox[0] ?? '')
->toContain('canGate="update"')
->toContain(':canResource="$resource"');
preg_match_all('/<x-forms\.button\b[^>]*>\s*Backup\s*<\/x-forms\.button>/s', $view, $backupButtons);
expect($backupButtons[0])->toHaveCount(3);
foreach ($backupButtons[0] as $backupButton) {
expect($backupButton)
->toContain('canGate="update"')
->toContain(':canResource="$resource"');
}
});
it('uses valid block wrappers around PR suffix helpers', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
expect($view)
->not->toContain('<span class="volumes-col-pr flex items-center gap-1.5">')
->not->toContain('<span class="volumes-mobile-label volumes-field-label flex items-center gap-1.5">');
expect(substr_count($view, '<x-helper helper="Adds -pr-N to the storage name or path so each preview uses isolated data. Disabling it shares production data with previews." />'))
->toBe(3);
});
it('creates and exposes volume backups for service storage', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,

View file

@ -1,5 +1,6 @@
<?php
use App\Models\LocalPersistentVolume;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Visus\Cuid2\Cuid2;
@ -37,6 +38,61 @@
->screenshot(filename: 'application-configuration-overview');
});
it('keeps the PR suffix listbox visible outside the volumes table while scrolling', function () {
foreach (range(1, 8) as $index) {
LocalPersistentVolume::create([
'uuid' => (string) new Cuid2,
'name' => $this->application->uuid.'-data-'.$index,
'mount_path' => '/data/'.$index,
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
'is_preview_suffix_enabled' => true,
]);
}
loginAndSkipBoarding();
$url = applicationConfigurationUrl(
$this->stack['project'],
$this->stack['environment'],
$this->application
).'/persistent-storage';
$page = visit($url);
$page->assertSee('PR suffix')
->assertSee('Add suffix')
->assertScript(<<<'JS'
() => {
const table = document.querySelector('.data-table');
const trigger = document.querySelector('[id$="isPreviewSuffixEnabled-trigger"]');
window.__volumeTableScrollHeight = table.scrollHeight;
trigger.click();
return true;
}
JS)
->wait(0.2)
->assertScript(<<<'JS'
() => {
const table = document.querySelector('.data-table');
const panel = document.querySelector('[id$="isPreviewSuffixEnabled-panel"]');
const beforeScroll = panel.getBoundingClientRect();
window.scrollBy(0, 100);
const afterScroll = panel.getBoundingClientRect();
return panel.parentElement === document.body
&& table.scrollHeight === window.__volumeTableScrollHeight
&& window.scrollY > 0
&& beforeScroll.top >= 0
&& beforeScroll.bottom <= window.innerHeight
&& afterScroll.top >= 0
&& afterScroll.bottom <= window.innerHeight;
}
JS)
->assertNoJavaScriptErrors()
->screenshot(filename: 'application-persistent-storage-pr-suffix-listbox');
});
it('saves application name description and ports from the general form', function () {
loginAndSkipBoarding();