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
This commit is contained in:
Srinath Narayanan 2021-01-19 22:42:45 -08:00 committed by GitHub
parent 2b2de7c645
commit c1937ca464
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 4944 additions and 3322 deletions

View File

@ -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 }]]
};

39
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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

View File

@ -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 {

View File

@ -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",

View File

@ -157,14 +157,14 @@ exports[`Feature panel renders all flags 1`] = `
/>
<StyledCheckboxBase
checked={false}
key="feature.enableLinkInjection"
label="Enable Injecting Notebook Viewer Link into the first cell"
key="feature.selfServeType"
label="Self serve feature"
onChange={[Function]}
/>
<StyledCheckboxBase
checked={false}
key="feature.canexceedmaximumvalue"
label="Can exceed max value"
key="feature.enableLinkInjection"
label="Enable Injecting Notebook Viewer Link into the first cell"
onChange={[Function]}
/>
</Stack>
@ -172,6 +172,12 @@ exports[`Feature panel renders all flags 1`] = `
className="checkboxRow"
horizontalAlign="space-between"
>
<StyledCheckboxBase
checked={false}
key="feature.canexceedmaximumvalue"
label="Can exceed max value"
onChange={[Function]}
/>
<StyledCheckboxBase
checked={false}
key="feature.enablefixedcollectionwithsharedthroughput"

View File

@ -1026,6 +1026,7 @@ exports[`SettingsComponent renders 1`] = `
"notificationConsoleData": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"onSwitchToConnectionString": [Function],
"onToggleKeyDown": [Function],
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
@ -1071,6 +1072,8 @@ exports[`SettingsComponent renders 1`] = `
"title": [Function],
"visible": [Function],
},
"renewToken": [Function],
"renewTokenError": [Function],
"resourceTokenCollection": [Function],
"resourceTokenCollectionId": [Function],
"resourceTokenDatabaseId": [Function],
@ -1117,6 +1120,14 @@ exports[`SettingsComponent renders 1`] = `
},
"selectedDatabaseId": [Function],
"selectedNode": [Function],
"selfServeComponentAdapter": SelfServeComponentAdapter {
"container": [Circular],
"parameters": [Function],
},
"selfServeLoadingComponentAdapter": SelfServeLoadingComponentAdapter {
"parameters": [Function],
},
"selfServeType": [Function],
"serverId": [Function],
"settingsPane": SettingsPane {
"container": [Circular],
@ -1163,6 +1174,7 @@ exports[`SettingsComponent renders 1`] = `
"shouldShowContextSwitchPrompt": [Function],
"shouldShowDataAccessExpiryDialog": [Function],
"shouldShowShareDialogContents": [Function],
"signInAad": [Function],
"sparkClusterConnectionInfo": [Function],
"splashScreenAdapter": SplashScreenComponentAdapter {
"clearMostRecent": [Function],
@ -1229,6 +1241,7 @@ exports[`SettingsComponent renders 1`] = `
"toggleLeftPaneExpandedKeyPress": [Function],
"toggleRead": [Function],
"toggleReadWrite": [Function],
"tokenForRenewal": [Function],
"uploadFilePane": UploadFilePane {
"container": [Circular],
"extensions": [Function],
@ -2296,6 +2309,7 @@ exports[`SettingsComponent renders 1`] = `
"notificationConsoleData": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"onSwitchToConnectionString": [Function],
"onToggleKeyDown": [Function],
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
@ -2341,6 +2355,8 @@ exports[`SettingsComponent renders 1`] = `
"title": [Function],
"visible": [Function],
},
"renewToken": [Function],
"renewTokenError": [Function],
"resourceTokenCollection": [Function],
"resourceTokenCollectionId": [Function],
"resourceTokenDatabaseId": [Function],
@ -2387,6 +2403,14 @@ exports[`SettingsComponent renders 1`] = `
},
"selectedDatabaseId": [Function],
"selectedNode": [Function],
"selfServeComponentAdapter": SelfServeComponentAdapter {
"container": [Circular],
"parameters": [Function],
},
"selfServeLoadingComponentAdapter": SelfServeLoadingComponentAdapter {
"parameters": [Function],
},
"selfServeType": [Function],
"serverId": [Function],
"settingsPane": SettingsPane {
"container": [Circular],
@ -2433,6 +2457,7 @@ exports[`SettingsComponent renders 1`] = `
"shouldShowContextSwitchPrompt": [Function],
"shouldShowDataAccessExpiryDialog": [Function],
"shouldShowShareDialogContents": [Function],
"signInAad": [Function],
"sparkClusterConnectionInfo": [Function],
"splashScreenAdapter": SplashScreenComponentAdapter {
"clearMostRecent": [Function],
@ -2499,6 +2524,7 @@ exports[`SettingsComponent renders 1`] = `
"toggleLeftPaneExpandedKeyPress": [Function],
"toggleRead": [Function],
"toggleReadWrite": [Function],
"tokenForRenewal": [Function],
"uploadFilePane": UploadFilePane {
"container": [Circular],
"extensions": [Function],
@ -3579,6 +3605,7 @@ exports[`SettingsComponent renders 1`] = `
"notificationConsoleData": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"onSwitchToConnectionString": [Function],
"onToggleKeyDown": [Function],
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
@ -3624,6 +3651,8 @@ exports[`SettingsComponent renders 1`] = `
"title": [Function],
"visible": [Function],
},
"renewToken": [Function],
"renewTokenError": [Function],
"resourceTokenCollection": [Function],
"resourceTokenCollectionId": [Function],
"resourceTokenDatabaseId": [Function],
@ -3670,6 +3699,14 @@ exports[`SettingsComponent renders 1`] = `
},
"selectedDatabaseId": [Function],
"selectedNode": [Function],
"selfServeComponentAdapter": SelfServeComponentAdapter {
"container": [Circular],
"parameters": [Function],
},
"selfServeLoadingComponentAdapter": SelfServeLoadingComponentAdapter {
"parameters": [Function],
},
"selfServeType": [Function],
"serverId": [Function],
"settingsPane": SettingsPane {
"container": [Circular],
@ -3716,6 +3753,7 @@ exports[`SettingsComponent renders 1`] = `
"shouldShowContextSwitchPrompt": [Function],
"shouldShowDataAccessExpiryDialog": [Function],
"shouldShowShareDialogContents": [Function],
"signInAad": [Function],
"sparkClusterConnectionInfo": [Function],
"splashScreenAdapter": SplashScreenComponentAdapter {
"clearMostRecent": [Function],
@ -3782,6 +3820,7 @@ exports[`SettingsComponent renders 1`] = `
"toggleLeftPaneExpandedKeyPress": [Function],
"toggleRead": [Function],
"toggleReadWrite": [Function],
"tokenForRenewal": [Function],
"uploadFilePane": UploadFilePane {
"container": [Circular],
"extensions": [Function],
@ -4849,6 +4888,7 @@ exports[`SettingsComponent renders 1`] = `
"notificationConsoleData": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"onSwitchToConnectionString": [Function],
"onToggleKeyDown": [Function],
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
@ -4894,6 +4934,8 @@ exports[`SettingsComponent renders 1`] = `
"title": [Function],
"visible": [Function],
},
"renewToken": [Function],
"renewTokenError": [Function],
"resourceTokenCollection": [Function],
"resourceTokenCollectionId": [Function],
"resourceTokenDatabaseId": [Function],
@ -4940,6 +4982,14 @@ exports[`SettingsComponent renders 1`] = `
},
"selectedDatabaseId": [Function],
"selectedNode": [Function],
"selfServeComponentAdapter": SelfServeComponentAdapter {
"container": [Circular],
"parameters": [Function],
},
"selfServeLoadingComponentAdapter": SelfServeLoadingComponentAdapter {
"parameters": [Function],
},
"selfServeType": [Function],
"serverId": [Function],
"settingsPane": SettingsPane {
"container": [Circular],
@ -4986,6 +5036,7 @@ exports[`SettingsComponent renders 1`] = `
"shouldShowContextSwitchPrompt": [Function],
"shouldShowDataAccessExpiryDialog": [Function],
"shouldShowShareDialogContents": [Function],
"signInAad": [Function],
"sparkClusterConnectionInfo": [Function],
"splashScreenAdapter": SplashScreenComponentAdapter {
"clearMostRecent": [Function],
@ -5052,6 +5103,7 @@ exports[`SettingsComponent renders 1`] = `
"toggleLeftPaneExpandedKeyPress": [Function],
"toggleRead": [Function],
"toggleReadWrite": [Function],
"tokenForRenewal": [Function],
"uploadFilePane": UploadFilePane {
"container": [Circular],
"extensions": [Function],

View File

@ -1,9 +1,9 @@
import { shallow } from "enzyme";
import React from "react";
import { Descriptor, SmartUiComponent } from "./SmartUiComponent";
import { shallow } from "enzyme";
import { SmartUiComponent, SmartUiDescriptor, UiType } from "./SmartUiComponent";
describe("SmartUiComponent", () => {
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(<SmartUiComponent descriptor={exampleData} onChange={exampleCallbacks} />);
it("should render", async () => {
const wrapper = shallow(
<SmartUiComponent descriptor={exampleData} currentValues={new Map()} onInputChange={undefined} />
);
await new Promise(resolve => setTimeout(resolve, 0));
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -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<string, InputType>) => void;
descriptor: SmartUiDescriptor;
currentValues: Map<string, InputType>;
onInputChange: (input: AnyInput, newValue: InputType) => void;
}
interface SmartUiComponentState {
currentValues: Map<string, InputType>;
errors: Map<string, string>;
}
@ -104,7 +107,6 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
constructor(props: SmartUiComponentProps) {
super(props);
this.state = {
currentValues: new Map(),
errors: new Map()
};
}
@ -113,30 +115,26 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
return (
<MessageBar>
{info.message}
{info.link && (
<Link href={info.link.href} target="_blank">
{info.link.text}
</Link>
)}
</MessageBar>
);
}
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 (
<div className="stringInputContainer">
<div>
<TextField
id={`${input.dataFieldName}-input`}
id={`${input.dataFieldName}-textBox-input`}
label={input.label}
type="text"
value={input.defaultValue}
value={value}
placeholder={input.placeholder}
onChange={(_, newValue) => this.onInputChange(newValue, input.dataFieldName)}
onChange={(_, newValue) => this.props.onInputChange(input, newValue)}
styles={{
subComponentStyles: {
label: {
@ -149,7 +147,6 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
}}
/>
</div>
</div>
);
}
@ -159,10 +156,11 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
this.setState({ errors });
}
private onValidate = (value: string, min: number, max: number, dataFieldName: string): string => {
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<SmartUiComponentProps, Sma
return undefined;
};
private onIncrement = (value: string, step: number, max: number, dataFieldName: string): string => {
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<SmartUiComponentProps, Sma
};
private renderNumberInput(input: NumberInput): JSX.Element {
const { label, min, max, defaultValue, dataFieldName, step } = input;
const props = { label, min, max, ariaLabel: label, step };
const { label, min, max, dataFieldName, step } = input;
const props = {
label: label,
min: min,
max: max,
ariaLabel: label,
step: step
};
if (input.inputType === "spin") {
const value = this.props.currentValues.get(dataFieldName) as number;
if (input.uiType === UiType.Spinner) {
return (
<div>
<>
<SpinButton
{...props}
defaultValue={defaultValue.toString()}
onValidate={newValue => 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,16 +225,15 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
{this.state.errors.has(dataFieldName) && (
<MessageBar messageBarType={MessageBarType.error}>Error: {this.state.errors.get(dataFieldName)}</MessageBar>
)}
</div>
</>
);
} else if (input.inputType === "slider") {
} else if (input.uiType === UiType.Slider) {
return (
<div id={`${input.dataFieldName}-slider-input`}>
<Slider
// showValue={true}
// valueFormat={}
{...props}
defaultValue={defaultValue}
onChange={newValue => this.onInputChange(newValue, dataFieldName)}
value={value}
onChange={newValue => this.props.onInputChange(input, newValue)}
styles={{
titleLabel: {
...SmartUiComponent.labelStyle,
@ -235,16 +242,18 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
valueLabel: SmartUiComponent.labelStyle
}}
/>
</div>
);
} 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 (
<div>
<div id={`${input.dataFieldName}-radioSwitch-input`}>
<div className="inputLabelContainer">
<Text variant="small" nowrap className="inputLabel">
{input.label}
@ -255,41 +264,33 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
{
label: input.falseLabel,
key: "false",
onSelect: () => 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}
/>
</div>
);
}
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 (
<Dropdown
id={`${input.dataFieldName}-dropown-input`}
label={label}
selectedKey={
this.state.currentValues.has(dataFieldName)
? (this.state.currentValues.get(dataFieldName) as string)
: defaultKey
}
onChange={(_, item: IDropdownOption) => 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.Component<SmartUiComponentProps, Sma
);
}
private renderError(input: AnyInput): JSX.Element {
return <MessageBar messageBarType={MessageBarType.error}>Error: {input.errorMessage}</MessageBar>;
}
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 (
<Stack tokens={containerStackTokens} className="widgetRendererContainer">
{node.info && this.renderInfo(node.info)}
<Stack.Item>
{node.info && this.renderInfo(node.info as Info)}
{node.input && this.renderInput(node.input)}
</Stack.Item>
{node.children && node.children.map(child => <div key={child.id}>{this.renderNode(child)}</div>)}
</Stack>
);
}
render(): JSX.Element {
return <>{this.renderNode(this.props.descriptor.root)}</>;
const containerStackTokens: IStackTokens = { childrenGap: 20 };
return (
<Stack tokens={containerStackTokens} styles={{ root: { width: 400, padding: 10 } }}>
{this.renderNode(this.props.descriptor.root)}
</Stack>
);
}
}

View File

@ -1,15 +1,30 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SmartUiComponent should render 1`] = `
<Fragment>
<Stack
styles={
Object {
"root": Object {
"padding": 10,
"width": 400,
},
}
}
tokens={
Object {
"childrenGap": 20,
}
}
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
"childrenGap": 15,
}
}
>
<StackItem>
<StyledMessageBarBase>
Start at $24/mo per database
<StyledLinkBase
@ -19,6 +34,7 @@ exports[`SmartUiComponent should render 1`] = `
More Details
</StyledLinkBase>
</StyledMessageBarBase>
</StackItem>
<div
key="throughput"
>
@ -26,11 +42,11 @@ exports[`SmartUiComponent should render 1`] = `
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
"childrenGap": 15,
}
}
>
<div>
<StackItem>
<CustomizedSpinButton
ariaLabel="Throughput (input)"
decrementButtonIcon={
@ -38,8 +54,8 @@ exports[`SmartUiComponent should render 1`] = `
"iconName": "ChevronDownSmall",
}
}
defaultValue="400"
disabled={false}
id="throughput-spinner-input"
incrementButtonIcon={
Object {
"iconName": "ChevronUpSmall",
@ -64,7 +80,7 @@ exports[`SmartUiComponent should render 1`] = `
}
}
/>
</div>
</StackItem>
</Stack>
</div>
<div
@ -74,13 +90,16 @@ exports[`SmartUiComponent should render 1`] = `
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
"childrenGap": 15,
}
}
>
<StackItem>
<div
id="throughput2-slider-input"
>
<StyledSliderBase
ariaLabel="Throughput (Slider)"
defaultValue={400}
label="Throughput (Slider)"
max={500}
min={400}
@ -102,6 +121,29 @@ exports[`SmartUiComponent should render 1`] = `
}
}
/>
</div>
</StackItem>
</Stack>
</div>
<div
key="throughput3"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
}
>
<StackItem>
<StyledMessageBarBase
messageBarType={1}
>
Error:
label, truelabel and falselabel are required for boolean input 'throughput3'
</StyledMessageBarBase>
</StackItem>
</Stack>
</div>
<div
@ -111,16 +153,16 @@ exports[`SmartUiComponent should render 1`] = `
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
"childrenGap": 15,
}
}
>
<StackItem>
<div
className="stringInputContainer"
>
<div>
<StyledTextFieldBase
id="containerId-input"
id="containerId-textBox-input"
label="Container id"
onChange={[Function]}
styles={
@ -140,7 +182,7 @@ exports[`SmartUiComponent should render 1`] = `
type="text"
/>
</div>
</div>
</StackItem>
</Stack>
</div>
<div
@ -150,11 +192,14 @@ exports[`SmartUiComponent should render 1`] = `
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
"childrenGap": 15,
}
}
>
<div>
<StackItem>
<div
id="analyticalStore-radioSwitch-input"
>
<div
className="inputLabelContainer"
>
@ -184,6 +229,7 @@ exports[`SmartUiComponent should render 1`] = `
selectedKey="true"
/>
</div>
</StackItem>
</Stack>
</div>
<div
@ -193,26 +239,28 @@ exports[`SmartUiComponent should render 1`] = `
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
"childrenGap": 15,
}
}
>
<StackItem>
<StyledWithResponsiveMode
id="database-dropown-input"
label="Database"
onChange={[Function]}
options={
Array [
Object {
"key": "db1",
"text": "database1",
"text": "Database 1",
},
Object {
"key": "db2",
"text": "database2",
"text": "Database 2",
},
Object {
"key": "db3",
"text": "database3",
"text": "Database 3",
},
]
}
@ -233,8 +281,9 @@ exports[`SmartUiComponent should render 1`] = `
}
}
/>
</StackItem>
</Stack>
</div>
</Stack>
</Fragment>
</Stack>
`;

View File

@ -88,6 +88,9 @@ 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
@ -131,6 +134,7 @@ export default class Explorer {
public isEnableMongoCapabilityPresent: ko.Computed<boolean>;
public isServerlessEnabled: ko.Computed<boolean>;
public isAccountReady: ko.Observable<boolean>;
public selfServeType: ko.Observable<SelfServeType>;
public canSaveQueries: ko.Computed<boolean>;
public features: ko.Observable<any>;
public serverId: ko.Observable<string>;
@ -156,6 +160,7 @@ export default class Explorer {
public selectedNode: ko.Observable<ViewModels.TreeNode>;
public isRefreshingExplorer: ko.Observable<boolean>;
private resourceTree: ResourceTreeAdapter;
private selfServeComponentAdapter: SelfServeComponentAdapter;
// Resource Token
public resourceTokenDatabaseId: ko.Observable<string>;
@ -214,6 +219,8 @@ export default class Explorer {
public shouldShowShareDialogContents: ko.Observable<boolean>;
public shareAccessData: ko.Observable<AdHocAccessData>;
public renewExplorerShareAccess: (explorer: Explorer, token: string) => Q.Promise<void>;
public renewTokenError: ko.Observable<string>;
public tokenForRenewal: ko.Observable<string>;
public shareAccessToggleState: ko.Observable<ShareAccessToggleState>;
public shareAccessUrl: ko.Observable<string>;
public shareUrlCopyHelperText: ko.Observable<string>;
@ -257,6 +264,7 @@ export default class Explorer {
private _dialogProps: ko.Observable<DialogProps>;
private addSynapseLinkDialog: DialogComponentAdapter;
private _addSynapseLinkDialogProps: ko.Observable<DialogProps>;
private selfServeLoadingComponentAdapter: SelfServeLoadingComponentAdapter;
private static readonly MaxNbDatabasesToAutoExpand = 5;
@ -292,6 +300,7 @@ export default class Explorer {
}
});
this.isAccountReady = ko.observable<boolean>(false);
this.selfServeType = ko.observable<SelfServeType>(undefined);
this._isInitializingNotebooks = false;
this._isInitializingSparkConnectionInfo = false;
this.arcadiaToken = ko.observable<string>();
@ -312,7 +321,7 @@ export default class Explorer {
this.isSynapseLinkUpdating = ko.observable<boolean>(false);
this.isAccountReady.subscribe(async (isAccountReady: boolean) => {
if (isAccountReady) {
this.isAuthWithResourceToken() ? await this.refreshDatabaseForResourceToken() : this.refreshAllDatabases(true);
this.isAuthWithResourceToken() ? this.refreshDatabaseForResourceToken() : this.refreshAllDatabases(true);
RouteHandler.getInstance().initHandler();
this.notebookWorkspaceManager = new NotebookWorkspaceManager();
this.arcadiaWorkspaces = ko.observableArray();
@ -379,6 +388,8 @@ export default class Explorer {
readWriteUrl: undefined,
readUrl: undefined
});
this.tokenForRenewal = ko.observable<string>("");
this.renewTokenError = ko.observable<string>("");
this.shareAccessUrl = ko.observable<string>();
this.shareUrlCopyHelperText = ko.observable<string>("Click to copy");
this.shareTokenCopyHelperText = ko.observable<string>("Click to copy");
@ -410,6 +421,7 @@ export default class Explorer {
this.isSchemaEnabled = ko.computed<boolean>(() => this.isFeatureEnabled(Constants.Features.enableSchema));
this.isNotificationConsoleExpanded = ko.observable<boolean>(false);
this.isAutoscaleDefaultEnabled = ko.observable<boolean>(false);
this.databases = ko.observableArray<ViewModels.Database>();
@ -693,6 +705,7 @@ export default class Explorer {
});
this.uploadItemsPaneAdapter = new UploadItemsPaneAdapter(this);
this.selfServeComponentAdapter = new SelfServeComponentAdapter(this);
this.loadQueryPane = new LoadQueryPane({
id: "loadquerypane",
@ -868,6 +881,7 @@ export default class Explorer {
});
this.commandBarComponentAdapter = new CommandBarComponentAdapter(this);
this.selfServeLoadingComponentAdapter = new SelfServeLoadingComponentAdapter();
this.notificationConsoleComponentAdapter = new NotificationConsoleComponentAdapter(this);
this._initSettings();
@ -1090,6 +1104,25 @@ export default class Explorer {
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(
@ -1241,6 +1274,16 @@ export default class Explorer {
$("#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;
};
@ -1330,17 +1373,21 @@ export default class Explorer {
}
}
public async refreshDatabaseForResourceToken(): Promise<any> {
public refreshDatabaseForResourceToken(): Q.Promise<any> {
const databaseId = this.resourceTokenDatabaseId();
const collectionId = this.resourceTokenCollectionId();
if (!databaseId || !collectionId) {
throw new Error("No collection ID or database ID for resource token");
return Q.reject();
}
return readCollection(databaseId, collectionId).then((collection: DataModels.Collection) => {
const deferred: Q.Deferred<void> = 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<any> {
@ -1806,11 +1853,25 @@ export default class Explorer {
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" && configContext.platform === Platform.Portal) {
if (process.env.NODE_ENV === "development") {
sessionStorage.setItem("portalDataExplorerInitMessage", JSON.stringify(inputs));
}
@ -1829,6 +1890,7 @@ export default class Explorer {
this.isTryCosmosDBSubscription(inputs.isTryCosmosDBSubscription);
this.isAuthWithResourceToken(inputs.isAuthWithresourceToken);
this.setFeatureFlagsFromFlights(inputs.flights);
this.setSelfServeType(inputs);
this._importExplorerConfigComplete = true;
updateConfigContext({
@ -1845,6 +1907,16 @@ export default class Explorer {
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);
}
}
@ -1926,6 +1998,18 @@ export default class Explorer {
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,

View File

@ -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 (
<div className="flexContainer">
<div id="divExplorer" className="flexContainer hideOverflows" style={{ display: "none" }}>
<div
id="divSelfServe"
className="flexContainer"
data-bind="visible: selfServeType() && selfServeType() !== 'none', react: selfServeComponentAdapter"
></div>
<div
id="divExplorer"
data-bind="if: selfServeType() === 'none'"
className="flexContainer hideOverflows"
style={{ display: "none" }}
>
{/* Main Command Bar - Start */}
<div data-bind="react: commandBarComponentAdapter" />
{/* Main Command Bar - End */}
@ -453,8 +466,11 @@ const App: React.FunctionComponent = () => {
/>
</div>
{/* Global loader - Start */}
<div className="splashLoaderContainer" data-bind="visible: isRefreshingExplorer">
<div className="splashLoaderContentContainer">
<div data-bind="visible: selfServeType() === undefined, react: selfServeLoadingComponentAdapter"></div>
<div data-bind="if: selfServeType() === 'none'" style={{ display: "none" }}>
<p className="connectExplorerContent">
<img src={hdeConnectImage} alt="Azure Cosmos DB" />
</p>
@ -466,6 +482,7 @@ const App: React.FunctionComponent = () => {
</p>
</div>
</div>
</div>
{/* Global loader - End */}
<div data-bind="react:uploadItemsPaneAdapter" />
<div data-bind='component: { name: "add-database-pane", params: {data: addDatabasePane} }' />

View File

@ -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>) | Info): ClassDecorator => {
return target => {
addPropertyToMap(target.prototype, "root", target.name, "info", info);
};
};

View File

@ -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<string, InputType>, newValue: InputType): Map<string, InputType> => {
currentState.set("dbThroughput", newValue);
currentState.set("collectionThroughput", newValue);
return currentState;
};
const initializeMaxThroughput = async (): Promise<number> => {
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<Info>
- role: Display an Info bar as the first element of the UI.
*/
@ClassInfo(selfServeExampleInfo)
export default class SelfServeExample extends SelfServeBaseClass {
/*
onSubmit()
- input: (currentValues: Map<string, InputType>) => Promise<void>
- 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<string, InputType>): Promise<void> => {
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<Map<string, InputType>>
- 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<Map<string, InputType>> => {
const defaults = new Map<string, InputType>();
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<Info>
- 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<string, InputType>, newValue: InputType) => Map<string, InputType>
- 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;
}

View File

@ -0,0 +1,101 @@
import { ChoiceItem, Info, InputType, UiType } from "../Explorer/Controls/SmartUi/SmartUiComponent";
import { addPropertyToMap, CommonInputTypes } from "./SelfServeUtils";
type ValueOf<T> = T[keyof T];
interface Decorator {
name: keyof CommonInputTypes;
value: ValueOf<CommonInputTypes>;
}
interface InputOptionsBase {
label: string;
}
export interface NumberInputOptions extends InputOptionsBase {
min: (() => Promise<number>) | number;
max: (() => Promise<number>) | number;
step: (() => Promise<number>) | number;
uiType: UiType;
}
export interface StringInputOptions extends InputOptionsBase {
placeholder?: (() => Promise<string>) | string;
}
export interface BooleanInputOptions extends InputOptionsBase {
trueLabel: (() => Promise<string>) | string;
falseLabel: (() => Promise<string>) | string;
}
export interface ChoiceInputOptions extends InputOptionsBase {
choices: (() => Promise<ChoiceItem[]>) | 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<string, InputType>, newValue: InputType) => Map<string, InputType>
): PropertyDecorator => {
return addToMap({ name: "onChange", value: onChange });
};
export const PropertyInfo = (info: (() => Promise<Info>) | 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 }
);
}
};

View File

@ -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<string, InputType>([
["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<string, InputType>): 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(<SelfServeComponent descriptor={exampleData} />);
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();
});
});

View File

@ -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>) | string;
dataFieldName: string;
type: InputTypeValue;
onChange?: (currentState: Map<string, InputType>, newValue: InputType) => Map<string, InputType>;
placeholder?: (() => Promise<string>) | string;
errorMessage?: string;
}
export interface NumberInput extends BaseInput {
min: (() => Promise<number>) | number;
max: (() => Promise<number>) | number;
step: (() => Promise<number>) | number;
defaultValue?: number;
uiType: UiType;
}
export interface BooleanInput extends BaseInput {
trueLabel: (() => Promise<string>) | string;
falseLabel: (() => Promise<string>) | string;
defaultValue?: boolean;
}
export interface StringInput extends BaseInput {
defaultValue?: string;
}
export interface ChoiceInput extends BaseInput {
choices: (() => Promise<ChoiceItem[]>) | ChoiceItem[];
defaultKey?: string;
}
export interface Node {
id: string;
info?: (() => Promise<Info>) | Info;
input?: AnyInput;
children?: Node[];
}
export interface SelfServeDescriptor {
root: Node;
initialize?: () => Promise<Map<string, InputType>>;
onSubmit?: (currentValues: Map<string, InputType>) => Promise<void>;
inputNames?: string[];
}
export type AnyInput = NumberInput | BooleanInput | StringInput | ChoiceInput;
export interface SelfServeComponentProps {
descriptor: SelfServeDescriptor;
}
export interface SelfServeComponentState {
root: SelfServeDescriptor;
currentValues: Map<string, InputType>;
baselineValues: Map<string, InputType>;
isRefreshing: boolean;
}
export class SelfServeComponent extends React.Component<SelfServeComponentProps, SelfServeComponentState> {
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<void> => {
this.setState({ isRefreshing: true });
await this.initializeSmartUiNode(this.props.descriptor.root);
await this.setDefaults();
this.setState({ isRefreshing: false });
};
private setDefaults = async (): Promise<void> => {
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<void> => {
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<AnyInput> => {
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<T>(value: T | (() => Promise<T>)): Promise<T> {
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 ? (
<div style={{ overflowX: "auto" }}>
<Stack tokens={containerStackTokens} styles={{ root: { width: 400, padding: 10 } }}>
<SmartUiComponent
descriptor={this.state.root as SmartUiDescriptor}
currentValues={this.state.currentValues}
onInputChange={this.onInputChange}
/>
<Stack horizontal tokens={{ childrenGap: 10 }}>
<PrimaryButton
id="submitButton"
styles={{ root: { width: 100 } }}
text="submit"
onClick={async () => {
await this.props.descriptor.onSubmit(this.state.currentValues);
this.setDefaults();
}}
/>
<PrimaryButton
id="discardButton"
styles={{ root: { width: 100 } }}
text="discard"
onClick={() => this.discard()}
/>
</Stack>
</Stack>
</div>
) : (
<Spinner
size={SpinnerSize.large}
styles={{ root: { textAlign: "center", justifyContent: "center", width: "100%", height: "100%" } }}
/>
);
}
}

View File

@ -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<SelfServeDescriptor>;
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<SelfServeDescriptor> => {
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 <h1>Invalid self serve type!</h1>;
}
const smartUiDescriptor = this.parameters();
return smartUiDescriptor ? <SelfServeComponent descriptor={smartUiDescriptor} /> : <></>;
}
private triggerRender() {
window.requestAnimationFrame(async () => {
const selfServeType = this.container.selfServeType();
const smartUiDescriptor = await SelfServeComponentAdapter.getDescriptor(selfServeType);
this.parameters(smartUiDescriptor);
});
}
}

View File

@ -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<number>;
constructor() {
this.parameters = ko.observable(Date.now());
}
public renderComponent(): JSX.Element {
return <Spinner size={SpinnerSize.large} />;
}
private triggerRender() {
window.requestAnimationFrame(() => this.renderComponent());
}
}

View File

@ -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<void> => {
return;
};
public initialize: () => Promise<Map<string, InputType>>;
}
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<void>;
public initialize = async (): Promise<Map<string, InputType>> => {
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<void> => {
return;
};
public initialize = async (): Promise<Map<string, InputType>> => {
return undefined;
};
}
expect(() => new Test().toSelfServeDescriptor()).toThrow(
"@SmartUi decorator was not declared for the class 'Test'"
);
});
it("updateContextWithDecorator", () => {
const context = new Map<string, CommonInputTypes>();
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<string, CommonInputTypes> = 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);
});
});

View File

@ -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<string, InputType>) => Promise<void>;
public abstract initialize: () => Promise<Map<string, InputType>>;
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>) | Info;
type?: InputTypeValue;
label?: (() => Promise<string>) | string;
placeholder?: (() => Promise<string>) | string;
dataFieldName?: string;
min?: (() => Promise<number>) | number;
max?: (() => Promise<number>) | number;
step?: (() => Promise<number>) | number;
trueLabel?: (() => Promise<string>) | string;
falseLabel?: (() => Promise<string>) | string;
choices?: (() => Promise<ChoiceItem[]>) | ChoiceItem[];
uiType?: string;
errorMessage?: string;
onChange?: (currentState: Map<string, InputType>, newValue: InputType) => Map<string, InputType>;
onSubmit?: (currentValues: Map<string, InputType>) => Promise<void>;
initialize?: () => Promise<Map<string, InputType>>;
}
const setValue = <T extends keyof CommonInputTypes, K extends CommonInputTypes[T]>(
name: T,
value: K,
fieldObject: CommonInputTypes
): void => {
fieldObject[name] = value;
};
const getValue = <T extends keyof CommonInputTypes>(name: T, fieldObject: CommonInputTypes): unknown => {
return fieldObject[name];
};
export const addPropertyToMap = <T extends keyof CommonInputTypes, K extends CommonInputTypes[T]>(
target: unknown,
propertyName: string,
className: string,
descriptorName: keyof CommonInputTypes,
descriptorValue: K
): void => {
const context =
(Reflect.getMetadata(className, target) as Map<string, CommonInputTypes>) ?? new Map<string, CommonInputTypes>();
updateContextWithDecorator(context, propertyName, className, descriptorName, descriptorValue);
Reflect.defineMetadata(className, context, target);
};
export const updateContextWithDecorator = <T extends keyof CommonInputTypes, K extends CommonInputTypes[T]>(
context: Map<string, CommonInputTypes>,
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<string, CommonInputTypes>;
const smartUiDescriptor = mapToSmartUiDescriptor(context);
Reflect.defineMetadata(className, smartUiDescriptor, target);
};
export const mapToSmartUiDescriptor = (context: Map<string, CommonInputTypes>): 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<string, CommonInputTypes>,
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;
}
};

View File

@ -0,0 +1,168 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SelfServeComponent should render 1`] = `
<div
style={
Object {
"overflowX": "auto",
}
}
>
<Stack
styles={
Object {
"root": Object {
"padding": 10,
"width": 400,
},
}
}
tokens={
Object {
"childrenGap": 20,
}
}
>
<SmartUiComponent
currentValues={
Map {
"throughput" => "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]}
/>
<Stack
horizontal={true}
tokens={
Object {
"childrenGap": 10,
}
}
>
<CustomizedPrimaryButton
id="submitButton"
onClick={[Function]}
styles={
Object {
"root": Object {
"width": 100,
},
}
}
text="submit"
/>
<CustomizedPrimaryButton
id="discardButton"
onClick={[Function]}
styles={
Object {
"root": Object {
"width": 100,
},
}
}
text="discard"
/>
</Stack>
</Stack>
</div>
`;

View File

@ -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<Frame> => {
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<ElementHandle<Element>> => {
const notebookNode = await getNotebookNode(frame, notebookName);
if (notebookNode) {

View File

@ -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");

View File

@ -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<string, string>([[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;
}
});
});

View File

@ -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<void> => {
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<void> => {
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
};

View File

@ -5,5 +5,6 @@ export enum TestExplorerParams {
portalRunnerDatabaseAccount = "portalRunnerDatabaseAccount",
portalRunnerDatabaseAccountKey = "portalRunnerDatabaseAccountKey",
portalRunnerSubscripton = "portalRunnerSubscripton",
portalRunnerResourceGroup = "portalRunnerResourceGroup"
portalRunnerResourceGroup = "portalRunnerResourceGroup",
selfServeType = "selfServeType"
}

View File

@ -0,0 +1,54 @@
import { Frame } from "puppeteer";
import { TestExplorerParams } from "./TestExplorerParams";
let testExplorerFrame: Frame;
export const getTestExplorerFrame = async (params?: Map<string, string>): Promise<Frame> => {
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();
};

View File

@ -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__/**/*"]
}

View File

@ -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",