diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx index 72c34ba43c..1e2166434e 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx @@ -76,8 +76,7 @@ export function UserQuerying({ onFilterStateChange, onFiltersUpdated }: UserQuer search: (prev) => ({ ...prev, ...params, - pageOffset: undefined, - userId: undefined + pageOffset: undefined }) }); // Only call onFiltersUpdated for actual filter changes, not search updates @@ -89,7 +88,7 @@ export function UserQuerying({ onFilterStateChange, onFiltersUpdated }: UserQuer ); useEffect(() => { - updateFilter({ search: debouncedSearch }, true); + updateFilter({ search: debouncedSearch || undefined }, true); }, [debouncedSearch, updateFilter]); // Count active filters for badge diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx index cd6145dc14..778dadc82b 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -51,8 +51,8 @@ export function UserTable({ const userInfo = useUserInfo(); const [sortDescriptor, setSortDescriptor] = useState(() => ({ - column: orderBy ?? "email", - direction: sortOrder === "Ascending" ? "ascending" : "descending" + column: orderBy ?? SortableUserProperties.Name, + direction: sortOrder === SortOrder.Descending ? "descending" : "ascending" })); const isKeyboardNavigation = useKeyboardNavigation(); const isMobile = useViewportResize(); @@ -66,9 +66,9 @@ export function UserTable({ UserStatus: userStatus, StartDate: startDate, EndDate: endDate, - OrderBy: orderBy, - SortOrder: sortOrder, - PageOffset: pageOffset + OrderBy: orderBy ?? SortableUserProperties.Name, + SortOrder: sortOrder ?? SortOrder.Ascending, + PageOffset: pageOffset ?? 0 } }, enabled: !isMobile @@ -112,12 +112,16 @@ export function UserTable({ const handleSortChange = useCallback( (newSortDescriptor: SortDescriptor) => { setSortDescriptor(newSortDescriptor); + const newOrderBy = (newSortDescriptor.column?.toString() ?? + SortableUserProperties.Name) as SortableUserProperties; + const newSortOrder = newSortDescriptor.direction === "ascending" ? SortOrder.Ascending : SortOrder.Descending; + navigate({ to: "/admin/users", search: (prev) => ({ ...prev, - orderBy: (newSortDescriptor.column?.toString() ?? "Name") as SortableUserProperties, - sortOrder: newSortDescriptor.direction === "ascending" ? SortOrder.Ascending : SortOrder.Descending, + orderBy: newOrderBy === SortableUserProperties.Name ? undefined : newOrderBy, + sortOrder: newSortOrder === SortOrder.Ascending ? undefined : newSortOrder, pageOffset: undefined }) }); diff --git a/application/account-management/WebApp/routes/admin/users/-hooks/useInfiniteUsers.ts b/application/account-management/WebApp/routes/admin/users/-hooks/useInfiniteUsers.ts index 8009dc3294..a338211e6f 100644 --- a/application/account-management/WebApp/routes/admin/users/-hooks/useInfiniteUsers.ts +++ b/application/account-management/WebApp/routes/admin/users/-hooks/useInfiniteUsers.ts @@ -2,8 +2,8 @@ import { useCallback, useEffect, useState } from "react"; import { api, type components, - type SortableUserProperties, - type SortOrder, + SortableUserProperties, + SortOrder, type UserRole, type UserStatus } from "@/shared/lib/api/client"; @@ -45,8 +45,8 @@ export function useInfiniteUsers({ UserStatus: userStatus, StartDate: startDate, EndDate: endDate, - OrderBy: orderBy, - SortOrder: sortOrder, + OrderBy: orderBy ?? SortableUserProperties.Name, + SortOrder: sortOrder ?? SortOrder.Ascending, PageOffset: 0 } }, @@ -65,8 +65,8 @@ export function useInfiniteUsers({ UserStatus: userStatus, StartDate: startDate, EndDate: endDate, - OrderBy: orderBy, - SortOrder: sortOrder, + OrderBy: orderBy ?? SortableUserProperties.Name, + SortOrder: sortOrder ?? SortOrder.Ascending, PageOffset: nextPageToLoad } }, diff --git a/application/account-management/WebApp/routes/admin/users/index.tsx b/application/account-management/WebApp/routes/admin/users/index.tsx index c3bd41b52d..8f22d5d3e5 100644 --- a/application/account-management/WebApp/routes/admin/users/index.tsx +++ b/application/account-management/WebApp/routes/admin/users/index.tsx @@ -22,9 +22,9 @@ const userPageSearchSchema = z.object({ userStatus: z.nativeEnum(UserStatus).nullable().optional(), startDate: z.string().optional(), endDate: z.string().optional(), - orderBy: z.nativeEnum(SortableUserProperties).default(SortableUserProperties.Name).optional(), - sortOrder: z.nativeEnum(SortOrder).default(SortOrder.Ascending).optional(), - pageOffset: z.number().default(0).optional(), + orderBy: z.nativeEnum(SortableUserProperties).optional(), + sortOrder: z.nativeEnum(SortOrder).optional(), + pageOffset: z.number().optional(), userId: z.string().optional() }); diff --git a/application/account-management/WebApp/tests/e2e/mobile-view-flows.spec.ts b/application/account-management/WebApp/tests/e2e/mobile-view-flows.spec.ts index 0c9c8f31d9..23a78ac8d7 100644 --- a/application/account-management/WebApp/tests/e2e/mobile-view-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/mobile-view-flows.spec.ts @@ -65,7 +65,7 @@ test.describe("@comprehensive", () => { await step("Open mobile menu & verify all navigation and settings are accessible")(async () => { await page.getByRole("button", { name: "Open navigation menu" }).click(); - const mobileDialog = page.getByRole("dialog"); + const mobileDialog = page.getByRole("dialog", { name: "Mobile navigation menu" }); await expect(mobileDialog).toBeVisible(); // Verify user profile section is visible @@ -120,7 +120,7 @@ test.describe("@comprehensive", () => { await expect(page.getByText(newTitle)).toBeVisible(); // Close mobile menu by clicking the X button - const mobileDialog = page.getByRole("dialog"); + const mobileDialog = page.getByRole("dialog", { name: "Mobile navigation menu" }); await mobileDialog.getByRole("button", { name: "Close menu" }).click(); await expect(mobileDialog).not.toBeVisible(); })(); @@ -148,7 +148,7 @@ test.describe("@comprehensive", () => { await step("Change language back to English & verify language updates")(async () => { await page.getByRole("button", { name: "Åbn navigationsmenu" }).click(); - const mobileDialog = page.getByRole("dialog"); + const mobileDialog = page.getByRole("dialog", { name: "Mobile navigation menu" }); await mobileDialog.getByRole("button", { name: "Sprog" }).click(); // Wait for language menu to open @@ -168,7 +168,7 @@ test.describe("@comprehensive", () => { await step("Change theme through mobile menu & verify theme applies")(async () => { await page.getByRole("button", { name: "Open navigation menu" }).click(); - const mobileDialog = page.getByRole("dialog"); + const mobileDialog = page.getByRole("dialog", { name: "Mobile navigation menu" }); await mobileDialog.getByRole("button", { name: "Theme" }).click(); // Wait for theme menu to open @@ -188,7 +188,7 @@ test.describe("@comprehensive", () => { await step("Navigate to users page through mobile menu & verify navigation works")(async () => { await page.getByRole("button", { name: "Open navigation menu" }).click(); - const mobileDialog = page.getByRole("dialog"); + const mobileDialog = page.getByRole("dialog", { name: "Mobile navigation menu" }); await mobileDialog.getByRole("link", { name: "Users" }).click(); // Mobile menu should close diff --git a/application/account-management/WebApp/tests/e2e/theme-flows.spec.ts b/application/account-management/WebApp/tests/e2e/theme-flows.spec.ts index 89ef6128e8..d6b6da64c8 100644 --- a/application/account-management/WebApp/tests/e2e/theme-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/theme-flows.spec.ts @@ -232,7 +232,7 @@ test.describe("@comprehensive", () => { await page.getByRole("button", { name: "User profile menu" }).click(); // Wait for user menu to open - const userMenu = page.getByRole("menu"); + const userMenu = page.getByRole("menu", { name: "User profile menu" }); await expect(userMenu).toBeVisible(); await page.getByRole("menuitem", { name: "Log out" }).click(); diff --git a/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts b/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts index 3ddd1e74f1..a46719f706 100644 --- a/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts @@ -1,6 +1,6 @@ import { expect } from "@playwright/test"; import { test } from "@shared/e2e/fixtures/page-auth"; -import { createTestContext, expectToastMessage } from "@shared/e2e/utils/test-assertions"; +import { blurActiveElement, createTestContext, expectToastMessage } from "@shared/e2e/utils/test-assertions"; import { completeSignupFlow, getVerificationCode, testUser } from "@shared/e2e/utils/test-data"; import { step } from "@shared/e2e/utils/test-step-wrapper"; @@ -329,13 +329,26 @@ test.describe("@comprehensive", () => { })(); await step("Filter by Owner role & verify only owner shown")(async () => { - // Clear the existing status filter (currently set to "Pending") - await page.getByLabel("User status").first().click(); - await page.getByRole("option", { name: "Any status" }).click(); - - // Now filter by Owner role - await page.getByLabel("User role").first().click(); - await page.getByRole("option", { name: "Owner" }).click(); + // Filters are in the dialog that was opened in the previous step + const filterDialog = page.getByRole("dialog", { name: "Filters" }); + + // Clear status filter + await filterDialog.getByLabel("User status").click(); + const statusListbox = page.getByRole("listbox", { name: "User status" }); + await expect(statusListbox).toBeVisible(); + await statusListbox.getByRole("option", { name: "Any status" }).click(); + await blurActiveElement(page); + + // Set role filter to Owner + await filterDialog.getByLabel("User role").click(); + const roleListbox = page.getByRole("listbox", { name: "User role" }); + await expect(roleListbox).toBeVisible(); + await roleListbox.getByRole("option", { name: "Owner" }).click(); + await blurActiveElement(page); + + // Click OK to apply and close dialog + await filterDialog.getByRole("button", { name: "OK" }).click(); + await expect(filterDialog).not.toBeVisible(); // Verify only owner is shown await expect(page.locator("tbody").first().first().locator("tr")).toHaveCount(1); @@ -345,9 +358,21 @@ test.describe("@comprehensive", () => { })(); await step("Filter by Member role & verify only members shown")(async () => { - // Change filter to Member role - await page.getByLabel("User role").first().click(); - await page.getByRole("option", { name: "Member" }).click(); + // Reopen dialog + await page.getByRole("button", { name: "Show filters" }).click(); + const filterDialog = page.getByRole("dialog", { name: "Filters" }); + await expect(filterDialog).toBeVisible(); + + // Set role filter to Member + await filterDialog.getByLabel("User role").click(); + const roleListbox = page.getByRole("listbox", { name: "User role" }); + await expect(roleListbox).toBeVisible(); + await roleListbox.getByRole("option", { name: "Member" }).click(); + await blurActiveElement(page); + + // Click OK to apply and close dialog + await filterDialog.getByRole("button", { name: "OK" }).click(); + await expect(filterDialog).not.toBeVisible(); // Verify only member users are shown await expect(page.locator("tbody").first().first().locator("tr")).toHaveCount(3); @@ -357,13 +382,39 @@ test.describe("@comprehensive", () => { await expect(page.locator("tbody").first()).not.toContainText(owner.email); })(); + await step("Clear all filters & verify all users are shown")(async () => { + // Reopen filter dialog + await page.getByRole("button", { name: "Show filters" }).click(); + const filterDialog = page.getByRole("dialog", { name: "Filters" }); + await expect(filterDialog).toBeVisible(); + + // Click Clear button to reset all filters + await filterDialog.getByRole("button", { name: "Clear" }).click(); + + // Close dialog with Escape (OK button gets detached after Clear click due to re-render) + await page.keyboard.press("Escape"); + await expect(filterDialog).not.toBeVisible(); + + // Verify all 4 users are shown (no filters applied) + await expect(page.locator("tbody").first().first().locator("tr")).toHaveCount(4); + })(); + await step("Filter by Pending status & verify only pending users shown")(async () => { - // Reset role filter and set status filter - await page.getByLabel("User role").first().click(); - await page.getByRole("option", { name: "Any role" }).click(); + // Reopen filter dialog + await page.getByRole("button", { name: "Show filters" }).click(); + const filterDialog = page.getByRole("dialog", { name: "Filters" }); + await expect(filterDialog).toBeVisible(); - await page.getByLabel("User status").first().click(); - await page.getByRole("option", { name: "Pending" }).click(); + // Set status filter to Pending + await filterDialog.getByLabel("User status").click(); + const statusListbox = page.getByRole("listbox", { name: "User status" }); + await expect(statusListbox).toBeVisible(); + await statusListbox.getByRole("option", { name: "Pending" }).click(); + await blurActiveElement(page); + + // Close dialog + await filterDialog.getByRole("button", { name: "OK" }).click(); + await expect(filterDialog).not.toBeVisible(); // Verify only pending users are shown (invited users who haven't confirmed) await expect(page.locator("tbody").first().first().locator("tr")).toHaveCount(3); @@ -374,9 +425,21 @@ test.describe("@comprehensive", () => { })(); await step("Filter by Active status & verify only active users shown")(async () => { - // Change filter to Active status - await page.getByLabel("User status").first().click(); - await page.getByRole("option", { name: "Active" }).click(); + // Reopen dialog + await page.getByRole("button", { name: "Show filters" }).click(); + const filterDialog = page.getByRole("dialog", { name: "Filters" }); + await expect(filterDialog).toBeVisible(); + + // Set status filter to Active + await filterDialog.getByLabel("User status").click(); + const statusListbox = page.getByRole("listbox", { name: "User status" }); + await expect(statusListbox).toBeVisible(); + await statusListbox.getByRole("option", { name: "Active" }).click(); + await blurActiveElement(page); + + // Click OK to apply and close dialog + await filterDialog.getByRole("button", { name: "OK" }).click(); + await expect(filterDialog).not.toBeVisible(); // Verify only active users are shown (owner who has confirmed email) await expect(page.locator("tbody").first().first().locator("tr")).toHaveCount(1); @@ -385,32 +448,35 @@ test.describe("@comprehensive", () => { })(); await step("Filter by past date range & verify no users shown")(async () => { - // Reset status filter first - await page.getByLabel("User status").first().click(); - await page.getByRole("option", { name: "Any status" }).click(); + // Reopen dialog + await page.getByRole("button", { name: "Show filters" }).click(); + const filterDialog = page.getByRole("dialog", { name: "Filters" }); + await expect(filterDialog).toBeVisible(); + + // Clear status filter + await filterDialog.getByLabel("User status").click(); + const statusListbox = page.getByRole("listbox", { name: "User status" }); + await expect(statusListbox).toBeVisible(); + await statusListbox.getByRole("option", { name: "Any status" }).click(); + await blurActiveElement(page); // Open date picker - await page.getByLabel("Modified date").first().click(); - - // Set start date to January 1, 2024 - await page.locator('[role="spinbutton"][aria-label="month, Start Date, "]').clear(); - await page.locator('[role="spinbutton"][aria-label="month, Start Date, "]').type("01"); - await page.locator('[role="spinbutton"][aria-label="day, Start Date, "]').clear(); - await page.locator('[role="spinbutton"][aria-label="day, Start Date, "]').type("01"); - await page.locator('[role="spinbutton"][aria-label="year, Start Date, "]').clear(); - await page.locator('[role="spinbutton"][aria-label="year, Start Date, "]').type("2024"); - - // Set end date to December 31, 2024 - await page.locator('[role="spinbutton"][aria-label="month, End Date, "]').clear(); - await page.locator('[role="spinbutton"][aria-label="month, End Date, "]').type("12"); - await page.locator('[role="spinbutton"][aria-label="day, End Date, "]').clear(); - await page.locator('[role="spinbutton"][aria-label="day, End Date, "]').type("31"); - await page.locator('[role="spinbutton"][aria-label="year, End Date, "]').clear(); - await page.locator('[role="spinbutton"][aria-label="year, End Date, "]').type("2024"); - - // Close the calendar + await filterDialog.getByLabel("Modified date").click(); + + // Close the calendar picker to enter keyboard mode await page.keyboard.press("Escape"); + // Click on year field to focus it for keyboard entry + await page.locator('[role="spinbutton"][aria-label="year, Start Date, "]').click({ force: true }); + + // Set dates via keyboard (auto-advances through fields) + await page.keyboard.type("20240101"); + await page.keyboard.type("20241231"); + + // Close the filter dialog + await filterDialog.getByRole("button", { name: "OK" }).click(); + await expect(filterDialog).not.toBeVisible(); + // Verify no users are shown for the past date range (users were created in 2025) await expect(page.locator("tbody").first().first().locator("tr")).toHaveCount(0); })(); @@ -443,7 +509,7 @@ test.describe("@comprehensive", () => { // Verify user is removed from table await expectToastMessage(context, `User deleted successfully: ${user1.email}`); - await expect(page.getByRole("alertdialog")).not.toBeVisible(); + await expect(page.getByRole("alertdialog", { name: "Delete user" })).not.toBeVisible(); await expect(page.locator("tbody").first().first().locator("tr")).toHaveCount(3); // owner + user2 + user3 await expect(page.getByText(user1.email)).not.toBeVisible(); await expect(page.locator("tbody").first()).toContainText(owner.email); @@ -520,11 +586,12 @@ test.describe("@comprehensive", () => { await step("Confirm bulk delete selected users & verify removal")(async () => { await page.getByRole("button", { name: "Delete 2 users" }).click(); - await expect(page.getByRole("alertdialog", { name: "Delete users" })).toBeVisible(); - await page.getByRole("button", { name: "Delete" }).click(); + const deleteDialog = page.getByRole("alertdialog", { name: "Delete users" }); + await expect(deleteDialog).toBeVisible(); + await deleteDialog.getByRole("button", { name: "Delete", exact: true }).click(); await expectToastMessage(context, "2 users deleted successfully"); - await expect(page.getByRole("alertdialog")).not.toBeVisible(); + await expect(deleteDialog).not.toBeVisible(); await expect(page.locator("tbody").first().first().locator("tr")).toHaveCount(1); // Only owner left await expect(page.getByText(user2.email)).not.toBeVisible(); await expect(page.getByText(user3.email)).not.toBeVisible(); diff --git a/application/shared-webapp/tests/e2e/utils/test-assertions.ts b/application/shared-webapp/tests/e2e/utils/test-assertions.ts index 55b0db437f..60d0673b41 100644 --- a/application/shared-webapp/tests/e2e/utils/test-assertions.ts +++ b/application/shared-webapp/tests/e2e/utils/test-assertions.ts @@ -216,17 +216,26 @@ export async function expectValidationError(context: TestContext, expectedMessag * @param expectedStatusCodes Array of expected HTTP status codes (e.g., [401, 403]) */ export async function expectNetworkErrors(context: TestContext, expectedStatusCodes: number[]): Promise { - const { monitoring } = context; + const { monitoring, page } = context; for (const statusCode of expectedStatusCodes) { const expectedNetworkError = `HTTP ${statusCode}`; - // Find matching network errors - const matchingErrors = monitoring.networkErrors.filter((error) => error.includes(expectedNetworkError)); + // Poll for the network error with a timeout (since response events may not be processed immediately) + const startTime = Date.now(); + const timeout = 3000; + let matchingErrors: string[] = []; + + while (matchingErrors.length === 0 && Date.now() - startTime < timeout) { + matchingErrors = monitoring.networkErrors.filter((error) => error.includes(expectedNetworkError)); + if (matchingErrors.length === 0) { + await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 10))); + } + } if (matchingErrors.length === 0) { throw new Error( - `Expected network error "${expectedNetworkError}" not found. Actual errors: [${monitoring.networkErrors.join(", ")}]` + `Expected network error "${expectedNetworkError}" not found within ${timeout}ms. Actual errors: [${monitoring.networkErrors.join(", ")}]` ); } diff --git a/application/shared-webapp/tests/e2e/utils/test-data.ts b/application/shared-webapp/tests/e2e/utils/test-data.ts index c0f063c170..7afb840f70 100644 --- a/application/shared-webapp/tests/e2e/utils/test-data.ts +++ b/application/shared-webapp/tests/e2e/utils/test-data.ts @@ -12,7 +12,7 @@ export function uniqueEmail(): string { // Compact timestamp (YY-MM-DDTHH-MM) const timestamp = new Date().toISOString().slice(2, 16).replace(/[-:T]/g, ""); - const username = faker.internet.userName().toLowerCase(); + const username = faker.internet.username().toLowerCase(); return `${username}@${timestamp}.local`; }