diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index a3e1ac7cf..d0d3837c2 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -293,6 +293,7 @@ export class HttpStatusCodes { public static readonly Accepted: number = 202; public static readonly NoContent: number = 204; public static readonly NotModified: number = 304; + public static readonly BadRequest: number = 400; public static readonly Unauthorized: number = 401; public static readonly Forbidden: number = 403; public static readonly NotFound: number = 404; diff --git a/src/Common/MongoProxyClient.ts b/src/Common/MongoProxyClient.ts index d5020745c..d3b24928a 100644 --- a/src/Common/MongoProxyClient.ts +++ b/src/Common/MongoProxyClient.ts @@ -738,6 +738,12 @@ export function useMongoProxyEndpoint(api: string): boolean { ); } +export class ThrottlingError extends Error { + constructor(message: string) { + super(message); + } +} + // TODO: This function throws most of the time except on Forbidden which is a bit strange // It causes problems for TypeScript understanding the types async function errorHandling(response: Response, action: string, params: unknown): Promise { @@ -747,6 +753,14 @@ async function errorHandling(response: Response, action: string, params: unknown if (response.status === HttpStatusCodes.Forbidden) { sendMessage({ type: MessageTypes.ForbiddenError, reason: errorMessage }); return; + } else if ( + response.status === HttpStatusCodes.BadRequest && + errorMessage.includes("Error=16500") && + errorMessage.includes("RetryAfterMs=") + ) { + // If throttling is happening, Cosmos DB will return a 400 with a body of: + // A write operation resulted in an error. Error=16500, RetryAfterMs=4, Details='Batch write error. + throw new ThrottlingError(errorMessage); } throw new Error(errorMessage); } diff --git a/src/Common/dataAccess/deleteDocument.ts b/src/Common/dataAccess/deleteDocument.ts index f20dc9cc8..1f551ee0e 100644 --- a/src/Common/dataAccess/deleteDocument.ts +++ b/src/Common/dataAccess/deleteDocument.ts @@ -26,14 +26,23 @@ export const deleteDocument = async (collection: CollectionBase, documentId: Doc } }; +export interface IBulkDeleteResult { + documentId: DocumentId; + requestCharge: number; + statusCode: number; + retryAfterMilliseconds?: number; +} + /** * Bulk delete documents * @param collection * @param documentId - * @returns array of ids that were successfully deleted + * @returns array of results and status codes */ -export const deleteDocuments = async (collection: CollectionBase, documentIds: DocumentId[]): Promise => { - const nbDocuments = documentIds.length; +export const deleteDocuments = async ( + collection: CollectionBase, + documentIds: DocumentId[], +): Promise => { const clearMessage = logConsoleProgress(`Deleting ${documentIds.length} ${getEntityName(true)}`); try { const v2Container = await client().database(collection.databaseId).container(collection.id()); @@ -56,18 +65,17 @@ export const deleteDocuments = async (collection: CollectionBase, documentIds: D operationType: BulkOperationType.Delete, })); - const promise = v2Container.items.bulk(operations).then((bulkResult) => { - return documentIdsChunk.filter((_, index) => bulkResult[index].statusCode === 204); + const promise = v2Container.items.bulk(operations).then((bulkResults) => { + return bulkResults.map((bulkResult, index) => { + const documentId = documentIdsChunk[index]; + return { ...bulkResult, documentId }; + }); }); promiseArray.push(promise); } const allResult = await Promise.all(promiseArray); const flatAllResult = Array.prototype.concat.apply([], allResult); - logConsoleInfo( - `Successfully deleted ${getEntityName(flatAllResult.length > 1)}: ${flatAllResult.length} out of ${nbDocuments}`, - ); - // TODO: handle case result.length != nbDocuments return flatAllResult; } catch (error) { handleError( diff --git a/src/Explorer/Controls/Dialog.tsx b/src/Explorer/Controls/Dialog.tsx index 87f4edb91..9f69d4761 100644 --- a/src/Explorer/Controls/Dialog.tsx +++ b/src/Explorer/Controls/Dialog.tsx @@ -35,7 +35,7 @@ export interface DialogState { textFieldProps?: TextFieldProps, primaryButtonDisabled?: boolean, ) => void; - showOkModalDialog: (title: string, subText: string) => void; + showOkModalDialog: (title: string, subText: string, linkProps?: LinkProps) => void; } export const useDialog: UseStore = create((set, get) => ({ @@ -83,7 +83,7 @@ export const useDialog: UseStore = create((set, get) => ({ textFieldProps, primaryButtonDisabled, }), - showOkModalDialog: (title: string, subText: string): void => + showOkModalDialog: (title: string, subText: string, linkProps?: LinkProps): void => get().openDialog({ isModal: true, title, @@ -94,6 +94,7 @@ export const useDialog: UseStore = create((set, get) => ({ get().closeDialog(); }, onSecondaryButtonClick: undefined, + linkProps, }), })); diff --git a/src/Explorer/Controls/ProgressModalDialog.tsx b/src/Explorer/Controls/ProgressModalDialog.tsx new file mode 100644 index 000000000..1d94d66ef --- /dev/null +++ b/src/Explorer/Controls/ProgressModalDialog.tsx @@ -0,0 +1,79 @@ +import { + Button, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + DialogTrigger, + Field, + ProgressBar, +} from "@fluentui/react-components"; +import * as React from "react"; + +interface ProgressModalDialogProps { + isOpen: boolean; + title: string; + message: string; + maxValue: number; + value: number; + dismissText: string; + onDismiss: () => void; + onCancel?: () => void; + /* mode drives the state of the action buttons + * inProgress: Show cancel button + * completed: Show close button + * aborting: Show cancel button, but disabled + * aborted: Show close button + */ + mode?: "inProgress" | "completed" | "aborting" | "aborted"; +} + +/** + * React component that renders a modal dialog with a progress bar. + */ +export const ProgressModalDialog: React.FC = ({ + isOpen, + title, + message, + maxValue, + value, + dismissText, + onCancel, + onDismiss, + children, + mode = "completed", +}) => ( + { + if (!data.open) { + onDismiss(); + } + }} + > + + + {title} + + + + + {children} + + + {mode === "inProgress" || mode === "aborting" ? ( + + ) : ( + + + + )} + + + + +); diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx index ab6c8ee70..14c1f16a8 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx @@ -1,7 +1,10 @@ import { FeedResponse, ItemDefinition, Resource } from "@azure/cosmos"; +import { waitFor } from "@testing-library/react"; import { deleteDocuments } from "Common/dataAccess/deleteDocument"; import { Platform, updateConfigContext } from "ConfigContext"; +import { useDialog } from "Explorer/Controls/Dialog"; import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact"; +import { ProgressModalDialog } from "Explorer/Controls/ProgressModalDialog"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter"; import { ButtonsDependencies, @@ -65,12 +68,14 @@ jest.mock("Explorer/Controls/Editor/EditorReact", () => ({ EditorReact: (props: EditorReactProps) => <>{props.content}, })); +const mockDialogState = { + showOkCancelModalDialog: jest.fn((title: string, subText: string, okLabel: string, onOk: () => void) => onOk()), + showOkModalDialog: () => {}, +}; + jest.mock("Explorer/Controls/Dialog", () => ({ useDialog: { - getState: jest.fn(() => ({ - showOkCancelModalDialog: (title: string, subText: string, okLabel: string, onOk: () => void) => onOk(), - showOkModalDialog: () => {}, - })), + getState: jest.fn(() => mockDialogState), }, })); @@ -80,6 +85,10 @@ jest.mock("Common/dataAccess/deleteDocument", () => ({ ), })); +jest.mock("Explorer/Controls/ProgressModalDialog", () => ({ + ProgressModalDialog: jest.fn(() => <>), +})); + async function waitForComponentToPaint

(wrapper: ReactWrapper

| ShallowWrapper

, amount = 0) { let newWrapper; await act(async () => { @@ -469,7 +478,29 @@ describe("Documents tab (noSql API)", () => { expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_BUTTON_ID)).toBeDefined(); }); - it("clicking Delete Document asks for confirmation", () => { + it("clicking Delete Document asks for confirmation", async () => { + act(async () => { + await useCommandBar + .getState() + .contextButtons.find((button) => button.id === DELETE_BUTTON_ID) + .onCommandClick(undefined); + }); + + expect(useDialog.getState().showOkCancelModalDialog).toHaveBeenCalled(); + }); + + it("clicking Delete Document for NoSql shows progress dialog", () => { + act(() => { + useCommandBar + .getState() + .contextButtons.find((button) => button.id === DELETE_BUTTON_ID) + .onCommandClick(undefined); + }); + + expect(ProgressModalDialog).toHaveBeenCalled(); + }); + + it("clicking Delete Document eventually calls delete client api", () => { const mockDeleteDocuments = deleteDocuments as jest.Mock; mockDeleteDocuments.mockClear(); @@ -480,7 +511,8 @@ describe("Documents tab (noSql API)", () => { .onCommandClick(undefined); }); - expect(mockDeleteDocuments).toHaveBeenCalled(); + // The implementation uses setTimeout, so wait for it to finish + waitFor(() => expect(mockDeleteDocuments).toHaveBeenCalled()); }); }); }); diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx index 67358b739..19314f005 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx @@ -1,5 +1,15 @@ import { Item, ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos"; -import { Button, Input, TableRowId, makeStyles, shorthands } from "@fluentui/react-components"; +import { + Button, + Input, + Link, + MessageBar, + MessageBarBody, + MessageBarTitle, + TableRowId, + makeStyles, + shorthands, +} from "@fluentui/react-components"; import { Dismiss16Filled } from "@fluentui/react-icons"; import { KeyCodes, QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants"; import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils"; @@ -16,6 +26,7 @@ import { Platform, configContext } from "ConfigContext"; import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent"; import { useDialog } from "Explorer/Controls/Dialog"; import { EditorReact } from "Explorer/Controls/Editor/EditorReact"; +import { ProgressModalDialog } from "Explorer/Controls/ProgressModalDialog"; import Explorer from "Explorer/Explorer"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter"; import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities"; @@ -35,7 +46,7 @@ import { QueryConstants } from "Shared/Constants"; import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; import { Action } from "Shared/Telemetry/TelemetryConstants"; import { userContext } from "UserContext"; -import { logConsoleError } from "Utils/NotificationConsoleUtils"; +import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils"; import { Allotment } from "allotment"; import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { format } from "react-string-format"; @@ -60,6 +71,9 @@ import TabsBase from "../TabsBase"; import { ColumnDefinition, DocumentsTableComponent, DocumentsTableComponentItem } from "./DocumentsTableComponent"; const MAX_FILTER_HISTORY_COUNT = 100; // Datalist will become scrollable, so we can afford to keep more items than fit on the screen +const NO_SQL_THROTTLING_DOC_URL = + "https://learn.microsoft.com/azure/cosmos-db/nosql/troubleshoot-request-rate-too-large"; +const MONGO_THROTTLING_DOC_URL = "https://learn.microsoft.com/azure/cosmos-db/mongodb/prevent-rate-limiting-errors"; const loadMoreHeight = LayoutConstants.rowHeight; export const useDocumentsTabStyles = makeStyles({ @@ -110,6 +124,20 @@ export const useDocumentsTabStyles = makeStyles({ ...shorthands.outline("1px", "dotted"), }, }, + floatingControlsContainer: { + position: "relative", + }, + floatingControls: { + position: "absolute", + top: "6px", + right: 0, + float: "right", + backgroundColor: "white", + zIndex: 1, + }, + deleteProgressContent: { + paddingTop: tokens.spacingVerticalL, + }, }); export class DocumentsTabV2 extends TabsBase { @@ -609,6 +637,23 @@ export const DocumentsTabComponent: React.FunctionComponent(SubComponentName.FilterHistory, _collection, [] as FilterHistory), ); + // For progress bar for bulk delete (noSql) + const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = React.useState(false); + const [bulkDeleteProcess, setBulkDeleteProcess] = useState<{ + pendingIds: DocumentId[]; + successfulIds: DocumentId[]; + throttledIds: DocumentId[]; + failedIds: DocumentId[]; + beforeExecuteMs: number; // Delay before executing delete. Used for retrying throttling after a specified delay + }>(undefined); + const [bulkDeleteOperation, setBulkDeleteOperation] = useState<{ + onCompleted: (documentIds: DocumentId[]) => void; + onFailed: (reason?: unknown) => void; + count: number; + collection: CollectionBase; + }>(undefined); + const [bulkDeleteMode, setBulkDeleteMode] = useState<"inProgress" | "completed" | "aborting" | "aborted">(undefined); + const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB); useEffect(() => { @@ -634,6 +679,97 @@ export const DocumentsTabComponent: React.FunctionComponent or check if the user is aborting the operation via state React + * variables. + * + * Inputs are the bulkDeleteOperation, bulkDeleteProcess and bulkDeleteMode state variables. + * When the bulkDeleteProcess changes, the function in the useEffect is triggered and checks if the process + * was aborted or completed, which will resolve the promise. + * Otherwise, it will attempt to delete documents of the pending and throttled ids arrays. + * Once deletion is completed, the function updates bulkDeleteProcess with the results, which will trigger + * the function to be called again. + */ + useEffect(() => { + if (!bulkDeleteOperation || !bulkDeleteProcess || !bulkDeleteMode) { + return; + } + + if (bulkDeleteMode === "completed" || bulkDeleteMode === "aborted") { + // no op in the case function is called again + return; + } + + if ( + (bulkDeleteProcess.pendingIds.length === 0 && bulkDeleteProcess.throttledIds.length === 0) || + bulkDeleteMode === "aborting" + ) { + // Successfully deleted all documents or operation was aborted + bulkDeleteOperation.onCompleted(bulkDeleteProcess.successfulIds); + setBulkDeleteMode(bulkDeleteMode === "aborting" ? "aborted" : "completed"); + return; + } + + // Start deleting documents or retry throttled requests + const newPendingIds = bulkDeleteProcess.pendingIds.concat(bulkDeleteProcess.throttledIds); + const timeout = bulkDeleteProcess.beforeExecuteMs || 0; + + setTimeout(() => { + deleteNoSqlDocuments(bulkDeleteOperation.collection, [...newPendingIds]) + .then((deleteResult) => { + let retryAfterMilliseconds = 0; + const newSuccessful: DocumentId[] = []; + const newThrottled: DocumentId[] = []; + const newFailed: DocumentId[] = []; + deleteResult.forEach((result) => { + if (result.statusCode === Constants.HttpStatusCodes.NoContent) { + newSuccessful.push(result.documentId); + } else if (result.statusCode === Constants.HttpStatusCodes.TooManyRequests) { + newThrottled.push(result.documentId); + retryAfterMilliseconds = Math.max(result.retryAfterMilliseconds, retryAfterMilliseconds); + } else if (result.statusCode >= 400) { + newFailed.push(result.documentId); + logConsoleError( + `Failed to delete document ${result.documentId.id} with status code ${result.statusCode}`, + ); + } + }); + + logConsoleInfo(`Successfully deleted ${newSuccessful.length} document(s)`); + + if (newThrottled.length > 0) { + logConsoleError( + `Failed to delete ${newThrottled.length} document(s) due to "Request too large" (429) error. Retrying...`, + ); + } + + // Update result of the bulk delete: method is called again, because the state variables changed + // it will decide at the next call what to do + setBulkDeleteProcess((prev) => ({ + pendingIds: [], + successfulIds: prev.successfulIds.concat(newSuccessful), + throttledIds: newThrottled, + failedIds: prev.failedIds.concat(newFailed), + beforeExecuteMs: retryAfterMilliseconds, + })); + }) + .catch((error) => { + console.error("Error deleting documents", error); + setBulkDeleteProcess((prev) => ({ + pendingIds: [], + throttledIds: [], + successfulIds: prev.successfulIds, + failedIds: prev.failedIds.concat(prev.pendingIds), + beforeExecuteMs: undefined, + })); + bulkDeleteOperation.onFailed(error); + }); + }, timeout); + }, [bulkDeleteOperation, bulkDeleteProcess, bulkDeleteMode]); + const applyFilterButton = { enabled: true, visible: true, @@ -983,8 +1119,35 @@ export const DocumentsTabComponent: React.FunctionComponent => + new Promise((resolve, reject) => { + setBulkDeleteOperation({ + onCompleted: resolve, + onFailed: reject, + count: documentIds.length, + collection, + }); + setBulkDeleteProcess({ + pendingIds: [...documentIds], + throttledIds: [], + successfulIds: [], + failedIds: [], + beforeExecuteMs: 0, + }); + setIsBulkDeleteDialogOpen(true); + setBulkDeleteMode("inProgress"); + }); + /** * Implementation using bulk delete NoSQL API + * @param list of document ids to delete + * @returns Promise of list of deleted document ids */ const _deleteDocuments = useCallback( async (toDeleteDocumentIds: DocumentId[]): Promise => { @@ -995,39 +1158,44 @@ export const DocumentsTabComponent: React.FunctionComponent => - partitionKey.systemKey - ? deleteNoSqlDocument(collection, toDeleteDocumentIds[0]).then(() => [toDeleteDocumentIds[0]]) - : deleteNoSqlDocuments(collection, toDeleteDocumentIds); - - // TODO: Once new mongo proxy is available for all users, remove the call for MongoProxyClient.deleteDocument(). - // MongoProxyClient.deleteDocuments() should be called for all users. - const _deleteMongoDocuments = async ( - databaseId: string, - collection: ViewModels.Collection, - documentIds: DocumentId[], - ) => - isMongoBulkDeleteDisabled - ? MongoProxyClient.deleteDocument(databaseId, collection, documentIds[0]).then(() => [toDeleteDocumentIds[0]]) - : MongoProxyClient.deleteDocuments(databaseId, collection, documentIds).then( - ({ deletedCount, isAcknowledged }) => { - if (deletedCount === toDeleteDocumentIds.length && isAcknowledged) { - return toDeleteDocumentIds; - } - throw new Error( - `Delete failed with deletedCount: ${deletedCount} and isAcknowledged: ${isAcknowledged}`, - ); - }, - ); - - const deletePromise = !isPreferredApiMongoDB - ? _deleteNoSqlDocuments(_collection, toDeleteDocumentIds) - : _deleteMongoDocuments(_collection.databaseId, _collection as ViewModels.Collection, toDeleteDocumentIds); + let deletePromise; + if (!isPreferredApiMongoDB) { + if (partitionKey.systemKey) { + // ---------------------------------------------------------------------------------------------------- + // TODO: Once JS SDK Bug fix for bulk deleting legacy containers (whose systemKey==1) is released: + // Remove the check for systemKey, remove call to deleteNoSqlDocument(). deleteNoSqlDocuments() should + // always be called for NoSQL. + deletePromise = deleteNoSqlDocument(_collection, toDeleteDocumentIds[0]).then(() => { + useDialog.getState().showOkModalDialog("Delete document", "Document successfully deleted."); + return [toDeleteDocumentIds[0]]; + }); + // ---------------------------------------------------------------------------------------------------- + } else { + deletePromise = _bulkDeleteNoSqlDocuments(_collection, toDeleteDocumentIds); + } + } else { + if (isMongoBulkDeleteDisabled) { + // TODO: Once new mongo proxy is available for all users, remove the call for MongoProxyClient.deleteDocument(). + // MongoProxyClient.deleteDocuments() should be called for all users. + deletePromise = MongoProxyClient.deleteDocument( + _collection.databaseId, + _collection as ViewModels.Collection, + toDeleteDocumentIds[0], + ).then(() => [toDeleteDocumentIds[0]]); + // ---------------------------------------------------------------------------------------------------- + } else { + deletePromise = MongoProxyClient.deleteDocuments( + _collection.databaseId, + _collection as ViewModels.Collection, + toDeleteDocumentIds, + ).then(({ deletedCount, isAcknowledged }) => { + if (deletedCount === toDeleteDocumentIds.length && isAcknowledged) { + return toDeleteDocumentIds; + } + throw new Error(`Delete failed with deletedCount: ${deletedCount} and isAcknowledged: ${isAcknowledged}`); + }); + } + } return deletePromise .then( @@ -1058,9 +1226,11 @@ export const DocumentsTabComponent: React.FunctionComponent setIsExecuting(false)); + .finally(() => { + setIsExecuting(false); + }); }, - [_collection, isPreferredApiMongoDB, onExecutionErrorChange, tabTitle], + [_collection, isPreferredApiMongoDB, onExecutionErrorChange, tabTitle, partitionKey.systemKey], ); const deleteDocuments = useCallback( @@ -1078,14 +1248,25 @@ export const DocumentsTabComponent: React.FunctionComponent - useDialog - .getState() - .showOkModalDialog("Delete documents", `Deleting document(s) failed (${error.message})`), + (error: Error) => { + if (error instanceof MongoProxyClient.ThrottlingError) { + useDialog + .getState() + .showOkModalDialog( + "Delete documents", + `Some documents failed to delete due to a rate limiting error. Please try again later. To prevent this in the future, consider increasing the throughput on your container or database.`, + { + linkText: "Learn More", + linkUrl: MONGO_THROTTLING_DOC_URL, + }, + ); + } else { + useDialog + .getState() + .showOkModalDialog("Delete documents", `Deleting document(s) failed (${error.message})`); + } + }, ) .finally(() => setIsExecuting(false)); }, @@ -1870,6 +2051,26 @@ export const DocumentsTabComponent: React.FunctionComponent { + let message = 'Some delete requests failed due to a "Request too large" exception (429)'; + + if (bulkDeleteOperation.count === bulkDeleteProcess.successfulIds.length) { + message += ", but were successfully retried."; + } else if (bulkDeleteMode === "inProgress" || bulkDeleteMode === "aborting") { + message += ". Retrying now."; + } else { + message += "."; + } + + return (message += + " To prevent this in the future, consider increasing the throughput on your container or database."); + }; + const onColumnSelectionChange = (newSelectedColumnIds: string[]): void => { // Do not allow to unselecting all columns if (newSelectedColumnIds.length === 0) { @@ -2076,6 +2277,50 @@ export const DocumentsTabComponent: React.FunctionComponent + {bulkDeleteOperation && ( + { + setIsBulkDeleteDialogOpen(false); + setBulkDeleteOperation(undefined); + }} + onCancel={() => setBulkDeleteMode("aborting")} + title={`Deleting ${bulkDeleteOperation.count} document(s)`} + message={`Successfully deleted ${bulkDeleteProcess.successfulIds.length} document(s).`} + maxValue={bulkDeleteOperation.count} + value={bulkDeleteProcess.successfulIds.length} + mode={bulkDeleteMode} + > +

+ {(bulkDeleteMode === "aborting" || bulkDeleteMode === "aborted") && ( +
Deleting document(s) was aborted.
+ )} + {(bulkDeleteProcess.failedIds.length > 0 || + (bulkDeleteProcess.throttledIds.length > 0 && bulkDeleteMode !== "inProgress")) && ( + + + Error + Failed to delete{" "} + {bulkDeleteMode === "inProgress" + ? bulkDeleteProcess.failedIds.length + : bulkDeleteProcess.failedIds.length + bulkDeleteProcess.throttledIds.length}{" "} + document(s). + + + )} + + + Warning + {get429WarningMessageNoSql()}{" "} + + Learn More + + + +
+ + )} ); }; diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2Mongo.test.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2Mongo.test.tsx index 5858999ec..6988f448d 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2Mongo.test.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2Mongo.test.tsx @@ -49,7 +49,8 @@ jest.mock("Common/MongoProxyClient", () => ({ id: "id1", }), ), - deleteDocuments: jest.fn(() => Promise.resolve()), + deleteDocuments: jest.fn(() => Promise.resolve({ deleteCount: 0, isAcknowledged: true })), + ThrottlingError: Error, useMongoProxyEndpoint: jest.fn(() => true), })); @@ -179,7 +180,7 @@ describe("Documents tab (Mongo API)", () => { expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_BUTTON_ID)).toBeDefined(); }); - it("clicking Delete Document asks for confirmation", () => { + it("clicking Delete Document eventually calls delete client api", () => { const mockDeleteDocuments = deleteDocuments as jest.Mock; mockDeleteDocuments.mockClear();