diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx index c3b723265..05e7adced 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,166 @@ 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 filter Dropdown with data-test attribute", () => { + render(); + + const filterDropdown = document.querySelector('[data-test="CopyJobsList/FilterColumnDropdown"]'); + expect(filterDropdown).toBeInTheDocument(); + }); + + it("renders filter Dropdown with Name column selected by default", () => { + render(); + + const dropdownInput = screen.getByRole("combobox"); + expect(dropdownInput).toHaveTextContent("Job name"); + }); + }); + + describe("Filtering", () => { + it("filters jobs by Name column when text is entered", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Filter by value..."); + 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("Filter by value..."); + 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("Filter by value..."); + 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 column when selected", async () => { + render(); + + // Open dropdown and select Status + const dropdown = screen.getByRole("combobox"); + fireEvent.click(dropdown); + + const statusOption = await screen.findByRole("option", { name: "Status" }); + fireEvent.click(statusOption); + + const filterInput = screen.getByPlaceholderText("Filter by value..."); + 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("shows no results when filter matches no jobs", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Filter by value..."); + 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("Filter by value..."); + 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("Filter by value..."); + 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("Filter by value..."); + 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 +510,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 +519,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 +608,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("Filter by value..."); + 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..66be04726 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx @@ -4,20 +4,24 @@ import { ConstrainMode, DetailsListLayoutMode, DetailsRow, + Dropdown, IColumn, IDetailsRowProps, + IDropdownOption, ScrollablePane, ScrollbarVisibility, ShimmeredDetailsList, 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"; import { openCopyJobDetailsPanel } from "../../Actions/CopyJobActions"; +import ContainerCopyMessages from "../../ContainerCopyMessages"; import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes"; import { getColumns } from "./CopyJobColumns"; @@ -30,9 +34,22 @@ interface CopyJobsListProps { const styles = { container: { height: "100%" } as React.CSSProperties, stackItem: { position: "relative", marginBottom: "20px" } as React.CSSProperties, + filterContainer: { + display: "flex", + flexDirection: "row" as const, + gap: "16px", + margin: "15px 5px", + }, + textFieldContainer: { width: "80%" }, + dropdownContainer: { width: "20%" }, }; -const PAGE_SIZE = 10; +const PAGE_SIZE = 15; +const columnsAddedToFilter = [ + ContainerCopyMessages.MonitorJobs.Columns.name, + ContainerCopyMessages.MonitorJobs.Columns.status, + ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime, +]; const CopyJobsList: React.FC = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => { const isDarkMode = useThemeStore((state) => state.isDarkMode); @@ -41,6 +58,36 @@ 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 [selectedFilterColumn, setSelectedFilterColumn] = React.useState("Name"); + + // Get columns to derive dropdown options + const columns: IColumn[] = getColumns(() => {}, handleActionClick, sortedColumnKey, isSortedDescending); + + // Generate dropdown options from column headers + const filterDropdownOptions: IDropdownOption[] = useMemo(() => { + return columns + .filter((col) => col.fieldName && columnsAddedToFilter.includes(col.name)) + .map((col) => ({ + key: col.fieldName as string, + text: col.name, + })); + }, [columns]); + + // Filter jobs based on filterText and selectedFilterColumn + const filteredJobs = useMemo(() => { + if (!filterText || !selectedFilterColumn) { + return sortedJobs; + } + const lowerFilterText = filterText.toLowerCase(); + return sortedJobs.filter((job: any) => { + const value = job[selectedFilterColumn]; + if (value === undefined || value === null) { + return false; + } + return String(value).toLowerCase().includes(lowerFilterText); + }); + }, [sortedJobs, filterText, selectedFilterColumn]); useEffect(() => { setSortedJobs(jobs); @@ -64,7 +111,23 @@ const CopyJobsList: React.FC = ({ jobs, handleActionClick, pa setStartIndex(0); }; - const columns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending); + // Reset start index when filter changes + useEffect(() => { + setStartIndex(0); + }, [filterText, selectedFilterColumn]); + + const sortableColumns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending); + + const handleFilterTextChange = ( + _event: React.FormEvent, + newValue?: string, + ) => { + setFilterText(newValue || ""); + }; + + const handleFilterColumnChange = (_event: React.FormEvent, option?: IDropdownOption) => { + setSelectedFilterColumn(option?.key as string | undefined); + }; const _handleRowClick = (job: CopyJobType) => { openCopyJobDetailsPanel(job); @@ -81,14 +144,36 @@ const CopyJobsList: React.FC = ({ jobs, handleActionClick, pa return (
+ +
+
+ +
+
+ +
+
+
+ {/* add the code here */} = ({ jobs, handleActionClick, pa /> - {sortedJobs.length > pageSize && ( + {filteredJobs.length > pageSize && ( { setStartIndex(startIdx); diff --git a/test/sql/containercopy/offlineMigration.spec.ts b/test/sql/containercopy/offlineMigration.spec.ts index de9597d49..726557f4d 100644 --- a/test/sql/containercopy/offlineMigration.spec.ts +++ b/test/sql/containercopy/offlineMigration.spec.ts @@ -248,6 +248,12 @@ test.describe("Container Copy - Offline Migration", () => { // Verify panel closes and job appears in the list await expect(panel).not.toBeVisible({ timeout: 5000 }); + // Use the filter functionality to search for the created job + const filterTextField = wrapper.getByTestId("CopyJobsList/FilterTextField"); + await filterTextField.waitFor({ state: "visible", timeout: 5000 }); + await filterTextField.fill(validJobName); + + // Wait for the job to be visible in the filtered list const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page"); await jobsListContainer.waitFor({ state: "visible", timeout: 5000 }); diff --git a/test/sql/containercopy/onlineMigration.spec.ts b/test/sql/containercopy/onlineMigration.spec.ts index 3914f6002..290171814 100644 --- a/test/sql/containercopy/onlineMigration.spec.ts +++ b/test/sql/containercopy/onlineMigration.spec.ts @@ -122,6 +122,10 @@ test.describe("Container Copy - Online Migration", () => { // Verify panel closes and job appears in the list await expect(panel).not.toBeVisible({ timeout: 5000 }); + const filterTextField = wrapper.getByTestId("CopyJobsList/FilterTextField"); + await filterTextField.waitFor({ state: "visible", timeout: 5000 }); + await filterTextField.fill(onlineMigrationJobName); + const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page"); await jobsListContainer.waitFor({ state: "visible", timeout: 5000 });