From c1937ca464ba26e31529faee1d332091652a4c74 Mon Sep 17 00:00:00 2001 From: Srinath Narayanan Date: Tue, 19 Jan 2021 22:42:45 -0800 Subject: [PATCH] Added the Self Serve Data Model (#367) * added recursion and inition decorators * working version * added todo comment and removed console.log * Added Recursive add * removed type requirement * proper resolution of promises * added custom element and base class * Made selfServe standalone page * Added custom renderer as async type * Added overall defaults * added inital open from data explorer * removed landingpage * added feature for self serve type * renamed sqlx->example and added invalid type * Added comments for Example * removed unnecessary changes * Resolved PR comments Added tests Moved onSubmt and initialize inside base class Moved testExplorer to separate folder made fields of SelfServe Class non static * fixed lint errors * fixed compilation errors * Removed reactbinding changes * renamed dropdown -> choice * Added SelfServeComponent * Addressed PR comments * merged master * added selfservetype.none for emulator and hosted experience * fixed formatting errors * Removed "any" type * undid package.json changes --- babel.config.js | 3 +- package-lock.json | 39 +- package.json | 3 + src/Common/Constants.ts | 1 + src/Contracts/ViewModels.ts | 2 + .../FeaturePanel/FeaturePanelComponent.tsx | 1 + .../FeaturePanelComponent.test.tsx.snap | 14 +- .../SettingsComponent.test.tsx.snap | 52 + .../SmartUi/SmartUiComponent.test.tsx | 41 +- .../Controls/SmartUi/SmartUiComponent.tsx | 277 +- .../SmartUiComponent.test.tsx.snap | 269 +- src/Explorer/Explorer.ts | 6062 +++++++++-------- src/Main.tsx | 37 +- src/SelfServe/ClassDecorators.tsx | 14 + src/SelfServe/Example/SelfServeExample.tsx | 167 + src/SelfServe/PropertyDecorators.tsx | 101 + src/SelfServe/SelfServeComponent.test.tsx | 104 + src/SelfServe/SelfServeComponent.tsx | 218 + src/SelfServe/SelfServeComponentAdapter.tsx | 51 + .../SelfServeLoadingComponentAdapter.tsx | 25 + src/SelfServe/SelfServeUtils.test.tsx | 277 + src/SelfServe/SelfServeUtils.tsx | 183 + .../SelfServeComponent.test.tsx.snap | 168 + test/notebooks/notebookTestUtils.ts | 50 - test/notebooks/uploadAndOpenNotebook.spec.ts | 5 +- test/selfServe/selfServeExample.spec.ts | 27 + .../testExplorer/TestExplorer.ts | 10 +- .../testExplorer/TestExplorerParams.ts | 3 +- test/testExplorer/TestExplorerUtils.ts | 54 + .../testExplorer/testExplorer.html | 0 tsconfig.json | 4 +- webpack.config.js | 4 +- 32 files changed, 4944 insertions(+), 3322 deletions(-) create mode 100644 src/SelfServe/ClassDecorators.tsx create mode 100644 src/SelfServe/Example/SelfServeExample.tsx create mode 100644 src/SelfServe/PropertyDecorators.tsx create mode 100644 src/SelfServe/SelfServeComponent.test.tsx create mode 100644 src/SelfServe/SelfServeComponent.tsx create mode 100644 src/SelfServe/SelfServeComponentAdapter.tsx create mode 100644 src/SelfServe/SelfServeLoadingComponentAdapter.tsx create mode 100644 src/SelfServe/SelfServeUtils.test.tsx create mode 100644 src/SelfServe/SelfServeUtils.tsx create mode 100644 src/SelfServe/__snapshots__/SelfServeComponent.test.tsx.snap create mode 100644 test/selfServe/selfServeExample.spec.ts rename test/{notebooks => }/testExplorer/TestExplorer.ts (94%) rename test/{notebooks => }/testExplorer/TestExplorerParams.ts (81%) create mode 100644 test/testExplorer/TestExplorerUtils.ts rename test/{notebooks => }/testExplorer/testExplorer.html (100%) diff --git a/babel.config.js b/babel.config.js index 73cba71b1..54700a3aa 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,4 @@ module.exports = { - presets: [["@babel/preset-env", { targets: { node: "current" } }], "@babel/preset-react", "@babel/preset-typescript"] + presets: [["@babel/preset-env", { targets: { node: "current" } }], "@babel/preset-react", "@babel/preset-typescript"], + plugins: [["@babel/plugin-proposal-decorators", { legacy: true }]] }; diff --git a/package-lock.json b/package-lock.json index c9ba8dadd..275820720 100644 --- a/package-lock.json +++ b/package-lock.json @@ -156,9 +156,9 @@ }, "dependencies": { "qs": { - "version": "6.9.4", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz", - "integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ==" + "version": "6.9.6", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.6.tgz", + "integrity": "sha512-TIRk4aqYLNoJUbd+g2lEdz5kLWIuTMRagAXxl78Q0RiVjAOugHmeKNGdd3cwo/ktpf9aL9epCfFqWDEKysUlLQ==" } } }, @@ -403,7 +403,6 @@ "version": "7.12.1", "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.12.1.tgz", "integrity": "sha512-hkL++rWeta/OVOBTRJc9a5Azh5mt5WgZUGAKMD8JM141YsE08K//bp1unBBieO6rUKkIPyUE0USQ30jAy3Sk1w==", - "dev": true, "requires": { "@babel/helper-function-name": "^7.10.4", "@babel/helper-member-expression-to-functions": "^7.12.1", @@ -630,6 +629,25 @@ "@babel/plugin-syntax-async-generators": "^7.8.0" } }, + "@babel/plugin-proposal-class-properties": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.12.1.tgz", + "integrity": "sha512-cKp3dlQsFsEs5CWKnN7BnSHOd0EOW8EKpEjkoz1pO2E5KzIDNV9Ros1b0CnmbVgAGXJubOYVBOGCT1OmJwOI7w==", + "requires": { + "@babel/helper-create-class-features-plugin": "^7.12.1", + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-proposal-decorators": { + "version": "7.12.12", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.12.12.tgz", + "integrity": "sha512-fhkE9lJYpw2mjHelBpM2zCbaA11aov2GJs7q4cFaXNrWx0H3bW58H9Esy2rdtYOghFBEYUDRIpvlgi+ZD+AvvQ==", + "requires": { + "@babel/helper-create-class-features-plugin": "^7.12.1", + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/plugin-syntax-decorators": "^7.12.1" + } + }, "@babel/plugin-proposal-dynamic-import": { "version": "7.12.1", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.12.1.tgz", @@ -739,6 +757,14 @@ "@babel/helper-plugin-utils": "^7.10.4" } }, + "@babel/plugin-syntax-decorators": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.12.1.tgz", + "integrity": "sha512-ir9YW5daRrTYiy9UJ2TzdNIJEZu8KclVzDcfSt4iEmOtwQ4llPtWInNKJyKnVXp1vE4bbVd5S31M/im3mYMO1w==", + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, "@babel/plugin-syntax-dynamic-import": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", @@ -18467,6 +18493,11 @@ "resolved": "https://registry.npmjs.org/redux-observable/-/redux-observable-2.0.0-alpha.0.tgz", "integrity": "sha512-w0RsVGprIFiYi1AhFCOATiv3ld2AtuobvbcVsLvX19p8eAwLowWl2OrKYcCq/QEeEpmSHTXutXfVfcBnzaWmdw==" }, + "reflect-metadata": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", + "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==" + }, "reflect.ownkeys": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz", diff --git a/package.json b/package.json index d62c1d083..d9f1efa93 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "@azure/cosmos": "3.9.0", "@azure/cosmos-language-service": "0.0.5", "@azure/identity": "1.2.1", + "@babel/plugin-proposal-class-properties": "7.12.1", + "@babel/plugin-proposal-decorators": "7.12.12", "@jupyterlab/services": "6.0.0-rc.2", "@jupyterlab/terminal": "3.0.0-rc.2", "@microsoft/applicationinsights-web": "2.5.9", @@ -87,6 +89,7 @@ "react-notification-system": "0.2.17", "react-redux": "7.1.3", "redux": "4.0.4", + "reflect-metadata": "0.1.13", "rx-jupyter": "5.5.12", "rxjs": "6.6.3", "styled-components": "4.3.2", diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 2c4c49675..30d693903 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -119,6 +119,7 @@ export class Features { public static readonly enableSchema = "enableschema"; public static readonly enableSDKoperations = "enablesdkoperations"; public static readonly showMinRUSurvey = "showminrusurvey"; + public static readonly selfServeType = "selfservetype"; } // flight names returned from the portal are always lowercase diff --git a/src/Contracts/ViewModels.ts b/src/Contracts/ViewModels.ts index c98243586..604b7af11 100644 --- a/src/Contracts/ViewModels.ts +++ b/src/Contracts/ViewModels.ts @@ -15,6 +15,7 @@ import DocumentId from "../Explorer/Tree/DocumentId"; import StoredProcedure from "../Explorer/Tree/StoredProcedure"; import Trigger from "../Explorer/Tree/Trigger"; import UserDefinedFunction from "../Explorer/Tree/UserDefinedFunction"; +import { SelfServeType } from "../SelfServe/SelfServeUtils"; import { UploadDetails } from "../workers/upload/definitions"; import * as DataModels from "./DataModels"; import { SubscriptionType } from "./SubscriptionType"; @@ -395,6 +396,7 @@ export interface DataExplorerInputsFrame { isAuthWithresourceToken?: boolean; defaultCollectionThroughput?: CollectionCreationDefaults; flights?: readonly string[]; + selfServeType?: SelfServeType; } export interface CollectionCreationDefaults { diff --git a/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx b/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx index cb41340cc..41f4ff001 100644 --- a/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx +++ b/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx @@ -48,6 +48,7 @@ export const FeaturePanelComponent: React.FunctionComponent = () => { { key: "feature.hosteddataexplorerenabled", label: "Hosted Data Explorer (deprecated?)", value: "true" }, { key: "feature.enablettl", label: "Enable TTL", value: "true" }, { key: "feature.enablegallerypublish", label: "Enable Notebook Gallery Publishing", value: "true" }, + { key: "feature.selfServeType", label: "Self serve feature", value: "sample" }, { key: "feature.enableLinkInjection", label: "Enable Injecting Notebook Viewer Link into the first cell", diff --git a/src/Explorer/Controls/FeaturePanel/__snapshots__/FeaturePanelComponent.test.tsx.snap b/src/Explorer/Controls/FeaturePanel/__snapshots__/FeaturePanelComponent.test.tsx.snap index 3a47b12eb..b96478515 100644 --- a/src/Explorer/Controls/FeaturePanel/__snapshots__/FeaturePanelComponent.test.tsx.snap +++ b/src/Explorer/Controls/FeaturePanel/__snapshots__/FeaturePanelComponent.test.tsx.snap @@ -157,14 +157,14 @@ exports[`Feature panel renders all flags 1`] = ` /> @@ -172,6 +172,12 @@ exports[`Feature panel renders all flags 1`] = ` className="checkboxRow" horizontalAlign="space-between" > + { - const exampleData: Descriptor = { + const exampleData: SmartUiDescriptor = { root: { id: "root", info: { @@ -24,7 +24,7 @@ describe("SmartUiComponent", () => { max: 500, step: 10, defaultValue: 400, - inputType: "spin" + uiType: UiType.Spinner } }, { @@ -37,7 +37,21 @@ describe("SmartUiComponent", () => { max: 500, step: 10, defaultValue: 400, - inputType: "slider" + uiType: UiType.Slider + } + }, + { + id: "throughput3", + input: { + label: "Throughput (invalid)", + dataFieldName: "throughput3", + type: "boolean", + min: 400, + max: 500, + step: 10, + defaultValue: 400, + uiType: UiType.Spinner, + errorMessage: "label, truelabel and falselabel are required for boolean input 'throughput3'" } }, { @@ -64,11 +78,11 @@ describe("SmartUiComponent", () => { input: { label: "Database", dataFieldName: "database", - type: "enum", + type: "object", choices: [ - { label: "Database 1", key: "db1", value: "database1" }, - { label: "Database 2", key: "db2", value: "database2" }, - { label: "Database 3", key: "db3", value: "database3" } + { label: "Database 1", key: "db1" }, + { label: "Database 2", key: "db2" }, + { label: "Database 3", key: "db3" } ], defaultKey: "db2" } @@ -77,10 +91,11 @@ describe("SmartUiComponent", () => { } }; - const exampleCallbacks = (): void => undefined; - - it("should render", () => { - const wrapper = shallow(); + it("should render", async () => { + const wrapper = shallow( + + ); + await new Promise(resolve => setTimeout(resolve, 0)); expect(wrapper).toMatchSnapshot(); }); }); diff --git a/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx b/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx index bb218c7a3..619c09bbc 100644 --- a/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx +++ b/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx @@ -5,11 +5,9 @@ import { SpinButton } from "office-ui-fabric-react/lib/SpinButton"; import { Dropdown, IDropdownOption } from "office-ui-fabric-react/lib/Dropdown"; import { TextField } from "office-ui-fabric-react/lib/TextField"; import { Text } from "office-ui-fabric-react/lib/Text"; -import { InputType } from "../../Tables/Constants"; import { RadioSwitchComponent } from "../RadioSwitchComponent/RadioSwitchComponent"; import { Stack, IStackTokens } from "office-ui-fabric-react/lib/Stack"; import { Link, MessageBar, MessageBarType } from "office-ui-fabric-react"; - import * as InputUtils from "./InputUtils"; import "./SmartUiComponent.less"; @@ -21,45 +19,16 @@ import "./SmartUiComponent.less"; * - a descriptor of the UX. */ -export type InputTypeValue = "number" | "string" | "boolean" | "enum"; +export type InputTypeValue = "number" | "string" | "boolean" | "object"; -/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ -export type EnumItem = { label: string; key: string; value: any }; - -export type InputType = number | string | boolean | EnumItem; - -interface BaseInput { - label: string; - dataFieldName: string; - type: InputTypeValue; - placeholder?: string; +export enum UiType { + Spinner = "Spinner", + Slider = "Slider" } -/** - * For now, this only supports integers - */ -export interface NumberInput extends BaseInput { - min?: number; - max?: number; - step: number; - defaultValue: number; - inputType: "spin" | "slider"; -} +export type ChoiceItem = { label: string; key: string }; -export interface BooleanInput extends BaseInput { - trueLabel: string; - falseLabel: string; - defaultValue: boolean; -} - -export interface StringInput extends BaseInput { - defaultValue?: string; -} - -export interface EnumInput extends BaseInput { - choices: EnumItem[]; - defaultKey: string; -} +export type InputType = number | string | boolean | ChoiceItem; export interface Info { message: string; @@ -69,28 +38,62 @@ export interface Info { }; } -export type AnyInput = NumberInput | BooleanInput | StringInput | EnumInput; +interface BaseInput { + label: string; + dataFieldName: string; + type: InputTypeValue; + placeholder?: string; + errorMessage?: string; +} -export interface Node { +/** + * For now, this only supports integers + */ +interface NumberInput extends BaseInput { + min: number; + max: number; + step: number; + defaultValue?: number; + uiType: UiType; +} + +interface BooleanInput extends BaseInput { + trueLabel: string; + falseLabel: string; + defaultValue?: boolean; +} + +interface StringInput extends BaseInput { + defaultValue?: string; +} + +interface ChoiceInput extends BaseInput { + choices: ChoiceItem[]; + defaultKey?: string; +} + +type AnyInput = NumberInput | BooleanInput | StringInput | ChoiceInput; + +interface Node { id: string; info?: Info; input?: AnyInput; children?: Node[]; } -export interface Descriptor { +export interface SmartUiDescriptor { root: Node; } /************************** Component implementation starts here ************************************* */ export interface SmartUiComponentProps { - descriptor: Descriptor; - onChange: (newValues: Map) => void; + descriptor: SmartUiDescriptor; + currentValues: Map; + onInputChange: (input: AnyInput, newValue: InputType) => void; } interface SmartUiComponentState { - currentValues: Map; errors: Map; } @@ -104,7 +107,6 @@ export class SmartUiComponent extends React.Component {info.message} - - {info.link.text} - + {info.link && ( + + {info.link.text} + + )} ); } - private onInputChange = (newValue: string | number | boolean, dataFieldName: string) => { - const { currentValues } = this.state; - currentValues.set(dataFieldName, newValue); - this.setState({ currentValues }, () => this.props.onChange(this.state.currentValues)); - }; - - private renderStringInput(input: StringInput): JSX.Element { + private renderTextInput(input: StringInput): JSX.Element { + const value = this.props.currentValues.get(input.dataFieldName) as string; return (
-
- this.onInputChange(newValue, input.dataFieldName)} - styles={{ - subComponentStyles: { - label: { - root: { - ...SmartUiComponent.labelStyle, - fontWeight: 600 - } + this.props.onInputChange(input, newValue)} + styles={{ + subComponentStyles: { + label: { + root: { + ...SmartUiComponent.labelStyle, + fontWeight: 600 } } - }} - /> -
+ } + }} + />
); } @@ -159,10 +156,11 @@ export class SmartUiComponent extends React.Component { + private onValidate = (input: AnyInput, value: string, min: number, max: number): string => { const newValue = InputUtils.onValidateValueChange(value, min, max); + const dataFieldName = input.dataFieldName; if (newValue) { - this.onInputChange(newValue, dataFieldName); + this.props.onInputChange(input, newValue); this.clearError(dataFieldName); return newValue.toString(); } else { @@ -173,20 +171,22 @@ export class SmartUiComponent extends React.Component { + private onIncrement = (input: AnyInput, value: string, step: number, max: number): string => { const newValue = InputUtils.onIncrementValue(value, step, max); + const dataFieldName = input.dataFieldName; if (newValue) { - this.onInputChange(newValue, dataFieldName); + this.props.onInputChange(input, newValue); this.clearError(dataFieldName); return newValue.toString(); } return undefined; }; - private onDecrement = (value: string, step: number, min: number, dataFieldName: string): string => { + private onDecrement = (input: AnyInput, value: string, step: number, min: number): string => { const newValue = InputUtils.onDecrementValue(value, step, min); + const dataFieldName = input.dataFieldName; if (newValue) { - this.onInputChange(newValue, dataFieldName); + this.props.onInputChange(input, newValue); this.clearError(dataFieldName); return newValue.toString(); } @@ -194,18 +194,26 @@ export class SmartUiComponent extends React.Component + <> this.onValidate(newValue, min, max, dataFieldName)} - onIncrement={newValue => this.onIncrement(newValue, step, max, dataFieldName)} - onDecrement={newValue => this.onDecrement(newValue, step, min, dataFieldName)} + id={`${input.dataFieldName}-spinner-input`} + value={value?.toString()} + onValidate={newValue => this.onValidate(input, newValue, props.min, props.max)} + onIncrement={newValue => this.onIncrement(input, newValue, props.step, props.max)} + onDecrement={newValue => this.onDecrement(input, newValue, props.step, props.min)} labelPosition={Position.top} styles={{ label: { @@ -217,34 +225,35 @@ export class SmartUiComponent extends React.ComponentError: {this.state.errors.get(dataFieldName)} )} + + ); + } else if (input.uiType === UiType.Slider) { + return ( +
+ this.props.onInputChange(input, newValue)} + styles={{ + titleLabel: { + ...SmartUiComponent.labelStyle, + fontWeight: 600 + }, + valueLabel: SmartUiComponent.labelStyle + }} + />
); - } else if (input.inputType === "slider") { - return ( - this.onInputChange(newValue, dataFieldName)} - styles={{ - titleLabel: { - ...SmartUiComponent.labelStyle, - fontWeight: 600 - }, - valueLabel: SmartUiComponent.labelStyle - }} - /> - ); } else { - return <>Unsupported number input type {input.inputType}; + return <>Unsupported number UI type {input.uiType}; } } private renderBooleanInput(input: BooleanInput): JSX.Element { - const { dataFieldName } = input; + const value = this.props.currentValues.get(input.dataFieldName) as boolean; + const selectedKey = value || input.defaultValue ? "true" : "false"; return ( -
+
{input.label} @@ -255,41 +264,33 @@ export class SmartUiComponent extends React.Component this.onInputChange(false, dataFieldName) + onSelect: () => this.props.onInputChange(input, false) }, { label: input.trueLabel, key: "true", - onSelect: () => this.onInputChange(true, dataFieldName) + onSelect: () => this.props.onInputChange(input, true) } ]} - selectedKey={ - (this.state.currentValues.has(dataFieldName) - ? (this.state.currentValues.get(dataFieldName) as boolean) - : input.defaultValue) - ? "true" - : "false" - } + selectedKey={selectedKey} />
); } - private renderEnumInput(input: EnumInput): JSX.Element { - const { label, defaultKey, dataFieldName, choices, placeholder } = input; + private renderChoiceInput(input: ChoiceInput): JSX.Element { + const { label, defaultKey: defaultKey, dataFieldName, choices, placeholder } = input; + const value = this.props.currentValues.get(dataFieldName) as string; return ( this.onInputChange(item.key.toString(), dataFieldName)} + selectedKey={value ? value : defaultKey} + onChange={(_, item: IDropdownOption) => this.props.onInputChange(input, item.key.toString())} placeholder={placeholder} options={choices.map(c => ({ key: c.key, - text: c.value + text: c.label }))} styles={{ label: { @@ -302,34 +303,48 @@ export class SmartUiComponent extends React.ComponentError: {input.errorMessage}; + } + private renderInput(input: AnyInput): JSX.Element { + if (input.errorMessage) { + return this.renderError(input); + } switch (input.type) { case "string": - return this.renderStringInput(input as StringInput); + return this.renderTextInput(input as StringInput); case "number": return this.renderNumberInput(input as NumberInput); case "boolean": return this.renderBooleanInput(input as BooleanInput); - case "enum": - return this.renderEnumInput(input as EnumInput); + case "object": + return this.renderChoiceInput(input as ChoiceInput); default: throw new Error(`Unknown input type: ${input.type}`); } } private renderNode(node: Node): JSX.Element { - const containerStackTokens: IStackTokens = { childrenGap: 10 }; + const containerStackTokens: IStackTokens = { childrenGap: 15 }; return ( - {node.info && this.renderInfo(node.info)} - {node.input && this.renderInput(node.input)} + + {node.info && this.renderInfo(node.info as Info)} + {node.input && this.renderInput(node.input)} + {node.children && node.children.map(child =>
{this.renderNode(child)}
)}
); } render(): JSX.Element { - return <>{this.renderNode(this.props.descriptor.root)}; + const containerStackTokens: IStackTokens = { childrenGap: 20 }; + return ( + + {this.renderNode(this.props.descriptor.root)} + + ); } } diff --git a/src/Explorer/Controls/SmartUi/__snapshots__/SmartUiComponent.test.tsx.snap b/src/Explorer/Controls/SmartUi/__snapshots__/SmartUiComponent.test.tsx.snap index 1e7c4d261..b8efc28e3 100644 --- a/src/Explorer/Controls/SmartUi/__snapshots__/SmartUiComponent.test.tsx.snap +++ b/src/Explorer/Controls/SmartUi/__snapshots__/SmartUiComponent.test.tsx.snap @@ -1,24 +1,40 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`SmartUiComponent should render 1`] = ` - + - - Start at $24/mo per database - - More Details - - + + + Start at $24/mo per database + + More Details + + +
@@ -26,11 +42,11 @@ exports[`SmartUiComponent should render 1`] = ` className="widgetRendererContainer" tokens={ Object { - "childrenGap": 10, + "childrenGap": 15, } } > -
+ -
+
- +
+ +
+ + +
+
+ + } + > + + + Error: + label, truelabel and falselabel are required for boolean input 'throughput3' + +
-
-
+ +
-
+
-
+
- - Analytical Store - + + Analytical Store + +
+
- -
+
- + + selectedKey="db2" + styles={ + Object { + "dropdown": Object { + "color": "#393939", + "fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif", + "fontSize": 12, + }, + "label": Object { + "color": "#393939", + "fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + }, + } + } + /> +
-
+ `; diff --git a/src/Explorer/Explorer.ts b/src/Explorer/Explorer.ts index a3965fe3b..4aaa767d9 100644 --- a/src/Explorer/Explorer.ts +++ b/src/Explorer/Explorer.ts @@ -1,2989 +1,3073 @@ -import * as ComponentRegisterer from "./ComponentRegisterer"; -import * as Constants from "../Common/Constants"; -import * as DataModels from "../Contracts/DataModels"; -import * as ko from "knockout"; -import * as MostRecentActivity from "./MostRecentActivity/MostRecentActivity"; -import * as path from "path"; -import * as SharedConstants from "../Shared/Constants"; -import * as ViewModels from "../Contracts/ViewModels"; -import _ from "underscore"; -import AddCollectionPane from "./Panes/AddCollectionPane"; -import AddDatabasePane from "./Panes/AddDatabasePane"; -import AddTableEntityPane from "./Panes/Tables/AddTableEntityPane"; -import AuthHeadersUtil from "../Platform/Hosted/Authorization"; -import CassandraAddCollectionPane from "./Panes/CassandraAddCollectionPane"; -import Database from "./Tree/Database"; -import DeleteCollectionConfirmationPane from "./Panes/DeleteCollectionConfirmationPane"; -import DeleteDatabaseConfirmationPane from "./Panes/DeleteDatabaseConfirmationPane"; -import { readCollection } from "../Common/dataAccess/readCollection"; -import { readDatabases } from "../Common/dataAccess/readDatabases"; -import EditTableEntityPane from "./Panes/Tables/EditTableEntityPane"; -import { normalizeArmEndpoint } from "../Common/EnvironmentUtility"; -import GraphStylingPane from "./Panes/GraphStylingPane"; -import hasher from "hasher"; -import NewVertexPane from "./Panes/NewVertexPane"; -import NotebookV2Tab, { NotebookTabOptions } from "./Tabs/NotebookV2Tab"; -import Q from "q"; -import ResourceTokenCollection from "./Tree/ResourceTokenCollection"; -import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor"; -import TerminalTab from "./Tabs/TerminalTab"; -import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants"; -import { ActionContracts, MessageTypes } from "../Contracts/ExplorerContracts"; -import { ArcadiaResourceManager } from "../SparkClusterManager/ArcadiaResourceManager"; -import { ArcadiaWorkspaceItem } from "./Controls/Arcadia/ArcadiaMenuPicker"; -import { AuthType } from "../AuthType"; -import { BindingHandlersRegisterer } from "../Bindings/BindingHandlersRegisterer"; -import { BrowseQueriesPane } from "./Panes/BrowseQueriesPane"; -import { CassandraAPIDataClient, TableDataClient, TablesAPIDataClient } from "./Tables/TableDataClient"; -import { CommandBarComponentAdapter } from "./Menus/CommandBar/CommandBarComponentAdapter"; -import { configContext, Platform, updateConfigContext } from "../ConfigContext"; -import { ConsoleData, ConsoleDataType } from "./Menus/NotificationConsole/NotificationConsoleComponent"; -import { decryptJWTToken, getAuthorizationHeader } from "../Utils/AuthorizationUtils"; -import { DefaultExperienceUtility } from "../Shared/DefaultExperienceUtility"; -import { DialogComponentAdapter } from "./Controls/DialogReactComponent/DialogComponentAdapter"; -import { DialogProps, TextFieldProps } from "./Controls/DialogReactComponent/DialogComponent"; -import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane"; -import { ExplorerMetrics } from "../Common/Constants"; -import { ExplorerSettings } from "../Shared/ExplorerSettings"; -import { FileSystemUtil } from "./Notebook/FileSystemUtil"; -import { handleOpenAction } from "./OpenActions"; -import { isInvalidParentFrameOrigin } from "../Utils/MessageValidation"; -import { IGalleryItem } from "../Juno/JunoClient"; -import { LoadQueryPane } from "./Panes/LoadQueryPane"; -import * as Logger from "../Common/Logger"; -import { sendMessage, sendCachedDataMessage, handleCachedDataMessage } from "../Common/MessageHandler"; -import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem"; -import { NotebookUtil } from "./Notebook/NotebookUtil"; -import { NotebookWorkspaceManager } from "../NotebookWorkspaceManager/NotebookWorkspaceManager"; -import { NotificationConsoleComponentAdapter } from "./Menus/NotificationConsole/NotificationConsoleComponentAdapter"; -import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils"; -import { QueriesClient } from "../Common/QueriesClient"; -import { QuerySelectPane } from "./Panes/Tables/QuerySelectPane"; -import { RenewAdHocAccessPane } from "./Panes/RenewAdHocAccessPane"; -import { ResourceProviderClientFactory } from "../ResourceProvider/ResourceProviderClientFactory"; -import { ResourceTreeAdapter } from "./Tree/ResourceTreeAdapter"; -import { ResourceTreeAdapterForResourceToken } from "./Tree/ResourceTreeAdapterForResourceToken"; -import { RouteHandler } from "../RouteHandlers/RouteHandler"; -import { SaveQueryPane } from "./Panes/SaveQueryPane"; -import { SettingsPane } from "./Panes/SettingsPane"; -import { SetupNotebooksPane } from "./Panes/SetupNotebooksPane"; -import { SplashScreenComponentAdapter } from "./SplashScreen/SplashScreenComponentApdapter"; -import { Splitter, SplitterBounds, SplitterDirection } from "../Common/Splitter"; -import { StringInputPane } from "./Panes/StringInputPane"; -import { TableColumnOptionsPane } from "./Panes/Tables/TableColumnOptionsPane"; -import { TabsManager } from "./Tabs/TabsManager"; -import { UploadFilePane } from "./Panes/UploadFilePane"; -import { UploadItemsPane } from "./Panes/UploadItemsPane"; -import { UploadItemsPaneAdapter } from "./Panes/UploadItemsPaneAdapter"; -import { ReactAdapter } from "../Bindings/ReactBindingHandler"; -import { toRawContentUri, fromContentUri } from "../Utils/GitHubUtils"; -import UserDefinedFunction from "./Tree/UserDefinedFunction"; -import StoredProcedure from "./Tree/StoredProcedure"; -import Trigger from "./Tree/Trigger"; -import { ContextualPaneBase } from "./Panes/ContextualPaneBase"; -import TabsBase from "./Tabs/TabsBase"; -import { CommandButtonComponentProps } from "./Controls/CommandButton/CommandButtonComponent"; -import { updateUserContext, userContext } from "../UserContext"; -import { stringToBlob } from "../Utils/BlobUtils"; -import { IChoiceGroupProps } from "office-ui-fabric-react"; -import { getErrorMessage, handleError, getErrorStack } from "../Common/ErrorHandlingUtils"; -import { SubscriptionType } from "../Contracts/SubscriptionType"; - -BindingHandlersRegisterer.registerBindingHandlers(); -// Hold a reference to ComponentRegisterer to prevent transpiler to ignore import -var tmp = ComponentRegisterer; - -enum ShareAccessToggleState { - ReadWrite, - Read -} - -interface AdHocAccessData { - readWriteUrl: string; - readUrl: string; -} - -export default class Explorer { - public flight: ko.Observable = ko.observable( - SharedConstants.CollectionCreation.DefaultAddCollectionDefaultFlight - ); - - public addCollectionText: ko.Observable; - public addDatabaseText: ko.Observable; - public collectionTitle: ko.Observable; - public deleteCollectionText: ko.Observable; - public deleteDatabaseText: ko.Observable; - public collectionTreeNodeAltText: ko.Observable; - public refreshTreeTitle: ko.Observable; - public hasWriteAccess: ko.Observable; - public collapsedResourceTreeWidth: number = ExplorerMetrics.CollapsedResourceTreeWidth; - - public databaseAccount: ko.Observable; - public collectionCreationDefaults: ViewModels.CollectionCreationDefaults = SharedConstants.CollectionCreationDefaults; - public subscriptionType: ko.Observable; - public defaultExperience: ko.Observable; - public isPreferredApiDocumentDB: ko.Computed; - public isPreferredApiCassandra: ko.Computed; - public isPreferredApiMongoDB: ko.Computed; - public isPreferredApiGraph: ko.Computed; - public isPreferredApiTable: ko.Computed; - public isFixedCollectionWithSharedThroughputSupported: ko.Computed; - public isEnableMongoCapabilityPresent: ko.Computed; - public isServerlessEnabled: ko.Computed; - public isAccountReady: ko.Observable; - public canSaveQueries: ko.Computed; - public features: ko.Observable; - public serverId: ko.Observable; - public isTryCosmosDBSubscription: ko.Observable; - public queriesClient: QueriesClient; - public tableDataClient: TableDataClient; - public splitter: Splitter; - public mostRecentActivity: MostRecentActivity.MostRecentActivity; - - // Notification Console - public notificationConsoleData: ko.ObservableArray; - public isNotificationConsoleExpanded: ko.Observable; - - // Panes - public contextPanes: ContextualPaneBase[]; - - // Resource Tree - public databases: ko.ObservableArray; - public nonSystemDatabases: ko.Computed; - public selectedDatabaseId: ko.Computed; - public selectedCollectionId: ko.Computed; - public isLeftPaneExpanded: ko.Observable; - public selectedNode: ko.Observable; - public isRefreshingExplorer: ko.Observable; - private resourceTree: ResourceTreeAdapter; - - // Resource Token - public resourceTokenDatabaseId: ko.Observable; - public resourceTokenCollectionId: ko.Observable; - public resourceTokenCollection: ko.Observable; - public resourceTokenPartitionKey: ko.Observable; - public isAuthWithResourceToken: ko.Observable; - public isResourceTokenCollectionNodeSelected: ko.Computed; - private resourceTreeForResourceToken: ResourceTreeAdapterForResourceToken; - - // Tabs - public isTabsContentExpanded: ko.Observable; - public galleryTab: any; - public notebookViewerTab: any; - public tabsManager: TabsManager; - - // Contextual panes - public addDatabasePane: AddDatabasePane; - public addCollectionPane: AddCollectionPane; - public deleteCollectionConfirmationPane: DeleteCollectionConfirmationPane; - public deleteDatabaseConfirmationPane: DeleteDatabaseConfirmationPane; - public graphStylingPane: GraphStylingPane; - public addTableEntityPane: AddTableEntityPane; - public editTableEntityPane: EditTableEntityPane; - public tableColumnOptionsPane: TableColumnOptionsPane; - public querySelectPane: QuerySelectPane; - public newVertexPane: NewVertexPane; - public cassandraAddCollectionPane: CassandraAddCollectionPane; - public settingsPane: SettingsPane; - public executeSprocParamsPane: ExecuteSprocParamsPane; - public renewAdHocAccessPane: RenewAdHocAccessPane; - public uploadItemsPane: UploadItemsPane; - public uploadItemsPaneAdapter: UploadItemsPaneAdapter; - public loadQueryPane: LoadQueryPane; - public saveQueryPane: ContextualPaneBase; - public browseQueriesPane: BrowseQueriesPane; - public uploadFilePane: UploadFilePane; - public stringInputPane: StringInputPane; - public setupNotebooksPane: SetupNotebooksPane; - public gitHubReposPane: ContextualPaneBase; - public publishNotebookPaneAdapter: ReactAdapter; - public copyNotebookPaneAdapter: ReactAdapter; - - // features - public isGalleryPublishEnabled: ko.Computed; - public isLinkInjectionEnabled: ko.Computed; - public isGitHubPaneEnabled: ko.Observable; - public isPublishNotebookPaneEnabled: ko.Observable; - public isCopyNotebookPaneEnabled: ko.Observable; - public isHostedDataExplorerEnabled: ko.Computed; - public isRightPanelV2Enabled: ko.Computed; - public isMongoIndexingEnabled: ko.Observable; - public canExceedMaximumValue: ko.Computed; - public isAutoscaleDefaultEnabled: ko.Observable; - - public shouldShowShareDialogContents: ko.Observable; - public shareAccessData: ko.Observable; - public renewExplorerShareAccess: (explorer: Explorer, token: string) => Q.Promise; - public shareAccessToggleState: ko.Observable; - public shareAccessUrl: ko.Observable; - public shareUrlCopyHelperText: ko.Observable; - public shareTokenCopyHelperText: ko.Observable; - public shouldShowDataAccessExpiryDialog: ko.Observable; - public shouldShowContextSwitchPrompt: ko.Observable; - public isSchemaEnabled: ko.Computed; - - // Notebooks - public isNotebookEnabled: ko.Observable; - public isNotebooksEnabledForAccount: ko.Observable; - public notebookServerInfo: ko.Observable; - public notebookWorkspaceManager: NotebookWorkspaceManager; - public sparkClusterConnectionInfo: ko.Observable; - public isSparkEnabled: ko.Observable; - public isSparkEnabledForAccount: ko.Observable; - public arcadiaToken: ko.Observable; - public arcadiaWorkspaces: ko.ObservableArray; - public hasStorageAnalyticsAfecFeature: ko.Observable; - public isSynapseLinkUpdating: ko.Observable; - public memoryUsageInfo: ko.Observable; - public notebookManager?: any; // This is dynamically loaded - - private _panes: ContextualPaneBase[] = []; - private _importExplorerConfigComplete: boolean = false; - private _isSystemDatabasePredicate: (database: ViewModels.Database) => boolean = database => false; - private _isInitializingNotebooks: boolean; - private _isInitializingSparkConnectionInfo: boolean; - private notebookBasePath: ko.Observable; - private _arcadiaManager: ArcadiaResourceManager; - private notebookToImport: { - name: string; - content: string; - }; - - // React adapters - private commandBarComponentAdapter: CommandBarComponentAdapter; - private splashScreenAdapter: SplashScreenComponentAdapter; - private notificationConsoleComponentAdapter: NotificationConsoleComponentAdapter; - private dialogComponentAdapter: DialogComponentAdapter; - private _dialogProps: ko.Observable; - private addSynapseLinkDialog: DialogComponentAdapter; - private _addSynapseLinkDialogProps: ko.Observable; - - private static readonly MaxNbDatabasesToAutoExpand = 5; - - constructor() { - const startKey: number = TelemetryProcessor.traceStart(Action.InitializeDataExplorer, { - dataExplorerArea: Constants.Areas.ResourceTree - }); - this.addCollectionText = ko.observable("New Collection"); - this.addDatabaseText = ko.observable("New Database"); - this.hasWriteAccess = ko.observable(true); - this.collectionTitle = ko.observable("Collections"); - this.collectionTreeNodeAltText = ko.observable("Collection"); - this.deleteCollectionText = ko.observable("Delete Collection"); - this.deleteDatabaseText = ko.observable("Delete Database"); - this.refreshTreeTitle = ko.observable("Refresh collections"); - - this.databaseAccount = ko.observable(); - this.subscriptionType = ko.observable(SharedConstants.CollectionCreation.DefaultSubscriptionType); - let firstInitialization = true; - this.isRefreshingExplorer = ko.observable(true); - this.isRefreshingExplorer.subscribe((isRefreshing: boolean) => { - if (!isRefreshing && firstInitialization) { - // set focus on first element - firstInitialization = false; - try { - document.getElementById("createNewContainerCommandButton").parentElement.parentElement.focus(); - } catch (e) { - Logger.logWarning( - "getElementById('createNewContainerCommandButton') failed to find element", - "Explorer/this.isRefreshingExplorer.subscribe" - ); - } - } - }); - this.isAccountReady = ko.observable(false); - this._isInitializingNotebooks = false; - this._isInitializingSparkConnectionInfo = false; - this.arcadiaToken = ko.observable(); - this.arcadiaToken.subscribe((token: string) => { - if (token) { - const notebookTabs = this.tabsManager.getTabs(ViewModels.CollectionTabKind.NotebookV2); - (notebookTabs || []).forEach((tab: NotebookV2Tab) => { - tab.reconfigureServiceEndpoints(); - }); - } - }); - this.isNotebooksEnabledForAccount = ko.observable(false); - this.isNotebooksEnabledForAccount.subscribe((isEnabledForAccount: boolean) => this.refreshCommandBarButtons()); - this.isSparkEnabledForAccount = ko.observable(false); - this.isSparkEnabledForAccount.subscribe((isEnabledForAccount: boolean) => this.refreshCommandBarButtons()); - this.hasStorageAnalyticsAfecFeature = ko.observable(false); - this.hasStorageAnalyticsAfecFeature.subscribe((enabled: boolean) => this.refreshCommandBarButtons()); - this.isSynapseLinkUpdating = ko.observable(false); - this.isAccountReady.subscribe(async (isAccountReady: boolean) => { - if (isAccountReady) { - this.isAuthWithResourceToken() ? await this.refreshDatabaseForResourceToken() : this.refreshAllDatabases(true); - RouteHandler.getInstance().initHandler(); - this.notebookWorkspaceManager = new NotebookWorkspaceManager(); - this.arcadiaWorkspaces = ko.observableArray(); - this._arcadiaManager = new ArcadiaResourceManager(); - this._isAfecFeatureRegistered(Constants.AfecFeatures.StorageAnalytics).then(isRegistered => - this.hasStorageAnalyticsAfecFeature(isRegistered) - ); - Promise.all([this._refreshNotebooksEnabledStateForAccount(), this._refreshSparkEnabledStateForAccount()]).then( - async () => { - this.isNotebookEnabled( - !this.isAuthWithResourceToken() && - ((await this._containsDefaultNotebookWorkspace(this.databaseAccount())) || - this.isFeatureEnabled(Constants.Features.enableNotebooks)) - ); - - TelemetryProcessor.trace(Action.NotebookEnabled, ActionModifiers.Mark, { - isNotebookEnabled: this.isNotebookEnabled(), - databaseAccountName: this.databaseAccount() && this.databaseAccount().name, - defaultExperience: this.defaultExperience && this.defaultExperience(), - dataExplorerArea: Constants.Areas.Notebook - }); - - if (this.isNotebookEnabled()) { - await this.initNotebooks(this.databaseAccount()); - const workspaces = await this._getArcadiaWorkspaces(); - this.arcadiaWorkspaces(workspaces); - } else if (this.notebookToImport) { - // if notebooks is not enabled but the user is trying to do a quickstart setup with notebooks, open the SetupNotebooksPane - this._openSetupNotebooksPaneForQuickstart(); - } - - this.isSparkEnabled( - (this.isNotebookEnabled() && - this.isSparkEnabledForAccount() && - this.arcadiaWorkspaces() && - this.arcadiaWorkspaces().length > 0) || - this.isFeatureEnabled(Constants.Features.enableSpark) - ); - if (this.isSparkEnabled()) { - const pollArcadiaTokenRefresh = async () => { - this.arcadiaToken(await this.getArcadiaToken()); - setTimeout(() => pollArcadiaTokenRefresh(), this.getTokenRefreshInterval(this.arcadiaToken())); - }; - await pollArcadiaTokenRefresh(); - } - } - ); - } - }); - this.memoryUsageInfo = ko.observable(); - - this.features = ko.observable(); - this.serverId = ko.observable(); - this.queriesClient = new QueriesClient(this); - this.isTryCosmosDBSubscription = ko.observable(false); - - this.resourceTokenDatabaseId = ko.observable(); - this.resourceTokenCollectionId = ko.observable(); - this.resourceTokenCollection = ko.observable(); - this.resourceTokenPartitionKey = ko.observable(); - this.isAuthWithResourceToken = ko.observable(false); - - this.shareAccessData = ko.observable({ - readWriteUrl: undefined, - readUrl: undefined - }); - this.shareAccessUrl = ko.observable(); - this.shareUrlCopyHelperText = ko.observable("Click to copy"); - this.shareTokenCopyHelperText = ko.observable("Click to copy"); - this.shareAccessToggleState = ko.observable(ShareAccessToggleState.ReadWrite); - this.shareAccessToggleState.subscribe((toggleState: ShareAccessToggleState) => { - if (toggleState === ShareAccessToggleState.ReadWrite) { - this.shareAccessUrl(this.shareAccessData && this.shareAccessData().readWriteUrl); - } else { - this.shareAccessUrl(this.shareAccessData && this.shareAccessData().readUrl); - } - }); - this.shouldShowShareDialogContents = ko.observable(false); - this.shouldShowDataAccessExpiryDialog = ko.observable(false); - this.shouldShowContextSwitchPrompt = ko.observable(false); - this.isGalleryPublishEnabled = ko.computed(() => - this.isFeatureEnabled(Constants.Features.enableGalleryPublish) - ); - this.isLinkInjectionEnabled = ko.computed(() => - 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); - - this.canExceedMaximumValue = ko.computed(() => - this.isFeatureEnabled(Constants.Features.canExceedMaximumValue) - ); - - this.isSchemaEnabled = ko.computed(() => this.isFeatureEnabled(Constants.Features.enableSchema)); - this.isNotificationConsoleExpanded = ko.observable(false); - this.isAutoscaleDefaultEnabled = ko.observable(false); - - this.databases = ko.observableArray(); - this.canSaveQueries = ko.computed(() => { - const savedQueriesDatabase: ViewModels.Database = _.find( - this.databases(), - (database: ViewModels.Database) => database.id() === Constants.SavedQueries.DatabaseName - ); - if (!savedQueriesDatabase) { - return false; - } - const savedQueriesCollection: ViewModels.Collection = - savedQueriesDatabase && - _.find( - savedQueriesDatabase.collections(), - (collection: ViewModels.Collection) => collection.id() === Constants.SavedQueries.CollectionName - ); - if (!savedQueriesCollection) { - return false; - } - return true; - }); - this.isLeftPaneExpanded = ko.observable(true); - this.selectedNode = ko.observable(); - this.selectedNode.subscribe((nodeSelected: ViewModels.TreeNode) => { - // Make sure switching tabs restores tabs display - this.isTabsContentExpanded(false); - }); - this.isResourceTokenCollectionNodeSelected = ko.computed(() => { - return ( - this.selectedNode() && - this.resourceTokenCollection() && - this.selectedNode().id() === this.resourceTokenCollection().id() - ); - }); - - const splitterBounds: SplitterBounds = { - min: ExplorerMetrics.SplitterMinWidth, - max: ExplorerMetrics.SplitterMaxWidth - }; - this.splitter = new Splitter({ - splitterId: "h_splitter1", - leftId: "resourcetree", - bounds: splitterBounds, - direction: SplitterDirection.Vertical - }); - this.notificationConsoleData = ko.observableArray([]); - this.defaultExperience = ko.observable(); - this.databaseAccount.subscribe(databaseAccount => { - const defaultExperience: string = DefaultExperienceUtility.getDefaultExperienceFromDatabaseAccount( - databaseAccount - ); - this.defaultExperience(defaultExperience); - updateUserContext({ - defaultExperience: DefaultExperienceUtility.mapDefaultExperienceStringToEnum(defaultExperience) - }); - }); - - this.isPreferredApiDocumentDB = ko.computed(() => { - const defaultExperience = (this.defaultExperience && this.defaultExperience()) || ""; - return defaultExperience.toLowerCase() === Constants.DefaultAccountExperience.DocumentDB.toLowerCase(); - }); - - this.isPreferredApiCassandra = ko.computed(() => { - const defaultExperience = (this.defaultExperience && this.defaultExperience()) || ""; - return defaultExperience.toLowerCase() === Constants.DefaultAccountExperience.Cassandra.toLowerCase(); - }); - this.isPreferredApiGraph = ko.computed(() => { - const defaultExperience = (this.defaultExperience && this.defaultExperience()) || ""; - return defaultExperience.toLowerCase() === Constants.DefaultAccountExperience.Graph.toLowerCase(); - }); - - this.isPreferredApiTable = ko.computed(() => { - const defaultExperience = (this.defaultExperience && this.defaultExperience()) || ""; - return defaultExperience.toLowerCase() === Constants.DefaultAccountExperience.Table.toLowerCase(); - }); - - this.isFixedCollectionWithSharedThroughputSupported = ko.computed(() => { - if (this.isFeatureEnabled(Constants.Features.enableFixedCollectionWithSharedThroughput)) { - return true; - } - - if (this.databaseAccount && !this.databaseAccount()) { - return false; - } - - return this.isEnableMongoCapabilityPresent(); - }); - - this.isServerlessEnabled = ko.computed( - () => - this.databaseAccount && - this.databaseAccount()?.properties?.capabilities?.find( - item => item.name === Constants.CapabilityNames.EnableServerless - ) !== undefined - ); - - this.isPreferredApiMongoDB = ko.computed(() => { - const defaultExperience = (this.defaultExperience && this.defaultExperience()) || ""; - if (defaultExperience.toLowerCase() === Constants.DefaultAccountExperience.MongoDB.toLowerCase()) { - return true; - } - - if (defaultExperience.toLowerCase() === Constants.DefaultAccountExperience.ApiForMongoDB.toLowerCase()) { - return true; - } - - if ( - this.databaseAccount && - this.databaseAccount() && - this.databaseAccount().kind.toLowerCase() === Constants.AccountKind.MongoDB - ) { - return true; - } - - return false; - }); - - this.isEnableMongoCapabilityPresent = ko.computed(() => { - const capabilities = this.databaseAccount && this.databaseAccount()?.properties?.capabilities; - if (!capabilities) { - return false; - } - - for (let i = 0; i < capabilities.length; i++) { - if (typeof capabilities[i] === "object" && capabilities[i].name === Constants.CapabilityNames.EnableMongo) { - return true; - } - } - - return false; - }); - - this.isHostedDataExplorerEnabled = ko.computed( - () => - configContext.platform === Platform.Portal && !this.isRunningOnNationalCloud() && !this.isPreferredApiGraph() - ); - this.isRightPanelV2Enabled = ko.computed(() => - this.isFeatureEnabled(Constants.Features.enableRightPanelV2) - ); - this.defaultExperience.subscribe((defaultExperience: string) => { - if ( - defaultExperience && - defaultExperience.toLowerCase() === Constants.DefaultAccountExperience.Cassandra.toLowerCase() - ) { - this._isSystemDatabasePredicate = (database: ViewModels.Database): boolean => { - return database.id() === "system"; - }; - } - }); - - this.selectedDatabaseId = ko.computed(() => { - const selectedNode = this.selectedNode(); - if (!selectedNode) { - return ""; - } - - switch (selectedNode.nodeKind) { - case "Collection": - return (selectedNode as ViewModels.CollectionBase).databaseId || ""; - case "Database": - return selectedNode.id() || ""; - case "DocumentId": - case "StoredProcedure": - case "Trigger": - case "UserDefinedFunction": - return selectedNode.collection.databaseId || ""; - default: - return ""; - } - }); - - this.nonSystemDatabases = ko.computed(() => { - return this.databases().filter((database: ViewModels.Database) => !this._isSystemDatabasePredicate(database)); - }); - - this.addDatabasePane = new AddDatabasePane({ - id: "adddatabasepane", - visible: ko.observable(false), - - container: this - }); - - this.addCollectionPane = new AddCollectionPane({ - isPreferredApiTable: ko.computed(() => this.isPreferredApiTable()), - id: "addcollectionpane", - visible: ko.observable(false), - - container: this - }); - - this.deleteCollectionConfirmationPane = new DeleteCollectionConfirmationPane({ - id: "deletecollectionconfirmationpane", - visible: ko.observable(false), - - container: this - }); - - this.deleteDatabaseConfirmationPane = new DeleteDatabaseConfirmationPane({ - id: "deletedatabaseconfirmationpane", - visible: ko.observable(false), - - container: this - }); - - this.graphStylingPane = new GraphStylingPane({ - id: "graphstylingpane", - visible: ko.observable(false), - - container: this - }); - - this.addTableEntityPane = new AddTableEntityPane({ - id: "addtableentitypane", - visible: ko.observable(false), - - container: this - }); - - this.editTableEntityPane = new EditTableEntityPane({ - id: "edittableentitypane", - visible: ko.observable(false), - - container: this - }); - - this.tableColumnOptionsPane = new TableColumnOptionsPane({ - id: "tablecolumnoptionspane", - visible: ko.observable(false), - - container: this - }); - - this.querySelectPane = new QuerySelectPane({ - id: "queryselectpane", - visible: ko.observable(false), - - container: this - }); - - this.newVertexPane = new NewVertexPane({ - id: "newvertexpane", - visible: ko.observable(false), - - container: this - }); - - this.cassandraAddCollectionPane = new CassandraAddCollectionPane({ - id: "cassandraaddcollectionpane", - visible: ko.observable(false), - - container: this - }); - - this.settingsPane = new SettingsPane({ - id: "settingspane", - visible: ko.observable(false), - - container: this - }); - - this.executeSprocParamsPane = new ExecuteSprocParamsPane({ - id: "executesprocparamspane", - visible: ko.observable(false), - - container: this - }); - - this.renewAdHocAccessPane = new RenewAdHocAccessPane({ - id: "renewadhocaccesspane", - visible: ko.observable(false), - - container: this - }); - - this.uploadItemsPane = new UploadItemsPane({ - id: "uploaditemspane", - visible: ko.observable(false), - - container: this - }); - - this.uploadItemsPaneAdapter = new UploadItemsPaneAdapter(this); - - this.loadQueryPane = new LoadQueryPane({ - id: "loadquerypane", - visible: ko.observable(false), - - container: this - }); - - this.saveQueryPane = new SaveQueryPane({ - id: "savequerypane", - visible: ko.observable(false), - - container: this - }); - - this.browseQueriesPane = new BrowseQueriesPane({ - id: "browsequeriespane", - visible: ko.observable(false), - - container: this - }); - - this.uploadFilePane = new UploadFilePane({ - id: "uploadfilepane", - visible: ko.observable(false), - - container: this - }); - - this.stringInputPane = new StringInputPane({ - id: "stringinputpane", - visible: ko.observable(false), - - container: this - }); - - this.setupNotebooksPane = new SetupNotebooksPane({ - id: "setupnotebookspane", - visible: ko.observable(false), - - container: this - }); - - this.tabsManager = new TabsManager(); - - this._panes = [ - this.addDatabasePane, - this.addCollectionPane, - this.deleteCollectionConfirmationPane, - this.deleteDatabaseConfirmationPane, - this.graphStylingPane, - this.addTableEntityPane, - this.editTableEntityPane, - this.tableColumnOptionsPane, - this.querySelectPane, - this.newVertexPane, - this.cassandraAddCollectionPane, - this.settingsPane, - this.executeSprocParamsPane, - this.renewAdHocAccessPane, - this.uploadItemsPane, - this.loadQueryPane, - this.saveQueryPane, - this.browseQueriesPane, - this.uploadFilePane, - this.stringInputPane, - this.setupNotebooksPane - ]; - this.addDatabaseText.subscribe((addDatabaseText: string) => this.addDatabasePane.title(addDatabaseText)); - this.isTabsContentExpanded = ko.observable(false); - - document.addEventListener( - "contextmenu", - function(e) { - e.preventDefault(); - }, - false - ); - - $(function() { - $(document.body).click(() => $(".commandDropdownContainer").hide()); - }); - - // TODO move this to API customization class - this.defaultExperience.subscribe(defaultExperience => { - const defaultExperienceNormalizedString = ( - defaultExperience || Constants.DefaultAccountExperience.Default - ).toLowerCase(); - - switch (defaultExperienceNormalizedString) { - case Constants.DefaultAccountExperience.DocumentDB.toLowerCase(): - this.addCollectionText("New Container"); - this.addDatabaseText("New Database"); - this.collectionTitle("SQL API"); - this.collectionTreeNodeAltText("Container"); - this.deleteCollectionText("Delete Container"); - this.deleteDatabaseText("Delete Database"); - this.addCollectionPane.title("Add Container"); - this.addCollectionPane.collectionIdTitle("Container id"); - this.addCollectionPane.collectionWithThroughputInSharedTitle( - "Provision dedicated throughput for this container" - ); - this.deleteCollectionConfirmationPane.title("Delete Container"); - this.deleteCollectionConfirmationPane.collectionIdConfirmationText("Confirm by typing the container id"); - this.refreshTreeTitle("Refresh containers"); - break; - case Constants.DefaultAccountExperience.MongoDB.toLowerCase(): - case Constants.DefaultAccountExperience.ApiForMongoDB.toLowerCase(): - this.addCollectionText("New Collection"); - this.addDatabaseText("New Database"); - this.collectionTitle("Collections"); - this.collectionTreeNodeAltText("Collection"); - this.deleteCollectionText("Delete Collection"); - this.deleteDatabaseText("Delete Database"); - this.addCollectionPane.title("Add Collection"); - this.addCollectionPane.collectionIdTitle("Collection id"); - this.addCollectionPane.collectionWithThroughputInSharedTitle( - "Provision dedicated throughput for this collection" - ); - this.refreshTreeTitle("Refresh collections"); - break; - case Constants.DefaultAccountExperience.Graph.toLowerCase(): - this.addCollectionText("New Graph"); - this.addDatabaseText("New Database"); - this.deleteCollectionText("Delete Graph"); - this.deleteDatabaseText("Delete Database"); - this.collectionTitle("Gremlin API"); - this.collectionTreeNodeAltText("Graph"); - this.addCollectionPane.title("Add Graph"); - this.addCollectionPane.collectionIdTitle("Graph id"); - this.addCollectionPane.collectionWithThroughputInSharedTitle("Provision dedicated throughput for this graph"); - this.deleteCollectionConfirmationPane.title("Delete Graph"); - this.deleteCollectionConfirmationPane.collectionIdConfirmationText("Confirm by typing the graph id"); - this.refreshTreeTitle("Refresh graphs"); - break; - case Constants.DefaultAccountExperience.Table.toLowerCase(): - this.addCollectionText("New Table"); - this.addDatabaseText("New Database"); - this.deleteCollectionText("Delete Table"); - this.deleteDatabaseText("Delete Database"); - this.collectionTitle("Azure Table API"); - this.collectionTreeNodeAltText("Table"); - this.addCollectionPane.title("Add Table"); - this.addCollectionPane.collectionIdTitle("Table id"); - this.addCollectionPane.collectionWithThroughputInSharedTitle("Provision dedicated throughput for this table"); - this.refreshTreeTitle("Refresh tables"); - this.addTableEntityPane.title("Add Table Entity"); - this.editTableEntityPane.title("Edit Table Entity"); - this.deleteCollectionConfirmationPane.title("Delete Table"); - this.deleteCollectionConfirmationPane.collectionIdConfirmationText("Confirm by typing the table id"); - this.tableDataClient = new TablesAPIDataClient(); - break; - case Constants.DefaultAccountExperience.Cassandra.toLowerCase(): - this.addCollectionText("New Table"); - this.addDatabaseText("New Keyspace"); - this.deleteCollectionText("Delete Table"); - this.deleteDatabaseText("Delete Keyspace"); - this.collectionTitle("Cassandra API"); - this.collectionTreeNodeAltText("Table"); - this.addCollectionPane.title("Add Table"); - this.addCollectionPane.collectionIdTitle("Table id"); - this.addCollectionPane.collectionWithThroughputInSharedTitle("Provision dedicated throughput for this table"); - this.refreshTreeTitle("Refresh tables"); - this.addTableEntityPane.title("Add Table Row"); - this.editTableEntityPane.title("Edit Table Row"); - this.deleteCollectionConfirmationPane.title("Delete Table"); - this.deleteCollectionConfirmationPane.collectionIdConfirmationText("Confirm by typing the table id"); - this.deleteDatabaseConfirmationPane.title("Delete Keyspace"); - this.deleteDatabaseConfirmationPane.databaseIdConfirmationText("Confirm by typing the keyspace id"); - this.tableDataClient = new CassandraAPIDataClient(); - break; - } - }); - - this.commandBarComponentAdapter = new CommandBarComponentAdapter(this); - this.notificationConsoleComponentAdapter = new NotificationConsoleComponentAdapter(this); - - this._initSettings(); - - TelemetryProcessor.traceSuccess( - Action.InitializeDataExplorer, - { dataExplorerArea: Constants.Areas.ResourceTree }, - startKey - ); - - this.isNotebookEnabled = ko.observable(false); - this.isNotebookEnabled.subscribe(async () => { - if (!this.notebookManager) { - const notebookManagerModule = await import( - /* webpackChunkName: "NotebookManager" */ "./Notebook/NotebookManager" - ); - this.notebookManager = new notebookManagerModule.default(); - this.notebookManager.initialize({ - container: this, - dialogProps: this._dialogProps, - notebookBasePath: this.notebookBasePath, - resourceTree: this.resourceTree, - refreshCommandBarButtons: () => this.refreshCommandBarButtons(), - refreshNotebookList: () => this.refreshNotebookList() - }); - - this.gitHubReposPane = this.notebookManager.gitHubReposPane; - this.isGitHubPaneEnabled(true); - } - - this.refreshCommandBarButtons(); - this.refreshNotebookList(); - }); - - this.isSparkEnabled = ko.observable(false); - this.isSparkEnabled.subscribe((isEnabled: boolean) => this.refreshCommandBarButtons()); - this.resourceTree = new ResourceTreeAdapter(this); - this.resourceTreeForResourceToken = new ResourceTreeAdapterForResourceToken(this); - this.notebookServerInfo = ko.observable({ - notebookServerEndpoint: undefined, - authToken: undefined - }); - this.notebookBasePath = ko.observable(Constants.Notebook.defaultBasePath); - this.sparkClusterConnectionInfo = ko.observable({ - userName: undefined, - password: undefined, - endpoints: [] - }); - - // Override notebook server parameters from URL parameters - const featureSubcription = this.features.subscribe(features => { - const serverInfo = this.notebookServerInfo(); - if (this.isFeatureEnabled(Constants.Features.notebookServerUrl)) { - serverInfo.notebookServerEndpoint = features[Constants.Features.notebookServerUrl]; - } - - if (this.isFeatureEnabled(Constants.Features.notebookServerToken)) { - serverInfo.authToken = features[Constants.Features.notebookServerToken]; - } - this.notebookServerInfo(serverInfo); - this.notebookServerInfo.valueHasMutated(); - - if (this.isFeatureEnabled(Constants.Features.notebookBasePath)) { - this.notebookBasePath(features[Constants.Features.notebookBasePath]); - } - - if (this.isFeatureEnabled(Constants.Features.livyEndpoint)) { - this.sparkClusterConnectionInfo({ - userName: undefined, - password: undefined, - endpoints: [ - { - endpoint: features[Constants.Features.livyEndpoint], - kind: DataModels.SparkClusterEndpointKind.Livy - } - ] - }); - this.sparkClusterConnectionInfo.valueHasMutated(); - } - - if (this.isFeatureEnabled(Constants.Features.enableSDKoperations)) { - updateUserContext({ useSDKOperations: true }); - } - - featureSubcription.dispose(); - }); - - this._dialogProps = ko.observable({ - isModal: false, - visible: false, - title: undefined, - subText: undefined, - primaryButtonText: undefined, - secondaryButtonText: undefined, - onPrimaryButtonClick: undefined, - onSecondaryButtonClick: undefined - }); - this.dialogComponentAdapter = new DialogComponentAdapter(); - this.dialogComponentAdapter.parameters = this._dialogProps; - this.splashScreenAdapter = new SplashScreenComponentAdapter(this); - this.mostRecentActivity = new MostRecentActivity.MostRecentActivity(this); - - this._addSynapseLinkDialogProps = ko.observable({ - isModal: false, - visible: false, - title: undefined, - subText: undefined, - primaryButtonText: undefined, - secondaryButtonText: undefined, - onPrimaryButtonClick: undefined, - onSecondaryButtonClick: undefined - }); - this.addSynapseLinkDialog = new DialogComponentAdapter(); - this.addSynapseLinkDialog.parameters = this._addSynapseLinkDialogProps; - } - - public openEnableSynapseLinkDialog(): void { - const addSynapseLinkDialogProps: DialogProps = { - linkProps: { - linkText: "Learn more", - linkUrl: "https://aka.ms/cosmosdb-synapselink" - }, - isModal: true, - visible: true, - title: `Enable Azure Synapse Link on your Cosmos DB account`, - subText: `Enable Azure Synapse Link to perform near real time analytical analytics on this account, without impacting the performance of your transactional workloads. - Azure Synapse Link brings together Cosmos Db Analytical Store and Synapse Analytics`, - primaryButtonText: "Enable Azure Synapse Link", - secondaryButtonText: "Cancel", - - onPrimaryButtonClick: async () => { - const startTime = TelemetryProcessor.traceStart(Action.EnableAzureSynapseLink); - const logId = NotificationConsoleUtils.logConsoleMessage( - ConsoleDataType.InProgress, - "Enabling Azure Synapse Link for this account. This may take a few minutes before you can enable analytical store for this account." - ); - this.isSynapseLinkUpdating(true); - this._closeSynapseLinkModalDialog(); - - const resourceProviderClient = new ResourceProviderClientFactory().getOrCreate(this.databaseAccount().id); - - try { - const databaseAccount: DataModels.DatabaseAccount = await resourceProviderClient.patchAsync( - this.databaseAccount().id, - "2019-12-12", - { - properties: { - enableAnalyticalStorage: true - } - } - ); - NotificationConsoleUtils.clearInProgressMessageWithId(logId); - NotificationConsoleUtils.logConsoleMessage( - ConsoleDataType.Info, - "Enabled Azure Synapse Link for this account" - ); - TelemetryProcessor.traceSuccess(Action.EnableAzureSynapseLink, startTime); - this.databaseAccount(databaseAccount); - } catch (error) { - NotificationConsoleUtils.clearInProgressMessageWithId(logId); - NotificationConsoleUtils.logConsoleMessage( - ConsoleDataType.Error, - `Enabling Azure Synapse Link for this account failed. ${getErrorMessage(error)}` - ); - TelemetryProcessor.traceFailure(Action.EnableAzureSynapseLink, startTime); - } finally { - this.isSynapseLinkUpdating(false); - } - }, - - onSecondaryButtonClick: () => { - this._closeSynapseLinkModalDialog(); - TelemetryProcessor.traceCancel(Action.EnableAzureSynapseLink); - } - }; - this._addSynapseLinkDialogProps(addSynapseLinkDialogProps); - TelemetryProcessor.traceStart(Action.EnableAzureSynapseLink); - - // TODO: return result - } - - public copyUrlLink(src: any, event: MouseEvent): void { - const urlLinkInput: HTMLInputElement = document.getElementById("shareUrlLink") as HTMLInputElement; - urlLinkInput && urlLinkInput.select(); - document.execCommand("copy"); - this.shareUrlCopyHelperText("Copied"); - setTimeout(() => this.shareUrlCopyHelperText("Click to copy"), Constants.ClientDefaults.copyHelperTimeoutMs); - - TelemetryProcessor.trace(Action.SelectItem, ActionModifiers.Mark, { - description: "Copy full screen URL", - databaseAccountName: this.databaseAccount() && this.databaseAccount().name, - defaultExperience: this.defaultExperience && this.defaultExperience(), - dataExplorerArea: Constants.Areas.ShareDialog - }); - } - - public onCopyUrlLinkKeyPress(src: any, event: KeyboardEvent): boolean { - if (event.keyCode === Constants.KeyCodes.Enter || event.keyCode === Constants.KeyCodes.Space) { - this.copyUrlLink(src, null); - return false; - } - - return true; - } - - public copyToken(src: any, event: MouseEvent): void { - const tokenInput: HTMLInputElement = document.getElementById("shareToken") as HTMLInputElement; - tokenInput && tokenInput.select(); - document.execCommand("copy"); - this.shareTokenCopyHelperText("Copied"); - setTimeout(() => this.shareTokenCopyHelperText("Click to copy"), Constants.ClientDefaults.copyHelperTimeoutMs); - } - - public onCopyTokenKeyPress(src: any, event: KeyboardEvent): boolean { - if (event.keyCode === Constants.KeyCodes.Enter || event.keyCode === Constants.KeyCodes.Space) { - this.copyToken(src, null); - return false; - } - - return true; - } - - public generateSharedAccessData(): void { - const id: string = NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.InProgress, "Generating share url"); - AuthHeadersUtil.generateEncryptedToken().then( - (tokenResponse: DataModels.GenerateTokenResponse) => { - NotificationConsoleUtils.clearInProgressMessageWithId(id); - NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, "Successfully generated share url"); - this.shareAccessData({ - readWriteUrl: this._getShareAccessUrlForToken(tokenResponse.readWrite), - readUrl: this._getShareAccessUrlForToken(tokenResponse.read) - }); - !this.shareAccessData().readWriteUrl && this.shareAccessToggleState(ShareAccessToggleState.Read); // select read toggle by default for readers - this.shareAccessToggleState.valueHasMutated(); // to set initial url and token state - this.shareAccessData.valueHasMutated(); - this._openShareDialog(); - }, - (error: any) => { - NotificationConsoleUtils.clearInProgressMessageWithId(id); - NotificationConsoleUtils.logConsoleMessage( - ConsoleDataType.Error, - `Failed to generate share url: ${getErrorMessage(error)}` - ); - console.error(error); - } - ); - } - - public renewShareAccess(token: string): Q.Promise { - if (!this.renewExplorerShareAccess) { - return Q.reject("Not implemented"); - } - - const deferred: Q.Deferred = Q.defer(); - const id: string = NotificationConsoleUtils.logConsoleMessage( - ConsoleDataType.InProgress, - "Initiating connection to account" - ); - this.renewExplorerShareAccess(this, token) - .then( - () => { - NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, "Connection successful"); - this.renewAdHocAccessPane && this.renewAdHocAccessPane.close(); - deferred.resolve(); - }, - (error: any) => { - NotificationConsoleUtils.logConsoleMessage( - ConsoleDataType.Error, - `Failed to connect: ${getErrorMessage(error)}` - ); - deferred.reject(error); - } - ) - .finally(() => { - NotificationConsoleUtils.clearInProgressMessageWithId(id); - }); - - return deferred.promise; - } - - public displayGuestAccessTokenRenewalPrompt(): void { - if (!$("#dataAccessTokenModal").dialog("instance")) { - const connectButton = { - text: "Connect", - class: "connectDialogButtons connectButton connectOkBtns", - click: () => { - this.renewAdHocAccessPane.open(); - $("#dataAccessTokenModal").dialog("close"); - } - }; - const cancelButton = { - text: "Cancel", - class: "connectDialogButtons cancelBtn", - click: () => { - $("#dataAccessTokenModal").dialog("close"); - } - }; - - $("#dataAccessTokenModal").dialog({ - autoOpen: false, - buttons: [connectButton, cancelButton], - closeOnEscape: false, - draggable: false, - dialogClass: "no-close", - height: 180, - modal: true, - position: { my: "center center", at: "center center", of: window }, - resizable: false, - title: "Temporary access expired", - width: 435, - close: (event: Event, ui: JQueryUI.DialogUIParams) => this.shouldShowDataAccessExpiryDialog(false) - }); - $("#dataAccessTokenModal").dialog("option", "classes", { - "ui-dialog-titlebar": "connectTitlebar" - }); - } - this.shouldShowDataAccessExpiryDialog(true); - $("#dataAccessTokenModal").dialog("open"); - } - - public isConnectExplorerVisible(): boolean { - return $("#connectExplorer").is(":visible") || false; - } - - public displayContextSwitchPromptForConnectionString(connectionString: string): void { - const yesButton = { - text: "OK", - class: "connectDialogButtons okBtn connectOkBtns", - click: () => { - $("#contextSwitchPrompt").dialog("close"); - this.tabsManager.closeTabs(); // clear all tabs so we dont leave any tabs from previous session open - this.renewShareAccess(connectionString); - } - }; - const noButton = { - text: "Cancel", - class: "connectDialogButtons cancelBtn", - click: () => { - $("#contextSwitchPrompt").dialog("close"); - } - }; - - if (!$("#contextSwitchPrompt").dialog("instance")) { - $("#contextSwitchPrompt").dialog({ - autoOpen: false, - buttons: [yesButton, noButton], - closeOnEscape: false, - draggable: false, - dialogClass: "no-close", - height: 255, - modal: true, - position: { my: "center center", at: "center center", of: window }, - resizable: false, - title: "Switch account", - width: 440, - close: (event: Event, ui: JQueryUI.DialogUIParams) => this.shouldShowDataAccessExpiryDialog(false) - }); - $("#contextSwitchPrompt").dialog("option", "classes", { - "ui-dialog-titlebar": "connectTitlebar" - }); - $("#contextSwitchPrompt").dialog("option", "open", (event: Event, ui: JQueryUI.DialogUIParams) => { - $(".ui-dialog ").css("z-index", 1001); - $("#contextSwitchPrompt") - .parent() - .siblings(".ui-widget-overlay") - .css("z-index", 1000); - }); - } - $("#contextSwitchPrompt").dialog("option", "buttons", [yesButton, noButton]); // rebind buttons so callbacks accept current connection string - this.shouldShowContextSwitchPrompt(true); - $("#contextSwitchPrompt").dialog("open"); - } - - public isReadWriteToggled: () => boolean = (): boolean => { - return this.shareAccessToggleState() === ShareAccessToggleState.ReadWrite; - }; - - public isReadToggled: () => boolean = (): boolean => { - return this.shareAccessToggleState() === ShareAccessToggleState.Read; - }; - - public toggleReadWrite: (src: any, event: MouseEvent) => void = (src: any, event: MouseEvent) => { - this.shareAccessToggleState(ShareAccessToggleState.ReadWrite); - }; - - public toggleRead: (src: any, event: MouseEvent) => void = (src: any, event: MouseEvent) => { - this.shareAccessToggleState(ShareAccessToggleState.Read); - }; - - public onToggleKeyDown: (src: any, event: KeyboardEvent) => boolean = (src: any, event: KeyboardEvent) => { - if (event.keyCode === Constants.KeyCodes.LeftArrow) { - this.toggleReadWrite(src, null); - return false; - } else if (event.keyCode === Constants.KeyCodes.RightArrow) { - this.toggleRead(src, null); - return false; - } - return true; - }; - - public isDatabaseNodeOrNoneSelected(): boolean { - return this.isNoneSelected() || this.isDatabaseNodeSelected(); - } - - public isDatabaseNodeSelected(): boolean { - return (this.selectedNode() && this.selectedNode().nodeKind === "Database") || false; - } - - public isNodeKindSelected(nodeKind: string): boolean { - return (this.selectedNode() && this.selectedNode().nodeKind === nodeKind) || false; - } - - public isNoneSelected(): boolean { - return this.selectedNode() == null; - } - - public isFeatureEnabled(feature: string): boolean { - const features = this.features(); - - if (!features) { - return false; - } - - if (feature in features && features[feature]) { - return true; - } - - return false; - } - - public logConsoleData(consoleData: ConsoleData): void { - this.notificationConsoleData.splice(0, 0, consoleData); - } - - public deleteInProgressConsoleDataWithId(id: string): void { - const updatedConsoleData = _.reject( - this.notificationConsoleData(), - (data: ConsoleData) => data.type === ConsoleDataType.InProgress && data.id === id - ); - this.notificationConsoleData(updatedConsoleData); - } - - public expandConsole(): void { - this.isNotificationConsoleExpanded(true); - } - - public collapseConsole(): void { - this.isNotificationConsoleExpanded(false); - } - - public toggleLeftPaneExpanded() { - this.isLeftPaneExpanded(!this.isLeftPaneExpanded()); - - if (this.isLeftPaneExpanded()) { - document.getElementById("expandToggleLeftPaneButton").focus(); - this.splitter.expandLeft(); - } else { - document.getElementById("collapseToggleLeftPaneButton").focus(); - this.splitter.collapseLeft(); - } - } - - public async refreshDatabaseForResourceToken(): Promise { - const databaseId = this.resourceTokenDatabaseId(); - const collectionId = this.resourceTokenCollectionId(); - if (!databaseId || !collectionId) { - throw new Error("No collection ID or database ID for resource token"); - } - - return readCollection(databaseId, collectionId).then((collection: DataModels.Collection) => { - this.resourceTokenCollection(new ResourceTokenCollection(this, databaseId, collection)); - this.selectedNode(this.resourceTokenCollection()); - }); - } - - public refreshAllDatabases(isInitialLoad?: boolean): Q.Promise { - this.isRefreshingExplorer(true); - const startKey: number = TelemetryProcessor.traceStart(Action.LoadDatabases, { - databaseAccountName: this.databaseAccount() && this.databaseAccount().name, - defaultExperience: this.defaultExperience && this.defaultExperience(), - dataExplorerArea: Constants.Areas.ResourceTree - }); - let resourceTreeStartKey: number = null; - if (isInitialLoad) { - resourceTreeStartKey = TelemetryProcessor.traceStart(Action.LoadResourceTree, { - databaseAccountName: this.databaseAccount() && this.databaseAccount().name, - defaultExperience: this.defaultExperience && this.defaultExperience(), - dataExplorerArea: Constants.Areas.ResourceTree - }); - } - - // TODO: Refactor - const deferred: Q.Deferred = Q.defer(); - this._setLoadingStatusText("Fetching databases..."); - readDatabases().then( - (databases: DataModels.Database[]) => { - this._setLoadingStatusText("Successfully fetched databases."); - TelemetryProcessor.traceSuccess( - Action.LoadDatabases, - { - databaseAccountName: this.databaseAccount().name, - defaultExperience: this.defaultExperience(), - dataExplorerArea: Constants.Areas.ResourceTree - }, - startKey - ); - const currentlySelectedNode: ViewModels.TreeNode = this.selectedNode(); - const deltaDatabases = this.getDeltaDatabases(databases); - this.addDatabasesToList(deltaDatabases.toAdd); - this.deleteDatabasesFromList(deltaDatabases.toDelete); - this.selectedNode(currentlySelectedNode); - this._setLoadingStatusText("Fetching containers..."); - this.refreshAndExpandNewDatabases(deltaDatabases.toAdd) - .then( - () => { - this._setLoadingStatusText("Successfully fetched containers."); - deferred.resolve(); - }, - reason => { - this._setLoadingStatusText("Failed to fetch containers."); - deferred.reject(reason); - } - ) - .finally(() => this.isRefreshingExplorer(false)); - }, - error => { - this._setLoadingStatusText("Failed to fetch databases."); - this.isRefreshingExplorer(false); - deferred.reject(error); - const errorMessage = getErrorMessage(error); - TelemetryProcessor.traceFailure( - Action.LoadDatabases, - { - databaseAccountName: this.databaseAccount().name, - defaultExperience: this.defaultExperience(), - dataExplorerArea: Constants.Areas.ResourceTree, - error: errorMessage, - errorStack: getErrorStack(error) - }, - startKey - ); - NotificationConsoleUtils.logConsoleMessage( - ConsoleDataType.Error, - `Error while refreshing databases: ${errorMessage}` - ); - } - ); - - return deferred.promise.then( - () => { - if (resourceTreeStartKey != null) { - TelemetryProcessor.traceSuccess( - Action.LoadResourceTree, - { - databaseAccountName: this.databaseAccount() && this.databaseAccount().name, - defaultExperience: this.defaultExperience && this.defaultExperience(), - dataExplorerArea: Constants.Areas.ResourceTree - }, - resourceTreeStartKey - ); - } - }, - error => { - if (resourceTreeStartKey != null) { - TelemetryProcessor.traceFailure( - Action.LoadResourceTree, - { - databaseAccountName: this.databaseAccount() && this.databaseAccount().name, - defaultExperience: this.defaultExperience && this.defaultExperience(), - dataExplorerArea: Constants.Areas.ResourceTree, - error: getErrorMessage(error), - errorStack: getErrorStack(error) - }, - resourceTreeStartKey - ); - } - } - ); - } - - public onRefreshDatabasesKeyPress = (source: any, event: KeyboardEvent): boolean => { - if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) { - this.onRefreshResourcesClick(source, null); - return false; - } - return true; - }; - - public onRefreshResourcesClick = (source: any, event: MouseEvent): void => { - const startKey: number = TelemetryProcessor.traceStart(Action.LoadDatabases, { - description: "Refresh button clicked", - databaseAccountName: this.databaseAccount() && this.databaseAccount().name, - defaultExperience: this.defaultExperience && this.defaultExperience(), - dataExplorerArea: Constants.Areas.ResourceTree - }); - this.isRefreshingExplorer(true); - this.isAuthWithResourceToken() ? this.refreshDatabaseForResourceToken() : this.refreshAllDatabases(); - this.refreshNotebookList(); - }; - - public toggleLeftPaneExpandedKeyPress = (source: any, event: KeyboardEvent): boolean => { - if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) { - this.toggleLeftPaneExpanded(); - return false; - } - return true; - }; - - // Facade - public provideFeedbackEmail = () => { - window.open(Constants.Urls.feedbackEmail, "_self"); - }; - - public async getArcadiaToken(): Promise { - return new Promise((resolve: (token: string) => void, reject: (error: any) => void) => { - sendCachedDataMessage(MessageTypes.GetArcadiaToken, undefined /** params **/).then( - (token: string) => { - resolve(token); - }, - (error: any) => { - Logger.logError(getErrorMessage(error), "Explorer/getArcadiaToken"); - resolve(undefined); - } - ); - }); - } - - private async _getArcadiaWorkspaces(): Promise { - try { - const workspaces = await this._arcadiaManager.listWorkspacesAsync([userContext.subscriptionId]); - let workspaceItems: ArcadiaWorkspaceItem[] = new Array(workspaces.length); - const sparkPromises: Promise[] = []; - workspaces.forEach((workspace, i) => { - let promise = this._arcadiaManager.listSparkPoolsAsync(workspaces[i].id).then( - sparkpools => { - workspaceItems[i] = { ...workspace, sparkPools: sparkpools }; - }, - error => { - Logger.logError(getErrorMessage(error), "Explorer/this._arcadiaManager.listSparkPoolsAsync"); - } - ); - sparkPromises.push(promise); - }); - - return Promise.all(sparkPromises).then(() => workspaceItems); - } catch (error) { - handleError(error, "Explorer/this._arcadiaManager.listWorkspacesAsync", "Get Arcadia workspaces failed"); - return Promise.resolve([]); - } - } - - public async createWorkspace(): Promise { - return sendCachedDataMessage(MessageTypes.CreateWorkspace, undefined /** params **/); - } - - public async createSparkPool(workspaceId: string): Promise { - return sendCachedDataMessage(MessageTypes.CreateSparkPool, [workspaceId]); - } - - public async initNotebooks(databaseAccount: DataModels.DatabaseAccount): Promise { - if (!databaseAccount) { - throw new Error("No database account specified"); - } - - if (this._isInitializingNotebooks) { - return; - } - this._isInitializingNotebooks = true; - - await this.ensureNotebookWorkspaceRunning(); - let connectionInfo: DataModels.NotebookWorkspaceConnectionInfo = { - authToken: undefined, - notebookServerEndpoint: undefined - }; - try { - connectionInfo = await this.notebookWorkspaceManager.getNotebookConnectionInfoAsync( - databaseAccount.id, - "default" - ); - } catch (error) { - this._isInitializingNotebooks = false; - handleError( - error, - "initNotebooks/getNotebookConnectionInfoAsync", - `Failed to get notebook workspace connection info: ${getErrorMessage(error)}` - ); - throw error; - } finally { - // Overwrite with feature flags - if (this.isFeatureEnabled(Constants.Features.notebookServerUrl)) { - connectionInfo.notebookServerEndpoint = this.features()[Constants.Features.notebookServerUrl]; - } - - if (this.isFeatureEnabled(Constants.Features.notebookServerToken)) { - connectionInfo.authToken = this.features()[Constants.Features.notebookServerToken]; - } - - this.notebookServerInfo(connectionInfo); - this.notebookServerInfo.valueHasMutated(); - this.refreshNotebookList(); - } - - this._isInitializingNotebooks = false; - } - - public resetNotebookWorkspace() { - if (!this.isNotebookEnabled() || !this.notebookManager?.notebookClient) { - handleError( - "Attempt to reset notebook workspace, but notebook is not enabled", - "Explorer/resetNotebookWorkspace" - ); - return; - } - const resetConfirmationDialogProps: DialogProps = { - isModal: true, - visible: true, - title: "Reset Workspace", - subText: "This lets you keep your notebook files and the workspace will be restored to default. Proceed anyway?", - primaryButtonText: "OK", - secondaryButtonText: "Cancel", - onPrimaryButtonClick: this._resetNotebookWorkspace, - onSecondaryButtonClick: this._closeModalDialog - }; - this._dialogProps(resetConfirmationDialogProps); - } - - private async _containsDefaultNotebookWorkspace(databaseAccount: DataModels.DatabaseAccount): Promise { - if (!databaseAccount) { - return false; - } - - try { - const workspaces = await this.notebookWorkspaceManager.getNotebookWorkspacesAsync(databaseAccount?.id); - return workspaces && workspaces.length > 0 && workspaces.some(workspace => workspace.name === "default"); - } catch (error) { - Logger.logError(getErrorMessage(error), "Explorer/_containsDefaultNotebookWorkspace"); - return false; - } - } - - private async ensureNotebookWorkspaceRunning() { - if (!this.databaseAccount()) { - return; - } - - let clearMessage; - try { - const notebookWorkspace = await this.notebookWorkspaceManager.getNotebookWorkspaceAsync( - this.databaseAccount().id, - "default" - ); - if ( - notebookWorkspace && - notebookWorkspace.properties && - notebookWorkspace.properties.status && - notebookWorkspace.properties.status.toLowerCase() === "stopped" - ) { - clearMessage = NotificationConsoleUtils.logConsoleProgress("Initializing notebook workspace"); - await this.notebookWorkspaceManager.startNotebookWorkspaceAsync(this.databaseAccount().id, "default"); - } - } catch (error) { - handleError(error, "Explorer/ensureNotebookWorkspaceRunning", "Failed to initialize notebook workspace"); - } finally { - clearMessage && clearMessage(); - } - } - - private _resetNotebookWorkspace = async () => { - this._closeModalDialog(); - const id = NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.InProgress, "Resetting notebook workspace"); - try { - await this.notebookManager?.notebookClient.resetWorkspace(); - NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, "Successfully reset notebook workspace"); - TelemetryProcessor.traceSuccess(Action.ResetNotebookWorkspace); - } catch (error) { - NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, `Failed to reset notebook workspace: ${error}`); - TelemetryProcessor.traceFailure(Action.ResetNotebookWorkspace, { - error: getErrorMessage(error), - errorStack: getErrorStack(error) - }); - throw error; - } finally { - NotificationConsoleUtils.clearInProgressMessageWithId(id); - } - }; - - private _closeModalDialog = () => { - this._dialogProps().visible = false; - this._dialogProps.valueHasMutated(); - }; - - private _closeSynapseLinkModalDialog = () => { - this._addSynapseLinkDialogProps().visible = false; - this._addSynapseLinkDialogProps.valueHasMutated(); - }; - - private _shouldProcessMessage(event: MessageEvent): boolean { - if (typeof event.data !== "object") { - return false; - } - if (event.data["signature"] !== "pcIframe") { - return false; - } - if (!("data" in event.data)) { - return false; - } - if (typeof event.data["data"] !== "object") { - return false; - } - - // before initialization completed give exception - const message = event.data.data; - if (!this._importExplorerConfigComplete && message && message.type) { - const messageType = message.type; - switch (messageType) { - case MessageTypes.SendNotification: - case MessageTypes.ClearNotification: - case MessageTypes.LoadingStatus: - case MessageTypes.InitTestExplorer: - return true; - } - } - if (!("inputs" in event.data["data"]) && !this._importExplorerConfigComplete) { - return false; - } - return true; - } - - public handleMessage(event: MessageEvent) { - if (isInvalidParentFrameOrigin(event)) { - return; - } - - if (!this._shouldProcessMessage(event)) { - return; - } - - const message: any = event.data.data; - const inputs: ViewModels.DataExplorerInputsFrame = message.inputs; - - const isRunningInPortal = configContext.platform === Platform.Portal; - const isRunningInDevMode = process.env.NODE_ENV === "development"; - if (inputs && configContext.BACKEND_ENDPOINT && isRunningInPortal && isRunningInDevMode) { - inputs.extensionEndpoint = configContext.PROXY_PATH; - } - - this.initDataExplorerWithFrameInputs(inputs); - - const openAction: ActionContracts.DataExplorerAction = message.openAction; - if (!!openAction) { - if (this.isRefreshingExplorer()) { - const subscription = this.databases.subscribe((databases: ViewModels.Database[]) => { - handleOpenAction(openAction, this.nonSystemDatabases(), this); - subscription.dispose(); - }); - } else { - handleOpenAction(openAction, this.nonSystemDatabases(), this); - } - } - if (message.actionType === ActionContracts.ActionType.TransmitCachedData) { - handleCachedDataMessage(message); - return; - } - if (message.type) { - switch (message.type) { - case MessageTypes.UpdateLocationHash: - if (!message.locationHash) { - break; - } - hasher.replaceHash(message.locationHash); - RouteHandler.getInstance().parseHash(message.locationHash); - break; - case MessageTypes.SendNotification: - if (!message.message) { - break; - } - NotificationConsoleUtils.logConsoleMessage( - message.consoleDataType || ConsoleDataType.Info, - message.message, - message.id - ); - break; - case MessageTypes.ClearNotification: - if (!message.id) { - break; - } - NotificationConsoleUtils.clearInProgressMessageWithId(message.id); - break; - case MessageTypes.LoadingStatus: - if (!message.text) { - break; - } - this._setLoadingStatusText(message.text, message.title); - break; - } - return; - } - - this.splashScreenAdapter.forceRender(); - } - - public findSelectedDatabase(): ViewModels.Database { - if (!this.selectedNode()) { - return null; - } - if (this.selectedNode().nodeKind === "Database") { - return _.find(this.databases(), (database: ViewModels.Database) => database.id() === this.selectedNode().id()); - } - return this.findSelectedCollection().database; - } - - public findDatabaseWithId(databaseId: string): ViewModels.Database { - return _.find(this.databases(), (database: ViewModels.Database) => database.id() === databaseId); - } - - public isLastNonEmptyDatabase(): boolean { - if (this.isLastDatabase() && this.databases()[0].collections && this.databases()[0].collections().length > 0) { - return true; - } - return false; - } - - public isLastDatabase(): boolean { - if (this.databases().length > 1) { - return false; - } - return true; - } - - public isSelectedDatabaseShared(): boolean { - const database = this.findSelectedDatabase(); - if (!!database) { - return database.offer && !!database.offer(); - } - - return false; - } - - public initDataExplorerWithFrameInputs(inputs: ViewModels.DataExplorerInputsFrame): void { - if (inputs != null) { - // In development mode, save the iframe message from the portal in session storage. - // This allows webpack hot reload to funciton properly - if (process.env.NODE_ENV === "development" && configContext.platform === Platform.Portal) { - sessionStorage.setItem("portalDataExplorerInitMessage", JSON.stringify(inputs)); - } - - const authorizationToken = inputs.authorizationToken || ""; - const masterKey = inputs.masterKey || ""; - const databaseAccount = inputs.databaseAccount || null; - if (inputs.defaultCollectionThroughput) { - this.collectionCreationDefaults = inputs.defaultCollectionThroughput; - } - this.features(inputs.features); - this.serverId(inputs.serverId); - this.databaseAccount(databaseAccount); - this.subscriptionType(inputs.subscriptionType); - this.hasWriteAccess(inputs.hasWriteAccess); - this.flight(inputs.addCollectionDefaultFlight); - this.isTryCosmosDBSubscription(inputs.isTryCosmosDBSubscription); - this.isAuthWithResourceToken(inputs.isAuthWithresourceToken); - this.setFeatureFlagsFromFlights(inputs.flights); - this._importExplorerConfigComplete = true; - - updateConfigContext({ - BACKEND_ENDPOINT: inputs.extensionEndpoint || "", - ARM_ENDPOINT: normalizeArmEndpoint(inputs.csmEndpoint || configContext.ARM_ENDPOINT) - }); - - updateUserContext({ - authorizationToken, - masterKey, - databaseAccount, - resourceGroup: inputs.resourceGroup, - subscriptionId: inputs.subscriptionId, - subscriptionType: inputs.subscriptionType, - quotaId: inputs.quotaId - }); - this.isAccountReady(true); - } - } - - public setFeatureFlagsFromFlights(flights: readonly string[]): void { - if (!flights) { - return; - } - if (flights.indexOf(Constants.Flights.AutoscaleTest) !== -1) { - this.isAutoscaleDefaultEnabled(true); - } - if (flights.indexOf(Constants.Flights.MongoIndexing) !== -1) { - this.isMongoIndexingEnabled(true); - } - } - - public findSelectedCollection(): ViewModels.Collection { - return (this.selectedNode().nodeKind === "Collection" - ? this.selectedNode() - : this.selectedNode().collection) as ViewModels.Collection; - } - - // TODO: Refactor below methods, minimize dependencies and add unit tests where necessary - public findSelectedStoredProcedure(): StoredProcedure { - const selectedCollection: ViewModels.Collection = this.findSelectedCollection(); - return _.find(selectedCollection.storedProcedures(), (storedProcedure: StoredProcedure) => { - const openedSprocTab = this.tabsManager.getTabs( - ViewModels.CollectionTabKind.StoredProcedures, - tab => tab.node && tab.node.rid === storedProcedure.rid - ); - return ( - storedProcedure.rid === this.selectedNode().rid || - (!!openedSprocTab && openedSprocTab.length > 0 && openedSprocTab[0].isActive()) - ); - }); - } - - public findSelectedUDF(): UserDefinedFunction { - const selectedCollection: ViewModels.Collection = this.findSelectedCollection(); - return _.find(selectedCollection.userDefinedFunctions(), (userDefinedFunction: UserDefinedFunction) => { - const openedUdfTab = this.tabsManager.getTabs( - ViewModels.CollectionTabKind.UserDefinedFunctions, - tab => tab.node && tab.node.rid === userDefinedFunction.rid - ); - return ( - userDefinedFunction.rid === this.selectedNode().rid || - (!!openedUdfTab && openedUdfTab.length > 0 && openedUdfTab[0].isActive()) - ); - }); - } - - public findSelectedTrigger(): Trigger { - const selectedCollection: ViewModels.Collection = this.findSelectedCollection(); - return _.find(selectedCollection.triggers(), (trigger: Trigger) => { - const openedTriggerTab = this.tabsManager.getTabs( - ViewModels.CollectionTabKind.Triggers, - tab => tab.node && tab.node.rid === trigger.rid - ); - return ( - trigger.rid === this.selectedNode().rid || - (!!openedTriggerTab && openedTriggerTab.length > 0 && openedTriggerTab[0].isActive()) - ); - }); - } - - public closeAllPanes(): void { - this._panes.forEach((pane: ContextualPaneBase) => pane.close()); - } - - public isRunningOnNationalCloud(): boolean { - return ( - this.serverId() === Constants.ServerIds.blackforest || - this.serverId() === Constants.ServerIds.fairfax || - this.serverId() === Constants.ServerIds.mooncake - ); - } - - public onUpdateTabsButtons(buttons: CommandButtonComponentProps[]): void { - this.commandBarComponentAdapter.onUpdateTabsButtons(buttons); - } - - public clickHostedAccountSwitch = () => { - sendMessage({ - type: MessageTypes.UpdateAccountSwitch, - click: true - }); - }; - - public clickHostedDirectorySwitch = () => { - sendMessage({ - type: MessageTypes.UpdateDirectoryControl, - click: true - }); - }; - - public refreshDatabaseAccount = () => { - sendMessage({ - type: MessageTypes.RefreshDatabaseAccount - }); - }; - - private refreshAndExpandNewDatabases(newDatabases: ViewModels.Database[]): Q.Promise { - // we reload collections for all databases so the resource tree reflects any collection-level changes - // i.e addition of stored procedures, etc. - const deferred: Q.Deferred = Q.defer(); - let loadCollectionPromises: Q.Promise[] = []; - - // If the user has a lot of databases, only load expanded databases. - const databasesToLoad = - this.databases().length <= Explorer.MaxNbDatabasesToAutoExpand - ? this.databases() - : this.databases().filter(db => db.isDatabaseExpanded()); - - const startKey: number = TelemetryProcessor.traceStart(Action.LoadCollections, { - databaseAccountName: this.databaseAccount() && this.databaseAccount().name, - defaultExperience: this.defaultExperience && this.defaultExperience(), - dataExplorerArea: Constants.Areas.ResourceTree - }); - databasesToLoad.forEach(async (database: ViewModels.Database) => { - await database.loadCollections(); - const isNewDatabase: boolean = _.some(newDatabases, (db: ViewModels.Database) => db.id() === database.id()); - if (isNewDatabase) { - database.expandDatabase(); - } - this.tabsManager.refreshActiveTab(tab => tab.collection && tab.collection.getDatabase().id() === database.id()); - }); - - Q.all(loadCollectionPromises).done( - () => { - deferred.resolve(); - TelemetryProcessor.traceSuccess( - Action.LoadCollections, - { dataExplorerArea: Constants.Areas.ResourceTree }, - startKey - ); - }, - (error: any) => { - deferred.reject(error); - TelemetryProcessor.traceFailure( - Action.LoadCollections, - { - databaseAccountName: this.databaseAccount() && this.databaseAccount().name, - defaultExperience: this.defaultExperience && this.defaultExperience(), - dataExplorerArea: Constants.Areas.ResourceTree, - error: getErrorMessage(error), - errorStack: getErrorStack(error) - }, - startKey - ); - } - ); - return deferred.promise; - } - - // TODO: Abstract this elsewhere - private _openShareDialog: () => void = (): void => { - if (!$("#shareDataAccessFlyout").dialog("instance")) { - const accountMetadataInfo = { - databaseAccountName: this.databaseAccount() && this.databaseAccount().name, - defaultExperience: this.defaultExperience && this.defaultExperience(), - dataExplorerArea: Constants.Areas.ShareDialog - }; - const openFullscreenButton = { - text: "Open", - class: "openFullScreenBtn openFullScreenCancelBtn", - click: () => { - TelemetryProcessor.trace( - Action.SelectItem, - ActionModifiers.Mark, - _.extend({}, { description: "Open full screen" }, accountMetadataInfo) - ); - - const hiddenAnchorElement: HTMLAnchorElement = document.createElement("a"); - hiddenAnchorElement.href = this.shareAccessUrl(); - hiddenAnchorElement.target = "_blank"; - $("#shareDataAccessFlyout").dialog("close"); - hiddenAnchorElement.click(); - } - }; - const cancelButton = { - text: "Cancel", - class: "shareCancelButton openFullScreenCancelBtn", - click: () => { - TelemetryProcessor.trace( - Action.SelectItem, - ActionModifiers.Mark, - _.extend({}, { description: "Cancel open full screen" }, accountMetadataInfo) - ); - $("#shareDataAccessFlyout").dialog("close"); - } - }; - $("#shareDataAccessFlyout").dialog({ - autoOpen: false, - buttons: [openFullscreenButton, cancelButton], - closeOnEscape: true, - draggable: false, - dialogClass: "no-close", - position: { my: "right top", at: "right bottom", of: $(".OpenFullScreen") }, - resizable: false, - title: "Open Full Screen", - width: 400, - close: (event: Event, ui: JQueryUI.DialogUIParams) => this.shouldShowShareDialogContents(false) - }); - $("#shareDataAccessFlyout").dialog("option", "classes", { - "ui-widget-content": "shareUrlDialog", - "ui-widget-header": "shareUrlTitle", - "ui-dialog-titlebar-close": "shareClose", - "ui-button": "shareCloseIcon", - "ui-button-icon": "cancelIcon", - "ui-icon": "" - }); - $("#shareDataAccessFlyout").dialog("option", "open", (event: Event, ui: JQueryUI.DialogUIParams) => - $(".openFullScreenBtn").focus() - ); - } - $("#shareDataAccessFlyout").dialog("close"); - this.shouldShowShareDialogContents(true); - $("#shareDataAccessFlyout").dialog("open"); - }; - - private _getShareAccessUrlForToken(token: string): string { - if (!token) { - return undefined; - } - - const urlPrefixWithKeyParam: string = `${configContext.hostedExplorerURL}?key=`; - const currentActiveTab = this.tabsManager.activeTab(); - - return `${urlPrefixWithKeyParam}${token}#/${(currentActiveTab && currentActiveTab.hashLocation()) || ""}`; - } - - private _initSettings() { - if (!ExplorerSettings.hasSettingsDefined()) { - ExplorerSettings.createDefaultSettings(); - } - } - - public findCollection(databaseId: string, collectionId: string): ViewModels.Collection { - const database: ViewModels.Database = this.databases().find( - (database: ViewModels.Database) => database.id() === databaseId - ); - return database?.collections().find((collection: ViewModels.Collection) => collection.id() === collectionId); - } - - public isLastCollection(): boolean { - let collectionCount = 0; - if (this.databases().length == 0) { - return false; - } - for (let i = 0; i < this.databases().length; i++) { - const database = this.databases()[i]; - collectionCount += database.collections().length; - if (collectionCount > 1) { - return false; - } - } - return true; - } - - private getDeltaDatabases( - updatedDatabaseList: DataModels.Database[] - ): { toAdd: ViewModels.Database[]; toDelete: ViewModels.Database[] } { - const newDatabases: DataModels.Database[] = _.filter(updatedDatabaseList, (database: DataModels.Database) => { - const databaseExists = _.some( - this.databases(), - (existingDatabase: ViewModels.Database) => existingDatabase.id() === database.id - ); - return !databaseExists; - }); - const databasesToAdd: ViewModels.Database[] = newDatabases.map( - (newDatabase: DataModels.Database) => new Database(this, newDatabase) - ); - - let databasesToDelete: ViewModels.Database[] = []; - ko.utils.arrayForEach(this.databases(), (database: ViewModels.Database) => { - const databasePresentInUpdatedList = _.some( - updatedDatabaseList, - (db: DataModels.Database) => db.id === database.id() - ); - if (!databasePresentInUpdatedList) { - databasesToDelete.push(database); - } - }); - - return { toAdd: databasesToAdd, toDelete: databasesToDelete }; - } - - private addDatabasesToList(databases: ViewModels.Database[]): void { - this.databases( - this.databases() - .concat(databases) - .sort((database1, database2) => database1.id().localeCompare(database2.id())) - ); - } - - private deleteDatabasesFromList(databasesToRemove: ViewModels.Database[]): void { - const databasesToKeep: ViewModels.Database[] = []; - - ko.utils.arrayForEach(this.databases(), (database: ViewModels.Database) => { - const shouldRemoveDatabase = _.some(databasesToRemove, (db: ViewModels.Database) => db.id === database.id); - if (!shouldRemoveDatabase) { - databasesToKeep.push(database); - } - }); - - this.databases(databasesToKeep); - } - - public uploadFile(name: string, content: string, parent: NotebookContentItem): Promise { - if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { - const error = "Attempt to upload notebook, but notebook is not enabled"; - handleError(error, "Explorer/uploadFile"); - throw new Error(error); - } - - const promise = this.notebookManager?.notebookContentClient.uploadFileAsync(name, content, parent); - promise - .then(() => this.resourceTree.triggerRender()) - .catch((reason: any) => this.showOkModalDialog("Unable to upload file", reason)); - return promise; - } - - public async importAndOpen(path: string): Promise { - const name = NotebookUtil.getName(path); - const item = NotebookUtil.createNotebookContentItem(name, path, "file"); - const parent = this.resourceTree.myNotebooksContentRoot; - - if (parent && parent.children && this.isNotebookEnabled() && this.notebookManager?.notebookClient) { - const existingItem = _.find(parent.children, node => node.name === name); - if (existingItem) { - return this.openNotebook(existingItem); - } - - const content = await this.readFile(item); - const uploadedItem = await this.uploadFile(name, content, parent); - return this.openNotebook(uploadedItem); - } - - return Promise.resolve(false); - } - - public async importAndOpenContent(name: string, content: string): Promise { - const parent = this.resourceTree.myNotebooksContentRoot; - - if (parent && parent.children && this.isNotebookEnabled() && this.notebookManager?.notebookClient) { - if (this.notebookToImport && this.notebookToImport.name === name && this.notebookToImport.content === content) { - this.notebookToImport = undefined; // we don't want to try opening this notebook again - } - - const existingItem = _.find(parent.children, node => node.name === name); - if (existingItem) { - return this.openNotebook(existingItem); - } - - const uploadedItem = await this.uploadFile(name, content, parent); - return this.openNotebook(uploadedItem); - } - - this.notebookToImport = { name, content }; // we'll try opening this notebook later on - return Promise.resolve(false); - } - - public async publishNotebook(name: string, content: string | unknown, parentDomElement: HTMLElement): Promise { - if (this.notebookManager) { - await this.notebookManager.openPublishNotebookPane( - name, - content, - parentDomElement, - this.isLinkInjectionEnabled() - ); - this.publishNotebookPaneAdapter = this.notebookManager.publishNotebookPaneAdapter; - this.isPublishNotebookPaneEnabled(true); - } - } - - public copyNotebook(name: string, content: string): void { - if (this.notebookManager) { - this.notebookManager.openCopyNotebookPane(name, content); - this.copyNotebookPaneAdapter = this.notebookManager.copyNotebookPaneAdapter; - this.isCopyNotebookPaneEnabled(true); - } - } - - public showOkModalDialog(title: string, msg: string): void { - this._dialogProps({ - isModal: true, - visible: true, - title, - subText: msg, - primaryButtonText: "Close", - secondaryButtonText: undefined, - onPrimaryButtonClick: this._closeModalDialog, - onSecondaryButtonClick: undefined - }); - } - - public showOkCancelModalDialog( - title: string, - msg: string, - okLabel: string, - onOk: () => void, - cancelLabel: string, - onCancel: () => void, - choiceGroupProps?: IChoiceGroupProps, - textFieldProps?: TextFieldProps, - isPrimaryButtonDisabled?: boolean - ): void { - this._dialogProps({ - isModal: true, - visible: true, - title, - subText: msg, - primaryButtonText: okLabel, - secondaryButtonText: cancelLabel, - onPrimaryButtonClick: () => { - this._closeModalDialog(); - onOk && onOk(); - }, - onSecondaryButtonClick: () => { - this._closeModalDialog(); - onCancel && onCancel(); - }, - choiceGroupProps, - textFieldProps, - primaryButtonDisabled: isPrimaryButtonDisabled - }); - } - - /** - * Note: To keep it simple, this creates a disconnected NotebookContentItem that is not connected to the resource tree. - * Connecting it to a tree possibly requires the intermediate missing folders if the item is nested in a subfolder. - * Manually creating the missing folders between the root and its parent dir would break the UX: expanding a folder - * will not fetch its content if the children array exists (and has only one child which was manually created). - * Fetching the intermediate folders possibly involves a few chained async calls which isn't ideal. - * - * @param name - * @param path - */ - public createNotebookContentItemFile(name: string, path: string): NotebookContentItem { - return NotebookUtil.createNotebookContentItem(name, path, "file"); - } - - public async openNotebook(notebookContentItem: NotebookContentItem): Promise { - if (!notebookContentItem || !notebookContentItem.path) { - throw new Error(`Invalid notebookContentItem: ${notebookContentItem}`); - } - - const notebookTabs = this.tabsManager.getTabs( - ViewModels.CollectionTabKind.NotebookV2, - tab => - (tab as NotebookV2Tab).notebookPath && - FileSystemUtil.isPathEqual((tab as NotebookV2Tab).notebookPath(), notebookContentItem.path) - ) as NotebookV2Tab[]; - let notebookTab = notebookTabs && notebookTabs[0]; - - if (notebookTab) { - this.tabsManager.activateTab(notebookTab); - } else { - const options: NotebookTabOptions = { - account: userContext.databaseAccount, - tabKind: ViewModels.CollectionTabKind.NotebookV2, - node: null, - title: notebookContentItem.name, - tabPath: notebookContentItem.path, - collection: null, - masterKey: userContext.masterKey || "", - hashLocation: "notebooks", - isActive: ko.observable(false), - isTabsContentExpanded: ko.observable(true), - onLoadStartKey: null, - onUpdateTabsButtons: this.onUpdateTabsButtons, - container: this, - notebookContentItem - }; - - try { - const NotebookTabV2 = await import(/* webpackChunkName: "NotebookV2Tab" */ "./Tabs/NotebookV2Tab"); - notebookTab = new NotebookTabV2.default(options); - this.tabsManager.activateNewTab(notebookTab); - } catch (reason) { - console.error("Import NotebookV2Tab failed!", reason); - return false; - } - } - - return true; - } - - public renameNotebook(notebookFile: NotebookContentItem): Q.Promise { - if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { - const error = "Attempt to rename notebook, but notebook is not enabled"; - handleError(error, "Explorer/renameNotebook"); - throw new Error(error); - } - - // Don't delete if tab is open to avoid accidental deletion - const openedNotebookTabs = this.tabsManager.getTabs( - ViewModels.CollectionTabKind.NotebookV2, - (tab: NotebookV2Tab) => { - return tab.notebookPath && FileSystemUtil.isPathEqual(tab.notebookPath(), notebookFile.path); - } - ); - if (openedNotebookTabs.length > 0) { - this.showOkModalDialog("Unable to rename file", "This file is being edited. Please close the tab and try again."); - return Q.reject(); - } - - const originalPath = notebookFile.path; - const result = this.stringInputPane - .openWithOptions({ - errorMessage: "Could not rename notebook", - inProgressMessage: "Renaming notebook to", - successMessage: "Renamed notebook to", - inputLabel: "Enter new notebook name", - paneTitle: "Rename Notebook", - submitButtonLabel: "Rename", - defaultInput: FileSystemUtil.stripExtension(notebookFile.name, "ipynb"), - onSubmit: (input: string) => this.notebookManager?.notebookContentClient.renameNotebook(notebookFile, input) - }) - .then(newNotebookFile => { - const notebookTabs = this.tabsManager.getTabs( - ViewModels.CollectionTabKind.NotebookV2, - (tab: NotebookV2Tab) => tab.notebookPath && FileSystemUtil.isPathEqual(tab.notebookPath(), originalPath) - ); - notebookTabs.forEach(tab => { - tab.tabTitle(newNotebookFile.name); - tab.tabPath(newNotebookFile.path); - (tab as NotebookV2Tab).notebookPath(newNotebookFile.path); - }); - - return newNotebookFile; - }); - result.then(() => this.resourceTree.triggerRender()); - return result; - } - - public onCreateDirectory(parent: NotebookContentItem): Q.Promise { - if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { - const error = "Attempt to create notebook directory, but notebook is not enabled"; - handleError(error, "Explorer/onCreateDirectory"); - throw new Error(error); - } - - const result = this.stringInputPane.openWithOptions({ - errorMessage: "Could not create directory ", - inProgressMessage: "Creating directory ", - successMessage: "Created directory ", - inputLabel: "Enter new directory name", - paneTitle: "Create new directory", - submitButtonLabel: "Create", - defaultInput: "", - onSubmit: (input: string) => this.notebookManager?.notebookContentClient.createDirectory(parent, input) - }); - result.then(() => this.resourceTree.triggerRender()); - return result; - } - - public readFile(notebookFile: NotebookContentItem): Promise { - if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { - const error = "Attempt to read file, but notebook is not enabled"; - handleError(error, "Explorer/downloadFile"); - throw new Error(error); - } - - return this.notebookManager?.notebookContentClient.readFileContent(notebookFile.path); - } - - public downloadFile(notebookFile: NotebookContentItem): Promise { - if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { - const error = "Attempt to download file, but notebook is not enabled"; - handleError(error, "Explorer/downloadFile"); - throw new Error(error); - } - - const clearMessage = NotificationConsoleUtils.logConsoleProgress(`Downloading ${notebookFile.path}`); - - return this.notebookManager?.notebookContentClient.readFileContent(notebookFile.path).then( - (content: string) => { - const blob = stringToBlob(content, "text/plain"); - if (navigator.msSaveBlob) { - // for IE and Edge - navigator.msSaveBlob(blob, notebookFile.name); - } else { - const downloadLink: HTMLAnchorElement = document.createElement("a"); - const url = URL.createObjectURL(blob); - downloadLink.href = url; - downloadLink.target = "_self"; - downloadLink.download = notebookFile.name; - - // for some reason, FF displays the download prompt only when - // the link is added to the dom so we add and remove it - document.body.appendChild(downloadLink); - downloadLink.click(); - downloadLink.remove(); - } - - clearMessage(); - }, - (error: any) => { - NotificationConsoleUtils.logConsoleMessage( - ConsoleDataType.Error, - `Could not download notebook ${getErrorMessage(error)}` - ); - - clearMessage(); - } - ); - } - - private async _refreshNotebooksEnabledStateForAccount(): Promise { - const authType = window.authType as AuthType; - if ( - authType === AuthType.EncryptedToken || - authType === AuthType.ResourceToken || - authType === AuthType.MasterKey - ) { - this.isNotebooksEnabledForAccount(false); - return; - } - - const databaseAccount = this.databaseAccount(); - const databaseAccountLocation = databaseAccount && databaseAccount.location.toLowerCase(); - const disallowedLocationsUri = `${configContext.BACKEND_ENDPOINT}/api/disallowedLocations`; - const authorizationHeader = getAuthorizationHeader(); - try { - const response = await fetch(disallowedLocationsUri, { - method: "POST", - body: JSON.stringify({ - resourceTypes: [Constants.ArmResourceTypes.notebookWorkspaces] - }), - headers: { - [authorizationHeader.header]: authorizationHeader.token, - [Constants.HttpHeaders.contentType]: "application/json" - } - }); - - if (!response.ok) { - throw new Error("Failed to fetch disallowed locations"); - } - - const disallowedLocations: string[] = await response.json(); - if (!disallowedLocations) { - Logger.logInfo("No disallowed locations found", "Explorer/isNotebooksEnabledForAccount"); - this.isNotebooksEnabledForAccount(true); - return; - } - const isAccountInAllowedLocation = !disallowedLocations.some( - disallowedLocation => disallowedLocation === databaseAccountLocation - ); - this.isNotebooksEnabledForAccount(isAccountInAllowedLocation); - } catch (error) { - Logger.logError(getErrorMessage(error), "Explorer/isNotebooksEnabledForAccount"); - this.isNotebooksEnabledForAccount(false); - } - } - - public _refreshSparkEnabledStateForAccount = async (): Promise => { - const subscriptionId = userContext.subscriptionId; - const armEndpoint = configContext.ARM_ENDPOINT; - const authType = window.authType as AuthType; - if (!subscriptionId || !armEndpoint || authType === AuthType.EncryptedToken) { - // explorer is not aware of the database account yet - this.isSparkEnabledForAccount(false); - return; - } - - const featureUri = `subscriptions/${subscriptionId}/providers/Microsoft.Features/providers/Microsoft.DocumentDb/features/${Constants.AfecFeatures.Spark}`; - const resourceProviderClient = new ResourceProviderClientFactory().getOrCreate(featureUri); - try { - const sparkNotebooksFeature: DataModels.AfecFeature = await resourceProviderClient.getAsync( - featureUri, - Constants.ArmApiVersions.armFeatures - ); - const isEnabled = - (sparkNotebooksFeature && - sparkNotebooksFeature.properties && - sparkNotebooksFeature.properties.state === "Registered") || - false; - this.isSparkEnabledForAccount(isEnabled); - } catch (error) { - Logger.logError(getErrorMessage(error), "Explorer/isSparkEnabledForAccount"); - this.isSparkEnabledForAccount(false); - } - }; - - public _isAfecFeatureRegistered = async (featureName: string): Promise => { - const subscriptionId = userContext.subscriptionId; - const armEndpoint = configContext.ARM_ENDPOINT; - const authType = window.authType as AuthType; - if (!featureName || !subscriptionId || !armEndpoint || authType === AuthType.EncryptedToken) { - // explorer is not aware of the database account yet - return false; - } - - const featureUri = `subscriptions/${subscriptionId}/providers/Microsoft.Features/providers/Microsoft.DocumentDb/features/${featureName}`; - const resourceProviderClient = new ResourceProviderClientFactory().getOrCreate(featureUri); - try { - const featureStatus: DataModels.AfecFeature = await resourceProviderClient.getAsync( - featureUri, - Constants.ArmApiVersions.armFeatures - ); - const isEnabled = - (featureStatus && featureStatus.properties && featureStatus.properties.state === "Registered") || false; - return isEnabled; - } catch (error) { - Logger.logError(getErrorMessage(error), "Explorer/isSparkEnabledForAccount"); - return false; - } - }; - private refreshNotebookList = async (): Promise => { - if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { - return; - } - - await this.resourceTree.initialize(); - this.notebookManager?.refreshPinnedRepos(); - if (this.notebookToImport) { - this.importAndOpenContent(this.notebookToImport.name, this.notebookToImport.content); - } - }; - - public deleteNotebookFile(item: NotebookContentItem): Promise { - if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { - const error = "Attempt to delete notebook file, but notebook is not enabled"; - handleError(error, "Explorer/deleteNotebookFile"); - throw new Error(error); - } - - // Don't delete if tab is open to avoid accidental deletion - const openedNotebookTabs = this.tabsManager.getTabs( - ViewModels.CollectionTabKind.NotebookV2, - (tab: NotebookV2Tab) => { - return tab.notebookPath && FileSystemUtil.isPathEqual(tab.notebookPath(), item.path); - } - ); - if (openedNotebookTabs.length > 0) { - this.showOkModalDialog("Unable to delete file", "This file is being edited. Please close the tab and try again."); - return Promise.reject(); - } - - if (item.type === NotebookContentItemType.Directory && item.children && item.children.length > 0) { - this._dialogProps({ - isModal: true, - visible: true, - title: "Unable to delete file", - subText: "Directory is not empty.", - primaryButtonText: "Close", - secondaryButtonText: undefined, - onPrimaryButtonClick: this._closeModalDialog, - onSecondaryButtonClick: undefined - }); - return Promise.reject(); - } - - return this.notebookManager?.notebookContentClient.deleteContentItem(item).then( - () => { - NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Successfully deleted: ${item.path}`); - }, - (reason: any) => { - NotificationConsoleUtils.logConsoleMessage( - ConsoleDataType.Error, - `Failed to delete "${item.path}": ${JSON.stringify(reason)}` - ); - } - ); - } - - /** - * This creates a new notebook file, then opens the notebook - */ - public onNewNotebookClicked(parent?: NotebookContentItem): void { - if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { - const error = "Attempt to create new notebook, but notebook is not enabled"; - handleError(error, "Explorer/onNewNotebookClicked"); - throw new Error(error); - } - - parent = parent || this.resourceTree.myNotebooksContentRoot; - - const notificationProgressId = NotificationConsoleUtils.logConsoleMessage( - ConsoleDataType.InProgress, - `Creating new notebook in ${parent.path}` - ); - - const startKey: number = TelemetryProcessor.traceStart(Action.CreateNewNotebook, { - databaseAccountName: this.databaseAccount() && this.databaseAccount().name, - defaultExperience: this.defaultExperience && this.defaultExperience(), - dataExplorerArea: Constants.Areas.Notebook - }); - - this.notebookManager?.notebookContentClient - .createNewNotebookFile(parent) - .then((newFile: NotebookContentItem) => { - NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Successfully created: ${newFile.name}`); - TelemetryProcessor.traceSuccess( - Action.CreateNewNotebook, - { - databaseAccountName: this.databaseAccount().name, - defaultExperience: this.defaultExperience(), - dataExplorerArea: Constants.Areas.Notebook - }, - startKey - ); - return this.openNotebook(newFile); - }) - .then(() => this.resourceTree.triggerRender()) - .catch((error: any) => { - const errorMessage = `Failed to create a new notebook: ${getErrorMessage(error)}`; - NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, errorMessage); - TelemetryProcessor.traceFailure( - Action.CreateNewNotebook, - { - databaseAccountName: this.databaseAccount().name, - defaultExperience: this.defaultExperience(), - dataExplorerArea: Constants.Areas.Notebook, - error: errorMessage, - errorStack: getErrorStack(error) - }, - startKey - ); - }) - .finally(() => NotificationConsoleUtils.clearInProgressMessageWithId(notificationProgressId)); - } - - public onUploadToNotebookServerClicked(parent?: NotebookContentItem): void { - parent = parent || this.resourceTree.myNotebooksContentRoot; - - this.uploadFilePane.openWithOptions({ - paneTitle: "Upload file to notebook server", - selectFileInputLabel: "Select file to upload", - errorMessage: "Could not upload file", - inProgressMessage: "Uploading file to notebook server", - successMessage: "Successfully uploaded file to notebook server", - onSubmit: async (file: File): Promise => { - const readFileAsText = (inputFile: File): Promise => { - const reader = new FileReader(); - return new Promise((resolve, reject) => { - reader.onerror = () => { - reader.abort(); - reject(`Problem parsing file: ${inputFile}`); - }; - reader.onload = () => { - resolve(reader.result as string); - }; - reader.readAsText(inputFile); - }); - }; - - const fileContent = await readFileAsText(file); - return this.uploadFile(file.name, fileContent, parent); - }, - extensions: undefined, - submitButtonLabel: "Upload" - }); - } - - public refreshContentItem(item: NotebookContentItem): Promise { - if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { - const error = "Attempt to refresh notebook list, but notebook is not enabled"; - handleError(error, "Explorer/refreshContentItem"); - return Promise.reject(new Error(error)); - } - - return this.notebookManager?.notebookContentClient.updateItemChildren(item); - } - - public getNotebookBasePath(): string { - return this.notebookBasePath(); - } - - public openNotebookTerminal(kind: ViewModels.TerminalKind) { - let title: string; - let hashLocation: string; - - switch (kind) { - case ViewModels.TerminalKind.Default: - title = "Terminal"; - hashLocation = "terminal"; - break; - - case ViewModels.TerminalKind.Mongo: - title = "Mongo Shell"; - hashLocation = "mongo-shell"; - break; - - case ViewModels.TerminalKind.Cassandra: - title = "Cassandra Shell"; - hashLocation = "cassandra-shell"; - break; - - default: - throw new Error("Terminal kind: ${kind} not supported"); - } - - const terminalTabs: TerminalTab[] = this.tabsManager.getTabs( - ViewModels.CollectionTabKind.Terminal, - tab => tab.hashLocation() == hashLocation - ) as TerminalTab[]; - let terminalTab: TerminalTab = terminalTabs && terminalTabs[0]; - - if (terminalTab) { - this.tabsManager.activateTab(terminalTab); - } else { - const newTab = new TerminalTab({ - account: userContext.databaseAccount, - tabKind: ViewModels.CollectionTabKind.Terminal, - node: null, - title: title, - tabPath: title, - collection: null, - hashLocation: hashLocation, - isActive: ko.observable(false), - isTabsContentExpanded: ko.observable(true), - onLoadStartKey: null, - onUpdateTabsButtons: this.onUpdateTabsButtons, - container: this, - kind: kind - }); - - this.tabsManager.activateNewTab(newTab); - } - } - - public async openGallery(notebookUrl?: string, galleryItem?: IGalleryItem, isFavorite?: boolean) { - let title: string = "Gallery"; - let hashLocation: string = "gallery"; - - const galleryTabs = this.tabsManager.getTabs( - ViewModels.CollectionTabKind.Gallery, - tab => tab.hashLocation() == hashLocation - ); - let galleryTab = galleryTabs && galleryTabs[0]; - - if (galleryTab) { - this.tabsManager.activateTab(galleryTab); - } else { - if (!this.galleryTab) { - this.galleryTab = await import(/* webpackChunkName: "GalleryTab" */ "./Tabs/GalleryTab"); - } - - const newTab = new this.galleryTab.default({ - // GalleryTabOptions - account: userContext.databaseAccount, - container: this, - junoClient: this.notebookManager?.junoClient, - notebookUrl, - galleryItem, - isFavorite, - // TabOptions - tabKind: ViewModels.CollectionTabKind.Gallery, - title: title, - tabPath: title, - documentClientUtility: null, - isActive: ko.observable(false), - hashLocation: hashLocation, - onUpdateTabsButtons: this.onUpdateTabsButtons, - isTabsContentExpanded: ko.observable(true), - onLoadStartKey: null - }); - - this.tabsManager.activateNewTab(newTab); - } - } - - public async openNotebookViewer(notebookUrl: string) { - const title = path.basename(notebookUrl); - const hashLocation = notebookUrl; - - if (!this.notebookViewerTab) { - this.notebookViewerTab = await import(/* webpackChunkName: "NotebookViewerTab" */ "./Tabs/NotebookViewerTab"); - } - - const notebookViewerTabModule = this.notebookViewerTab; - - let isNotebookViewerOpen = (tab: TabsBase) => { - const notebookViewerTab = tab as typeof notebookViewerTabModule.default; - return notebookViewerTab.notebookUrl === notebookUrl; - }; - - const notebookViewerTabs = this.tabsManager.getTabs(ViewModels.CollectionTabKind.NotebookV2, tab => { - return tab.hashLocation() == hashLocation && isNotebookViewerOpen(tab); - }); - let notebookViewerTab = notebookViewerTabs && notebookViewerTabs[0]; - - if (notebookViewerTab) { - this.tabsManager.activateNewTab(notebookViewerTab); - } else { - notebookViewerTab = new this.notebookViewerTab.default({ - account: userContext.databaseAccount, - tabKind: ViewModels.CollectionTabKind.NotebookViewer, - node: null, - title: title, - tabPath: title, - documentClientUtility: null, - collection: null, - hashLocation: hashLocation, - isActive: ko.observable(false), - isTabsContentExpanded: ko.observable(true), - onLoadStartKey: null, - onUpdateTabsButtons: this.onUpdateTabsButtons, - container: this, - notebookUrl - }); - - this.tabsManager.activateNewTab(notebookViewerTab); - } - } - - public onNewCollectionClicked(): void { - if (this.isPreferredApiCassandra()) { - this.cassandraAddCollectionPane.open(); - } else { - this.addCollectionPane.open(this.selectedDatabaseId()); - } - document.getElementById("linkAddCollection").focus(); - } - - private refreshCommandBarButtons(): void { - const activeTab = this.tabsManager.activeTab(); - if (activeTab) { - activeTab.onActivate(); // TODO only update tabs buttons? - } else { - this.onUpdateTabsButtons([]); - } - } - - private getTokenRefreshInterval(token: string): number { - let tokenRefreshInterval = Constants.ClientDefaults.arcadiaTokenRefreshInterval; - if (!token) { - return tokenRefreshInterval; - } - - try { - const tokenPayload = decryptJWTToken(this.arcadiaToken()); - if (tokenPayload && tokenPayload.hasOwnProperty("exp")) { - const expirationTime = tokenPayload.exp as number; // seconds since unix epoch - const now = new Date().getTime() / 1000; - const tokenExpirationIntervalInMs = (expirationTime - now) * 1000; - if (tokenExpirationIntervalInMs < tokenRefreshInterval) { - tokenRefreshInterval = - tokenExpirationIntervalInMs - Constants.ClientDefaults.arcadiaTokenRefreshIntervalPaddingMs; - } - } - return tokenRefreshInterval; - } catch (error) { - Logger.logError(getErrorMessage(error), "Explorer/getTokenRefreshInterval"); - return tokenRefreshInterval; - } - } - - private _setLoadingStatusText(text: string, title: string = "Welcome to Azure Cosmos DB") { - if (!text) { - return; - } - - const loadingText = document.getElementById("explorerLoadingStatusText"); - if (!loadingText) { - Logger.logError( - "getElementById('explorerLoadingStatusText') failed to find element", - "Explorer/_setLoadingStatusText" - ); - return; - } - loadingText.innerHTML = text; - - const loadingTitle = document.getElementById("explorerLoadingStatusTitle"); - if (!loadingTitle) { - Logger.logError( - "getElementById('explorerLoadingStatusTitle') failed to find element", - "Explorer/_setLoadingStatusText" - ); - } else { - loadingTitle.innerHTML = title; - } - } - - private _openSetupNotebooksPaneForQuickstart(): void { - const title = "Enable Notebooks (Preview)"; - const description = - "You have not yet created a notebooks workspace for this account. To proceed and start using notebooks, we'll need to create a default notebooks workspace in this account."; - - this.setupNotebooksPane.openWithTitleAndDescription(title, description); - } - - public async handleOpenFileAction(path: string): Promise { - if (this.isAccountReady() && !(await this._containsDefaultNotebookWorkspace(this.databaseAccount()))) { - this.closeAllPanes(); - this._openSetupNotebooksPaneForQuickstart(); - } - - // We still use github urls like https://github.com/Azure-Samples/cosmos-notebooks/blob/master/CSharp_quickstarts/GettingStarted_CSharp.ipynb - // when launching a notebook quickstart from Portal. In future we should just use gallery id and use Juno to fetch instead of directly - // calling GitHub. For now convert this url to a raw url and download content. - const gitHubInfo = fromContentUri(path); - if (gitHubInfo) { - const rawUrl = toRawContentUri(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.branch, gitHubInfo.path); - const response = await fetch(rawUrl); - if (response.status === Constants.HttpStatusCodes.OK) { - this.notebookToImport = { - name: NotebookUtil.getName(path), - content: await response.text() - }; - - this.importAndOpenContent(this.notebookToImport.name, this.notebookToImport.content); - } - } - } - - public async loadSelectedDatabaseOffer(): Promise { - const database = this.findSelectedDatabase(); - await database?.loadOffer(); - } - - public async loadDatabaseOffers(): Promise { - await Promise.all( - this.databases()?.map(async (database: ViewModels.Database) => { - await database.loadOffer(); - }) - ); - } - - public isFirstResourceCreated(): boolean { - const databases: ViewModels.Database[] = this.databases(); - - if (!databases || databases.length === 0) { - return false; - } - - return databases.some(database => { - // user has created at least one collection - if (database.collections()?.length > 0) { - return true; - } - // user has created a database with shared throughput - if (database.offer()) { - return true; - } - // use has created an empty database without shared throughput - return false; - }); - } -} +import * as ComponentRegisterer from "./ComponentRegisterer"; +import * as Constants from "../Common/Constants"; +import * as DataModels from "../Contracts/DataModels"; +import * as ko from "knockout"; +import * as MostRecentActivity from "./MostRecentActivity/MostRecentActivity"; +import * as path from "path"; +import * as SharedConstants from "../Shared/Constants"; +import * as ViewModels from "../Contracts/ViewModels"; +import _ from "underscore"; +import AddCollectionPane from "./Panes/AddCollectionPane"; +import AddDatabasePane from "./Panes/AddDatabasePane"; +import AddTableEntityPane from "./Panes/Tables/AddTableEntityPane"; +import AuthHeadersUtil from "../Platform/Hosted/Authorization"; +import CassandraAddCollectionPane from "./Panes/CassandraAddCollectionPane"; +import Database from "./Tree/Database"; +import DeleteCollectionConfirmationPane from "./Panes/DeleteCollectionConfirmationPane"; +import DeleteDatabaseConfirmationPane from "./Panes/DeleteDatabaseConfirmationPane"; +import { readCollection } from "../Common/dataAccess/readCollection"; +import { readDatabases } from "../Common/dataAccess/readDatabases"; +import EditTableEntityPane from "./Panes/Tables/EditTableEntityPane"; +import { normalizeArmEndpoint } from "../Common/EnvironmentUtility"; +import GraphStylingPane from "./Panes/GraphStylingPane"; +import hasher from "hasher"; +import NewVertexPane from "./Panes/NewVertexPane"; +import NotebookV2Tab, { NotebookTabOptions } from "./Tabs/NotebookV2Tab"; +import Q from "q"; +import ResourceTokenCollection from "./Tree/ResourceTokenCollection"; +import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor"; +import TerminalTab from "./Tabs/TerminalTab"; +import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants"; +import { ActionContracts, MessageTypes } from "../Contracts/ExplorerContracts"; +import { ArcadiaResourceManager } from "../SparkClusterManager/ArcadiaResourceManager"; +import { ArcadiaWorkspaceItem } from "./Controls/Arcadia/ArcadiaMenuPicker"; +import { AuthType } from "../AuthType"; +import { BindingHandlersRegisterer } from "../Bindings/BindingHandlersRegisterer"; +import { BrowseQueriesPane } from "./Panes/BrowseQueriesPane"; +import { CassandraAPIDataClient, TableDataClient, TablesAPIDataClient } from "./Tables/TableDataClient"; +import { CommandBarComponentAdapter } from "./Menus/CommandBar/CommandBarComponentAdapter"; +import { configContext, Platform, updateConfigContext } from "../ConfigContext"; +import { ConsoleData, ConsoleDataType } from "./Menus/NotificationConsole/NotificationConsoleComponent"; +import { decryptJWTToken, getAuthorizationHeader } from "../Utils/AuthorizationUtils"; +import { DefaultExperienceUtility } from "../Shared/DefaultExperienceUtility"; +import { DialogComponentAdapter } from "./Controls/DialogReactComponent/DialogComponentAdapter"; +import { DialogProps, TextFieldProps } from "./Controls/DialogReactComponent/DialogComponent"; +import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane"; +import { ExplorerMetrics } from "../Common/Constants"; +import { ExplorerSettings } from "../Shared/ExplorerSettings"; +import { FileSystemUtil } from "./Notebook/FileSystemUtil"; +import { handleOpenAction } from "./OpenActions"; +import { isInvalidParentFrameOrigin } from "../Utils/MessageValidation"; +import { IGalleryItem } from "../Juno/JunoClient"; +import { LoadQueryPane } from "./Panes/LoadQueryPane"; +import * as Logger from "../Common/Logger"; +import { sendMessage, sendCachedDataMessage, handleCachedDataMessage } from "../Common/MessageHandler"; +import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem"; +import { NotebookUtil } from "./Notebook/NotebookUtil"; +import { NotebookWorkspaceManager } from "../NotebookWorkspaceManager/NotebookWorkspaceManager"; +import { NotificationConsoleComponentAdapter } from "./Menus/NotificationConsole/NotificationConsoleComponentAdapter"; +import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils"; +import { QueriesClient } from "../Common/QueriesClient"; +import { QuerySelectPane } from "./Panes/Tables/QuerySelectPane"; +import { RenewAdHocAccessPane } from "./Panes/RenewAdHocAccessPane"; +import { ResourceProviderClientFactory } from "../ResourceProvider/ResourceProviderClientFactory"; +import { ResourceTreeAdapter } from "./Tree/ResourceTreeAdapter"; +import { ResourceTreeAdapterForResourceToken } from "./Tree/ResourceTreeAdapterForResourceToken"; +import { RouteHandler } from "../RouteHandlers/RouteHandler"; +import { SaveQueryPane } from "./Panes/SaveQueryPane"; +import { SettingsPane } from "./Panes/SettingsPane"; +import { SetupNotebooksPane } from "./Panes/SetupNotebooksPane"; +import { SplashScreenComponentAdapter } from "./SplashScreen/SplashScreenComponentApdapter"; +import { Splitter, SplitterBounds, SplitterDirection } from "../Common/Splitter"; +import { StringInputPane } from "./Panes/StringInputPane"; +import { TableColumnOptionsPane } from "./Panes/Tables/TableColumnOptionsPane"; +import { TabsManager } from "./Tabs/TabsManager"; +import { UploadFilePane } from "./Panes/UploadFilePane"; +import { UploadItemsPane } from "./Panes/UploadItemsPane"; +import { UploadItemsPaneAdapter } from "./Panes/UploadItemsPaneAdapter"; +import { ReactAdapter } from "../Bindings/ReactBindingHandler"; +import { toRawContentUri, fromContentUri } from "../Utils/GitHubUtils"; +import UserDefinedFunction from "./Tree/UserDefinedFunction"; +import StoredProcedure from "./Tree/StoredProcedure"; +import Trigger from "./Tree/Trigger"; +import { ContextualPaneBase } from "./Panes/ContextualPaneBase"; +import TabsBase from "./Tabs/TabsBase"; +import { CommandButtonComponentProps } from "./Controls/CommandButton/CommandButtonComponent"; +import { updateUserContext, userContext } from "../UserContext"; +import { stringToBlob } from "../Utils/BlobUtils"; +import { IChoiceGroupProps } from "office-ui-fabric-react"; +import { getErrorMessage, handleError, getErrorStack } from "../Common/ErrorHandlingUtils"; +import { SubscriptionType } from "../Contracts/SubscriptionType"; +import { SelfServeLoadingComponentAdapter } from "../SelfServe/SelfServeLoadingComponentAdapter"; +import { SelfServeType } from "../SelfServe/SelfServeUtils"; +import { SelfServeComponentAdapter } from "../SelfServe/SelfServeComponentAdapter"; + +BindingHandlersRegisterer.registerBindingHandlers(); +// Hold a reference to ComponentRegisterer to prevent transpiler to ignore import +var tmp = ComponentRegisterer; + +enum ShareAccessToggleState { + ReadWrite, + Read +} + +interface AdHocAccessData { + readWriteUrl: string; + readUrl: string; +} + +export default class Explorer { + public flight: ko.Observable = ko.observable( + SharedConstants.CollectionCreation.DefaultAddCollectionDefaultFlight + ); + + public addCollectionText: ko.Observable; + public addDatabaseText: ko.Observable; + public collectionTitle: ko.Observable; + public deleteCollectionText: ko.Observable; + public deleteDatabaseText: ko.Observable; + public collectionTreeNodeAltText: ko.Observable; + public refreshTreeTitle: ko.Observable; + public hasWriteAccess: ko.Observable; + public collapsedResourceTreeWidth: number = ExplorerMetrics.CollapsedResourceTreeWidth; + + public databaseAccount: ko.Observable; + public collectionCreationDefaults: ViewModels.CollectionCreationDefaults = SharedConstants.CollectionCreationDefaults; + public subscriptionType: ko.Observable; + public defaultExperience: ko.Observable; + public isPreferredApiDocumentDB: ko.Computed; + public isPreferredApiCassandra: ko.Computed; + public isPreferredApiMongoDB: ko.Computed; + public isPreferredApiGraph: ko.Computed; + public isPreferredApiTable: ko.Computed; + public isFixedCollectionWithSharedThroughputSupported: ko.Computed; + public isEnableMongoCapabilityPresent: ko.Computed; + public isServerlessEnabled: ko.Computed; + public isAccountReady: ko.Observable; + public selfServeType: ko.Observable; + public canSaveQueries: ko.Computed; + public features: ko.Observable; + public serverId: ko.Observable; + public isTryCosmosDBSubscription: ko.Observable; + public queriesClient: QueriesClient; + public tableDataClient: TableDataClient; + public splitter: Splitter; + public mostRecentActivity: MostRecentActivity.MostRecentActivity; + + // Notification Console + public notificationConsoleData: ko.ObservableArray; + public isNotificationConsoleExpanded: ko.Observable; + + // Panes + public contextPanes: ContextualPaneBase[]; + + // Resource Tree + public databases: ko.ObservableArray; + public nonSystemDatabases: ko.Computed; + public selectedDatabaseId: ko.Computed; + public selectedCollectionId: ko.Computed; + public isLeftPaneExpanded: ko.Observable; + public selectedNode: ko.Observable; + public isRefreshingExplorer: ko.Observable; + private resourceTree: ResourceTreeAdapter; + private selfServeComponentAdapter: SelfServeComponentAdapter; + + // Resource Token + public resourceTokenDatabaseId: ko.Observable; + public resourceTokenCollectionId: ko.Observable; + public resourceTokenCollection: ko.Observable; + public resourceTokenPartitionKey: ko.Observable; + public isAuthWithResourceToken: ko.Observable; + public isResourceTokenCollectionNodeSelected: ko.Computed; + private resourceTreeForResourceToken: ResourceTreeAdapterForResourceToken; + + // Tabs + public isTabsContentExpanded: ko.Observable; + public galleryTab: any; + public notebookViewerTab: any; + public tabsManager: TabsManager; + + // Contextual panes + public addDatabasePane: AddDatabasePane; + public addCollectionPane: AddCollectionPane; + public deleteCollectionConfirmationPane: DeleteCollectionConfirmationPane; + public deleteDatabaseConfirmationPane: DeleteDatabaseConfirmationPane; + public graphStylingPane: GraphStylingPane; + public addTableEntityPane: AddTableEntityPane; + public editTableEntityPane: EditTableEntityPane; + public tableColumnOptionsPane: TableColumnOptionsPane; + public querySelectPane: QuerySelectPane; + public newVertexPane: NewVertexPane; + public cassandraAddCollectionPane: CassandraAddCollectionPane; + public settingsPane: SettingsPane; + public executeSprocParamsPane: ExecuteSprocParamsPane; + public renewAdHocAccessPane: RenewAdHocAccessPane; + public uploadItemsPane: UploadItemsPane; + public uploadItemsPaneAdapter: UploadItemsPaneAdapter; + public loadQueryPane: LoadQueryPane; + public saveQueryPane: ContextualPaneBase; + public browseQueriesPane: BrowseQueriesPane; + public uploadFilePane: UploadFilePane; + public stringInputPane: StringInputPane; + public setupNotebooksPane: SetupNotebooksPane; + public gitHubReposPane: ContextualPaneBase; + public publishNotebookPaneAdapter: ReactAdapter; + public copyNotebookPaneAdapter: ReactAdapter; + + // features + public isGalleryPublishEnabled: ko.Computed; + public isLinkInjectionEnabled: ko.Computed; + public isGitHubPaneEnabled: ko.Observable; + public isPublishNotebookPaneEnabled: ko.Observable; + public isCopyNotebookPaneEnabled: ko.Observable; + public isHostedDataExplorerEnabled: ko.Computed; + public isRightPanelV2Enabled: ko.Computed; + public isMongoIndexingEnabled: ko.Observable; + public canExceedMaximumValue: ko.Computed; + public isAutoscaleDefaultEnabled: ko.Observable; + + public shouldShowShareDialogContents: ko.Observable; + public shareAccessData: ko.Observable; + public renewExplorerShareAccess: (explorer: Explorer, token: string) => Q.Promise; + public renewTokenError: ko.Observable; + public tokenForRenewal: ko.Observable; + public shareAccessToggleState: ko.Observable; + public shareAccessUrl: ko.Observable; + public shareUrlCopyHelperText: ko.Observable; + public shareTokenCopyHelperText: ko.Observable; + public shouldShowDataAccessExpiryDialog: ko.Observable; + public shouldShowContextSwitchPrompt: ko.Observable; + public isSchemaEnabled: ko.Computed; + + // Notebooks + public isNotebookEnabled: ko.Observable; + public isNotebooksEnabledForAccount: ko.Observable; + public notebookServerInfo: ko.Observable; + public notebookWorkspaceManager: NotebookWorkspaceManager; + public sparkClusterConnectionInfo: ko.Observable; + public isSparkEnabled: ko.Observable; + public isSparkEnabledForAccount: ko.Observable; + public arcadiaToken: ko.Observable; + public arcadiaWorkspaces: ko.ObservableArray; + public hasStorageAnalyticsAfecFeature: ko.Observable; + public isSynapseLinkUpdating: ko.Observable; + public memoryUsageInfo: ko.Observable; + public notebookManager?: any; // This is dynamically loaded + + private _panes: ContextualPaneBase[] = []; + private _importExplorerConfigComplete: boolean = false; + private _isSystemDatabasePredicate: (database: ViewModels.Database) => boolean = database => false; + private _isInitializingNotebooks: boolean; + private _isInitializingSparkConnectionInfo: boolean; + private notebookBasePath: ko.Observable; + private _arcadiaManager: ArcadiaResourceManager; + private notebookToImport: { + name: string; + content: string; + }; + + // React adapters + private commandBarComponentAdapter: CommandBarComponentAdapter; + private splashScreenAdapter: SplashScreenComponentAdapter; + private notificationConsoleComponentAdapter: NotificationConsoleComponentAdapter; + private dialogComponentAdapter: DialogComponentAdapter; + private _dialogProps: ko.Observable; + private addSynapseLinkDialog: DialogComponentAdapter; + private _addSynapseLinkDialogProps: ko.Observable; + private selfServeLoadingComponentAdapter: SelfServeLoadingComponentAdapter; + + private static readonly MaxNbDatabasesToAutoExpand = 5; + + constructor() { + const startKey: number = TelemetryProcessor.traceStart(Action.InitializeDataExplorer, { + dataExplorerArea: Constants.Areas.ResourceTree + }); + this.addCollectionText = ko.observable("New Collection"); + this.addDatabaseText = ko.observable("New Database"); + this.hasWriteAccess = ko.observable(true); + this.collectionTitle = ko.observable("Collections"); + this.collectionTreeNodeAltText = ko.observable("Collection"); + this.deleteCollectionText = ko.observable("Delete Collection"); + this.deleteDatabaseText = ko.observable("Delete Database"); + this.refreshTreeTitle = ko.observable("Refresh collections"); + + this.databaseAccount = ko.observable(); + this.subscriptionType = ko.observable(SharedConstants.CollectionCreation.DefaultSubscriptionType); + let firstInitialization = true; + this.isRefreshingExplorer = ko.observable(true); + this.isRefreshingExplorer.subscribe((isRefreshing: boolean) => { + if (!isRefreshing && firstInitialization) { + // set focus on first element + firstInitialization = false; + try { + document.getElementById("createNewContainerCommandButton").parentElement.parentElement.focus(); + } catch (e) { + Logger.logWarning( + "getElementById('createNewContainerCommandButton') failed to find element", + "Explorer/this.isRefreshingExplorer.subscribe" + ); + } + } + }); + this.isAccountReady = ko.observable(false); + this.selfServeType = ko.observable(undefined); + this._isInitializingNotebooks = false; + this._isInitializingSparkConnectionInfo = false; + this.arcadiaToken = ko.observable(); + this.arcadiaToken.subscribe((token: string) => { + if (token) { + const notebookTabs = this.tabsManager.getTabs(ViewModels.CollectionTabKind.NotebookV2); + (notebookTabs || []).forEach((tab: NotebookV2Tab) => { + tab.reconfigureServiceEndpoints(); + }); + } + }); + this.isNotebooksEnabledForAccount = ko.observable(false); + this.isNotebooksEnabledForAccount.subscribe((isEnabledForAccount: boolean) => this.refreshCommandBarButtons()); + this.isSparkEnabledForAccount = ko.observable(false); + this.isSparkEnabledForAccount.subscribe((isEnabledForAccount: boolean) => this.refreshCommandBarButtons()); + this.hasStorageAnalyticsAfecFeature = ko.observable(false); + this.hasStorageAnalyticsAfecFeature.subscribe((enabled: boolean) => this.refreshCommandBarButtons()); + this.isSynapseLinkUpdating = ko.observable(false); + this.isAccountReady.subscribe(async (isAccountReady: boolean) => { + if (isAccountReady) { + this.isAuthWithResourceToken() ? this.refreshDatabaseForResourceToken() : this.refreshAllDatabases(true); + RouteHandler.getInstance().initHandler(); + this.notebookWorkspaceManager = new NotebookWorkspaceManager(); + this.arcadiaWorkspaces = ko.observableArray(); + this._arcadiaManager = new ArcadiaResourceManager(); + this._isAfecFeatureRegistered(Constants.AfecFeatures.StorageAnalytics).then(isRegistered => + this.hasStorageAnalyticsAfecFeature(isRegistered) + ); + Promise.all([this._refreshNotebooksEnabledStateForAccount(), this._refreshSparkEnabledStateForAccount()]).then( + async () => { + this.isNotebookEnabled( + !this.isAuthWithResourceToken() && + ((await this._containsDefaultNotebookWorkspace(this.databaseAccount())) || + this.isFeatureEnabled(Constants.Features.enableNotebooks)) + ); + + TelemetryProcessor.trace(Action.NotebookEnabled, ActionModifiers.Mark, { + isNotebookEnabled: this.isNotebookEnabled(), + databaseAccountName: this.databaseAccount() && this.databaseAccount().name, + defaultExperience: this.defaultExperience && this.defaultExperience(), + dataExplorerArea: Constants.Areas.Notebook + }); + + if (this.isNotebookEnabled()) { + await this.initNotebooks(this.databaseAccount()); + const workspaces = await this._getArcadiaWorkspaces(); + this.arcadiaWorkspaces(workspaces); + } else if (this.notebookToImport) { + // if notebooks is not enabled but the user is trying to do a quickstart setup with notebooks, open the SetupNotebooksPane + this._openSetupNotebooksPaneForQuickstart(); + } + + this.isSparkEnabled( + (this.isNotebookEnabled() && + this.isSparkEnabledForAccount() && + this.arcadiaWorkspaces() && + this.arcadiaWorkspaces().length > 0) || + this.isFeatureEnabled(Constants.Features.enableSpark) + ); + if (this.isSparkEnabled()) { + const pollArcadiaTokenRefresh = async () => { + this.arcadiaToken(await this.getArcadiaToken()); + setTimeout(() => pollArcadiaTokenRefresh(), this.getTokenRefreshInterval(this.arcadiaToken())); + }; + await pollArcadiaTokenRefresh(); + } + } + ); + } + }); + this.memoryUsageInfo = ko.observable(); + + this.features = ko.observable(); + this.serverId = ko.observable(); + this.queriesClient = new QueriesClient(this); + this.isTryCosmosDBSubscription = ko.observable(false); + + this.resourceTokenDatabaseId = ko.observable(); + this.resourceTokenCollectionId = ko.observable(); + this.resourceTokenCollection = ko.observable(); + this.resourceTokenPartitionKey = ko.observable(); + this.isAuthWithResourceToken = ko.observable(false); + + this.shareAccessData = ko.observable({ + readWriteUrl: undefined, + readUrl: undefined + }); + this.tokenForRenewal = ko.observable(""); + this.renewTokenError = ko.observable(""); + this.shareAccessUrl = ko.observable(); + this.shareUrlCopyHelperText = ko.observable("Click to copy"); + this.shareTokenCopyHelperText = ko.observable("Click to copy"); + this.shareAccessToggleState = ko.observable(ShareAccessToggleState.ReadWrite); + this.shareAccessToggleState.subscribe((toggleState: ShareAccessToggleState) => { + if (toggleState === ShareAccessToggleState.ReadWrite) { + this.shareAccessUrl(this.shareAccessData && this.shareAccessData().readWriteUrl); + } else { + this.shareAccessUrl(this.shareAccessData && this.shareAccessData().readUrl); + } + }); + this.shouldShowShareDialogContents = ko.observable(false); + this.shouldShowDataAccessExpiryDialog = ko.observable(false); + this.shouldShowContextSwitchPrompt = ko.observable(false); + this.isGalleryPublishEnabled = ko.computed(() => + this.isFeatureEnabled(Constants.Features.enableGalleryPublish) + ); + this.isLinkInjectionEnabled = ko.computed(() => + 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); + + this.canExceedMaximumValue = ko.computed(() => + this.isFeatureEnabled(Constants.Features.canExceedMaximumValue) + ); + + this.isSchemaEnabled = ko.computed(() => this.isFeatureEnabled(Constants.Features.enableSchema)); + this.isNotificationConsoleExpanded = ko.observable(false); + + this.isAutoscaleDefaultEnabled = ko.observable(false); + + this.databases = ko.observableArray(); + this.canSaveQueries = ko.computed(() => { + const savedQueriesDatabase: ViewModels.Database = _.find( + this.databases(), + (database: ViewModels.Database) => database.id() === Constants.SavedQueries.DatabaseName + ); + if (!savedQueriesDatabase) { + return false; + } + const savedQueriesCollection: ViewModels.Collection = + savedQueriesDatabase && + _.find( + savedQueriesDatabase.collections(), + (collection: ViewModels.Collection) => collection.id() === Constants.SavedQueries.CollectionName + ); + if (!savedQueriesCollection) { + return false; + } + return true; + }); + this.isLeftPaneExpanded = ko.observable(true); + this.selectedNode = ko.observable(); + this.selectedNode.subscribe((nodeSelected: ViewModels.TreeNode) => { + // Make sure switching tabs restores tabs display + this.isTabsContentExpanded(false); + }); + this.isResourceTokenCollectionNodeSelected = ko.computed(() => { + return ( + this.selectedNode() && + this.resourceTokenCollection() && + this.selectedNode().id() === this.resourceTokenCollection().id() + ); + }); + + const splitterBounds: SplitterBounds = { + min: ExplorerMetrics.SplitterMinWidth, + max: ExplorerMetrics.SplitterMaxWidth + }; + this.splitter = new Splitter({ + splitterId: "h_splitter1", + leftId: "resourcetree", + bounds: splitterBounds, + direction: SplitterDirection.Vertical + }); + this.notificationConsoleData = ko.observableArray([]); + this.defaultExperience = ko.observable(); + this.databaseAccount.subscribe(databaseAccount => { + const defaultExperience: string = DefaultExperienceUtility.getDefaultExperienceFromDatabaseAccount( + databaseAccount + ); + this.defaultExperience(defaultExperience); + updateUserContext({ + defaultExperience: DefaultExperienceUtility.mapDefaultExperienceStringToEnum(defaultExperience) + }); + }); + + this.isPreferredApiDocumentDB = ko.computed(() => { + const defaultExperience = (this.defaultExperience && this.defaultExperience()) || ""; + return defaultExperience.toLowerCase() === Constants.DefaultAccountExperience.DocumentDB.toLowerCase(); + }); + + this.isPreferredApiCassandra = ko.computed(() => { + const defaultExperience = (this.defaultExperience && this.defaultExperience()) || ""; + return defaultExperience.toLowerCase() === Constants.DefaultAccountExperience.Cassandra.toLowerCase(); + }); + this.isPreferredApiGraph = ko.computed(() => { + const defaultExperience = (this.defaultExperience && this.defaultExperience()) || ""; + return defaultExperience.toLowerCase() === Constants.DefaultAccountExperience.Graph.toLowerCase(); + }); + + this.isPreferredApiTable = ko.computed(() => { + const defaultExperience = (this.defaultExperience && this.defaultExperience()) || ""; + return defaultExperience.toLowerCase() === Constants.DefaultAccountExperience.Table.toLowerCase(); + }); + + this.isFixedCollectionWithSharedThroughputSupported = ko.computed(() => { + if (this.isFeatureEnabled(Constants.Features.enableFixedCollectionWithSharedThroughput)) { + return true; + } + + if (this.databaseAccount && !this.databaseAccount()) { + return false; + } + + return this.isEnableMongoCapabilityPresent(); + }); + + this.isServerlessEnabled = ko.computed( + () => + this.databaseAccount && + this.databaseAccount()?.properties?.capabilities?.find( + item => item.name === Constants.CapabilityNames.EnableServerless + ) !== undefined + ); + + this.isPreferredApiMongoDB = ko.computed(() => { + const defaultExperience = (this.defaultExperience && this.defaultExperience()) || ""; + if (defaultExperience.toLowerCase() === Constants.DefaultAccountExperience.MongoDB.toLowerCase()) { + return true; + } + + if (defaultExperience.toLowerCase() === Constants.DefaultAccountExperience.ApiForMongoDB.toLowerCase()) { + return true; + } + + if ( + this.databaseAccount && + this.databaseAccount() && + this.databaseAccount().kind.toLowerCase() === Constants.AccountKind.MongoDB + ) { + return true; + } + + return false; + }); + + this.isEnableMongoCapabilityPresent = ko.computed(() => { + const capabilities = this.databaseAccount && this.databaseAccount()?.properties?.capabilities; + if (!capabilities) { + return false; + } + + for (let i = 0; i < capabilities.length; i++) { + if (typeof capabilities[i] === "object" && capabilities[i].name === Constants.CapabilityNames.EnableMongo) { + return true; + } + } + + return false; + }); + + this.isHostedDataExplorerEnabled = ko.computed( + () => + configContext.platform === Platform.Portal && !this.isRunningOnNationalCloud() && !this.isPreferredApiGraph() + ); + this.isRightPanelV2Enabled = ko.computed(() => + this.isFeatureEnabled(Constants.Features.enableRightPanelV2) + ); + this.defaultExperience.subscribe((defaultExperience: string) => { + if ( + defaultExperience && + defaultExperience.toLowerCase() === Constants.DefaultAccountExperience.Cassandra.toLowerCase() + ) { + this._isSystemDatabasePredicate = (database: ViewModels.Database): boolean => { + return database.id() === "system"; + }; + } + }); + + this.selectedDatabaseId = ko.computed(() => { + const selectedNode = this.selectedNode(); + if (!selectedNode) { + return ""; + } + + switch (selectedNode.nodeKind) { + case "Collection": + return (selectedNode as ViewModels.CollectionBase).databaseId || ""; + case "Database": + return selectedNode.id() || ""; + case "DocumentId": + case "StoredProcedure": + case "Trigger": + case "UserDefinedFunction": + return selectedNode.collection.databaseId || ""; + default: + return ""; + } + }); + + this.nonSystemDatabases = ko.computed(() => { + return this.databases().filter((database: ViewModels.Database) => !this._isSystemDatabasePredicate(database)); + }); + + this.addDatabasePane = new AddDatabasePane({ + id: "adddatabasepane", + visible: ko.observable(false), + + container: this + }); + + this.addCollectionPane = new AddCollectionPane({ + isPreferredApiTable: ko.computed(() => this.isPreferredApiTable()), + id: "addcollectionpane", + visible: ko.observable(false), + + container: this + }); + + this.deleteCollectionConfirmationPane = new DeleteCollectionConfirmationPane({ + id: "deletecollectionconfirmationpane", + visible: ko.observable(false), + + container: this + }); + + this.deleteDatabaseConfirmationPane = new DeleteDatabaseConfirmationPane({ + id: "deletedatabaseconfirmationpane", + visible: ko.observable(false), + + container: this + }); + + this.graphStylingPane = new GraphStylingPane({ + id: "graphstylingpane", + visible: ko.observable(false), + + container: this + }); + + this.addTableEntityPane = new AddTableEntityPane({ + id: "addtableentitypane", + visible: ko.observable(false), + + container: this + }); + + this.editTableEntityPane = new EditTableEntityPane({ + id: "edittableentitypane", + visible: ko.observable(false), + + container: this + }); + + this.tableColumnOptionsPane = new TableColumnOptionsPane({ + id: "tablecolumnoptionspane", + visible: ko.observable(false), + + container: this + }); + + this.querySelectPane = new QuerySelectPane({ + id: "queryselectpane", + visible: ko.observable(false), + + container: this + }); + + this.newVertexPane = new NewVertexPane({ + id: "newvertexpane", + visible: ko.observable(false), + + container: this + }); + + this.cassandraAddCollectionPane = new CassandraAddCollectionPane({ + id: "cassandraaddcollectionpane", + visible: ko.observable(false), + + container: this + }); + + this.settingsPane = new SettingsPane({ + id: "settingspane", + visible: ko.observable(false), + + container: this + }); + + this.executeSprocParamsPane = new ExecuteSprocParamsPane({ + id: "executesprocparamspane", + visible: ko.observable(false), + + container: this + }); + + this.renewAdHocAccessPane = new RenewAdHocAccessPane({ + id: "renewadhocaccesspane", + visible: ko.observable(false), + + container: this + }); + + this.uploadItemsPane = new UploadItemsPane({ + id: "uploaditemspane", + visible: ko.observable(false), + + container: this + }); + + this.uploadItemsPaneAdapter = new UploadItemsPaneAdapter(this); + this.selfServeComponentAdapter = new SelfServeComponentAdapter(this); + + this.loadQueryPane = new LoadQueryPane({ + id: "loadquerypane", + visible: ko.observable(false), + + container: this + }); + + this.saveQueryPane = new SaveQueryPane({ + id: "savequerypane", + visible: ko.observable(false), + + container: this + }); + + this.browseQueriesPane = new BrowseQueriesPane({ + id: "browsequeriespane", + visible: ko.observable(false), + + container: this + }); + + this.uploadFilePane = new UploadFilePane({ + id: "uploadfilepane", + visible: ko.observable(false), + + container: this + }); + + this.stringInputPane = new StringInputPane({ + id: "stringinputpane", + visible: ko.observable(false), + + container: this + }); + + this.setupNotebooksPane = new SetupNotebooksPane({ + id: "setupnotebookspane", + visible: ko.observable(false), + + container: this + }); + + this.tabsManager = new TabsManager(); + + this._panes = [ + this.addDatabasePane, + this.addCollectionPane, + this.deleteCollectionConfirmationPane, + this.deleteDatabaseConfirmationPane, + this.graphStylingPane, + this.addTableEntityPane, + this.editTableEntityPane, + this.tableColumnOptionsPane, + this.querySelectPane, + this.newVertexPane, + this.cassandraAddCollectionPane, + this.settingsPane, + this.executeSprocParamsPane, + this.renewAdHocAccessPane, + this.uploadItemsPane, + this.loadQueryPane, + this.saveQueryPane, + this.browseQueriesPane, + this.uploadFilePane, + this.stringInputPane, + this.setupNotebooksPane + ]; + this.addDatabaseText.subscribe((addDatabaseText: string) => this.addDatabasePane.title(addDatabaseText)); + this.isTabsContentExpanded = ko.observable(false); + + document.addEventListener( + "contextmenu", + function(e) { + e.preventDefault(); + }, + false + ); + + $(function() { + $(document.body).click(() => $(".commandDropdownContainer").hide()); + }); + + // TODO move this to API customization class + this.defaultExperience.subscribe(defaultExperience => { + const defaultExperienceNormalizedString = ( + defaultExperience || Constants.DefaultAccountExperience.Default + ).toLowerCase(); + + switch (defaultExperienceNormalizedString) { + case Constants.DefaultAccountExperience.DocumentDB.toLowerCase(): + this.addCollectionText("New Container"); + this.addDatabaseText("New Database"); + this.collectionTitle("SQL API"); + this.collectionTreeNodeAltText("Container"); + this.deleteCollectionText("Delete Container"); + this.deleteDatabaseText("Delete Database"); + this.addCollectionPane.title("Add Container"); + this.addCollectionPane.collectionIdTitle("Container id"); + this.addCollectionPane.collectionWithThroughputInSharedTitle( + "Provision dedicated throughput for this container" + ); + this.deleteCollectionConfirmationPane.title("Delete Container"); + this.deleteCollectionConfirmationPane.collectionIdConfirmationText("Confirm by typing the container id"); + this.refreshTreeTitle("Refresh containers"); + break; + case Constants.DefaultAccountExperience.MongoDB.toLowerCase(): + case Constants.DefaultAccountExperience.ApiForMongoDB.toLowerCase(): + this.addCollectionText("New Collection"); + this.addDatabaseText("New Database"); + this.collectionTitle("Collections"); + this.collectionTreeNodeAltText("Collection"); + this.deleteCollectionText("Delete Collection"); + this.deleteDatabaseText("Delete Database"); + this.addCollectionPane.title("Add Collection"); + this.addCollectionPane.collectionIdTitle("Collection id"); + this.addCollectionPane.collectionWithThroughputInSharedTitle( + "Provision dedicated throughput for this collection" + ); + this.refreshTreeTitle("Refresh collections"); + break; + case Constants.DefaultAccountExperience.Graph.toLowerCase(): + this.addCollectionText("New Graph"); + this.addDatabaseText("New Database"); + this.deleteCollectionText("Delete Graph"); + this.deleteDatabaseText("Delete Database"); + this.collectionTitle("Gremlin API"); + this.collectionTreeNodeAltText("Graph"); + this.addCollectionPane.title("Add Graph"); + this.addCollectionPane.collectionIdTitle("Graph id"); + this.addCollectionPane.collectionWithThroughputInSharedTitle("Provision dedicated throughput for this graph"); + this.deleteCollectionConfirmationPane.title("Delete Graph"); + this.deleteCollectionConfirmationPane.collectionIdConfirmationText("Confirm by typing the graph id"); + this.refreshTreeTitle("Refresh graphs"); + break; + case Constants.DefaultAccountExperience.Table.toLowerCase(): + this.addCollectionText("New Table"); + this.addDatabaseText("New Database"); + this.deleteCollectionText("Delete Table"); + this.deleteDatabaseText("Delete Database"); + this.collectionTitle("Azure Table API"); + this.collectionTreeNodeAltText("Table"); + this.addCollectionPane.title("Add Table"); + this.addCollectionPane.collectionIdTitle("Table id"); + this.addCollectionPane.collectionWithThroughputInSharedTitle("Provision dedicated throughput for this table"); + this.refreshTreeTitle("Refresh tables"); + this.addTableEntityPane.title("Add Table Entity"); + this.editTableEntityPane.title("Edit Table Entity"); + this.deleteCollectionConfirmationPane.title("Delete Table"); + this.deleteCollectionConfirmationPane.collectionIdConfirmationText("Confirm by typing the table id"); + this.tableDataClient = new TablesAPIDataClient(); + break; + case Constants.DefaultAccountExperience.Cassandra.toLowerCase(): + this.addCollectionText("New Table"); + this.addDatabaseText("New Keyspace"); + this.deleteCollectionText("Delete Table"); + this.deleteDatabaseText("Delete Keyspace"); + this.collectionTitle("Cassandra API"); + this.collectionTreeNodeAltText("Table"); + this.addCollectionPane.title("Add Table"); + this.addCollectionPane.collectionIdTitle("Table id"); + this.addCollectionPane.collectionWithThroughputInSharedTitle("Provision dedicated throughput for this table"); + this.refreshTreeTitle("Refresh tables"); + this.addTableEntityPane.title("Add Table Row"); + this.editTableEntityPane.title("Edit Table Row"); + this.deleteCollectionConfirmationPane.title("Delete Table"); + this.deleteCollectionConfirmationPane.collectionIdConfirmationText("Confirm by typing the table id"); + this.deleteDatabaseConfirmationPane.title("Delete Keyspace"); + this.deleteDatabaseConfirmationPane.databaseIdConfirmationText("Confirm by typing the keyspace id"); + this.tableDataClient = new CassandraAPIDataClient(); + break; + } + }); + + this.commandBarComponentAdapter = new CommandBarComponentAdapter(this); + this.selfServeLoadingComponentAdapter = new SelfServeLoadingComponentAdapter(); + this.notificationConsoleComponentAdapter = new NotificationConsoleComponentAdapter(this); + + this._initSettings(); + + TelemetryProcessor.traceSuccess( + Action.InitializeDataExplorer, + { dataExplorerArea: Constants.Areas.ResourceTree }, + startKey + ); + + this.isNotebookEnabled = ko.observable(false); + this.isNotebookEnabled.subscribe(async () => { + if (!this.notebookManager) { + const notebookManagerModule = await import( + /* webpackChunkName: "NotebookManager" */ "./Notebook/NotebookManager" + ); + this.notebookManager = new notebookManagerModule.default(); + this.notebookManager.initialize({ + container: this, + dialogProps: this._dialogProps, + notebookBasePath: this.notebookBasePath, + resourceTree: this.resourceTree, + refreshCommandBarButtons: () => this.refreshCommandBarButtons(), + refreshNotebookList: () => this.refreshNotebookList() + }); + + this.gitHubReposPane = this.notebookManager.gitHubReposPane; + this.isGitHubPaneEnabled(true); + } + + this.refreshCommandBarButtons(); + this.refreshNotebookList(); + }); + + this.isSparkEnabled = ko.observable(false); + this.isSparkEnabled.subscribe((isEnabled: boolean) => this.refreshCommandBarButtons()); + this.resourceTree = new ResourceTreeAdapter(this); + this.resourceTreeForResourceToken = new ResourceTreeAdapterForResourceToken(this); + this.notebookServerInfo = ko.observable({ + notebookServerEndpoint: undefined, + authToken: undefined + }); + this.notebookBasePath = ko.observable(Constants.Notebook.defaultBasePath); + this.sparkClusterConnectionInfo = ko.observable({ + userName: undefined, + password: undefined, + endpoints: [] + }); + + // Override notebook server parameters from URL parameters + const featureSubcription = this.features.subscribe(features => { + const serverInfo = this.notebookServerInfo(); + if (this.isFeatureEnabled(Constants.Features.notebookServerUrl)) { + serverInfo.notebookServerEndpoint = features[Constants.Features.notebookServerUrl]; + } + + if (this.isFeatureEnabled(Constants.Features.notebookServerToken)) { + serverInfo.authToken = features[Constants.Features.notebookServerToken]; + } + this.notebookServerInfo(serverInfo); + this.notebookServerInfo.valueHasMutated(); + + if (this.isFeatureEnabled(Constants.Features.notebookBasePath)) { + this.notebookBasePath(features[Constants.Features.notebookBasePath]); + } + + if (this.isFeatureEnabled(Constants.Features.livyEndpoint)) { + this.sparkClusterConnectionInfo({ + userName: undefined, + password: undefined, + endpoints: [ + { + endpoint: features[Constants.Features.livyEndpoint], + kind: DataModels.SparkClusterEndpointKind.Livy + } + ] + }); + this.sparkClusterConnectionInfo.valueHasMutated(); + } + + if (this.isFeatureEnabled(Constants.Features.enableSDKoperations)) { + updateUserContext({ useSDKOperations: true }); + } + + featureSubcription.dispose(); + }); + + this._dialogProps = ko.observable({ + isModal: false, + visible: false, + title: undefined, + subText: undefined, + primaryButtonText: undefined, + secondaryButtonText: undefined, + onPrimaryButtonClick: undefined, + onSecondaryButtonClick: undefined + }); + this.dialogComponentAdapter = new DialogComponentAdapter(); + this.dialogComponentAdapter.parameters = this._dialogProps; + this.splashScreenAdapter = new SplashScreenComponentAdapter(this); + this.mostRecentActivity = new MostRecentActivity.MostRecentActivity(this); + + this._addSynapseLinkDialogProps = ko.observable({ + isModal: false, + visible: false, + title: undefined, + subText: undefined, + primaryButtonText: undefined, + secondaryButtonText: undefined, + onPrimaryButtonClick: undefined, + onSecondaryButtonClick: undefined + }); + this.addSynapseLinkDialog = new DialogComponentAdapter(); + this.addSynapseLinkDialog.parameters = this._addSynapseLinkDialogProps; + } + + public openEnableSynapseLinkDialog(): void { + const addSynapseLinkDialogProps: DialogProps = { + linkProps: { + linkText: "Learn more", + linkUrl: "https://aka.ms/cosmosdb-synapselink" + }, + isModal: true, + visible: true, + title: `Enable Azure Synapse Link on your Cosmos DB account`, + subText: `Enable Azure Synapse Link to perform near real time analytical analytics on this account, without impacting the performance of your transactional workloads. + Azure Synapse Link brings together Cosmos Db Analytical Store and Synapse Analytics`, + primaryButtonText: "Enable Azure Synapse Link", + secondaryButtonText: "Cancel", + + onPrimaryButtonClick: async () => { + const startTime = TelemetryProcessor.traceStart(Action.EnableAzureSynapseLink); + const logId = NotificationConsoleUtils.logConsoleMessage( + ConsoleDataType.InProgress, + "Enabling Azure Synapse Link for this account. This may take a few minutes before you can enable analytical store for this account." + ); + this.isSynapseLinkUpdating(true); + this._closeSynapseLinkModalDialog(); + + const resourceProviderClient = new ResourceProviderClientFactory().getOrCreate(this.databaseAccount().id); + + try { + const databaseAccount: DataModels.DatabaseAccount = await resourceProviderClient.patchAsync( + this.databaseAccount().id, + "2019-12-12", + { + properties: { + enableAnalyticalStorage: true + } + } + ); + NotificationConsoleUtils.clearInProgressMessageWithId(logId); + NotificationConsoleUtils.logConsoleMessage( + ConsoleDataType.Info, + "Enabled Azure Synapse Link for this account" + ); + TelemetryProcessor.traceSuccess(Action.EnableAzureSynapseLink, startTime); + this.databaseAccount(databaseAccount); + } catch (error) { + NotificationConsoleUtils.clearInProgressMessageWithId(logId); + NotificationConsoleUtils.logConsoleMessage( + ConsoleDataType.Error, + `Enabling Azure Synapse Link for this account failed. ${getErrorMessage(error)}` + ); + TelemetryProcessor.traceFailure(Action.EnableAzureSynapseLink, startTime); + } finally { + this.isSynapseLinkUpdating(false); + } + }, + + onSecondaryButtonClick: () => { + this._closeSynapseLinkModalDialog(); + TelemetryProcessor.traceCancel(Action.EnableAzureSynapseLink); + } + }; + this._addSynapseLinkDialogProps(addSynapseLinkDialogProps); + TelemetryProcessor.traceStart(Action.EnableAzureSynapseLink); + + // TODO: return result + } + + public copyUrlLink(src: any, event: MouseEvent): void { + const urlLinkInput: HTMLInputElement = document.getElementById("shareUrlLink") as HTMLInputElement; + urlLinkInput && urlLinkInput.select(); + document.execCommand("copy"); + this.shareUrlCopyHelperText("Copied"); + setTimeout(() => this.shareUrlCopyHelperText("Click to copy"), Constants.ClientDefaults.copyHelperTimeoutMs); + + TelemetryProcessor.trace(Action.SelectItem, ActionModifiers.Mark, { + description: "Copy full screen URL", + databaseAccountName: this.databaseAccount() && this.databaseAccount().name, + defaultExperience: this.defaultExperience && this.defaultExperience(), + dataExplorerArea: Constants.Areas.ShareDialog + }); + } + + public onCopyUrlLinkKeyPress(src: any, event: KeyboardEvent): boolean { + if (event.keyCode === Constants.KeyCodes.Enter || event.keyCode === Constants.KeyCodes.Space) { + this.copyUrlLink(src, null); + return false; + } + + return true; + } + + public copyToken(src: any, event: MouseEvent): void { + const tokenInput: HTMLInputElement = document.getElementById("shareToken") as HTMLInputElement; + tokenInput && tokenInput.select(); + document.execCommand("copy"); + this.shareTokenCopyHelperText("Copied"); + setTimeout(() => this.shareTokenCopyHelperText("Click to copy"), Constants.ClientDefaults.copyHelperTimeoutMs); + } + + public onCopyTokenKeyPress(src: any, event: KeyboardEvent): boolean { + if (event.keyCode === Constants.KeyCodes.Enter || event.keyCode === Constants.KeyCodes.Space) { + this.copyToken(src, null); + return false; + } + + return true; + } + + public renewToken = (): void => { + TelemetryProcessor.trace(Action.ConnectEncryptionToken); + this.renewTokenError(""); + const id: string = NotificationConsoleUtils.logConsoleMessage( + ConsoleDataType.InProgress, + "Initiating connection to account" + ); + this.renewExplorerShareAccess(this, this.tokenForRenewal()) + .fail((error: any) => { + const stringifiedError: string = getErrorMessage(error); + this.renewTokenError("Invalid connection string specified"); + NotificationConsoleUtils.logConsoleMessage( + ConsoleDataType.Error, + `Failed to initiate connection to account: ${stringifiedError}` + ); + }) + .finally(() => NotificationConsoleUtils.clearInProgressMessageWithId(id)); + }; + + public generateSharedAccessData(): void { + const id: string = NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.InProgress, "Generating share url"); + AuthHeadersUtil.generateEncryptedToken().then( + (tokenResponse: DataModels.GenerateTokenResponse) => { + NotificationConsoleUtils.clearInProgressMessageWithId(id); + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, "Successfully generated share url"); + this.shareAccessData({ + readWriteUrl: this._getShareAccessUrlForToken(tokenResponse.readWrite), + readUrl: this._getShareAccessUrlForToken(tokenResponse.read) + }); + !this.shareAccessData().readWriteUrl && this.shareAccessToggleState(ShareAccessToggleState.Read); // select read toggle by default for readers + this.shareAccessToggleState.valueHasMutated(); // to set initial url and token state + this.shareAccessData.valueHasMutated(); + this._openShareDialog(); + }, + (error: any) => { + NotificationConsoleUtils.clearInProgressMessageWithId(id); + NotificationConsoleUtils.logConsoleMessage( + ConsoleDataType.Error, + `Failed to generate share url: ${getErrorMessage(error)}` + ); + console.error(error); + } + ); + } + + public renewShareAccess(token: string): Q.Promise { + if (!this.renewExplorerShareAccess) { + return Q.reject("Not implemented"); + } + + const deferred: Q.Deferred = Q.defer(); + const id: string = NotificationConsoleUtils.logConsoleMessage( + ConsoleDataType.InProgress, + "Initiating connection to account" + ); + this.renewExplorerShareAccess(this, token) + .then( + () => { + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, "Connection successful"); + this.renewAdHocAccessPane && this.renewAdHocAccessPane.close(); + deferred.resolve(); + }, + (error: any) => { + NotificationConsoleUtils.logConsoleMessage( + ConsoleDataType.Error, + `Failed to connect: ${getErrorMessage(error)}` + ); + deferred.reject(error); + } + ) + .finally(() => { + NotificationConsoleUtils.clearInProgressMessageWithId(id); + }); + + return deferred.promise; + } + + public displayGuestAccessTokenRenewalPrompt(): void { + if (!$("#dataAccessTokenModal").dialog("instance")) { + const connectButton = { + text: "Connect", + class: "connectDialogButtons connectButton connectOkBtns", + click: () => { + this.renewAdHocAccessPane.open(); + $("#dataAccessTokenModal").dialog("close"); + } + }; + const cancelButton = { + text: "Cancel", + class: "connectDialogButtons cancelBtn", + click: () => { + $("#dataAccessTokenModal").dialog("close"); + } + }; + + $("#dataAccessTokenModal").dialog({ + autoOpen: false, + buttons: [connectButton, cancelButton], + closeOnEscape: false, + draggable: false, + dialogClass: "no-close", + height: 180, + modal: true, + position: { my: "center center", at: "center center", of: window }, + resizable: false, + title: "Temporary access expired", + width: 435, + close: (event: Event, ui: JQueryUI.DialogUIParams) => this.shouldShowDataAccessExpiryDialog(false) + }); + $("#dataAccessTokenModal").dialog("option", "classes", { + "ui-dialog-titlebar": "connectTitlebar" + }); + } + this.shouldShowDataAccessExpiryDialog(true); + $("#dataAccessTokenModal").dialog("open"); + } + + public isConnectExplorerVisible(): boolean { + return $("#connectExplorer").is(":visible") || false; + } + + public displayContextSwitchPromptForConnectionString(connectionString: string): void { + const yesButton = { + text: "OK", + class: "connectDialogButtons okBtn connectOkBtns", + click: () => { + $("#contextSwitchPrompt").dialog("close"); + this.tabsManager.closeTabs(); // clear all tabs so we dont leave any tabs from previous session open + this.renewShareAccess(connectionString); + } + }; + const noButton = { + text: "Cancel", + class: "connectDialogButtons cancelBtn", + click: () => { + $("#contextSwitchPrompt").dialog("close"); + } + }; + + if (!$("#contextSwitchPrompt").dialog("instance")) { + $("#contextSwitchPrompt").dialog({ + autoOpen: false, + buttons: [yesButton, noButton], + closeOnEscape: false, + draggable: false, + dialogClass: "no-close", + height: 255, + modal: true, + position: { my: "center center", at: "center center", of: window }, + resizable: false, + title: "Switch account", + width: 440, + close: (event: Event, ui: JQueryUI.DialogUIParams) => this.shouldShowDataAccessExpiryDialog(false) + }); + $("#contextSwitchPrompt").dialog("option", "classes", { + "ui-dialog-titlebar": "connectTitlebar" + }); + $("#contextSwitchPrompt").dialog("option", "open", (event: Event, ui: JQueryUI.DialogUIParams) => { + $(".ui-dialog ").css("z-index", 1001); + $("#contextSwitchPrompt") + .parent() + .siblings(".ui-widget-overlay") + .css("z-index", 1000); + }); + } + $("#contextSwitchPrompt").dialog("option", "buttons", [yesButton, noButton]); // rebind buttons so callbacks accept current connection string + this.shouldShowContextSwitchPrompt(true); + $("#contextSwitchPrompt").dialog("open"); + } + + public displayConnectExplorerForm(): void { + $("#divExplorer").hide(); + $("#connectExplorer").css("display", "flex"); + } + + public hideConnectExplorerForm(): void { + $("#connectExplorer").hide(); + $("#divExplorer").show(); + } + + public isReadWriteToggled: () => boolean = (): boolean => { + return this.shareAccessToggleState() === ShareAccessToggleState.ReadWrite; + }; + + public isReadToggled: () => boolean = (): boolean => { + return this.shareAccessToggleState() === ShareAccessToggleState.Read; + }; + + public toggleReadWrite: (src: any, event: MouseEvent) => void = (src: any, event: MouseEvent) => { + this.shareAccessToggleState(ShareAccessToggleState.ReadWrite); + }; + + public toggleRead: (src: any, event: MouseEvent) => void = (src: any, event: MouseEvent) => { + this.shareAccessToggleState(ShareAccessToggleState.Read); + }; + + public onToggleKeyDown: (src: any, event: KeyboardEvent) => boolean = (src: any, event: KeyboardEvent) => { + if (event.keyCode === Constants.KeyCodes.LeftArrow) { + this.toggleReadWrite(src, null); + return false; + } else if (event.keyCode === Constants.KeyCodes.RightArrow) { + this.toggleRead(src, null); + return false; + } + return true; + }; + + public isDatabaseNodeOrNoneSelected(): boolean { + return this.isNoneSelected() || this.isDatabaseNodeSelected(); + } + + public isDatabaseNodeSelected(): boolean { + return (this.selectedNode() && this.selectedNode().nodeKind === "Database") || false; + } + + public isNodeKindSelected(nodeKind: string): boolean { + return (this.selectedNode() && this.selectedNode().nodeKind === nodeKind) || false; + } + + public isNoneSelected(): boolean { + return this.selectedNode() == null; + } + + public isFeatureEnabled(feature: string): boolean { + const features = this.features(); + + if (!features) { + return false; + } + + if (feature in features && features[feature]) { + return true; + } + + return false; + } + + public logConsoleData(consoleData: ConsoleData): void { + this.notificationConsoleData.splice(0, 0, consoleData); + } + + public deleteInProgressConsoleDataWithId(id: string): void { + const updatedConsoleData = _.reject( + this.notificationConsoleData(), + (data: ConsoleData) => data.type === ConsoleDataType.InProgress && data.id === id + ); + this.notificationConsoleData(updatedConsoleData); + } + + public expandConsole(): void { + this.isNotificationConsoleExpanded(true); + } + + public collapseConsole(): void { + this.isNotificationConsoleExpanded(false); + } + + public toggleLeftPaneExpanded() { + this.isLeftPaneExpanded(!this.isLeftPaneExpanded()); + + if (this.isLeftPaneExpanded()) { + document.getElementById("expandToggleLeftPaneButton").focus(); + this.splitter.expandLeft(); + } else { + document.getElementById("collapseToggleLeftPaneButton").focus(); + this.splitter.collapseLeft(); + } + } + + public refreshDatabaseForResourceToken(): Q.Promise { + const databaseId = this.resourceTokenDatabaseId(); + const collectionId = this.resourceTokenCollectionId(); + if (!databaseId || !collectionId) { + return Q.reject(); + } + + const deferred: Q.Deferred = Q.defer(); + readCollection(databaseId, collectionId).then((collection: DataModels.Collection) => { + this.resourceTokenCollection(new ResourceTokenCollection(this, databaseId, collection)); + this.selectedNode(this.resourceTokenCollection()); + deferred.resolve(); + }); + + return deferred.promise; + } + + public refreshAllDatabases(isInitialLoad?: boolean): Q.Promise { + this.isRefreshingExplorer(true); + const startKey: number = TelemetryProcessor.traceStart(Action.LoadDatabases, { + databaseAccountName: this.databaseAccount() && this.databaseAccount().name, + defaultExperience: this.defaultExperience && this.defaultExperience(), + dataExplorerArea: Constants.Areas.ResourceTree + }); + let resourceTreeStartKey: number = null; + if (isInitialLoad) { + resourceTreeStartKey = TelemetryProcessor.traceStart(Action.LoadResourceTree, { + databaseAccountName: this.databaseAccount() && this.databaseAccount().name, + defaultExperience: this.defaultExperience && this.defaultExperience(), + dataExplorerArea: Constants.Areas.ResourceTree + }); + } + + // TODO: Refactor + const deferred: Q.Deferred = Q.defer(); + this._setLoadingStatusText("Fetching databases..."); + readDatabases().then( + (databases: DataModels.Database[]) => { + this._setLoadingStatusText("Successfully fetched databases."); + TelemetryProcessor.traceSuccess( + Action.LoadDatabases, + { + databaseAccountName: this.databaseAccount().name, + defaultExperience: this.defaultExperience(), + dataExplorerArea: Constants.Areas.ResourceTree + }, + startKey + ); + const currentlySelectedNode: ViewModels.TreeNode = this.selectedNode(); + const deltaDatabases = this.getDeltaDatabases(databases); + this.addDatabasesToList(deltaDatabases.toAdd); + this.deleteDatabasesFromList(deltaDatabases.toDelete); + this.selectedNode(currentlySelectedNode); + this._setLoadingStatusText("Fetching containers..."); + this.refreshAndExpandNewDatabases(deltaDatabases.toAdd) + .then( + () => { + this._setLoadingStatusText("Successfully fetched containers."); + deferred.resolve(); + }, + reason => { + this._setLoadingStatusText("Failed to fetch containers."); + deferred.reject(reason); + } + ) + .finally(() => this.isRefreshingExplorer(false)); + }, + error => { + this._setLoadingStatusText("Failed to fetch databases."); + this.isRefreshingExplorer(false); + deferred.reject(error); + const errorMessage = getErrorMessage(error); + TelemetryProcessor.traceFailure( + Action.LoadDatabases, + { + databaseAccountName: this.databaseAccount().name, + defaultExperience: this.defaultExperience(), + dataExplorerArea: Constants.Areas.ResourceTree, + error: errorMessage, + errorStack: getErrorStack(error) + }, + startKey + ); + NotificationConsoleUtils.logConsoleMessage( + ConsoleDataType.Error, + `Error while refreshing databases: ${errorMessage}` + ); + } + ); + + return deferred.promise.then( + () => { + if (resourceTreeStartKey != null) { + TelemetryProcessor.traceSuccess( + Action.LoadResourceTree, + { + databaseAccountName: this.databaseAccount() && this.databaseAccount().name, + defaultExperience: this.defaultExperience && this.defaultExperience(), + dataExplorerArea: Constants.Areas.ResourceTree + }, + resourceTreeStartKey + ); + } + }, + error => { + if (resourceTreeStartKey != null) { + TelemetryProcessor.traceFailure( + Action.LoadResourceTree, + { + databaseAccountName: this.databaseAccount() && this.databaseAccount().name, + defaultExperience: this.defaultExperience && this.defaultExperience(), + dataExplorerArea: Constants.Areas.ResourceTree, + error: getErrorMessage(error), + errorStack: getErrorStack(error) + }, + resourceTreeStartKey + ); + } + } + ); + } + + public onRefreshDatabasesKeyPress = (source: any, event: KeyboardEvent): boolean => { + if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) { + this.onRefreshResourcesClick(source, null); + return false; + } + return true; + }; + + public onRefreshResourcesClick = (source: any, event: MouseEvent): void => { + const startKey: number = TelemetryProcessor.traceStart(Action.LoadDatabases, { + description: "Refresh button clicked", + databaseAccountName: this.databaseAccount() && this.databaseAccount().name, + defaultExperience: this.defaultExperience && this.defaultExperience(), + dataExplorerArea: Constants.Areas.ResourceTree + }); + this.isRefreshingExplorer(true); + this.isAuthWithResourceToken() ? this.refreshDatabaseForResourceToken() : this.refreshAllDatabases(); + this.refreshNotebookList(); + }; + + public toggleLeftPaneExpandedKeyPress = (source: any, event: KeyboardEvent): boolean => { + if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) { + this.toggleLeftPaneExpanded(); + return false; + } + return true; + }; + + // Facade + public provideFeedbackEmail = () => { + window.open(Constants.Urls.feedbackEmail, "_self"); + }; + + public async getArcadiaToken(): Promise { + return new Promise((resolve: (token: string) => void, reject: (error: any) => void) => { + sendCachedDataMessage(MessageTypes.GetArcadiaToken, undefined /** params **/).then( + (token: string) => { + resolve(token); + }, + (error: any) => { + Logger.logError(getErrorMessage(error), "Explorer/getArcadiaToken"); + resolve(undefined); + } + ); + }); + } + + private async _getArcadiaWorkspaces(): Promise { + try { + const workspaces = await this._arcadiaManager.listWorkspacesAsync([userContext.subscriptionId]); + let workspaceItems: ArcadiaWorkspaceItem[] = new Array(workspaces.length); + const sparkPromises: Promise[] = []; + workspaces.forEach((workspace, i) => { + let promise = this._arcadiaManager.listSparkPoolsAsync(workspaces[i].id).then( + sparkpools => { + workspaceItems[i] = { ...workspace, sparkPools: sparkpools }; + }, + error => { + Logger.logError(getErrorMessage(error), "Explorer/this._arcadiaManager.listSparkPoolsAsync"); + } + ); + sparkPromises.push(promise); + }); + + return Promise.all(sparkPromises).then(() => workspaceItems); + } catch (error) { + handleError(error, "Explorer/this._arcadiaManager.listWorkspacesAsync", "Get Arcadia workspaces failed"); + return Promise.resolve([]); + } + } + + public async createWorkspace(): Promise { + return sendCachedDataMessage(MessageTypes.CreateWorkspace, undefined /** params **/); + } + + public async createSparkPool(workspaceId: string): Promise { + return sendCachedDataMessage(MessageTypes.CreateSparkPool, [workspaceId]); + } + + public async initNotebooks(databaseAccount: DataModels.DatabaseAccount): Promise { + if (!databaseAccount) { + throw new Error("No database account specified"); + } + + if (this._isInitializingNotebooks) { + return; + } + this._isInitializingNotebooks = true; + + await this.ensureNotebookWorkspaceRunning(); + let connectionInfo: DataModels.NotebookWorkspaceConnectionInfo = { + authToken: undefined, + notebookServerEndpoint: undefined + }; + try { + connectionInfo = await this.notebookWorkspaceManager.getNotebookConnectionInfoAsync( + databaseAccount.id, + "default" + ); + } catch (error) { + this._isInitializingNotebooks = false; + handleError( + error, + "initNotebooks/getNotebookConnectionInfoAsync", + `Failed to get notebook workspace connection info: ${getErrorMessage(error)}` + ); + throw error; + } finally { + // Overwrite with feature flags + if (this.isFeatureEnabled(Constants.Features.notebookServerUrl)) { + connectionInfo.notebookServerEndpoint = this.features()[Constants.Features.notebookServerUrl]; + } + + if (this.isFeatureEnabled(Constants.Features.notebookServerToken)) { + connectionInfo.authToken = this.features()[Constants.Features.notebookServerToken]; + } + + this.notebookServerInfo(connectionInfo); + this.notebookServerInfo.valueHasMutated(); + this.refreshNotebookList(); + } + + this._isInitializingNotebooks = false; + } + + public resetNotebookWorkspace() { + if (!this.isNotebookEnabled() || !this.notebookManager?.notebookClient) { + handleError( + "Attempt to reset notebook workspace, but notebook is not enabled", + "Explorer/resetNotebookWorkspace" + ); + return; + } + const resetConfirmationDialogProps: DialogProps = { + isModal: true, + visible: true, + title: "Reset Workspace", + subText: "This lets you keep your notebook files and the workspace will be restored to default. Proceed anyway?", + primaryButtonText: "OK", + secondaryButtonText: "Cancel", + onPrimaryButtonClick: this._resetNotebookWorkspace, + onSecondaryButtonClick: this._closeModalDialog + }; + this._dialogProps(resetConfirmationDialogProps); + } + + private async _containsDefaultNotebookWorkspace(databaseAccount: DataModels.DatabaseAccount): Promise { + if (!databaseAccount) { + return false; + } + + try { + const workspaces = await this.notebookWorkspaceManager.getNotebookWorkspacesAsync(databaseAccount?.id); + return workspaces && workspaces.length > 0 && workspaces.some(workspace => workspace.name === "default"); + } catch (error) { + Logger.logError(getErrorMessage(error), "Explorer/_containsDefaultNotebookWorkspace"); + return false; + } + } + + private async ensureNotebookWorkspaceRunning() { + if (!this.databaseAccount()) { + return; + } + + let clearMessage; + try { + const notebookWorkspace = await this.notebookWorkspaceManager.getNotebookWorkspaceAsync( + this.databaseAccount().id, + "default" + ); + if ( + notebookWorkspace && + notebookWorkspace.properties && + notebookWorkspace.properties.status && + notebookWorkspace.properties.status.toLowerCase() === "stopped" + ) { + clearMessage = NotificationConsoleUtils.logConsoleProgress("Initializing notebook workspace"); + await this.notebookWorkspaceManager.startNotebookWorkspaceAsync(this.databaseAccount().id, "default"); + } + } catch (error) { + handleError(error, "Explorer/ensureNotebookWorkspaceRunning", "Failed to initialize notebook workspace"); + } finally { + clearMessage && clearMessage(); + } + } + + private _resetNotebookWorkspace = async () => { + this._closeModalDialog(); + const id = NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.InProgress, "Resetting notebook workspace"); + try { + await this.notebookManager?.notebookClient.resetWorkspace(); + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, "Successfully reset notebook workspace"); + TelemetryProcessor.traceSuccess(Action.ResetNotebookWorkspace); + } catch (error) { + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, `Failed to reset notebook workspace: ${error}`); + TelemetryProcessor.traceFailure(Action.ResetNotebookWorkspace, { + error: getErrorMessage(error), + errorStack: getErrorStack(error) + }); + throw error; + } finally { + NotificationConsoleUtils.clearInProgressMessageWithId(id); + } + }; + + private _closeModalDialog = () => { + this._dialogProps().visible = false; + this._dialogProps.valueHasMutated(); + }; + + private _closeSynapseLinkModalDialog = () => { + this._addSynapseLinkDialogProps().visible = false; + this._addSynapseLinkDialogProps.valueHasMutated(); + }; + + private _shouldProcessMessage(event: MessageEvent): boolean { + if (typeof event.data !== "object") { + return false; + } + if (event.data["signature"] !== "pcIframe") { + return false; + } + if (!("data" in event.data)) { + return false; + } + if (typeof event.data["data"] !== "object") { + return false; + } + + // before initialization completed give exception + const message = event.data.data; + if (!this._importExplorerConfigComplete && message && message.type) { + const messageType = message.type; + switch (messageType) { + case MessageTypes.SendNotification: + case MessageTypes.ClearNotification: + case MessageTypes.LoadingStatus: + case MessageTypes.InitTestExplorer: + return true; + } + } + if (!("inputs" in event.data["data"]) && !this._importExplorerConfigComplete) { + return false; + } + return true; + } + + public handleMessage(event: MessageEvent) { + if (isInvalidParentFrameOrigin(event)) { + return; + } + + if (!this._shouldProcessMessage(event)) { + return; + } + + const message: any = event.data.data; + const inputs: ViewModels.DataExplorerInputsFrame = message.inputs; + + const isRunningInPortal = configContext.platform === Platform.Portal; + const isRunningInDevMode = process.env.NODE_ENV === "development"; + if (inputs && configContext.BACKEND_ENDPOINT && isRunningInPortal && isRunningInDevMode) { + inputs.extensionEndpoint = configContext.PROXY_PATH; + } + + this.initDataExplorerWithFrameInputs(inputs); + + const openAction: ActionContracts.DataExplorerAction = message.openAction; + if (!!openAction) { + if (this.isRefreshingExplorer()) { + const subscription = this.databases.subscribe((databases: ViewModels.Database[]) => { + handleOpenAction(openAction, this.nonSystemDatabases(), this); + subscription.dispose(); + }); + } else { + handleOpenAction(openAction, this.nonSystemDatabases(), this); + } + } + if (message.actionType === ActionContracts.ActionType.TransmitCachedData) { + handleCachedDataMessage(message); + return; + } + if (message.type) { + switch (message.type) { + case MessageTypes.UpdateLocationHash: + if (!message.locationHash) { + break; + } + hasher.replaceHash(message.locationHash); + RouteHandler.getInstance().parseHash(message.locationHash); + break; + case MessageTypes.SendNotification: + if (!message.message) { + break; + } + NotificationConsoleUtils.logConsoleMessage( + message.consoleDataType || ConsoleDataType.Info, + message.message, + message.id + ); + break; + case MessageTypes.ClearNotification: + if (!message.id) { + break; + } + NotificationConsoleUtils.clearInProgressMessageWithId(message.id); + break; + case MessageTypes.LoadingStatus: + if (!message.text) { + break; + } + this._setLoadingStatusText(message.text, message.title); + break; + } + return; + } + + this.splashScreenAdapter.forceRender(); + } + + public findSelectedDatabase(): ViewModels.Database { + if (!this.selectedNode()) { + return null; + } + if (this.selectedNode().nodeKind === "Database") { + return _.find(this.databases(), (database: ViewModels.Database) => database.id() === this.selectedNode().id()); + } + return this.findSelectedCollection().database; + } + + public findDatabaseWithId(databaseId: string): ViewModels.Database { + return _.find(this.databases(), (database: ViewModels.Database) => database.id() === databaseId); + } + + public isLastNonEmptyDatabase(): boolean { + if (this.isLastDatabase() && this.databases()[0].collections && this.databases()[0].collections().length > 0) { + return true; + } + return false; + } + + public isLastDatabase(): boolean { + if (this.databases().length > 1) { + return false; + } + return true; + } + + public isSelectedDatabaseShared(): boolean { + const database = this.findSelectedDatabase(); + if (!!database) { + return database.offer && !!database.offer(); + } + + return false; + } + + public setSelfServeType(inputs: ViewModels.DataExplorerInputsFrame): void { + const selfServeFeature = inputs.features[Constants.Features.selfServeType]; + if (selfServeFeature) { + // self serve type received from query string + const selfServeType = SelfServeType[selfServeFeature?.toLowerCase() as keyof typeof SelfServeType]; + this.selfServeType(selfServeType ? selfServeType : SelfServeType.invalid); + } else if (inputs.selfServeType) { + // self serve type received from portal + this.selfServeType(inputs.selfServeType); + } else { + this.selfServeType(SelfServeType.none); + } + } + + public initDataExplorerWithFrameInputs(inputs: ViewModels.DataExplorerInputsFrame): void { + if (inputs != null) { + // In development mode, save the iframe message from the portal in session storage. + // This allows webpack hot reload to funciton properly + if (process.env.NODE_ENV === "development") { + sessionStorage.setItem("portalDataExplorerInitMessage", JSON.stringify(inputs)); + } + + const authorizationToken = inputs.authorizationToken || ""; + const masterKey = inputs.masterKey || ""; + const databaseAccount = inputs.databaseAccount || null; + if (inputs.defaultCollectionThroughput) { + this.collectionCreationDefaults = inputs.defaultCollectionThroughput; + } + this.features(inputs.features); + this.serverId(inputs.serverId); + this.databaseAccount(databaseAccount); + this.subscriptionType(inputs.subscriptionType); + this.hasWriteAccess(inputs.hasWriteAccess); + this.flight(inputs.addCollectionDefaultFlight); + this.isTryCosmosDBSubscription(inputs.isTryCosmosDBSubscription); + this.isAuthWithResourceToken(inputs.isAuthWithresourceToken); + this.setFeatureFlagsFromFlights(inputs.flights); + this.setSelfServeType(inputs); + this._importExplorerConfigComplete = true; + + updateConfigContext({ + BACKEND_ENDPOINT: inputs.extensionEndpoint || "", + ARM_ENDPOINT: normalizeArmEndpoint(inputs.csmEndpoint || configContext.ARM_ENDPOINT) + }); + + updateUserContext({ + authorizationToken, + masterKey, + databaseAccount, + resourceGroup: inputs.resourceGroup, + subscriptionId: inputs.subscriptionId, + subscriptionType: inputs.subscriptionType, + quotaId: inputs.quotaId + }); + TelemetryProcessor.traceSuccess( + Action.LoadDatabaseAccount, + { + resourceId: this.databaseAccount && this.databaseAccount().id, + dataExplorerArea: Constants.Areas.ResourceTree, + databaseAccount: this.databaseAccount && this.databaseAccount() + }, + inputs.loadDatabaseAccountTimestamp + ); + + this.isAccountReady(true); + } + } + + public setFeatureFlagsFromFlights(flights: readonly string[]): void { + if (!flights) { + return; + } + if (flights.indexOf(Constants.Flights.AutoscaleTest) !== -1) { + this.isAutoscaleDefaultEnabled(true); + } + if (flights.indexOf(Constants.Flights.MongoIndexing) !== -1) { + this.isMongoIndexingEnabled(true); + } + } + + public findSelectedCollection(): ViewModels.Collection { + return (this.selectedNode().nodeKind === "Collection" + ? this.selectedNode() + : this.selectedNode().collection) as ViewModels.Collection; + } + + // TODO: Refactor below methods, minimize dependencies and add unit tests where necessary + public findSelectedStoredProcedure(): StoredProcedure { + const selectedCollection: ViewModels.Collection = this.findSelectedCollection(); + return _.find(selectedCollection.storedProcedures(), (storedProcedure: StoredProcedure) => { + const openedSprocTab = this.tabsManager.getTabs( + ViewModels.CollectionTabKind.StoredProcedures, + tab => tab.node && tab.node.rid === storedProcedure.rid + ); + return ( + storedProcedure.rid === this.selectedNode().rid || + (!!openedSprocTab && openedSprocTab.length > 0 && openedSprocTab[0].isActive()) + ); + }); + } + + public findSelectedUDF(): UserDefinedFunction { + const selectedCollection: ViewModels.Collection = this.findSelectedCollection(); + return _.find(selectedCollection.userDefinedFunctions(), (userDefinedFunction: UserDefinedFunction) => { + const openedUdfTab = this.tabsManager.getTabs( + ViewModels.CollectionTabKind.UserDefinedFunctions, + tab => tab.node && tab.node.rid === userDefinedFunction.rid + ); + return ( + userDefinedFunction.rid === this.selectedNode().rid || + (!!openedUdfTab && openedUdfTab.length > 0 && openedUdfTab[0].isActive()) + ); + }); + } + + public findSelectedTrigger(): Trigger { + const selectedCollection: ViewModels.Collection = this.findSelectedCollection(); + return _.find(selectedCollection.triggers(), (trigger: Trigger) => { + const openedTriggerTab = this.tabsManager.getTabs( + ViewModels.CollectionTabKind.Triggers, + tab => tab.node && tab.node.rid === trigger.rid + ); + return ( + trigger.rid === this.selectedNode().rid || + (!!openedTriggerTab && openedTriggerTab.length > 0 && openedTriggerTab[0].isActive()) + ); + }); + } + + public closeAllPanes(): void { + this._panes.forEach((pane: ContextualPaneBase) => pane.close()); + } + + public isRunningOnNationalCloud(): boolean { + return ( + this.serverId() === Constants.ServerIds.blackforest || + this.serverId() === Constants.ServerIds.fairfax || + this.serverId() === Constants.ServerIds.mooncake + ); + } + + public onUpdateTabsButtons(buttons: CommandButtonComponentProps[]): void { + this.commandBarComponentAdapter.onUpdateTabsButtons(buttons); + } + + public signInAad = () => { + TelemetryProcessor.trace(Action.SignInAad, undefined, { area: "Explorer" }); + sendMessage({ + type: MessageTypes.AadSignIn + }); + }; + + public onSwitchToConnectionString = () => { + $("#connectWithAad").hide(); + $("#connectWithConnectionString").show(); + }; + + public clickHostedAccountSwitch = () => { + sendMessage({ + type: MessageTypes.UpdateAccountSwitch, + click: true + }); + }; + + public clickHostedDirectorySwitch = () => { + sendMessage({ + type: MessageTypes.UpdateDirectoryControl, + click: true + }); + }; + + public refreshDatabaseAccount = () => { + sendMessage({ + type: MessageTypes.RefreshDatabaseAccount + }); + }; + + private refreshAndExpandNewDatabases(newDatabases: ViewModels.Database[]): Q.Promise { + // we reload collections for all databases so the resource tree reflects any collection-level changes + // i.e addition of stored procedures, etc. + const deferred: Q.Deferred = Q.defer(); + let loadCollectionPromises: Q.Promise[] = []; + + // If the user has a lot of databases, only load expanded databases. + const databasesToLoad = + this.databases().length <= Explorer.MaxNbDatabasesToAutoExpand + ? this.databases() + : this.databases().filter(db => db.isDatabaseExpanded()); + + const startKey: number = TelemetryProcessor.traceStart(Action.LoadCollections, { + databaseAccountName: this.databaseAccount() && this.databaseAccount().name, + defaultExperience: this.defaultExperience && this.defaultExperience(), + dataExplorerArea: Constants.Areas.ResourceTree + }); + databasesToLoad.forEach(async (database: ViewModels.Database) => { + await database.loadCollections(); + const isNewDatabase: boolean = _.some(newDatabases, (db: ViewModels.Database) => db.id() === database.id()); + if (isNewDatabase) { + database.expandDatabase(); + } + this.tabsManager.refreshActiveTab(tab => tab.collection && tab.collection.getDatabase().id() === database.id()); + }); + + Q.all(loadCollectionPromises).done( + () => { + deferred.resolve(); + TelemetryProcessor.traceSuccess( + Action.LoadCollections, + { dataExplorerArea: Constants.Areas.ResourceTree }, + startKey + ); + }, + (error: any) => { + deferred.reject(error); + TelemetryProcessor.traceFailure( + Action.LoadCollections, + { + databaseAccountName: this.databaseAccount() && this.databaseAccount().name, + defaultExperience: this.defaultExperience && this.defaultExperience(), + dataExplorerArea: Constants.Areas.ResourceTree, + error: getErrorMessage(error), + errorStack: getErrorStack(error) + }, + startKey + ); + } + ); + return deferred.promise; + } + + // TODO: Abstract this elsewhere + private _openShareDialog: () => void = (): void => { + if (!$("#shareDataAccessFlyout").dialog("instance")) { + const accountMetadataInfo = { + databaseAccountName: this.databaseAccount() && this.databaseAccount().name, + defaultExperience: this.defaultExperience && this.defaultExperience(), + dataExplorerArea: Constants.Areas.ShareDialog + }; + const openFullscreenButton = { + text: "Open", + class: "openFullScreenBtn openFullScreenCancelBtn", + click: () => { + TelemetryProcessor.trace( + Action.SelectItem, + ActionModifiers.Mark, + _.extend({}, { description: "Open full screen" }, accountMetadataInfo) + ); + + const hiddenAnchorElement: HTMLAnchorElement = document.createElement("a"); + hiddenAnchorElement.href = this.shareAccessUrl(); + hiddenAnchorElement.target = "_blank"; + $("#shareDataAccessFlyout").dialog("close"); + hiddenAnchorElement.click(); + } + }; + const cancelButton = { + text: "Cancel", + class: "shareCancelButton openFullScreenCancelBtn", + click: () => { + TelemetryProcessor.trace( + Action.SelectItem, + ActionModifiers.Mark, + _.extend({}, { description: "Cancel open full screen" }, accountMetadataInfo) + ); + $("#shareDataAccessFlyout").dialog("close"); + } + }; + $("#shareDataAccessFlyout").dialog({ + autoOpen: false, + buttons: [openFullscreenButton, cancelButton], + closeOnEscape: true, + draggable: false, + dialogClass: "no-close", + position: { my: "right top", at: "right bottom", of: $(".OpenFullScreen") }, + resizable: false, + title: "Open Full Screen", + width: 400, + close: (event: Event, ui: JQueryUI.DialogUIParams) => this.shouldShowShareDialogContents(false) + }); + $("#shareDataAccessFlyout").dialog("option", "classes", { + "ui-widget-content": "shareUrlDialog", + "ui-widget-header": "shareUrlTitle", + "ui-dialog-titlebar-close": "shareClose", + "ui-button": "shareCloseIcon", + "ui-button-icon": "cancelIcon", + "ui-icon": "" + }); + $("#shareDataAccessFlyout").dialog("option", "open", (event: Event, ui: JQueryUI.DialogUIParams) => + $(".openFullScreenBtn").focus() + ); + } + $("#shareDataAccessFlyout").dialog("close"); + this.shouldShowShareDialogContents(true); + $("#shareDataAccessFlyout").dialog("open"); + }; + + private _getShareAccessUrlForToken(token: string): string { + if (!token) { + return undefined; + } + + const urlPrefixWithKeyParam: string = `${configContext.hostedExplorerURL}?key=`; + const currentActiveTab = this.tabsManager.activeTab(); + + return `${urlPrefixWithKeyParam}${token}#/${(currentActiveTab && currentActiveTab.hashLocation()) || ""}`; + } + + private _initSettings() { + if (!ExplorerSettings.hasSettingsDefined()) { + ExplorerSettings.createDefaultSettings(); + } + } + + public findCollection(databaseId: string, collectionId: string): ViewModels.Collection { + const database: ViewModels.Database = this.databases().find( + (database: ViewModels.Database) => database.id() === databaseId + ); + return database?.collections().find((collection: ViewModels.Collection) => collection.id() === collectionId); + } + + public isLastCollection(): boolean { + let collectionCount = 0; + if (this.databases().length == 0) { + return false; + } + for (let i = 0; i < this.databases().length; i++) { + const database = this.databases()[i]; + collectionCount += database.collections().length; + if (collectionCount > 1) { + return false; + } + } + return true; + } + + private getDeltaDatabases( + updatedDatabaseList: DataModels.Database[] + ): { toAdd: ViewModels.Database[]; toDelete: ViewModels.Database[] } { + const newDatabases: DataModels.Database[] = _.filter(updatedDatabaseList, (database: DataModels.Database) => { + const databaseExists = _.some( + this.databases(), + (existingDatabase: ViewModels.Database) => existingDatabase.id() === database.id + ); + return !databaseExists; + }); + const databasesToAdd: ViewModels.Database[] = newDatabases.map( + (newDatabase: DataModels.Database) => new Database(this, newDatabase) + ); + + let databasesToDelete: ViewModels.Database[] = []; + ko.utils.arrayForEach(this.databases(), (database: ViewModels.Database) => { + const databasePresentInUpdatedList = _.some( + updatedDatabaseList, + (db: DataModels.Database) => db.id === database.id() + ); + if (!databasePresentInUpdatedList) { + databasesToDelete.push(database); + } + }); + + return { toAdd: databasesToAdd, toDelete: databasesToDelete }; + } + + private addDatabasesToList(databases: ViewModels.Database[]): void { + this.databases( + this.databases() + .concat(databases) + .sort((database1, database2) => database1.id().localeCompare(database2.id())) + ); + } + + private deleteDatabasesFromList(databasesToRemove: ViewModels.Database[]): void { + const databasesToKeep: ViewModels.Database[] = []; + + ko.utils.arrayForEach(this.databases(), (database: ViewModels.Database) => { + const shouldRemoveDatabase = _.some(databasesToRemove, (db: ViewModels.Database) => db.id === database.id); + if (!shouldRemoveDatabase) { + databasesToKeep.push(database); + } + }); + + this.databases(databasesToKeep); + } + + public uploadFile(name: string, content: string, parent: NotebookContentItem): Promise { + if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { + const error = "Attempt to upload notebook, but notebook is not enabled"; + handleError(error, "Explorer/uploadFile"); + throw new Error(error); + } + + const promise = this.notebookManager?.notebookContentClient.uploadFileAsync(name, content, parent); + promise + .then(() => this.resourceTree.triggerRender()) + .catch((reason: any) => this.showOkModalDialog("Unable to upload file", reason)); + return promise; + } + + public async importAndOpen(path: string): Promise { + const name = NotebookUtil.getName(path); + const item = NotebookUtil.createNotebookContentItem(name, path, "file"); + const parent = this.resourceTree.myNotebooksContentRoot; + + if (parent && parent.children && this.isNotebookEnabled() && this.notebookManager?.notebookClient) { + const existingItem = _.find(parent.children, node => node.name === name); + if (existingItem) { + return this.openNotebook(existingItem); + } + + const content = await this.readFile(item); + const uploadedItem = await this.uploadFile(name, content, parent); + return this.openNotebook(uploadedItem); + } + + return Promise.resolve(false); + } + + public async importAndOpenContent(name: string, content: string): Promise { + const parent = this.resourceTree.myNotebooksContentRoot; + + if (parent && parent.children && this.isNotebookEnabled() && this.notebookManager?.notebookClient) { + if (this.notebookToImport && this.notebookToImport.name === name && this.notebookToImport.content === content) { + this.notebookToImport = undefined; // we don't want to try opening this notebook again + } + + const existingItem = _.find(parent.children, node => node.name === name); + if (existingItem) { + return this.openNotebook(existingItem); + } + + const uploadedItem = await this.uploadFile(name, content, parent); + return this.openNotebook(uploadedItem); + } + + this.notebookToImport = { name, content }; // we'll try opening this notebook later on + return Promise.resolve(false); + } + + public async publishNotebook(name: string, content: string | unknown, parentDomElement: HTMLElement): Promise { + if (this.notebookManager) { + await this.notebookManager.openPublishNotebookPane( + name, + content, + parentDomElement, + this.isLinkInjectionEnabled() + ); + this.publishNotebookPaneAdapter = this.notebookManager.publishNotebookPaneAdapter; + this.isPublishNotebookPaneEnabled(true); + } + } + + public copyNotebook(name: string, content: string): void { + if (this.notebookManager) { + this.notebookManager.openCopyNotebookPane(name, content); + this.copyNotebookPaneAdapter = this.notebookManager.copyNotebookPaneAdapter; + this.isCopyNotebookPaneEnabled(true); + } + } + + public showOkModalDialog(title: string, msg: string): void { + this._dialogProps({ + isModal: true, + visible: true, + title, + subText: msg, + primaryButtonText: "Close", + secondaryButtonText: undefined, + onPrimaryButtonClick: this._closeModalDialog, + onSecondaryButtonClick: undefined + }); + } + + public showOkCancelModalDialog( + title: string, + msg: string, + okLabel: string, + onOk: () => void, + cancelLabel: string, + onCancel: () => void, + choiceGroupProps?: IChoiceGroupProps, + textFieldProps?: TextFieldProps, + isPrimaryButtonDisabled?: boolean + ): void { + this._dialogProps({ + isModal: true, + visible: true, + title, + subText: msg, + primaryButtonText: okLabel, + secondaryButtonText: cancelLabel, + onPrimaryButtonClick: () => { + this._closeModalDialog(); + onOk && onOk(); + }, + onSecondaryButtonClick: () => { + this._closeModalDialog(); + onCancel && onCancel(); + }, + choiceGroupProps, + textFieldProps, + primaryButtonDisabled: isPrimaryButtonDisabled + }); + } + + /** + * Note: To keep it simple, this creates a disconnected NotebookContentItem that is not connected to the resource tree. + * Connecting it to a tree possibly requires the intermediate missing folders if the item is nested in a subfolder. + * Manually creating the missing folders between the root and its parent dir would break the UX: expanding a folder + * will not fetch its content if the children array exists (and has only one child which was manually created). + * Fetching the intermediate folders possibly involves a few chained async calls which isn't ideal. + * + * @param name + * @param path + */ + public createNotebookContentItemFile(name: string, path: string): NotebookContentItem { + return NotebookUtil.createNotebookContentItem(name, path, "file"); + } + + public async openNotebook(notebookContentItem: NotebookContentItem): Promise { + if (!notebookContentItem || !notebookContentItem.path) { + throw new Error(`Invalid notebookContentItem: ${notebookContentItem}`); + } + + const notebookTabs = this.tabsManager.getTabs( + ViewModels.CollectionTabKind.NotebookV2, + tab => + (tab as NotebookV2Tab).notebookPath && + FileSystemUtil.isPathEqual((tab as NotebookV2Tab).notebookPath(), notebookContentItem.path) + ) as NotebookV2Tab[]; + let notebookTab = notebookTabs && notebookTabs[0]; + + if (notebookTab) { + this.tabsManager.activateTab(notebookTab); + } else { + const options: NotebookTabOptions = { + account: userContext.databaseAccount, + tabKind: ViewModels.CollectionTabKind.NotebookV2, + node: null, + title: notebookContentItem.name, + tabPath: notebookContentItem.path, + collection: null, + masterKey: userContext.masterKey || "", + hashLocation: "notebooks", + isActive: ko.observable(false), + isTabsContentExpanded: ko.observable(true), + onLoadStartKey: null, + onUpdateTabsButtons: this.onUpdateTabsButtons, + container: this, + notebookContentItem + }; + + try { + const NotebookTabV2 = await import(/* webpackChunkName: "NotebookV2Tab" */ "./Tabs/NotebookV2Tab"); + notebookTab = new NotebookTabV2.default(options); + this.tabsManager.activateNewTab(notebookTab); + } catch (reason) { + console.error("Import NotebookV2Tab failed!", reason); + return false; + } + } + + return true; + } + + public renameNotebook(notebookFile: NotebookContentItem): Q.Promise { + if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { + const error = "Attempt to rename notebook, but notebook is not enabled"; + handleError(error, "Explorer/renameNotebook"); + throw new Error(error); + } + + // Don't delete if tab is open to avoid accidental deletion + const openedNotebookTabs = this.tabsManager.getTabs( + ViewModels.CollectionTabKind.NotebookV2, + (tab: NotebookV2Tab) => { + return tab.notebookPath && FileSystemUtil.isPathEqual(tab.notebookPath(), notebookFile.path); + } + ); + if (openedNotebookTabs.length > 0) { + this.showOkModalDialog("Unable to rename file", "This file is being edited. Please close the tab and try again."); + return Q.reject(); + } + + const originalPath = notebookFile.path; + const result = this.stringInputPane + .openWithOptions({ + errorMessage: "Could not rename notebook", + inProgressMessage: "Renaming notebook to", + successMessage: "Renamed notebook to", + inputLabel: "Enter new notebook name", + paneTitle: "Rename Notebook", + submitButtonLabel: "Rename", + defaultInput: FileSystemUtil.stripExtension(notebookFile.name, "ipynb"), + onSubmit: (input: string) => this.notebookManager?.notebookContentClient.renameNotebook(notebookFile, input) + }) + .then(newNotebookFile => { + const notebookTabs = this.tabsManager.getTabs( + ViewModels.CollectionTabKind.NotebookV2, + (tab: NotebookV2Tab) => tab.notebookPath && FileSystemUtil.isPathEqual(tab.notebookPath(), originalPath) + ); + notebookTabs.forEach(tab => { + tab.tabTitle(newNotebookFile.name); + tab.tabPath(newNotebookFile.path); + (tab as NotebookV2Tab).notebookPath(newNotebookFile.path); + }); + + return newNotebookFile; + }); + result.then(() => this.resourceTree.triggerRender()); + return result; + } + + public onCreateDirectory(parent: NotebookContentItem): Q.Promise { + if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { + const error = "Attempt to create notebook directory, but notebook is not enabled"; + handleError(error, "Explorer/onCreateDirectory"); + throw new Error(error); + } + + const result = this.stringInputPane.openWithOptions({ + errorMessage: "Could not create directory ", + inProgressMessage: "Creating directory ", + successMessage: "Created directory ", + inputLabel: "Enter new directory name", + paneTitle: "Create new directory", + submitButtonLabel: "Create", + defaultInput: "", + onSubmit: (input: string) => this.notebookManager?.notebookContentClient.createDirectory(parent, input) + }); + result.then(() => this.resourceTree.triggerRender()); + return result; + } + + public readFile(notebookFile: NotebookContentItem): Promise { + if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { + const error = "Attempt to read file, but notebook is not enabled"; + handleError(error, "Explorer/downloadFile"); + throw new Error(error); + } + + return this.notebookManager?.notebookContentClient.readFileContent(notebookFile.path); + } + + public downloadFile(notebookFile: NotebookContentItem): Promise { + if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { + const error = "Attempt to download file, but notebook is not enabled"; + handleError(error, "Explorer/downloadFile"); + throw new Error(error); + } + + const clearMessage = NotificationConsoleUtils.logConsoleProgress(`Downloading ${notebookFile.path}`); + + return this.notebookManager?.notebookContentClient.readFileContent(notebookFile.path).then( + (content: string) => { + const blob = stringToBlob(content, "text/plain"); + if (navigator.msSaveBlob) { + // for IE and Edge + navigator.msSaveBlob(blob, notebookFile.name); + } else { + const downloadLink: HTMLAnchorElement = document.createElement("a"); + const url = URL.createObjectURL(blob); + downloadLink.href = url; + downloadLink.target = "_self"; + downloadLink.download = notebookFile.name; + + // for some reason, FF displays the download prompt only when + // the link is added to the dom so we add and remove it + document.body.appendChild(downloadLink); + downloadLink.click(); + downloadLink.remove(); + } + + clearMessage(); + }, + (error: any) => { + NotificationConsoleUtils.logConsoleMessage( + ConsoleDataType.Error, + `Could not download notebook ${getErrorMessage(error)}` + ); + + clearMessage(); + } + ); + } + + private async _refreshNotebooksEnabledStateForAccount(): Promise { + const authType = window.authType as AuthType; + if ( + authType === AuthType.EncryptedToken || + authType === AuthType.ResourceToken || + authType === AuthType.MasterKey + ) { + this.isNotebooksEnabledForAccount(false); + return; + } + + const databaseAccount = this.databaseAccount(); + const databaseAccountLocation = databaseAccount && databaseAccount.location.toLowerCase(); + const disallowedLocationsUri = `${configContext.BACKEND_ENDPOINT}/api/disallowedLocations`; + const authorizationHeader = getAuthorizationHeader(); + try { + const response = await fetch(disallowedLocationsUri, { + method: "POST", + body: JSON.stringify({ + resourceTypes: [Constants.ArmResourceTypes.notebookWorkspaces] + }), + headers: { + [authorizationHeader.header]: authorizationHeader.token, + [Constants.HttpHeaders.contentType]: "application/json" + } + }); + + if (!response.ok) { + throw new Error("Failed to fetch disallowed locations"); + } + + const disallowedLocations: string[] = await response.json(); + if (!disallowedLocations) { + Logger.logInfo("No disallowed locations found", "Explorer/isNotebooksEnabledForAccount"); + this.isNotebooksEnabledForAccount(true); + return; + } + const isAccountInAllowedLocation = !disallowedLocations.some( + disallowedLocation => disallowedLocation === databaseAccountLocation + ); + this.isNotebooksEnabledForAccount(isAccountInAllowedLocation); + } catch (error) { + Logger.logError(getErrorMessage(error), "Explorer/isNotebooksEnabledForAccount"); + this.isNotebooksEnabledForAccount(false); + } + } + + public _refreshSparkEnabledStateForAccount = async (): Promise => { + const subscriptionId = userContext.subscriptionId; + const armEndpoint = configContext.ARM_ENDPOINT; + const authType = window.authType as AuthType; + if (!subscriptionId || !armEndpoint || authType === AuthType.EncryptedToken) { + // explorer is not aware of the database account yet + this.isSparkEnabledForAccount(false); + return; + } + + const featureUri = `subscriptions/${subscriptionId}/providers/Microsoft.Features/providers/Microsoft.DocumentDb/features/${Constants.AfecFeatures.Spark}`; + const resourceProviderClient = new ResourceProviderClientFactory().getOrCreate(featureUri); + try { + const sparkNotebooksFeature: DataModels.AfecFeature = await resourceProviderClient.getAsync( + featureUri, + Constants.ArmApiVersions.armFeatures + ); + const isEnabled = + (sparkNotebooksFeature && + sparkNotebooksFeature.properties && + sparkNotebooksFeature.properties.state === "Registered") || + false; + this.isSparkEnabledForAccount(isEnabled); + } catch (error) { + Logger.logError(getErrorMessage(error), "Explorer/isSparkEnabledForAccount"); + this.isSparkEnabledForAccount(false); + } + }; + + public _isAfecFeatureRegistered = async (featureName: string): Promise => { + const subscriptionId = userContext.subscriptionId; + const armEndpoint = configContext.ARM_ENDPOINT; + const authType = window.authType as AuthType; + if (!featureName || !subscriptionId || !armEndpoint || authType === AuthType.EncryptedToken) { + // explorer is not aware of the database account yet + return false; + } + + const featureUri = `subscriptions/${subscriptionId}/providers/Microsoft.Features/providers/Microsoft.DocumentDb/features/${featureName}`; + const resourceProviderClient = new ResourceProviderClientFactory().getOrCreate(featureUri); + try { + const featureStatus: DataModels.AfecFeature = await resourceProviderClient.getAsync( + featureUri, + Constants.ArmApiVersions.armFeatures + ); + const isEnabled = + (featureStatus && featureStatus.properties && featureStatus.properties.state === "Registered") || false; + return isEnabled; + } catch (error) { + Logger.logError(getErrorMessage(error), "Explorer/isSparkEnabledForAccount"); + return false; + } + }; + private refreshNotebookList = async (): Promise => { + if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { + return; + } + + await this.resourceTree.initialize(); + this.notebookManager?.refreshPinnedRepos(); + if (this.notebookToImport) { + this.importAndOpenContent(this.notebookToImport.name, this.notebookToImport.content); + } + }; + + public deleteNotebookFile(item: NotebookContentItem): Promise { + if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { + const error = "Attempt to delete notebook file, but notebook is not enabled"; + handleError(error, "Explorer/deleteNotebookFile"); + throw new Error(error); + } + + // Don't delete if tab is open to avoid accidental deletion + const openedNotebookTabs = this.tabsManager.getTabs( + ViewModels.CollectionTabKind.NotebookV2, + (tab: NotebookV2Tab) => { + return tab.notebookPath && FileSystemUtil.isPathEqual(tab.notebookPath(), item.path); + } + ); + if (openedNotebookTabs.length > 0) { + this.showOkModalDialog("Unable to delete file", "This file is being edited. Please close the tab and try again."); + return Promise.reject(); + } + + if (item.type === NotebookContentItemType.Directory && item.children && item.children.length > 0) { + this._dialogProps({ + isModal: true, + visible: true, + title: "Unable to delete file", + subText: "Directory is not empty.", + primaryButtonText: "Close", + secondaryButtonText: undefined, + onPrimaryButtonClick: this._closeModalDialog, + onSecondaryButtonClick: undefined + }); + return Promise.reject(); + } + + return this.notebookManager?.notebookContentClient.deleteContentItem(item).then( + () => { + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Successfully deleted: ${item.path}`); + }, + (reason: any) => { + NotificationConsoleUtils.logConsoleMessage( + ConsoleDataType.Error, + `Failed to delete "${item.path}": ${JSON.stringify(reason)}` + ); + } + ); + } + + /** + * This creates a new notebook file, then opens the notebook + */ + public onNewNotebookClicked(parent?: NotebookContentItem): void { + if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { + const error = "Attempt to create new notebook, but notebook is not enabled"; + handleError(error, "Explorer/onNewNotebookClicked"); + throw new Error(error); + } + + parent = parent || this.resourceTree.myNotebooksContentRoot; + + const notificationProgressId = NotificationConsoleUtils.logConsoleMessage( + ConsoleDataType.InProgress, + `Creating new notebook in ${parent.path}` + ); + + const startKey: number = TelemetryProcessor.traceStart(Action.CreateNewNotebook, { + databaseAccountName: this.databaseAccount() && this.databaseAccount().name, + defaultExperience: this.defaultExperience && this.defaultExperience(), + dataExplorerArea: Constants.Areas.Notebook + }); + + this.notebookManager?.notebookContentClient + .createNewNotebookFile(parent) + .then((newFile: NotebookContentItem) => { + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Successfully created: ${newFile.name}`); + TelemetryProcessor.traceSuccess( + Action.CreateNewNotebook, + { + databaseAccountName: this.databaseAccount().name, + defaultExperience: this.defaultExperience(), + dataExplorerArea: Constants.Areas.Notebook + }, + startKey + ); + return this.openNotebook(newFile); + }) + .then(() => this.resourceTree.triggerRender()) + .catch((error: any) => { + const errorMessage = `Failed to create a new notebook: ${getErrorMessage(error)}`; + NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, errorMessage); + TelemetryProcessor.traceFailure( + Action.CreateNewNotebook, + { + databaseAccountName: this.databaseAccount().name, + defaultExperience: this.defaultExperience(), + dataExplorerArea: Constants.Areas.Notebook, + error: errorMessage, + errorStack: getErrorStack(error) + }, + startKey + ); + }) + .finally(() => NotificationConsoleUtils.clearInProgressMessageWithId(notificationProgressId)); + } + + public onUploadToNotebookServerClicked(parent?: NotebookContentItem): void { + parent = parent || this.resourceTree.myNotebooksContentRoot; + + this.uploadFilePane.openWithOptions({ + paneTitle: "Upload file to notebook server", + selectFileInputLabel: "Select file to upload", + errorMessage: "Could not upload file", + inProgressMessage: "Uploading file to notebook server", + successMessage: "Successfully uploaded file to notebook server", + onSubmit: async (file: File): Promise => { + const readFileAsText = (inputFile: File): Promise => { + const reader = new FileReader(); + return new Promise((resolve, reject) => { + reader.onerror = () => { + reader.abort(); + reject(`Problem parsing file: ${inputFile}`); + }; + reader.onload = () => { + resolve(reader.result as string); + }; + reader.readAsText(inputFile); + }); + }; + + const fileContent = await readFileAsText(file); + return this.uploadFile(file.name, fileContent, parent); + }, + extensions: undefined, + submitButtonLabel: "Upload" + }); + } + + public refreshContentItem(item: NotebookContentItem): Promise { + if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { + const error = "Attempt to refresh notebook list, but notebook is not enabled"; + handleError(error, "Explorer/refreshContentItem"); + return Promise.reject(new Error(error)); + } + + return this.notebookManager?.notebookContentClient.updateItemChildren(item); + } + + public getNotebookBasePath(): string { + return this.notebookBasePath(); + } + + public openNotebookTerminal(kind: ViewModels.TerminalKind) { + let title: string; + let hashLocation: string; + + switch (kind) { + case ViewModels.TerminalKind.Default: + title = "Terminal"; + hashLocation = "terminal"; + break; + + case ViewModels.TerminalKind.Mongo: + title = "Mongo Shell"; + hashLocation = "mongo-shell"; + break; + + case ViewModels.TerminalKind.Cassandra: + title = "Cassandra Shell"; + hashLocation = "cassandra-shell"; + break; + + default: + throw new Error("Terminal kind: ${kind} not supported"); + } + + const terminalTabs: TerminalTab[] = this.tabsManager.getTabs( + ViewModels.CollectionTabKind.Terminal, + tab => tab.hashLocation() == hashLocation + ) as TerminalTab[]; + let terminalTab: TerminalTab = terminalTabs && terminalTabs[0]; + + if (terminalTab) { + this.tabsManager.activateTab(terminalTab); + } else { + const newTab = new TerminalTab({ + account: userContext.databaseAccount, + tabKind: ViewModels.CollectionTabKind.Terminal, + node: null, + title: title, + tabPath: title, + collection: null, + hashLocation: hashLocation, + isActive: ko.observable(false), + isTabsContentExpanded: ko.observable(true), + onLoadStartKey: null, + onUpdateTabsButtons: this.onUpdateTabsButtons, + container: this, + kind: kind + }); + + this.tabsManager.activateNewTab(newTab); + } + } + + public async openGallery(notebookUrl?: string, galleryItem?: IGalleryItem, isFavorite?: boolean) { + let title: string = "Gallery"; + let hashLocation: string = "gallery"; + + const galleryTabs = this.tabsManager.getTabs( + ViewModels.CollectionTabKind.Gallery, + tab => tab.hashLocation() == hashLocation + ); + let galleryTab = galleryTabs && galleryTabs[0]; + + if (galleryTab) { + this.tabsManager.activateTab(galleryTab); + } else { + if (!this.galleryTab) { + this.galleryTab = await import(/* webpackChunkName: "GalleryTab" */ "./Tabs/GalleryTab"); + } + + const newTab = new this.galleryTab.default({ + // GalleryTabOptions + account: userContext.databaseAccount, + container: this, + junoClient: this.notebookManager?.junoClient, + notebookUrl, + galleryItem, + isFavorite, + // TabOptions + tabKind: ViewModels.CollectionTabKind.Gallery, + title: title, + tabPath: title, + documentClientUtility: null, + isActive: ko.observable(false), + hashLocation: hashLocation, + onUpdateTabsButtons: this.onUpdateTabsButtons, + isTabsContentExpanded: ko.observable(true), + onLoadStartKey: null + }); + + this.tabsManager.activateNewTab(newTab); + } + } + + public async openNotebookViewer(notebookUrl: string) { + const title = path.basename(notebookUrl); + const hashLocation = notebookUrl; + + if (!this.notebookViewerTab) { + this.notebookViewerTab = await import(/* webpackChunkName: "NotebookViewerTab" */ "./Tabs/NotebookViewerTab"); + } + + const notebookViewerTabModule = this.notebookViewerTab; + + let isNotebookViewerOpen = (tab: TabsBase) => { + const notebookViewerTab = tab as typeof notebookViewerTabModule.default; + return notebookViewerTab.notebookUrl === notebookUrl; + }; + + const notebookViewerTabs = this.tabsManager.getTabs(ViewModels.CollectionTabKind.NotebookV2, tab => { + return tab.hashLocation() == hashLocation && isNotebookViewerOpen(tab); + }); + let notebookViewerTab = notebookViewerTabs && notebookViewerTabs[0]; + + if (notebookViewerTab) { + this.tabsManager.activateNewTab(notebookViewerTab); + } else { + notebookViewerTab = new this.notebookViewerTab.default({ + account: userContext.databaseAccount, + tabKind: ViewModels.CollectionTabKind.NotebookViewer, + node: null, + title: title, + tabPath: title, + documentClientUtility: null, + collection: null, + hashLocation: hashLocation, + isActive: ko.observable(false), + isTabsContentExpanded: ko.observable(true), + onLoadStartKey: null, + onUpdateTabsButtons: this.onUpdateTabsButtons, + container: this, + notebookUrl + }); + + this.tabsManager.activateNewTab(notebookViewerTab); + } + } + + public onNewCollectionClicked(): void { + if (this.isPreferredApiCassandra()) { + this.cassandraAddCollectionPane.open(); + } else { + this.addCollectionPane.open(this.selectedDatabaseId()); + } + document.getElementById("linkAddCollection").focus(); + } + + private refreshCommandBarButtons(): void { + const activeTab = this.tabsManager.activeTab(); + if (activeTab) { + activeTab.onActivate(); // TODO only update tabs buttons? + } else { + this.onUpdateTabsButtons([]); + } + } + + private getTokenRefreshInterval(token: string): number { + let tokenRefreshInterval = Constants.ClientDefaults.arcadiaTokenRefreshInterval; + if (!token) { + return tokenRefreshInterval; + } + + try { + const tokenPayload = decryptJWTToken(this.arcadiaToken()); + if (tokenPayload && tokenPayload.hasOwnProperty("exp")) { + const expirationTime = tokenPayload.exp as number; // seconds since unix epoch + const now = new Date().getTime() / 1000; + const tokenExpirationIntervalInMs = (expirationTime - now) * 1000; + if (tokenExpirationIntervalInMs < tokenRefreshInterval) { + tokenRefreshInterval = + tokenExpirationIntervalInMs - Constants.ClientDefaults.arcadiaTokenRefreshIntervalPaddingMs; + } + } + return tokenRefreshInterval; + } catch (error) { + Logger.logError(getErrorMessage(error), "Explorer/getTokenRefreshInterval"); + return tokenRefreshInterval; + } + } + + private _setLoadingStatusText(text: string, title: string = "Welcome to Azure Cosmos DB") { + if (!text) { + return; + } + + const loadingText = document.getElementById("explorerLoadingStatusText"); + if (!loadingText) { + Logger.logError( + "getElementById('explorerLoadingStatusText') failed to find element", + "Explorer/_setLoadingStatusText" + ); + return; + } + loadingText.innerHTML = text; + + const loadingTitle = document.getElementById("explorerLoadingStatusTitle"); + if (!loadingTitle) { + Logger.logError( + "getElementById('explorerLoadingStatusTitle') failed to find element", + "Explorer/_setLoadingStatusText" + ); + } else { + loadingTitle.innerHTML = title; + } + } + + private _openSetupNotebooksPaneForQuickstart(): void { + const title = "Enable Notebooks (Preview)"; + const description = + "You have not yet created a notebooks workspace for this account. To proceed and start using notebooks, we'll need to create a default notebooks workspace in this account."; + + this.setupNotebooksPane.openWithTitleAndDescription(title, description); + } + + public async handleOpenFileAction(path: string): Promise { + if (this.isAccountReady() && !(await this._containsDefaultNotebookWorkspace(this.databaseAccount()))) { + this.closeAllPanes(); + this._openSetupNotebooksPaneForQuickstart(); + } + + // We still use github urls like https://github.com/Azure-Samples/cosmos-notebooks/blob/master/CSharp_quickstarts/GettingStarted_CSharp.ipynb + // when launching a notebook quickstart from Portal. In future we should just use gallery id and use Juno to fetch instead of directly + // calling GitHub. For now convert this url to a raw url and download content. + const gitHubInfo = fromContentUri(path); + if (gitHubInfo) { + const rawUrl = toRawContentUri(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.branch, gitHubInfo.path); + const response = await fetch(rawUrl); + if (response.status === Constants.HttpStatusCodes.OK) { + this.notebookToImport = { + name: NotebookUtil.getName(path), + content: await response.text() + }; + + this.importAndOpenContent(this.notebookToImport.name, this.notebookToImport.content); + } + } + } + + public async loadSelectedDatabaseOffer(): Promise { + const database = this.findSelectedDatabase(); + await database?.loadOffer(); + } + + public async loadDatabaseOffers(): Promise { + await Promise.all( + this.databases()?.map(async (database: ViewModels.Database) => { + await database.loadOffer(); + }) + ); + } + + public isFirstResourceCreated(): boolean { + const databases: ViewModels.Database[] = this.databases(); + + if (!databases || databases.length === 0) { + return false; + } + + return databases.some(database => { + // user has created at least one collection + if (database.collections()?.length > 0) { + return true; + } + // user has created a database with shared throughput + if (database.offer()) { + return true; + } + // use has created an empty database without shared throughput + return false; + }); + } +} diff --git a/src/Main.tsx b/src/Main.tsx index bb6867133..2e2540d72 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -81,6 +81,7 @@ import { DefaultExperienceUtility } from "./Shared/DefaultExperienceUtility"; import { parseResourceTokenConnectionString } from "./Platform/Hosted/Helpers/ResourceTokenUtils"; import { AccountKind, DefaultAccountExperience, ServerIds } from "./Common/Constants"; import { listKeys } from "./Utils/arm/generatedClients/2020-04-01/databaseAccounts"; +import { SelfServeType } from "./SelfServe/SelfServeUtils"; const App: React.FunctionComponent = () => { useEffect(() => { @@ -89,6 +90,7 @@ const App: React.FunctionComponent = () => { if (config.platform === Platform.Hosted) { const win = (window as unknown) as HostedExplorerChildFrame; explorer = new Explorer(); + explorer.selfServeType(SelfServeType.none); if (win.hostedConfig.authType === AuthType.EncryptedToken) { // TODO: Remove window.authType window.authType = AuthType.EncryptedToken; @@ -236,6 +238,7 @@ const App: React.FunctionComponent = () => { } else if (config.platform === Platform.Emulator) { window.authType = AuthType.MasterKey; explorer = new Explorer(); + explorer.selfServeType(SelfServeType.none); explorer.databaseAccount(emulatorAccount); explorer.isAccountReady(true); } else if (config.platform === Platform.Portal) { @@ -261,7 +264,17 @@ const App: React.FunctionComponent = () => { return (
-
+
+
{/* Main Command Bar - Start */}
{/* Main Command Bar - End */} @@ -453,17 +466,21 @@ const App: React.FunctionComponent = () => { />
{/* Global loader - Start */} +
-

- Azure Cosmos DB -

-

- Welcome to Azure Cosmos DB -

- +
+
+

+ Azure Cosmos DB +

+

+ Welcome to Azure Cosmos DB +

+ +
{/* Global loader - End */} diff --git a/src/SelfServe/ClassDecorators.tsx b/src/SelfServe/ClassDecorators.tsx new file mode 100644 index 000000000..aeb9ad8e2 --- /dev/null +++ b/src/SelfServe/ClassDecorators.tsx @@ -0,0 +1,14 @@ +import { Info } from "../Explorer/Controls/SmartUi/SmartUiComponent"; +import { addPropertyToMap, buildSmartUiDescriptor } from "./SelfServeUtils"; + +export const IsDisplayable = (): ClassDecorator => { + return target => { + buildSmartUiDescriptor(target.name, target.prototype); + }; +}; + +export const ClassInfo = (info: (() => Promise) | Info): ClassDecorator => { + return target => { + addPropertyToMap(target.prototype, "root", target.name, "info", info); + }; +}; diff --git a/src/SelfServe/Example/SelfServeExample.tsx b/src/SelfServe/Example/SelfServeExample.tsx new file mode 100644 index 000000000..739e49cda --- /dev/null +++ b/src/SelfServe/Example/SelfServeExample.tsx @@ -0,0 +1,167 @@ +import { PropertyInfo, OnChange, Values } from "../PropertyDecorators"; +import { ClassInfo, IsDisplayable } from "../ClassDecorators"; +import { SelfServeBaseClass } from "../SelfServeUtils"; +import { ChoiceItem, Info, InputType, UiType } from "../../Explorer/Controls/SmartUi/SmartUiComponent"; +import { SessionStorageUtility } from "../../Shared/StorageUtility"; + +export enum Regions { + NorthCentralUS = "NCUS", + WestUS = "WUS", + EastUS2 = "EUS2" +} + +export const regionDropdownItems: ChoiceItem[] = [ + { label: "North Central US", key: Regions.NorthCentralUS }, + { label: "West US", key: Regions.WestUS }, + { label: "East US 2", key: Regions.EastUS2 } +]; + +export const selfServeExampleInfo: Info = { + message: "This is a self serve class" +}; + +export const regionDropdownInfo: Info = { + message: "More regions can be added in the future." +}; + +const onDbThroughputChange = (currentState: Map, newValue: InputType): Map => { + currentState.set("dbThroughput", newValue); + currentState.set("collectionThroughput", newValue); + return currentState; +}; + +const initializeMaxThroughput = async (): Promise => { + return 10000; +}; + +/* + This is an example self serve class that auto generates UI components for your feature. + + Each self serve class + - Needs to extends the SelfServeBase class. + - Needs to have the @IsDisplayable() decorator to tell the compiler that UI needs to be generated from this class. + - Needs to define an onSubmit() function, a callback for when the submit button is clicked. + - Needs to define an initialize() function, to set default values for the inputs. + + You can test this self serve UI by using the featureflag '?feature.selfServeType=example' + and plumb in similar feature flags for your own self serve class. +*/ + +/* + @IsDisplayable() + - role: Indicates to the compiler that UI should be generated from this class. +*/ +@IsDisplayable() +/* + @ClassInfo() + - optional + - input: Info | () => Promise + - role: Display an Info bar as the first element of the UI. +*/ +@ClassInfo(selfServeExampleInfo) +export default class SelfServeExample extends SelfServeBaseClass { + /* + onSubmit() + - input: (currentValues: Map) => Promise + - role: Callback that is triggerred when the submit button is clicked. You should perform your rest API + calls here using the data from the different inputs passed as a Map to this callback function. + + In this example, the onSubmit callback simply sets the value for keys corresponding to the field name + in the SessionStorage. + */ + public onSubmit = async (currentValues: Map): Promise => { + SessionStorageUtility.setEntry("regions", currentValues.get("regions")?.toString()); + SessionStorageUtility.setEntry("enableLogging", currentValues.get("enableLogging")?.toString()); + SessionStorageUtility.setEntry("accountName", currentValues.get("accountName")?.toString()); + SessionStorageUtility.setEntry("dbThroughput", currentValues.get("dbThroughput")?.toString()); + SessionStorageUtility.setEntry("collectionThroughput", currentValues.get("collectionThroughput")?.toString()); + }; + + /* + initialize() + - input: () => Promise> + - role: Set default values for the properties of this class. + + The properties of this class (namely regions, enableLogging, accountName, dbThroughput, collectionThroughput), + having the @Values decorator, will each correspond to an UI element. Their values can be of 'InputType'. Their + defaults can be set by setting values in a Map corresponding to the field's name. + + Typically, you can make rest calls in the async initialize function, to fetch the initial values for + these fields. This is called after the onSubmit callback, to reinitialize the defaults. + + In this example, the initialize function simply reads the SessionStorage to fetch the default values + for these fields. These are then set when the changes are submitted. + */ + public initialize = async (): Promise> => { + const defaults = new Map(); + defaults.set("regions", SessionStorageUtility.getEntry("regions")); + defaults.set("enableLogging", SessionStorageUtility.getEntry("enableLogging") === "true"); + const stringInput = SessionStorageUtility.getEntry("accountName"); + defaults.set("accountName", stringInput ? stringInput : ""); + const numberSliderInput = parseInt(SessionStorageUtility.getEntry("dbThroughput")); + defaults.set("dbThroughput", isNaN(numberSliderInput) ? 1 : numberSliderInput); + const numberSpinnerInput = parseInt(SessionStorageUtility.getEntry("collectionThroughput")); + defaults.set("collectionThroughput", isNaN(numberSpinnerInput) ? 1 : numberSpinnerInput); + return defaults; + }; + + /* + @PropertyInfo() + - optional + - input: Info | () => Promise + - role: Display an Info bar above the UI element for this property. + */ + @PropertyInfo(regionDropdownInfo) + + /* + @Values() : + - input: NumberInputOptions | StringInputOptions | BooleanInputOptions | ChoiceInputOptions + - role: Specifies the required options to display the property as TextBox, Number Spinner/Slider, Radio buton or Dropdown. + */ + @Values({ label: "Regions", choices: regionDropdownItems }) + regions: ChoiceItem; + + @Values({ + label: "Enable Logging", + trueLabel: "Enable", + falseLabel: "Disable" + }) + enableLogging: boolean; + + @Values({ + label: "Account Name", + placeholder: "Enter the account name" + }) + accountName: string; + + /* + @OnChange() + - optional + - input: (currentValues: Map, newValue: InputType) => Map + - role: Takes a Map of current values and the newValue for this property as inputs. This is called when a property + changes its value in the UI. This can be used to change other input values based on some other input. + + The new Map of propertyName -> value is returned. + + In this example, the onDbThroughputChange function sets the collectionThroughput to the same value as the dbThroughput + when the slider in moved in the UI. + */ + @OnChange(onDbThroughputChange) + @Values({ + label: "Database Throughput", + min: 400, + max: initializeMaxThroughput, + step: 100, + uiType: UiType.Slider + }) + dbThroughput: number; + + @Values({ + label: "Collection Throughput", + min: 400, + max: initializeMaxThroughput, + step: 100, + uiType: UiType.Spinner + }) + collectionThroughput: number; +} diff --git a/src/SelfServe/PropertyDecorators.tsx b/src/SelfServe/PropertyDecorators.tsx new file mode 100644 index 000000000..9b8017b4e --- /dev/null +++ b/src/SelfServe/PropertyDecorators.tsx @@ -0,0 +1,101 @@ +import { ChoiceItem, Info, InputType, UiType } from "../Explorer/Controls/SmartUi/SmartUiComponent"; +import { addPropertyToMap, CommonInputTypes } from "./SelfServeUtils"; + +type ValueOf = T[keyof T]; +interface Decorator { + name: keyof CommonInputTypes; + value: ValueOf; +} + +interface InputOptionsBase { + label: string; +} + +export interface NumberInputOptions extends InputOptionsBase { + min: (() => Promise) | number; + max: (() => Promise) | number; + step: (() => Promise) | number; + uiType: UiType; +} + +export interface StringInputOptions extends InputOptionsBase { + placeholder?: (() => Promise) | string; +} + +export interface BooleanInputOptions extends InputOptionsBase { + trueLabel: (() => Promise) | string; + falseLabel: (() => Promise) | string; +} + +export interface ChoiceInputOptions extends InputOptionsBase { + choices: (() => Promise) | ChoiceItem[]; +} + +type InputOptions = NumberInputOptions | StringInputOptions | BooleanInputOptions | ChoiceInputOptions; + +const isNumberInputOptions = (inputOptions: InputOptions): inputOptions is NumberInputOptions => { + return "min" in inputOptions; +}; + +const isBooleanInputOptions = (inputOptions: InputOptions): inputOptions is BooleanInputOptions => { + return "trueLabel" in inputOptions; +}; + +const isChoiceInputOptions = (inputOptions: InputOptions): inputOptions is ChoiceInputOptions => { + return "choices" in inputOptions; +}; + +const addToMap = (...decorators: Decorator[]): PropertyDecorator => { + return (target, property) => { + let className = target.constructor.name; + const propertyName = property.toString(); + if (className === "Function") { + //eslint-disable-next-line @typescript-eslint/ban-types + className = (target as Function).name; + throw new Error(`Property '${propertyName}' in class '${className}'should be not be static.`); + } + + const propertyType = (Reflect.getMetadata("design:type", target, property)?.name as string)?.toLowerCase(); + addPropertyToMap(target, propertyName, className, "type", propertyType); + addPropertyToMap(target, propertyName, className, "dataFieldName", propertyName); + + decorators.map((decorator: Decorator) => + addPropertyToMap(target, propertyName, className, decorator.name, decorator.value) + ); + }; +}; + +export const OnChange = ( + onChange: (currentState: Map, newValue: InputType) => Map +): PropertyDecorator => { + return addToMap({ name: "onChange", value: onChange }); +}; + +export const PropertyInfo = (info: (() => Promise) | Info): PropertyDecorator => { + return addToMap({ name: "info", value: info }); +}; + +export const Values = (inputOptions: InputOptions): PropertyDecorator => { + if (isNumberInputOptions(inputOptions)) { + return addToMap( + { name: "label", value: inputOptions.label }, + { name: "min", value: inputOptions.min }, + { name: "max", value: inputOptions.max }, + { name: "step", value: inputOptions.step }, + { name: "uiType", value: inputOptions.uiType } + ); + } else if (isBooleanInputOptions(inputOptions)) { + return addToMap( + { name: "label", value: inputOptions.label }, + { name: "trueLabel", value: inputOptions.trueLabel }, + { name: "falseLabel", value: inputOptions.falseLabel } + ); + } else if (isChoiceInputOptions(inputOptions)) { + return addToMap({ name: "label", value: inputOptions.label }, { name: "choices", value: inputOptions.choices }); + } else { + return addToMap( + { name: "label", value: inputOptions.label }, + { name: "placeholder", value: inputOptions.placeholder } + ); + } +}; diff --git a/src/SelfServe/SelfServeComponent.test.tsx b/src/SelfServe/SelfServeComponent.test.tsx new file mode 100644 index 000000000..32cf9ba73 --- /dev/null +++ b/src/SelfServe/SelfServeComponent.test.tsx @@ -0,0 +1,104 @@ +import React from "react"; +import { shallow } from "enzyme"; +import { SelfServeDescriptor, SelfServeComponent, SelfServeComponentState } from "./SelfServeComponent"; +import { InputType, UiType } from "../Explorer/Controls/SmartUi/SmartUiComponent"; + +describe("SelfServeComponent", () => { + const defaultValues = new Map([ + ["throughput", "450"], + ["analyticalStore", "false"], + ["database", "db2"] + ]); + const initializeMock = jest.fn(async () => defaultValues); + const onSubmitMock = jest.fn(async () => { + return; + }); + + const exampleData: SelfServeDescriptor = { + initialize: initializeMock, + onSubmit: onSubmitMock, + inputNames: ["throughput", "containerId", "analyticalStore", "database"], + root: { + id: "root", + info: { + message: "Start at $24/mo per database", + link: { + href: "https://aka.ms/azure-cosmos-db-pricing", + text: "More Details" + } + }, + children: [ + { + id: "throughput", + input: { + label: "Throughput (input)", + dataFieldName: "throughput", + type: "number", + min: 400, + max: 500, + step: 10, + defaultValue: 400, + uiType: UiType.Spinner + } + }, + { + id: "containerId", + input: { + label: "Container id", + dataFieldName: "containerId", + type: "string" + } + }, + { + id: "analyticalStore", + input: { + label: "Analytical Store", + trueLabel: "Enabled", + falseLabel: "Disabled", + defaultValue: true, + dataFieldName: "analyticalStore", + type: "boolean" + } + }, + { + id: "database", + input: { + label: "Database", + dataFieldName: "database", + type: "object", + choices: [ + { label: "Database 1", key: "db1" }, + { label: "Database 2", key: "db2" }, + { label: "Database 3", key: "db3" } + ], + defaultKey: "db2" + } + } + ] + } + }; + + const verifyDefaultsSet = (currentValues: Map): void => { + for (const key of currentValues.keys()) { + if (defaultValues.has(key)) { + expect(defaultValues.get(key)).toEqual(currentValues.get(key)); + } + } + }; + + it("should render", async () => { + const wrapper = shallow(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(wrapper).toMatchSnapshot(); + + // initialize() should be called and defaults should be set when component is mounted + expect(initializeMock).toHaveBeenCalled(); + const state = wrapper.state() as SelfServeComponentState; + verifyDefaultsSet(state.currentValues); + + // onSubmit() must be called when submit button is clicked + const submitButton = wrapper.find("#submitButton"); + submitButton.simulate("click"); + expect(onSubmitMock).toHaveBeenCalled(); + }); +}); diff --git a/src/SelfServe/SelfServeComponent.tsx b/src/SelfServe/SelfServeComponent.tsx new file mode 100644 index 000000000..7bb6208a1 --- /dev/null +++ b/src/SelfServe/SelfServeComponent.tsx @@ -0,0 +1,218 @@ +import React from "react"; +import { IStackTokens, PrimaryButton, Spinner, SpinnerSize, Stack } from "office-ui-fabric-react"; +import { + ChoiceItem, + InputType, + InputTypeValue, + SmartUiComponent, + UiType, + SmartUiDescriptor, + Info +} from "../Explorer/Controls/SmartUi/SmartUiComponent"; + +export interface BaseInput { + label: (() => Promise) | string; + dataFieldName: string; + type: InputTypeValue; + onChange?: (currentState: Map, newValue: InputType) => Map; + placeholder?: (() => Promise) | string; + errorMessage?: string; +} + +export interface NumberInput extends BaseInput { + min: (() => Promise) | number; + max: (() => Promise) | number; + step: (() => Promise) | number; + defaultValue?: number; + uiType: UiType; +} + +export interface BooleanInput extends BaseInput { + trueLabel: (() => Promise) | string; + falseLabel: (() => Promise) | string; + defaultValue?: boolean; +} + +export interface StringInput extends BaseInput { + defaultValue?: string; +} + +export interface ChoiceInput extends BaseInput { + choices: (() => Promise) | ChoiceItem[]; + defaultKey?: string; +} + +export interface Node { + id: string; + info?: (() => Promise) | Info; + input?: AnyInput; + children?: Node[]; +} + +export interface SelfServeDescriptor { + root: Node; + initialize?: () => Promise>; + onSubmit?: (currentValues: Map) => Promise; + inputNames?: string[]; +} + +export type AnyInput = NumberInput | BooleanInput | StringInput | ChoiceInput; + +export interface SelfServeComponentProps { + descriptor: SelfServeDescriptor; +} + +export interface SelfServeComponentState { + root: SelfServeDescriptor; + currentValues: Map; + baselineValues: Map; + isRefreshing: boolean; +} + +export class SelfServeComponent extends React.Component { + componentDidMount(): void { + this.initializeSmartUiComponent(); + } + + constructor(props: SelfServeComponentProps) { + super(props); + this.state = { + root: this.props.descriptor, + currentValues: new Map(), + baselineValues: new Map(), + isRefreshing: false + }; + } + + private initializeSmartUiComponent = async (): Promise => { + this.setState({ isRefreshing: true }); + await this.initializeSmartUiNode(this.props.descriptor.root); + await this.setDefaults(); + this.setState({ isRefreshing: false }); + }; + + private setDefaults = async (): Promise => { + this.setState({ isRefreshing: true }); + let { currentValues, baselineValues } = this.state; + + const initialValues = await this.props.descriptor.initialize(); + for (const key of initialValues.keys()) { + if (this.props.descriptor.inputNames.indexOf(key) === -1) { + this.setState({ isRefreshing: false }); + throw new Error(`${key} is not an input property of this class.`); + } + + currentValues = currentValues.set(key, initialValues.get(key)); + baselineValues = baselineValues.set(key, initialValues.get(key)); + } + this.setState({ currentValues, baselineValues, isRefreshing: false }); + }; + + public discard = (): void => { + let { currentValues } = this.state; + const { baselineValues } = this.state; + for (const key of baselineValues.keys()) { + currentValues = currentValues.set(key, baselineValues.get(key)); + } + this.setState({ currentValues }); + }; + + private initializeSmartUiNode = async (currentNode: Node): Promise => { + currentNode.info = await this.getResolvedValue(currentNode.info); + + if (currentNode.input) { + currentNode.input = await this.getResolvedInput(currentNode.input); + } + + const promises = currentNode.children?.map(async (child: Node) => await this.initializeSmartUiNode(child)); + if (promises) { + await Promise.all(promises); + } + }; + + private getResolvedInput = async (input: AnyInput): Promise => { + input.label = await this.getResolvedValue(input.label); + input.placeholder = await this.getResolvedValue(input.placeholder); + + switch (input.type) { + case "string": { + return input as StringInput; + } + case "number": { + const numberInput = input as NumberInput; + numberInput.min = await this.getResolvedValue(numberInput.min); + numberInput.max = await this.getResolvedValue(numberInput.max); + numberInput.step = await this.getResolvedValue(numberInput.step); + return numberInput; + } + case "boolean": { + const booleanInput = input as BooleanInput; + booleanInput.trueLabel = await this.getResolvedValue(booleanInput.trueLabel); + booleanInput.falseLabel = await this.getResolvedValue(booleanInput.falseLabel); + return booleanInput; + } + default: { + const choiceInput = input as ChoiceInput; + choiceInput.choices = await this.getResolvedValue(choiceInput.choices); + return choiceInput; + } + } + }; + + public async getResolvedValue(value: T | (() => Promise)): Promise { + if (value instanceof Function) { + return value(); + } + return value; + } + + private onInputChange = (input: AnyInput, newValue: InputType) => { + if (input.onChange) { + const newValues = input.onChange(this.state.currentValues, newValue); + this.setState({ currentValues: newValues }); + } else { + const dataFieldName = input.dataFieldName; + const { currentValues } = this.state; + currentValues.set(dataFieldName, newValue); + this.setState({ currentValues }); + } + }; + + public render(): JSX.Element { + const containerStackTokens: IStackTokens = { childrenGap: 20 }; + return !this.state.isRefreshing ? ( +
+ + + + + { + await this.props.descriptor.onSubmit(this.state.currentValues); + this.setDefaults(); + }} + /> + this.discard()} + /> + + +
+ ) : ( + + ); + } +} diff --git a/src/SelfServe/SelfServeComponentAdapter.tsx b/src/SelfServe/SelfServeComponentAdapter.tsx new file mode 100644 index 000000000..44d4c5fa7 --- /dev/null +++ b/src/SelfServe/SelfServeComponentAdapter.tsx @@ -0,0 +1,51 @@ +/** + * This adapter is responsible to render the React component + * If the component signals a change through the callback passed in the properties, it must render the React component when appropriate + * and update any knockout observables passed from the parent. + */ +import * as ko from "knockout"; +import * as React from "react"; +import { ReactAdapter } from "../Bindings/ReactBindingHandler"; +import Explorer from "../Explorer/Explorer"; +import { SelfServeDescriptor, SelfServeComponent } from "./SelfServeComponent"; +import { SelfServeType } from "./SelfServeUtils"; + +export class SelfServeComponentAdapter implements ReactAdapter { + public parameters: ko.Observable; + public container: Explorer; + + constructor(container: Explorer) { + this.container = container; + this.parameters = ko.observable(undefined); + this.container.selfServeType.subscribe(() => { + this.triggerRender(); + }); + } + + public static getDescriptor = async (selfServeType: SelfServeType): Promise => { + switch (selfServeType) { + case SelfServeType.example: { + const SelfServeExample = await import(/* webpackChunkName: "SelfServeExample" */ "./Example/SelfServeExample"); + return new SelfServeExample.default().toSelfServeDescriptor(); + } + default: + return undefined; + } + }; + + public renderComponent(): JSX.Element { + if (this.container.selfServeType() === SelfServeType.invalid) { + return

Invalid self serve type!

; + } + const smartUiDescriptor = this.parameters(); + return smartUiDescriptor ? : <>; + } + + private triggerRender() { + window.requestAnimationFrame(async () => { + const selfServeType = this.container.selfServeType(); + const smartUiDescriptor = await SelfServeComponentAdapter.getDescriptor(selfServeType); + this.parameters(smartUiDescriptor); + }); + } +} diff --git a/src/SelfServe/SelfServeLoadingComponentAdapter.tsx b/src/SelfServe/SelfServeLoadingComponentAdapter.tsx new file mode 100644 index 000000000..a27ae1ece --- /dev/null +++ b/src/SelfServe/SelfServeLoadingComponentAdapter.tsx @@ -0,0 +1,25 @@ +/** + * This adapter is responsible to render the React component + * If the component signals a change through the callback passed in the properties, it must render the React component when appropriate + * and update any knockout observables passed from the parent. + */ +import * as ko from "knockout"; +import { Spinner, SpinnerSize } from "office-ui-fabric-react"; +import * as React from "react"; +import { ReactAdapter } from "../Bindings/ReactBindingHandler"; + +export class SelfServeLoadingComponentAdapter implements ReactAdapter { + public parameters: ko.Observable; + + constructor() { + this.parameters = ko.observable(Date.now()); + } + + public renderComponent(): JSX.Element { + return ; + } + + private triggerRender() { + window.requestAnimationFrame(() => this.renderComponent()); + } +} diff --git a/src/SelfServe/SelfServeUtils.test.tsx b/src/SelfServe/SelfServeUtils.test.tsx new file mode 100644 index 000000000..b081ba73b --- /dev/null +++ b/src/SelfServe/SelfServeUtils.test.tsx @@ -0,0 +1,277 @@ +import { + CommonInputTypes, + mapToSmartUiDescriptor, + SelfServeBaseClass, + updateContextWithDecorator +} from "./SelfServeUtils"; +import { InputType, UiType } from "./../Explorer/Controls/SmartUi/SmartUiComponent"; + +describe("SelfServeUtils", () => { + it("initialize should be declared for self serve classes", () => { + class Test extends SelfServeBaseClass { + public onSubmit = async (): Promise => { + return; + }; + public initialize: () => Promise>; + } + expect(() => new Test().toSelfServeDescriptor()).toThrow("initialize() was not declared for the class 'Test'"); + }); + + it("onSubmit should be declared for self serve classes", () => { + class Test extends SelfServeBaseClass { + public onSubmit: () => Promise; + public initialize = async (): Promise> => { + return undefined; + }; + } + expect(() => new Test().toSelfServeDescriptor()).toThrow("onSubmit() was not declared for the class 'Test'"); + }); + + it("@SmartUi decorator must be present for self serve classes", () => { + class Test extends SelfServeBaseClass { + public onSubmit = async (): Promise => { + return; + }; + public initialize = async (): Promise> => { + return undefined; + }; + } + expect(() => new Test().toSelfServeDescriptor()).toThrow( + "@SmartUi decorator was not declared for the class 'Test'" + ); + }); + + it("updateContextWithDecorator", () => { + const context = new Map(); + updateContextWithDecorator(context, "dbThroughput", "testClass", "max", 1); + updateContextWithDecorator(context, "dbThroughput", "testClass", "min", 2); + updateContextWithDecorator(context, "collThroughput", "testClass", "max", 5); + expect(context.size).toEqual(2); + expect(context.get("dbThroughput")).toEqual({ id: "dbThroughput", max: 1, min: 2 }); + expect(context.get("collThroughput")).toEqual({ id: "collThroughput", max: 5 }); + }); + + it("mapToSmartUiDescriptor", () => { + const context: Map = new Map([ + [ + "dbThroughput", + { + id: "dbThroughput", + dataFieldName: "dbThroughput", + type: "number", + label: "Database Throughput", + min: 1, + max: 5, + step: 1, + uiType: UiType.Slider + } + ], + [ + "collThroughput", + { + id: "collThroughput", + dataFieldName: "collThroughput", + type: "number", + label: "Coll Throughput", + min: 1, + max: 5, + step: 1, + uiType: UiType.Spinner + } + ], + [ + "invalidThroughput", + { + id: "invalidThroughput", + dataFieldName: "invalidThroughput", + type: "boolean", + label: "Invalid Coll Throughput", + min: 1, + max: 5, + step: 1, + uiType: UiType.Spinner, + errorMessage: "label, truelabel and falselabel are required for boolean input" + } + ], + [ + "collName", + { + id: "collName", + dataFieldName: "collName", + type: "string", + label: "Coll Name", + placeholder: "placeholder text" + } + ], + [ + "enableLogging", + { + id: "enableLogging", + dataFieldName: "enableLogging", + type: "boolean", + label: "Enable Logging", + trueLabel: "Enable", + falseLabel: "Disable" + } + ], + [ + "invalidEnableLogging", + { + id: "invalidEnableLogging", + dataFieldName: "invalidEnableLogging", + type: "boolean", + label: "Invalid Enable Logging", + placeholder: "placeholder text" + } + ], + [ + "regions", + { + id: "regions", + dataFieldName: "regions", + type: "object", + label: "Regions", + choices: [ + { label: "South West US", key: "SWUS" }, + { label: "North Central US", key: "NCUS" }, + { label: "East US 2", key: "EUS2" } + ] + } + ], + [ + "invalidRegions", + { + id: "invalidRegions", + dataFieldName: "invalidRegions", + type: "object", + label: "Invalid Regions", + placeholder: "placeholder text" + } + ] + ]); + const expectedDescriptor = { + root: { + id: "root", + children: [ + { + id: "dbThroughput", + input: { + id: "dbThroughput", + dataFieldName: "dbThroughput", + type: "number", + label: "Database Throughput", + min: 1, + max: 5, + step: 1, + uiType: "Slider" + }, + children: [] as Node[] + }, + { + id: "collThroughput", + input: { + id: "collThroughput", + dataFieldName: "collThroughput", + type: "number", + label: "Coll Throughput", + min: 1, + max: 5, + step: 1, + uiType: "Spinner" + }, + children: [] as Node[] + }, + { + id: "invalidThroughput", + input: { + id: "invalidThroughput", + dataFieldName: "invalidThroughput", + type: "boolean", + label: "Invalid Coll Throughput", + min: 1, + max: 5, + step: 1, + uiType: "Spinner", + errorMessage: "label, truelabel and falselabel are required for boolean input 'invalidThroughput'." + }, + children: [] as Node[] + }, + { + id: "collName", + input: { + id: "collName", + dataFieldName: "collName", + type: "string", + label: "Coll Name", + placeholder: "placeholder text" + }, + children: [] as Node[] + }, + { + id: "enableLogging", + input: { + id: "enableLogging", + dataFieldName: "enableLogging", + type: "boolean", + label: "Enable Logging", + trueLabel: "Enable", + falseLabel: "Disable" + }, + children: [] as Node[] + }, + { + id: "invalidEnableLogging", + input: { + id: "invalidEnableLogging", + dataFieldName: "invalidEnableLogging", + type: "boolean", + label: "Invalid Enable Logging", + placeholder: "placeholder text", + errorMessage: "label, truelabel and falselabel are required for boolean input 'invalidEnableLogging'." + }, + children: [] as Node[] + }, + { + id: "regions", + input: { + id: "regions", + dataFieldName: "regions", + type: "object", + label: "Regions", + choices: [ + { label: "South West US", key: "SWUS" }, + { label: "North Central US", key: "NCUS" }, + { label: "East US 2", key: "EUS2" } + ] + }, + children: [] as Node[] + }, + { + id: "invalidRegions", + input: { + id: "invalidRegions", + dataFieldName: "invalidRegions", + type: "object", + label: "Invalid Regions", + placeholder: "placeholder text", + errorMessage: "label and choices are required for Choice input 'invalidRegions'." + }, + children: [] as Node[] + } + ] + }, + inputNames: [ + "dbThroughput", + "collThroughput", + "invalidThroughput", + "collName", + "enableLogging", + "invalidEnableLogging", + "regions", + "invalidRegions" + ] + }; + const descriptor = mapToSmartUiDescriptor(context); + expect(descriptor).toEqual(expectedDescriptor); + }); +}); diff --git a/src/SelfServe/SelfServeUtils.tsx b/src/SelfServe/SelfServeUtils.tsx new file mode 100644 index 000000000..e32eb69e9 --- /dev/null +++ b/src/SelfServe/SelfServeUtils.tsx @@ -0,0 +1,183 @@ +import "reflect-metadata"; +import { ChoiceItem, Info, InputTypeValue, InputType } from "../Explorer/Controls/SmartUi/SmartUiComponent"; +import { + BooleanInput, + ChoiceInput, + SelfServeDescriptor, + NumberInput, + StringInput, + Node, + AnyInput +} from "./SelfServeComponent"; + +export enum SelfServeType { + // No self serve type passed, launch explorer + none = "none", + // Unsupported self serve type passed as feature flag + invalid = "invalid", + // Add your self serve types here + example = "example" +} + +export abstract class SelfServeBaseClass { + public abstract onSubmit: (currentValues: Map) => Promise; + public abstract initialize: () => Promise>; + + public toSelfServeDescriptor(): SelfServeDescriptor { + const className = this.constructor.name; + const smartUiDescriptor = Reflect.getMetadata(className, this) as SelfServeDescriptor; + + if (!this.initialize) { + throw new Error(`initialize() was not declared for the class '${className}'`); + } + if (!this.onSubmit) { + throw new Error(`onSubmit() was not declared for the class '${className}'`); + } + if (!smartUiDescriptor?.root) { + throw new Error(`@SmartUi decorator was not declared for the class '${className}'`); + } + + smartUiDescriptor.initialize = this.initialize; + smartUiDescriptor.onSubmit = this.onSubmit; + return smartUiDescriptor; + } +} + +export interface CommonInputTypes { + id: string; + info?: (() => Promise) | Info; + type?: InputTypeValue; + label?: (() => Promise) | string; + placeholder?: (() => Promise) | string; + dataFieldName?: string; + min?: (() => Promise) | number; + max?: (() => Promise) | number; + step?: (() => Promise) | number; + trueLabel?: (() => Promise) | string; + falseLabel?: (() => Promise) | string; + choices?: (() => Promise) | ChoiceItem[]; + uiType?: string; + errorMessage?: string; + onChange?: (currentState: Map, newValue: InputType) => Map; + onSubmit?: (currentValues: Map) => Promise; + initialize?: () => Promise>; +} + +const setValue = ( + name: T, + value: K, + fieldObject: CommonInputTypes +): void => { + fieldObject[name] = value; +}; + +const getValue = (name: T, fieldObject: CommonInputTypes): unknown => { + return fieldObject[name]; +}; + +export const addPropertyToMap = ( + target: unknown, + propertyName: string, + className: string, + descriptorName: keyof CommonInputTypes, + descriptorValue: K +): void => { + const context = + (Reflect.getMetadata(className, target) as Map) ?? new Map(); + updateContextWithDecorator(context, propertyName, className, descriptorName, descriptorValue); + Reflect.defineMetadata(className, context, target); +}; + +export const updateContextWithDecorator = ( + context: Map, + propertyName: string, + className: string, + descriptorName: keyof CommonInputTypes, + descriptorValue: K +): void => { + if (!(context instanceof Map)) { + throw new Error(`@SmartUi should be the first decorator for the class '${className}'.`); + } + + const propertyObject = context.get(propertyName) ?? { id: propertyName }; + + if (getValue(descriptorName, propertyObject) && descriptorName !== "type" && descriptorName !== "dataFieldName") { + throw new Error( + `Duplicate value passed for '${descriptorName}' on property '${propertyName}' of class '${className}'` + ); + } + + setValue(descriptorName, descriptorValue, propertyObject); + context.set(propertyName, propertyObject); +}; + +export const buildSmartUiDescriptor = (className: string, target: unknown): void => { + const context = Reflect.getMetadata(className, target) as Map; + const smartUiDescriptor = mapToSmartUiDescriptor(context); + Reflect.defineMetadata(className, smartUiDescriptor, target); +}; + +export const mapToSmartUiDescriptor = (context: Map): SelfServeDescriptor => { + const root = context.get("root"); + context.delete("root"); + const inputNames: string[] = []; + + const smartUiDescriptor: SelfServeDescriptor = { + root: { + id: "root", + info: root?.info, + children: [] + } + }; + + while (context.size > 0) { + const key = context.keys().next().value; + addToDescriptor(context, smartUiDescriptor.root, key, inputNames); + } + smartUiDescriptor.inputNames = inputNames; + + return smartUiDescriptor; +}; + +const addToDescriptor = ( + context: Map, + root: Node, + key: string, + inputNames: string[] +): void => { + const value = context.get(key); + inputNames.push(value.id); + const element = { + id: value.id, + info: value.info, + input: getInput(value), + children: [] + } as Node; + context.delete(key); + root.children.push(element); +}; + +const getInput = (value: CommonInputTypes): AnyInput => { + switch (value.type) { + case "number": + if (!value.label || !value.step || !value.uiType || !value.min || !value.max) { + value.errorMessage = `label, step, min, max and uiType are required for number input '${value.id}'.`; + } + return value as NumberInput; + case "string": + if (!value.label) { + value.errorMessage = `label is required for string input '${value.id}'.`; + } + return value as StringInput; + case "boolean": + if (!value.label || !value.trueLabel || !value.falseLabel) { + value.errorMessage = `label, truelabel and falselabel are required for boolean input '${value.id}'.`; + } + return value as BooleanInput; + default: + if (!value.label || !value.choices) { + value.errorMessage = `label and choices are required for Choice input '${value.id}'.`; + } + return value as ChoiceInput; + } +}; diff --git a/src/SelfServe/__snapshots__/SelfServeComponent.test.tsx.snap b/src/SelfServe/__snapshots__/SelfServeComponent.test.tsx.snap new file mode 100644 index 000000000..ce478192b --- /dev/null +++ b/src/SelfServe/__snapshots__/SelfServeComponent.test.tsx.snap @@ -0,0 +1,168 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SelfServeComponent should render 1`] = ` +
+ + "450", + "analyticalStore" => "false", + "database" => "db2", + } + } + descriptor={ + Object { + "initialize": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + }, + "inputNames": Array [ + "throughput", + "containerId", + "analyticalStore", + "database", + ], + "onSubmit": [MockFunction], + "root": Object { + "children": Array [ + Object { + "id": "throughput", + "info": undefined, + "input": Object { + "dataFieldName": "throughput", + "defaultValue": 400, + "label": "Throughput (input)", + "max": 500, + "min": 400, + "placeholder": undefined, + "step": 10, + "type": "number", + "uiType": "Spinner", + }, + }, + Object { + "id": "containerId", + "info": undefined, + "input": Object { + "dataFieldName": "containerId", + "label": "Container id", + "placeholder": undefined, + "type": "string", + }, + }, + Object { + "id": "analyticalStore", + "info": undefined, + "input": Object { + "dataFieldName": "analyticalStore", + "defaultValue": true, + "falseLabel": "Disabled", + "label": "Analytical Store", + "placeholder": undefined, + "trueLabel": "Enabled", + "type": "boolean", + }, + }, + Object { + "id": "database", + "info": undefined, + "input": Object { + "choices": Array [ + Object { + "key": "db1", + "label": "Database 1", + }, + Object { + "key": "db2", + "label": "Database 2", + }, + Object { + "key": "db3", + "label": "Database 3", + }, + ], + "dataFieldName": "database", + "defaultKey": "db2", + "label": "Database", + "placeholder": undefined, + "type": "object", + }, + }, + ], + "id": "root", + "info": Object { + "link": Object { + "href": "https://aka.ms/azure-cosmos-db-pricing", + "text": "More Details", + }, + "message": "Start at $24/mo per database", + }, + }, + } + } + onInputChange={[Function]} + /> + + + + + +
+`; diff --git a/test/notebooks/notebookTestUtils.ts b/test/notebooks/notebookTestUtils.ts index 0a81d6e66..75826b509 100644 --- a/test/notebooks/notebookTestUtils.ts +++ b/test/notebooks/notebookTestUtils.ts @@ -1,59 +1,9 @@ import { ElementHandle, Frame } from "puppeteer"; -import { TestExplorerParams } from "./testExplorer/TestExplorerParams"; import * as path from "path"; export const NOTEBOOK_OPERATION_DELAY = 5000; export const RENDER_DELAY = 2500; -let testExplorerFrame: Frame; -export const getTestExplorerFrame = async (): Promise => { - if (testExplorerFrame) { - return testExplorerFrame; - } - - const notebooksTestRunnerTenantId = process.env.NOTEBOOKS_TEST_RUNNER_TENANT_ID; - const notebooksTestRunnerClientId = process.env.NOTEBOOKS_TEST_RUNNER_CLIENT_ID; - const notebooksTestRunnerClientSecret = process.env.NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET; - const portalRunnerDatabaseAccount = process.env.PORTAL_RUNNER_DATABASE_ACCOUNT; - const portalRunnerDatabaseAccountKey = process.env.PORTAL_RUNNER_DATABASE_ACCOUNT_KEY; - const portalRunnerSubscripton = process.env.PORTAL_RUNNER_SUBSCRIPTION; - const portalRunnerResourceGroup = process.env.PORTAL_RUNNER_RESOURCE_GROUP; - - const testExplorerUrl = new URL("testExplorer.html", "https://localhost:1234"); - testExplorerUrl.searchParams.append( - TestExplorerParams.notebooksTestRunnerTenantId, - encodeURI(notebooksTestRunnerTenantId) - ); - testExplorerUrl.searchParams.append( - TestExplorerParams.notebooksTestRunnerClientId, - encodeURI(notebooksTestRunnerClientId) - ); - testExplorerUrl.searchParams.append( - TestExplorerParams.notebooksTestRunnerClientSecret, - encodeURI(notebooksTestRunnerClientSecret) - ); - testExplorerUrl.searchParams.append( - TestExplorerParams.portalRunnerDatabaseAccount, - encodeURI(portalRunnerDatabaseAccount) - ); - testExplorerUrl.searchParams.append( - TestExplorerParams.portalRunnerDatabaseAccountKey, - encodeURI(portalRunnerDatabaseAccountKey) - ); - testExplorerUrl.searchParams.append(TestExplorerParams.portalRunnerSubscripton, encodeURI(portalRunnerSubscripton)); - testExplorerUrl.searchParams.append( - TestExplorerParams.portalRunnerResourceGroup, - encodeURI(portalRunnerResourceGroup) - ); - - await page.goto(testExplorerUrl.toString()); - - const handle = await page.waitForSelector("iframe"); - testExplorerFrame = await handle.contentFrame(); - await testExplorerFrame.waitForSelector(".galleryHeader"); - return testExplorerFrame; -}; - export const uploadNotebookIfNotExist = async (frame: Frame, notebookName: string): Promise> => { const notebookNode = await getNotebookNode(frame, notebookName); if (notebookNode) { diff --git a/test/notebooks/uploadAndOpenNotebook.spec.ts b/test/notebooks/uploadAndOpenNotebook.spec.ts index 8e539277a..c7017eb3d 100644 --- a/test/notebooks/uploadAndOpenNotebook.spec.ts +++ b/test/notebooks/uploadAndOpenNotebook.spec.ts @@ -1,6 +1,6 @@ -import "expect-puppeteer"; -import { getTestExplorerFrame, uploadNotebookIfNotExist } from "./notebookTestUtils"; +import { uploadNotebookIfNotExist } from "./notebookTestUtils"; import { ElementHandle, Frame } from "puppeteer"; +import { getTestExplorerFrame } from "../testExplorer/TestExplorerUtils"; jest.setTimeout(300000); @@ -12,6 +12,7 @@ describe("Notebook UI tests", () => { it("Upload, Open and Delete Notebook", async () => { try { frame = await getTestExplorerFrame(); + await frame.waitForSelector(".galleryHeader"); uploadedNotebookNode = await uploadNotebookIfNotExist(frame, notebookName); await uploadedNotebookNode.click(); await frame.waitForSelector(".tabNavText"); diff --git a/test/selfServe/selfServeExample.spec.ts b/test/selfServe/selfServeExample.spec.ts new file mode 100644 index 000000000..a753d73b9 --- /dev/null +++ b/test/selfServe/selfServeExample.spec.ts @@ -0,0 +1,27 @@ +import { Frame } from "puppeteer"; +import { TestExplorerParams } from "../testExplorer/TestExplorerParams"; +import { getTestExplorerFrame } from "../testExplorer/TestExplorerUtils"; +import { SelfServeType } from "../../src/SelfServe/SelfServeUtils"; + +jest.setTimeout(300000); + +let frame: Frame; +describe("Self Serve", () => { + it("Launch Self Serve Example", async () => { + try { + frame = await getTestExplorerFrame( + new Map([[TestExplorerParams.selfServeType, SelfServeType.example]]) + ); + await frame.waitForSelector("#regions-dropown-input"); + await frame.waitForSelector("#enableLogging-radioSwitch-input"); + await frame.waitForSelector("#accountName-textBox-input"); + await frame.waitForSelector("#dbThroughput-slider-input"); + await frame.waitForSelector("#collectionThroughput-spinner-input"); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const testName = (expect as any).getState().currentTestName; + await page.screenshot({ path: `Test Failed ${testName}.jpg` }); + throw error; + } + }); +}); diff --git a/test/notebooks/testExplorer/TestExplorer.ts b/test/testExplorer/TestExplorer.ts similarity index 94% rename from test/notebooks/testExplorer/TestExplorer.ts rename to test/testExplorer/TestExplorer.ts index ac9cdb071..c1a83cf67 100644 --- a/test/notebooks/testExplorer/TestExplorer.ts +++ b/test/testExplorer/TestExplorer.ts @@ -1,11 +1,11 @@ -import { MessageTypes } from "../../../src/Contracts/ExplorerContracts"; -import "../../../less/hostedexplorer.less"; +import { MessageTypes } from "../../src/Contracts/ExplorerContracts"; +import "../../less/hostedexplorer.less"; import { TestExplorerParams } from "./TestExplorerParams"; import { ClientSecretCredential } from "@azure/identity"; import { DatabaseAccountsGetResponse } from "@azure/arm-cosmosdb/esm/models"; import { CosmosDBManagementClient } from "@azure/arm-cosmosdb"; import * as msRest from "@azure/ms-rest-js"; -import * as ViewModels from "../../../src/Contracts/ViewModels"; +import * as ViewModels from "../../src/Contracts/ViewModels"; class CustomSigner implements msRest.ServiceClientCredentials { private token: string; @@ -87,6 +87,7 @@ const initTestExplorer = async (): Promise => { const portalRunnerResourceGroup = decodeURIComponent( urlSearchParams.get(TestExplorerParams.portalRunnerResourceGroup) ); + const selfServeType = urlSearchParams.get(TestExplorerParams.selfServeType); const token = await AADLogin( notebooksTestRunnerTenantId, @@ -128,7 +129,8 @@ const initTestExplorer = async (): Promise => { throughput: { fixed: 400, unlimited: 400, unlimitedmax: 100000, unlimitedmin: 400, shared: 400 } }, // add UI test only when feature is not dependent on flights anymore - flights: [] + flights: [], + selfServeType: selfServeType } as ViewModels.DataExplorerInputsFrame }; diff --git a/test/notebooks/testExplorer/TestExplorerParams.ts b/test/testExplorer/TestExplorerParams.ts similarity index 81% rename from test/notebooks/testExplorer/TestExplorerParams.ts rename to test/testExplorer/TestExplorerParams.ts index 1a3e239a7..c5436eed8 100644 --- a/test/notebooks/testExplorer/TestExplorerParams.ts +++ b/test/testExplorer/TestExplorerParams.ts @@ -5,5 +5,6 @@ export enum TestExplorerParams { portalRunnerDatabaseAccount = "portalRunnerDatabaseAccount", portalRunnerDatabaseAccountKey = "portalRunnerDatabaseAccountKey", portalRunnerSubscripton = "portalRunnerSubscripton", - portalRunnerResourceGroup = "portalRunnerResourceGroup" + portalRunnerResourceGroup = "portalRunnerResourceGroup", + selfServeType = "selfServeType" } diff --git a/test/testExplorer/TestExplorerUtils.ts b/test/testExplorer/TestExplorerUtils.ts new file mode 100644 index 000000000..be3728e6e --- /dev/null +++ b/test/testExplorer/TestExplorerUtils.ts @@ -0,0 +1,54 @@ +import { Frame } from "puppeteer"; +import { TestExplorerParams } from "./TestExplorerParams"; + +let testExplorerFrame: Frame; +export const getTestExplorerFrame = async (params?: Map): Promise => { + if (testExplorerFrame) { + return testExplorerFrame; + } + + const notebooksTestRunnerTenantId = process.env.NOTEBOOKS_TEST_RUNNER_TENANT_ID; + const notebooksTestRunnerClientId = process.env.NOTEBOOKS_TEST_RUNNER_CLIENT_ID; + const notebooksTestRunnerClientSecret = process.env.NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET; + const portalRunnerDatabaseAccount = process.env.PORTAL_RUNNER_DATABASE_ACCOUNT; + const portalRunnerDatabaseAccountKey = process.env.PORTAL_RUNNER_DATABASE_ACCOUNT_KEY; + const portalRunnerSubscripton = process.env.PORTAL_RUNNER_SUBSCRIPTION; + const portalRunnerResourceGroup = process.env.PORTAL_RUNNER_RESOURCE_GROUP; + + const testExplorerUrl = new URL("testExplorer.html", "https://localhost:1234"); + testExplorerUrl.searchParams.append( + TestExplorerParams.notebooksTestRunnerTenantId, + encodeURI(notebooksTestRunnerTenantId) + ); + testExplorerUrl.searchParams.append( + TestExplorerParams.notebooksTestRunnerClientId, + encodeURI(notebooksTestRunnerClientId) + ); + testExplorerUrl.searchParams.append( + TestExplorerParams.notebooksTestRunnerClientSecret, + encodeURI(notebooksTestRunnerClientSecret) + ); + testExplorerUrl.searchParams.append( + TestExplorerParams.portalRunnerDatabaseAccount, + encodeURI(portalRunnerDatabaseAccount) + ); + testExplorerUrl.searchParams.append( + TestExplorerParams.portalRunnerDatabaseAccountKey, + encodeURI(portalRunnerDatabaseAccountKey) + ); + testExplorerUrl.searchParams.append(TestExplorerParams.portalRunnerSubscripton, encodeURI(portalRunnerSubscripton)); + testExplorerUrl.searchParams.append( + TestExplorerParams.portalRunnerResourceGroup, + encodeURI(portalRunnerResourceGroup) + ); + + if (params) { + for (const key of params.keys()) { + testExplorerUrl.searchParams.append(key, encodeURI(params.get(key))); + } + } + + await page.goto(testExplorerUrl.toString()); + const handle = await page.waitForSelector("iframe"); + return await handle.contentFrame(); +}; diff --git a/test/notebooks/testExplorer/testExplorer.html b/test/testExplorer/testExplorer.html similarity index 100% rename from test/notebooks/testExplorer/testExplorer.html rename to test/testExplorer/testExplorer.html diff --git a/tsconfig.json b/tsconfig.json index 0c45faa01..59e7064d3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,8 @@ "downlevelIteration": true, "module": "esnext", "target": "es5", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, "lib": ["es5", "es6", "dom", "webworker.importscripts"], "jsx": "react", "moduleResolution": "node", @@ -19,6 +21,6 @@ "noEmit": true, "types": ["jest"] }, - "include": ["./src/**/*", "./test/notebooks/testExplorer/**/*"], + "include": ["./src/**/*", "./test/testExplorer/TestExplorer.ts"], "exclude": ["./src/**/__mocks__/**/*"] } diff --git a/webpack.config.js b/webpack.config.js index 7ffaefaeb..7769dc9a2 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -143,7 +143,7 @@ module.exports = function(env = {}, argv = {}) { }), new HtmlWebpackPlugin({ filename: "testExplorer.html", - template: "test/notebooks/testExplorer/testExplorer.html", + template: "test/testExplorer/testExplorer.html", chunks: ["testExplorer"] }), new HtmlWebpackPlugin({ @@ -184,7 +184,7 @@ module.exports = function(env = {}, argv = {}) { index: "./src/Index.ts", quickstart: "./src/quickstart.ts", hostedExplorer: "./src/HostedExplorer.tsx", - testExplorer: "./test/notebooks/testExplorer/TestExplorer.ts", + testExplorer: "./test/testExplorer/TestExplorer.ts", heatmap: "./src/Controls/Heatmap/Heatmap.ts", terminal: "./src/Terminal/index.ts", notebookViewer: "./src/NotebookViewer/NotebookViewer.tsx",