From 08f68016dd342fe944564f79969f4ceceddc88d6 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Sat, 5 Sep 2026 15:58:12 +0200
Subject: [PATCH] fix(storage): prevent PR suffix dropdown clipping (#11637)
---
resources/css/app.css | 5 +-
.../views/components/forms/listbox.blade.php | 57 ++++++-----
.../project/shared/storages/all.blade.php | 98 +++++++++++--------
.../Feature/ListboxTriggerTruncationTest.php | 7 ++
.../PersistentStorageVolumesLayoutTest.php | 68 +++++++++++++
.../Browser/ApplicationConfigurationTest.php | 56 +++++++++++
6 files changed, 223 insertions(+), 68 deletions(-)
diff --git a/resources/css/app.css b/resources/css/app.css
index 40af521c69..06f0b251af 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -2673,7 +2673,10 @@ input[type="search"]::-webkit-search-results-decoration {
}
.volumes-col-backup {
- align-items: flex-start;
+ flex-direction: row;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 0.5rem;
}
.volumes-cell-actions {
diff --git a/resources/views/components/forms/listbox.blade.php b/resources/views/components/forms/listbox.blade.php
index d04a5f57e5..4c572596d6 100644
--- a/resources/views/components/forms/listbox.blade.php
+++ b/resources/views/components/forms/listbox.blade.php
@@ -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()">
@if ($portal)
-
-
- {{ $emptyText }}
+
+
+
+ {{ $emptyText }}
+
+
+
+
-
-
-
-
+
@else
Source Path
Destination Path
@if ($supportsPreviewSuffix)
-
- PR suffix
-
+
+ PR suffix
+
+
@endif
Backup
@if ($showActionsColumn)
@@ -74,7 +74,10 @@
@if ($supportsPreviewSuffix)
-
PR suffix
+
+ PR suffix
+
+
{{ $form['isPreviewSuffixEnabled'] ? 'Add suffix' : 'Share volume' }}
@endif
@@ -82,16 +85,22 @@
+
+ ]" canGate="update" :canResource="$resource" />
@endif
Backup
@if ($hasEnabledBackup)
-
-
-
-
$hasS3Backup])
- title="{{ $hasS3Backup ? 'Backups are saved to S3' : 'Backups are stored locally only' }}">
- {{ $hasS3Backup ? 'S3' : 'Local' }}
-
+ @if ($backupUrl)
+
$hasS3Backup,
+ ])
+ title="Volume backup is enabled"
+ aria-label="{{ $hasS3Backup ? 'Backups are saved to S3' : 'Backups are stored locally only' }}">
+ {{ $hasS3Backup ? 'S3' : 'Local' }}
+
+ @else
+
$hasS3Backup])
+ title="Volume backup is enabled"
+ aria-label="{{ $hasS3Backup ? 'Backups are saved to S3' : 'Backups are stored locally only' }}">
+ {{ $hasS3Backup ? 'S3' : 'Local' }}
+
+ @endif
@else
-
@endif
@@ -200,10 +218,10 @@
@if ($showBackupAction)
-
+
+ Backup
+
@if ($resource instanceof \App\Models\Application)
isBackupSolutionAvailable())
-
+
+ Backup
+
diff --git a/tests/Feature/ListboxTriggerTruncationTest.php b/tests/Feature/ListboxTriggerTruncationTest.php
index 82e526bb3b..a7cd462954 100644
--- a/tests/Feature/ListboxTriggerTruncationTest.php
+++ b/tests/Feature/ListboxTriggerTruncationTest.php
@@ -78,6 +78,13 @@ test('portaled listboxes measure content without inheriting the viewport width',
->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'
toContain('12rem')
->not->toContain('17.5rem');
+ expect($allView)
+ ->toContain("'table-badge', 'table-badge-success' => \$hasS3Backup")
+ ->not->toContain('');
+
+ 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(
+ '//',
+ $view,
+ $previewSuffixListbox
+ );
+
+ expect($previewSuffixListbox[0] ?? '')
+ ->toContain('canGate="update"')
+ ->toContain(':canResource="$resource"');
+
+ preg_match_all('/]*>\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('')
+ ->not->toContain('');
+
+ expect(substr_count($view, ''))
+ ->toBe(3);
+});
+
it('creates and exposes volume backups for service storage', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
diff --git a/tests/v4/Browser/ApplicationConfigurationTest.php b/tests/v4/Browser/ApplicationConfigurationTest.php
index fa7fb4ffa3..161f96f661 100644
--- a/tests/v4/Browser/ApplicationConfigurationTest.php
+++ b/tests/v4/Browser/ApplicationConfigurationTest.php
@@ -1,5 +1,6 @@
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();