Tim Jones 1 месяц назад
Родитель
Сommit
6c4f294228
33 измененных файлов с 1017 добавлено и 36 удалено
  1. 2 1
      Shared.Rcl/AccessPoints/AccessPointCardComponent.razor
  2. 34 25
      Shared.Rcl/Components/ResourceTagEditor.razor
  3. 3 1
      Shared.Rcl/Desktops/DesktopCardComponent.razor
  4. 3 1
      Shared.Rcl/Firewalls/FirewallCardComponent.razor
  5. 3 1
      Shared.Rcl/Laptops/LaptopCardComponent.razor
  6. 171 0
      Shared.Rcl/Modals/CommaSeparatedStringModal.razor
  7. 3 1
      Shared.Rcl/Routers/RouterCardComponent.razor
  8. 3 1
      Shared.Rcl/Servers/ServerCardComponent.razor
  9. 3 1
      Shared.Rcl/Services/ServiceCardComponent.razor
  10. 3 1
      Shared.Rcl/Switches/SwitchCardComponent.razor
  11. 3 1
      Shared.Rcl/Systems/SystemCardComponent.razor
  12. 3 1
      Shared.Rcl/Ups/UpsCardComponent.razor
  13. 67 0
      Tests.E2e/AccessPointCardTests.cs
  14. 70 0
      Tests.E2e/DesktopCardTests.cs
  15. 67 0
      Tests.E2e/FirewallCardTests.cs
  16. 68 0
      Tests.E2e/LaptopCardTests.cs
  17. 2 0
      Tests.E2e/PageObjectModels/AccessPointCardPom.cs
  18. 3 1
      Tests.E2e/PageObjectModels/DesktopCardPom.cs
  19. 2 0
      Tests.E2e/PageObjectModels/FirewallCardPom.cs
  20. 2 0
      Tests.E2e/PageObjectModels/LaptopCardPom.cs
  21. 2 0
      Tests.E2e/PageObjectModels/RouterCardPom.cs
  22. 2 0
      Tests.E2e/PageObjectModels/ServerCardPom.cs
  23. 2 0
      Tests.E2e/PageObjectModels/ServiceCardPom.cs
  24. 2 0
      Tests.E2e/PageObjectModels/SwitchCardPom.cs
  25. 2 0
      Tests.E2e/PageObjectModels/SystemCardPom.cs
  26. 101 0
      Tests.E2e/PageObjectModels/TagsPom.cs
  27. 2 0
      Tests.E2e/PageObjectModels/UpsCardPom.cs
  28. 66 0
      Tests.E2e/RouterCardTests.cs
  29. 68 0
      Tests.E2e/ServerCardTests.cs
  30. 60 0
      Tests.E2e/ServiceCardTests.cs
  31. 67 0
      Tests.E2e/SwitchCardTests.cs
  32. 60 0
      Tests.E2e/SystemCardTests.cs
  33. 68 0
      Tests.E2e/UpsCardTests.cs

+ 2 - 1
Shared.Rcl/AccessPoints/AccessPointCardComponent.razor

@@ -104,7 +104,8 @@
             }
         </div>
 
-        <ResourceTagEditor Resource="AccessPoint"/>
+        <ResourceTagEditor Resource="AccessPoint"
+                           TestIdPrefix="accesspoint" />
 
         <div class="md:col-span-2"
              data-testid="accesspoint-notes-section">

+ 34 - 25
Shared.Rcl/Components/ResourceTagEditor.razor

@@ -1,16 +1,20 @@
 @using RackPeek.Domain.UseCases.Tags
 @typeparam TResource where TResource : RackPeek.Domain.Resources.Resource
 @inject NavigationManager Nav
-
 @inject IAddTagUseCase<TResource> AddTagUseCase
 @inject IRemoveTagUseCase<TResource> RemoveTagUseCase
 
-<div class="md:col-span-2">
-    <div class="flex items-center justify-between mb-1 group">
+<div class="md:col-span-2"
+     data-testid="@BaseTestId">
+
+    <div class="flex items-center justify-between mb-1 group"
+         data-testid="@($"{BaseTestId}-header")">
+
         <div class="text-zinc-400">
             Tags
             <button class="hover:text-emerald-400 ml-1"
                     title="Add Tag"
+                    data-testid="@($"{BaseTestId}-add")"
                     @onclick="OpenAddTag">
                 +
             </button>
@@ -19,23 +23,26 @@
 
     @if (Resource.Tags.Any())
     {
-        <div class="flex flex-wrap gap-2">
+        <div class="flex flex-wrap gap-2"
+             data-testid="@($"{BaseTestId}-list")">
+
             @foreach (var tag in Resource.Tags.OrderBy(t => t))
             {
-                <div class="flex text-xs rounded overflow-hidden border border-zinc-700">
+                <div class="flex text-xs rounded overflow-hidden border border-zinc-700"
+                     data-testid="@($"{BaseTestId}-tag-{tag}")">
 
-                    <!-- LEFT SIDE (Navigate) -->
                     <button type="button"
                             class="px-2 py-0.5 bg-zinc-800 text-zinc-300 hover:bg-emerald-800 hover:text-emerald-200 transition"
                             title="View tag"
+                            data-testid="@($"{BaseTestId}-tag-{tag}-view")"
                             @onclick="() => NavigateToTag(tag)">
                         @tag
                     </button>
 
-                    <!-- RIGHT SIDE (Delete) -->
                     <button type="button"
                             class="px-1 py-0.5 bg-zinc-800 text-zinc-400 hover:bg-red-800 hover:text-red-200 transition border-l border-zinc-700"
                             title="Remove tag"
+                            data-testid="@($"{BaseTestId}-tag-{tag}-remove")"
                             @onclick="() => RemoveTag(tag)">
                     </button>
@@ -47,37 +54,40 @@
 
 </div>
 
-<StringValueModal
+<CommaSeparatedStringModal
     IsOpen="_tagModalOpen"
     IsOpenChanged="v => _tagModalOpen = v"
-    Title="Add Tag"
-    Description="Enter tag value"
-    Label="Tag"
-    Value=""
-    OnSubmit="HandleTagSubmit"/>
+    Title="Add Tags"
+    Description="Enter one or more tags separated by commas"
+    Label="Tags"
+    TestIdPrefix="@BaseTestId"
+    OnSubmit="HandleTagsSubmit" />
 
 @code {
     [Parameter][EditorRequired] public TResource Resource { get; set; } = default!;
-
     [Parameter] public EventCallback OnTagsChanged { get; set; }
+    [Parameter] public string? TestIdPrefix { get; set; }
 
-    bool _tagModalOpen;
+    private bool _tagModalOpen;
+
+    private string BaseTestId =>
+        string.IsNullOrWhiteSpace(TestIdPrefix)
+            ? "resource-tag-editor"
+            : $"{TestIdPrefix}-resource-tag-editor";
 
     void OpenAddTag()
     {
         _tagModalOpen = true;
     }
 
-    async Task HandleTagSubmit(string value)
+    public async Task HandleTagsSubmit(IReadOnlyList<string> values)
     {
-        if (string.IsNullOrWhiteSpace(value))
-            return;
-
-        await AddTagUseCase.ExecuteAsync(
-            Resource.Name,
-            value);
-
-        _tagModalOpen = false;
+        foreach (var value in values)
+        {
+            await AddTagUseCase.ExecuteAsync(
+                Resource.Name,
+                value);
+        }
 
         if (OnTagsChanged.HasDelegate)
             await OnTagsChanged.InvokeAsync();
@@ -97,5 +107,4 @@
     {
         Nav.NavigateTo($"tags/{Uri.EscapeDataString(tag)}");
     }
-
 }

+ 3 - 1
Shared.Rcl/Desktops/DesktopCardComponent.razor

@@ -204,7 +204,9 @@
             }
         </div>
 
-        <ResourceTagEditor Resource="Desktop"/>
+        <ResourceTagEditor Resource="Desktop"
+                           TestIdPrefix="desktop" />
+
 
     </div>
 

+ 3 - 1
Shared.Rcl/Firewalls/FirewallCardComponent.razor

@@ -162,7 +162,9 @@
             }
         </div>
 
-        <ResourceTagEditor Resource="Firewall" />
+        <ResourceTagEditor Resource="Firewall" 
+                           TestIdPrefix="firewall" />
+
 
         <div class="md:col-span-2"
              data-testid="firewall-notes-section">

+ 3 - 1
Shared.Rcl/Laptops/LaptopCardComponent.razor

@@ -175,7 +175,9 @@
 
     </div>
 
-    <ResourceTagEditor Resource="Laptop" />
+    <ResourceTagEditor Resource="Laptop" 
+                       TestIdPrefix="laptop" />
+
 
     <div class="md:col-span-2"
          data-testid="laptop-notes-section">

+ 171 - 0
Shared.Rcl/Modals/CommaSeparatedStringModal.razor

@@ -0,0 +1,171 @@
+@using System.ComponentModel.DataAnnotations
+
+@if (IsOpen)
+{
+    <div class="fixed inset-0 z-50 flex items-center justify-center"
+         data-testid="@BaseTestId">
+
+        <!-- Backdrop -->
+        <div class="absolute inset-0 bg-black/70"
+             data-testid="@($"{BaseTestId}-backdrop")"
+             @onclick="Cancel">
+        </div>
+
+        <!-- Modal -->
+        <div class="relative bg-zinc-900 border border-zinc-800 rounded w-full max-w-md p-4"
+             data-testid="@($"{BaseTestId}-container")">
+
+            <!-- Header -->
+            <div class="flex justify-between items-center mb-3"
+                 data-testid="@($"{BaseTestId}-header")">
+
+                <div class="text-zinc-100 text-sm font-medium"
+                     data-testid="@($"{BaseTestId}-title")">
+                    @Title
+                </div>
+
+                <button class="text-zinc-400 hover:text-zinc-200"
+                        data-testid="@($"{BaseTestId}-close")"
+                        @onclick="Cancel">
+                    ✕
+                </button>
+            </div>
+
+            @if (!string.IsNullOrWhiteSpace(Description))
+            {
+                <div class="text-xs text-zinc-400 mb-4"
+                     data-testid="@($"{BaseTestId}-description")">
+                    @Description
+                </div>
+            }
+
+            <!-- Form -->
+            <EditForm Model="_model"
+                      OnValidSubmit="HandleValidSubmit"
+                      data-testid="@($"{BaseTestId}-form")">
+
+                <DataAnnotationsValidator />
+
+                @if (!string.IsNullOrEmpty(_error))
+                {
+                    <div class="text-xs text-red-400 mb-3"
+                         data-testid="@($"{BaseTestId}-error")">
+                        @_error
+                    </div>
+                }
+
+                <div class="text-sm"
+                     data-testid="@($"{BaseTestId}-field")">
+
+                    <label class="block text-zinc-400 mb-1">
+                        @Label
+                    </label>
+
+                    <InputText class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                               data-testid="@($"{BaseTestId}-input")"
+                               @bind-Value="_model.Value" />
+                </div>
+
+                <!-- Actions -->
+                <div class="flex justify-end gap-2 mt-5"
+                     data-testid="@($"{BaseTestId}-actions")">
+
+                    <button type="button"
+                            class="px-3 py-1 rounded border border-zinc-700 text-zinc-300 hover:bg-zinc-800"
+                            data-testid="@($"{BaseTestId}-cancel")"
+                            @onclick="Cancel">
+                        Cancel
+                    </button>
+
+                    <button type="submit"
+                            class="px-3 py-1 rounded bg-emerald-600 text-black hover:bg-emerald-500"
+                            data-testid="@($"{BaseTestId}-submit")">
+                        Accept
+                    </button>
+                </div>
+
+            </EditForm>
+        </div>
+    </div>
+}
+
+@code {
+    [Parameter] public bool IsOpen { get; set; }
+    [Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
+
+    [Parameter] public string Title { get; set; } = "Add values";
+    [Parameter] public string? Description { get; set; }
+    [Parameter] public string Label { get; set; } = "Values";
+
+    [Parameter] public string? Value { get; set; }
+
+    [Parameter] public EventCallback<IReadOnlyList<string>> OnSubmit { get; set; }
+
+    [Parameter] public string? TestIdPrefix { get; set; }
+
+    private string BaseTestId =>
+        string.IsNullOrWhiteSpace(TestIdPrefix)
+            ? "comma-separated-string-modal"
+            : $"{TestIdPrefix}-comma-separated-string-modal";
+
+    private FormModel _model = new();
+    private string? _error;
+
+    protected override void OnParametersSet()
+    {
+        if (IsOpen)
+        {
+            _error = null;
+            _model = new FormModel
+            {
+                Value = Value
+            };
+        }
+    }
+
+    private async Task HandleValidSubmit()
+    {
+        _error = null;
+
+        try
+        {
+            var values = (_model.Value ?? string.Empty)
+                .Split(',', StringSplitOptions.RemoveEmptyEntries)
+                .Select(x => x.Trim())
+                .Where(x => !string.IsNullOrWhiteSpace(x))
+                .Distinct(StringComparer.OrdinalIgnoreCase)
+                .ToList();
+
+            if (!values.Any())
+            {
+                _error = "Please enter at least one value.";
+                return;
+            }
+
+            await OnSubmit.InvokeAsync(values);
+            await Close();
+        }
+        catch (Exception ex)
+        {
+            _error = ex.Message;
+        }
+    }
+
+    private async Task Cancel()
+    {
+        await Close();
+    }
+
+    private async Task Close()
+    {
+        _model = new FormModel();
+        _error = null;
+        await IsOpenChanged.InvokeAsync(false);
+    }
+
+    private class FormModel
+    {
+        [Required]
+        public string? Value { get; set; }
+    }
+}

+ 3 - 1
Shared.Rcl/Routers/RouterCardComponent.razor

@@ -164,7 +164,9 @@
             }
         </div>
 
-        <ResourceTagEditor Resource="Router" />
+        <ResourceTagEditor Resource="Router" 
+                           TestIdPrefix="router" />
+
 
         <div class="md:col-span-2"
              data-testid="router-notes-section">

+ 3 - 1
Shared.Rcl/Servers/ServerCardComponent.razor

@@ -231,7 +231,9 @@
 
 
     </div>
-    <ResourceTagEditor Resource="Server"/>
+    <ResourceTagEditor Resource="Server"
+                       TestIdPrefix="server" />
+
 
     <div class="md:col-span-2">
         <div class="text-zinc-400 mb-1">Notes</div>

+ 3 - 1
Shared.Rcl/Services/ServiceCardComponent.razor

@@ -188,7 +188,9 @@
         </div>
 
 
-        <ResourceTagEditor Resource="Service"/>
+        <ResourceTagEditor Resource="Service"
+                           TestIdPrefix="service" />
+
 
         <div class="md:col-span-2">
             <div class="text-zinc-400 mb-1">Notes</div>

+ 3 - 1
Shared.Rcl/Switches/SwitchCardComponent.razor

@@ -162,7 +162,9 @@
             }
         </div>
 
-        <ResourceTagEditor Resource="Switch" />
+        <ResourceTagEditor Resource="Switch" 
+                           TestIdPrefix="switch" />
+
 
         <div class="md:col-span-2"
              data-testid="switch-notes-section">

+ 3 - 1
Shared.Rcl/Systems/SystemCardComponent.razor

@@ -227,7 +227,9 @@
                 }
             }
         </div>
-        <ResourceTagEditor Resource="System"/>
+        <ResourceTagEditor Resource="System"
+                           TestIdPrefix="system" />
+
 
         <div class="md:col-span-2">
             <div class="text-zinc-400 mb-1">Notes</div>

+ 3 - 1
Shared.Rcl/Ups/UpsCardComponent.razor

@@ -104,7 +104,9 @@
             }
         </div>
 
-        <ResourceTagEditor Resource="Ups" />
+        <ResourceTagEditor Resource="Ups" 
+                           TestIdPrefix="ups" />
+
 
         <div class="md:col-span-2"
              data-testid="ups-notes-section">

+ 67 - 0
Tests.E2e/AccessPointCardTests.cs

@@ -246,4 +246,71 @@ public class AccessPointCardTests(
             await context.CloseAsync();
         }
     }
+    
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_AccessPoint_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoAccessPointsListAsync();
+
+            var list = new AccessPointsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddAccessPointAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new AccessPointCardPom(page);
+            await card.AssertCardVisibleAsync(name);
+
+            var tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("accesspoint", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("accesspoint", "Foo");
+            await tags.AssertTagVisibleAsync("accesspoint", "Bar");
+            await tags.AssertTagVisibleAsync("accesspoint", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("accesspoint", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("accesspoint", "Bar");
+            await tags.AssertTagVisibleAsync("accesspoint", "Foo");
+            await tags.AssertTagVisibleAsync("accesspoint", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("accesspoint", "Foo");
+            await tags.AssertTagVisibleAsync("accesspoint", "Baz");
+            await tags.AssertTagNotVisibleAsync("accesspoint", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
 }

+ 70 - 0
Tests.E2e/DesktopCardTests.cs

@@ -281,4 +281,74 @@ public class DesktopCardTests(
             await context.CloseAsync();
         }
     }
+    
+    
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_Desktop_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoDesktopsListAsync();
+
+            var list = new DesktopsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddDesktopAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new DesktopCardPom(page);
+            await Assertions.Expect(card.DesktopItem(name)).ToBeVisibleAsync();
+
+            var tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("desktop", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("desktop", "Foo");
+            await tags.AssertTagVisibleAsync("desktop", "Bar");
+            await tags.AssertTagVisibleAsync("desktop", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("desktop", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("desktop", "Bar");
+            await tags.AssertTagVisibleAsync("desktop", "Foo");
+            await tags.AssertTagVisibleAsync("desktop", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("desktop", "Foo");
+            await tags.AssertTagVisibleAsync("desktop", "Baz");
+            await tags.AssertTagNotVisibleAsync("desktop", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+    
+    
 }

+ 67 - 0
Tests.E2e/FirewallCardTests.cs

@@ -228,4 +228,71 @@ public class FirewallCardTests(
             await context.CloseAsync();
         }
     }
+    
+    
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_Firewall_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoFirewallsListAsync();
+
+            var list = new FirewallsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddFirewallAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new FirewallCardPom(page);
+
+            var tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("firewall", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("firewall", "Foo");
+            await tags.AssertTagVisibleAsync("firewall", "Bar");
+            await tags.AssertTagVisibleAsync("firewall", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("firewall", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("firewall", "Bar");
+            await tags.AssertTagVisibleAsync("firewall", "Foo");
+            await tags.AssertTagVisibleAsync("firewall", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("firewall", "Foo");
+            await tags.AssertTagVisibleAsync("firewall", "Baz");
+            await tags.AssertTagNotVisibleAsync("firewall", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
 }

+ 68 - 0
Tests.E2e/LaptopCardTests.cs

@@ -281,4 +281,72 @@ public class LaptopCardTests(
             await context.CloseAsync();
         }
     }
+    
+    
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_Laptop_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoLaptopsListAsync();
+
+            var list = new LaptopListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddLaptopAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new LaptopCardPom(page);
+            await Assertions.Expect(card.LaptopItem(name)).ToBeVisibleAsync();
+
+            var tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("laptop", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("laptop", "Foo");
+            await tags.AssertTagVisibleAsync("laptop", "Bar");
+            await tags.AssertTagVisibleAsync("laptop", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("laptop", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("laptop", "Bar");
+            await tags.AssertTagVisibleAsync("laptop", "Foo");
+            await tags.AssertTagVisibleAsync("laptop", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("laptop", "Foo");
+            await tags.AssertTagVisibleAsync("laptop", "Baz");
+            await tags.AssertTagNotVisibleAsync("laptop", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
 }

+ 2 - 0
Tests.E2e/PageObjectModels/AccessPointCardPom.cs

@@ -4,6 +4,8 @@ using Microsoft.Playwright;
 
 public class AccessPointCardPom(IPage page)
 {
+    public TagsPom Tags => new(page);
+
     // Root
     public ILocator Card(string accessPointName)
         => page.GetByTestId($"accesspoint-item-{Sanitize(accessPointName)}");

+ 3 - 1
Tests.E2e/PageObjectModels/DesktopCardPom.cs

@@ -3,7 +3,9 @@ namespace Tests.E2e.PageObjectModels;
 using Microsoft.Playwright;
 
 public class DesktopCardPom(IPage page)
-{
+{    
+    public TagsPom Tags => new(page);
+
     // -------------------------------------------------
     // Root + Navigation
     // -------------------------------------------------

+ 2 - 0
Tests.E2e/PageObjectModels/FirewallCardPom.cs

@@ -4,6 +4,8 @@ using Microsoft.Playwright;
 
 public class FirewallCardPom(IPage page)
 {
+    public TagsPom Tags => new(page);
+
     // -------------------------------------------------
     // Dynamic Firewall Item (root)
     // -------------------------------------------------

+ 2 - 0
Tests.E2e/PageObjectModels/LaptopCardPom.cs

@@ -4,6 +4,8 @@ using Microsoft.Playwright;
 
 public class LaptopCardPom(IPage page)
 {
+    public TagsPom Tags => new(page);
+
     // -------------------------------------------------
     // Root + Navigation
     // -------------------------------------------------

+ 2 - 0
Tests.E2e/PageObjectModels/RouterCardPom.cs

@@ -4,6 +4,8 @@ using Microsoft.Playwright;
 
 public class RouterCardPom(IPage page)
 {
+    public TagsPom Tags => new(page);
+
     // -------------------------------------------------
     // Dynamic Router Item (root)
     // -------------------------------------------------

+ 2 - 0
Tests.E2e/PageObjectModels/ServerCardPom.cs

@@ -4,6 +4,8 @@ namespace Tests.E2e.PageObjectModels;
 
 public class ServerCardPom(IPage page)
 {
+    public TagsPom Tags => new(page);
+
     // -------------------------------------------------
     // Root / Identity
     // -------------------------------------------------

+ 2 - 0
Tests.E2e/PageObjectModels/ServiceCardPom.cs

@@ -4,6 +4,8 @@ namespace Tests.E2e.PageObjectModels;
 
 public class ServiceCardPom(IPage page)
 {
+    public TagsPom Tags => new(page);
+
     // -------------------------------------------------
     // Root
     // -------------------------------------------------

+ 2 - 0
Tests.E2e/PageObjectModels/SwitchCardPom.cs

@@ -4,6 +4,8 @@ using Microsoft.Playwright;
 
 public class SwitchCardPom(IPage page)
 {
+    public TagsPom Tags => new(page);
+
     // -------------------------------------------------
     // Dynamic Switch Item (root)
     // -------------------------------------------------

+ 2 - 0
Tests.E2e/PageObjectModels/SystemCardPom.cs

@@ -4,6 +4,8 @@ namespace Tests.E2e.PageObjectModels;
 
 public class SystemCardPom(IPage page)
 {
+    public TagsPom Tags => new(page);
+
     // -------------------------------------------------
     // Helpers
     // -------------------------------------------------

+ 101 - 0
Tests.E2e/PageObjectModels/TagsPom.cs

@@ -0,0 +1,101 @@
+namespace Tests.E2e.PageObjectModels;
+
+using Microsoft.Playwright;
+
+public class TagsPom(IPage page)
+{
+    // -------------------------------------------------
+    // Root
+    // -------------------------------------------------
+
+    public ILocator Root(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-resource-tag-editor");
+
+    public ILocator Header(string testIdPrefix)
+        => Root(testIdPrefix).GetByTestId($"{testIdPrefix}-resource-tag-editor-header");
+
+    public ILocator AddButton(string testIdPrefix)
+        => Root(testIdPrefix).GetByTestId($"{testIdPrefix}-resource-tag-editor-add");
+
+    public ILocator TagList(string testIdPrefix)
+        => Root(testIdPrefix).GetByTestId($"{testIdPrefix}-resource-tag-editor-list");
+
+    // -------------------------------------------------
+    // Individual Tags
+    // -------------------------------------------------
+
+    public ILocator Tag(string testIdPrefix, string tag)
+        => Root(testIdPrefix)
+            .GetByTestId($"{testIdPrefix}-resource-tag-editor-tag-{tag}");
+
+    public ILocator ViewTagButton(string testIdPrefix, string tag)
+        => Root(testIdPrefix)
+            .GetByTestId($"{testIdPrefix}-resource-tag-editor-tag-{tag}-view");
+
+    public ILocator RemoveTagButton(string testIdPrefix, string tag)
+        => Root(testIdPrefix)
+            .GetByTestId($"{testIdPrefix}-resource-tag-editor-tag-{tag}-remove");
+
+    // -------------------------------------------------
+    // Modal (CommaSeparatedStringModal)
+    // -------------------------------------------------
+
+    public ILocator Modal(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-resource-tag-editor-comma-separated-string-modal");
+
+    public ILocator ModalInput(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-resource-tag-editor-comma-separated-string-modal-input");
+
+    public ILocator ModalSubmit(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-resource-tag-editor-comma-separated-string-modal-submit");
+
+    public ILocator ModalCancel(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-resource-tag-editor-comma-separated-string-modal-cancel");
+
+    // -------------------------------------------------
+    // Assertions
+    // -------------------------------------------------
+
+    public async Task AssertTagVisibleAsync(string testIdPrefix, string tag)
+    {
+        await Assertions.Expect(Tag(testIdPrefix, tag)).ToBeVisibleAsync();
+    }
+
+    public async Task AssertTagNotVisibleAsync(string testIdPrefix, string tag)
+    {
+        await Assertions.Expect(Tag(testIdPrefix, tag)).Not.ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task AddTagsAsync(string testIdPrefix, params string[] tags)
+    {
+        await AddButton(testIdPrefix).ClickAsync();
+
+        await Assertions.Expect(ModalInput(testIdPrefix)).ToBeVisibleAsync();
+
+        var value = string.Join(", ", tags);
+
+        await ModalInput(testIdPrefix).FillAsync(value);
+        await ModalSubmit(testIdPrefix).ClickAsync();
+
+        foreach (var tag in tags)
+        {
+            await AssertTagVisibleAsync(testIdPrefix, tag);
+        }
+    }
+
+    public async Task RemoveTagAsync(string testIdPrefix, string tag)
+    {
+        await RemoveTagButton(testIdPrefix, tag).ClickAsync();
+        await AssertTagNotVisibleAsync(testIdPrefix, tag);
+    }
+
+    public async Task NavigateToTagAsync(string testIdPrefix, string tag)
+    {
+        await ViewTagButton(testIdPrefix, tag).ClickAsync();
+        await page.WaitForURLAsync($"**/tags/{tag}");
+    }
+}

+ 2 - 0
Tests.E2e/PageObjectModels/UpsCardPom.cs

@@ -4,6 +4,8 @@ namespace Tests.E2e.PageObjectModels;
 
 public class UpsCardPom(IPage page)
 {
+    public TagsPom Tags => new(page);
+
     // -------------------------------------------------
     // Root
     // -------------------------------------------------

+ 66 - 0
Tests.E2e/RouterCardTests.cs

@@ -228,4 +228,70 @@ public class RouterCardTests(
             await context.CloseAsync();
         }
     }
+    
+    
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_Router_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoRoutersListAsync();
+
+            var list = new RouterListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddRouterAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new RouterCardPom(page);
+            var tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("router", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("router", "Foo");
+            await tags.AssertTagVisibleAsync("router", "Bar");
+            await tags.AssertTagVisibleAsync("router", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("router", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("router", "Bar");
+            await tags.AssertTagVisibleAsync("router", "Foo");
+            await tags.AssertTagVisibleAsync("router", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("router", "Foo");
+            await tags.AssertTagVisibleAsync("router", "Baz");
+            await tags.AssertTagNotVisibleAsync("router", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
 }

+ 68 - 0
Tests.E2e/ServerCardTests.cs

@@ -101,4 +101,72 @@ public class ServerCardTests(
             await context.CloseAsync();
         }
     }
+    
+    
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_Server_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoServersListAsync();
+
+            var list = new ServersListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddServerAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new ServerCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            var tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("server", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("server", "Foo");
+            await tags.AssertTagVisibleAsync("server", "Bar");
+            await tags.AssertTagVisibleAsync("server", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("server", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("server", "Bar");
+            await tags.AssertTagVisibleAsync("server", "Foo");
+            await tags.AssertTagVisibleAsync("server", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("server", "Foo");
+            await tags.AssertTagVisibleAsync("server", "Baz");
+            await tags.AssertTagNotVisibleAsync("server", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
 }

+ 60 - 0
Tests.E2e/ServiceCardTests.cs

@@ -178,4 +178,64 @@ public class ServiceCardTests(
             await context.CloseAsync();
         }
     }
+    
+    
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_Service_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/services/list");
+
+            var list = new ServicesListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddServiceAsync(name);
+            await page.WaitForURLAsync($"**/resources/services/{name}");
+
+            var card = new ServiceCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            var tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("service", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("service", "Foo");
+            await tags.AssertTagVisibleAsync("service", "Bar");
+            await tags.AssertTagVisibleAsync("service", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("service", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("service", "Bar");
+            await tags.AssertTagVisibleAsync("service", "Foo");
+            await tags.AssertTagVisibleAsync("service", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("service", "Foo");
+            await tags.AssertTagVisibleAsync("service", "Baz");
+            await tags.AssertTagNotVisibleAsync("service", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
 }

+ 67 - 0
Tests.E2e/SwitchCardTests.cs

@@ -228,4 +228,71 @@ public class SwitchCardTests(
             await context.CloseAsync();
         }
     }
+    
+    
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_Switch_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoSwitchesListAsync();
+
+            var list = new SwitchListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddSwitchAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new SwitchCardPom(page);
+
+            var tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("switch", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("switch", "Foo");
+            await tags.AssertTagVisibleAsync("switch", "Bar");
+            await tags.AssertTagVisibleAsync("switch", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("switch", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("switch", "Bar");
+            await tags.AssertTagVisibleAsync("switch", "Foo");
+            await tags.AssertTagVisibleAsync("switch", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("switch", "Foo");
+            await tags.AssertTagVisibleAsync("switch", "Baz");
+            await tags.AssertTagNotVisibleAsync("switch", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
 }

+ 60 - 0
Tests.E2e/SystemCardTests.cs

@@ -203,5 +203,65 @@ public class SystemCardTests(
             await context.CloseAsync();
         }
     }
+    
+    
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_System_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/systems/list");
+
+            var list = new SystemsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddSystemAsync(name);
+            await page.WaitForURLAsync($"**/resources/systems/{name}");
+
+            var card = new SystemCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            var tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("system", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("system", "Foo");
+            await tags.AssertTagVisibleAsync("system", "Bar");
+            await tags.AssertTagVisibleAsync("system", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("system", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("system", "Bar");
+            await tags.AssertTagVisibleAsync("system", "Foo");
+            await tags.AssertTagVisibleAsync("system", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("system", "Foo");
+            await tags.AssertTagVisibleAsync("system", "Baz");
+            await tags.AssertTagNotVisibleAsync("system", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
 
 }

+ 68 - 0
Tests.E2e/UpsCardTests.cs

@@ -173,4 +173,72 @@ public class UpsCardTests(
             await context.CloseAsync();
         }
     }
+    
+    
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_Ups_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoUpsListAsync();
+
+            var list = new UpsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddUpsAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new UpsCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            var tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("ups", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("ups", "Foo");
+            await tags.AssertTagVisibleAsync("ups", "Bar");
+            await tags.AssertTagVisibleAsync("ups", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("ups", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("ups", "Bar");
+            await tags.AssertTagVisibleAsync("ups", "Foo");
+            await tags.AssertTagVisibleAsync("ups", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("ups", "Foo");
+            await tags.AssertTagVisibleAsync("ups", "Baz");
+            await tags.AssertTagNotVisibleAsync("ups", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
 }