From c79b5f1e5c26f4ad0f7fd571985933e56a9d80b8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 19 Nov 2025 10:54:19 +0100 Subject: [PATCH 1/3] feat: add environment variable autocomplete component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new EnvVarInput component that provides autocomplete suggestions for shared environment variables from team, project, and environment scopes. Users can reference variables using {{ syntax. 🤖 Generated with Claude Code Co-Authored-By: Claude --- .../Shared/EnvironmentVariable/Add.php | 69 +++++ app/View/Components/Forms/EnvVarInput.php | 73 +++++ .../components/forms/env-var-input.blade.php | 273 ++++++++++++++++++ .../shared/environment-variable/add.blade.php | 14 +- tests/Unit/EnvVarInputComponentTest.php | 67 +++++ .../EnvironmentVariableAutocompleteTest.php | 53 ++++ 6 files changed, 546 insertions(+), 3 deletions(-) create mode 100644 app/View/Components/Forms/EnvVarInput.php create mode 100644 resources/views/components/forms/env-var-input.blade.php create mode 100644 tests/Unit/EnvVarInputComponentTest.php create mode 100644 tests/Unit/Livewire/EnvironmentVariableAutocompleteTest.php diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index 5f5e12e0a..fa65e8bd2 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -2,8 +2,11 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Models\Environment; +use App\Models\Project; use App\Traits\EnvironmentVariableAnalyzer; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Computed; use Livewire\Component; class Add extends Component @@ -56,6 +59,72 @@ public function mount() $this->problematicVariables = self::getProblematicVariablesForFrontend(); } + #[Computed] + public function availableSharedVariables(): array + { + $team = currentTeam(); + $result = [ + 'team' => [], + 'project' => [], + 'environment' => [], + ]; + + // Early return if no team + if (! $team) { + return $result; + } + + // Check if user can view team variables + try { + $this->authorize('view', $team); + $result['team'] = $team->environment_variables() + ->pluck('key') + ->toArray(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view team variables + } + + // Get project variables if we have a project_uuid in route + $projectUuid = data_get($this->parameters, 'project_uuid'); + if ($projectUuid) { + $project = Project::where('team_id', $team->id) + ->where('uuid', $projectUuid) + ->first(); + + if ($project) { + try { + $this->authorize('view', $project); + $result['project'] = $project->environment_variables() + ->pluck('key') + ->toArray(); + + // Get environment variables if we have an environment_uuid in route + $environmentUuid = data_get($this->parameters, 'environment_uuid'); + if ($environmentUuid) { + $environment = $project->environments() + ->where('uuid', $environmentUuid) + ->first(); + + if ($environment) { + try { + $this->authorize('view', $environment); + $result['environment'] = $environment->environment_variables() + ->pluck('key') + ->toArray(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view environment variables + } + } + } + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view project variables + } + } + } + + return $result; + } + public function submit() { $this->validate(); diff --git a/app/View/Components/Forms/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php new file mode 100644 index 000000000..6b37e3a7b --- /dev/null +++ b/app/View/Components/Forms/EnvVarInput.php @@ -0,0 +1,73 @@ +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; + } + + return view('components.forms.env-var-input'); + } +} diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php new file mode 100644 index 000000000..c621f566b --- /dev/null +++ b/resources/views/components/forms/env-var-input.blade.php @@ -0,0 +1,273 @@ +
+ @if ($label) + + @endif + +
+ + merge(['class' => $defaultClass]) }} + @required($required) + @readonly($readonly) + @if ($modelBinding !== 'null') + wire:model="{{ $modelBinding }}" + wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" + @endif + wire:loading.attr="disabled" + type="{{ $type }}" + @disabled($disabled) + @if ($htmlId !== 'null') id={{ $htmlId }} @endif + name="{{ $name }}" + placeholder="{{ $attributes->get('placeholder') }}" + @if ($autofocus) autofocus @endif> + + {{-- Dropdown for suggestions --}} +
+ + + + + +
+ +
+
+
+ + @if (!$label && $helper) + + @endif + @error($modelBinding) + + @enderror +
diff --git a/resources/views/livewire/project/shared/environment-variable/add.blade.php b/resources/views/livewire/project/shared/environment-variable/add.blade.php index 353fe02de..2016c8c9f 100644 --- a/resources/views/livewire/project/shared/environment-variable/add.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/add.blade.php @@ -1,8 +1,16 @@
- + + + @if (!$shared) +
+ Tip: Type {{ to reference a shared environment + variable +
+ @endif @if (!$shared) Save - + \ No newline at end of file diff --git a/tests/Unit/EnvVarInputComponentTest.php b/tests/Unit/EnvVarInputComponentTest.php new file mode 100644 index 000000000..f4fc8bcb5 --- /dev/null +++ b/tests/Unit/EnvVarInputComponentTest.php @@ -0,0 +1,67 @@ +required)->toBeFalse() + ->and($component->disabled)->toBeFalse() + ->and($component->readonly)->toBeFalse() + ->and($component->defaultClass)->toBe('input') + ->and($component->availableVars)->toBe([]); +}); + +it('uses provided id', function () { + $component = new EnvVarInput(id: 'env-key'); + + expect($component->id)->toBe('env-key'); +}); + +it('accepts available vars', function () { + $vars = [ + 'team' => ['DATABASE_URL', 'API_KEY'], + 'project' => ['STRIPE_KEY'], + 'environment' => ['DEBUG'], + ]; + + $component = new EnvVarInput(availableVars: $vars); + + expect($component->availableVars)->toBe($vars); +}); + +it('accepts required parameter', function () { + $component = new EnvVarInput(required: true); + + expect($component->required)->toBeTrue(); +}); + +it('accepts disabled state', function () { + $component = new EnvVarInput(disabled: true); + + expect($component->disabled)->toBeTrue(); +}); + +it('accepts readonly state', function () { + $component = new EnvVarInput(readonly: true); + + expect($component->readonly)->toBeTrue(); +}); + +it('accepts autofocus parameter', function () { + $component = new EnvVarInput(autofocus: true); + + expect($component->autofocus)->toBeTrue(); +}); + +it('accepts authorization properties', function () { + $component = new EnvVarInput( + canGate: 'update', + canResource: 'resource', + autoDisable: false + ); + + expect($component->canGate)->toBe('update') + ->and($component->canResource)->toBe('resource') + ->and($component->autoDisable)->toBeFalse(); +}); diff --git a/tests/Unit/Livewire/EnvironmentVariableAutocompleteTest.php b/tests/Unit/Livewire/EnvironmentVariableAutocompleteTest.php new file mode 100644 index 000000000..19da8b43b --- /dev/null +++ b/tests/Unit/Livewire/EnvironmentVariableAutocompleteTest.php @@ -0,0 +1,53 @@ +toBeTrue(); +}); + +it('component has required properties for environment variable autocomplete', function () { + $component = new Add; + + expect($component)->toHaveProperty('key') + ->and($component)->toHaveProperty('value') + ->and($component)->toHaveProperty('is_multiline') + ->and($component)->toHaveProperty('is_literal') + ->and($component)->toHaveProperty('is_runtime') + ->and($component)->toHaveProperty('is_buildtime') + ->and($component)->toHaveProperty('parameters'); +}); + +it('returns empty arrays when currentTeam returns null', function () { + // Mock Auth facade to return null for user + Auth::shouldReceive('user') + ->andReturn(null); + + $component = new Add; + $component->parameters = []; + + $result = $component->availableSharedVariables(); + + expect($result)->toBe([ + 'team' => [], + 'project' => [], + 'environment' => [], + ]); +}); + +it('availableSharedVariables method wraps authorization checks in try-catch blocks', function () { + // Read the source code to verify the authorization pattern + $reflectionMethod = new ReflectionMethod(Add::class, 'availableSharedVariables'); + $source = file_get_contents($reflectionMethod->getFileName()); + + // Verify that the method contains authorization checks + expect($source)->toContain('$this->authorize(\'view\', $team)') + ->and($source)->toContain('$this->authorize(\'view\', $project)') + ->and($source)->toContain('$this->authorize(\'view\', $environment)') + // Verify authorization checks are wrapped in try-catch blocks + ->and($source)->toContain('} catch (\Illuminate\Auth\Access\AuthorizationException $e) {'); +}); From 4147cfa537dbb0f34c234bbd5e656bf89756eebd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:11:49 +0100 Subject: [PATCH 2/3] refactor: use Laravel route() helper for shared variable URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace hardcoded URL paths in getScopeUrl() with Laravel's route() helper - Add scopeUrls property to EnvVarInput component with named routes - Pass projectUuid and environmentUuid to enable context-specific environment links - Environment scope link now navigates to the specific project/environment shared variables page 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/View/Components/Forms/EnvVarInput.php | 16 ++++++++++++++++ .../components/forms/env-var-input.blade.php | 13 ++++--------- .../shared/environment-variable/add.blade.php | 4 +++- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/app/View/Components/Forms/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php index 6b37e3a7b..7cf8ee8fa 100644 --- a/app/View/Components/Forms/EnvVarInput.php +++ b/app/View/Components/Forms/EnvVarInput.php @@ -14,6 +14,8 @@ class EnvVarInput extends Component public ?string $htmlId = null; + public array $scopeUrls = []; + public function __construct( public ?string $id = null, public ?string $name = null, @@ -33,6 +35,8 @@ public function __construct( public mixed $canResource = null, public bool $autoDisable = true, public array $availableVars = [], + public ?string $projectUuid = null, + public ?string $environmentUuid = null, ) { // Handle authorization-based disabling if ($this->canGate && $this->canResource && $this->autoDisable) { @@ -68,6 +72,18 @@ public function render(): View|Closure|string $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id; } + $this->scopeUrls = [ + 'team' => route('shared-variables.team.index'), + 'project' => route('shared-variables.project.index'), + 'environment' => $this->projectUuid && $this->environmentUuid + ? route('shared-variables.environment.show', [ + 'project_uuid' => $this->projectUuid, + 'environment_uuid' => $this->environmentUuid, + ]) + : route('shared-variables.environment.index'), + 'default' => route('shared-variables.index'), + ]; + return view('components.forms.env-var-input'); } } diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php index c621f566b..5639cdbca 100644 --- a/resources/views/components/forms/env-var-input.blade.php +++ b/resources/views/components/forms/env-var-input.blade.php @@ -18,6 +18,7 @@ currentScope: null, availableScopes: ['team', 'project', 'environment'], availableVars: @js($availableVars), + scopeUrls: @js($scopeUrls), isAutocompleteDisabled() { const hasAnyVars = Object.values(this.availableVars).some(vars => vars.length > 0); @@ -28,13 +29,14 @@ const input = this.$refs.input; if (!input) return; + const value = input.value || ''; + if (this.isAutocompleteDisabled()) { this.showDropdown = false; return; } this.cursorPosition = input.selectionStart || 0; - const value = input.value || ''; const textBeforeCursor = value.substring(0, this.cursorPosition); const openBraces = '{' + '{'; @@ -107,14 +109,7 @@ }, getScopeUrl(scope) { - if (scope === 'team') { - return '/shared-variables/team'; - } else if (scope === 'project') { - return '/shared-variables/projects'; - } else if (scope === 'environment') { - return '/shared-variables/environments'; - } - return '/shared-variables'; + return this.scopeUrls[scope] || this.scopeUrls['default']; }, selectSuggestion(suggestion) { diff --git a/resources/views/livewire/project/shared/environment-variable/add.blade.php b/resources/views/livewire/project/shared/environment-variable/add.blade.php index 2016c8c9f..6b6c660ec 100644 --- a/resources/views/livewire/project/shared/environment-variable/add.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/add.blade.php @@ -3,7 +3,9 @@ + :availableVars="$shared ? [] : $this->availableSharedVariables" + :projectUuid="data_get($parameters, 'project_uuid')" + :environmentUuid="data_get($parameters, 'environment_uuid')" /> @if (!$shared)
From 92dff0c0c7367ce8603811d41a7b9834fc0e1dc4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:55:37 +0100 Subject: [PATCH 3/3] fix: prevent divide-by-zero in env-var autocomplete navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a critical bug in the environment variable autocomplete component where arrow key navigation could cause divide-by-zero errors when the suggestions array is empty. Changes: - Add early guard in handleKeydown to check for empty suggestions array before performing modulo operations - Remove unreachable "No suggestions" template that could never display - Add validation to hide dropdown when user types third brace ({{{) - Refactor add.blade.php to use @if directives instead of x-show for better performance and cleaner code The fix ensures arrow keys do nothing when suggestions are empty, preventing JavaScript errors while maintaining all existing functionality including the scoped empty state messages with helpful links. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../components/forms/env-var-input.blade.php | 12 ++++++------ .../shared/environment-variable/add.blade.php | 18 ++++++++++-------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php index 5639cdbca..0859db78d 100644 --- a/resources/views/components/forms/env-var-input.blade.php +++ b/resources/views/components/forms/env-var-input.blade.php @@ -47,6 +47,11 @@ return; } + if (lastBraceIndex > 0 && textBeforeCursor[lastBraceIndex - 1] === '{') { + this.showDropdown = false; + return; + } + const textAfterBrace = textBeforeCursor.substring(lastBraceIndex); const closeBraces = '}' + '}'; if (textAfterBrace.includes(closeBraces)) { @@ -156,6 +161,7 @@ handleKeydown(event) { if (!this.showDropdown) return; + if (!this.suggestions || this.suggestions.length === 0) return; if (event.key === 'ArrowDown') { event.preventDefault(); @@ -225,12 +231,6 @@ class="text-coollabs dark:text-warning hover:underline text-xs mt-1 inline-block
- -
- - + @if ($is_multiline) + + @else + + @endif - @if (!$shared) -
+ @if (!$shared && !$is_multiline) +
Tip: Type {{ to reference a shared environment variable