From 05407b3e0fd7e7c7a0ee0c6491cab577f836a9f5 Mon Sep 17 00:00:00 2001 From: BChoudhury-ms Date: Fri, 23 Jan 2026 20:58:46 +0530 Subject: [PATCH] Add search/filter support to Copy Jobs list with pagination updates (#2343) * search the copy job * remove timeout * Update src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix pagination race condition when filtering copy jobs (#2351) * Initial plan * Fix pagination race condition by resetting startIndex synchronously Co-authored-by: BChoudhury-ms <201893606+BChoudhury-ms@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: BChoudhury-ms <201893606+BChoudhury-ms@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: BChoudhury-ms <201893606+BChoudhury-ms@users.noreply.github.com> --- .../Components/CopyJobsList.test.tsx | 201 +++++++++++++++++- .../Components/CopyJobsList.tsx | 57 ++++- .../ContainerCopy/containerCopyStyles.less | 46 ++-- .../containercopy/offlineMigration.spec.ts | 10 +- .../sql/containercopy/onlineMigration.spec.ts | 12 +- .../containercopy/permissionsScreen.spec.ts | 8 +- 6 files changed, 294 insertions(+), 40 deletions(-) diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx index c3b723265..64778f2ff 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx @@ -11,9 +11,17 @@ jest.mock("../../Actions/CopyJobActions", () => ({ jest.mock("./CopyJobColumns", () => ({ getColumns: jest.fn(() => [ + { + key: "LastUpdatedTime", + name: "Date & time", + fieldName: "LastUpdatedTime", + minWidth: 140, + maxWidth: 300, + isResizable: true, + }, { key: "Name", - name: "Name", + name: "Job name", fieldName: "Name", minWidth: 140, maxWidth: 300, @@ -165,6 +173,165 @@ describe("CopyJobsList", () => { expect(screen.getByTestId("action-menu-job-2")).toBeInTheDocument(); expect(screen.getByTestId("action-menu-job-3")).toBeInTheDocument(); }); + + it("renders filter TextField with data-test attribute", () => { + render(); + + const filterTextField = document.querySelector('[data-test="CopyJobsList/FilterTextField"]'); + expect(filterTextField).toBeInTheDocument(); + }); + + it("renders search TextField with correct placeholder", () => { + render(); + + const searchInput = screen.getByPlaceholderText("Search jobs..."); + expect(searchInput).toBeInTheDocument(); + }); + }); + + describe("Filtering", () => { + it("filters jobs by Name when text is entered", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Job 1" } }); + + await waitFor(() => { + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument(); + expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument(); + }); + }); + + it("filters jobs case-insensitively", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "test job 1" } }); + + await waitFor(() => { + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument(); + }); + }); + + it("shows all jobs when filter text is empty", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Job 1" } }); + + await waitFor(() => { + expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument(); + }); + + fireEvent.change(filterInput, { target: { value: "" } }); + + await waitFor(() => { + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.getByText("Test Job 2")).toBeInTheDocument(); + expect(screen.getByText("Test Job 3")).toBeInTheDocument(); + }); + }); + + it("filters jobs by Status across all columns", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: CopyJobStatusType.Running } }); + + await waitFor(() => { + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument(); + expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument(); + }); + }); + + it("filters jobs by Mode across all columns", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Offline" } }); + + await waitFor(() => { + expect(screen.queryByText("Test Job 1")).not.toBeInTheDocument(); + expect(screen.getByText("Test Job 2")).toBeInTheDocument(); + expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument(); + }); + }); + + it("shows no results when filter matches no jobs", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "NonExistentJob" } }); + + await waitFor(() => { + expect(screen.queryByText("Test Job 1")).not.toBeInTheDocument(); + expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument(); + expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument(); + }); + }); + + it("filters by partial text match", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Test" } }); + + await waitFor(() => { + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.getByText("Test Job 2")).toBeInTheDocument(); + expect(screen.getByText("Test Job 3")).toBeInTheDocument(); + }); + }); + + it("resets pagination when filter changes", async () => { + const manyJobs: CopyJobType[] = Array.from({ length: 25 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Test Job ${i + 1}`, + })); + + render(); + + // Navigate to page 2 + fireEvent.click(screen.getByLabelText("Go to next page")); + + await waitFor(() => { + expect(screen.getByText("Showing 11 - 20 of 25 items")).toBeInTheDocument(); + }); + + // Apply filter - should reset to page 1 + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Job 1" } }); + + await waitFor(() => { + // Filtered results show from the beginning + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + }); + }); + + it("updates filtered count in pager", async () => { + const manyJobs: CopyJobType[] = Array.from({ length: 25 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: i < 5 ? `Alpha Job ${i + 1}` : `Beta Job ${i + 1}`, + })); + + render(); + + expect(screen.getByText("Showing 1 - 10 of 25 items")).toBeInTheDocument(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Alpha" } }); + + await waitFor(() => { + expect(screen.queryByText("Showing 1 - 10 of 25 items")).not.toBeInTheDocument(); + // Pager should not be visible since filtered results (5) are less than page size (10) + expect(screen.queryByLabelText("Go to next page")).not.toBeInTheDocument(); + }); + }); }); describe("Pagination", () => { @@ -342,7 +509,7 @@ describe("CopyJobsList", () => { describe("Component Props", () => { it("uses default page size when not provided", () => { - const manyJobs: CopyJobType[] = Array.from({ length: 12 }, (_, i) => ({ + const manyJobs: CopyJobType[] = Array.from({ length: 20 }, (_, i) => ({ ...mockJobs[0], ID: `job-${i + 1}`, Name: `Test Job ${i + 1}`, @@ -351,7 +518,7 @@ describe("CopyJobsList", () => { render(); expect(screen.getByLabelText("Go to next page")).toBeInTheDocument(); - expect(screen.getByText("Showing 1 - 10 of 12 items")).toBeInTheDocument(); + expect(screen.getByText("Showing 1 - 15 of 20 items")).toBeInTheDocument(); }); it("passes correct props to getColumns function", async () => { @@ -440,7 +607,33 @@ describe("CopyJobsList", () => { render(); }).not.toThrow(); - expect(screen.getByText("Showing 1 - 10 of 1000 items")).toBeInTheDocument(); + expect(screen.getByText("Showing 1 - 15 of 1000 items")).toBeInTheDocument(); + }); + + it("handles filtering with null or undefined values gracefully", async () => { + const jobsWithNullValues: CopyJobType[] = [ + { + ...mockJobs[0], + ID: "job-with-values", + Name: "Valid Job", + }, + { + ...mockJobs[1], + ID: "job-null-name", + Name: undefined as unknown as string, + }, + ]; + + expect(() => { + render(); + }).not.toThrow(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Valid" } }); + + await waitFor(() => { + expect(screen.getByText("Valid Job")).toBeInTheDocument(); + }); }); }); }); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx index a263ac137..dcdfd1033 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx @@ -12,8 +12,9 @@ import { Stack, Sticky, StickyPositionType, + TextField, } from "@fluentui/react"; -import React, { useEffect } from "react"; +import React, { useEffect, useMemo } from "react"; import Pager from "../../../../Common/Pager"; import { useThemeStore } from "../../../../hooks/useTheme"; import { getThemeTokens } from "../../../Theme/ThemeUtil"; @@ -30,9 +31,15 @@ interface CopyJobsListProps { const styles = { container: { height: "100%" } as React.CSSProperties, stackItem: { position: "relative", marginBottom: "20px" } as React.CSSProperties, + filterContainer: { + margin: "15px 5px", + }, }; -const PAGE_SIZE = 10; +const PAGE_SIZE = 15; + +// Columns to search across +const searchableFields = ["Name", "Status", "LastUpdatedTime", "Mode"]; const CopyJobsList: React.FC = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => { const isDarkMode = useThemeStore((state) => state.isDarkMode); @@ -41,6 +48,23 @@ const CopyJobsList: React.FC = ({ jobs, handleActionClick, pa const [sortedJobs, setSortedJobs] = React.useState(jobs); const [sortedColumnKey, setSortedColumnKey] = React.useState(undefined); const [isSortedDescending, setIsSortedDescending] = React.useState(false); + const [filterText, setFilterText] = React.useState(""); + + const filteredJobs = useMemo(() => { + if (!filterText) { + return sortedJobs; + } + const lowerFilterText = filterText.toLowerCase(); + return sortedJobs.filter((job: any) => { + return searchableFields.some((field) => { + const value = job[field]; + if (value === undefined || value === null) { + return false; + } + return String(value).toLowerCase().includes(lowerFilterText); + }); + }); + }, [sortedJobs, filterText]); useEffect(() => { setSortedJobs(jobs); @@ -64,7 +88,15 @@ const CopyJobsList: React.FC = ({ jobs, handleActionClick, pa setStartIndex(0); }; - const columns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending); + const sortableColumns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending); + + const handleFilterTextChange = ( + _event: React.FormEvent, + newValue?: string, + ) => { + setFilterText(newValue || ""); + setStartIndex(0); + }; const _handleRowClick = (job: CopyJobType) => { openCopyJobDetailsPanel(job); @@ -81,14 +113,25 @@ const CopyJobsList: React.FC = ({ jobs, handleActionClick, pa return (
+ +
+ +
+
= ({ jobs, handleActionClick, pa /> - {sortedJobs.length > pageSize && ( + {filteredJobs.length > pageSize && ( { setStartIndex(startIdx); diff --git a/src/Explorer/ContainerCopy/containerCopyStyles.less b/src/Explorer/ContainerCopy/containerCopyStyles.less index 6f99f4055..9cc625860 100644 --- a/src/Explorer/ContainerCopy/containerCopyStyles.less +++ b/src/Explorer/ContainerCopy/containerCopyStyles.less @@ -1,5 +1,27 @@ @import "../../../less/Common/Constants.less"; +.themedTextFieldStyles() { + .ms-TextField { + .ms-TextField-fieldGroup { + background-color: var(--colorNeutralBackground1); + border-color: var(--colorNeutralStroke1); + } + + .ms-TextField-field { + color: var(--colorNeutralForeground1); + background-color: var(--colorNeutralBackground1); + + &::placeholder { + color: var(--colorNeutralForeground4); + } + } + + .ms-Label { + color: var(--colorNeutralForeground1); + } + } +} + // Common theme-aware classes .themeText { color: var(--colorNeutralForeground1); @@ -119,25 +141,8 @@ filter: invert(1); } - .ms-TextField { - .ms-TextField-fieldGroup { - background-color: var(--colorNeutralBackground1); - border-color: var(--colorNeutralStroke1); - } + .themedTextFieldStyles(); - .ms-TextField-field { - color: var(--colorNeutralForeground1); - background-color: var(--colorNeutralBackground1); - - &::placeholder { - color: var(--colorNeutralForeground4); - } - } - - .ms-Label { - color: var(--colorNeutralForeground1); - } - } .migrationTypeDescription { p { color: var(--colorNeutralForeground1); @@ -173,6 +178,11 @@ width: 100%; max-width: 100%; margin: 0 auto; + + body.isDarkMode & { + .themedTextFieldStyles(); + } + .ms-DetailsList { width: 100%; diff --git a/test/sql/containercopy/offlineMigration.spec.ts b/test/sql/containercopy/offlineMigration.spec.ts index de9597d49..e99610cb4 100644 --- a/test/sql/containercopy/offlineMigration.spec.ts +++ b/test/sql/containercopy/offlineMigration.spec.ts @@ -246,13 +246,17 @@ test.describe("Container Copy - Offline Migration", () => { expect(response.ok()).toBe(true); // Verify panel closes and job appears in the list - await expect(panel).not.toBeVisible({ timeout: 5000 }); + await expect(panel).not.toBeVisible(); + + const filterTextField = wrapper.getByTestId("CopyJobsList/FilterTextField"); + await filterTextField.waitFor({ state: "visible" }); + await filterTextField.fill(validJobName); const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page"); - await jobsListContainer.waitFor({ state: "visible", timeout: 5000 }); + await jobsListContainer.waitFor({ state: "visible" }); const jobItem = jobsListContainer.getByText(validJobName); - await jobItem.waitFor({ state: "visible", timeout: 5000 }); + await jobItem.waitFor({ state: "visible" }); await expect(jobItem).toBeVisible(); }); }); diff --git a/test/sql/containercopy/onlineMigration.spec.ts b/test/sql/containercopy/onlineMigration.spec.ts index 3914f6002..e11b3decd 100644 --- a/test/sql/containercopy/onlineMigration.spec.ts +++ b/test/sql/containercopy/onlineMigration.spec.ts @@ -120,18 +120,22 @@ test.describe("Container Copy - Online Migration", () => { expect(response.ok()).toBe(true); // Verify panel closes and job appears in the list - await expect(panel).not.toBeVisible({ timeout: 5000 }); + await expect(panel).not.toBeVisible(); + + const filterTextField = wrapper.getByTestId("CopyJobsList/FilterTextField"); + await filterTextField.waitFor({ state: "visible" }); + await filterTextField.fill(onlineMigrationJobName); const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page"); - await jobsListContainer.waitFor({ state: "visible", timeout: 5000 }); + await jobsListContainer.waitFor({ state: "visible" }); let jobRow, statusCell, actionMenuButton; jobRow = jobsListContainer.locator(".ms-DetailsRow", { hasText: onlineMigrationJobName }); statusCell = jobRow.locator("[data-automationid='DetailsRowCell'][data-automation-key='CopyJobStatus']"); - await jobRow.waitFor({ state: "visible", timeout: 5000 }); + await jobRow.waitFor({ state: "visible" }); // Verify job status changes to queued state - await expect(statusCell).toContainText(/running|queued|pending/i, { timeout: 5000 }); + await expect(statusCell).toContainText(/running|queued|pending/i); // Test job lifecycle management through action menu actionMenuButton = wrapper.getByTestId(`CopyJobActionMenu/Button:${onlineMigrationJobName}`); diff --git a/test/sql/containercopy/permissionsScreen.spec.ts b/test/sql/containercopy/permissionsScreen.spec.ts index fa7d3e199..f592bf4c7 100644 --- a/test/sql/containercopy/permissionsScreen.spec.ts +++ b/test/sql/containercopy/permissionsScreen.spec.ts @@ -134,7 +134,7 @@ test.describe("Container Copy - Permission Screen Verification", () => { const pitrBtn = accordionPanel.getByTestId("pointInTimeRestore:PrimaryBtn"); await expect(pitrBtn).toBeVisible(); - await pitrBtn.click(); + await pitrBtn.click({ force: true }); // Verify new page opens with correct URL pattern page.context().on("page", async (newPage) => { @@ -246,7 +246,7 @@ test.describe("Container Copy - Permission Screen Verification", () => { const toggleButton = crossAccordionPanel.getByTestId("btn-toggle"); await expect(toggleButton).toBeVisible(); - await toggleButton.click(); + await toggleButton.click({ force: true }); // Verify popover functionality const popover = frame.locator("[data-test='popover-container']"); @@ -257,7 +257,7 @@ test.describe("Container Copy - Permission Screen Verification", () => { await expect(yesButton).toBeVisible(); await expect(noButton).toBeVisible(); - await yesButton.click(); + await yesButton.click({ force: true }); // Verify loading states await expect(loadingOverlay).toBeVisible(); @@ -265,6 +265,6 @@ test.describe("Container Copy - Permission Screen Verification", () => { await expect(popover).toBeHidden({ timeout: 10 * 1000 }); // Cancel the panel to clean up - await panel.getByRole("button", { name: "Cancel" }).click(); + await panel.getByRole("button", { name: "Cancel" }).click({ force: true }); }); });