feat(ui): add search functionality for environment variables (#10421)

This commit is contained in:
Andras Bacsai
2026-06-03 13:48:03 +02:00
committed by GitHub
4 changed files with 405 additions and 53 deletions
@@ -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();
}
}
@@ -43,38 +43,69 @@
@endif
</div>
@if ($view === 'normal')
<div>
<h3>Production Environment Variables</h3>
<div>Environment (secrets) variables for Production.</div>
</div>
@forelse ($this->environmentVariables as $env)
<livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}" :env="$env"
:type="$resource->type()" />
@empty
<div>No environment variables found.</div>
@endforelse
@if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariables->isNotEmpty())
@foreach ($this->hardcodedEnvironmentVariables as $index => $env)
<livewire:project.shared.environment-variable.show-hardcoded
wire:key="hardcoded-prod-{{ $env['key'] }}-{{ $env['service_name'] ?? 'default' }}-{{ $index }}"
:env="$env" />
@endforeach
@endif
@if ($resource->type() === 'application' && $resource->environment_variables_preview->count() > 0 && $showPreview)
<div>
<h3>Preview Deployments Environment Variables</h3>
<div>Environment (secrets) variables for Preview Deployments.</div>
<div class="w-full md:w-96">
<div class="relative">
<input type="search" placeholder="Search" aria-label="Search environment variables"
wire:model.live.debounce.300ms="search" class="w-full input pl-10" />
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<div class="relative w-4 h-4">
<svg wire:loading.remove wire:target="search" aria-hidden="true"
class="absolute inset-0 w-4 h-4 dark:text-neutral-400" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<svg wire:loading wire:target="search" aria-hidden="true"
class="absolute inset-0 w-4 h-4 text-coollabs dark:text-warning animate-spin" fill="none"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4" />
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
</div>
</div>
@foreach ($this->environmentVariablesPreview as $env)
<livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}" :env="$env"
:type="$resource->type()" />
@endforeach
@if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariablesPreview->isNotEmpty())
@foreach ($this->hardcodedEnvironmentVariablesPreview as $index => $env)
<livewire:project.shared.environment-variable.show-hardcoded
wire:key="hardcoded-preview-{{ $env['key'] }}-{{ $env['service_name'] ?? 'default' }}-{{ $index }}"
:env="$env" />
</div>
@if ($this->isSearchActive && ! $this->hasEnvironmentVariables)
<div>No environment variables found.</div>
@else
@if ($this->environmentVariables->isNotEmpty() || $this->hardcodedEnvironmentVariables->isNotEmpty())
<div>
<h3>Production Environment Variables</h3>
<div>Environment (secrets) variables for Production.</div>
</div>
@foreach ($this->environmentVariables as $env)
<livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}" :env="$env"
:type="$resource->type()" />
@endforeach
@if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariables->isNotEmpty())
@foreach ($this->hardcodedEnvironmentVariables as $index => $env)
<livewire:project.shared.environment-variable.show-hardcoded
wire:key="hardcoded-prod-{{ $env['key'] }}-{{ $env['service_name'] ?? 'default' }}-{{ $index }}" :env="$env" />
@endforeach
@endif
@endif
@if (
$resource->type() === 'application' &&
$showPreview &&
($this->environmentVariablesPreview->isNotEmpty() || $this->hardcodedEnvironmentVariablesPreview->isNotEmpty())
)
<div>
<h3>Preview Deployments Environment Variables</h3>
<div>Environment (secrets) variables for Preview Deployments.</div>
</div>
@foreach ($this->environmentVariablesPreview as $env)
<livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}" :env="$env"
:type="$resource->type()" />
@endforeach
@if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariablesPreview->isNotEmpty())
@foreach ($this->hardcodedEnvironmentVariablesPreview as $index => $env)
<livewire:project.shared.environment-variable.show-hardcoded
wire:key="hardcoded-preview-{{ $env['key'] }}-{{ $env['service_name'] ?? 'default' }}-{{ $index }}"
:env="$env" />
@endforeach
@endif
@endif
@endif
@else
@@ -88,8 +119,9 @@
label="Production Environment Variables"></x-forms.textarea>
@if ($showPreview)
<x-forms.textarea rows="10" class="whitespace-pre-wrap font-sans" label="Preview Deployments Environment Variables"
id="variablesPreview" wire:model="variablesPreview"></x-forms.textarea>
<x-forms.textarea rows="10" class="whitespace-pre-wrap font-sans"
label="Preview Deployments Environment Variables" id="variablesPreview"
wire:model="variablesPreview"></x-forms.textarea>
@endif
<x-forms.button type="submit" class="btn btn-primary">Save All Environment Variables</x-forms.button>
@@ -98,8 +130,9 @@
label="Production Environment Variables" disabled></x-forms.textarea>
@if ($showPreview)
<x-forms.textarea rows="10" class="whitespace-pre-wrap font-sans" label="Preview Deployments Environment Variables"
id="variablesPreview" wire:model="variablesPreview" disabled></x-forms.textarea>
<x-forms.textarea rows="10" class="whitespace-pre-wrap font-sans"
label="Preview Deployments Environment Variables" id="variablesPreview" wire:model="variablesPreview"
disabled></x-forms.textarea>
@endif
@endcan
</form>
@@ -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, '<h3>Production Environment Variables</h3>'));
});
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('<div>No environment variables found.</div>')
->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');
});
@@ -0,0 +1,251 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\All;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Service;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 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');
});