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 b29b45635..2e1302041 100644 --- a/package-lock.json +++ b/package-lock.json @@ -393,7 +393,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", @@ -620,6 +619,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", @@ -729,6 +747,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", diff --git a/package.json b/package.json index fda4efe8a..91af31275 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.1.0", + "@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", diff --git a/src/Bindings/ReactBindingHandler.ts b/src/Bindings/ReactBindingHandler.ts index 8ecb51eee..95b05a97f 100644 --- a/src/Bindings/ReactBindingHandler.ts +++ b/src/Bindings/ReactBindingHandler.ts @@ -15,7 +15,7 @@ import * as ReactDOM from "react-dom"; export interface ReactAdapter { parameters: any; - renderComponent: () => JSX.Element; + renderComponent: (() => Promise) | (() => JSX.Element); setElement?: (elt: Element) => void; } @@ -36,12 +36,12 @@ export class Registerer { } // If any of the ko observable change inside parameters, trigger a new render. - ko.computed(() => ko.toJSON(adapter.parameters)).subscribe(() => - ReactDOM.render(adapter.renderComponent(), element) + ko.computed(() => ko.toJSON(adapter.parameters)).subscribe(async () => + ReactDOM.render(await adapter.renderComponent(), element) ); // Initial rendering at mount point - ReactDOM.render(adapter.renderComponent(), element); + Promise.resolve(adapter.renderComponent()).then(component => ReactDOM.render(component, element)); } } as ko.BindingHandler; } diff --git a/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx b/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx index daa0ab64e..c70f730d3 100644 --- a/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx +++ b/src/Explorer/Controls/FeaturePanel/FeaturePanelComponent.tsx @@ -48,7 +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.selfServeTypeForTest", label: "self serve type passed on for testing", value: "sample" }, + { key: "feature.selfServeTypeForTest", label: "Self serve type passed on for testing", 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..96481387c 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" > + { + let initializeCalled = false; + let fetchMaxCalled = false; + + const initializeMock = async () => { + initializeCalled = true; + return new Map(); + }; + const fetchMaxvalue = async () => { + fetchMaxCalled = true; + return 500; + }; + const exampleData: Descriptor = { onSubmit: async () => { return; }, - initialize: async () => { - return undefined; - }, + initialize: initializeMock, root: { id: "root", info: { @@ -27,11 +37,10 @@ describe("SmartUiComponent", () => { dataFieldName: "throughput", type: "number", min: 400, - max: 500, + max: fetchMaxvalue, step: 10, defaultValue: 400, - inputType: "spinner", - onChange: undefined + uiType: UiType.Spinner } }, { @@ -44,8 +53,21 @@ describe("SmartUiComponent", () => { max: 500, step: 10, defaultValue: 400, - inputType: "slider", - onChange: undefined + 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'" } }, { @@ -53,8 +75,7 @@ describe("SmartUiComponent", () => { input: { label: "Container id", dataFieldName: "containerId", - type: "string", - onChange: undefined + type: "string" } }, { @@ -65,8 +86,7 @@ describe("SmartUiComponent", () => { falseLabel: "Disabled", defaultValue: true, dataFieldName: "analyticalStore", - type: "boolean", - onChange: undefined + type: "boolean" } }, { @@ -80,7 +100,6 @@ describe("SmartUiComponent", () => { { label: "Database 2", key: "db2" }, { label: "Database 3", key: "db3" } ], - onChange: undefined, defaultKey: "db2" } } @@ -88,8 +107,17 @@ describe("SmartUiComponent", () => { } }; - it("should render", () => { + it("should render", done => { const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + setImmediate(() => { + expect(wrapper).toMatchSnapshot(); + expect(initializeCalled).toBeTruthy(); + expect(fetchMaxCalled).toBeTruthy(); + + wrapper.setState({ isRefreshing: true }); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); + done(); + }); }); }); diff --git a/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx b/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx index 60934c4e2..4c524ce40 100644 --- a/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx +++ b/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx @@ -22,12 +22,15 @@ import "./SmartUiComponent.less"; export type InputTypeValue = "number" | "string" | "boolean" | "object"; -export type NumberInputType = "spinner" | "slider"; +export enum UiType { + Spinner = "Spinner", + Slider = "Slider" +} /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ -export type ChoiceItem = { label: string; key: string }; +export type DropdownItem = { label: string; key: string }; -export type InputType = number | string | boolean | ChoiceItem | JSX.Element; +export type InputType = number | string | boolean | DropdownItem | JSX.Element; export interface BaseInput { label: (() => Promise) | string; @@ -35,7 +38,7 @@ export interface BaseInput { type: InputTypeValue; onChange?: (currentState: Map, newValue: InputType) => Map; placeholder?: (() => Promise) | string; - customElement?: ((currentValues: Map) => Promise) | JSX.Element; + errorMessage?: string; } /** @@ -46,7 +49,7 @@ export interface NumberInput extends BaseInput { max: (() => Promise) | number; step: (() => Promise) | number; defaultValue?: number; - inputType: NumberInputType; + uiType: UiType; } export interface BooleanInput extends BaseInput { @@ -59,8 +62,8 @@ export interface StringInput extends BaseInput { defaultValue?: string; } -export interface ChoiceInput extends BaseInput { - choices: (() => Promise) | ChoiceItem[]; +export interface DropdownInput extends BaseInput { + choices: (() => Promise) | DropdownItem[]; defaultKey?: string; } @@ -72,7 +75,7 @@ export interface Info { }; } -export type AnyInput = NumberInput | BooleanInput | StringInput | ChoiceInput; +export type AnyInput = NumberInput | BooleanInput | StringInput | DropdownInput; export interface Node { id: string; @@ -83,8 +86,9 @@ export interface Node { export interface Descriptor { root: Node; - initialize: () => Promise>; - onSubmit: (currentValues: Map) => Promise; + initialize?: () => Promise>; + onSubmit?: (currentValues: Map) => Promise; + inputNames?: string[]; } /************************** Component implementation starts here ************************************* */ @@ -97,56 +101,30 @@ interface SmartUiComponentState { currentValues: Map; baselineValues: Map; errors: Map; - customInputIndex: number; isRefreshing: boolean; } export class SmartUiComponent extends React.Component { - private customInputs: AnyInput[] = []; - private shouldRenderCustomComponents = true; - private static readonly labelStyle = { color: "#393939", fontFamily: "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif", fontSize: 12 }; + componentDidMount(): void { + this.setDefaultValues(); + } + constructor(props: SmartUiComponentProps) { super(props); this.state = { baselineValues: new Map(), currentValues: new Map(), errors: new Map(), - customInputIndex: 0, isRefreshing: false }; - - this.setDefaultValues(); } - componentDidUpdate = async (): Promise => { - if (!this.customInputs.length) { - return; - } - if (!this.shouldRenderCustomComponents) { - this.shouldRenderCustomComponents = true; - return; - } - - if (this.state.customInputIndex === this.customInputs.length) { - this.shouldRenderCustomComponents = false; - this.setState({ customInputIndex: 0 }); - return; - } - - const input = this.customInputs[this.state.customInputIndex]; - const dataFieldName = input.dataFieldName; - const element = await (input.customElement as Function)(this.state.currentValues); - const { currentValues } = this.state; - currentValues.set(dataFieldName, element); - this.setState({ currentValues: currentValues, customInputIndex: this.state.customInputIndex + 1 }); - }; - private setDefaultValues = async (): Promise => { this.setState({ isRefreshing: true }); await this.setDefaults(this.props.descriptor.root); @@ -159,6 +137,11 @@ export class SmartUiComponent extends React.Component await this.setDefaults(child))); + const promises = currentNode.children?.map(async (child: Node) => await this.setDefaults(child)); + if (promises) { + await Promise.all(promises); + } }; private getModifiedInput = async (input: AnyInput): Promise => { @@ -195,13 +180,6 @@ export class SmartUiComponent extends React.Component ); - } else if (input.inputType === "slider") { + } else if (input.uiType === UiType.Slider) { return ( ); } else { - return <>Unsupported number input type {input.inputType}; + return <>Unsupported number UI type {input.uiType}; } } @@ -440,7 +418,7 @@ export class SmartUiComponent extends React.Component this.onInputChange(input, item.key.toString())} placeholder={placeholder as string} - options={(choices as ChoiceItem[]).map(c => ({ + options={(choices as DropdownItem[]).map(c => ({ key: c.key, text: c.label }))} @@ -467,19 +445,13 @@ export class SmartUiComponent extends React.Component; - } else { - return input.customElement as JSX.Element; - } + private renderError(input: AnyInput): JSX.Element { + return Error: {input.errorMessage}; } private renderInput(input: AnyInput): JSX.Element { - if (input.customElement) { - return this.renderCustomInput(input); + if (input.errorMessage) { + return this.renderError(input); } switch (input.type) { case "string": @@ -489,7 +461,7 @@ export class SmartUiComponent extends React.Component {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..d9559ef84 100644 --- a/src/Explorer/Controls/SmartUi/__snapshots__/SmartUiComponent.test.tsx.snap +++ b/src/Explorer/Controls/SmartUi/__snapshots__/SmartUiComponent.test.tsx.snap @@ -1,240 +1,340 @@ // 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 + + + +
- - -
-
- -
-
- +
+ +
+ + +
+
+ + + -
-
-
-
-
- + +
+
-
-
- - Analytical Store - -
- -
- -
-
- - - -
+ > + + + Error: + label, truelabel and falselabel are required for boolean input 'throughput3' + + +
+
+
+ + +
+
+ +
+
+
+
+
+
+ + +
+
+ + Analytical Store + +
+ +
+
+
+
+
+ + + + + +
+
+ + + + - +
+`; + +exports[`SmartUiComponent should render 2`] = ` + `; diff --git a/src/Explorer/Explorer.ts b/src/Explorer/Explorer.ts index c601cb1d8..ff8667c96 100644 --- a/src/Explorer/Explorer.ts +++ b/src/Explorer/Explorer.ts @@ -1857,8 +1857,6 @@ export default class Explorer { this.selfServeType(inputs.selfServeType); } else { this.selfServeType(SelfServeTypes.none); - this._setLoadingStatusText("Connecting...", "Welcome to Azure Cosmos DB"); - this._setConnectingImage(); } } @@ -2994,11 +2992,6 @@ export default class Explorer { } } - private _setConnectingImage() { - const connectingImage = document.getElementById("explorerConnectingImage"); - connectingImage.innerHTML = ''; - } - private _openSetupNotebooksPaneForQuickstart(): void { const title = "Enable Notebooks (Preview)"; const description = diff --git a/src/Main.tsx b/src/Main.tsx index ee7ec64ba..8609b6e03 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -78,6 +78,7 @@ import hdeConnectImage from "../images/HdeConnectCosmosDB.svg"; import refreshImg from "../images/refresh-cosmos.svg"; import arrowLeftImg from "../images/imgarrowlefticon.svg"; import { KOCommentEnd, KOCommentIfStart } from "./koComment"; +import { Spinner, SpinnerSize } from "office-ui-fabric-react"; // TODO: Encapsulate and reuse all global variables as environment variables window.authType = AuthType.AAD; @@ -131,9 +132,14 @@ const App: React.FunctionComponent = () => { className="flexContainer" data-bind="visible: selfServeType() && selfServeType() !== 'none', react: selfServeComponentAdapter" > -
+
{/* Main Command Bar - Start */} -
+
{/* Main Command Bar - End */} {/* Share url flyout - Start */}
{
{/* Share url flyout - End */} {/* Collections Tree and Tabs - Begin */} -
+
{/* Collections Tree - Start */}
@@ -308,10 +314,7 @@ const App: React.FunctionComponent = () => { data-bind="visible: !isRefreshingExplorer() && tabsManager.openedTabs().length === 0" >
-
+
{ role="contentinfo" aria-label="Notification console" id="explorerNotificationConsole" - data-bind="react: notificationConsoleComponentAdapter, visible: selfServeType() === 'none'" + data-bind="react: notificationConsoleComponentAdapter" />
{/* Explorer Connection - Encryption Token / AAD - Start */} @@ -379,25 +382,20 @@ const App: React.FunctionComponent = () => {
{/* Explorer Connection - Encryption Token / AAD - End */} {/* Global loader - Start */} + {window.dataExplorer && } +
-

-

- +
+

+ Azure Cosmos DB +

+

Welcome to Azure Cosmos DB

+

+ Connecting... +

+
{/* Global loader - End */} diff --git a/src/SelfServe/ClassDecorators.tsx b/src/SelfServe/ClassDecorators.tsx new file mode 100644 index 000000000..6245563c7 --- /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: Function) => { + buildSmartUiDescriptor(target.name, target.prototype); + }; +}; + +export const ClassInfo = (info: (() => Promise) | Info): ClassDecorator => { + return (target: Function) => { + addPropertyToMap(target.prototype, "root", target.name, "info", info); + }; +}; diff --git a/src/SelfServe/ClassDescriptors.tsx b/src/SelfServe/ClassDescriptors.tsx deleted file mode 100644 index a657b950a..000000000 --- a/src/SelfServe/ClassDescriptors.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Info, InputType } from "../Explorer/Controls/SmartUi/SmartUiComponent"; -import { addPropertyToMap, toSmartUiDescriptor } from "./SelfServeUtils"; - -export const SmartUi = (): ClassDecorator => { - return (target: Function) => { - toSmartUiDescriptor(target.name, target); - }; -}; - -export const ClassInfo = (info: (() => Promise) | Info): ClassDecorator => { - return (target: Function) => { - addPropertyToMap(target, "root", target.name, "info", info); - }; -}; - -export const OnSubmit = (onSubmit: (currentValues: Map) => Promise): ClassDecorator => { - return (target: Function) => { - addPropertyToMap(target, "root", target.name, "onSubmit", onSubmit); - }; -}; - -export const Initialize = (initialize: () => Promise>): ClassDecorator => { - return (target: Function) => { - addPropertyToMap(target, "root", target.name, "initialize", initialize); - }; -}; diff --git a/src/SelfServe/Example/CustomComponent.tsx b/src/SelfServe/Example/CustomComponent.tsx deleted file mode 100644 index ebc11a530..000000000 --- a/src/SelfServe/Example/CustomComponent.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from "react"; -import { HoverCard, HoverCardType, Stack, Text } from "office-ui-fabric-react"; -import { InputType } from "../../Explorer/Controls/SmartUi/SmartUiComponent"; - -interface TextComponentProps { - text: string; - currentValues: Map; -} - -export class TextComponent extends React.Component { - private onHover = (): JSX.Element => { - return ( - - Choice: {this.props.currentValues.get("choiceInput")?.toString()} - Boolean: {this.props.currentValues.get("booleanInput")?.toString()} - String: {this.props.currentValues.get("stringInput")?.toString()} - Slider: {this.props.currentValues.get("numberSliderInput")?.toString()} - Spinner: {this.props.currentValues.get("numberSpinnerInput")?.toString()} - - ); - }; - - public render(): JSX.Element { - return ( - - {this.props.text} - - ); - } -} diff --git a/src/SelfServe/Example/Example.tsx b/src/SelfServe/Example/Example.tsx deleted file mode 100644 index 2e11647c5..000000000 --- a/src/SelfServe/Example/Example.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { - Label, - ParentOf, - PropertyInfo, - OnChange, - Placeholder, - CustomElement, - ChoiceInput, - BooleanInput, - NumberInput -} from "../PropertyDescriptors"; -import { SmartUi, ClassInfo, OnSubmit, Initialize } from "../ClassDescriptors"; -import { - initializeSelfServeExample, - choiceInfo, - choiceOptions, - onSliderChange, - onSubmit, - renderText, - selfServeExampleInfo, - descriptionElement, - initializeNumberMaxValue -} from "./ExampleApis"; -import { SelfServeBase } from "../SelfServeUtils"; -import { ChoiceItem } from "../../Explorer/Controls/SmartUi/SmartUiComponent"; - -/* -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 @SmartUi() descriptor to tell the compiler that UI needs to be generated from this class. - - Needs to have an @OnSubmit() descriptor, a callback for when the submit button is clicked. - - Needs to have an @Initialize() descriptor, to set default values for the inputs. - -You can test this self serve UI by using the featureflag '?feature.selfServeTypeForTest=example' -and plumb in similar feature flags for your own self serve class. - -The default values and functions used for this class can be found in ExampleApis.tsx -*/ - -/* -@SmartUi() - - role: Generated the JSON required to convert this class into the required UI. This is done during compile time. -*/ -@SmartUi() -/* -@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. -*/ -@OnSubmit(onSubmit) -/* -@ClassInfo() - - input: Info | () => Promise - - role: Display an Info bar as the first element of the UI. -*/ -@ClassInfo(selfServeExampleInfo) -/* -@Initialize() - - input: () => Promise> - - role: Set default values for the properties of this class. - - The static properties of this class (namely choiceInput, booleanInput, stringInput, numberSliderInput, numberSpinnerInput) - 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 function passed to @Initialize() to fetch the initial values for - these fields. This is called after the onSubmit callback, to reinitialize the defaults. - - In this example, the initializeSelfServeExample function simply reads the SessionStorage to fetch the default values - for these fields. These are then set when the changes are submitted. -*/ -@Initialize(initializeSelfServeExample) -export class SelfServeExample extends SelfServeBase { - /* - @CustomElement() - - input: JSX.Element | (currentValues: Map => Promise) - - role: Display a custom element by either passing the element itself, or by passing a function that takes the current values - and renders a Component / JSX.Element. - - In this example, we first use a static JSX.Element to show a description text. We also declare a CustomComponent, that - takes a Map of propertyName -> value, as input. It uses this to display a Hoverable Card which shows a snapshot of - the current values. - */ - @CustomElement(descriptionElement) - static description: string; - - /* - @ParentOf() - - input: string[] - - role: Determines which UI elements are the children of which UI element. An array containing the names of the child properties - is passsed. You need to make sure these children are declared in this Class as proeprties. - */ - @ParentOf(["choiceInput", "booleanInput", "stringInput", "numberSliderInput", "numberSpinnerInput"]) - @CustomElement(renderText("Hover to see current values...")) - static currentValues: string; - - /* - @Label() - - input: string | () => Promise - - role: Adds a label for the UI element. This is ignored for a custom element but is required for all other properties. - */ - @Label("Choice") - - /* - @PropertyInfo() - - input: Info | () => Promise - - role: Display an Info bar above the UI element for this property. - */ - @PropertyInfo(choiceInfo) - - /* - @ChoiceInput() - - input: ChoiceItem[] | () => Promise - - role: Display a dropdown with choices. - */ - @ChoiceInput(choiceOptions) - static choiceInput: ChoiceItem; - - @Label("Boolean") - /* - @BooleanInput() - - input: - trueLabel : string | () => Promise - falseLabel : string | () => Promise - - role: Add a boolean input eith radio buttons for true and false values. - */ - @BooleanInput({ - trueLabel: "allowed", - falseLabel: "not allowed" - }) - static booleanInput: boolean; - - @Label("String") - /* - @PlaceHolder() - - input: string | () => Promise - - role: Adds a placeholder for the string input - */ - @Placeholder("instance name") - static stringInput: string; - - @Label("Slider") - - /* - @OnChange() - - 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 onSliderChange function sets the spinner input to the same value as the slider input - when the slider in moved in the UI. - */ - @OnChange(onSliderChange) - - /* - @NumberInput() - - input: - min : number | () => Promise - max : number | () => Promise - step : number | () => Promise - numberInputType : NumberInputType - - role: Display a numeric input as slider or a spinner. The Min, Max and step to increase by need to be provided as well. - In this example, the Max value is fetched via an async function. This is resolved every time the UI is reloaded. - */ - @NumberInput({ - min: 1, - max: initializeNumberMaxValue, - step: 1, - numberInputType: "slider" - }) - static numberSliderInput: number; - - @Label("Spinner") - @NumberInput({ - min: 1, - max: initializeNumberMaxValue, - step: 1, - numberInputType: "spinner" - }) - static numberSpinnerInput: number; -} diff --git a/src/SelfServe/Example/ExampleApis.tsx b/src/SelfServe/Example/ExampleApis.tsx deleted file mode 100644 index c292a3ce9..000000000 --- a/src/SelfServe/Example/ExampleApis.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from "react"; -import { ChoiceItem, Info, InputType } from "../../Explorer/Controls/SmartUi/SmartUiComponent"; -import { TextComponent } from "./CustomComponent"; -import { SessionStorageUtility } from "../../Shared/StorageUtility"; -import { Text } from "office-ui-fabric-react"; - -export enum Choices { - Choice1 = "Choice1", - Choice2 = "Choice2", - Choice3 = "Choice3" -} - -export const choiceOptions: ChoiceItem[] = [ - { label: "Choice 1", key: Choices.Choice1 }, - { label: "Choice 2", key: Choices.Choice2 }, - { label: "Choice 3", key: Choices.Choice3 } -]; - -export const selfServeExampleInfo: Info = { - message: "This is a self serve class" -}; - -export const choiceInfo: Info = { - message: "More choices can be added in the future." -}; - -export const onSliderChange = (currentState: Map, newValue: InputType): Map => { - currentState.set("numberSliderInput", newValue); - currentState.set("numberSpinnerInput", newValue); - return currentState; -}; - -export const onSubmit = async (currentValues: Map): Promise => { - SessionStorageUtility.setEntry("choiceInput", currentValues.get("choiceInput")?.toString()); - SessionStorageUtility.setEntry("booleanInput", currentValues.get("booleanInput")?.toString()); - SessionStorageUtility.setEntry("stringInput", currentValues.get("stringInput")?.toString()); - SessionStorageUtility.setEntry("numberSliderInput", currentValues.get("numberSliderInput")?.toString()); - SessionStorageUtility.setEntry("numberSpinnerInput", currentValues.get("numberSpinnerInput")?.toString()); -}; - -const delay = (ms: number): Promise => { - return new Promise(resolve => setTimeout(resolve, ms)); -}; - -export const initializeSelfServeExample = async (): Promise> => { - await delay(1000); - const defaults = new Map(); - defaults.set("choiceInput", SessionStorageUtility.getEntry("choiceInput")); - defaults.set("booleanInput", SessionStorageUtility.getEntry("booleanInput") === "true"); - defaults.set("stringInput", SessionStorageUtility.getEntry("stringInput")); - const numberSliderInput = parseInt(SessionStorageUtility.getEntry("numberSliderInput")); - defaults.set("numberSliderInput", !isNaN(numberSliderInput) ? numberSliderInput : 1); - const numberSpinnerInput = parseInt(SessionStorageUtility.getEntry("numberSpinnerInput")); - defaults.set("numberSpinnerInput", !isNaN(numberSpinnerInput) ? numberSpinnerInput : 1); - return defaults; -}; - -export const initializeNumberMaxValue = async (): Promise => { - await delay(2000); - return 5; -}; - -export const descriptionElement = This is an example of Self serve class.; - -export const renderText = (text: string): ((currentValues: Map) => Promise) => { - const elementPromiseFunction = async (currentValues: Map): Promise => { - return ; - }; - return elementPromiseFunction; -}; diff --git a/src/SelfServe/Example/SelfServeExample.tsx b/src/SelfServe/Example/SelfServeExample.tsx new file mode 100644 index 000000000..3bca37549 --- /dev/null +++ b/src/SelfServe/Example/SelfServeExample.tsx @@ -0,0 +1,175 @@ +import { PropertyInfo, OnChange, Values } from "../PropertyDecorators"; +import { ClassInfo, IsDisplayable } from "../ClassDecorators"; +import { SelfServeBaseClass } from "../SelfServeUtils"; +import { DropdownItem, 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: DropdownItem[] = [ + { 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." +}; + +export const delay = (ms: number): Promise => { + console.log("delay called"); + return new Promise(resolve => setTimeout(resolve, ms)); +}; + +const onDbThroughputChange = (currentState: Map, newValue: InputType): Map => { + currentState.set("dbThroughput", newValue); + currentState.set("collectionThroughput", newValue); + return currentState; +}; + +const initializeMaxThroughput = async (): Promise => { + await delay(2000); + 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.selfServeTypeForTest=example' + and plumb in similar feature flags for your own self serve class. +*/ + +/* + @IsDisplayable() + - role: Generated the JSON required to convert this class into the required UI. This is done during compile time. +*/ +@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 => { + await delay(1000); + 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> => { + await delay(1000); + 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 | DropdownInputOptions + - role: Specifies the required options to display the property as TextBox, Number Spinner/Slider, Radio buton or Dropdown. + */ + @Values({ label: "Regions", choices: regionDropdownItems }) + regions: DropdownItem; + + @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..8899acdce --- /dev/null +++ b/src/SelfServe/PropertyDecorators.tsx @@ -0,0 +1,107 @@ +import { DropdownItem, Info, InputType, UiType } from "../Explorer/Controls/SmartUi/SmartUiComponent"; +import { addPropertyToMap } from "./SelfServeUtils"; + +interface Decorator { + name: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any; +} + +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 DropdownInputOptions extends InputOptionsBase { + choices: (() => Promise) | DropdownItem[]; +} + +type InputOptions = NumberInputOptions | StringInputOptions | BooleanInputOptions | DropdownInputOptions; + +function isNumberInputOptions(inputOptions: InputOptions): inputOptions is NumberInputOptions { + return !!(inputOptions as NumberInputOptions).min; +} + +function isBooleanInputOptions(inputOptions: InputOptions): inputOptions is BooleanInputOptions { + return !!(inputOptions as BooleanInputOptions).trueLabel; +} + +function isDropdownInputOptions(inputOptions: InputOptions): inputOptions is DropdownInputOptions { + return !!(inputOptions as DropdownInputOptions).choices; +} + +const addToMap = (...decorators: Decorator[]): PropertyDecorator => { + return (target, property) => { + let className = target.constructor.name; + const propertyName = property.toString(); + if (className === "Function") { + 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)) { + const numberInputOptions = inputOptions as NumberInputOptions; + return addToMap( + { name: "label", value: numberInputOptions.label }, + { name: "min", value: numberInputOptions.min }, + { name: "max", value: numberInputOptions.max }, + { name: "step", value: numberInputOptions.step }, + { name: "uiType", value: numberInputOptions.uiType } + ); + } else if (isBooleanInputOptions(inputOptions)) { + const booleanInputOptions = inputOptions as BooleanInputOptions; + return addToMap( + { name: "label", value: booleanInputOptions.label }, + { name: "trueLabel", value: booleanInputOptions.trueLabel }, + { name: "falseLabel", value: booleanInputOptions.falseLabel } + ); + } else if (isDropdownInputOptions(inputOptions)) { + const dropdownInputOptions = inputOptions as DropdownInputOptions; + return addToMap( + { name: "label", value: dropdownInputOptions.label }, + { name: "choices", value: dropdownInputOptions.choices } + ); + } else { + const stringInputOptions = inputOptions as StringInputOptions; + return addToMap( + { name: "label", value: stringInputOptions.label }, + { name: "placeholder", value: stringInputOptions.placeholder } + ); + } +}; diff --git a/src/SelfServe/PropertyDescriptors.tsx b/src/SelfServe/PropertyDescriptors.tsx deleted file mode 100644 index fe28c6335..000000000 --- a/src/SelfServe/PropertyDescriptors.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { ChoiceItem, Info, InputType, NumberInputType } from "../Explorer/Controls/SmartUi/SmartUiComponent"; -import { addPropertyToMap } from "./SelfServeUtils"; - -interface Decorator { - name: string; - value: unknown; -} - -const addToMap = (...decorators: Decorator[]): PropertyDecorator => { - return (target, property) => { - const className = (target as Function).name; - const propertyType = (Reflect.getMetadata("design:type", target, property).name as string).toLowerCase(); - - addPropertyToMap(target, property.toString(), className, "type", propertyType); - addPropertyToMap(target, property.toString(), className, "dataFieldName", property.toString()); - - if (!className) { - throw new Error("property descriptor applied to non static field!"); - } - decorators.map((decorator: Decorator) => - addPropertyToMap(target, property.toString(), className, decorator.name, decorator.value) - ); - }; -}; - -export const OnChange = ( - onChange: (currentState: Map, newValue: InputType) => Map -): PropertyDecorator => { - return addToMap({ name: "onChange", value: onChange }); -}; - -export const CustomElement = ( - customElement: ((currentValues: Map) => Promise) | JSX.Element -): PropertyDecorator => { - return addToMap({ name: "customElement", value: customElement }); -}; - -export const PropertyInfo = (info: (() => Promise) | Info): PropertyDecorator => { - return addToMap({ name: "info", value: info }); -}; - -export const Placeholder = (placeholder: (() => Promise) | string): PropertyDecorator => { - return addToMap({ name: "placeholder", value: placeholder }); -}; - -export const ParentOf = (children: string[]): PropertyDecorator => { - return addToMap({ name: "parentOf", value: children }); -}; - -export const Label = (label: (() => Promise) | string): PropertyDecorator => { - return addToMap({ name: "label", value: label }); -}; - -export interface NumberInputOptions { - min: (() => Promise) | number; - max: (() => Promise) | number; - step: (() => Promise) | number; - numberInputType: NumberInputType; -} - -export const NumberInput = (numberInputOptions: NumberInputOptions): PropertyDecorator => { - return addToMap( - { name: "min", value: numberInputOptions.min }, - { name: "max", value: numberInputOptions.max }, - { name: "step", value: numberInputOptions.step }, - { name: "inputType", value: numberInputOptions.numberInputType } - ); -}; - -export interface BooleanInputOptions { - trueLabel: (() => Promise) | string; - falseLabel: (() => Promise) | string; -} - -export const BooleanInput = (booleanInputOptions: BooleanInputOptions): PropertyDecorator => { - return addToMap( - { name: "trueLabel", value: booleanInputOptions.trueLabel }, - { name: "falseLabel", value: booleanInputOptions.falseLabel } - ); -}; - -export const ChoiceInput = (choices: (() => Promise) | ChoiceItem[]): PropertyDecorator => { - return addToMap({ name: "choices", value: choices }); -}; diff --git a/src/SelfServe/SelfServeComponentAdapter.tsx b/src/SelfServe/SelfServeComponentAdapter.tsx index a1298a8cc..eb83aebed 100644 --- a/src/SelfServe/SelfServeComponentAdapter.tsx +++ b/src/SelfServe/SelfServeComponentAdapter.tsx @@ -9,7 +9,6 @@ import { ReactAdapter } from "../Bindings/ReactBindingHandler"; import Explorer from "../Explorer/Explorer"; import { Descriptor, SmartUiComponent } from "../Explorer/Controls/SmartUi/SmartUiComponent"; import { SelfServeTypes } from "./SelfServeUtils"; -import { SelfServeExample } from "./Example/Example"; export class SelfServeComponentAdapter implements ReactAdapter { public parameters: ko.Observable; @@ -23,18 +22,20 @@ export class SelfServeComponentAdapter implements ReactAdapter { }); } - private getDescriptor = (selfServeType: SelfServeTypes): Descriptor => { + public static getDescriptor = async (selfServeType: SelfServeTypes): Promise => { switch (selfServeType) { - case SelfServeTypes.example: - return SelfServeExample.toSmartUiDescriptor(); + case SelfServeTypes.example: { + const SelfServeExample = await import(/* webpackChunkName: "SelfServeExample" */ "./Example/SelfServeExample"); + return new SelfServeExample.default().toSmartUiDescriptor(); + } default: return undefined; } }; - public renderComponent(): JSX.Element { + public async renderComponent(): Promise { const selfServeType = this.container.selfServeType(); - const smartUiDescriptor = this.getDescriptor(selfServeType); + const smartUiDescriptor = await SelfServeComponentAdapter.getDescriptor(selfServeType); const element = smartUiDescriptor ? ( diff --git a/src/SelfServe/SelfServeUtils.test.tsx b/src/SelfServe/SelfServeUtils.test.tsx new file mode 100644 index 000000000..dee67df87 --- /dev/null +++ b/src/SelfServe/SelfServeUtils.test.tsx @@ -0,0 +1,275 @@ +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().toSmartUiDescriptor()).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().toSmartUiDescriptor()).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().toSmartUiDescriptor()).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 Dropdown 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 index 7f48871ed..b58701f88 100644 --- a/src/SelfServe/SelfServeUtils.tsx +++ b/src/SelfServe/SelfServeUtils.tsx @@ -1,6 +1,6 @@ import "reflect-metadata"; import { - ChoiceItem, + DropdownItem, Node, Info, InputTypeValue, @@ -9,22 +9,43 @@ import { NumberInput, StringInput, BooleanInput, - ChoiceInput, + DropdownInput, InputType } from "../Explorer/Controls/SmartUi/SmartUiComponent"; -const SelfServeType = "selfServeType"; +export enum SelfServeTypes { + none = "none", + invalid = "invalid", + example = "example" +} -export class SelfServeBase { - public static toSmartUiDescriptor(): Descriptor { - return Reflect.getMetadata(this.name, this) as Descriptor; +export abstract class SelfServeBaseClass { + public abstract onSubmit: (currentValues: Map) => Promise; + public abstract initialize: () => Promise>; + + public toSmartUiDescriptor(): Descriptor { + const className = this.constructor.name; + const smartUiDescriptor = Reflect.getMetadata(className, this) as Descriptor; + + 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; - parentOf?: string[]; type?: InputTypeValue; label?: (() => Promise) | string; placeholder?: (() => Promise) | string; @@ -34,12 +55,12 @@ export interface CommonInputTypes { step?: (() => Promise) | number; trueLabel?: (() => Promise) | string; falseLabel?: (() => Promise) | string; - choices?: (() => Promise) | ChoiceItem[]; - inputType?: string; + choices?: (() => Promise) | DropdownItem[]; + uiType?: string; + errorMessage?: string; onChange?: (currentState: Map, newValue: InputType) => Map; onSubmit?: (currentValues: Map) => Promise; initialize?: () => Promise>; - customElement?: ((currentValues: Map) => Promise) | JSX.Element; } const setValue = ( @@ -54,95 +75,86 @@ const getValue = (name: T, fieldObject: Common return fieldObject[name]; }; -export const addPropertyToMap = ( - target: Object, - propertyKey: string, - metadataKey: string, +export const addPropertyToMap = ( + target: unknown, + propertyName: string, + className: string, descriptorName: string, - descriptorValue: any + descriptorValue: K ): void => { - const descriptorKey = descriptorName.toString() as keyof CommonInputTypes; - let context = Reflect.getMetadata(metadataKey, target) as Map; - + let context = Reflect.getMetadata(className, target) as Map; if (!context) { context = new Map(); } + updateContextWithDecorator(context, propertyName, className, descriptorName, descriptorValue); + Reflect.defineMetadata(className, context, target); +}; + +export const updateContextWithDecorator = ( + context: Map, + propertyName: string, + className: string, + descriptorName: string, + descriptorValue: K +): void => { + const descriptorKey = descriptorName as keyof CommonInputTypes; if (!(context instanceof Map)) { - throw new Error("@SmartUi should be the first decorator for the class."); + console.log(context); + throw new Error(`@SmartUi should be the first decorator for the class '${className}'.`); } - let propertyObject = context.get(propertyKey); + let propertyObject = context.get(propertyName); if (!propertyObject) { - propertyObject = { id: propertyKey }; + propertyObject = { id: propertyName }; } if (getValue(descriptorKey, propertyObject) && descriptorKey !== "type" && descriptorKey !== "dataFieldName") { - throw new Error("duplicate descriptor"); + throw new Error( + `Duplicate value passed for '${descriptorKey}' on property '${propertyName}' of class '${className}'` + ); } setValue(descriptorKey, descriptorValue, propertyObject); - context.set(propertyKey, propertyObject); - - Reflect.defineMetadata(metadataKey, context, target); + context.set(propertyName, propertyObject); }; -export const toSmartUiDescriptor = (metadataKey: string, target: Object): void => { - const context = Reflect.getMetadata(metadataKey, target) as Map; - Reflect.defineMetadata(metadataKey, context, target); +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): Descriptor => { const root = context.get("root"); context.delete("root"); + const inputNames: string[] = []; - if (!root?.onSubmit) { - throw new Error( - "@OnSubmit decorator not declared for the class. Please ensure @SmartUi is the first decorator used for the class." - ); - } - - if (!root?.initialize) { - throw new Error( - "@Initialize decorator not declared for the class. Please ensure @SmartUi is the first decorator used for the class." - ); - } - - const smartUiDescriptor = { - onSubmit: root.onSubmit, - initialize: root.initialize, + const smartUiDescriptor: Descriptor = { root: { id: "root", - info: root.info, + info: root?.info, children: [] - } as Node - } as Descriptor; + } + }; while (context.size > 0) { const key = context.keys().next().value; - addToDescriptor(context, smartUiDescriptor, smartUiDescriptor.root, key); + addToDescriptor(context, smartUiDescriptor.root, key, inputNames); } + smartUiDescriptor.inputNames = inputNames; - Reflect.defineMetadata(metadataKey, smartUiDescriptor, target); + return smartUiDescriptor; }; const addToDescriptor = ( context: Map, - smartUiDescriptor: Descriptor, root: Node, - key: string + key: string, + inputNames: string[] ): void => { const value = context.get(key); - if (!value) { - // should already be added to root - const childNode = getChildFromRoot(key, smartUiDescriptor); - if (!childNode) { - // if not found at root level, error out - throw new Error("Either child does not exist or child has been assigned to more than one parent"); - } - root.children.push(childNode); - return; - } - - const childrenKeys = value.parentOf; + inputNames.push(value.id); const element = { id: value.id, info: value.info, @@ -150,61 +162,30 @@ const addToDescriptor = ( children: [] } as Node; context.delete(key); - for (const childKey in childrenKeys) { - addToDescriptor(context, smartUiDescriptor, element, childrenKeys[childKey]); - } root.children.push(element); }; -const getChildFromRoot = (key: string, smartUiDescriptor: Descriptor): Node => { - let i = 0; - const children = smartUiDescriptor.root.children; - while (i < children.length) { - if (children[i]?.id === key) { - const value = children[i]; - delete children[i]; - return value; - } else { - i++; - } - } - return undefined; -}; - const getInput = (value: CommonInputTypes): AnyInput => { - if (!value.label && !value.customElement) { - throw new Error("label is required."); - } - switch (value.type) { case "number": - if (!value.step || !value.inputType || !value.min || !value.max) { - throw new Error("step, min, miax and inputType are needed for number type"); + 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.trueLabel || !value.falseLabel) { - throw new Error("truelabel and falselabel are needed for boolean type"); + 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.choices) { - throw new Error("choices are needed for enum type"); + if (!value.label || !value.choices) { + value.errorMessage = `label and choices are required for Dropdown input '${value.id}'.`; } - return value as ChoiceInput; + return value as DropdownInput; } }; - -export enum SelfServeTypes { - none = "none", - invalid = "invalid", - example = "example" -} - -export const getSelfServeType = (search: string): SelfServeTypes => { - const params = new URLSearchParams(search); - const selfServeTypeParam = params.get(SelfServeType)?.toLowerCase(); - return SelfServeTypes[selfServeTypeParam as keyof typeof SelfServeTypes]; -}; 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..d4266d7f9 --- /dev/null +++ b/test/selfServe/selfServeExample.spec.ts @@ -0,0 +1,29 @@ +import { Frame } from "puppeteer"; +import { TestExplorerParams } from "../testExplorer/TestExplorerParams"; +import { getTestExplorerFrame } from "../testExplorer/TestExplorerUtils"; +import { SelfServeTypes } from "../../src/SelfServe/SelfServeUtils"; + +jest.setTimeout(300000); + +let frame: Frame; +describe("Notebook UI tests", () => { + it("Upload, Open and Delete Notebook", async () => { + try { + frame = await getTestExplorerFrame( + new Map([[TestExplorerParams.selfServeType, SelfServeTypes.example]]) + ); + await frame.waitForSelector(".ms-Dropdown"); + const dropdownLabel = await frame.$eval(".ms-Dropdown-label", element => element.textContent); + expect(dropdownLabel).toEqual("Regions"); + await frame.waitForSelector(".radioSwitchComponent"); + await frame.waitForSelector(".ms-TextField"); + await frame.waitForSelector(".ms-Slider "); + await frame.waitForSelector(".ms-spinButton-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 c1f8378aa..f0ab3e9da 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,6 @@ "noEmit": true, "types": ["jest"] }, - "include": ["./src/**/*", "./test/notebooks/testExplorer/**/*"], + "include": ["./src/**/*", "test/testExplorer/**/*"], "exclude": ["./src/**/__mocks__/**/*"] } diff --git a/webpack.config.js b/webpack.config.js index a90e8896c..c968071bc 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -142,7 +142,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({ @@ -183,7 +183,7 @@ module.exports = function(env = {}, argv = {}) { index: "./src/Index.ts", quickstart: "./src/quickstart.ts", hostedExplorer: "./src/HostedExplorer.ts", - 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",