From 4f32548e09c7dfe03057b0bb4e01203bc97e4d57 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:19:18 +0200 Subject: [PATCH] feat(tags): connect resources to a tag from the tag page Add a "Connect to resources" modal to the tag show page for small screens, where the tags canvas is hidden. A new toggleResource action attaches or detaches the tag on a team resource after an authorization check, then refreshes the tag's applications and services. Also improve responsive layouts: - Wrap section headings, project/environment toolbars and view toggles on narrow screens; keep modal headers from shrinking. - Move the environment settings link to an icon next to the environment name and link the project name back to its environments. - Group project icon, name and description into one "Project details" form with an unsaved-changes bar. - Show dashboard "view all" links for projects and servers only when there are more items than the dashboard limit. --- app/Livewire/Tags/Show.php | 60 ++++++++++++- .../components/canvas-view-toggle.blade.php | 2 +- .../views/components/modal-input.blade.php | 2 +- .../components/section-heading.blade.php | 4 +- resources/views/livewire/dashboard.blade.php | 4 +- .../views/livewire/project/edit.blade.php | 84 +++++++++---------- .../views/livewire/project/index.blade.php | 2 +- .../livewire/project/resource/index.blade.php | 31 +++---- .../views/livewire/project/show.blade.php | 24 +++--- resources/views/livewire/tags/show.blade.php | 44 ++++++++-- tests/Feature/DashboardViewModeTest.php | 11 +++ ...rojectAndEnvironmentSettingsButtonTest.php | 4 +- tests/Feature/TagsShowPageTest.php | 69 +++++++++++++++ 13 files changed, 259 insertions(+), 82 deletions(-) diff --git a/app/Livewire/Tags/Show.php b/app/Livewire/Tags/Show.php index e2f4980d75..01450abe9a 100644 --- a/app/Livewire/Tags/Show.php +++ b/app/Livewire/Tags/Show.php @@ -7,6 +7,7 @@ use App\Livewire\Concerns\CreatesTags; use App\Livewire\Concerns\RemembersCanvasView; use App\Models\ApplicationDeploymentQueue; use App\Models\Tag; +use App\Services\TagCanvas; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; @@ -41,6 +42,9 @@ class Show extends Component #[Locked] public ?array $deploymentsPerTagPerServer = null; + /** The "Connect to resources" modal on small screens, where the tags canvas is not shown. */ + public bool $connectResourcesOpen = false; + public function mount() { if (str($this->tagName)->isEmpty() && $this->prefersCanvasView('tags')) { @@ -123,9 +127,39 @@ class Show extends Component } } + public function openConnectResources(): void + { + $this->connectResourcesOpen = true; + } + /** - * Creates a tag without resources. Tag resources on the tags canvas or on the resource. + * Adds the tag to a resource of the tag's team, or removes it when the resource already has it. */ + public function toggleResource(string $uuid): void + { + try { + $resource = app(TagCanvas::class)->resources($this->tag->team_id)->get($uuid); + if (! $resource) { + $this->dispatch('error', 'Resource not found.'); + + return; + } + $this->authorize('update', $resource); + + if ($resource->tags->contains('id', $this->tag->id)) { + $resource->tags()->detach($this->tag->id); + } else { + $resource->tags()->attach($this->tag->id); + } + + $this->applications = $this->tag->applications()->get(); + $this->services = $this->tag->services()->get(); + $this->getDeployments(); + } catch (\Exception $e) { + handleError($e, $this); + } + } + /** * Deletes the tag and removes it from every resource. */ @@ -158,6 +192,30 @@ class Show extends Component 'resourceCount' => $applicationsCount + $servicesCount, ]; })->values()->toArray(), + 'connectableResources' => $this->connectableResources(), ]); } + + /** + * Resources of the tag's team for the "Connect to resources" modal, by "Project / Environment". + * Loaded only while the modal is open. + * + * @return Collection> + */ + private function connectableResources(): Collection + { + if (! $this->connectResourcesOpen || ! $this->tag) { + return collect(); + } + + return app(TagCanvas::class)->resources($this->tag->team_id) + ->groupBy(fn ($resource) => $resource->environment->project->name.' / '.$resource->environment->name) + ->sortKeys(SORT_NATURAL | SORT_FLAG_CASE) + ->map(fn (Collection $resources) => $resources->map(fn ($resource) => [ + 'uuid' => $resource->uuid, + 'name' => $resource->name, + 'tagged' => $resource->tags->contains('id', $this->tag->id), + 'can_update' => auth()->user()->can('update', $resource), + ])->values()); + } } diff --git a/resources/views/components/canvas-view-toggle.blade.php b/resources/views/components/canvas-view-toggle.blade.php index e72bf27213..f27e7bab5a 100644 --- a/resources/views/components/canvas-view-toggle.blade.php +++ b/resources/views/components/canvas-view-toggle.blade.php @@ -1,7 +1,7 @@ @props(['listUrl' => null, 'canvasUrl' => null, 'onCanvas' => false, 'views' => null]) @php $idle = 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg dark:hover:bg-white/[0.06] dark:hover:text-fg'; - $canvasItem = 'relative flex h-7.5 items-center gap-1.5 rounded-md px-2.5 text-[12px] font-medium transition-colors'; + $canvasItem = 'relative flex h-7.5 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md px-2.5 text-[12px] font-medium transition-colors'; $views ??= ['table' => ['Table view', 'unordered-list'], 'grid' => ['Grid view', 'grid']]; // One list view (no table / grid choice): nothing to switch to on the list page. $singleList = count($views) === 1; diff --git a/resources/views/components/modal-input.blade.php b/resources/views/components/modal-input.blade.php index 58f70ab206..b026094ca2 100644 --- a/resources/views/components/modal-input.blade.php +++ b/resources/views/components/modal-input.blade.php @@ -60,7 +60,7 @@ 'sm:h-[40rem]' => $fixedHeight, ]) style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)"> -
+

{{ $title }}

@if ($subtitle) diff --git a/resources/views/components/section-heading.blade.php b/resources/views/components/section-heading.blade.php index 5bfd6cd208..c96b40a7c7 100644 --- a/resources/views/components/section-heading.blade.php +++ b/resources/views/components/section-heading.blade.php @@ -14,7 +14,7 @@ puts a small control (such as a + button) right after the title. --}}
merge(['class' => 'mb-3 min-w-0']) }}> -
+

@@ -29,7 +29,7 @@ @endif

@isset($actions) -
+
@if (filled($href)) @include('components.section-heading-link') @endif diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index fa3d5b06a8..352060701a 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -84,7 +84,7 @@ } }"> + :href="$projects->count() > $dashboardItemLimit ? route('project.index') : null"> {{-- Projects has no sidebar entry, so the dashboard is where new projects start. --}} @can('createAnyResource') @@ -272,7 +272,7 @@ } }"> + :href="$servers->count() > $dashboardItemLimit ? route('server.index') : null"> @can('createAnyResource')
-
+ +
+
+
+

Project details

+

Icon, name, and description shown across the dashboard.

+
+
+
+
-
-
-

Project icon

-

Upload a JPG, PNG, or WebP image. It will appear in the projects list.

-
-
-
-
- Project icon preview - @if ($project->icon_path) - {{ $project->name }} icon - @else - - @endif -
-
-
- - - - - @if ($project->icon_path) - Remove - @endif +
Icon
+
+
+ Project icon preview + @if ($project->icon_path) + {{ $project->name }} icon + @else + + @endif +
+
+
+ + + + + @if ($project->icon_path) + Remove + @endif +
+

JPG, PNG, or WebP. Shown in the projects list.

+

+ @error('icon') +

{{ $message }}

+ @enderror +
+
-

- @error('icon')

{{ $message }}

@enderror -
-
-
- -
- -
-
-
-

Project details

-

Name and describe this project across the dashboard.

-
-
-
diff --git a/resources/views/livewire/project/index.blade.php b/resources/views/livewire/project/index.blade.php index d57262e2e0..7a476c1c0c 100644 --- a/resources/views/livewire/project/index.blade.php +++ b/resources/views/livewire/project/index.blade.php @@ -48,7 +48,7 @@
-
+ -
+
-
+
+ + @forelse ($connectableResources as $context => $resources) +
+

{{ $context }}

+
+ @foreach ($resources as $resource) + + @endforeach +
+
+ @empty +
+ +
+ @endforelse + +
@can('delete', $tag) assertSee("viewMode: 'grid'", false) ->assertSee("viewMode: 'table'", false); }); + +it('links to all projects only when the dashboard hides some of them', function () { + Project::factory()->count(8)->create(['team_id' => $this->team->id]); + + Livewire::test(Dashboard::class)->assertDontSee(route('project.index'), false); + + Project::factory()->create(['team_id' => $this->team->id]); + + Livewire::test(Dashboard::class)->assertSee(route('project.index'), false); +}); diff --git a/tests/Feature/ProjectAndEnvironmentSettingsButtonTest.php b/tests/Feature/ProjectAndEnvironmentSettingsButtonTest.php index bd6f8bdfc8..ec7f1423d0 100644 --- a/tests/Feature/ProjectAndEnvironmentSettingsButtonTest.php +++ b/tests/Feature/ProjectAndEnvironmentSettingsButtonTest.php @@ -35,7 +35,7 @@ test('project show page header links to project settings', function () { $editUrl = route('project.edit', ['project_uuid' => $this->project->uuid]); Livewire::test(ProjectShow::class, ['project_uuid' => $this->project->uuid]) - ->assertSee('Settings') + ->assertSee('Open settings for Test Project', false) ->assertSee('Project settings', false) ->assertSeeHtml($editUrl); }); @@ -51,7 +51,7 @@ test('environment resources page header links to environment settings', function 'environment_uuid' => $this->environment->uuid, ])) ->assertSuccessful() - ->assertSee('Settings') + ->assertSee('Open settings for '.$this->environment->name, false) ->assertSee('Environment settings', false) ->assertSee($editUrl, false); }); diff --git a/tests/Feature/TagsShowPageTest.php b/tests/Feature/TagsShowPageTest.php index b454b59e18..e3ff87911b 100644 --- a/tests/Feature/TagsShowPageTest.php +++ b/tests/Feature/TagsShowPageTest.php @@ -98,3 +98,72 @@ it('redirects to the overview when the requested tag does not exist', function ( Livewire::test(Show::class, ['tagName' => 'missing-tag']) ->assertRedirect(route('tags.show')); }); + +it('lists the team resources in the connect modal and toggles the tag on a resource', function () { + $tag = Tag::create(['name' => 'hello', 'team_id' => $this->team->id]); + $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, 'name' => 'shop']); + $environment = Environment::factory()->create(['project_id' => $project->id, 'name' => 'prod']); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'name' => 'untagged-app', + ]); + + $component = Livewire::test(Show::class, ['tagName' => 'hello']) + ->assertDontSee('shop / prod') + ->call('openConnectResources') + ->assertSee('shop / prod') + ->assertSee('untagged-app') + ->call('toggleResource', $application->uuid); + + expect($application->tags()->whereKey($tag->id)->exists())->toBeTrue(); + expect($component->get('applications')->pluck('uuid')->all())->toBe([$application->uuid]); + + $component->call('toggleResource', $application->uuid); + + expect($application->tags()->whereKey($tag->id)->exists())->toBeFalse(); +}); + +it('does not tag a resource of another team from the connect modal', function () { + $tag = Tag::create(['name' => 'hello', 'team_id' => $this->team->id]); + $otherTeam = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $otherTeam->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->first(); + $project = Project::factory()->create(['team_id' => $otherTeam->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + Livewire::test(Show::class, ['tagName' => 'hello']) + ->call('toggleResource', $application->uuid) + ->assertDispatched('error'); + + expect($application->tags()->exists())->toBeFalse(); +}); + +it('does not let a member change tags from the connect modal', function () { + $tag = Tag::create(['name' => 'hello', 'team_id' => $this->team->id]); + $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]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $member = User::factory()->create(); + $member->teams()->attach($this->team, ['role' => 'member']); + $this->actingAs($member); + + Livewire::test(Show::class, ['tagName' => 'hello']) + ->call('toggleResource', $application->uuid); + + expect($application->tags()->exists())->toBeFalse(); +});