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
+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();
});