-
{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 ?
Clear filters : 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
diff --git a/tests/GroundControl.Api.Tests/Projects/ProjectsHandlerTests.cs b/tests/GroundControl.Api.Tests/Projects/ProjectsHandlerTests.cs
index 8765f9f3..bb4fab59 100644
--- a/tests/GroundControl.Api.Tests/Projects/ProjectsHandlerTests.cs
+++ b/tests/GroundControl.Api.Tests/Projects/ProjectsHandlerTests.cs
@@ -229,6 +229,131 @@ 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 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()
{
@@ -662,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);
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()
{