diff --git a/README.md b/README.md index 9057742d5..fb28c5d7a 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,10 @@ Jest and Puppeteer are used for end to end browser based tests and are contained We generally adhere to the release strategy [documented by the Azure SDK Guidelines](https://azure.github.io/azure-sdk/policies_repobranching.html#release-branches). Most releases should happen from the master branch. If master contains commits that cannot be released, you may create a release from a `release/` or `hotfix/` branch. See linked documentation for more details. +### Architechture + +[![](https://mermaid.ink/img/eyJjb2RlIjoiZ3JhcGggTFJcbiAgaG9zdGVkKGh0dHBzOi8vY29zbW9zLmF6dXJlLmNvbSlcbiAgcG9ydGFsKFBvcnRhbClcbiAgZW11bGF0b3IoRW11bGF0b3IpXG4gIGFhZFtBQURdXG4gIHJlc291cmNlVG9rZW5bUmVzb3VyY2UgVG9rZW5dXG4gIGNvbm5lY3Rpb25TdHJpbmdbQ29ubmVjdGlvbiBTdHJpbmddXG4gIHBvcnRhbFRva2VuW0VuY3J5cHRlZCBQb3J0YWwgVG9rZW5dXG4gIG1hc3RlcktleVtNYXN0ZXIgS2V5XVxuICBhcm1bQVJNIFJlc291cmNlIFByb3ZpZGVyXVxuICBkYXRhcGxhbmVbRGF0YSBQbGFuZV1cbiAgcHJveHlbUG9ydGFsIEFQSSBQcm94eV1cbiAgc3FsW1NRTF1cbiAgbW9uZ29bTW9uZ29dXG4gIHRhYmxlc1tUYWJsZXNdXG4gIGNhc3NhbmRyYVtDYXNzYW5kcmFdXG4gIGdyYWZbR3JhcGhdXG5cblxuICBlbXVsYXRvciAtLT4gbWFzdGVyS2V5IC0tLS0-IGRhdGFwbGFuZVxuICBwb3J0YWwgLS0-IGFhZFxuICBob3N0ZWQgLS0-IHBvcnRhbFRva2VuICYgcmVzb3VyY2VUb2tlbiAmIGNvbm5lY3Rpb25TdHJpbmcgJiBhYWRcbiAgYWFkIC0tLT4gYXJtXG4gIGFhZCAtLS0-IGRhdGFwbGFuZVxuICBhYWQgLS0tPiBwcm94eVxuICByZXNvdXJjZVRva2VuIC0tLT4gc3FsIC0tPiBkYXRhcGxhbmVcbiAgcG9ydGFsVG9rZW4gLS0tPiBwcm94eVxuICBwcm94eSAtLT4gZGF0YXBsYW5lXG4gIGNvbm5lY3Rpb25TdHJpbmcgLS0-IHNxbCAmIG1vbmdvICYgY2Fzc2FuZHJhICYgZ3JhZiAmIHRhYmxlc1xuICBzcWwgLS0-IGRhdGFwbGFuZVxuICB0YWJsZXMgLS0-IGRhdGFwbGFuZVxuICBtb25nbyAtLT4gcHJveHlcbiAgY2Fzc2FuZHJhIC0tPiBwcm94eVxuICBncmFmIC0tPiBwcm94eVxuXG5cdFx0IiwibWVybWFpZCI6eyJ0aGVtZSI6ImRlZmF1bHQifSwidXBkYXRlRWRpdG9yIjpmYWxzZX0)](https://mermaid-js.github.io/mermaid-live-editor/#/edit/eyJjb2RlIjoiZ3JhcGggTFJcbiAgaG9zdGVkKGh0dHBzOi8vY29zbW9zLmF6dXJlLmNvbSlcbiAgcG9ydGFsKFBvcnRhbClcbiAgZW11bGF0b3IoRW11bGF0b3IpXG4gIGFhZFtBQURdXG4gIHJlc291cmNlVG9rZW5bUmVzb3VyY2UgVG9rZW5dXG4gIGNvbm5lY3Rpb25TdHJpbmdbQ29ubmVjdGlvbiBTdHJpbmddXG4gIHBvcnRhbFRva2VuW0VuY3J5cHRlZCBQb3J0YWwgVG9rZW5dXG4gIG1hc3RlcktleVtNYXN0ZXIgS2V5XVxuICBhcm1bQVJNIFJlc291cmNlIFByb3ZpZGVyXVxuICBkYXRhcGxhbmVbRGF0YSBQbGFuZV1cbiAgcHJveHlbUG9ydGFsIEFQSSBQcm94eV1cbiAgc3FsW1NRTF1cbiAgbW9uZ29bTW9uZ29dXG4gIHRhYmxlc1tUYWJsZXNdXG4gIGNhc3NhbmRyYVtDYXNzYW5kcmFdXG4gIGdyYWZbR3JhcGhdXG5cblxuICBlbXVsYXRvciAtLT4gbWFzdGVyS2V5IC0tLS0-IGRhdGFwbGFuZVxuICBwb3J0YWwgLS0-IGFhZFxuICBob3N0ZWQgLS0-IHBvcnRhbFRva2VuICYgcmVzb3VyY2VUb2tlbiAmIGNvbm5lY3Rpb25TdHJpbmcgJiBhYWRcbiAgYWFkIC0tLT4gYXJtXG4gIGFhZCAtLS0-IGRhdGFwbGFuZVxuICBhYWQgLS0tPiBwcm94eVxuICByZXNvdXJjZVRva2VuIC0tLT4gc3FsIC0tPiBkYXRhcGxhbmVcbiAgcG9ydGFsVG9rZW4gLS0tPiBwcm94eVxuICBwcm94eSAtLT4gZGF0YXBsYW5lXG4gIGNvbm5lY3Rpb25TdHJpbmcgLS0-IHNxbCAmIG1vbmdvICYgY2Fzc2FuZHJhICYgZ3JhZiAmIHRhYmxlc1xuICBzcWwgLS0-IGRhdGFwbGFuZVxuICB0YWJsZXMgLS0-IGRhdGFwbGFuZVxuICBtb25nbyAtLT4gcHJveHlcbiAgY2Fzc2FuZHJhIC0tPiBwcm94eVxuICBncmFmIC0tPiBwcm94eVxuXG5cdFx0IiwibWVybWFpZCI6eyJ0aGVtZSI6ImRlZmF1bHQifSwidXBkYXRlRWRpdG9yIjpmYWxzZX0) + # Contributing Please read the [contribution guidelines](./CONTRIBUTING.md). diff --git a/less/hostedexplorer.less b/less/hostedexplorer.less index 137c76f11..86186d366 100644 --- a/less/hostedexplorer.less +++ b/less/hostedexplorer.less @@ -13,6 +13,11 @@ @NavMediumSpace: 10px; @NavLargeSpace: 15px; +.skip-link { + position: fixed; + top: -200px; +} + html { font-family: wf_segoe-ui_normal, "Segoe UI", "Segoe WP", Tahoma, Arial, sans-serif; padding: 0px; diff --git a/less/tree.less b/less/tree.less index f2f7dcd6b..56a5ee38e 100644 --- a/less/tree.less +++ b/less/tree.less @@ -1,20 +1,12 @@ @import "./Common/Constants"; -.main { - width: 100%; - float: left; - transition: all .0s ease-in-out; - -ms-transition: all 0s ease-in-out; - -webkit-transition: all 0s ease-in-out; - -moz-transition: all .0s ease-in-out; - height: 100%; - background-color: white; - border-left: 0px solid white; -} .resourceTree { height: 100%; flex: 0 0 auto; + .main { + height: 100%; + } } .resourceTreeScroll { diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 0a72c55b8..9d17f96ae 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -133,6 +133,7 @@ export class Features { export class Flights { public static readonly SettingsV2 = "settingsv2"; public static readonly MongoIndexEditor = "mongoindexeditor"; + public static readonly MongoIndexing = "mongoindexing"; } export class AfecFeatures { diff --git a/src/Common/ErrorHandlingUtils.ts b/src/Common/ErrorHandlingUtils.ts index 0491fab77..440b07b25 100644 --- a/src/Common/ErrorHandlingUtils.ts +++ b/src/Common/ErrorHandlingUtils.ts @@ -21,7 +21,7 @@ export const handleError = (error: string | ARMError | Error, area: string, cons sendNotificationForError(errorMessage, errorCode); }; -export const getErrorMessage = (error: string | Error): string => { +export const getErrorMessage = (error: string | Error = ""): string => { const errorMessage = typeof error === "string" ? error : error.message; return replaceKnownError(errorMessage); }; @@ -45,10 +45,10 @@ const sendNotificationForError = (errorMessage: string, errorCode: number | stri const replaceKnownError = (errorMessage: string): string => { if ( window.dataExplorer?.subscriptionType() === SubscriptionType.Internal && - errorMessage.indexOf("SharedOffer is Disabled for your account") >= 0 + errorMessage?.indexOf("SharedOffer is Disabled for your account") >= 0 ) { return "Database throughput is not supported for internal subscriptions."; - } else if (errorMessage.indexOf("Partition key paths must contain only valid") >= 0) { + } else if (errorMessage?.indexOf("Partition key paths must contain only valid") >= 0) { return "Partition key paths must contain only valid characters and not contain a trailing slash or wildcard character."; } diff --git a/src/Common/OfferUtility.ts b/src/Common/OfferUtility.ts index a6e1377fe..6d7f6bf7f 100644 --- a/src/Common/OfferUtility.ts +++ b/src/Common/OfferUtility.ts @@ -2,8 +2,11 @@ import { Offer, SDKOfferDefinition } from "../Contracts/DataModels"; import { OfferResponse } from "@azure/cosmos"; import { HttpHeaders } from "./Constants"; -export const parseSDKOfferResponse = (offerResponse: OfferResponse): Offer => { - const offerDefinition: SDKOfferDefinition = offerResponse?.resource; +export const parseSDKOfferResponse = (offerResponse: OfferResponse): Offer | undefined => { + const offerDefinition: SDKOfferDefinition | undefined = offerResponse?.resource; + if (!offerDefinition) { + return undefined; + } const offerContent = offerDefinition.content; if (!offerContent) { return undefined; @@ -12,7 +15,7 @@ export const parseSDKOfferResponse = (offerResponse: OfferResponse): Offer => { const minimumThroughput = offerContent.collectionThroughputInfo?.minimumRUForCollection; const autopilotSettings = offerContent.offerAutopilotSettings; - if (autopilotSettings) { + if (autopilotSettings && autopilotSettings.maxThroughput && minimumThroughput) { return { id: offerDefinition.id, autoscaleMaxThroughput: autopilotSettings.maxThroughput, diff --git a/src/Common/Splitter.ts b/src/Common/Splitter.ts index 823a88f99..5699247f1 100644 --- a/src/Common/Splitter.ts +++ b/src/Common/Splitter.ts @@ -23,10 +23,10 @@ export class Splitter { public splitterId: string; public leftSideId: string; - public splitter: HTMLElement; - public leftSide: HTMLElement; - public lastX: number; - public lastWidth: number; + public splitter!: HTMLElement; + public leftSide!: HTMLElement; + public lastX!: number; + public lastWidth!: number; private isCollapsed: ko.Observable; private bounds: SplitterBounds; @@ -42,9 +42,10 @@ export class Splitter { } public initialize() { - this.splitter = document.getElementById(this.splitterId); - this.leftSide = document.getElementById(this.leftSideId); - + if (document.getElementById(this.splitterId) !== null && document.getElementById(this.leftSideId) != null) { + this.splitter = document.getElementById(this.splitterId); + this.leftSide = document.getElementById(this.leftSideId); + } const isVerticalSplitter: boolean = this.direction === SplitterDirection.Vertical; const splitterOptions: JQueryUI.ResizableOptions = { animate: true, diff --git a/src/Contracts/DataModels.ts b/src/Contracts/DataModels.ts index 505090c18..1ee61c489 100644 --- a/src/Contracts/DataModels.ts +++ b/src/Contracts/DataModels.ts @@ -210,9 +210,9 @@ export interface QueryMetrics { export interface Offer { id: string; - autoscaleMaxThroughput: number; - manualThroughput: number; - minimumThroughput: number; + autoscaleMaxThroughput: number | undefined; + manualThroughput: number | undefined; + minimumThroughput: number | undefined; offerDefinition?: SDKOfferDefinition; offerReplacePending: boolean; } diff --git a/src/Explorer/Controls/CollapsiblePanel/CollapsiblePanelComponent.ts b/src/Explorer/Controls/CollapsiblePanel/CollapsiblePanelComponent.ts index 959980371..b0f398f1a 100644 --- a/src/Explorer/Controls/CollapsiblePanel/CollapsiblePanelComponent.ts +++ b/src/Explorer/Controls/CollapsiblePanel/CollapsiblePanelComponent.ts @@ -42,7 +42,7 @@ interface CollapsiblePanelParams { * Use the optional "collapseToLeft" parameter to collapse to the left. */ class CollapsiblePanelViewModel { - private params: CollapsiblePanelParams; + public params: CollapsiblePanelParams; private isCollapsed: ko.Observable; public constructor(params: CollapsiblePanelParams) { @@ -50,7 +50,7 @@ class CollapsiblePanelViewModel { this.isCollapsed = params.isCollapsed || ko.observable(false); } - private toggleCollapse(): void { + public toggleCollapse(): void { this.isCollapsed(!this.isCollapsed()); } } diff --git a/src/Explorer/Controls/InputTypeahead/InputTypeahead.ts b/src/Explorer/Controls/InputTypeahead/InputTypeahead.ts index 9a7f9257f..aff4a14fd 100644 --- a/src/Explorer/Controls/InputTypeahead/InputTypeahead.ts +++ b/src/Explorer/Controls/InputTypeahead/InputTypeahead.ts @@ -71,7 +71,7 @@ interface InputTypeaheadParams { /** * This function gets called when pressing ENTER on the input box */ - submitFct?: (inputValue: string, selection: Item) => void; + submitFct?: (inputValue: string | null, selection: Item | null) => void; /** * Typehead comes with a Search button that we normally remove. @@ -88,8 +88,8 @@ interface OnClickItem { } interface Cache { - inputValue: string; - selection: Item; + inputValue: string | null; + selection: Item | null; } class InputTypeaheadViewModel { @@ -98,15 +98,12 @@ class InputTypeaheadViewModel { private params: InputTypeaheadParams; private cache: Cache; - private inputValue: string; - private selection: Item; public constructor(params: InputTypeaheadParams) { this.instanceNumber = InputTypeaheadViewModel.instanceCount++; this.params = params; this.params.choices.subscribe(this.initializeTypeahead.bind(this)); - this.cache = { inputValue: null, selection: null @@ -161,7 +158,7 @@ class InputTypeaheadViewModel { } } - $.typeahead(options); + ($ as any).typeahead(options); } /** @@ -177,11 +174,11 @@ class InputTypeaheadViewModel { * Use ko's "template: afterRender" callback to do that without actually using any template. * Another way is to call it within setTimeout() in constructor. */ - private afterRender(): void { + public afterRender(): void { this.initializeTypeahead(); } - private submit(): void { + public submit(): void { if (this.params.submitFct) { this.params.submitFct(this.cache.inputValue, this.cache.selection); } diff --git a/src/Explorer/Controls/JsonEditor/JsonEditorComponent.ts b/src/Explorer/Controls/JsonEditor/JsonEditorComponent.ts index 459f3b884..7611fe567 100644 --- a/src/Explorer/Controls/JsonEditor/JsonEditorComponent.ts +++ b/src/Explorer/Controls/JsonEditor/JsonEditorComponent.ts @@ -59,10 +59,12 @@ export class JsonEditorViewModel extends WaitsForTemplateViewModel { this.params = params; this.params.content.subscribe((newValue: string) => { - if (!!this.editor) { - this.editor.getModel().setValue(newValue); - } else { - this.createEditor(newValue, this.configureEditor.bind(this)); + if (newValue) { + if (!!this.editor) { + this.editor.getModel().setValue(newValue); + } else { + this.createEditor(newValue, this.configureEditor.bind(this)); + } } }); diff --git a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx index c8ea4f390..01d0c8d6d 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx @@ -231,7 +231,7 @@ describe("SettingsComponent", () => { it("getUpdatedConflictResolutionPolicy", () => { const wrapper = shallow(); - const conflictResolutionPolicyPath = "_ts"; + const conflictResolutionPolicyPath = "/_ts"; const conflictResolutionPolicyProcedure = "sample_sproc"; const expectSprocPath = "/dbs/" + collection.databaseId + "/colls/" + collection.id() + "/sprocs/" + conflictResolutionPolicyProcedure; diff --git a/src/Explorer/Controls/Settings/SettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsComponent.tsx index 704bebb24..8b901bb19 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.tsx @@ -138,8 +138,8 @@ export class SettingsComponent extends React.Component, newValue?: string ): void => { - const newThroughput = getSanitizedInputValue(newValue, this.autoPilotInputMaxValue); + const newThroughput = getSanitizedInputValue(newValue); this.props.onMaxAutoPilotThroughputChange(newThroughput); }; @@ -435,7 +435,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component< event: React.FormEvent, newValue?: string ): void => { - const newThroughput = getSanitizedInputValue(newValue, this.throughputInputMaxValue); + const newThroughput = getSanitizedInputValue(newValue); if (this.overrideWithAutoPilotSettings()) { this.props.onMaxAutoPilotThroughputChange(newThroughput); } else { diff --git a/src/Explorer/Controls/Settings/SettingsUtils.tsx b/src/Explorer/Controls/Settings/SettingsUtils.tsx index 06b29b4b3..daa9f5e85 100644 --- a/src/Explorer/Controls/Settings/SettingsUtils.tsx +++ b/src/Explorer/Controls/Settings/SettingsUtils.tsx @@ -101,13 +101,13 @@ export const parseConflictResolutionProcedure = (procedureFromBackEnd: string): return procedureFromBackEnd; }; -export const getSanitizedInputValue = (newValueString: string, max: number): number => { +export const getSanitizedInputValue = (newValueString: string, max?: number): number => { const newValue = parseInt(newValueString); if (isNaN(newValue)) { return zeroValue; } // make sure new value does not exceed the maximum throughput - return Math.min(newValue, max); + return max ? Math.min(newValue, max) : newValue; }; export const isDirty = (current: isDirtyTypes, baseline: isDirtyTypes): boolean => { diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap index 788652f7a..e2a70a521 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap @@ -954,6 +954,7 @@ exports[`SettingsComponent renders 1`] = ` "isHostedDataExplorerEnabled": [Function], "isLeftPaneExpanded": [Function], "isLinkInjectionEnabled": [Function], + "isMongoIndexingEnabled": [Function], "isNotebookEnabled": [Function], "isNotebooksEnabledForAccount": [Function], "isNotificationConsoleExpanded": [Function], @@ -1187,11 +1188,9 @@ exports[`SettingsComponent renders 1`] = ` }, "direction": "vertical", "isCollapsed": [Function], - "leftSide": null, "leftSideId": "resourcetree", "onResizeStart": [Function], "onResizeStop": [Function], - "splitter": null, "splitterId": "h_splitter1", }, "stringInputPane": StringInputPane { @@ -2237,6 +2236,7 @@ exports[`SettingsComponent renders 1`] = ` "isHostedDataExplorerEnabled": [Function], "isLeftPaneExpanded": [Function], "isLinkInjectionEnabled": [Function], + "isMongoIndexingEnabled": [Function], "isNotebookEnabled": [Function], "isNotebooksEnabledForAccount": [Function], "isNotificationConsoleExpanded": [Function], @@ -2470,11 +2470,9 @@ exports[`SettingsComponent renders 1`] = ` }, "direction": "vertical", "isCollapsed": [Function], - "leftSide": null, "leftSideId": "resourcetree", "onResizeStart": [Function], "onResizeStop": [Function], - "splitter": null, "splitterId": "h_splitter1", }, "stringInputPane": StringInputPane { @@ -3533,6 +3531,7 @@ exports[`SettingsComponent renders 1`] = ` "isHostedDataExplorerEnabled": [Function], "isLeftPaneExpanded": [Function], "isLinkInjectionEnabled": [Function], + "isMongoIndexingEnabled": [Function], "isNotebookEnabled": [Function], "isNotebooksEnabledForAccount": [Function], "isNotificationConsoleExpanded": [Function], @@ -3766,11 +3765,9 @@ exports[`SettingsComponent renders 1`] = ` }, "direction": "vertical", "isCollapsed": [Function], - "leftSide": null, "leftSideId": "resourcetree", "onResizeStart": [Function], "onResizeStop": [Function], - "splitter": null, "splitterId": "h_splitter1", }, "stringInputPane": StringInputPane { @@ -4816,6 +4813,7 @@ exports[`SettingsComponent renders 1`] = ` "isHostedDataExplorerEnabled": [Function], "isLeftPaneExpanded": [Function], "isLinkInjectionEnabled": [Function], + "isMongoIndexingEnabled": [Function], "isNotebookEnabled": [Function], "isNotebooksEnabledForAccount": [Function], "isNotificationConsoleExpanded": [Function], @@ -5049,11 +5047,9 @@ exports[`SettingsComponent renders 1`] = ` }, "direction": "vertical", "isCollapsed": [Function], - "leftSide": null, "leftSideId": "resourcetree", "onResizeStart": [Function], "onResizeStop": [Function], - "splitter": null, "splitterId": "h_splitter1", }, "stringInputPane": StringInputPane { diff --git a/src/Explorer/Explorer.ts b/src/Explorer/Explorer.ts index ff8667c96..542cf12f5 100644 --- a/src/Explorer/Explorer.ts +++ b/src/Explorer/Explorer.ts @@ -212,6 +212,7 @@ export default class Explorer { public isCopyNotebookPaneEnabled: ko.Observable; public isHostedDataExplorerEnabled: ko.Computed; public isRightPanelV2Enabled: ko.Computed; + public isMongoIndexingEnabled: ko.Observable; public canExceedMaximumValue: ko.Computed; public shouldShowShareDialogContents: ko.Observable; @@ -409,6 +410,7 @@ export default class Explorer { this.isFeatureEnabled(Constants.Features.enableLinkInjection) ); this.isGitHubPaneEnabled = ko.observable(false); + this.isMongoIndexingEnabled = ko.observable(false); this.isPublishNotebookPaneEnabled = ko.observable(false); this.isCopyNotebookPaneEnabled = ko.observable(false); @@ -1918,6 +1920,9 @@ export default class Explorer { if (!flights) { return; } + if (flights.indexOf(Constants.Flights.MongoIndexing) !== -1) { + this.isMongoIndexingEnabled(true); + } } public findSelectedCollection(): ViewModels.Collection { diff --git a/src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.ts b/src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.ts index 81324ecfb..0a4d8fd85 100644 --- a/src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.ts +++ b/src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.ts @@ -11,7 +11,9 @@ export class ArraysByKeyCache { public constructor(maxNbElements: number) { this.maxNbElements = maxNbElements; - this.clear(); + this.keyQueue = []; + this.cache = {}; + this.totalElements = 0; } public clear(): void { @@ -58,7 +60,7 @@ export class ArraysByKeyCache { * @param startIndex * @param pageSize */ - public retrieve(key: string, startIndex: number, pageSize: number): T[] { + public retrieve(key: string, startIndex: number, pageSize: number): T[] | null { if (!this.cache.hasOwnProperty(key)) { return null; } @@ -77,8 +79,10 @@ export class ArraysByKeyCache { private reduceCacheSize(): void { // remove an key and its array const oldKey = this.keyQueue.shift(); - this.totalElements -= this.cache[oldKey].length; - delete this.cache[oldKey]; + if (oldKey) { + this.totalElements -= this.cache[oldKey].length; + delete this.cache[oldKey]; + } } /** diff --git a/src/Explorer/Graph/GraphExplorerComponent/GraphData.ts b/src/Explorer/Graph/GraphExplorerComponent/GraphData.ts index 420d98e5e..9cbf502bd 100644 --- a/src/Explorer/Graph/GraphExplorerComponent/GraphData.ts +++ b/src/Explorer/Graph/GraphExplorerComponent/GraphData.ts @@ -413,13 +413,13 @@ export class GraphData { * @param node * @param prop */ - public static getNodePropValue(node: D3Node, prop: string): string | number | boolean { + public static getNodePropValue(node: D3Node, prop: string): undefined | string | number | boolean { if (node.hasOwnProperty(prop)) { return (node as any)[prop]; } // This is DocDB specific - if (node.hasOwnProperty("properties") && node.properties.hasOwnProperty(prop)) { + if (node.properties && node.properties.hasOwnProperty(prop)) { return node.properties[prop][0]["value"]; } @@ -496,7 +496,7 @@ export class GraphData { * Get list of children ids of a given vertex * @param vertex */ - private static getChildrenId(vertex: GremlinVertex): string[] { + public static getChildrenId(vertex: GremlinVertex): string[] { const ids = {}; // HashSet if (vertex.hasOwnProperty("outE")) { let outE = vertex.outE; diff --git a/src/Explorer/Notebook/NotebookComponent/__mocks__/rx-jupyter.ts b/src/Explorer/Notebook/NotebookComponent/__mocks__/rx-jupyter.ts index 1472d5f5d..1b02ef08e 100644 --- a/src/Explorer/Notebook/NotebookComponent/__mocks__/rx-jupyter.ts +++ b/src/Explorer/Notebook/NotebookComponent/__mocks__/rx-jupyter.ts @@ -1,18 +1,17 @@ import { Observable, of } from "rxjs"; -import { AjaxResponse } from "rxjs/ajax"; -import { ServerConfig } from "rx-jupyter"; +import { AjaxRequest, AjaxResponse } from "rxjs/ajax"; let fakeAjaxResponse: AjaxResponse = { - originalEvent: undefined, + originalEvent: (undefined), xhr: new XMLHttpRequest(), - request: null, + request: (null), status: 200, response: {}, - responseText: null, + responseText: "", responseType: "json" }; export const sessions = { - create: (serverConfig: ServerConfig, body: object): Observable => of(fakeAjaxResponse), + create: (serverConfig: unknown, body: object): Observable => of(fakeAjaxResponse), __setResponse: (response: AjaxResponse) => { fakeAjaxResponse = response; }, diff --git a/src/Explorer/Notebook/NotebookContentClient.ts b/src/Explorer/Notebook/NotebookContentClient.ts index d745c7e4f..60358d306 100644 --- a/src/Explorer/Notebook/NotebookContentClient.ts +++ b/src/Explorer/Notebook/NotebookContentClient.ts @@ -11,7 +11,7 @@ import { stringifyNotebook } from "@nteract/commutable"; export class NotebookContentClient { constructor( private notebookServerInfo: ko.Observable, - private notebookBasePath: ko.Observable, + public notebookBasePath: ko.Observable, private contentProvider: IContentProvider ) {} @@ -117,8 +117,11 @@ export class NotebookContentClient { private async checkIfFilepathExists(filepath: string): Promise { const parentDirPath = NotebookUtil.getParentPath(filepath); - const items = await this.fetchNotebookFiles(parentDirPath); - return items.some(value => FileSystemUtil.isPathEqual(value.path, filepath)); + if (parentDirPath) { + const items = await this.fetchNotebookFiles(parentDirPath); + return items.some(value => FileSystemUtil.isPathEqual(value.path, filepath)); + } + return false; } /** @@ -189,7 +192,7 @@ export class NotebookContentClient { const dir = xhr.response; const item = NotebookUtil.createNotebookContentItem(dir.name, dir.path, dir.type); item.parent = parent; - parent.children.push(item); + parent.children!.push(item); return item; }); } @@ -225,7 +228,7 @@ export class NotebookContentClient { * Convert rx-jupyter type to our type * @param type */ - private static getType(type: FileType): NotebookContentItemType { + public static getType(type: FileType): NotebookContentItemType { switch (type) { case "directory": return NotebookContentItemType.Directory; diff --git a/src/Explorer/Panes/AddCollectionPane.html b/src/Explorer/Panes/AddCollectionPane.html index 89096032c..cbbdc8195 100644 --- a/src/Explorer/Panes/AddCollectionPane.html +++ b/src/Explorer/Panes/AddCollectionPane.html @@ -257,7 +257,7 @@ range of values and is likely to have evenly distributed access patterns.

- { - public data: T[]; + public data: T[] | null; public sortOrder: any; public serverCallInProgress: boolean; diff --git a/src/Explorer/Tables/DataTable/TableEntityListViewModel.ts b/src/Explorer/Tables/DataTable/TableEntityListViewModel.ts index 668ec71db..2a3e7765f 100644 --- a/src/Explorer/Tables/DataTable/TableEntityListViewModel.ts +++ b/src/Explorer/Tables/DataTable/TableEntityListViewModel.ts @@ -16,14 +16,75 @@ import * as Entities from "../Entities"; import QueryTablesTab from "../../Tabs/QueryTablesTab"; import * as TableEntityProcessor from "../TableEntityProcessor"; import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor"; -import * as DataModels from "../../../Contracts/DataModels"; import * as ViewModels from "../../../Contracts/ViewModels"; -import { getErrorMessage, getErrorStack } from "../../../Common/ErrorHandlingUtils"; interface IListTableEntitiesSegmentedResult extends Entities.IListTableEntitiesResult { ExceedMaximumRetries?: boolean; } +export interface ErrorDataModel { + message: string; + severity?: string; + location?: { + start: string; + end: string; + }; + code?: string; +} + +function parseError(err: any): ErrorDataModel[] { + try { + return _parse(err); + } catch (e) { + return [{ message: JSON.stringify(err) }]; + } +} + +function _parse(err: any): ErrorDataModel[] { + var normalizedErrors: ErrorDataModel[] = []; + if (err.message && !err.code) { + normalizedErrors.push(err); + } else { + const innerErrors: any[] = _getInnerErrors(err.message); + normalizedErrors = innerErrors.map(innerError => + typeof innerError === "string" ? { message: innerError } : innerError + ); + } + + return normalizedErrors; +} + +function _getInnerErrors(message: string): any[] { + /* + The backend error message has an inner-message which is a stringified object. + For SQL errors, the "errors" property is an array of SqlErrorDataModel. + Example: + "Message: {"Errors":["Resource with specified id or name already exists"]}\r\nActivityId: 80005000008d40b6a, Request URI: /apps/19000c000c0a0005/services/mctestdocdbprod-MasterService-0-00066ab9937/partitions/900005f9000e676fb8/replicas/13000000000955p" + For non-SQL errors the "Errors" propery is an array of string. + Example: + "Message: {"errors":[{"severity":"Error","location":{"start":7,"end":8},"code":"SC1001","message":"Syntax error, incorrect syntax near '.'."}]}\r\nActivityId: d3300016d4084e310a, Request URI: /apps/12401f9e1df77/services/dc100232b1f44545/partitions/f86f3bc0001a2f78/replicas/13085003638s" + */ + + let innerMessage: any = null; + + const singleLineMessage = message.replace(/[\r\n]|\r|\n/g, ""); + try { + // Multi-Partition error flavor + const regExp = /^(.*)ActivityId: (.*)/g; + const regString = regExp.exec(singleLineMessage); + const innerMessageString = regString[1]; + innerMessage = JSON.parse(innerMessageString); + } catch (e) { + // Single-partition error flavor + const regExp = /^Message: (.*)ActivityId: (.*), Request URI: (.*)/g; + const regString = regExp.exec(singleLineMessage); + const innerMessageString = regString[1]; + innerMessage = JSON.parse(innerMessageString); + } + + return innerMessage.errors ? innerMessage.errors : innerMessage.Errors; +} + /** * Storage Table Entity List ViewModel */ @@ -387,8 +448,17 @@ export default class TableEntityListViewModel extends DataTableViewModel { } }) .catch((error: any) => { - const errorMessage = getErrorMessage(error); - this.queryErrorMessage(errorMessage); + const parsedErrors = parseError(error); + var errors = parsedErrors.map(error => { + return { + message: error.message, + start: error.location ? error.location.start : undefined, + end: error.location ? error.location.end : undefined, + code: error.code, + severity: error.severity + }; + }); + this.queryErrorMessage(errors[0].message); if (this.queryTablesTab.onLoadStartKey != null && this.queryTablesTab.onLoadStartKey != undefined) { TelemetryProcessor.traceFailure( Action.Tab, @@ -399,8 +469,7 @@ export default class TableEntityListViewModel extends DataTableViewModel { defaultExperience: this.queryTablesTab.collection.container.defaultExperience(), dataExplorerArea: Areas.Tab, tabTitle: this.queryTablesTab.tabTitle(), - error: errorMessage, - errorStack: getErrorStack(error) + error: error }, this.queryTablesTab.onLoadStartKey ); @@ -421,47 +490,53 @@ export default class TableEntityListViewModel extends DataTableViewModel { * Note that this also means that we can get less entities than the requested download size in a successful call. * See Microsoft Azure API Documentation at: https://msdn.microsoft.com/en-us/library/azure/dd135718.aspx */ - private async prefetchData( + private prefetchData( tableQuery: Entities.ITableQuery, downloadSize: number, currentRetry: number = 0 - ): Promise { + ): Q.Promise { if (!this.cache.serverCallInProgress) { this.cache.serverCallInProgress = true; this.allDownloaded = false; this.lastPrefetchTime = new Date().getTime(); - const time = this.lastPrefetchTime; + var time = this.lastPrefetchTime; + var promise: Q.Promise; if (this._documentIterator && this.continuationToken) { // TODO handle Cassandra case - const response = await this._documentIterator.fetchNext(); - const entities: Entities.ITableEntity[] = TableEntityProcessor.convertDocumentsToEntities(response?.resources); - return { - Results: entities, - ContinuationToken: this._documentIterator.hasMoreResults() - }; - } - - try { - let documents: IListTableEntitiesSegmentedResult; - if (this.continuationToken && this.queryTablesTab.container.isPreferredApiCassandra()) { - documents = await this.queryTablesTab.container.tableDataClient.queryDocuments( + promise = Q(this._documentIterator.fetchNext().then(response => response.resources)).then( + (documents: any[]) => { + let entities: Entities.ITableEntity[] = TableEntityProcessor.convertDocumentsToEntities(documents); + let finalEntities: IListTableEntitiesSegmentedResult = { + Results: entities, + ContinuationToken: this._documentIterator.hasMoreResults() + }; + return Q.resolve(finalEntities); + } + ); + } else if (this.continuationToken && this.queryTablesTab.container.isPreferredApiCassandra()) { + promise = Q( + this.queryTablesTab.container.tableDataClient.queryDocuments( this.queryTablesTab.collection, this.cqlQuery(), true, this.continuationToken - ); - } else { - const query = this.queryTablesTab.container.isPreferredApiCassandra() ? this.cqlQuery() : this.sqlQuery(); - documents = await this.queryTablesTab.container.tableDataClient.queryDocuments( - this.queryTablesTab.collection, - query, - true - ); - + ) + ); + } else { + let query = this.sqlQuery(); + if (this.queryTablesTab.container.isPreferredApiCassandra()) { + query = this.cqlQuery(); + } + promise = Q( + this.queryTablesTab.container.tableDataClient.queryDocuments(this.queryTablesTab.collection, query, true) + ); + } + return promise + .then((result: IListTableEntitiesSegmentedResult) => { if (!this._documentIterator) { - this._documentIterator = documents.iterator; + this._documentIterator = result.iterator; } var actualDownloadSize: number = 0; @@ -472,11 +547,11 @@ export default class TableEntityListViewModel extends DataTableViewModel { return Q.resolve(null); } - var entities = documents.Results; + var entities = result.Results; actualDownloadSize = entities.length; // Queries can fetch no results and still return a continuation header. See prefetchAndRender() method. - this.continuationToken = this.isCancelled ? null : documents.ContinuationToken; + this.continuationToken = this.isCancelled ? null : result.ContinuationToken; if (!this.continuationToken) { this.allDownloaded = true; @@ -508,22 +583,20 @@ export default class TableEntityListViewModel extends DataTableViewModel { // For #2.1, set prefetch exceeds maximum retry number and end prefetch. // For #2.2, go to next round prefetch. if (this.allDownloaded || nextDownloadSize === 0) { - return documents; + return Q.resolve(result); } if (currentRetry >= TableEntityListViewModel._maximumNumberOfPrefetchRetries) { - documents.ExceedMaximumRetries = true; - return documents; + result.ExceedMaximumRetries = true; + return Q.resolve(result); } - - return await this.prefetchData(tableQuery, nextDownloadSize, currentRetry + 1); - } - } catch (error) { - this.cache.serverCallInProgress = false; - throw error; - } + return this.prefetchData(tableQuery, nextDownloadSize, currentRetry + 1); + }) + .catch((error: Error) => { + this.cache.serverCallInProgress = false; + return Q.reject(error); + }); } - - return undefined; + return null; } } diff --git a/src/Explorer/Tables/Entities.ts b/src/Explorer/Tables/Entities.ts index 0972e8410..f5f3f3386 100644 --- a/src/Explorer/Tables/Entities.ts +++ b/src/Explorer/Tables/Entities.ts @@ -7,7 +7,7 @@ export interface ITableEntity { export interface ITableEntityForTablesAPI extends ITableEntity { PartitionKey: ITableEntityAttribute; RowKey: ITableEntityAttribute; - Timestamp?: ITableEntityAttribute; + Timestamp: ITableEntityAttribute; } export interface ITableEntityAttribute { diff --git a/src/Explorer/Tabs/QueryTab.html b/src/Explorer/Tabs/QueryTab.html index e27a4bb7c..ff4b40b06 100644 --- a/src/Explorer/Tabs/QueryTab.html +++ b/src/Explorer/Tabs/QueryTab.html @@ -11,6 +11,17 @@ Start by writing a Mongo query, for example: {'id':'foo'} or { } to get all the documents. +
+
+ Error + + We have detected you may be using a subquery. Non-correlated subqueries are not currently supported. + Please see Cosmos sub query documentation for further information + +
+
; + public maybeSubQuery: ko.Computed; public sqlQueryEditorContent: ko.Observable; public selectedContent: ko.Observable; public sqlStatementToExecute: ko.Observable; @@ -120,6 +119,11 @@ export default class QueryTab extends TabsBase implements ViewModels.WaitsForTem return (container && (container.isPreferredApiDocumentDB() || container.isPreferredApiGraph())) || false; }); + this.maybeSubQuery = ko.computed(function() { + const sql = this.sqlQueryEditorContent(); + return sql && /.*\(.*SELECT.*\)/i.test(sql); + }, this); + this.saveQueryButton = { enabled: this._isSaveQueriesEnabled, visible: this._isSaveQueriesEnabled diff --git a/src/Explorer/Tree/AccessibleVerticalList.ts b/src/Explorer/Tree/AccessibleVerticalList.ts index 818328a41..96db22df8 100644 --- a/src/Explorer/Tree/AccessibleVerticalList.ts +++ b/src/Explorer/Tree/AccessibleVerticalList.ts @@ -8,7 +8,7 @@ enum ScrollPosition { export class AccessibleVerticalList { private items: any[] = []; - private onSelect: (item: any) => void; + private onSelect?: (item: any) => void; public currentItemIndex: ko.Observable; public currentItem: ko.Computed; @@ -42,7 +42,9 @@ export class AccessibleVerticalList { const targetElement = targetContainer .getElementsByClassName("accessibleListElement") .item(this.currentItemIndex()); - this.scrollElementIntoContainerViewIfNeeded(targetElement, targetContainer, ScrollPosition.Top); + if (targetElement) { + this.scrollElementIntoContainerViewIfNeeded(targetElement, targetContainer, ScrollPosition.Top); + } return false; } if (event.keyCode === 40) { @@ -52,7 +54,9 @@ export class AccessibleVerticalList { const targetElement = targetContainer .getElementsByClassName("accessibleListElement") .item(this.currentItemIndex()); - this.scrollElementIntoContainerViewIfNeeded(targetElement, targetContainer, ScrollPosition.Bottom); + if (targetElement) { + this.scrollElementIntoContainerViewIfNeeded(targetElement, targetContainer, ScrollPosition.Top); + } return false; } return true; diff --git a/src/Shared/DefaultExperienceUtility.ts b/src/Shared/DefaultExperienceUtility.ts index 81550466e..4007296b0 100644 --- a/src/Shared/DefaultExperienceUtility.ts +++ b/src/Shared/DefaultExperienceUtility.ts @@ -4,7 +4,7 @@ import * as DataModels from "../Contracts/DataModels"; import { DefaultAccountExperienceType } from "../DefaultAccountExperienceType"; export class DefaultExperienceUtility { - public static getDefaultExperienceFromDatabaseAccount(databaseAccount: DataModels.DatabaseAccount): string { + public static getDefaultExperienceFromDatabaseAccount(databaseAccount: DataModels.DatabaseAccount): string | null { if (!databaseAccount) { return null; } @@ -81,11 +81,9 @@ export class DefaultExperienceUtility { private static _getDefaultExperience(kind: string, capabilities: DataModels.Capability[]): string { const defaultDefaultExperience: string = Constants.DefaultAccountExperience.DocumentDB; - const defaultExperienceFromKind: string = DefaultExperienceUtility._getDefaultExperienceFromAccountKind(kind); - const defaultExperienceFromCapabilities: string = DefaultExperienceUtility._getDefaultExperienceFromAccountCapabilities( - capabilities - ); - + const defaultExperienceFromKind: string = DefaultExperienceUtility._getDefaultExperienceFromAccountKind(kind) || ""; + const defaultExperienceFromCapabilities: string = + DefaultExperienceUtility._getDefaultExperienceFromAccountCapabilities(capabilities) || ""; if (!!defaultExperienceFromKind) { return defaultExperienceFromKind; } @@ -97,7 +95,7 @@ export class DefaultExperienceUtility { return defaultDefaultExperience; } - private static _getDefaultExperienceFromAccountKind(kind: string): string { + private static _getDefaultExperienceFromAccountKind(kind: string): string | null { if (!kind) { return null; } @@ -113,7 +111,7 @@ export class DefaultExperienceUtility { return null; } - private static _getDefaultExperienceFromAccountCapabilities(capabilities: DataModels.Capability[]): string { + private static _getDefaultExperienceFromAccountCapabilities(capabilities: DataModels.Capability[]): string | null { if (!capabilities) { return null; } diff --git a/src/Terminal/index.ts b/src/Terminal/index.ts index 7b49dde8b..3a7ae8913 100644 --- a/src/Terminal/index.ts +++ b/src/Terminal/index.ts @@ -19,8 +19,8 @@ const getUrlVars = (): { [key: string]: string } => { }; const createServerSettings = (urlVars: { [key: string]: string }): ServerConnection.ISettings => { - let body: BodyInit; - let headers: HeadersInit; + let body: BodyInit | undefined; + let headers: HeadersInit | undefined; if (urlVars.hasOwnProperty(TerminalQueryParams.TerminalEndpoint)) { body = JSON.stringify({ endpoint: urlVars[TerminalQueryParams.TerminalEndpoint] diff --git a/src/Utils/arm/request.test.ts b/src/Utils/arm/request.test.ts index 89b6d488a..6ed76093c 100644 --- a/src/Utils/arm/request.test.ts +++ b/src/Utils/arm/request.test.ts @@ -1,5 +1,7 @@ import { armRequest } from "./request"; import fetch from "node-fetch"; +import { updateUserContext } from "../../UserContext"; +import { AuthType } from "../../AuthType"; interface Global { Headers: unknown; @@ -8,6 +10,11 @@ interface Global { ((global as unknown) as Global).Headers = ((fetch as unknown) as Global).Headers; describe("ARM request", () => { + window.authType = AuthType.AAD; + updateUserContext({ + authorizationToken: "some-token" + }); + it("should call window.fetch", async () => { window.fetch = jest.fn().mockResolvedValue({ ok: true, @@ -48,4 +55,24 @@ describe("ARM request", () => { ).rejects.toThrow(); expect(window.fetch).toHaveBeenCalledTimes(2); }); + + it("should throw token error", async () => { + window.authType = AuthType.AAD; + updateUserContext({ + authorizationToken: undefined + }); + const headers = new Headers(); + headers.set("location", "https://foo.com/operationStatus"); + window.fetch = jest.fn().mockResolvedValue({ + ok: true, + headers, + status: 200, + json: async () => { + return { status: "Failed" }; + } + }); + await expect(() => + armRequest({ apiVersion: "2001-01-01", host: "https://foo.com", path: "foo", method: "GET" }) + ).rejects.toThrow("No authority token provided"); + }); }); diff --git a/src/Utils/arm/request.ts b/src/Utils/arm/request.ts index 23229d46b..429f69c51 100644 --- a/src/Utils/arm/request.ts +++ b/src/Utils/arm/request.ts @@ -30,7 +30,7 @@ export class ARMError extends Error { Object.setPrototypeOf(this, ARMError.prototype); } - public code: string | number; + public code?: string | number; } interface ARMQueryParams { @@ -63,6 +63,10 @@ export async function armRequest({ queryParams.metricNames && url.searchParams.append("metricnames", queryParams.metricNames); } + if (!userContext.authorizationToken) { + throw new Error("No authority token provided"); + } + const response = await window.fetch(url.href, { method, headers: { @@ -98,6 +102,10 @@ export async function armRequest({ } async function getOperationStatus(operationStatusUrl: string) { + if (!userContext.authorizationToken) { + throw new Error("No authority token provided"); + } + const response = await window.fetch(operationStatusUrl, { headers: { Authorization: userContext.authorizationToken diff --git a/src/hostedExplorer.html b/src/hostedExplorer.html index 6b1dfb202..07763f12d 100644 --- a/src/hostedExplorer.html +++ b/src/hostedExplorer.html @@ -7,6 +7,7 @@ +