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.
This commit is contained in:
Andras Bacsai
2026-10-15 13:19:18 +02:00
parent 88e710058e
commit 4f32548e09
13 changed files with 259 additions and 82 deletions
+59 -1
View File
@@ -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<string, Collection<int, array{uuid: string, name: string, tagged: bool, can_update: bool}>>
*/
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());
}
}
@@ -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;
@@ -60,7 +60,7 @@
'sm:h-[40rem]' => $fixedHeight,
])
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-wrap! sm:flex-nowrap!">
<header class="shrink-0 flex-wrap! sm:flex-nowrap!">
<div class="min-w-0 flex-1 py-0.5">
<h3 class="truncate">{{ $title }}</h3>
@if ($subtitle)
@@ -14,7 +14,7 @@
puts a small control (such as a + button) right after the title.
--}}
<div {{ $attributes->merge(['class' => 'mb-3 min-w-0']) }}>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div class="flex min-w-0 items-baseline gap-2">
<div class="flex min-w-0 items-center gap-1">
<h2 class="min-w-0 truncate text-[14px]! leading-5! font-semibold! text-black dark:text-fg">
@@ -29,7 +29,7 @@
@endif
</div>
@isset($actions)
<div class="flex shrink-0 items-center gap-3">
<div class="ml-auto flex shrink-0 items-center gap-3">
@if (filled($href))
@include('components.section-heading-link')
@endif
+2 -2
View File
@@ -84,7 +84,7 @@
}
}">
<x-section-heading title="Projects"
:href="route('project.index')">
:href="$projects->count() > $dashboardItemLimit ? route('project.index') : null">
{{-- Projects has no sidebar entry, so the dashboard is where new projects start. --}}
@can('createAnyResource')
<x-slot:titleAction>
@@ -272,7 +272,7 @@
}
}">
<x-section-heading title="Servers"
:href="route('server.index')">
:href="$servers->count() > $dashboardItemLimit ? route('server.index') : null">
@can('createAnyResource')
<x-slot:titleAction>
<a href="{{ route('server.create') }}" {{ wireNavigate() }} class="{{ $titleIconButton }}"
+42 -42
View File
@@ -7,7 +7,17 @@
</header>
<div class="flex flex-col gap-6">
<section class="application-settings-section" x-data="{
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Project details</h2>
<p>Icon, name, and description shown across the dashboard.</p>
</div>
</div>
<div class="application-settings-section-body grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2" x-data="{
preview: null,
processing: false,
uploadError: null,
@@ -55,48 +65,38 @@
}
},
}">
<div class="application-settings-section-header">
<div>
<h2>Project icon</h2>
<p>Upload a JPG, PNG, or WebP image. It will appear in the projects list.</p>
</div>
</div>
<div class="application-settings-section-body flex items-center gap-4">
<div class="flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
<img x-cloak x-show="preview" :src="preview" alt="Project icon preview" class="h-full w-full object-cover">
@if ($project->icon_path)
<img x-show="!preview" src="{{ project_icon_url($project) }}"
alt="{{ $project->name }} icon" class="h-full w-full object-cover">
@else
<x-reicon x-show="!preview" name="projects" class="size-6" />
@endif
</div>
<div class="flex min-w-0 flex-1 flex-col gap-3">
<div class="flex flex-wrap gap-2">
<input x-ref="iconInput" type="file" x-on:change="prepareIcon($event)" accept="image/jpeg,image/png,image/webp" class="hidden">
<x-forms.button type="button" x-on:click="$refs.iconInput.click()" x-bind:disabled="processing">
<span x-text="processing ? 'Uploading…' : 'Browse…'"></span>
</x-forms.button>
@if ($project->icon_path)
<x-forms.button type="button" wire:click="removeIcon" x-bind:disabled="processing" isError>Remove</x-forms.button>
@endif
<div class="mb-2 text-sm font-medium leading-4">Icon</div>
<div class="flex items-center gap-4">
<div class="flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-neutral-200 text-neutral-700 dark:bg-white/[0.1] dark:text-fg">
<img x-cloak x-show="preview" :src="preview" alt="Project icon preview" class="h-full w-full object-cover">
@if ($project->icon_path)
<img x-show="!preview" src="{{ project_icon_url($project) }}"
alt="{{ $project->name }} icon" class="h-full w-full object-cover">
@else
<x-reicon x-show="!preview" name="projects" class="size-6" />
@endif
</div>
<div class="flex min-w-0 flex-1 flex-col gap-2">
<div class="flex flex-wrap gap-2">
<input x-ref="iconInput" type="file" x-on:change="prepareIcon($event)"
accept="image/jpeg,image/png,image/webp" class="hidden">
<x-forms.button type="button" x-on:click="$refs.iconInput.click()"
x-bind:disabled="processing">
<span x-text="processing ? 'Uploading…' : 'Browse…'"></span>
</x-forms.button>
@if ($project->icon_path)
<x-forms.button type="button" wire:click="removeIcon" x-bind:disabled="processing"
isError>Remove</x-forms.button>
@endif
</div>
<p class="text-xs text-neutral-500 dark:text-fg-dim">JPG, PNG, or WebP. Shown in the projects list.</p>
<p x-cloak x-show="uploadError" x-text="uploadError" class="text-xs text-red-500"></p>
@error('icon')
<p class="text-xs text-red-500">{{ $message }}</p>
@enderror
</div>
</div>
</div>
<p x-cloak x-show="uploadError" x-text="uploadError" class="text-xs text-red-500"></p>
@error('icon') <p class="text-xs text-red-500">{{ $message }}</p> @enderror
</div>
</div>
</section>
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Project details</h2>
<p>Name and describe this project across the dashboard.</p>
</div>
</div>
<div class="application-settings-section-body grid gap-4 sm:grid-cols-2">
<x-forms.input label="Name" id="name" />
<x-forms.input label="Description" id="description" />
</div>
@@ -48,7 +48,7 @@
</button>
</div>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<x-table.dropdown panel-class="w-52!">
<x-slot:trigger>
<button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
@@ -5,13 +5,26 @@
<div x-data="resourceIndex()" class="w-full">
<header class="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<h1 class="truncate text-[24px]! leading-7! font-semibold! tracking-tight!">{{ $environment->name }}</h1>
<div class="flex min-w-0 items-center gap-1.5">
<h1 class="truncate text-[24px]! leading-7! font-semibold! tracking-tight!">{{ $environment->name }}</h1>
@can('update', $project)
<a href="{{ route('project.environment.edit', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) }}"
{{ wireNavigate() }}
class="flex size-7 shrink-0 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
title="Environment settings"
aria-label="Open settings for {{ $environment->name }}">
<x-reicon name="settings" class="size-4" />
</a>
@endcan
</div>
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">
<span x-text="`${resources.length} ${resources.length === 1 ? 'resource' : 'resources'}`"></span>
in {{ $project->name }}
in <a href="{{ route('project.show', ['project_uuid' => $project->uuid]) }}" {{ wireNavigate() }}
class="font-medium text-neutral-700 underline-offset-2 hover:text-black hover:underline dark:text-fg dark:hover:text-white"
title="Show all environments in {{ $project->name }}">{{ $project->name }}</a>
</p>
</div>
<div class="flex w-fit shrink-0 items-center gap-2">
<div class="flex flex-wrap items-center gap-2 sm:shrink-0">
@if ($environment->isEmpty())
{{-- The list toolbar (with its canvas switch) is hidden when there are no resources. --}}
<x-canvas-view-toggle :views="['list' => ['List view', 'unordered-list']]"
@@ -25,16 +38,6 @@
<x-reicon name="variables" class="size-3.5" />
Shared variables
</a>
@can('update', $project)
<a href="{{ route('project.environment.edit', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) }}"
{{ wireNavigate() }}
class="button whitespace-nowrap"
title="Environment settings"
aria-label="Open settings for {{ $environment->name }}">
<x-reicon name="settings" class="size-3.5" />
Settings
</a>
@endcan
@can('createAnyResource')
<a href="{{ route('project.resource.create', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) }}"
{{ wireNavigate() }}
@@ -79,7 +82,7 @@
</button>
</div>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<x-table.dropdown panel-class="w-64! overflow-hidden! p-0!" :multiselectable="true">
<x-slot:trigger>
<button type="button" class="button max-w-64"
+13 -11
View File
@@ -5,7 +5,18 @@
<div x-data="projectEnvironments()" class="w-full">
<header class="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<h1 class="truncate text-[24px]! leading-7! font-semibold! tracking-tight!">{{ $project->name }}</h1>
<div class="flex min-w-0 items-center gap-1.5">
<h1 class="truncate text-[24px]! leading-7! font-semibold! tracking-tight!">{{ $project->name }}</h1>
@can('update', $project)
<a href="{{ route('project.edit', ['project_uuid' => $project->uuid]) }}"
{{ wireNavigate() }}
class="flex size-7 shrink-0 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
title="Project settings"
aria-label="Open settings for {{ $project->name }}">
<x-reicon name="settings" class="size-4" />
</a>
@endcan
</div>
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">
<span
x-text="`${environments.length} ${environments.length === 1 ? 'environment' : 'environments'}`"></span>
@@ -15,15 +26,6 @@
@can('update', $project)
<div class="flex w-fit shrink-0 items-center gap-2">
<a href="{{ route('project.edit', ['project_uuid' => $project->uuid]) }}"
{{ wireNavigate() }}
class="button"
title="Project settings"
aria-label="Open settings for {{ $project->name }}">
<x-reicon name="settings" class="size-3.5" />
Settings
</a>
<x-modal-input title="New Environment">
<x-slot:content>
<button type="button"
@@ -58,7 +60,7 @@
</button>
</div>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<x-table.dropdown panel-class="w-48!">
<x-slot:trigger>
<button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
+39 -5
View File
@@ -56,7 +56,7 @@
</button>
</div>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<x-table.dropdown panel-class="w-52!">
<x-slot:trigger>
<button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
@@ -280,10 +280,44 @@
<x-application.settings-section :title="$tag->name"
description="Use this webhook to deploy every resource with this tag.">
<x-slot:actions>
<a href="{{ app(App\Services\TagCanvas::class)->connectLink($tag) }}" class="button whitespace-nowrap"
title="Add this tag to resources, and see which resources have it">
<x-reicon name="link" class="size-3.5" />
Connect to resources
{{-- Two ways to connect: a list in a modal, or the canvas (wide screens only). --}}
<x-modal-input title="Connect to resources" subtitle="Add this tag to resources, or remove it."
wireOpen="connectResourcesOpen" :wireIgnore="false" :contentClicks="false">
<x-slot:content>
<button type="button" class="button whitespace-nowrap" wire:click="openConnectResources">
<x-reicon name="link" class="size-3.5" />
Connect to resources
</button>
</x-slot:content>
@forelse ($connectableResources as $context => $resources)
<div wire:key="connect-group-{{ $context }}" class="mb-4 last:mb-0">
<p class="mb-1.5 truncate text-[11px] font-medium text-neutral-500 dark:text-fg-faint">{{ $context }}</p>
<div class="flex flex-col gap-1">
@foreach ($resources as $resource)
<label wire:key="connect-resource-{{ $resource['uuid'] }}" @class([
'flex items-center gap-2.5 rounded-md border border-neutral-200 px-3 py-2 text-[13px] dark:border-white/[0.08]',
'cursor-pointer' => $resource['can_update'],
'opacity-60' => ! $resource['can_update'],
])>
<input type="checkbox" @checked($resource['tagged']) @disabled(! $resource['can_update'])
wire:click="toggleResource(@js($resource['uuid']))" wire:loading.attr="disabled"
class="size-4 shrink-0 rounded-sm border-neutral-300 text-accent focus:ring-accent dark:border-white/20 dark:bg-transparent" />
<span class="min-w-0 truncate">{{ $resource['name'] }}</span>
</label>
@endforeach
</div>
</div>
@empty
<div wire:loading.remove wire:target="openConnectResources">
<x-empty title="No resources yet" description="Create a resource in a project first." icon-name="tags" size="sm" />
</div>
@endforelse
</x-modal-input>
<a href="{{ app(App\Services\TagCanvas::class)->connectLink($tag) }}"
class="button hidden whitespace-nowrap md:inline-flex"
title="Add this tag to resources on the canvas, and see which resources have it">
<x-reicon name="deploy-canvas" class="size-3.5" />
Connect in canvas
</a>
@can('delete', $tag)
<x-modal-confirmation title="Delete this tag?" isErrorButton buttonTitle="Delete"
+11
View File
@@ -2,6 +2,7 @@
use App\Livewire\Dashboard;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -42,3 +43,13 @@ it('remembers the view of each dashboard section on its own', function () {
->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);
});
@@ -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);
});
+69
View File
@@ -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();
});