From d1b30ce704b97a650bbd275d923d9c7aee9e8ead Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:26:45 +0200 Subject: [PATCH] fix(tags): pass only the tag id when quick adding a tag The available tags list now sends only the tag id to the Livewire action. The component resolves the tag from the current team, so tag names with special characters, such as apostrophes, work. Co-Authored-By: Claude Opus 5.5 --- .ai/lessons.md | 4 + app/Livewire/Project/Shared/Tags.php | 10 +-- .../livewire/project/shared/tags.blade.php | 2 +- tests/Feature/TagQuickAddTest.php | 81 +++++++++++++++++++ tests/v4/Browser/TagQuickAddTest.php | 35 ++++++++ 5 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 tests/Feature/TagQuickAddTest.php create mode 100644 tests/v4/Browser/TagQuickAddTest.php 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'); +});