From d6d50ec8c5f7f654799525084b2bb9c8aac65fab Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sat, 9 May 2026 09:32:47 +0100 Subject: [PATCH 1/6] build: bump Aspire/Azure deps and pin SharpCompress for GHSA-6c8g-7p36-r338 --- Directory.Packages.props | 11 ++++++----- .../GroundControl.AppHost.csproj | 7 +++++-- .../GroundControl.Persistence.MongoDb.csproj | 5 ++++- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 7494dd25..6de6eb50 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,14 +8,14 @@ - + - - + + @@ -49,20 +49,21 @@ + - + - + diff --git a/src/GroundControl.AppHost/GroundControl.AppHost.csproj b/src/GroundControl.AppHost/GroundControl.AppHost.csproj index cfa691bd..08014b81 100644 --- a/src/GroundControl.AppHost/GroundControl.AppHost.csproj +++ b/src/GroundControl.AppHost/GroundControl.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe @@ -17,8 +17,11 @@ - + + + + \ No newline at end of file diff --git a/src/GroundControl.Persistence.MongoDb/GroundControl.Persistence.MongoDb.csproj b/src/GroundControl.Persistence.MongoDb/GroundControl.Persistence.MongoDb.csproj index d5934c6a..1e6437a2 100644 --- a/src/GroundControl.Persistence.MongoDb/GroundControl.Persistence.MongoDb.csproj +++ b/src/GroundControl.Persistence.MongoDb/GroundControl.Persistence.MongoDb.csproj @@ -17,8 +17,11 @@ - + + + + From ecc9d7feccdfd84c6ab414cacd84e2d582a4b79e Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sat, 9 May 2026 09:34:38 +0100 Subject: [PATCH 2/6] feat(api): support filtering for ungrouped projects on list endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `ungrouped=true` query parameter to GET /api/projects that returns only projects whose GroupId is null. Mutually exclusive with `groupId` — combining both yields a 400 ProblemDetails. Powers the ungrouped bucket on the redesigned Projects page. --- .../Contracts/ProjectPaginationQuery.cs | 17 +++++- .../Contracts/ListQuery.cs | 2 +- .../Contracts/ProjectListQuery.cs | 21 ++++++++ .../Stores/ProjectStore.cs | 6 ++- .../Projects/ProjectsHandlerTests.cs | 39 ++++++++++++++ .../Projects/ProjectStoreTests.cs | 52 +++++++++++++++++++ 6 files changed, 134 insertions(+), 3 deletions(-) diff --git a/src/GroundControl.Api/Features/Projects/Contracts/ProjectPaginationQuery.cs b/src/GroundControl.Api/Features/Projects/Contracts/ProjectPaginationQuery.cs index 56a82885..2cf1e005 100644 --- a/src/GroundControl.Api/Features/Projects/Contracts/ProjectPaginationQuery.cs +++ b/src/GroundControl.Api/Features/Projects/Contracts/ProjectPaginationQuery.cs @@ -1,13 +1,27 @@ +using System.ComponentModel.DataAnnotations; using GroundControl.Api.Shared.Pagination; using GroundControl.Persistence.Contracts; +using ValidationContext = System.ComponentModel.DataAnnotations.ValidationContext; namespace GroundControl.Api.Features.Projects.Contracts; -internal sealed class ProjectPaginationQuery : PaginationQuery +internal sealed class ProjectPaginationQuery : PaginationQuery, IValidatableObject { public Guid? GroupId { get; init; } + public bool? Ungrouped { get; init; } + public string? Search { get; init; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Ungrouped == true && GroupId.HasValue) + { + yield return new ValidationResult( + "Ungrouped cannot be combined with GroupId.", + [nameof(Ungrouped), nameof(GroupId)]); + } + } } internal static class ProjectPaginationQueryExtensions @@ -20,6 +34,7 @@ internal static class ProjectPaginationQueryExtensions SortField = query.SortField ?? PaginationQuery.DefaultSortField, SortOrder = query.SortOrder ?? PaginationQuery.DefaultSortOrder, GroupId = query.GroupId, + Ungrouped = query.Ungrouped ?? false, Search = query.Search, }; } \ No newline at end of file diff --git a/src/GroundControl.Persistence.Abstractions/Contracts/ListQuery.cs b/src/GroundControl.Persistence.Abstractions/Contracts/ListQuery.cs index 28996e48..9effb743 100644 --- a/src/GroundControl.Persistence.Abstractions/Contracts/ListQuery.cs +++ b/src/GroundControl.Persistence.Abstractions/Contracts/ListQuery.cs @@ -34,7 +34,7 @@ public class ListQuery : IValidatableObject public string SortOrder { get; set; } = "asc"; /// - public IEnumerable Validate(ValidationContext validationContext) + public virtual IEnumerable Validate(ValidationContext validationContext) { if (!string.IsNullOrWhiteSpace(After) && !string.IsNullOrWhiteSpace(Before)) { diff --git a/src/GroundControl.Persistence.Abstractions/Contracts/ProjectListQuery.cs b/src/GroundControl.Persistence.Abstractions/Contracts/ProjectListQuery.cs index 6c5f0223..97c18902 100644 --- a/src/GroundControl.Persistence.Abstractions/Contracts/ProjectListQuery.cs +++ b/src/GroundControl.Persistence.Abstractions/Contracts/ProjectListQuery.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + namespace GroundControl.Persistence.Contracts; /// @@ -10,8 +12,27 @@ public class ProjectListQuery : ListQuery /// public Guid? GroupId { get; set; } + /// + /// Gets or sets a value indicating whether to return only projects that have no owning group. + /// + public bool Ungrouped { get; set; } + /// /// Gets or sets the optional text search filter. /// public string? Search { get; set; } + + /// + public override IEnumerable Validate(ValidationContext validationContext) + { + foreach (var result in base.Validate(validationContext)) + { + yield return result; + } + + if (Ungrouped && GroupId.HasValue) + { + yield return new ValidationResult("Ungrouped cannot be combined with GroupId.", [nameof(Ungrouped), nameof(GroupId)]); + } + } } \ No newline at end of file diff --git a/src/GroundControl.Persistence.MongoDb/Stores/ProjectStore.cs b/src/GroundControl.Persistence.MongoDb/Stores/ProjectStore.cs index 7e33ed14..56cca073 100644 --- a/src/GroundControl.Persistence.MongoDb/Stores/ProjectStore.cs +++ b/src/GroundControl.Persistence.MongoDb/Stores/ProjectStore.cs @@ -114,7 +114,11 @@ private static FilterDefinition BuildEntityFilter(ProjectListQuery quer { var filters = new List>(); - if (query.GroupId.HasValue) + if (query.Ungrouped) + { + filters.Add(Builders.Filter.Eq(project => project.GroupId, null)); + } + else if (query.GroupId.HasValue) { filters.Add(Builders.Filter.Eq(project => project.GroupId, query.GroupId.Value)); } diff --git a/tests/GroundControl.Api.Tests/Projects/ProjectsHandlerTests.cs b/tests/GroundControl.Api.Tests/Projects/ProjectsHandlerTests.cs index 8765f9f3..ca1d593a 100644 --- a/tests/GroundControl.Api.Tests/Projects/ProjectsHandlerTests.cs +++ b/tests/GroundControl.Api.Tests/Projects/ProjectsHandlerTests.cs @@ -229,6 +229,45 @@ public async Task GetProjects_WithGroupIdFilter_ReturnsOnlyGroupProjects() page.Data.ShouldContain(p => p.Name == "Group Project"); } + [Fact] + public async Task GetProjects_WithUngroupedFilter_ReturnsOnlyProjectsWithoutGroup() + { + // Arrange + await using var factory = CreateFactory(); + using var apiClient = factory.CreateClient(); + var group = await CreateGroupAsync(apiClient, "Engineering", TestCancellationToken); + await CreateProjectAsync(apiClient, "Grouped Project", TestCancellationToken, group.Id); + await CreateProjectAsync(apiClient, "Ungrouped Project", TestCancellationToken); + + // Act + var response = await apiClient.GetAsync( + "/api/projects?limit=25&sortField=name&sortOrder=asc&ungrouped=true", TestCancellationToken); + + var page = await ReadPageAsync(response, TestCancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.OK); + page.Data.ShouldAllBe(p => p.GroupId == null); + page.Data.ShouldContain(p => p.Name == "Ungrouped Project"); + page.Data.ShouldNotContain(p => p.Name == "Grouped Project"); + } + + [Fact] + public async Task GetProjects_WithUngroupedAndGroupId_ReturnsValidationProblem() + { + // Arrange + await using var factory = CreateFactory(); + using var apiClient = factory.CreateClient(); + var group = await CreateGroupAsync(apiClient, "Engineering", TestCancellationToken); + + // Act + var response = await apiClient.GetAsync( + $"/api/projects?ungrouped=true&groupId={group.Id}", TestCancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + [Fact] public async Task GetProjects_WithPagination_ReturnsPaginatedResults() { diff --git a/tests/GroundControl.Persistence.MongoDb.Tests/Projects/ProjectStoreTests.cs b/tests/GroundControl.Persistence.MongoDb.Tests/Projects/ProjectStoreTests.cs index 30480609..d1fb12ef 100644 --- a/tests/GroundControl.Persistence.MongoDb.Tests/Projects/ProjectStoreTests.cs +++ b/tests/GroundControl.Persistence.MongoDb.Tests/Projects/ProjectStoreTests.cs @@ -69,6 +69,58 @@ public async Task ListAsync_WithForwardAndBackwardPagination_ReturnsExpectedPage previousPage.TotalCount.ShouldBe(3); } + [Fact] + public async Task ListAsync_WithUngrouped_ReturnsOnlyProjectsWithoutGroup() + { + // Arrange + var cancellationToken = TestContext.Current.CancellationToken; + var (store, _) = await CreateStoreAsync(cancellationToken); + var groupId = Guid.CreateVersion7(); + + await store.CreateAsync(CreateProject("Grouped", groupId), cancellationToken); + await store.CreateAsync(CreateProject("Ungrouped Alpha"), cancellationToken); + await store.CreateAsync(CreateProject("Ungrouped Beta"), cancellationToken); + + // Act + var result = await store.ListAsync(new ProjectListQuery + { + Ungrouped = true, + SortField = "name", + SortOrder = "asc" + }, cancellationToken); + + // Assert + result.Items.Select(project => project.Name).ShouldBe(["Ungrouped Alpha", "Ungrouped Beta"]); + result.TotalCount.ShouldBe(2); + result.Items.ShouldAllBe(project => project.GroupId == null); + } + + [Fact] + public async Task ListAsync_WithUngroupedAndSearch_FiltersUngroupedProjects() + { + // Arrange + var cancellationToken = TestContext.Current.CancellationToken; + var (store, _) = await CreateStoreAsync(cancellationToken); + var groupId = Guid.CreateVersion7(); + + await store.CreateAsync(CreateProject("Billing API", groupId), cancellationToken); + await store.CreateAsync(CreateProject("Billing Portal"), cancellationToken); + await store.CreateAsync(CreateProject("Inventory"), cancellationToken); + + // Act + var result = await store.ListAsync(new ProjectListQuery + { + Ungrouped = true, + Search = "billing", + SortField = "name", + SortOrder = "asc" + }, cancellationToken); + + // Assert + result.Items.Select(project => project.Name).ShouldBe(["Billing Portal"]); + result.TotalCount.ShouldBe(1); + } + [Fact] public async Task ListAsync_WithGroupIdAndSearch_ReturnsOnlyMatchingProjectsInGroup() { From c9212887b8d0884c1847a5247610bf05605a0280 Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sat, 9 May 2026 09:38:23 +0100 Subject: [PATCH 3/6] feat(api): add grouped projects endpoint for projects page Adds GET /api/projects/grouped that returns the first page of projects for every group plus a separate ungrouped bucket, all sorted by name ascending. Sections empty after applying the optional `search` filter are omitted. Per-group page size defaults to 10, capped at 100. Built to power the redesigned Projects page in a single round trip; each section returns a cursor that can be passed back to GET /api/projects with groupId or ungrouped=true to fetch subsequent pages. --- .../Projects/Contracts/GroupProjects.cs | 38 ++++++ .../Contracts/GroupedProjectsQuery.cs | 13 ++ .../Contracts/GroupedProjectsResponse.cs | 19 +++ .../Projects/Contracts/UngroupedProjects.cs | 23 ++++ .../Projects/ListGroupedProjectsHandler.cs | 128 ++++++++++++++++++ .../Features/Projects/ProjectsModule.cs | 2 + .../Projects/ProjectsHandlerTests.cs | 94 +++++++++++++ 7 files changed, 317 insertions(+) create mode 100644 src/GroundControl.Api/Features/Projects/Contracts/GroupProjects.cs create mode 100644 src/GroundControl.Api/Features/Projects/Contracts/GroupedProjectsQuery.cs create mode 100644 src/GroundControl.Api/Features/Projects/Contracts/GroupedProjectsResponse.cs create mode 100644 src/GroundControl.Api/Features/Projects/Contracts/UngroupedProjects.cs create mode 100644 src/GroundControl.Api/Features/Projects/ListGroupedProjectsHandler.cs diff --git a/src/GroundControl.Api/Features/Projects/Contracts/GroupProjects.cs b/src/GroundControl.Api/Features/Projects/Contracts/GroupProjects.cs new file mode 100644 index 00000000..c4b8ade1 --- /dev/null +++ b/src/GroundControl.Api/Features/Projects/Contracts/GroupProjects.cs @@ -0,0 +1,38 @@ +namespace GroundControl.Api.Features.Projects.Contracts; + +/// +/// Represents a single group section within a grouped project listing. +/// +internal sealed record GroupProjects +{ + /// + /// Gets the group identifier. + /// + public required Guid Id { get; init; } + + /// + /// Gets the group display name. + /// + public required string Name { get; init; } + + /// + /// Gets the optional group description. + /// + public string? Description { get; init; } + + /// + /// Gets the total number of projects in this group that match the current filter. + /// + public required long TotalCount { get; init; } + + /// + /// Gets the first page of matching projects in this group, sorted by name ascending. + /// + public required IReadOnlyList Projects { get; init; } + + /// + /// Gets the cursor used to fetch the next page via GET /api/projects?groupId=&after=, or + /// when no more pages exist. + /// + public string? NextCursor { get; init; } +} \ No newline at end of file diff --git a/src/GroundControl.Api/Features/Projects/Contracts/GroupedProjectsQuery.cs b/src/GroundControl.Api/Features/Projects/Contracts/GroupedProjectsQuery.cs new file mode 100644 index 00000000..ff46c618 --- /dev/null +++ b/src/GroundControl.Api/Features/Projects/Contracts/GroupedProjectsQuery.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace GroundControl.Api.Features.Projects.Contracts; + +internal sealed class GroupedProjectsQuery +{ + public const int DefaultPerGroup = 10; + + public string? Search { get; init; } + + [Range(1, 100)] + public int? PerGroup { get; init; } +} \ No newline at end of file diff --git a/src/GroundControl.Api/Features/Projects/Contracts/GroupedProjectsResponse.cs b/src/GroundControl.Api/Features/Projects/Contracts/GroupedProjectsResponse.cs new file mode 100644 index 00000000..62332b76 --- /dev/null +++ b/src/GroundControl.Api/Features/Projects/Contracts/GroupedProjectsResponse.cs @@ -0,0 +1,19 @@ +namespace GroundControl.Api.Features.Projects.Contracts; + +/// +/// Represents a project listing partitioned by owning group, with a separate bucket for ungrouped projects. +/// +internal sealed record GroupedProjectsResponse +{ + /// + /// Gets the per-group sections, sorted by group name ascending. Sections whose project list is empty + /// after applying the search filter are omitted. + /// + public required IReadOnlyList Groups { get; init; } + + /// + /// Gets the bucket of projects that have no owning group, or when no ungrouped + /// projects match the current filter. + /// + public UngroupedProjects? Ungrouped { get; init; } +} \ No newline at end of file diff --git a/src/GroundControl.Api/Features/Projects/Contracts/UngroupedProjects.cs b/src/GroundControl.Api/Features/Projects/Contracts/UngroupedProjects.cs new file mode 100644 index 00000000..8c70cb9c --- /dev/null +++ b/src/GroundControl.Api/Features/Projects/Contracts/UngroupedProjects.cs @@ -0,0 +1,23 @@ +namespace GroundControl.Api.Features.Projects.Contracts; + +/// +/// Represents the bucket of projects that have no owning group. +/// +internal sealed record UngroupedProjects +{ + /// + /// Gets the total number of ungrouped projects that match the current filter. + /// + public required long TotalCount { get; init; } + + /// + /// Gets the first page of matching ungrouped projects, sorted by name ascending. + /// + public required IReadOnlyList Projects { get; init; } + + /// + /// Gets the cursor used to fetch the next page via GET /api/projects?ungrouped=true&after=, + /// or when no more pages exist. + /// + public string? NextCursor { get; init; } +} \ No newline at end of file diff --git a/src/GroundControl.Api/Features/Projects/ListGroupedProjectsHandler.cs b/src/GroundControl.Api/Features/Projects/ListGroupedProjectsHandler.cs new file mode 100644 index 00000000..5aff1af9 --- /dev/null +++ b/src/GroundControl.Api/Features/Projects/ListGroupedProjectsHandler.cs @@ -0,0 +1,128 @@ +using GroundControl.Api.Features.Projects.Contracts; +using GroundControl.Api.Shared.Security; +using GroundControl.Persistence.Contracts; +using GroundControl.Persistence.Stores; +using Microsoft.AspNetCore.Mvc; + +namespace GroundControl.Api.Features.Projects; + +internal sealed class ListGroupedProjectsHandler : IEndpointHandler +{ + private const int MaxGroups = 10; + + private readonly IGroupStore _groupStore; + private readonly IProjectStore _projectStore; + + public ListGroupedProjectsHandler(IGroupStore groupStore, IProjectStore projectStore) + { + _groupStore = groupStore ?? throw new ArgumentNullException(nameof(groupStore)); + _projectStore = projectStore ?? throw new ArgumentNullException(nameof(projectStore)); + } + + public static void Endpoint(IEndpointRouteBuilder endpoints) + { + endpoints.MapGet("/grouped", async ( + [AsParameters] GroupedProjectsQuery query, + [FromServices] ListGroupedProjectsHandler handler, + CancellationToken cancellationToken = default) => await handler.HandleAsync(query, cancellationToken)) + .RequireAuthorization(Permissions.ProjectsRead) + .WithSummary("List projects grouped by owning group") + .WithDescription( + "Returns the first page of projects for every group plus an ungrouped bucket, all sorted by name ascending. " + + "Sections whose project list is empty after applying the search filter are omitted. " + + "Use the returned per-section cursor with GET /api/projects to fetch the next page.") + .Produces() + .ProducesProblem(StatusCodes.Status400BadRequest) + .WithName(nameof(ListGroupedProjectsHandler)); + } + + private async Task HandleAsync(GroupedProjectsQuery query, CancellationToken cancellationToken = default) + { + var perGroup = query.PerGroup ?? GroupedProjectsQuery.DefaultPerGroup; + var search = string.IsNullOrWhiteSpace(query.Search) ? null : query.Search; + + var groupsPage = await _groupStore.ListAsync( + new ListQuery + { + Limit = MaxGroups, + SortField = "name", + SortOrder = "asc" + }, + cancellationToken); + + var groupTasks = groupsPage.Items + .Select(group => LoadGroupSectionAsync(group, search, perGroup, cancellationToken)) + .ToList(); + + var ungroupedTask = LoadUngroupedSectionAsync(search, perGroup, cancellationToken); + + var groupResults = await Task.WhenAll(groupTasks); + var ungrouped = await ungroupedTask; + + var sections = groupResults + .Where(section => section is not null) + .Select(section => section!) + .ToList(); + + return TypedResults.Ok(new GroupedProjectsResponse + { + Groups = sections, + Ungrouped = ungrouped + }); + } + + private async Task LoadGroupSectionAsync(Group group, string? search, int perGroup, CancellationToken cancellationToken) + { + var page = await _projectStore.ListAsync( + new ProjectListQuery + { + Limit = perGroup, + SortField = "name", + SortOrder = "asc", + GroupId = group.Id, + Search = search + }, + cancellationToken); + + if (page.TotalCount == 0) + { + return null; + } + + return new GroupProjects + { + Id = group.Id, + Name = group.Name, + Description = group.Description, + TotalCount = page.TotalCount, + Projects = page.Items.Select(ProjectResponse.From).ToList(), + NextCursor = page.NextCursor + }; + } + + private async Task LoadUngroupedSectionAsync(string? search, int perGroup, CancellationToken cancellationToken) + { + var page = await _projectStore.ListAsync( + new ProjectListQuery + { + Limit = perGroup, + SortField = "name", + SortOrder = "asc", + Ungrouped = true, + Search = search + }, + cancellationToken); + + if (page.TotalCount == 0) + { + return null; + } + + return new UngroupedProjects + { + TotalCount = page.TotalCount, + Projects = page.Items.Select(ProjectResponse.From).ToList(), + NextCursor = page.NextCursor + }; + } +} \ No newline at end of file diff --git a/src/GroundControl.Api/Features/Projects/ProjectsModule.cs b/src/GroundControl.Api/Features/Projects/ProjectsModule.cs index d9b7626a..086e3824 100644 --- a/src/GroundControl.Api/Features/Projects/ProjectsModule.cs +++ b/src/GroundControl.Api/Features/Projects/ProjectsModule.cs @@ -10,6 +10,7 @@ public void OnServiceConfiguration(WebApplicationBuilder builder) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); @@ -30,6 +31,7 @@ public void OnApplicationConfiguration(WebApplication app) CreateProjectHandler.Endpoint(group); GetProjectHandler.Endpoint(group); ListProjectsHandler.Endpoint(group); + ListGroupedProjectsHandler.Endpoint(group); UpdateProjectHandler.Endpoint(group); DeleteProjectHandler.Endpoint(group); AddProjectTemplateHandler.Endpoint(group); diff --git a/tests/GroundControl.Api.Tests/Projects/ProjectsHandlerTests.cs b/tests/GroundControl.Api.Tests/Projects/ProjectsHandlerTests.cs index ca1d593a..bb4fab59 100644 --- a/tests/GroundControl.Api.Tests/Projects/ProjectsHandlerTests.cs +++ b/tests/GroundControl.Api.Tests/Projects/ProjectsHandlerTests.cs @@ -268,6 +268,92 @@ public async Task GetProjects_WithUngroupedAndGroupId_ReturnsValidationProblem() response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); } + [Fact] + public async Task GetGroupedProjects_ReturnsGroupSectionsAndUngroupedBucket() + { + // Arrange + await using var factory = CreateFactory(); + using var apiClient = factory.CreateClient(); + var platform = await CreateGroupAsync(apiClient, "Platform", TestCancellationToken); + var commerce = await CreateGroupAsync(apiClient, "Commerce", TestCancellationToken); + + await CreateProjectAsync(apiClient, "checkout-api", TestCancellationToken, platform.Id); + await CreateProjectAsync(apiClient, "config-cache", TestCancellationToken, platform.Id); + await CreateProjectAsync(apiClient, "ledger-worker", TestCancellationToken, platform.Id); + await CreateProjectAsync(apiClient, "storefront-web", TestCancellationToken, commerce.Id); + await CreateProjectAsync(apiClient, "loose-project", TestCancellationToken); + + // Act + var response = await apiClient.GetAsync("/api/projects/grouped?perGroup=2", TestCancellationToken); + var grouped = await ReadGroupedAsync(response, TestCancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.OK); + grouped.Groups.Select(g => g.Name).ShouldBe(["Commerce", "Platform"]); + + var platformSection = grouped.Groups.Single(g => g.Id == platform.Id); + platformSection.TotalCount.ShouldBe(3); + platformSection.Projects.Select(p => p.Name).ShouldBe(["checkout-api", "config-cache"]); + platformSection.NextCursor.ShouldNotBeNull(); + + var commerceSection = grouped.Groups.Single(g => g.Id == commerce.Id); + commerceSection.TotalCount.ShouldBe(1); + commerceSection.Projects.Select(p => p.Name).ShouldBe(["storefront-web"]); + commerceSection.NextCursor.ShouldBeNull(); + + grouped.Ungrouped.ShouldNotBeNull(); + grouped.Ungrouped.TotalCount.ShouldBe(1); + grouped.Ungrouped.Projects.Select(p => p.Name).ShouldBe(["loose-project"]); + } + + [Fact] + public async Task GetGroupedProjects_WithSearch_OmitsEmptyGroupsAndUngrouped() + { + // Arrange + await using var factory = CreateFactory(); + using var apiClient = factory.CreateClient(); + var platform = await CreateGroupAsync(apiClient, "Platform", TestCancellationToken); + var customer = await CreateGroupAsync(apiClient, "Customer", TestCancellationToken); + + await CreateProjectAsync(apiClient, "auth-service", TestCancellationToken, platform.Id); + await CreateProjectAsync(apiClient, "checkout-api", TestCancellationToken, platform.Id); + await CreateProjectAsync(apiClient, "loyalty-svc", TestCancellationToken, customer.Id); + + // Act + var response = await apiClient.GetAsync("/api/projects/grouped?search=auth", TestCancellationToken); + var grouped = await ReadGroupedAsync(response, TestCancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.OK); + grouped.Groups.Select(g => g.Id).ShouldBe([platform.Id]); + grouped.Groups.Single().Projects.Select(p => p.Name).ShouldBe(["auth-service"]); + grouped.Ungrouped.ShouldBeNull(); + } + + [Fact] + public async Task GetGroupedProjects_DefaultsPerGroupToConfiguredSize() + { + // Arrange + await using var factory = CreateFactory(); + using var apiClient = factory.CreateClient(); + var group = await CreateGroupAsync(apiClient, "Platform", TestCancellationToken); + for (var i = 1; i <= 16; i++) + { + await CreateProjectAsync(apiClient, $"project-{i:00}", TestCancellationToken, group.Id); + } + + // Act + var response = await apiClient.GetAsync("/api/projects/grouped", TestCancellationToken); + var grouped = await ReadGroupedAsync(response, TestCancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.OK); + var section = grouped.Groups.Single(); + section.TotalCount.ShouldBe(16); + section.Projects.Count.ShouldBe(GroupedProjectsQuery.DefaultPerGroup); + section.NextCursor.ShouldNotBeNull(); + } + [Fact] public async Task GetProjects_WithPagination_ReturnsPaginatedResults() { @@ -701,6 +787,14 @@ private static async Task> ReadPageAsync(Http return page; } + private static async Task ReadGroupedAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + var grouped = await response.Content.ReadFromJsonAsync(WebJsonSerializerOptions, TestCancellationToken).ConfigureAwait(false); + grouped.ShouldNotBeNull(); + + return grouped; + } + private static async Task ReadProjectAsync(HttpResponseMessage response, CancellationToken cancellationToken) { var project = await response.Content.ReadFromJsonAsync(WebJsonSerializerOptions, TestCancellationToken).ConfigureAwait(false); From cd47ae7f5a01b4dcb744b45d9ac92c0966ddf5b2 Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sat, 9 May 2026 10:09:49 +0100 Subject: [PATCH 4/6] feat(tower): redesign Projects page with grouped sections Replaces the flat Projects list with a grouped view powered by GET /api/projects/grouped: one section per owning group plus a separate "Other projects" bucket for ungrouped projects, all sorted by name. Sections empty after applying the search filter are omitted. The visible filter row is removed; search now lives behind a Filter button + popover that submits on Enter or Done. Active filters surface as dismissible chips just below the page header. Per-group "Show more" buttons lazily fetch the next 4 projects via the existing list endpoint using the cursor returned by each section. The old per-row group filter and sort dropdowns are gone. A new nested route /projects/group/$groupId hosts the focused per-group flat list behind each "View all" link. Adds tests for ProjectsFilterPopover, ActiveFilterChips, and the new relative-time formatter. --- src/GroundControl.Api/OpenApi.json | 173 ++++++++ .../src/api/endpoints/projects.ts | 4 + src/GroundControl.Tower/src/api/types.ts | 101 +++++ .../tower/projects/ActiveFilterChips.test.tsx | 29 ++ .../tower/projects/ActiveFilterChips.tsx | 40 ++ .../tower/projects/OtherProjectsSection.tsx | 82 ++++ .../tower/projects/ProjectGroupSection.tsx | 94 +++++ .../components/tower/projects/ProjectRow.tsx | 29 ++ .../projects/ProjectsFilterPopover.test.tsx | 74 ++++ .../tower/projects/ProjectsFilterPopover.tsx | 105 +++++ .../src/lib/relative-time.test.ts | 31 ++ .../src/lib/relative-time.ts | 40 ++ .../src/queries/useGroupedProjects.ts | 24 ++ .../src/queries/useProjects.ts | 41 +- src/GroundControl.Tower/src/routeTree.gen.ts | 21 + .../src/routes/projects/group/$groupId.tsx | 149 +++++++ .../src/routes/projects/index.tsx | 397 +++++------------- 17 files changed, 1127 insertions(+), 307 deletions(-) create mode 100644 src/GroundControl.Tower/src/components/tower/projects/ActiveFilterChips.test.tsx create mode 100644 src/GroundControl.Tower/src/components/tower/projects/ActiveFilterChips.tsx create mode 100644 src/GroundControl.Tower/src/components/tower/projects/OtherProjectsSection.tsx create mode 100644 src/GroundControl.Tower/src/components/tower/projects/ProjectGroupSection.tsx create mode 100644 src/GroundControl.Tower/src/components/tower/projects/ProjectRow.tsx create mode 100644 src/GroundControl.Tower/src/components/tower/projects/ProjectsFilterPopover.test.tsx create mode 100644 src/GroundControl.Tower/src/components/tower/projects/ProjectsFilterPopover.tsx create mode 100644 src/GroundControl.Tower/src/lib/relative-time.test.ts create mode 100644 src/GroundControl.Tower/src/lib/relative-time.ts create mode 100644 src/GroundControl.Tower/src/queries/useGroupedProjects.ts create mode 100644 src/GroundControl.Tower/src/routes/projects/group/$groupId.tsx diff --git a/src/GroundControl.Api/OpenApi.json b/src/GroundControl.Api/OpenApi.json index 854edf7f..e6da9f14 100644 --- a/src/GroundControl.Api/OpenApi.json +++ b/src/GroundControl.Api/OpenApi.json @@ -1684,6 +1684,13 @@ "format": "uuid" } }, + { + "name": "Ungrouped", + "in": "query", + "schema": { + "type": "boolean" + } + }, { "name": "Search", "in": "query", @@ -1941,6 +1948,61 @@ } } }, + "/api/projects/grouped": { + "get": { + "tags": [ + "Projects" + ], + "summary": "List projects grouped by owning group", + "description": "Returns the first page of projects for every group plus an ungrouped bucket, all sorted by name ascending. Sections whose project list is empty after applying the search filter are omitted. Use the returned per-section cursor with GET /api/projects to fetch the next page.", + "operationId": "ListGroupedProjectsHandler", + "parameters": [ + { + "name": "Search", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "PerGroup", + "in": "query", + "schema": { + "maximum": 100, + "minimum": 1, + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupedProjectsResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/projects/{id}/templates/{templateId}": { "put": { "tags": [ @@ -4900,6 +4962,84 @@ }, "description": "Represents a role grant in request and response contracts." }, + "GroupedProjectsResponse": { + "required": [ + "groups" + ], + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupProjects" + }, + "description": "Gets the per-group sections, sorted by group name ascending. Sections whose project list is empty\r\nafter applying the search filter are omitted." + }, + "ungrouped": { + "oneOf": [ + { + "type": "null" + }, + { + "description": "Gets the bucket of projects that have no owning group, or `null` when no ungrouped\r\nprojects match the current filter.", + "$ref": "#/components/schemas/UngroupedProjects" + } + ] + } + }, + "description": "Represents a project listing partitioned by owning group, with a separate bucket for ungrouped projects." + }, + "GroupProjects": { + "required": [ + "id", + "name", + "totalCount", + "projects" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Gets the group identifier.", + "format": "uuid" + }, + "name": { + "type": "string", + "description": "Gets the group display name." + }, + "description": { + "type": [ + "null", + "string" + ], + "description": "Gets the optional group description." + }, + "totalCount": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "description": "Gets the total number of projects in this group that match the current filter.", + "format": "int64" + }, + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectResponse" + }, + "description": "Gets the first page of matching projects in this group, sorted by name ascending." + }, + "nextCursor": { + "type": [ + "null", + "string" + ], + "description": "Gets the cursor used to fetch the next page via `GET /api/projects?groupId=&after=`, or\r\n`null` when no more pages exist." + } + }, + "description": "Represents a single group section within a grouped project listing." + }, "GroupResponse": { "required": [ "id", @@ -6050,6 +6190,39 @@ }, "description": "Represents the API response body for a template." }, + "UngroupedProjects": { + "required": [ + "totalCount", + "projects" + ], + "type": "object", + "properties": { + "totalCount": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "description": "Gets the total number of ungrouped projects that match the current filter.", + "format": "int64" + }, + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectResponse" + }, + "description": "Gets the first page of matching ungrouped projects, sorted by name ascending." + }, + "nextCursor": { + "type": [ + "null", + "string" + ], + "description": "Gets the cursor used to fetch the next page via `GET /api/projects?ungrouped=true&after=`,\r\nor `null` when no more pages exist." + } + }, + "description": "Represents the bucket of projects that have no owning group." + }, "UpdateClientRequest": { "required": [ "name", diff --git a/src/GroundControl.Tower/src/api/endpoints/projects.ts b/src/GroundControl.Tower/src/api/endpoints/projects.ts index 649129b2..b677e0b0 100644 --- a/src/GroundControl.Tower/src/api/endpoints/projects.ts +++ b/src/GroundControl.Tower/src/api/endpoints/projects.ts @@ -4,6 +4,10 @@ export function getProjects(query?: ApiQuery<'ListProjectsHandler'>) { return apiFetch>('/api/projects', { query }); } +export function getGroupedProjects(query?: ApiQuery<'ListGroupedProjectsHandler'>) { + return apiFetch>('/api/projects/grouped', { query }); +} + export function getProject(id: string) { return apiFetch>(`/api/projects/${encodeURIComponent(id)}`); } diff --git a/src/GroundControl.Tower/src/api/types.ts b/src/GroundControl.Tower/src/api/types.ts index f176f4f5..bde8fbda 100644 --- a/src/GroundControl.Tower/src/api/types.ts +++ b/src/GroundControl.Tower/src/api/types.ts @@ -444,6 +444,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/projects/grouped": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List projects grouped by owning group + * @description Returns the first page of projects for every group plus an ungrouped bucket, all sorted by name ascending. Sections whose project list is empty after applying the search filter are omitted. Use the returned per-section cursor with GET /api/projects to fetch the next page. + */ + get: operations["ListGroupedProjectsHandler"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/projects/{id}/templates/{templateId}": { parameters: { query?: never; @@ -1200,6 +1220,39 @@ export interface components { [key: string]: string[]; }; }; + /** @description Represents a project listing partitioned by owning group, with a separate bucket for ungrouped projects. */ + GroupedProjectsResponse: { + /** + * @description Gets the per-group sections, sorted by group name ascending. Sections whose project list is empty + * after applying the search filter are omitted. + */ + groups: components["schemas"]["GroupProjects"][]; + ungrouped?: null | components["schemas"]["UngroupedProjects"]; + }; + /** @description Represents a single group section within a grouped project listing. */ + GroupProjects: { + /** + * Format: uuid + * @description Gets the group identifier. + */ + id: string; + /** @description Gets the group display name. */ + name: string; + /** @description Gets the optional group description. */ + description?: null | string; + /** + * Format: int64 + * @description Gets the total number of projects in this group that match the current filter. + */ + totalCount: number | string; + /** @description Gets the first page of matching projects in this group, sorted by name ascending. */ + projects: components["schemas"]["ProjectResponse"][]; + /** + * @description Gets the cursor used to fetch the next page via `GET /api/projects?groupId=&after=`, or + * `null` when no more pages exist. + */ + nextCursor?: null | string; + }; /** @description Represents the API response body for a group. */ GroupResponse: { /** @@ -1731,6 +1784,21 @@ export interface components { */ updatedBy: string; }; + /** @description Represents the bucket of projects that have no owning group. */ + UngroupedProjects: { + /** + * Format: int64 + * @description Gets the total number of ungrouped projects that match the current filter. + */ + totalCount: number | string; + /** @description Gets the first page of matching ungrouped projects, sorted by name ascending. */ + projects: components["schemas"]["ProjectResponse"][]; + /** + * @description Gets the cursor used to fetch the next page via `GET /api/projects?ungrouped=true&after=`, + * or `null` when no more pages exist. + */ + nextCursor?: null | string; + }; /** @description Represents the request body for updating a client. */ UpdateClientRequest: { /** @description Gets the client name. */ @@ -3034,6 +3102,7 @@ export interface operations { parameters: { query?: { GroupId?: string; + Ungrouped?: boolean; Search?: string; /** @description Gets the cursor pointing to the item after which results should begin (forward pagination). */ After?: string; @@ -3254,6 +3323,38 @@ export interface operations { }; }; }; + ListGroupedProjectsHandler: { + parameters: { + query?: { + Search?: string; + PerGroup?: number | string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GroupedProjectsResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; AddProjectTemplateHandler: { parameters: { query?: never; diff --git a/src/GroundControl.Tower/src/components/tower/projects/ActiveFilterChips.test.tsx b/src/GroundControl.Tower/src/components/tower/projects/ActiveFilterChips.test.tsx new file mode 100644 index 00000000..3b74db84 --- /dev/null +++ b/src/GroundControl.Tower/src/components/tower/projects/ActiveFilterChips.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { ActiveFilterChips } from './ActiveFilterChips'; + +describe('ActiveFilterChips', () => { + it('renders nothing when no filters are active', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the search chip with the value', () => { + render(); + + expect(screen.getByText(/Search:/)).toBeInTheDocument(); + expect(screen.getByText('"auth"')).toBeInTheDocument(); + }); + + it('invokes the dismiss callback when the chip is removed', async () => { + const user = userEvent.setup(); + const onRemoveSearch = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Remove search filter' })); + + expect(onRemoveSearch).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/src/GroundControl.Tower/src/components/tower/projects/ActiveFilterChips.tsx b/src/GroundControl.Tower/src/components/tower/projects/ActiveFilterChips.tsx new file mode 100644 index 00000000..72e295d3 --- /dev/null +++ b/src/GroundControl.Tower/src/components/tower/projects/ActiveFilterChips.tsx @@ -0,0 +1,40 @@ +import { X } from 'lucide-react'; + +interface ActiveFilterChipsProps { + onRemoveSearch: () => void; + search: string | undefined; +} + +export function ActiveFilterChips({ onRemoveSearch, search }: ActiveFilterChipsProps) { + if (!search) { + return null; + } + + return ( +
+ +
+ ); +} + +interface ChipProps { + label: string; + onRemove: () => void; + value: string; +} + +function Chip({ label, onRemove, value }: ChipProps) { + return ( + + {label}: "{value}" + + + ); +} \ No newline at end of file diff --git a/src/GroundControl.Tower/src/components/tower/projects/OtherProjectsSection.tsx b/src/GroundControl.Tower/src/components/tower/projects/OtherProjectsSection.tsx new file mode 100644 index 00000000..fbc790e9 --- /dev/null +++ b/src/GroundControl.Tower/src/components/tower/projects/OtherProjectsSection.tsx @@ -0,0 +1,82 @@ +import { ChevronDown } from 'lucide-react'; +import { useState } from 'react'; +import { useGroupProjectsPage } from '@/queries/useProjects'; +import { ProjectRow, type ProjectRowItem } from './ProjectRow'; + +interface OtherProjectsSectionProps { + initialNextCursor: string | null | undefined; + initialProjects: ProjectRowItem[]; + search: string | undefined; + totalCount: number; +} + +interface SectionState { + cursor: string | null | undefined; + pendingCursor: string | undefined; + projects: ProjectRowItem[]; +} + +export function OtherProjectsSection({ initialNextCursor, initialProjects, search, totalCount }: OtherProjectsSectionProps) { + const [state, setState] = useState({ + cursor: initialNextCursor, + pendingCursor: undefined, + projects: initialProjects, + }); + + const remaining = Math.max(0, totalCount - state.projects.length); + const next = useGroupProjectsPage('ungrouped', search, state.pendingCursor); + + if (next.isSuccess && next.data && state.pendingCursor) { + setState((current) => current.pendingCursor === undefined + ? current + : { + cursor: next.data!.nextCursor ?? null, + pendingCursor: undefined, + projects: [...current.projects, ...(next.data!.data ?? [])], + }); + } + + function loadMore() { + if (!state.cursor) { + return; + } + + setState((current) => ({ ...current, pendingCursor: current.cursor ?? undefined })); + } + + return ( +
+
+
+ Other projects + {totalCount} +
+
+ +
+
    + {state.projects.map((project) => ( +
  • + +
  • + ))} +
+
+ + {state.cursor && remaining > 0 ? ( +
+ +
+ ) : null} +
+ ); +} \ No newline at end of file diff --git a/src/GroundControl.Tower/src/components/tower/projects/ProjectGroupSection.tsx b/src/GroundControl.Tower/src/components/tower/projects/ProjectGroupSection.tsx new file mode 100644 index 00000000..01442503 --- /dev/null +++ b/src/GroundControl.Tower/src/components/tower/projects/ProjectGroupSection.tsx @@ -0,0 +1,94 @@ +import { ChevronDown } from 'lucide-react'; +import { Link } from '@tanstack/react-router'; +import { useState } from 'react'; +import { useGroupProjectsPage } from '@/queries/useProjects'; +import { ProjectRow, type ProjectRowItem } from './ProjectRow'; + +interface ProjectGroupSectionProps { + description?: string | null; + groupId: string; + initialNextCursor: string | null | undefined; + initialProjects: ProjectRowItem[]; + name: string; + search: string | undefined; + totalCount: number; +} + +interface SectionState { + cursor: string | null | undefined; + pendingCursor: string | undefined; + projects: ProjectRowItem[]; +} + +export function ProjectGroupSection({ groupId, initialNextCursor, initialProjects, name, search, totalCount }: ProjectGroupSectionProps) { + const [state, setState] = useState({ + cursor: initialNextCursor, + pendingCursor: undefined, + projects: initialProjects, + }); + + const remaining = Math.max(0, totalCount - state.projects.length); + + const next = useGroupProjectsPage(groupId, search, state.pendingCursor); + + if (next.isSuccess && next.data && state.pendingCursor) { + setState((current) => current.pendingCursor === undefined + ? current + : { + cursor: next.data!.nextCursor ?? null, + pendingCursor: undefined, + projects: [...current.projects, ...(next.data!.data ?? [])], + }); + } + + function loadMore() { + if (!state.cursor) { + return; + } + + setState((current) => ({ ...current, pendingCursor: current.cursor ?? undefined })); + } + + return ( +
+
+
+

{name}

+ {totalCount} {totalCount === 1 ? 'project' : 'projects'} +
+ + View all → + +
+ +
+
    + {state.projects.map((project) => ( +
  • + +
  • + ))} +
+
+ + {state.cursor && remaining > 0 ? ( +
+ +
+ ) : null} +
+ ); +} \ No newline at end of file diff --git a/src/GroundControl.Tower/src/components/tower/projects/ProjectRow.tsx b/src/GroundControl.Tower/src/components/tower/projects/ProjectRow.tsx new file mode 100644 index 00000000..4dc04806 --- /dev/null +++ b/src/GroundControl.Tower/src/components/tower/projects/ProjectRow.tsx @@ -0,0 +1,29 @@ +import { Link } from '@tanstack/react-router'; +import { formatRelativeTime } from '@/lib/relative-time'; +import type { ApiResponse } from '@/api/client'; + +export type ProjectRowItem = ApiResponse<'GetProjectHandler'>; + +interface ProjectRowProps { + project: ProjectRowItem; +} + +export function ProjectRow({ project }: ProjectRowProps) { + return ( + +
+
{project.name}
+ {project.description ? ( +
{project.description}
+ ) : null} +
+
+ Updated {formatRelativeTime(project.updatedAt)} +
+ + ); +} \ No newline at end of file diff --git a/src/GroundControl.Tower/src/components/tower/projects/ProjectsFilterPopover.test.tsx b/src/GroundControl.Tower/src/components/tower/projects/ProjectsFilterPopover.test.tsx new file mode 100644 index 00000000..4a2ab4f6 --- /dev/null +++ b/src/GroundControl.Tower/src/components/tower/projects/ProjectsFilterPopover.test.tsx @@ -0,0 +1,74 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { ProjectsFilterPopover } from './ProjectsFilterPopover'; + +describe('ProjectsFilterPopover', () => { + it('does not render the count pill when no search filter is applied', () => { + render(); + + expect(screen.queryByText('1')).not.toBeInTheDocument(); + }); + + it('renders the count pill when a search filter is applied', () => { + render(); + + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('applies the trimmed search and closes when Done is clicked', async () => { + const user = userEvent.setup(); + const onApply = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Filter projects' })); + await user.type(screen.getByPlaceholderText('Project name or description'), ' auth '); + await user.click(screen.getByRole('button', { name: 'Done' })); + + expect(onApply).toHaveBeenCalledWith('auth'); + }); + + it('applies an empty search as undefined', async () => { + const user = userEvent.setup(); + const onApply = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Filter projects' })); + const input = screen.getByPlaceholderText('Project name or description'); + await user.clear(input); + await user.click(screen.getByRole('button', { name: 'Done' })); + + expect(onApply).toHaveBeenCalledWith(undefined); + }); + + it('submits the filter when Enter is pressed', async () => { + const user = userEvent.setup(); + const onApply = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Filter projects' })); + const input = screen.getByPlaceholderText('Project name or description'); + await user.type(input, 'billing{Enter}'); + + expect(onApply).toHaveBeenCalledWith('billing'); + }); + + it('clear all resets the filter and closes the popover', async () => { + const user = userEvent.setup(); + const onApply = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Filter projects' })); + await user.click(screen.getByRole('button', { name: 'Clear all' })); + + expect(onApply).toHaveBeenCalledWith(undefined); + }); + + it('disables clear all when no filter is active', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Filter projects' })); + expect(screen.getByRole('button', { name: 'Clear all' })).toBeDisabled(); + }); +}); \ No newline at end of file diff --git a/src/GroundControl.Tower/src/components/tower/projects/ProjectsFilterPopover.tsx b/src/GroundControl.Tower/src/components/tower/projects/ProjectsFilterPopover.tsx new file mode 100644 index 00000000..8c20c500 --- /dev/null +++ b/src/GroundControl.Tower/src/components/tower/projects/ProjectsFilterPopover.tsx @@ -0,0 +1,105 @@ +import { Filter, Search } from 'lucide-react'; +import { useEffect, useId, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; + +interface ProjectsFilterPopoverProps { + appliedSearch: string | undefined; + onApply: (search: string | undefined) => void; +} + +export function ProjectsFilterPopover({ appliedSearch, onApply }: ProjectsFilterPopoverProps) { + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(appliedSearch ?? ''); + const labelId = useId(); + const inputRef = useRef(null); + const activeCount = appliedSearch ? 1 : 0; + + useEffect(() => { + if (open) { + setDraft(appliedSearch ?? ''); + } + }, [open, appliedSearch]); + + function applyAndClose() { + const trimmed = draft.trim(); + onApply(trimmed.length > 0 ? trimmed : undefined); + setOpen(false); + } + + function clearAll() { + setDraft(''); + onApply(undefined); + setOpen(false); + } + + return ( + + + + + + { + event.preventDefault(); + inputRef.current?.focus(); + inputRef.current?.select(); + }}> +
+
+ +
+
+
+ +
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/GroundControl.Tower/src/lib/relative-time.test.ts b/src/GroundControl.Tower/src/lib/relative-time.test.ts new file mode 100644 index 00000000..c5ba20a9 --- /dev/null +++ b/src/GroundControl.Tower/src/lib/relative-time.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { formatRelativeTime } from './relative-time'; + +describe('formatRelativeTime', () => { + const now = new Date('2026-05-09T12:00:00Z'); + + it('returns "just now" for sub-minute differences', () => { + expect(formatRelativeTime('2026-05-09T11:59:30Z', now)).toBe('just now'); + }); + + it('formats minutes', () => { + expect(formatRelativeTime('2026-05-09T11:55:00Z', now)).toMatch(/min/); + }); + + it('formats hours', () => { + expect(formatRelativeTime('2026-05-09T07:00:00Z', now)).toMatch(/hr|hours?/i); + }); + + it('formats days as yesterday for -1', () => { + expect(formatRelativeTime('2026-05-08T12:00:00Z', now)).toMatch(/yesterday|day/i); + }); + + it('formats older entries as months', () => { + expect(formatRelativeTime('2026-02-09T12:00:00Z', now)).toMatch(/mo|month/i); + }); + + it('accepts a Date input', () => { + const target = new Date('2026-05-09T11:00:00Z'); + expect(formatRelativeTime(target, now)).toMatch(/hr|hour/i); + }); +}); \ No newline at end of file diff --git a/src/GroundControl.Tower/src/lib/relative-time.ts b/src/GroundControl.Tower/src/lib/relative-time.ts new file mode 100644 index 00000000..f772557f --- /dev/null +++ b/src/GroundControl.Tower/src/lib/relative-time.ts @@ -0,0 +1,40 @@ +const MinuteSeconds = 60; +const HourSeconds = 60 * MinuteSeconds; +const DaySeconds = 24 * HourSeconds; +const WeekSeconds = 7 * DaySeconds; +const MonthSeconds = 30 * DaySeconds; +const YearSeconds = 365 * DaySeconds; + +const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' }); + +export function formatRelativeTime(value: string | Date, now: Date = new Date()): string { + const target = typeof value === 'string' ? new Date(value) : value; + const diffSeconds = Math.round((target.getTime() - now.getTime()) / 1000); + const absSeconds = Math.abs(diffSeconds); + + if (absSeconds < MinuteSeconds) { + return 'just now'; + } + + if (absSeconds < HourSeconds) { + return formatter.format(Math.round(diffSeconds / MinuteSeconds), 'minute'); + } + + if (absSeconds < DaySeconds) { + return formatter.format(Math.round(diffSeconds / HourSeconds), 'hour'); + } + + if (absSeconds < WeekSeconds) { + return formatter.format(Math.round(diffSeconds / DaySeconds), 'day'); + } + + if (absSeconds < MonthSeconds) { + return formatter.format(Math.round(diffSeconds / WeekSeconds), 'week'); + } + + if (absSeconds < YearSeconds) { + return formatter.format(Math.round(diffSeconds / MonthSeconds), 'month'); + } + + return formatter.format(Math.round(diffSeconds / YearSeconds), 'year'); +} \ No newline at end of file diff --git a/src/GroundControl.Tower/src/queries/useGroupedProjects.ts b/src/GroundControl.Tower/src/queries/useGroupedProjects.ts new file mode 100644 index 00000000..9d7ae9ed --- /dev/null +++ b/src/GroundControl.Tower/src/queries/useGroupedProjects.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; +import { getGroupedProjects } from '@/api/endpoints/projects'; +import type { ApiQuery, ApiResponse } from '@/api/client'; + +export type GroupedProjectsResponse = NonNullable>; +export type GroupedProjectsQuery = ApiQuery<'ListGroupedProjectsHandler'>; + +export const groupedProjectsQueryKey = (query: GroupedProjectsQuery) => ['projects', 'grouped', query] as const; + +export function useGroupedProjects(query?: GroupedProjectsQuery) { + const request = buildQuery(query); + + return useQuery({ + queryFn: () => getGroupedProjects(request), + queryKey: groupedProjectsQueryKey(request), + }); +} + +function buildQuery(query?: GroupedProjectsQuery): GroupedProjectsQuery { + return { + Search: query?.Search, + PerGroup: query?.PerGroup, + }; +} \ No newline at end of file diff --git a/src/GroundControl.Tower/src/queries/useProjects.ts b/src/GroundControl.Tower/src/queries/useProjects.ts index 1d7ce588..15f70270 100644 --- a/src/GroundControl.Tower/src/queries/useProjects.ts +++ b/src/GroundControl.Tower/src/queries/useProjects.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation } from '@tanstack/react-query'; import { addProjectTemplate, createProject, getProjects, removeProjectTemplate, updateProject } from '@/api/endpoints/projects'; import type { ApiQuery, ApiRequestBody, ApiResponse } from '@/api/client'; import { useConflictMutation } from '@/lib/mutations'; @@ -7,16 +7,40 @@ import { queryClient } from '@/lib/query-client'; export type CreateProjectRequest = ApiRequestBody<'CreateProjectHandler'>; export type UpdateProjectRequest = ApiRequestBody<'UpdateProjectHandler'>; export type ProjectResponse = ApiResponse<'GetProjectHandler'>; +export type ProjectsQuery = ApiQuery<'ListProjectsHandler'>; + +const PerGroupShowMoreSize = 4; export function useProjects(query?: ProjectsQuery) { const request = buildProjectsQuery(query); return useQuery({ queryFn: () => getProjects(request), - queryKey: ['projects', request], + queryKey: ['projects', 'list', request], + }); +} + +export function useGroupProjectsPage(scope: GroupScope, search: string | undefined, cursor: string | undefined) { + const request: ProjectsQuery = { + After: cursor, + GroupId: scope === 'ungrouped' ? undefined : scope, + Limit: PerGroupShowMoreSize, + Search: search, + SortField: 'name', + SortOrder: 'asc', + Ungrouped: scope === 'ungrouped' ? true : undefined, + }; + + return useQuery({ + enabled: cursor !== undefined, + queryFn: () => getProjects(request), + queryKey: ['projects', 'show-more', scope, search, cursor], + staleTime: 60_000, }); } +export type GroupScope = string | 'ungrouped'; + function buildProjectsQuery(query?: ProjectsQuery): ProjectsQuery { return { Limit: query?.Limit ?? 100, @@ -25,36 +49,39 @@ function buildProjectsQuery(query?: ProjectsQuery): ProjectsQuery { After: query?.After, Before: query?.Before, GroupId: query?.GroupId, + Ungrouped: query?.Ungrouped, Search: query?.Search, }; } -export type ProjectsQuery = ApiQuery<'ListProjectsHandler'>; +function invalidateProjects() { + return queryClient.invalidateQueries({ queryKey: ['projects'] }); +} export function useCreateProject() { return useMutation({ mutationFn: (body: CreateProjectRequest) => createProject(body), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }), + onSuccess: invalidateProjects, }); } export function useUpdateProject(projectId: string) { return useConflictMutation<{ body: UpdateProjectRequest }, ProjectResponse>( (variables) => updateProject(projectId, variables.body, variables.version), - { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }) }, + { onSuccess: invalidateProjects }, ); } export function useAttachProjectTemplate(projectId: string) { return useConflictMutation<{ templateId: string }, unknown>( (variables) => addProjectTemplate(projectId, variables.templateId, variables.version), - { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }) }, + { onSuccess: invalidateProjects }, ); } export function useDetachProjectTemplate(projectId: string) { return useConflictMutation<{ templateId: string }, unknown>( (variables) => removeProjectTemplate(projectId, variables.templateId, variables.version), - { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }) }, + { onSuccess: invalidateProjects }, ); } \ No newline at end of file diff --git a/src/GroundControl.Tower/src/routeTree.gen.ts b/src/GroundControl.Tower/src/routeTree.gen.ts index 6dc04e99..e7f07dbe 100644 --- a/src/GroundControl.Tower/src/routeTree.gen.ts +++ b/src/GroundControl.Tower/src/routeTree.gen.ts @@ -23,6 +23,7 @@ import { Route as AdminGroupsRouteImport } from './routes/admin/groups' import { Route as ProjectsProjectIdRouteRouteImport } from './routes/projects/$projectId/route' import { Route as ProjectsProjectIdIndexRouteImport } from './routes/projects/$projectId/index' import { Route as AdminGroupsIndexRouteImport } from './routes/admin/groups.index' +import { Route as ProjectsGroupGroupIdRouteImport } from './routes/projects/group/$groupId' import { Route as ProjectsProjectIdSnapshotsRouteImport } from './routes/projects/$projectId/snapshots' import { Route as ProjectsProjectIdConfigRouteImport } from './routes/projects/$projectId/config' import { Route as ProjectsProjectIdClientsRouteImport } from './routes/projects/$projectId/clients' @@ -98,6 +99,11 @@ const AdminGroupsIndexRoute = AdminGroupsIndexRouteImport.update({ path: '/', getParentRoute: () => AdminGroupsRoute, } as any) +const ProjectsGroupGroupIdRoute = ProjectsGroupGroupIdRouteImport.update({ + id: '/projects/group/$groupId', + path: '/projects/group/$groupId', + getParentRoute: () => rootRouteImport, +} as any) const ProjectsProjectIdSnapshotsRoute = ProjectsProjectIdSnapshotsRouteImport.update({ id: '/snapshots', @@ -138,6 +144,7 @@ export interface FileRoutesByFullPath { '/projects/$projectId/clients': typeof ProjectsProjectIdClientsRoute '/projects/$projectId/config': typeof ProjectsProjectIdConfigRoute '/projects/$projectId/snapshots': typeof ProjectsProjectIdSnapshotsRoute + '/projects/group/$groupId': typeof ProjectsGroupGroupIdRoute '/admin/groups/': typeof AdminGroupsIndexRoute '/projects/$projectId/': typeof ProjectsProjectIdIndexRoute } @@ -156,6 +163,7 @@ export interface FileRoutesByTo { '/projects/$projectId/clients': typeof ProjectsProjectIdClientsRoute '/projects/$projectId/config': typeof ProjectsProjectIdConfigRoute '/projects/$projectId/snapshots': typeof ProjectsProjectIdSnapshotsRoute + '/projects/group/$groupId': typeof ProjectsGroupGroupIdRoute '/admin/groups': typeof AdminGroupsIndexRoute '/projects/$projectId': typeof ProjectsProjectIdIndexRoute } @@ -177,6 +185,7 @@ export interface FileRoutesById { '/projects/$projectId/clients': typeof ProjectsProjectIdClientsRoute '/projects/$projectId/config': typeof ProjectsProjectIdConfigRoute '/projects/$projectId/snapshots': typeof ProjectsProjectIdSnapshotsRoute + '/projects/group/$groupId': typeof ProjectsGroupGroupIdRoute '/admin/groups/': typeof AdminGroupsIndexRoute '/projects/$projectId/': typeof ProjectsProjectIdIndexRoute } @@ -199,6 +208,7 @@ export interface FileRouteTypes { | '/projects/$projectId/clients' | '/projects/$projectId/config' | '/projects/$projectId/snapshots' + | '/projects/group/$groupId' | '/admin/groups/' | '/projects/$projectId/' fileRoutesByTo: FileRoutesByTo @@ -217,6 +227,7 @@ export interface FileRouteTypes { | '/projects/$projectId/clients' | '/projects/$projectId/config' | '/projects/$projectId/snapshots' + | '/projects/group/$groupId' | '/admin/groups' | '/projects/$projectId' id: @@ -237,6 +248,7 @@ export interface FileRouteTypes { | '/projects/$projectId/clients' | '/projects/$projectId/config' | '/projects/$projectId/snapshots' + | '/projects/group/$groupId' | '/admin/groups/' | '/projects/$projectId/' fileRoutesById: FileRoutesById @@ -254,6 +266,7 @@ export interface RootRouteChildren { AdminTokensRoute: typeof AdminTokensRoute AdminUsersRoute: typeof AdminUsersRoute ProjectsIndexRoute: typeof ProjectsIndexRoute + ProjectsGroupGroupIdRoute: typeof ProjectsGroupGroupIdRoute } declare module '@tanstack/react-router' { @@ -356,6 +369,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminGroupsIndexRouteImport parentRoute: typeof AdminGroupsRoute } + '/projects/group/$groupId': { + id: '/projects/group/$groupId' + path: '/projects/group/$groupId' + fullPath: '/projects/group/$groupId' + preLoaderRoute: typeof ProjectsGroupGroupIdRouteImport + parentRoute: typeof rootRouteImport + } '/projects/$projectId/snapshots': { id: '/projects/$projectId/snapshots' path: '/snapshots' @@ -434,6 +454,7 @@ const rootRouteChildren: RootRouteChildren = { AdminTokensRoute: AdminTokensRoute, AdminUsersRoute: AdminUsersRoute, ProjectsIndexRoute: ProjectsIndexRoute, + ProjectsGroupGroupIdRoute: ProjectsGroupGroupIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/GroundControl.Tower/src/routes/projects/group/$groupId.tsx b/src/GroundControl.Tower/src/routes/projects/group/$groupId.tsx new file mode 100644 index 00000000..7423e5ac --- /dev/null +++ b/src/GroundControl.Tower/src/routes/projects/group/$groupId.tsx @@ -0,0 +1,149 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'; +import { useMemo } from 'react'; +import { ActiveFilterChips } from '@/components/tower/projects/ActiveFilterChips'; +import { ProjectRow } from '@/components/tower/projects/ProjectRow'; +import { ProjectsFilterPopover } from '@/components/tower/projects/ProjectsFilterPopover'; +import { PageContent } from '@/components/tower/shell/PageContent'; +import { PageHeader } from '@/components/tower/shell/PageHeader'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useGroups } from '@/queries/useGroups'; +import { useProjects } from '@/queries/useProjects'; + +const PageSize = 25; + +type GroupSearch = { + after?: string; + before?: string; + search?: string; +}; + +export const Route = createFileRoute('/projects/group/$groupId')({ + validateSearch: (search): GroupSearch => ({ + after: readOptionalString(search.after), + before: readOptionalString(search.before), + search: readOptionalString(search.search), + }), + component: ProjectsGroupRoute, +}); + +function ProjectsGroupRoute() { + const navigate = useNavigate({ from: Route.fullPath }); + const { groupId } = Route.useParams(); + const routeSearch = Route.useSearch(); + const groups = useGroups(); + const group = useMemo(() => (groups.data?.data ?? []).find((entry) => entry.id === groupId), [groups.data?.data, groupId]); + const projects = useProjects({ + After: routeSearch.after, + Before: routeSearch.before, + GroupId: groupId, + Limit: PageSize, + Search: routeSearch.search, + SortField: 'name', + SortOrder: 'asc', + }); + + function applySearch(value: string | undefined) { + void navigate({ + replace: true, + search: () => ({ after: undefined, before: undefined, search: value }), + }); + } + + function goNext() { + if (!projects.data?.nextCursor) { + return; + } + + void navigate({ + search: (current) => ({ ...current, after: projects.data?.nextCursor ?? undefined, before: undefined }), + }); + } + + function goPrevious() { + if (!projects.data?.previousCursor) { + return; + } + + void navigate({ + search: (current) => ({ ...current, after: undefined, before: projects.data?.previousCursor ?? undefined }), + }); + } + + const totalCount = Number(projects.data?.totalCount ?? 0); + const items = projects.data?.data ?? []; + const groupName = group?.name ?? 'Group'; + + return ( + <> + + + + + )} + description={group?.description ?? undefined} + eyebrow={( + Projects + )} + title={groupName} + /> + + +
+ applySearch(undefined)} search={routeSearch.search} /> + + {projects.isLoading ? ( +
{Array.from({ length: 5 }, (_, index) => )}
+ ) : null} + + {projects.isError ? ( +
Projects could not be loaded.
+ ) : null} + + {!projects.isLoading && !projects.isError && items.length === 0 ? ( +
+ No matching projects in {groupName}. +
+ ) : null} + + {items.length > 0 ? ( +
+
    + {items.map((project) => ( +
  • + +
  • + ))} +
+
+ ) : null} + + {!projects.isLoading && items.length > 0 ? ( +
+ {totalCount} {totalCount === 1 ? 'project' : 'projects'} +
+ + +
+
+ ) : null} +
+
+ + ); +} + +function readOptionalString(value: unknown) { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined; +} \ No newline at end of file diff --git a/src/GroundControl.Tower/src/routes/projects/index.tsx b/src/GroundControl.Tower/src/routes/projects/index.tsx index 0b096876..394e0999 100644 --- a/src/GroundControl.Tower/src/routes/projects/index.tsx +++ b/src/GroundControl.Tower/src/routes/projects/index.tsx @@ -1,350 +1,147 @@ -import { ChevronLeft, ChevronRight, Search, X } from 'lucide-react'; -import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'; -import { useDeferredValue, useMemo } from 'react'; -import { Badge } from '@/components/tower/data/Badge'; -import { InlineCode } from '@/components/tower/data/InlineCode'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { ActiveFilterChips } from '@/components/tower/projects/ActiveFilterChips'; import { NewProjectModal } from '@/components/tower/projects/NewProjectModal'; -import { PageHeader } from '@/components/tower/shell/PageHeader'; +import { OtherProjectsSection } from '@/components/tower/projects/OtherProjectsSection'; +import { ProjectGroupSection } from '@/components/tower/projects/ProjectGroupSection'; +import { ProjectsFilterPopover } from '@/components/tower/projects/ProjectsFilterPopover'; import { PageContent } from '@/components/tower/shell/PageContent'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { PageHeader } from '@/components/tower/shell/PageHeader'; import { Skeleton } from '@/components/ui/skeleton'; -import { useGroups } from '@/queries/useGroups'; -import { useProjects } from '@/queries/useProjects'; - -const AllGroupsValue = 'all-groups'; -const PageSize = 12; -const DefaultSortValue = 'name-asc'; +import { useGroupedProjects } from '@/queries/useGroupedProjects'; -const SortOptions = { - 'name-asc': { label: 'Name (A-Z)', sortField: 'name', sortOrder: 'asc' }, - 'name-desc': { label: 'Name (Z-A)', sortField: 'name', sortOrder: 'desc' }, - 'updated-desc': { label: 'Recently updated', sortField: 'updatedAt', sortOrder: 'desc' }, - 'updated-asc': { label: 'Least recently updated', sortField: 'updatedAt', sortOrder: 'asc' }, -} as const; - -type PageState = { - after?: string; - before?: string; - index: number; -}; +const PerGroup = 4; -type SortValue = keyof typeof SortOptions; type ProjectsSearch = { - after?: string; - before?: string; - group?: string; - page: number; - query: string; - sort: SortValue; + search?: string; }; -export const DefaultProjectsSearch: ProjectsSearch = { - page: 0, - query: '', - sort: DefaultSortValue, -}; +export const DefaultProjectsSearch: ProjectsSearch = {}; export const Route = createFileRoute('/projects/')({ validateSearch: (search): ProjectsSearch => ({ - after: readOptionalString(search.after), - before: readOptionalString(search.before), - group: readOptionalString(search.group), - page: readPageIndex(search.page), - query: readOptionalString(search.query) ?? DefaultProjectsSearch.query, - sort: readSortValue(search.sort), + search: readOptionalString(search.search), }), component: ProjectsRoute, }); function ProjectsRoute() { const navigate = useNavigate({ from: Route.fullPath }); - const routeSearch = Route.useSearch(); - const searchText = routeSearch.query; - const selectedGroupId = routeSearch.group ?? AllGroupsValue; - const selectedSort = routeSearch.sort; - const page: PageState = useMemo(() => ({ after: routeSearch.after, before: routeSearch.before, index: routeSearch.page }), [routeSearch.after, routeSearch.before, routeSearch.page]); - const deferredSearch = useDeferredValue(searchText.trim()); - const groupFilter = routeSearch.group; - const sort = SortOptions[selectedSort]; - const projects = useProjects({ - After: page.after, - Before: page.before, - GroupId: groupFilter, - Limit: PageSize, - Search: deferredSearch || undefined, - SortField: sort.sortField, - SortOrder: sort.sortOrder, - }); - const groups = useGroups(); - const groupNames = useMemo(() => new Map((groups.data?.data ?? []).map((group) => [group.id, group.name])), [groups.data?.data]); - const projectItems = projects.data?.data ?? []; - const totalCount = Number(projects.data?.totalCount ?? 0); - const hasFilters = !!deferredSearch || !!groupFilter; - const showingFrom = totalCount === 0 ? 0 : page.index * PageSize + 1; - const showingTo = totalCount === 0 ? 0 : page.index * PageSize + projectItems.length; - const summaryText = projects.isLoading ? 'Loading projects...' : `${showingFrom}-${showingTo} of ${totalCount} project${totalCount === 1 ? '' : 's'}`; - - function clearFilters() { - void navigate({ - replace: true, - search: (current) => normalizeSearch({ - ...current, - after: undefined, - before: undefined, - group: undefined, - page: 0, - query: '', - }), - }); - } - - function goToNextPage() { - if (!projects.data?.nextCursor) { - return; - } - - void navigate({ - search: (current) => normalizeSearch({ - ...current, - after: projects.data?.nextCursor ?? undefined, - before: undefined, - page: current.page + 1, - }), - }); - } - - function goToPreviousPage() { - if (!projects.data?.previousCursor) { - return; - } - - void navigate({ - search: (current) => normalizeSearch({ - ...current, - after: undefined, - before: projects.data?.previousCursor ?? undefined, - page: Math.max(0, current.page - 1), - }), - }); - } + const { search } = Route.useSearch(); + const grouped = useGroupedProjects({ Search: search, PerGroup }); - function updateQuery(query: string) { + function applySearch(value: string | undefined) { void navigate({ replace: true, - search: (current) => normalizeSearch({ - ...current, - after: undefined, - before: undefined, - page: 0, - query, - }), + search: () => ({ search: value }), }); } - function updateGroup(group: string) { - void navigate({ - replace: true, - search: (current) => normalizeSearch({ - ...current, - after: undefined, - before: undefined, - group: group === AllGroupsValue ? undefined : group, - page: 0, - }), - }); - } - - function updateSort(sortValue: string) { - if (!isSortValue(sortValue)) { - return; - } - - void navigate({ - replace: true, - search: (current) => normalizeSearch({ - ...current, - after: undefined, - before: undefined, - page: 0, - sort: sortValue, - }), - }); - } + const groups = grouped.data?.groups ?? []; + const ungrouped = grouped.data?.ungrouped ?? null; + const ungroupedCount = ungrouped ? Number(ungrouped.totalCount) : 0; + const hasResults = groups.length > 0 || ungroupedCount > 0; return ( <> - } description="Manage your configuration projects" title="Projects" /> + + + + + )} + description="Manage your configuration projects, organised by group." + title="Projects" + />
-
-
-
-
-
-
- -
- -
- -
- -
- -
- -
- - {projects.isLoading ? : null} - {projects.isError ?
Projects could not be loaded.
: null} - {!projects.isLoading && !projects.isError && projectItems.length === 0 ? : null} - {projectItems.length > 0 ? ( -
    - {projectItems.map((project) => ( -
  • - -
    -

    - {project.name} -

    - {project.groupId ? groupNames.get(project.groupId) ?? 'group pending' : 'ungrouped'} -
    -
    -

    {project.description || 'No description provided.'}

    - Updated {formatDate(project.updatedAt)} + applySearch(undefined)} search={search} /> + + {grouped.isLoading ? ( +
    + {Array.from({ length: 2 }, (_, sectionIndex) => ( +
    + +
    + {Array.from({ length: 3 }, (_, rowIndex) => )} +
    - -
  • + ))} +
+ ) : null} + + {grouped.isError ? ( +
Projects could not be loaded.
+ ) : null} + + {!grouped.isLoading && !grouped.isError && !hasResults ? ( + applySearch(undefined)} /> + ) : null} + + {groups.map((group) => ( + ))} - - ) : null} - {!projects.isLoading && !projects.isError && (projectItems.length > 0 || hasFilters) ? ( -
-
-
{summaryText}
-
-
- - -
-
- ) : null} + {ungrouped && ungroupedCount > 0 ? ( + + ) : null}
); } -function ProjectSkeletonList() { - return ( -
- {Array.from({ length: 5 }, (_, index) => )} -
- ); +interface EmptyStateProps { + hasFilter: boolean; + onClearFilter: () => void; } -function EmptyProjects({ hasFilters, onClearFilters }: { hasFilters: boolean; onClearFilters: () => void }) { +function EmptyState({ hasFilter, onClearFilter }: EmptyStateProps) { + if (hasFilter) { + return ( +
+

No matching projects

+

No project matches the current search. Try a different term or clear the filter.

+
+ +
+
+ ); + } + return (
-

{hasFilters ? 'No matching projects' : 'No projects yet'}

-

- {hasFilters ? 'Try clearing the current search or group filter to see more projects.' : 'Create the first project to start collecting entries, scopes, variables, templates, and snapshots.'} -

-
- {hasFilters ? : null} +

No projects yet

+

Create the first project to start collecting entries, scopes, variables, templates, and snapshots.

+
); } -function formatDate(value: string) { - return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)); -} - -function isSortValue(value: unknown): value is SortValue { - return typeof value === 'string' && value in SortOptions; -} - -function normalizeSearch(search: ProjectsSearch): ProjectsSearch { - return { - after: readOptionalString(search.after), - before: readOptionalString(search.before), - group: readOptionalString(search.group), - page: search.page > 0 ? search.page : DefaultProjectsSearch.page, - query: readOptionalString(search.query) ?? DefaultProjectsSearch.query, - sort: search.sort, - }; -} - function readOptionalString(value: unknown) { return typeof value === 'string' && value.trim().length > 0 ? value : undefined; -} - -function readPageIndex(value: unknown) { - if (typeof value === 'number' && Number.isInteger(value) && value >= 0) { - return value; - } - - if (typeof value === 'string') { - const parsed = Number.parseInt(value, 10); - return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0; - } - - return 0; -} - -function readSortValue(value: unknown): SortValue { - return isSortValue(value) ? value : DefaultSortValue; -} +} \ No newline at end of file From b735dcdc93c0bdbab28486466f0e1aef8f4cbe26 Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sat, 9 May 2026 11:41:48 +0100 Subject: [PATCH 5/6] feat(api): log warning when grouped projects soft cap is hit Emit a structured warning when the groups list for the composite endpoint returns a non-null next cursor so operations can detect when tenants exceed the 10-group soft cap before users notice missing sections. --- .../Projects/ListGroupedProjectsHandler.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/GroundControl.Api/Features/Projects/ListGroupedProjectsHandler.cs b/src/GroundControl.Api/Features/Projects/ListGroupedProjectsHandler.cs index 5aff1af9..22f0ced9 100644 --- a/src/GroundControl.Api/Features/Projects/ListGroupedProjectsHandler.cs +++ b/src/GroundControl.Api/Features/Projects/ListGroupedProjectsHandler.cs @@ -12,11 +12,13 @@ internal sealed class ListGroupedProjectsHandler : IEndpointHandler private readonly IGroupStore _groupStore; private readonly IProjectStore _projectStore; + private readonly ILogger _logger; - public ListGroupedProjectsHandler(IGroupStore groupStore, IProjectStore projectStore) + public ListGroupedProjectsHandler(IGroupStore groupStore, IProjectStore projectStore, ILogger logger) { _groupStore = groupStore ?? throw new ArgumentNullException(nameof(groupStore)); _projectStore = projectStore ?? throw new ArgumentNullException(nameof(projectStore)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public static void Endpoint(IEndpointRouteBuilder endpoints) @@ -50,6 +52,11 @@ private async Task HandleAsync(GroupedProjectsQuery query, Cancellation }, cancellationToken); + if (groupsPage.NextCursor is not null) + { + _logger.LogGroupsSoftCapHit(MaxGroups, groupsPage.TotalCount); + } + var groupTasks = groupsPage.Items .Select(group => LoadGroupSectionAsync(group, search, perGroup, cancellationToken)) .ToList(); @@ -125,4 +132,10 @@ private async Task HandleAsync(GroupedProjectsQuery query, Cancellation NextCursor = page.NextCursor }; } +} + +internal static partial class ListGroupedProjectsHandlerLogs +{ + [LoggerMessage(1, LogLevel.Warning, "Grouped projects endpoint hit the soft cap of {Cap} groups (total groups: {TotalCount}); sections beyond the cap are omitted from the response.")] + public static partial void LogGroupsSoftCapHit(this ILogger logger, int cap, long totalCount); } \ No newline at end of file From 8e099b123fd45d2fd7d03ea2bb92733b3d2a216e Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sat, 9 May 2026 11:41:53 +0100 Subject: [PATCH 6/6] fix(tower): address review feedback for projects page - Move the show-more pagination merge from the render body to a useEffect in ProjectGroupSection and OtherProjectsSection, removing conditional setState during render. - Use a single-group GET via a new useGroup hook on the per-group page instead of fetching the full groups list and filtering client side. - Add staleTime to useGroupedProjects so navigating back from a per-group page does not immediately refetch the index. - Replace the redundant string|'ungrouped' alias with an UngroupedScope sentinel constant. - Restore alphabetical import order in useProjects. --- .../tower/projects/OtherProjectsSection.tsx | 24 ++++++++++++------- .../tower/projects/ProjectGroupSection.tsx | 21 +++++++++------- .../src/queries/useGroupedProjects.ts | 1 + .../src/queries/useGroups.ts | 14 ++++++++++- .../src/queries/useProjects.ts | 12 ++++++---- .../src/routes/projects/group/$groupId.tsx | 10 ++++---- 6 files changed, 53 insertions(+), 29 deletions(-) diff --git a/src/GroundControl.Tower/src/components/tower/projects/OtherProjectsSection.tsx b/src/GroundControl.Tower/src/components/tower/projects/OtherProjectsSection.tsx index fbc790e9..354b598a 100644 --- a/src/GroundControl.Tower/src/components/tower/projects/OtherProjectsSection.tsx +++ b/src/GroundControl.Tower/src/components/tower/projects/OtherProjectsSection.tsx @@ -1,6 +1,6 @@ import { ChevronDown } from 'lucide-react'; -import { useState } from 'react'; -import { useGroupProjectsPage } from '@/queries/useProjects'; +import { useEffect, useState } from 'react'; +import { UngroupedScope, useGroupProjectsPage } from '@/queries/useProjects'; import { ProjectRow, type ProjectRowItem } from './ProjectRow'; interface OtherProjectsSectionProps { @@ -23,18 +23,24 @@ export function OtherProjectsSection({ initialNextCursor, initialProjects, searc projects: initialProjects, }); - const remaining = Math.max(0, totalCount - state.projects.length); - const next = useGroupProjectsPage('ungrouped', search, state.pendingCursor); + const next = useGroupProjectsPage(UngroupedScope, search, state.pendingCursor); + + useEffect(() => { + if (!next.isSuccess || !next.data || !state.pendingCursor) { + return; + } - if (next.isSuccess && next.data && state.pendingCursor) { + const page = next.data; setState((current) => current.pendingCursor === undefined ? current : { - cursor: next.data!.nextCursor ?? null, + cursor: page.nextCursor ?? null, pendingCursor: undefined, - projects: [...current.projects, ...(next.data!.data ?? [])], + projects: [...current.projects, ...(page.data ?? [])], }); - } + }, [next.isSuccess, next.data, state.pendingCursor]); + + const remaining = Math.max(0, totalCount - state.projects.length); function loadMore() { if (!state.cursor) { @@ -79,4 +85,4 @@ export function OtherProjectsSection({ initialNextCursor, initialProjects, searc ) : null} ); -} \ No newline at end of file +} diff --git a/src/GroundControl.Tower/src/components/tower/projects/ProjectGroupSection.tsx b/src/GroundControl.Tower/src/components/tower/projects/ProjectGroupSection.tsx index 01442503..1f783d68 100644 --- a/src/GroundControl.Tower/src/components/tower/projects/ProjectGroupSection.tsx +++ b/src/GroundControl.Tower/src/components/tower/projects/ProjectGroupSection.tsx @@ -1,6 +1,6 @@ import { ChevronDown } from 'lucide-react'; import { Link } from '@tanstack/react-router'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useGroupProjectsPage } from '@/queries/useProjects'; import { ProjectRow, type ProjectRowItem } from './ProjectRow'; @@ -27,19 +27,24 @@ export function ProjectGroupSection({ groupId, initialNextCursor, initialProject projects: initialProjects, }); - const remaining = Math.max(0, totalCount - state.projects.length); - const next = useGroupProjectsPage(groupId, search, state.pendingCursor); - if (next.isSuccess && next.data && state.pendingCursor) { + useEffect(() => { + if (!next.isSuccess || !next.data || !state.pendingCursor) { + return; + } + + const page = next.data; setState((current) => current.pendingCursor === undefined ? current : { - cursor: next.data!.nextCursor ?? null, + cursor: page.nextCursor ?? null, pendingCursor: undefined, - projects: [...current.projects, ...(next.data!.data ?? [])], + projects: [...current.projects, ...(page.data ?? [])], }); - } + }, [next.isSuccess, next.data, state.pendingCursor]); + + const remaining = Math.max(0, totalCount - state.projects.length); function loadMore() { if (!state.cursor) { @@ -91,4 +96,4 @@ export function ProjectGroupSection({ groupId, initialNextCursor, initialProject ) : null} ); -} \ No newline at end of file +} diff --git a/src/GroundControl.Tower/src/queries/useGroupedProjects.ts b/src/GroundControl.Tower/src/queries/useGroupedProjects.ts index 9d7ae9ed..9b4b32fb 100644 --- a/src/GroundControl.Tower/src/queries/useGroupedProjects.ts +++ b/src/GroundControl.Tower/src/queries/useGroupedProjects.ts @@ -13,6 +13,7 @@ export function useGroupedProjects(query?: GroupedProjectsQuery) { return useQuery({ queryFn: () => getGroupedProjects(request), queryKey: groupedProjectsQueryKey(request), + staleTime: 60_000, }); } diff --git a/src/GroundControl.Tower/src/queries/useGroups.ts b/src/GroundControl.Tower/src/queries/useGroups.ts index 805cbf3d..e55000e2 100644 --- a/src/GroundControl.Tower/src/queries/useGroups.ts +++ b/src/GroundControl.Tower/src/queries/useGroups.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery } from '@tanstack/react-query'; -import { getGroupMembers, getGroups, setGroupMember } from '@/api/endpoints/groups'; +import { getGroup, getGroupMembers, getGroups, setGroupMember } from '@/api/endpoints/groups'; import type { ApiResponse } from '@/api/client'; import { queryClient } from '@/lib/query-client'; @@ -22,6 +22,18 @@ export function useGroups() { }); } +export function groupQueryKey(groupId: string) { + return ['groups', groupId] as const; +} + +export function useGroup(groupId: string) { + return useQuery({ + queryFn: () => getGroup(groupId), + queryKey: groupQueryKey(groupId), + staleTime: 30_000, + }); +} + export function useGroupMembers(groupId: string | null) { return useQuery({ enabled: !!groupId, diff --git a/src/GroundControl.Tower/src/queries/useProjects.ts b/src/GroundControl.Tower/src/queries/useProjects.ts index 15f70270..fba7c470 100644 --- a/src/GroundControl.Tower/src/queries/useProjects.ts +++ b/src/GroundControl.Tower/src/queries/useProjects.ts @@ -1,4 +1,4 @@ -import { useQuery, useMutation } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { addProjectTemplate, createProject, getProjects, removeProjectTemplate, updateProject } from '@/api/endpoints/projects'; import type { ApiQuery, ApiRequestBody, ApiResponse } from '@/api/client'; import { useConflictMutation } from '@/lib/mutations'; @@ -20,15 +20,19 @@ export function useProjects(query?: ProjectsQuery) { }); } +export const UngroupedScope = 'ungrouped'; +export type GroupScope = string; + export function useGroupProjectsPage(scope: GroupScope, search: string | undefined, cursor: string | undefined) { + const isUngrouped = scope === UngroupedScope; const request: ProjectsQuery = { After: cursor, - GroupId: scope === 'ungrouped' ? undefined : scope, + GroupId: isUngrouped ? undefined : scope, Limit: PerGroupShowMoreSize, Search: search, SortField: 'name', SortOrder: 'asc', - Ungrouped: scope === 'ungrouped' ? true : undefined, + Ungrouped: isUngrouped ? true : undefined, }; return useQuery({ @@ -39,8 +43,6 @@ export function useGroupProjectsPage(scope: GroupScope, search: string | undefin }); } -export type GroupScope = string | 'ungrouped'; - function buildProjectsQuery(query?: ProjectsQuery): ProjectsQuery { return { Limit: query?.Limit ?? 100, diff --git a/src/GroundControl.Tower/src/routes/projects/group/$groupId.tsx b/src/GroundControl.Tower/src/routes/projects/group/$groupId.tsx index 7423e5ac..672173e7 100644 --- a/src/GroundControl.Tower/src/routes/projects/group/$groupId.tsx +++ b/src/GroundControl.Tower/src/routes/projects/group/$groupId.tsx @@ -1,6 +1,5 @@ import { ChevronLeft, ChevronRight } from 'lucide-react'; import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'; -import { useMemo } from 'react'; import { ActiveFilterChips } from '@/components/tower/projects/ActiveFilterChips'; import { ProjectRow } from '@/components/tower/projects/ProjectRow'; import { ProjectsFilterPopover } from '@/components/tower/projects/ProjectsFilterPopover'; @@ -8,7 +7,7 @@ import { PageContent } from '@/components/tower/shell/PageContent'; import { PageHeader } from '@/components/tower/shell/PageHeader'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; -import { useGroups } from '@/queries/useGroups'; +import { useGroup } from '@/queries/useGroups'; import { useProjects } from '@/queries/useProjects'; const PageSize = 25; @@ -32,8 +31,7 @@ function ProjectsGroupRoute() { const navigate = useNavigate({ from: Route.fullPath }); const { groupId } = Route.useParams(); const routeSearch = Route.useSearch(); - const groups = useGroups(); - const group = useMemo(() => (groups.data?.data ?? []).find((entry) => entry.id === groupId), [groups.data?.data, groupId]); + const group = useGroup(groupId); const projects = useProjects({ After: routeSearch.after, Before: routeSearch.before, @@ -73,7 +71,7 @@ function ProjectsGroupRoute() { const totalCount = Number(projects.data?.totalCount ?? 0); const items = projects.data?.data ?? []; - const groupName = group?.name ?? 'Group'; + const groupName = group.data?.name ?? 'Group'; return ( <> @@ -86,7 +84,7 @@ function ProjectsGroupRoute() {
)} - description={group?.description ?? undefined} + description={group.data?.description ?? undefined} eyebrow={( Projects )}