diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
index 53b55009e..a19837e16 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
@@ -7,6 +7,8 @@ use App\Models\EnvironmentVariable;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Str;
use Livewire\Component;
class All extends Component
@@ -25,6 +27,8 @@ class All extends Component
public string $view = 'normal';
+ public string $search = '';
+
public bool $is_env_sorting_enabled = false;
public bool $use_build_secrets = false;
@@ -35,6 +39,20 @@ class All extends Component
'environmentVariableDeleted' => 'refreshEnvs',
];
+ public function updatedSearch(): void
+ {
+ $this->clearEnvironmentVariableCaches();
+ }
+
+ private function clearEnvironmentVariableCaches(): void
+ {
+ unset($this->environmentVariables);
+ unset($this->environmentVariablesPreview);
+ unset($this->hardcodedEnvironmentVariables);
+ unset($this->hardcodedEnvironmentVariablesPreview);
+ unset($this->hasEnvironmentVariables);
+ }
+
public function mount()
{
$this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false);
@@ -65,8 +83,27 @@ class All extends Component
public function getEnvironmentVariablesProperty()
{
- $query = $this->resource->environment_variables()
- ->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
+ return $this->getEnvironmentVariables(false);
+ }
+
+ public function getEnvironmentVariablesPreviewProperty()
+ {
+ return $this->getEnvironmentVariables(true);
+ }
+
+ private function getEnvironmentVariables(bool $isPreview, bool $withSearch = true): Collection
+ {
+ $query = $isPreview
+ ? $this->resource->environment_variables_preview()
+ : $this->resource->environment_variables();
+
+ $query->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
+
+ if ($withSearch && $this->searchTerm() !== '') {
+ $escapedSearch = addcslashes(Str::lower($this->searchTerm()), '%_\\');
+
+ $query->whereRaw("LOWER(key) LIKE ? ESCAPE '\\'", ['%'.$escapedSearch.'%']);
+ }
if ($this->is_env_sorting_enabled) {
$query->orderBy('key');
@@ -77,18 +114,22 @@ class All extends Component
return $query->get();
}
- public function getEnvironmentVariablesPreviewProperty()
+ private function searchTerm(): string
{
- $query = $this->resource->environment_variables_preview()
- ->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
+ return trim($this->search);
+ }
- if ($this->is_env_sorting_enabled) {
- $query->orderBy('key');
- } else {
- $query->orderBy('order');
- }
+ public function getHasEnvironmentVariablesProperty(): bool
+ {
+ return $this->environmentVariables->isNotEmpty() ||
+ $this->environmentVariablesPreview->isNotEmpty() ||
+ $this->hardcodedEnvironmentVariables->isNotEmpty() ||
+ $this->hardcodedEnvironmentVariablesPreview->isNotEmpty();
+ }
- return $query->get();
+ public function getIsSearchActiveProperty(): bool
+ {
+ return $this->searchTerm() !== '';
}
public function getHardcodedEnvironmentVariablesProperty()
@@ -138,6 +179,12 @@ class All extends Component
return ! in_array($var['key'], $managedKeys);
});
+ if ($this->searchTerm() !== '') {
+ $hardcodedVars = $hardcodedVars->filter(function ($var) {
+ return str($var['key'])->contains($this->searchTerm(), true);
+ });
+ }
+
// Apply sorting based on is_env_sorting_enabled
if ($this->is_env_sorting_enabled) {
$hardcodedVars = $hardcodedVars->sortBy('key')->values();
@@ -149,9 +196,9 @@ class All extends Component
public function getDevView()
{
- $this->variables = $this->formatEnvironmentVariables($this->environmentVariables);
+ $this->variables = $this->formatEnvironmentVariables($this->getEnvironmentVariables(false, false));
if ($this->showPreview) {
- $this->variablesPreview = $this->formatEnvironmentVariables($this->environmentVariablesPreview);
+ $this->variablesPreview = $this->formatEnvironmentVariables($this->getEnvironmentVariables(true, false));
}
}
@@ -282,9 +329,7 @@ class All extends Component
$environment->order = $maxOrder + 1;
$environment->save();
- // Clear computed property cache to force refresh
- unset($this->environmentVariables);
- unset($this->environmentVariablesPreview);
+ $this->clearEnvironmentVariableCaches();
$this->dispatch('success', 'Environment variable added.');
}
@@ -413,9 +458,7 @@ class All extends Component
public function refreshEnvs()
{
$this->resource->refresh();
- // Clear computed property cache to force refresh
- unset($this->environmentVariables);
- unset($this->environmentVariablesPreview);
+ $this->clearEnvironmentVariableCaches();
$this->getDevView();
}
}
diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php
index 2ae3ad166..991327265 100644
--- a/resources/views/livewire/project/shared/environment-variable/all.blade.php
+++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php
@@ -43,38 +43,69 @@
@endif
@if ($view === 'normal')
-
No environment variables found.
- @endforelse
- @if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariables->isNotEmpty())
- @foreach ($this->hardcodedEnvironmentVariables as $index => $env)
-
-
Preview Deployments Environment Variables
-
Environment (secrets) variables for Preview Deployments.
+
+
- @foreach ($this->environmentVariablesPreview as $env)
-
- @endforeach
- @if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariablesPreview->isNotEmpty())
- @foreach ($this->hardcodedEnvironmentVariablesPreview as $index => $env)
-
+
+ @if ($this->isSearchActive && ! $this->hasEnvironmentVariables)
+
No environment variables found.
+ @else
+ @if ($this->environmentVariables->isNotEmpty() || $this->hardcodedEnvironmentVariables->isNotEmpty())
+
+
Production Environment Variables
+
Environment (secrets) variables for Production.
+
+ @foreach ($this->environmentVariables as $env)
+
@endforeach
+ @if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariables->isNotEmpty())
+ @foreach ($this->hardcodedEnvironmentVariables as $index => $env)
+
+ @endforeach
+ @endif
+ @endif
+ @if (
+ $resource->type() === 'application' &&
+ $showPreview &&
+ ($this->environmentVariablesPreview->isNotEmpty() || $this->hardcodedEnvironmentVariablesPreview->isNotEmpty())
+ )
+
+
Preview Deployments Environment Variables
+
Environment (secrets) variables for Preview Deployments.
+
+ @foreach ($this->environmentVariablesPreview as $env)
+
+ @endforeach
+ @if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariablesPreview->isNotEmpty())
+ @foreach ($this->hardcodedEnvironmentVariablesPreview as $index => $env)
+
+ @endforeach
+ @endif
@endif
@endif
@else
@@ -88,8 +119,9 @@
label="Production Environment Variables">
@if ($showPreview)
-
+
@endif
Save All Environment Variables
@@ -98,8 +130,9 @@
label="Production Environment Variables" disabled>
@if ($showPreview)
-
+
@endif
@endcan
diff --git a/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php b/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php
index e27fd0b4b..de97e6095 100644
--- a/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php
+++ b/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php
@@ -29,3 +29,28 @@ it('uses sans font for the developer bulk environment variable editor', function
->not->toContain('wire:model="variables" monospace')
->not->toContain('wire:model="variablesPreview" monospace');
});
+
+it('renders the environment variable search field above the production title', function () {
+ $view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
+
+ expect(strpos($view, 'aria-label="Search environment variables"'))
+ ->toBeLessThan(strpos($view, '
Production Environment Variables
'));
+});
+
+it('renders a single no results message for empty environment variable searches', function () {
+ $view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
+
+ expect($view)
+ ->toContain('@if ($this->isSearchActive && ! $this->hasEnvironmentVariables)')
+ ->toContain('
No environment variables found.
')
+ ->toContain('@else');
+});
+
+it('only renders the production section when production variables are visible', function () {
+ $view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
+
+ expect($view)
+ ->toContain('@if ($this->environmentVariables->isNotEmpty() || $this->hardcodedEnvironmentVariables->isNotEmpty())')
+ ->not->toContain('@forelse ($this->environmentVariables as $env)')
+ ->not->toContain('@empty');
+});
diff --git a/tests/Feature/EnvironmentVariableSearchTest.php b/tests/Feature/EnvironmentVariableSearchTest.php
new file mode 100644
index 000000000..613144d53
--- /dev/null
+++ b/tests/Feature/EnvironmentVariableSearchTest.php
@@ -0,0 +1,251 @@
+ 0]);
+
+ $this->user = User::factory()->create();
+ $this->team = Team::factory()->create();
+ $this->team->members()->attach($this->user, ['role' => 'owner']);
+ $this->project = Project::factory()->create([
+ 'team_id' => $this->team->id,
+ ]);
+ $this->environment = Environment::factory()->create([
+ 'project_id' => $this->project->id,
+ ]);
+
+ $this->actingAs($this->user);
+});
+
+it('filters production environment variables by key case-insensitively', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'API_KEY',
+ 'value' => 'secret',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'DATABASE_URL',
+ 'value' => 'postgres://example',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ ]);
+
+ $component = Livewire::test(All::class, ['resource' => $application])
+ ->set('search', 'api');
+
+ expect($component->instance()->environmentVariables->pluck('key')->all())
+ ->toBe(['API_KEY']);
+});
+
+it('treats production environment variable search wildcards literally', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'API_KEY',
+ 'value' => 'secret',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'APIXKEY',
+ 'value' => 'other-secret',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'PERCENT%KEY',
+ 'value' => 'percent-secret',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ ]);
+
+ $component = Livewire::test(All::class, ['resource' => $application])
+ ->set('search', 'api_key');
+
+ expect($component->instance()->environmentVariables->pluck('key')->all())
+ ->toBe(['API_KEY']);
+
+ $component->set('search', '%KEY');
+
+ expect($component->instance()->environmentVariables->pluck('key')->all())
+ ->toBe(['PERCENT%KEY']);
+});
+
+it('filters preview environment variables by key case-insensitively', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'PREVIEW_TOKEN',
+ 'value' => 'preview-secret',
+ 'is_preview' => true,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'OTHER_PREVIEW_VALUE',
+ 'value' => 'preview-other',
+ 'is_preview' => true,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ ]);
+
+ $component = Livewire::test(All::class, ['resource' => $application])
+ ->set('search', 'token');
+
+ expect($component->instance()->environmentVariablesPreview->pluck('key')->all())
+ ->toBe(['PREVIEW_TOKEN']);
+});
+
+it('filters hardcoded Docker Compose environment variables by key case-insensitively', function () {
+ $service = Service::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'docker_compose_raw' => <<<'YAML'
+services:
+ app:
+ image: nginx
+ environment:
+ API_TOKEN: hardcoded-secret
+ DATABASE_URL: postgres://example
+YAML,
+ ]);
+
+ $component = Livewire::test(All::class, ['resource' => $service])
+ ->set('search', 'api');
+
+ expect($component->instance()->hardcodedEnvironmentVariables->pluck('key')->all())
+ ->toBe(['API_TOKEN']);
+});
+
+it('does not show the empty production message when search only matches hardcoded variables', function () {
+ $service = Service::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'docker_compose_raw' => <<<'YAML'
+services:
+ app:
+ image: nginx
+ environment:
+ API_TOKEN: hardcoded-secret
+ DATABASE_URL: postgres://example
+YAML,
+ ]);
+
+ Livewire::test(All::class, ['resource' => $service])
+ ->set('search', 'api')
+ ->assertSee('Production Environment Variables')
+ ->assertSee('API_TOKEN')
+ ->assertDontSee('No environment variables found.');
+});
+
+it('keeps developer view unfiltered after searching', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'API_KEY',
+ 'value' => 'secret',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'DATABASE_URL',
+ 'value' => 'postgres://example',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ ]);
+
+ $component = Livewire::test(All::class, ['resource' => $application])
+ ->set('search', 'api')
+ ->call('switch')
+ ->assertSet('view', 'dev');
+
+ expect($component->get('variables'))
+ ->toContain('API_KEY=secret')
+ ->toContain('DATABASE_URL=postgres://example');
+});
+
+it('does not delete non-matching variables when saving developer view after searching', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'API_KEY',
+ 'value' => 'secret',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'DATABASE_URL',
+ 'value' => 'postgres://example',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ ]);
+
+ Livewire::test(All::class, ['resource' => $application])
+ ->set('search', 'api')
+ ->call('switch')
+ ->call('submit');
+
+ expect($application->environment_variables()->pluck('key')->all())
+ ->toContain('API_KEY')
+ ->toContain('DATABASE_URL');
+});
+
+it('hides the preview section when search filters out all preview variables', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'API_KEY',
+ 'value' => 'secret',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ ]);
+
+ $application->environment_variables_preview()->where('key', 'API_KEY')->delete();
+
+ EnvironmentVariable::create([
+ 'key' => 'PREVIEW_TOKEN',
+ 'value' => 'preview-secret',
+ 'is_preview' => true,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ ]);
+
+ Livewire::test(All::class, ['resource' => $application])
+ ->set('search', 'api')
+ ->assertSee('Production Environment Variables')
+ ->assertSee('API_KEY')
+ ->assertDontSee('Preview Deployments Environment Variables')
+ ->assertDontSee('PREVIEW_TOKEN');
+});