diff --git a/package-lock.json b/package-lock.json index 905e8e205..e4273cd1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22134,6 +22134,12 @@ "resolved": "https://registry.npmjs.org/react-splitter-layout/-/react-splitter-layout-4.0.0.tgz", "integrity": "sha512-SLqOjBOxRuizWUa83w6q5/u9cDWa9/yj9Iko9V9JFN8x+cqIXiDlUFWSx+icz3IIgvsN/oRIw3za5/32RjIwrA==" }, + "react-string-format": { + "version": "1.0.1", + "resolved": "https://msazure.pkgs.visualstudio.com/_packaging/AzurePortal/npm/registry/react-string-format/-/react-string-format-1.0.1.tgz", + "integrity": "sha1-JyQaRZHqURInBBx64HC3FJBh3AA=", + "license": "MIT" + }, "react-syntax-highlighter": { "version": "12.2.1", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-12.2.1.tgz", diff --git a/package.json b/package.json index 10c8d544a..8fe8ad2ff 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "react-notification-system": "0.2.17", "react-redux": "7.1.3", "react-splitter-layout": "4.0.0", + "react-string-format": "1.0.1", "react-youtube": "9.0.1", "redux": "4.0.4", "reflect-metadata": "0.1.13", diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx index ca6411f71..a6ae0f2f2 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx @@ -67,7 +67,7 @@ export function createStaticCommandBarButtons( } } - if (userContext.apiType !== "Tables") { + if (userContext.apiType !== "Tables" && configContext.platform !== Platform.Fabric) { newCollectionBtn.children = [createNewCollectionGroup(container)]; const newDatabaseBtn = createNewDatabase(container); newCollectionBtn.children.push(newDatabaseBtn); diff --git a/src/Explorer/Panes/AddCollectionPanel.tsx b/src/Explorer/Panes/AddCollectionPanel.tsx index 33ce4bda3..2427c46d2 100644 --- a/src/Explorer/Panes/AddCollectionPanel.tsx +++ b/src/Explorer/Panes/AddCollectionPanel.tsx @@ -114,7 +114,8 @@ export class AddCollectionPanel extends React.Component - -
- - Create new + {configContext.platform !== Platform.Fabric && ( + +
+ + Create new - - Use existing -
-
+ + Use existing +
+
+ )} {this.state.createNewDatabase && ( diff --git a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx index fcf2b1d29..478020c54 100644 --- a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx +++ b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx @@ -1,5 +1,14 @@ import { PriorityLevel } from "@azure/cosmos"; -import { Checkbox, ChoiceGroup, IChoiceGroupOption, SpinButton } from "@fluentui/react"; +import { + Checkbox, + ChoiceGroup, + IChoiceGroupOption, + ISpinButtonStyles, + IToggleStyles, + Position, + SpinButton, + Toggle, +} from "@fluentui/react"; import * as Constants from "Common/Constants"; import { InfoTooltip } from "Common/Tooltip/InfoTooltip"; import { configContext } from "ConfigContext"; @@ -9,7 +18,7 @@ import { userContext } from "UserContext"; import { logConsoleInfo } from "Utils/NotificationConsoleUtils"; import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils"; import { useSidePanel } from "hooks/useSidePanel"; -import React, { FunctionComponent, MouseEvent, useState } from "react"; +import React, { FunctionComponent, useState } from "react"; import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm"; export const SettingsPane: FunctionComponent = () => { @@ -20,6 +29,13 @@ export const SettingsPane: FunctionComponent = () => { ? Constants.Queries.UnlimitedPageOption : Constants.Queries.CustomPageOption, ); + const [queryTimeoutEnabled, setQueryTimeoutEnabled] = useState( + LocalStorageUtility.getEntryBoolean(StorageKey.QueryTimeoutEnabled), + ); + const [queryTimeout, setQueryTimeout] = useState(LocalStorageUtility.getEntryNumber(StorageKey.QueryTimeout)); + const [automaticallyCancelQueryAfterTimeout, setAutomaticallyCancelQueryAfterTimeout] = useState( + LocalStorageUtility.getEntryBoolean(StorageKey.AutomaticallyCancelQueryAfterTimeout), + ); const [customItemPerPage, setCustomItemPerPage] = useState( LocalStorageUtility.getEntryNumber(StorageKey.CustomItemPerPage) || 0, ); @@ -54,7 +70,7 @@ export const SettingsPane: FunctionComponent = () => { const shouldShowCrossPartitionOption = userContext.apiType !== "Gremlin"; const shouldShowParallelismOption = userContext.apiType !== "Gremlin"; const shouldShowPriorityLevelOption = PriorityBasedExecutionUtils.isFeatureEnabled(); - const handlerOnSubmit = (e: MouseEvent) => { + const handlerOnSubmit = () => { setIsExecuting(true); LocalStorageUtility.setEntryNumber( @@ -62,6 +78,7 @@ export const SettingsPane: FunctionComponent = () => { isCustomPageOptionSelected() ? customItemPerPage : Constants.Queries.unlimitedItemsPerPage, ); LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage); + LocalStorageUtility.setEntryBoolean(StorageKey.QueryTimeoutEnabled, queryTimeoutEnabled); LocalStorageUtility.setEntryString(StorageKey.ContainerPaginationEnabled, containerPaginationEnabled.toString()); LocalStorageUtility.setEntryString(StorageKey.IsCrossPartitionQueryEnabled, crossPartitionQueryEnabled.toString()); LocalStorageUtility.setEntryNumber(StorageKey.MaxDegreeOfParellism, maxDegreeOfParallelism); @@ -74,6 +91,14 @@ export const SettingsPane: FunctionComponent = () => { ); } + if (queryTimeoutEnabled) { + LocalStorageUtility.setEntryNumber(StorageKey.QueryTimeout, queryTimeout); + LocalStorageUtility.setEntryBoolean( + StorageKey.AutomaticallyCancelQueryAfterTimeout, + automaticallyCancelQueryAfterTimeout, + ); + } + setIsExecuting(false); logConsoleInfo( `Updated items per page setting to ${LocalStorageUtility.getEntryNumber(StorageKey.ActualItemPerPage)}`, @@ -98,7 +123,6 @@ export const SettingsPane: FunctionComponent = () => { `Updated query setting to ${LocalStorageUtility.getEntryString(StorageKey.SetPartitionKeyUndefined)}`, ); closeSidePanel(); - e.preventDefault(); }; const isCustomPageOptionSelected = () => { @@ -113,7 +137,7 @@ export const SettingsPane: FunctionComponent = () => { formError: "", isExecuting, submitButtonText: "Apply", - onSubmit: () => handlerOnSubmit(undefined), + onSubmit: () => handlerOnSubmit(), }; const pageOptionList: IChoiceGroupOption[] = [ { key: Constants.Queries.CustomPageOption, text: "Custom" }, @@ -141,6 +165,21 @@ export const SettingsPane: FunctionComponent = () => { setPageOption(option.key); }; + const handleOnQueryTimeoutToggleChange = (ev: React.MouseEvent, checked?: boolean): void => { + setQueryTimeoutEnabled(checked); + }; + + const handleOnAutomaticallyCancelQueryToggleChange = (ev: React.MouseEvent, checked?: boolean): void => { + setAutomaticallyCancelQueryAfterTimeout(checked); + }; + + const handleOnQueryTimeoutSpinButtonChange = (ev: React.MouseEvent, newValue?: string): void => { + const queryTimeout = Number(newValue); + if (!isNaN(queryTimeout)) { + setQueryTimeout(queryTimeout); + } + }; + const choiceButtonStyles = { root: { clear: "both", @@ -162,6 +201,35 @@ export const SettingsPane: FunctionComponent = () => { }, ], }; + + const queryTimeoutToggleStyles: IToggleStyles = { + label: { + fontSize: 12, + fontWeight: 400, + display: "block", + }, + root: {}, + container: {}, + pill: {}, + thumb: {}, + text: {}, + }; + + const queryTimeoutSpinButtonStyles: ISpinButtonStyles = { + label: { + fontSize: 12, + fontWeight: 400, + }, + root: { + paddingBottom: 10, + }, + labelWrapper: {}, + icon: {}, + spinButtonWrapper: {}, + input: {}, + arrowButtonsContainer: {}, + }; + return (
@@ -212,6 +280,50 @@ export const SettingsPane: FunctionComponent = () => {
)} + {userContext.apiType === "SQL" && ( +
+
+
+ + Query Timeout + + + When a query reaches a specified time limit, a popup with an option to cancel the query will show + unless automatic cancellation has been enabled + +
+
+ +
+ {queryTimeoutEnabled && ( +
+ + +
+ )} +
+
+ )}
diff --git a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap index 14837845b..372e7e179 100644 --- a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap +++ b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap @@ -97,6 +97,46 @@ exports[`Settings Pane should render Default properly 1`] = `
+
+
+
+ + Query Timeout + + + When a query reaches a specified time limit, a popup with an option to cancel the query will show unless automatic cancellation has been enabled + +
+
+ +
+
+
diff --git a/src/Explorer/Tabs/DocumentsTab.html b/src/Explorer/Tabs/DocumentsTab.html index 094c18e10..4283a661c 100644 --- a/src/Explorer/Tabs/DocumentsTab.html +++ b/src/Explorer/Tabs/DocumentsTab.html @@ -98,7 +98,7 @@
diff --git a/src/Explorer/Tabs/DocumentsTab.ts b/src/Explorer/Tabs/DocumentsTab.ts index bf021c12d..8d95bbf18 100644 --- a/src/Explorer/Tabs/DocumentsTab.ts +++ b/src/Explorer/Tabs/DocumentsTab.ts @@ -2,6 +2,9 @@ import { ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Re import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities"; import * as ko from "knockout"; import Q from "q"; +import { format } from "react-string-format"; +import { QueryConstants } from "Shared/Constants"; +import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; import DeleteDocumentIcon from "../../../images/DeleteDocument.svg"; import NewDocumentIcon from "../../../images/NewDocument.svg"; import UploadIcon from "../../../images/Upload_16x16.svg"; @@ -80,6 +83,7 @@ export default class DocumentsTab extends TabsBase { private _resourceTokenPartitionKey: string; private _isQueryCopilotSampleContainer: boolean; private queryAbortController: AbortController; + private cancelQueryTimeoutID: NodeJS.Timeout; constructor(options: ViewModels.DocumentsTabOptions) { super(options); @@ -351,11 +355,11 @@ export default class DocumentsTab extends TabsBase { * Query first page of documents * Select and query first document and display content */ - private async autoPopulateContent() { + private async autoPopulateContent(applyFilterButtonPressed?: boolean) { // reset iterator this._documentsIterator = this.createIterator(); // load documents - await this.loadNextPage(); + await this.loadNextPage(applyFilterButtonPressed); // Select first document and load content if (this.documentIds().length > 0) { @@ -392,12 +396,14 @@ export default class DocumentsTab extends TabsBase { return true; }; - public async refreshDocumentsGrid(): Promise { + public async refreshDocumentsGrid(applyFilterButtonPressed?: boolean): Promise { // clear documents grid this.documentIds([]); - try { - await this.autoPopulateContent(); + // reset iterator + this._documentsIterator = this.createIterator(); + // load documents + await this.autoPopulateContent(applyFilterButtonPressed); // collapse filter this.appliedFilter(this.filterContent()); this.isFilterExpanded(false); @@ -737,9 +743,35 @@ export default class DocumentsTab extends TabsBase { this.initDocumentEditor(documentId, content); } - public loadNextPage(): Q.Promise { + public loadNextPage(applyFilterButtonClicked?: boolean): Q.Promise { this.isExecuting(true); this.isExecutionError(false); + let automaticallyCancelQueryAfterTimeout: boolean; + if (applyFilterButtonClicked && this.queryTimeoutEnabled()) { + const queryTimeout: number = LocalStorageUtility.getEntryNumber(StorageKey.QueryTimeout); + automaticallyCancelQueryAfterTimeout = LocalStorageUtility.getEntryBoolean( + StorageKey.AutomaticallyCancelQueryAfterTimeout, + ); + const cancelQueryTimeoutID: NodeJS.Timeout = setTimeout(() => { + if (this.isExecuting()) { + if (automaticallyCancelQueryAfterTimeout) { + this.queryAbortController.abort(); + } else { + useDialog + .getState() + .showOkCancelModalDialog( + QueryConstants.CancelQueryTitle, + format(QueryConstants.CancelQuerySubTextTemplate, QueryConstants.CancelQueryTimeoutThresholdReached), + "Yes", + () => this.queryAbortController.abort(), + "No", + undefined, + ); + } + } + }, queryTimeout); + this.cancelQueryTimeoutID = cancelQueryTimeoutID; + } return this._loadNextPageInternal() .then( (documentsIdsResponse = []) => { @@ -795,7 +827,15 @@ export default class DocumentsTab extends TabsBase { } }, ) - .finally(() => this.isExecuting(false)); + .finally(() => { + this.isExecuting(false); + if (applyFilterButtonClicked && this.queryTimeoutEnabled()) { + clearTimeout(this.cancelQueryTimeoutID); + if (!automaticallyCancelQueryAfterTimeout) { + useDialog.getState().closeDialog(); + } + } + }); } public onLoadMoreKeyInput = (source: any, event: KeyboardEvent): void => { @@ -973,4 +1013,8 @@ export default class DocumentsTab extends TabsBase { useSelectedNode.getState().isQueryCopilotCollectionSelected(), }; } + + private queryTimeoutEnabled(): boolean { + return !this.isPreferredApiMongoDB && LocalStorageUtility.getEntryBoolean(StorageKey.QueryTimeoutEnabled); + } } diff --git a/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx b/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx index 98384d92e..d4deda184 100644 --- a/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx +++ b/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx @@ -1,12 +1,16 @@ import { FeedOptions } from "@azure/cosmos"; +import { useDialog } from "Explorer/Controls/Dialog"; import { OnExecuteQueryClick } from "Explorer/QueryCopilot/Shared/QueryCopilotClient"; import { QueryCopilotResults } from "Explorer/QueryCopilot/Shared/QueryCopilotResults"; import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar"; import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection"; +import { QueryConstants } from "Shared/Constants"; +import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot"; import React, { Fragment } from "react"; import SplitterLayout from "react-splitter-layout"; import "react-splitter-layout/lib/index.css"; +import { format } from "react-string-format"; import LaunchCopilot from "../../../../images/CopilotTabIcon.svg"; import CancelQueryIcon from "../../../../images/Entity_cancel.svg"; import ExecuteQueryIcon from "../../../../images/ExecuteQuery.svg"; @@ -80,6 +84,7 @@ interface IQueryTabStates { isExecuting: boolean; showCopilotSidebar: boolean; queryCopilotGeneratedQuery: string; + cancelQueryTimeoutID: NodeJS.Timeout; } export default class QueryTabComponent extends React.Component { @@ -107,13 +112,13 @@ export default class QueryTabComponent extends React.Component 0, visible: true, @@ -250,6 +255,34 @@ export default class QueryTabComponent extends React.Component { + if (this.state.isExecuting) { + if (automaticallyCancelQueryAfterTimeout) { + this.queryAbortController.abort(); + } else { + useDialog + .getState() + .showOkCancelModalDialog( + QueryConstants.CancelQueryTitle, + format(QueryConstants.CancelQuerySubTextTemplate, QueryConstants.CancelQueryTimeoutThresholdReached), + "Yes", + () => this.queryAbortController.abort(), + "No", + undefined, + ); + } + } + }, queryTimeout); + this.setState({ + cancelQueryTimeoutID, + }); + } useCommandBar.getState().setContextButtons(this.getTabsButtons()); try { @@ -273,7 +306,14 @@ export default class QueryTabComponent extends React.Component void; componentDidMount(): void { diff --git a/src/Shared/Constants.ts b/src/Shared/Constants.ts index 8594ccc25..1c2e91cfb 100644 --- a/src/Shared/Constants.ts +++ b/src/Shared/Constants.ts @@ -208,3 +208,9 @@ export class FreeTierLimits { public static RU: number = 1000; public static Storage: number = 25; } + +export class QueryConstants { + public static readonly CancelQueryTitle: string = "Cancel query"; + public static readonly CancelQuerySubTextTemplate: string = "{0} Do you want to cancel this query?"; + public static readonly CancelQueryTimeoutThresholdReached: string = "The query timeout threshold has been reached."; +} diff --git a/src/Shared/StorageUtility.ts b/src/Shared/StorageUtility.ts index d142c07e4..dd142c8f3 100644 --- a/src/Shared/StorageUtility.ts +++ b/src/Shared/StorageUtility.ts @@ -4,6 +4,9 @@ import * as SessionStorageUtility from "./SessionStorageUtility"; export { LocalStorageUtility, SessionStorageUtility }; export enum StorageKey { ActualItemPerPage, + QueryTimeoutEnabled, + QueryTimeout, + AutomaticallyCancelQueryAfterTimeout, ContainerPaginationEnabled, CustomItemPerPage, DatabaseAccountId,