feat: add environment variable autocomplete component (#7282)

This commit is contained in:
Andras Bacsai 2025-11-25 11:22:00 +01:00 committed by GitHub
commit 17f4b126b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 562 additions and 4 deletions

View file

@ -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();

View file

@ -0,0 +1,89 @@
<?php
namespace App\View\Components\Forms;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Gate;
use Illuminate\View\Component;
use Visus\Cuid2\Cuid2;
class EnvVarInput extends Component
{
public ?string $modelBinding = null;
public ?string $htmlId = null;
public array $scopeUrls = [];
public function __construct(
public ?string $id = null,
public ?string $name = null,
public ?string $type = 'text',
public ?string $value = null,
public ?string $label = null,
public bool $required = false,
public bool $disabled = false,
public bool $readonly = false,
public ?string $helper = null,
public string $defaultClass = 'input',
public string $autocomplete = 'off',
public ?int $minlength = null,
public ?int $maxlength = null,
public bool $autofocus = false,
public ?string $canGate = null,
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) {
$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;
}
$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');
}
}

View file

@ -0,0 +1,268 @@
<div class="w-full">
@if ($label)
<label class="flex gap-1 items-center mb-1 text-sm font-medium">{{ $label }}
@if ($required)
<x-highlighted text="*" />
@endif
@if ($helper)
<x-helper :helper="$helper" />
@endif
</label>
@endif
<div x-data="{
showDropdown: false,
suggestions: [],
selectedIndex: 0,
cursorPosition: 0,
currentScope: null,
availableScopes: ['team', 'project', 'environment'],
availableVars: @js($availableVars),
scopeUrls: @js($scopeUrls),
isAutocompleteDisabled() {
const hasAnyVars = Object.values(this.availableVars).some(vars => vars.length > 0);
return !hasAnyVars;
},
handleInput() {
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 textBeforeCursor = value.substring(0, this.cursorPosition);
const openBraces = '{' + '{';
const lastBraceIndex = textBeforeCursor.lastIndexOf(openBraces);
if (lastBraceIndex === -1) {
this.showDropdown = false;
return;
}
if (lastBraceIndex > 0 && textBeforeCursor[lastBraceIndex - 1] === '{') {
this.showDropdown = false;
return;
}
const textAfterBrace = textBeforeCursor.substring(lastBraceIndex);
const closeBraces = '}' + '}';
if (textAfterBrace.includes(closeBraces)) {
this.showDropdown = false;
return;
}
const content = textAfterBrace.substring(2).trim();
if (content === '') {
this.currentScope = null;
this.suggestions = this.availableScopes.map(scope => ({
type: 'scope',
value: scope,
display: scope
}));
this.selectedIndex = 0;
this.showDropdown = true;
} else if (content.includes('.')) {
const [scope, partial] = content.split('.');
if (!this.availableScopes.includes(scope)) {
this.showDropdown = false;
return;
}
this.currentScope = scope;
const scopeVars = this.availableVars[scope] || [];
const filtered = scopeVars.filter(v =>
v.toLowerCase().includes((partial || '').toLowerCase())
);
if (filtered.length === 0 && scopeVars.length === 0) {
this.suggestions = [];
this.showDropdown = true;
} else {
this.suggestions = filtered.map(varName => ({
type: 'variable',
value: varName,
display: `${scope}.${varName}`,
scope: scope
}));
this.selectedIndex = 0;
this.showDropdown = filtered.length > 0;
}
} else {
this.currentScope = null;
const filtered = this.availableScopes.filter(scope =>
scope.toLowerCase().includes(content.toLowerCase())
);
this.suggestions = filtered.map(scope => ({
type: 'scope',
value: scope,
display: scope
}));
this.selectedIndex = 0;
this.showDropdown = filtered.length > 0;
}
},
getScopeUrl(scope) {
return this.scopeUrls[scope] || this.scopeUrls['default'];
},
selectSuggestion(suggestion) {
const input = this.$refs.input;
if (!input) return;
const value = input.value || '';
const textBeforeCursor = value.substring(0, this.cursorPosition);
const textAfterCursor = value.substring(this.cursorPosition);
const openBraces = '{' + '{';
const lastBraceIndex = textBeforeCursor.lastIndexOf(openBraces);
if (suggestion.type === 'scope') {
const newText = value.substring(0, lastBraceIndex) +
openBraces + ' ' + suggestion.value + '.' +
textAfterCursor;
input.value = newText;
this.cursorPosition = lastBraceIndex + 3 + suggestion.value.length + 1;
this.$nextTick(() => {
input.setSelectionRange(this.cursorPosition, this.cursorPosition);
input.focus();
this.handleInput();
});
} else {
const closeBraces = '}' + '}';
const newText = value.substring(0, lastBraceIndex) +
openBraces + ' ' + suggestion.display + ' ' + closeBraces +
textAfterCursor;
input.value = newText;
this.cursorPosition = lastBraceIndex + 3 + suggestion.display.length + 3;
input.dispatchEvent(new Event('input'));
this.showDropdown = false;
this.selectedIndex = 0;
this.$nextTick(() => {
input.setSelectionRange(this.cursorPosition, this.cursorPosition);
input.focus();
});
}
},
handleKeydown(event) {
if (!this.showDropdown) return;
if (!this.suggestions || this.suggestions.length === 0) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
this.selectedIndex = (this.selectedIndex + 1) % this.suggestions.length;
this.$nextTick(() => {
const el = document.getElementById('suggestion-' + this.selectedIndex);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this.selectedIndex = this.selectedIndex <= 0 ? this.suggestions.length - 1 : this.selectedIndex - 1;
this.$nextTick(() => {
const el = document.getElementById('suggestion-' + this.selectedIndex);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
} else if (event.key === 'Enter' && this.showDropdown) {
event.preventDefault();
if (this.suggestions[this.selectedIndex]) {
this.selectSuggestion(this.suggestions[this.selectedIndex]);
}
} else if (event.key === 'Escape') {
this.showDropdown = false;
}
}
}"
@click.outside="showDropdown = false"
class="relative">
<input
x-ref="input"
@input="handleInput()"
@keydown="handleKeydown($event)"
@click="handleInput()"
autocomplete="{{ $autocomplete }}"
{{ $attributes->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 --}}
<div x-show="showDropdown"
x-transition
class="absolute z-[60] w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg">
<template x-if="suggestions.length === 0 && currentScope">
<div class="px-3 py-2 text-sm text-neutral-500 dark:text-neutral-400">
<div>No shared variables found in <span class="font-semibold" x-text="currentScope"></span> scope.</div>
<a :href="getScopeUrl(currentScope)"
class="text-coollabs dark:text-warning hover:underline text-xs mt-1 inline-block"
target="_blank">
Add <span x-text="currentScope"></span> variables
</a>
</div>
</template>
<div x-show="suggestions.length > 0"
x-ref="dropdownList"
class="max-h-48 overflow-y-scroll"
style="scrollbar-width: thin;">
<template x-for="(suggestion, index) in suggestions" :key="index">
<div :id="'suggestion-' + index"
@click="selectSuggestion(suggestion)"
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200 flex items-center gap-2"
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': index === selectedIndex }">
<template x-if="suggestion.type === 'scope'">
<span class="text-xs px-2 py-0.5 bg-coollabs/10 dark:bg-warning/10 text-coollabs dark:text-warning rounded">
SCOPE
</span>
</template>
<template x-if="suggestion.type === 'variable'">
<span class="text-xs px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 rounded">
VAR
</span>
</template>
<span class="text-sm font-mono" x-text="suggestion.display"></span>
</div>
</template>
</div>
</div>
</div>
@if (!$label && $helper)
<x-helper :helper="$helper" />
@endif
@error($modelBinding)
<label class="label">
<span class="text-red-500 label-text-alt">{{ $message }}</span>
</label>
@enderror
</div>

View file

@ -1,8 +1,20 @@
<form class="flex flex-col w-full gap-2 rounded-sm" wire:submit='submit'>
<x-forms.input placeholder="NODE_ENV" id="key" label="Name" required />
<x-forms.textarea x-show="$wire.is_multiline === true" x-cloak id="value" label="Value" required />
<x-forms.input x-show="$wire.is_multiline === false" x-cloak placeholder="production" id="value"
x-bind:label="$wire.is_multiline === false && 'Value'" required />
@if ($is_multiline)
<x-forms.textarea id="value" label="Value" required />
@else
<x-forms.env-var-input placeholder="production" id="value" label="Value" required
:availableVars="$shared ? [] : $this->availableSharedVariables"
:projectUuid="data_get($parameters, 'project_uuid')"
:environmentUuid="data_get($parameters, 'environment_uuid')" />
@endif
@if (!$shared && !$is_multiline)
<div class="text-xs text-neutral-500 dark:text-neutral-400 -mt-1">
Tip: Type <span class="font-mono dark:text-warning text-coollabs">{{</span> to reference a shared environment
variable
</div>
@endif
@if (!$shared)
<x-forms.checkbox id="is_buildtime"
@ -22,4 +34,4 @@
<x-forms.button type="submit" @click="slideOverOpen=false">
Save
</x-forms.button>
</form>
</form>

View file

@ -0,0 +1,67 @@
<?php
use App\View\Components\Forms\EnvVarInput;
it('renders with default properties', function () {
$component = new EnvVarInput;
expect($component->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();
});

View file

@ -0,0 +1,53 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\Add;
use Illuminate\Support\Facades\Auth;
it('has availableSharedVariables computed property', function () {
$component = new Add;
// Check that the method exists
expect(method_exists($component, 'availableSharedVariables'))->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) {');
});