diff --git a/.ai/lessons.md b/.ai/lessons.md
index de73b119be..e0f315ae8e 100644
--- a/.ai/lessons.md
+++ b/.ai/lessons.md
@@ -5,6 +5,7 @@
- When a symptom matches an earlier fix, inspect that fix and prove why it no longer works before adding another workaround.
- Test old reports against the current branch because later changes can make the report obsolete.
- Use the same regression test before and after the production change so the result shows the behavior difference.
+- Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`.
## Verify the complete user flow
- Do not use a passing unit test, a successful build, or a healthy process as proof for a reported UI failure.
@@ -49,3 +50,6 @@
## Fail closed at public webhook boundaries
- Reject missing or blank secrets before signature verification, and return generic errors without logging secrets, signatures, or payloads.
+
+## Pass identities to Livewire actions
+- Pass record IDs to Livewire actions instead of display values, and resolve team-scoped records on the server. When JavaScript needs text, use `@js()` or `Js::from()`.
diff --git a/app/Livewire/Project/Shared/Tags.php b/app/Livewire/Project/Shared/Tags.php
index 61d04e20b5..6b3493ace9 100644
--- a/app/Livewire/Project/Shared/Tags.php
+++ b/app/Livewire/Project/Shared/Tags.php
@@ -67,17 +67,17 @@ class Tags extends Component
}
}
- public function addTag(string $id, string $name)
+ public function addTag(string $id)
{
try {
$this->authorize('update', $this->resource);
- $name = strip_tags($name);
- if ($this->resource->tags()->where('id', $id)->exists()) {
- $this->dispatch('error', 'Duplicate tags.', "Tag $name already added.");
+ $tag = Tag::ownedByCurrentTeam()->findOrFail($id);
+ if ($this->resource->tags()->whereKey($tag->id)->exists()) {
+ $this->dispatch('error', 'Duplicate tags.', 'Tag '.e($tag->name).' already added.');
return;
}
- $this->resource->tags()->attach($id);
+ $this->resource->tags()->attach($tag->id);
$this->refresh();
$this->dispatch('success', 'Tag added.');
} catch (\Exception $e) {
diff --git a/resources/views/livewire/project/shared/tags.blade.php b/resources/views/livewire/project/shared/tags.blade.php
index 1fb1c9d6ad..1debd351b1 100644
--- a/resources/views/livewire/project/shared/tags.blade.php
+++ b/resources/views/livewire/project/shared/tags.blade.php
@@ -52,7 +52,7 @@
@foreach ($filteredTags as $tag)
+ wire:click="addTag('{{ $tag->id }}')">
{{ $tag->name }}
diff --git a/tests/Feature/TagQuickAddTest.php b/tests/Feature/TagQuickAddTest.php
new file mode 100644
index 0000000000..1289268aef
--- /dev/null
+++ b/tests/Feature/TagQuickAddTest.php
@@ -0,0 +1,81 @@
+ 0]);
+ $this->team = Team::factory()->create();
+ $this->admin = User::factory()->create();
+ $this->admin->teams()->attach($this->team, ['role' => 'admin']);
+ $this->member = User::factory()->create();
+ $this->member->teams()->attach($this->team, ['role' => 'member']);
+
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ $destination = StandaloneDocker::where('server_id', $server->id)->first();
+ $project = Project::factory()->create(['team_id' => $this->team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+ $this->application = Application::factory()->create([
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => $destination->getMorphClass(),
+ ]);
+
+ $this->actingAs($this->admin);
+ session(['currentTeam' => $this->team]);
+});
+
+test('quick add passes only the tag id to the action', function () {
+ $name = "o'reilly";
+ Livewire::test(Tags::class, ['resource' => $this->application])
+ ->set('newTags', $name)
+ ->call('submit')
+ ->assertNotDispatched('error');
+
+ $tag = Tag::ownedByCurrentTeam()->where('name', $name)->firstOrFail();
+ $otherApplication = Application::factory()->create([
+ 'environment_id' => $this->application->environment_id,
+ 'destination_id' => $this->application->destination_id,
+ 'destination_type' => $this->application->destination_type,
+ ]);
+
+ $html = Livewire::test(Tags::class, ['resource' => $otherApplication])->html();
+ expect($html)->toContain("wire:click=\"addTag('{$tag->id}')\"")
+ ->not->toMatch('/wire:click="[^"]*reilly/');
+});
+
+test('addTag accepts only a tag owned by the current team', function () {
+ $ownTag = Tag::create(['name' => 'own-tag', 'team_id' => $this->team->id]);
+ $otherTag = Tag::create(['name' => 'other-tag', 'team_id' => Team::factory()->create()->id]);
+
+ Livewire::test(Tags::class, ['resource' => $this->application])
+ ->call('addTag', (string) $otherTag->id);
+ expect($this->application->tags()->whereKey($otherTag->id)->exists())->toBeFalse();
+
+ Livewire::test(Tags::class, ['resource' => $this->application])
+ ->call('addTag', (string) $ownTag->id);
+ expect($this->application->tags()->whereKey($ownTag->id)->exists())->toBeTrue();
+});
+
+test('member cannot create a tag through resource settings', function () {
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ Livewire::test(Tags::class, ['resource' => $this->application])
+ ->set('newTags', 'member-tag')
+ ->call('submit')
+ ->assertDispatched('error');
+ expect(Tag::where('name', 'member-tag')->exists())->toBeFalse();
+});
diff --git a/tests/v4/Browser/TagQuickAddTest.php b/tests/v4/Browser/TagQuickAddTest.php
new file mode 100644
index 0000000000..87b0e64bae
--- /dev/null
+++ b/tests/v4/Browser/TagQuickAddTest.php
@@ -0,0 +1,35 @@
+ 'Source App']);
+ $target = createBrowserApplication($stack, ['name' => 'Target App']);
+ $source->tags()->attach($tag = Tag::create(['name' => "o'reilly", 'team_id' => 0]));
+
+ $page = visit('/login')
+ ->fill('email', 'test@example.com')
+ ->fill('password', 'password')
+ ->click('Login')
+ ->assertSee('Dashboard')
+ ->navigate(route('project.application.tags', [
+ 'project_uuid' => $stack['project']->uuid,
+ 'environment_uuid' => $stack['environment']->uuid,
+ 'application_uuid' => $target->uuid,
+ ]))
+ ->assertSee("o'reilly");
+
+ $page->script(<<<'JS'
+ () => [...document.querySelectorAll('#available-tags-section button')]
+ .find(button => button.textContent.includes('reilly'))
+ .click()
+ JS);
+ $page->wait(1.5);
+
+ expect($target->tags()->whereKey($tag->id)->exists())->toBeTrue();
+ $page->screenshot(filename: 'tag-quick-add');
+});