From f83a2c44421893dfcf4d1ecf2cf763122d3055a0 Mon Sep 17 00:00:00 2001 From: Laurent Nguyen Date: Fri, 23 Jan 2026 07:37:45 +0100 Subject: [PATCH 1/3] feat: redact sensitive information from BadRequest errors in telemetry logging (#2321) Co-authored-by: Laurent Nguyen --- src/Common/ErrorHandlingUtils.ts | 19 +- .../dataAccess/queryDocumentsPage.test.ts | 171 ++++++++++++++++++ src/Common/dataAccess/queryDocumentsPage.ts | 52 +++++- 3 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 src/Common/dataAccess/queryDocumentsPage.test.ts diff --git a/src/Common/ErrorHandlingUtils.ts b/src/Common/ErrorHandlingUtils.ts index bfa321fe9..ea25d8e34 100644 --- a/src/Common/ErrorHandlingUtils.ts +++ b/src/Common/ErrorHandlingUtils.ts @@ -7,16 +7,27 @@ import { HttpStatusCodes } from "./Constants"; import { logError } from "./Logger"; import { sendMessage } from "./MessageHandler"; -export const handleError = (error: string | ARMError | Error, area: string, consoleErrorPrefix?: string): void => { +export interface HandleErrorOptions { + /** Optional redacted error to use for telemetry logging instead of the original error */ + redactedError?: string | ARMError | Error; +} + +export const handleError = ( + error: string | ARMError | Error, + area: string, + consoleErrorPrefix?: string, + options?: HandleErrorOptions, +): void => { const errorMessage = getErrorMessage(error); const errorCode = error instanceof ARMError ? error.code : undefined; - // logs error to data explorer console + // logs error to data explorer console (always shows original, non-redacted message) const consoleErrorMessage = consoleErrorPrefix ? `${consoleErrorPrefix}:\n ${errorMessage}` : errorMessage; logConsoleError(consoleErrorMessage); - // logs error to both app insight and kusto - logError(errorMessage, area, errorCode); + // logs error to both app insight and kusto (use redacted message if provided) + const telemetryErrorMessage = options?.redactedError ? getErrorMessage(options.redactedError) : errorMessage; + logError(telemetryErrorMessage, area, errorCode); // checks for errors caused by firewall and sends them to portal to handle sendNotificationForError(errorMessage, errorCode); diff --git a/src/Common/dataAccess/queryDocumentsPage.test.ts b/src/Common/dataAccess/queryDocumentsPage.test.ts new file mode 100644 index 000000000..adf68bd02 --- /dev/null +++ b/src/Common/dataAccess/queryDocumentsPage.test.ts @@ -0,0 +1,171 @@ +import { redactSyntaxErrorMessage } from "./queryDocumentsPage"; + +/* Typical error to redact looks like this (the message property contains a JSON string with nested structure): +{ + "message": "{\"code\":\"BadRequest\",\"message\":\"{\\\"errors\\\":[{\\\"severity\\\":\\\"Error\\\",\\\"location\\\":{\\\"start\\\":0,\\\"end\\\":5},\\\"code\\\":\\\"SC1001\\\",\\\"message\\\":\\\"Syntax error, incorrect syntax near 'Crazy'.\\\"}]}\\r\\nActivityId: d5424e10-51bd-46f7-9aec-7b40bed36f17, Windows/10.0.20348 cosmos-netstandard-sdk/3.18.0\"}" +} +*/ + +// Helper to create the nested error structure that matches what the SDK returns +const createNestedError = ( + errors: Array<{ severity?: string; location?: { start: number; end: number }; code: string; message: string }>, + activityId: string = "test-activity-id", +): { message: string } => { + const innerErrorsJson = JSON.stringify({ errors }); + const innerMessage = `${innerErrorsJson}\r\n${activityId}`; + const outerJson = JSON.stringify({ code: "BadRequest", message: innerMessage }); + return { message: outerJson }; +}; + +// Helper to parse the redacted result +const parseRedactedResult = (result: { message: string }) => { + const outerParsed = JSON.parse(result.message); + const [innerErrorsJson, activityIdPart] = outerParsed.message.split("\r\n"); + const innerErrors = JSON.parse(innerErrorsJson); + return { outerParsed, innerErrors, activityIdPart }; +}; + +describe("redactSyntaxErrorMessage", () => { + it("should redact SC1001 error message", () => { + const error = createNestedError( + [ + { + severity: "Error", + location: { start: 0, end: 5 }, + code: "SC1001", + message: "Syntax error, incorrect syntax near 'Crazy'.", + }, + ], + "ActivityId: d5424e10-51bd-46f7-9aec-7b40bed36f17", + ); + + const result = redactSyntaxErrorMessage(error) as { message: string }; + const { outerParsed, innerErrors, activityIdPart } = parseRedactedResult(result); + + expect(outerParsed.code).toBe("BadRequest"); + expect(innerErrors.errors[0].message).toBe("__REDACTED__"); + expect(activityIdPart).toContain("ActivityId: d5424e10-51bd-46f7-9aec-7b40bed36f17"); + }); + + it("should redact SC2001 error message", () => { + const error = createNestedError( + [ + { + severity: "Error", + location: { start: 0, end: 10 }, + code: "SC2001", + message: "Some sensitive syntax error message.", + }, + ], + "ActivityId: abc123", + ); + + const result = redactSyntaxErrorMessage(error) as { message: string }; + const { outerParsed, innerErrors, activityIdPart } = parseRedactedResult(result); + + expect(outerParsed.code).toBe("BadRequest"); + expect(innerErrors.errors[0].message).toBe("__REDACTED__"); + expect(activityIdPart).toContain("ActivityId: abc123"); + }); + + it("should redact multiple errors with SC1001 and SC2001 codes", () => { + const error = createNestedError( + [ + { severity: "Error", code: "SC1001", message: "First error" }, + { severity: "Error", code: "SC2001", message: "Second error" }, + ], + "ActivityId: xyz", + ); + + const result = redactSyntaxErrorMessage(error) as { message: string }; + const { innerErrors } = parseRedactedResult(result); + + expect(innerErrors.errors[0].message).toBe("__REDACTED__"); + expect(innerErrors.errors[1].message).toBe("__REDACTED__"); + }); + + it("should not redact errors with other codes", () => { + const error = createNestedError( + [{ severity: "Error", code: "SC9999", message: "This should not be redacted." }], + "ActivityId: test123", + ); + + const result = redactSyntaxErrorMessage(error); + + expect(result).toBe(error); // Should return original error unchanged + }); + + it("should not modify non-BadRequest errors", () => { + const innerMessage = JSON.stringify({ errors: [{ code: "SC1001", message: "Should not be redacted" }] }); + const error = { + message: JSON.stringify({ code: "NotFound", message: innerMessage }), + }; + + const result = redactSyntaxErrorMessage(error); + + expect(result).toBe(error); + }); + + it("should handle errors without message property", () => { + const error = { code: "BadRequest" }; + + const result = redactSyntaxErrorMessage(error); + + expect(result).toBe(error); + }); + + it("should handle non-object errors", () => { + const stringError = "Simple string error"; + const nullError: null = null; + const undefinedError: undefined = undefined; + + expect(redactSyntaxErrorMessage(stringError)).toBe(stringError); + expect(redactSyntaxErrorMessage(nullError)).toBe(nullError); + expect(redactSyntaxErrorMessage(undefinedError)).toBe(undefinedError); + }); + + it("should handle malformed JSON in message", () => { + const error = { + message: "not valid json", + }; + + const result = redactSyntaxErrorMessage(error); + + expect(result).toBe(error); + }); + + it("should handle message without ActivityId suffix", () => { + const innerErrorsJson = JSON.stringify({ + errors: [{ severity: "Error", code: "SC1001", message: "Syntax error near something." }], + }); + const error = { + message: JSON.stringify({ code: "BadRequest", message: innerErrorsJson + "\r\n" }), + }; + + const result = redactSyntaxErrorMessage(error) as { message: string }; + const { innerErrors } = parseRedactedResult(result); + + expect(innerErrors.errors[0].message).toBe("__REDACTED__"); + }); + + it("should preserve other error properties", () => { + const baseError = createNestedError([{ code: "SC1001", message: "Error" }], "ActivityId: test"); + const error = { + ...baseError, + statusCode: 400, + additionalInfo: "extra data", + }; + + const result = redactSyntaxErrorMessage(error) as { + message: string; + statusCode: number; + additionalInfo: string; + }; + + expect(result.statusCode).toBe(400); + expect(result.additionalInfo).toBe("extra data"); + + const { innerErrors } = parseRedactedResult(result); + expect(innerErrors.errors[0].message).toBe("__REDACTED__"); + }); +}); diff --git a/src/Common/dataAccess/queryDocumentsPage.ts b/src/Common/dataAccess/queryDocumentsPage.ts index 556ed290c..b5ec9c684 100644 --- a/src/Common/dataAccess/queryDocumentsPage.ts +++ b/src/Common/dataAccess/queryDocumentsPage.ts @@ -4,6 +4,51 @@ import { getEntityName } from "../DocumentUtility"; import { handleError } from "../ErrorHandlingUtils"; import { MinimalQueryIterator, nextPage } from "../IteratorUtilities"; +// Redact sensitive information from BadRequest errors with specific codes +export const redactSyntaxErrorMessage = (error: unknown): unknown => { + const codesToRedact = ["SC1001", "SC2001"]; + + try { + // Handle error objects with a message property + if (error && typeof error === "object" && "message" in error) { + const errorObj = error as { code?: string; message?: string }; + if (typeof errorObj.message === "string") { + // Parse the inner JSON from the message + const innerJson = JSON.parse(errorObj.message); + if (innerJson.code === "BadRequest" && typeof innerJson.message === "string") { + const [innerErrorsJson, activityIdPart] = innerJson.message.split("\r\n"); + const innerErrorsObj = JSON.parse(innerErrorsJson); + if (Array.isArray(innerErrorsObj.errors)) { + let modified = false; + innerErrorsObj.errors = innerErrorsObj.errors.map((err: { code?: string; message?: string }) => { + if (err.code && codesToRedact.includes(err.code)) { + modified = true; + return { ...err, message: "__REDACTED__" }; + } + return err; + }); + + if (modified) { + // Reconstruct the message with the redacted content + const redactedMessage = JSON.stringify(innerErrorsObj) + `\r\n${activityIdPart}`; + const redactedError = { + ...error, + message: JSON.stringify({ ...innerJson, message: redactedMessage }), + body: undefined as unknown, // Clear body to avoid sensitive data + }; + return redactedError; + } + } + } + } + } + } catch { + // If parsing fails, return the original error + } + + return error; +}; + export const queryDocumentsPage = async ( resourceName: string, documentsIterator: MinimalQueryIterator, @@ -18,7 +63,12 @@ export const queryDocumentsPage = async ( logConsoleInfo(`Successfully fetched ${itemCount} ${entityName} for container ${resourceName}`); return result; } catch (error) { - handleError(error, "QueryDocumentsPage", `Failed to query ${entityName} for container ${resourceName}`); + // Redact sensitive information for telemetry while showing original in console + const redactedError = redactSyntaxErrorMessage(error); + + handleError(error, "QueryDocumentsPage", `Failed to query ${entityName} for container ${resourceName}`, { + redactedError: redactedError as Error, + }); throw error; } finally { clearMessage(); From 703218debf6d032156edb512305f44f0bccb9894 Mon Sep 17 00:00:00 2001 From: Laurent Nguyen Date: Fri, 23 Jan 2026 07:38:04 +0100 Subject: [PATCH 2/3] Fix error message in bulk delete to reflect total document count (#2331) * Fix error message in bulk delete to reflect total document count * Fix format --- src/Common/dataAccess/deleteDocument.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Common/dataAccess/deleteDocument.ts b/src/Common/dataAccess/deleteDocument.ts index 9d4b8a396..b8a4a0430 100644 --- a/src/Common/dataAccess/deleteDocument.ts +++ b/src/Common/dataAccess/deleteDocument.ts @@ -44,7 +44,8 @@ export const deleteDocuments = async ( documentIds: DocumentId[], abortSignal: AbortSignal, ): Promise => { - const clearMessage = logConsoleProgress(`Deleting ${documentIds.length} ${getEntityName(true)}`); + const totalCount = documentIds.length; + const clearMessage = logConsoleProgress(`Deleting ${totalCount} ${getEntityName(true)}`); try { const v2Container = await client().database(collection.databaseId).container(collection.id()); @@ -83,11 +84,7 @@ export const deleteDocuments = async ( const flatAllResult = Array.prototype.concat.apply([], allResult); return flatAllResult; } catch (error) { - handleError( - error, - "DeleteDocuments", - `Error while deleting ${documentIds.length} ${getEntityName(documentIds.length > 1)}`, - ); + handleError(error, "DeleteDocuments", `Error while deleting ${totalCount} ${getEntityName(totalCount > 1)}`); throw error; } finally { clearMessage(); From 05407b3e0fd7e7c7a0ee0c6491cab577f836a9f5 Mon Sep 17 00:00:00 2001 From: BChoudhury-ms Date: Fri, 23 Jan 2026 20:58:46 +0530 Subject: [PATCH 3/3] 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 }); }); });