From 49bf8c60db5a60653e56150f8518227bc76a149c Mon Sep 17 00:00:00 2001 From: Srinath Narayanan Date: Tue, 26 Jan 2021 09:44:14 -0800 Subject: [PATCH] Added more Self Serve functionalities (#401) * 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 * added toggle, visibility, text display,commandbar * added sqlx example * added onRefrssh * formatting changes * rmoved radioswitch display * updated smartui tests * Added more tests * onSubmit -> onSave * Resolved PR comments --- .../SmartUi/SmartUiComponent.test.tsx | 74 +- .../Controls/SmartUi/SmartUiComponent.tsx | 184 ++-- .../SmartUiComponent.test.tsx.snap | 806 +++++++++++++----- src/SelfServe/ClassDecorators.tsx | 14 - ...{PropertyDecorators.tsx => Decorators.tsx} | 48 +- src/SelfServe/Example/SelfServeExample.rp.ts | 64 ++ src/SelfServe/Example/SelfServeExample.tsx | 210 +++-- src/SelfServe/SelfServeComponent.test.tsx | 147 +++- src/SelfServe/SelfServeComponent.tsx | 354 +++++--- src/SelfServe/SelfServeComponentAdapter.tsx | 7 +- src/SelfServe/SelfServeTypes.ts | 130 +++ src/SelfServe/SelfServeUtils.test.tsx | 55 +- src/SelfServe/SelfServeUtils.tsx | 93 +- src/SelfServe/SqlX/SqlX.rp.ts | 33 + src/SelfServe/SqlX/SqlX.tsx | 97 +++ .../SelfServeComponent.test.tsx.snap | 800 ++++++++++++++++- test/selfServe/selfServeExample.spec.ts | 23 +- 17 files changed, 2479 insertions(+), 660 deletions(-) delete mode 100644 src/SelfServe/ClassDecorators.tsx rename src/SelfServe/{PropertyDecorators.tsx => Decorators.tsx} (66%) create mode 100644 src/SelfServe/Example/SelfServeExample.rp.ts create mode 100644 src/SelfServe/SelfServeTypes.ts create mode 100644 src/SelfServe/SqlX/SqlX.rp.ts create mode 100644 src/SelfServe/SqlX/SqlX.tsx diff --git a/src/Explorer/Controls/SmartUi/SmartUiComponent.test.tsx b/src/Explorer/Controls/SmartUi/SmartUiComponent.test.tsx index 000e8b905..3e13580f6 100644 --- a/src/Explorer/Controls/SmartUi/SmartUiComponent.test.tsx +++ b/src/Explorer/Controls/SmartUi/SmartUiComponent.test.tsx @@ -1,6 +1,7 @@ import React from "react"; import { shallow } from "enzyme"; -import { SmartUiComponent, SmartUiDescriptor, UiType } from "./SmartUiComponent"; +import { SmartUiComponent, SmartUiDescriptor } from "./SmartUiComponent"; +import { NumberUiType, SmartUiInput } from "../../../SelfServe/SelfServeTypes"; describe("SmartUiComponent", () => { const exampleData: SmartUiDescriptor = { @@ -14,6 +15,20 @@ describe("SmartUiComponent", () => { }, }, children: [ + { + id: "description", + input: { + dataFieldName: "description", + type: "string", + description: { + text: "this is an example description text.", + link: { + href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction", + text: "Click here for more information.", + }, + }, + }, + }, { id: "throughput", input: { @@ -24,7 +39,7 @@ describe("SmartUiComponent", () => { max: 500, step: 10, defaultValue: 400, - uiType: UiType.Spinner, + uiType: NumberUiType.Spinner, }, }, { @@ -37,7 +52,7 @@ describe("SmartUiComponent", () => { max: 500, step: 10, defaultValue: 400, - uiType: UiType.Slider, + uiType: NumberUiType.Slider, }, }, { @@ -50,7 +65,7 @@ describe("SmartUiComponent", () => { max: 500, step: 10, defaultValue: 400, - uiType: UiType.Spinner, + uiType: NumberUiType.Spinner, errorMessage: "label, truelabel and falselabel are required for boolean input 'throughput3'", }, }, @@ -91,11 +106,58 @@ describe("SmartUiComponent", () => { }, }; - it("should render", async () => { + it("should render and honor input's hidden, disabled state", async () => { + const currentValues = new Map(); const wrapper = shallow( - + { + return; + }} + /> ); await new Promise((resolve) => setTimeout(resolve, 0)); expect(wrapper).toMatchSnapshot(); + expect(wrapper.exists("#containerId-textField-input")).toBeTruthy(); + + currentValues.set("containerId", { value: "container1", hidden: true }); + wrapper.setProps({ currentValues }); + wrapper.update(); + expect(wrapper.exists("#containerId-textField-input")).toBeFalsy(); + + currentValues.set("containerId", { value: "container1", hidden: false, disabled: true }); + wrapper.setProps({ currentValues }); + wrapper.update(); + const containerIdTextField = wrapper.find("#containerId-textField-input"); + expect(containerIdTextField.props().disabled).toBeTruthy(); + }); + + it("disable all inputs", async () => { + const wrapper = shallow( + { + return; + }} + /> + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(wrapper).toMatchSnapshot(); + const throughputSpinner = wrapper.find("#throughput-spinner-input"); + expect(throughputSpinner.props().disabled).toBeTruthy(); + const throughput2Slider = wrapper.find("#throughput2-slider-input").childAt(0); + expect(throughput2Slider.props().disabled).toBeTruthy(); + const containerIdTextField = wrapper.find("#containerId-textField-input"); + expect(containerIdTextField.props().disabled).toBeTruthy(); + const analyticalStoreToggle = wrapper.find("#analyticalStore-toggle-input"); + expect(analyticalStoreToggle.props().disabled).toBeTruthy(); + const databaseDropdown = wrapper.find("#database-dropdown-input"); + expect(databaseDropdown.props().disabled).toBeTruthy(); }); }); diff --git a/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx b/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx index 4befeed08..0bde146ea 100644 --- a/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx +++ b/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx @@ -5,11 +5,19 @@ 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 { RadioSwitchComponent } from "../RadioSwitchComponent/RadioSwitchComponent"; import { Stack, IStackTokens } from "office-ui-fabric-react/lib/Stack"; -import { Link, MessageBar, MessageBarType } from "office-ui-fabric-react"; +import { Link, MessageBar, MessageBarType, Toggle } from "office-ui-fabric-react"; import * as InputUtils from "./InputUtils"; import "./SmartUiComponent.less"; +import { + ChoiceItem, + Description, + Info, + InputType, + InputTypeValue, + NumberUiType, + SmartUiInput, +} from "../../../SelfServe/SelfServeTypes"; /** * Generic UX renderer @@ -19,29 +27,14 @@ import "./SmartUiComponent.less"; * - a descriptor of the UX. */ -export type InputTypeValue = "number" | "string" | "boolean" | "object"; - -export enum UiType { - Spinner = "Spinner", - Slider = "Slider", -} - -export type ChoiceItem = { label: string; key: string }; - -export type InputType = number | string | boolean | ChoiceItem; - -export interface Info { - message: string; - link?: { - href: string; - text: string; - }; -} - -interface BaseInput { - label: string; +interface BaseDisplay { dataFieldName: string; + errorMessage?: string; type: InputTypeValue; +} + +interface BaseInput extends BaseDisplay { + label: string; placeholder?: string; errorMessage?: string; } @@ -54,7 +47,7 @@ interface NumberInput extends BaseInput { max: number; step: number; defaultValue?: number; - uiType: UiType; + uiType: NumberUiType; } interface BooleanInput extends BaseInput { @@ -72,12 +65,16 @@ interface ChoiceInput extends BaseInput { defaultKey?: string; } -type AnyInput = NumberInput | BooleanInput | StringInput | ChoiceInput; +interface DescriptionDisplay extends BaseDisplay { + description: Description; +} + +type AnyDisplay = NumberInput | BooleanInput | StringInput | ChoiceInput | DescriptionDisplay; interface Node { id: string; info?: Info; - input?: AnyInput; + input?: AnyDisplay; children?: Node[]; } @@ -86,11 +83,12 @@ export interface SmartUiDescriptor { } /************************** Component implementation starts here ************************************* */ - export interface SmartUiComponentProps { descriptor: SmartUiDescriptor; - currentValues: Map; - onInputChange: (input: AnyInput, newValue: InputType) => void; + currentValues: Map; + onInputChange: (input: AnyDisplay, newValue: InputType) => void; + onError: (hasError: boolean) => void; + disabled: boolean; } interface SmartUiComponentState { @@ -98,12 +96,22 @@ interface SmartUiComponentState { } export class SmartUiComponent extends React.Component { + private shouldCheckErrors = true; private static readonly labelStyle = { color: "#393939", fontFamily: "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif", fontSize: 12, }; + componentDidUpdate(): void { + if (!this.shouldCheckErrors) { + this.shouldCheckErrors = true; + return; + } + this.props.onError(this.state.errors.size > 0); + this.shouldCheckErrors = false; + } + constructor(props: SmartUiComponentProps) { super(props); this.state = { @@ -113,7 +121,7 @@ export class SmartUiComponent extends React.Component + {info.message} {info.link && ( @@ -125,17 +133,20 @@ export class SmartUiComponent extends React.Component this.props.onInputChange(input, newValue)} styles={{ + root: { width: 400 }, subComponentStyles: { label: { root: { @@ -150,13 +161,27 @@ export class SmartUiComponent extends React.Component + {input.description.text}{" "} + {description.link && ( + + {input.description.link.text} + + )} + + ); + } + private clearError(dataFieldName: string): void { const { errors } = this.state; errors.delete(dataFieldName); this.setState({ errors }); } - private onValidate = (input: AnyInput, value: string, min: number, max: number): string => { + private onValidate = (input: NumberInput, value: string, min: number, max: number): string => { const newValue = InputUtils.onValidateValueChange(value, min, max); const dataFieldName = input.dataFieldName; if (newValue) { @@ -165,13 +190,13 @@ export class SmartUiComponent extends React.Component { + private onIncrement = (input: NumberInput, value: string, step: number, max: number): string => { const newValue = InputUtils.onIncrementValue(value, step, max); const dataFieldName = input.dataFieldName; if (newValue) { @@ -182,7 +207,7 @@ export class SmartUiComponent extends React.Component { + private onDecrement = (input: NumberInput, value: string, step: number, min: number): string => { const newValue = InputUtils.onDecrementValue(value, step, min); const dataFieldName = input.dataFieldName; if (newValue) { @@ -203,10 +228,11 @@ export class SmartUiComponent extends React.Component + this.onIncrement(input, newValue, props.step, props.max)} onDecrement={(newValue) => this.onDecrement(input, newValue, props.step, props.min)} labelPosition={Position.top} + disabled={disabled} styles={{ label: { ...SmartUiComponent.labelStyle, @@ -225,16 +252,18 @@ export class SmartUiComponent extends React.ComponentError: {this.state.errors.get(dataFieldName)} )} - + ); - } else if (input.uiType === UiType.Slider) { + } else if (input.uiType === NumberUiType.Slider) { return (
this.props.onInputChange(input, newValue)} styles={{ + root: { width: 400 }, titleLabel: { ...SmartUiComponent.labelStyle, fontWeight: 600, @@ -250,49 +279,44 @@ export class SmartUiComponent extends React.Component -
- - {input.label} - -
- this.props.onInputChange(input, false), - }, - { - label: input.trueLabel, - key: "true", - onSelect: () => this.props.onInputChange(input, true), - }, - ]} - selectedKey={selectedKey} - /> -
+ this.props.onInputChange(input, checked)} + styles={{ root: { width: 400 } }} + /> ); } private renderChoiceInput(input: ChoiceInput): JSX.Element { const { label, defaultKey: defaultKey, dataFieldName, choices, placeholder } = input; - const value = this.props.currentValues.get(dataFieldName) as string; + const value = this.props.currentValues.get(dataFieldName)?.value as string; + const disabled = this.props.disabled || this.props.currentValues.get(dataFieldName)?.disabled; + let selectedKey = value ? value : defaultKey; + if (!selectedKey) { + selectedKey = ""; + } return ( this.props.onInputChange(input, item.key.toString())} placeholder={placeholder} + disabled={disabled} options={choices.map((c) => ({ key: c.key, text: c.label, }))} styles={{ + root: { width: 400 }, label: { ...SmartUiComponent.labelStyle, fontWeight: 600, @@ -303,16 +327,23 @@ export class SmartUiComponent extends React.ComponentError: {input.errorMessage}; } - private renderInput(input: AnyInput): JSX.Element { + private renderDisplay(input: AnyDisplay): JSX.Element { if (input.errorMessage) { return this.renderError(input); } + const inputHidden = this.props.currentValues.get(input.dataFieldName)?.hidden; + if (inputHidden) { + return <>; + } switch (input.type) { case "string": + if ("description" in input) { + return this.renderDescription(input as DescriptionDisplay); + } return this.renderTextInput(input as StringInput); case "number": return this.renderNumberInput(input as NumberInput); @@ -326,13 +357,13 @@ export class SmartUiComponent extends React.Component {node.info && this.renderInfo(node.info as Info)} - {node.input && this.renderInput(node.input)} + {node.input && this.renderDisplay(node.input)} {node.children && node.children.map((child) =>
{this.renderNode(child)}
)} @@ -340,11 +371,6 @@ export class SmartUiComponent extends React.Component - {this.renderNode(this.props.descriptor.root)} - - ); + return this.renderNode(this.props.descriptor.root); } } diff --git a/src/Explorer/Controls/SmartUi/__snapshots__/SmartUiComponent.test.tsx.snap b/src/Explorer/Controls/SmartUi/__snapshots__/SmartUiComponent.test.tsx.snap index b8efc28e3..ec2697d01 100644 --- a/src/Explorer/Controls/SmartUi/__snapshots__/SmartUiComponent.test.tsx.snap +++ b/src/Explorer/Controls/SmartUi/__snapshots__/SmartUiComponent.test.tsx.snap @@ -1,52 +1,405 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`SmartUiComponent should render 1`] = ` +exports[`SmartUiComponent disable all inputs 1`] = ` - - - - Start at $24/mo per database - - More Details - - - -
- + + Start at $24/mo per database + - + More Details + + + +
+ + + + this is an example description text. + + + Click here for more information. + + + + +
+
+ + + + + + + +
+
+ + +
+ +
+
+
+
+
+ + + + Error: + label, truelabel and falselabel are required for boolean input 'throughput3' + + + +
+
+ + +
+ +
+
+
+
+
+ + + + + +
+
+ + + + + +
+
+`; + +exports[`SmartUiComponent should render and honor input's hidden, disabled state 1`] = ` + + + + Start at $24/mo per database + + More Details + + + +
+ + + + this is an example description text. + + + Click here for more information. + + + + +
+
+ + + - - -
-
- + + +
+
+ - -
- -
-
-
-
-
- - - - Error: - label, truelabel and falselabel are required for boolean input 'throughput3' - - - -
-
- - -
- -
-
-
-
-
- - -
-
- - Analytical Store - -
- -
-
-
-
-
- - - +
+ - - -
-
+
+ +
+
+
+ + + + Error: + label, truelabel and falselabel are required for boolean input 'throughput3' + + + +
+
+ + +
+ +
+
+
+
+
+ + + + + +
+
+ + + + + +
`; diff --git a/src/SelfServe/ClassDecorators.tsx b/src/SelfServe/ClassDecorators.tsx deleted file mode 100644 index 5659ab365..000000000 --- a/src/SelfServe/ClassDecorators.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Info } from "../Explorer/Controls/SmartUi/SmartUiComponent"; -import { addPropertyToMap, buildSmartUiDescriptor } from "./SelfServeUtils"; - -export const IsDisplayable = (): ClassDecorator => { - return (target) => { - buildSmartUiDescriptor(target.name, target.prototype); - }; -}; - -export const ClassInfo = (info: (() => Promise) | Info): ClassDecorator => { - return (target) => { - addPropertyToMap(target.prototype, "root", target.name, "info", info); - }; -}; diff --git a/src/SelfServe/PropertyDecorators.tsx b/src/SelfServe/Decorators.tsx similarity index 66% rename from src/SelfServe/PropertyDecorators.tsx rename to src/SelfServe/Decorators.tsx index 9b8017b4e..9c060e6c0 100644 --- a/src/SelfServe/PropertyDecorators.tsx +++ b/src/SelfServe/Decorators.tsx @@ -1,10 +1,10 @@ -import { ChoiceItem, Info, InputType, UiType } from "../Explorer/Controls/SmartUi/SmartUiComponent"; -import { addPropertyToMap, CommonInputTypes } from "./SelfServeUtils"; +import { ChoiceItem, Description, Info, InputType, NumberUiType, SmartUiInput } from "./SelfServeTypes"; +import { addPropertyToMap, DecoratorProperties, buildSmartUiDescriptor } from "./SelfServeUtils"; type ValueOf = T[keyof T]; interface Decorator { - name: keyof CommonInputTypes; - value: ValueOf; + name: keyof DecoratorProperties; + value: ValueOf; } interface InputOptionsBase { @@ -15,7 +15,7 @@ export interface NumberInputOptions extends InputOptionsBase { min: (() => Promise) | number; max: (() => Promise) | number; step: (() => Promise) | number; - uiType: UiType; + uiType: NumberUiType; } export interface StringInputOptions extends InputOptionsBase { @@ -29,9 +29,19 @@ export interface BooleanInputOptions extends InputOptionsBase { export interface ChoiceInputOptions extends InputOptionsBase { choices: (() => Promise) | ChoiceItem[]; + placeholder?: (() => Promise) | string; } -type InputOptions = NumberInputOptions | StringInputOptions | BooleanInputOptions | ChoiceInputOptions; +export interface DescriptionDisplayOptions { + description?: (() => Promise) | Description; +} + +type InputOptions = + | NumberInputOptions + | StringInputOptions + | BooleanInputOptions + | ChoiceInputOptions + | DescriptionDisplayOptions; const isNumberInputOptions = (inputOptions: InputOptions): inputOptions is NumberInputOptions => { return "min" in inputOptions; @@ -45,6 +55,10 @@ const isChoiceInputOptions = (inputOptions: InputOptions): inputOptions is Choic return "choices" in inputOptions; }; +const isDescriptionDisplayOptions = (inputOptions: InputOptions): inputOptions is DescriptionDisplayOptions => { + return "description" in inputOptions; +}; + const addToMap = (...decorators: Decorator[]): PropertyDecorator => { return (target, property) => { let className = target.constructor.name; @@ -66,7 +80,7 @@ const addToMap = (...decorators: Decorator[]): PropertyDecorator => { }; export const OnChange = ( - onChange: (currentState: Map, newValue: InputType) => Map + onChange: (currentState: Map, newValue: InputType) => Map ): PropertyDecorator => { return addToMap({ name: "onChange", value: onChange }); }; @@ -91,7 +105,13 @@ export const Values = (inputOptions: InputOptions): PropertyDecorator => { { name: "falseLabel", value: inputOptions.falseLabel } ); } else if (isChoiceInputOptions(inputOptions)) { - return addToMap({ name: "label", value: inputOptions.label }, { name: "choices", value: inputOptions.choices }); + return addToMap( + { name: "label", value: inputOptions.label }, + { name: "placeholder", value: inputOptions.placeholder }, + { name: "choices", value: inputOptions.choices } + ); + } else if (isDescriptionDisplayOptions(inputOptions)) { + return addToMap({ name: "description", value: inputOptions.description }); } else { return addToMap( { name: "label", value: inputOptions.label }, @@ -99,3 +119,15 @@ export const Values = (inputOptions: InputOptions): PropertyDecorator => { ); } }; + +export const IsDisplayable = (): ClassDecorator => { + return (target) => { + buildSmartUiDescriptor(target.name, target.prototype); + }; +}; + +export const ClassInfo = (info: (() => Promise) | Info): ClassDecorator => { + return (target) => { + addPropertyToMap(target.prototype, "root", target.name, "info", info); + }; +}; diff --git a/src/SelfServe/Example/SelfServeExample.rp.ts b/src/SelfServe/Example/SelfServeExample.rp.ts new file mode 100644 index 000000000..76cbde9b9 --- /dev/null +++ b/src/SelfServe/Example/SelfServeExample.rp.ts @@ -0,0 +1,64 @@ +import { get } from "../../Utils/arm/generatedClients/2020-04-01/databaseAccounts"; +import { userContext } from "../../UserContext"; +import { SessionStorageUtility } from "../../Shared/StorageUtility"; +import { RefreshResult } from "../SelfServeTypes"; +export enum Regions { + NorthCentralUS = "NorthCentralUS", + WestUS = "WestUS", + EastUS2 = "EastUS2", +} + +export interface InitializeResponse { + regions: Regions; + enableLogging: boolean; + accountName: string; + collectionThroughput: number; + dbThroughput: number; +} + +export const getMaxThroughput = async (): Promise => { + return 10000; +}; + +export const update = async ( + regions: Regions, + enableLogging: boolean, + accountName: string, + collectionThroughput: number, + dbThoughput: number +): Promise => { + SessionStorageUtility.setEntry("regions", regions); + SessionStorageUtility.setEntry("enableLogging", enableLogging?.toString()); + SessionStorageUtility.setEntry("accountName", accountName); + SessionStorageUtility.setEntry("collectionThroughput", collectionThroughput?.toString()); + SessionStorageUtility.setEntry("dbThroughput", dbThoughput?.toString()); +}; + +export const initialize = async (): Promise => { + const regions = Regions[SessionStorageUtility.getEntry("regions") as keyof typeof Regions]; + const enableLogging = SessionStorageUtility.getEntry("enableLogging") === "true"; + const accountName = SessionStorageUtility.getEntry("accountName"); + let collectionThroughput = parseInt(SessionStorageUtility.getEntry("collectionThroughput")); + collectionThroughput = isNaN(collectionThroughput) ? undefined : collectionThroughput; + let dbThroughput = parseInt(SessionStorageUtility.getEntry("dbThroughput")); + dbThroughput = isNaN(dbThroughput) ? undefined : dbThroughput; + return { + regions: regions, + enableLogging: enableLogging, + accountName: accountName, + collectionThroughput: collectionThroughput, + dbThroughput: dbThroughput, + }; +}; + +export const onRefreshSelfServeExample = async (): Promise => { + const subscriptionId = userContext.subscriptionId; + const resourceGroup = userContext.resourceGroup; + const databaseAccountName = userContext.databaseAccount.name; + const databaseAccountGetResults = await get(subscriptionId, resourceGroup, databaseAccountName); + const isUpdateInProgress = databaseAccountGetResults.properties.provisioningState !== "Succeeded"; + return { + isUpdateInProgress: isUpdateInProgress, + notificationMessage: "Self Serve Example successfully refreshing", + }; +}; diff --git a/src/SelfServe/Example/SelfServeExample.tsx b/src/SelfServe/Example/SelfServeExample.tsx index 1ff77400b..39459989f 100644 --- a/src/SelfServe/Example/SelfServeExample.tsx +++ b/src/SelfServe/Example/SelfServeExample.tsx @@ -1,37 +1,57 @@ -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"; +import { PropertyInfo, OnChange, Values, IsDisplayable, ClassInfo } from "../Decorators"; +import { + ChoiceItem, + Info, + InputType, + NumberUiType, + RefreshResult, + SelfServeBaseClass, + SelfServeNotification, + SelfServeNotificationType, + SmartUiInput, +} from "../SelfServeTypes"; +import { onRefreshSelfServeExample, getMaxThroughput, Regions, update, initialize } from "./SelfServeExample.rp"; -export enum Regions { - NorthCentralUS = "NCUS", - WestUS = "WUS", - EastUS2 = "EUS2", -} - -export const regionDropdownItems: ChoiceItem[] = [ +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 = { +const selfServeExampleInfo: Info = { message: "This is a self serve class", }; -export const regionDropdownInfo: Info = { +const regionDropdownInfo: Info = { message: "More regions can be added in the future.", }; -const onDbThroughputChange = (currentState: Map, newValue: InputType): Map => { - currentState.set("dbThroughput", newValue); - currentState.set("collectionThroughput", newValue); +const onRegionsChange = (currentState: Map, newValue: InputType): Map => { + currentState.set("regions", { value: newValue }); + const currentEnableLogging = currentState.get("enableLogging"); + if (newValue === Regions.NorthCentralUS) { + currentState.set("enableLogging", { value: false, disabled: true }); + } else { + currentState.set("enableLogging", { value: currentEnableLogging.value, disabled: false }); + } return currentState; }; -const initializeMaxThroughput = async (): Promise => { - return 10000; +const onEnableDbLevelThroughputChange = ( + currentState: Map, + newValue: InputType +): Map => { + currentState.set("enableDbLevelThroughput", { value: newValue }); + const currentDbThroughput = currentState.get("dbThroughput"); + const isDbThroughputHidden = newValue === undefined || !(newValue as boolean); + currentState.set("dbThroughput", { value: currentDbThroughput.value, hidden: isDbThroughputHidden }); + return currentState; +}; + +const validate = (currentvalues: Map): void => { + if (!currentvalues.get("regions").value || !currentvalues.get("accountName").value) { + throw new Error("Regions and AccountName should not be empty."); + } }; /* @@ -40,8 +60,9 @@ const initializeMaxThroughput = async (): Promise => { 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 onSave() function, a callback for when the submit button is clicked. - Needs to define an initialize() function, to set default values for the inputs. + - Needs to define an onRefresh() function, a callback for when the refresh button is clicked. 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. @@ -61,25 +82,46 @@ const initializeMaxThroughput = async (): Promise => { @ClassInfo(selfServeExampleInfo) export default class SelfServeExample extends SelfServeBaseClass { /* - onSubmit() + onRefresh() + - role : Callback that is triggerrd when the refresh button is clicked. You should perform the your rest API + call to check if the update action is completed. + - returns: + RefreshResult - + isComponentUpdating: Indicated if the state is still being updated + notificationMessage: Notification message to be shown in case the component is still being updated + i.e, isComponentUpdating is true + */ + public onRefresh = async (): Promise => { + return onRefreshSelfServeExample(); + }; + + /* + onSave() - 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 this example, the onSave callback simply sets the value for keys corresponding to the field name in the SessionStorage. + - returns: SelfServeNotification - + message: The message to be displayed in the message bar after the onSave is completed + type: The type of message bar to be used (info, warning, error) */ - public onSubmit = async (currentValues: Map): Promise => { - SessionStorageUtility.setEntry("regions", currentValues.get("regions")?.toString()); - SessionStorageUtility.setEntry("enableLogging", currentValues.get("enableLogging")?.toString()); - SessionStorageUtility.setEntry("accountName", currentValues.get("accountName")?.toString()); - SessionStorageUtility.setEntry("dbThroughput", currentValues.get("dbThroughput")?.toString()); - SessionStorageUtility.setEntry("collectionThroughput", currentValues.get("collectionThroughput")?.toString()); + public onSave = async (currentValues: Map): Promise => { + validate(currentValues); + const regions = Regions[currentValues.get("regions")?.value as keyof typeof Regions]; + const enableLogging = currentValues.get("enableLogging")?.value as boolean; + const accountName = currentValues.get("accountName")?.value as string; + const collectionThroughput = currentValues.get("collectionThroughput")?.value as number; + const enableDbLevelThroughput = currentValues.get("enableDbLevelThroughput")?.value as boolean; + let dbThroughput = currentValues.get("dbThroughput")?.value as number; + dbThroughput = enableDbLevelThroughput ? dbThroughput : undefined; + await update(regions, enableLogging, accountName, collectionThroughput, dbThroughput); + return { message: "submitted successfully", type: SelfServeNotificationType.info }; }; /* initialize() - - input: () => Promise> - role: Set default values for the properties of this class. The properties of this class (namely regions, enableLogging, accountName, dbThroughput, collectionThroughput), @@ -87,24 +129,46 @@ export default class SelfServeExample extends SelfServeBaseClass { 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. + these fields. This is called after the onSave 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. + - returns: () => Promise> */ - public initialize = async (): Promise> => { - const defaults = new Map(); - defaults.set("regions", SessionStorageUtility.getEntry("regions")); - defaults.set("enableLogging", SessionStorageUtility.getEntry("enableLogging") === "true"); - const stringInput = SessionStorageUtility.getEntry("accountName"); - defaults.set("accountName", stringInput ? stringInput : ""); - const numberSliderInput = parseInt(SessionStorageUtility.getEntry("dbThroughput")); - defaults.set("dbThroughput", isNaN(numberSliderInput) ? 1 : numberSliderInput); - const numberSpinnerInput = parseInt(SessionStorageUtility.getEntry("collectionThroughput")); - defaults.set("collectionThroughput", isNaN(numberSpinnerInput) ? 1 : numberSpinnerInput); + public initialize = async (): Promise> => { + const initializeResponse = await initialize(); + const defaults = new Map(); + defaults.set("regions", { value: initializeResponse.regions }); + defaults.set("enableLogging", { value: initializeResponse.enableLogging }); + const accountName = initializeResponse.accountName; + defaults.set("accountName", { value: accountName ? accountName : "" }); + defaults.set("collectionThroughput", { value: initializeResponse.collectionThroughput }); + const enableDbLevelThroughput = !!initializeResponse.dbThroughput; + defaults.set("enableDbLevelThroughput", { value: enableDbLevelThroughput }); + defaults.set("dbThroughput", { value: initializeResponse.dbThroughput, hidden: !enableDbLevelThroughput }); return defaults; }; + /* + @Values() : + - input: NumberInputOptions | StringInputOptions | BooleanInputOptions | ChoiceInputOptions | DescriptionDisplay + - role: Specifies the required options to display the property as + a) TextBox for text input + b) Spinner/Slider for number input + c) Radio buton/Toggle for boolean input + d) Dropdown for choice input + e) Text (with optional hyperlink) for descriptions + */ + @Values({ + description: { + text: "This class sets collection and database throughput.", + link: { + href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction", + text: "Click here for more information", + }, + }, + }) + description: string; /* @PropertyInfo() - optional @@ -114,11 +178,22 @@ export default class SelfServeExample extends SelfServeBaseClass { @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. + @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, + say prop1, changes its value in the UI. This can be used to + a) Change the value (and reflect it in the UI) for prop2 based on prop1. + b) Change the visibility for prop2 in the UI, based on prop1 + + The new Map of propertyName -> value is returned. + + In this example, the onRegionsChange function sets the enableLogging property to false (and disables + the corresponsing toggle UI) when "regions" is set to "North Central US", and enables the toggle for + any other value of "regions" */ - @Values({ label: "Regions", choices: regionDropdownItems }) + @OnChange(onRegionsChange) + @Values({ label: "Regions", choices: regionDropdownItems, placeholder: "Select a region" }) regions: ChoiceItem; @Values({ @@ -134,34 +209,33 @@ export default class SelfServeExample extends SelfServeBaseClass { }) 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, + max: getMaxThroughput, step: 100, - uiType: UiType.Spinner, + uiType: NumberUiType.Spinner, }) collectionThroughput: number; + + /* + In this example, the onEnableDbLevelThroughputChange function makes the dbThroughput property visible when + enableDbLevelThroughput, a boolean, is set to true and hides dbThroughput property when it is set to false. + */ + @OnChange(onEnableDbLevelThroughputChange) + @Values({ + label: "Enable DB level throughput", + trueLabel: "Enable", + falseLabel: "Disable", + }) + enableDbLevelThroughput: boolean; + + @Values({ + label: "Database Throughput", + min: 400, + max: getMaxThroughput, + step: 100, + uiType: NumberUiType.Slider, + }) + dbThroughput: number; } diff --git a/src/SelfServe/SelfServeComponent.test.tsx b/src/SelfServe/SelfServeComponent.test.tsx index b51e1a96b..5544caeb5 100644 --- a/src/SelfServe/SelfServeComponent.test.tsx +++ b/src/SelfServe/SelfServeComponent.test.tsx @@ -1,23 +1,36 @@ import React from "react"; import { shallow } from "enzyme"; -import { SelfServeDescriptor, SelfServeComponent, SelfServeComponentState } from "./SelfServeComponent"; -import { InputType, UiType } from "../Explorer/Controls/SmartUi/SmartUiComponent"; +import { SelfServeComponent, SelfServeComponentState } from "./SelfServeComponent"; +import { NumberUiType, SelfServeDescriptor, SelfServeNotificationType, SmartUiInput } from "./SelfServeTypes"; describe("SelfServeComponent", () => { - const defaultValues = new Map([ - ["throughput", "450"], - ["analyticalStore", "false"], - ["database", "db2"], + const defaultValues = new Map([ + ["throughput", { value: 450 }], + ["analyticalStore", { value: false }], + ["database", { value: "db2" }], ]); - const initializeMock = jest.fn(async () => defaultValues); - const onSubmitMock = jest.fn(async () => { - return; + const updatedValues = new Map([ + ["throughput", { value: 460 }], + ["analyticalStore", { value: true }], + ["database", { value: "db2" }], + ]); + + const initializeMock = jest.fn(async () => new Map(defaultValues)); + const onSaveMock = jest.fn(async () => { + return { message: "submitted successfully", type: SelfServeNotificationType.info }; + }); + const onRefreshMock = jest.fn(async () => { + return { isUpdateInProgress: false, notificationMessage: "refresh performed successfully" }; + }); + const onRefreshIsUpdatingMock = jest.fn(async () => { + return { isUpdateInProgress: true, notificationMessage: "refresh performed successfully" }; }); const exampleData: SelfServeDescriptor = { initialize: initializeMock, - onSubmit: onSubmitMock, - inputNames: ["throughput", "containerId", "analyticalStore", "database"], + onSave: onSaveMock, + onRefresh: onRefreshMock, + inputNames: ["throughput", "analyticalStore", "database"], root: { id: "root", info: { @@ -38,7 +51,7 @@ describe("SelfServeComponent", () => { max: 500, step: 10, defaultValue: 400, - uiType: UiType.Spinner, + uiType: NumberUiType.Spinner, }, }, { @@ -78,27 +91,109 @@ describe("SelfServeComponent", () => { }, }; - const verifyDefaultsSet = (currentValues: Map): void => { - for (const key of currentValues.keys()) { - if (defaultValues.has(key)) { - expect(defaultValues.get(key)).toEqual(currentValues.get(key)); - } + const isEqual = (source: Map, target: Map): void => { + expect(target.size).toEqual(source.size); + for (const key of source.keys()) { + expect(target.get(key)).toEqual(source.get(key)); } }; - it("should render", async () => { + it("should render and honor save, discard, refresh actions", async () => { const wrapper = shallow(); await new Promise((resolve) => setTimeout(resolve, 0)); expect(wrapper).toMatchSnapshot(); - // initialize() should be called and defaults should be set when component is mounted - expect(initializeMock).toHaveBeenCalled(); - const state = wrapper.state() as SelfServeComponentState; - verifyDefaultsSet(state.currentValues); + // initialize() and onRefresh() should be called and defaults should be set when component is mounted + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(onRefreshMock).toHaveBeenCalledTimes(1); + let state = wrapper.state() as SelfServeComponentState; + isEqual(state.currentValues, defaultValues); - // onSubmit() must be called when submit button is clicked - const submitButton = wrapper.find("#submitButton"); - submitButton.simulate("click"); - expect(onSubmitMock).toHaveBeenCalled(); + // when currentValues and baselineValues differ, save and discard should not be disabled + wrapper.setState({ currentValues: updatedValues }); + wrapper.update(); + state = wrapper.state() as SelfServeComponentState; + isEqual(state.currentValues, updatedValues); + const selfServeComponent = wrapper.instance() as SelfServeComponent; + expect(selfServeComponent.isSaveButtonDisabled()).toBeFalsy(); + expect(selfServeComponent.isDiscardButtonDisabled()).toBeFalsy(); + + // when errors exist, save is disabled but discard is enabled + wrapper.setState({ hasErrors: true }); + wrapper.update(); + state = wrapper.state() as SelfServeComponentState; + expect(selfServeComponent.isSaveButtonDisabled()).toBeTruthy(); + expect(selfServeComponent.isDiscardButtonDisabled()).toBeFalsy(); + + // discard resets currentValues to baselineValues + selfServeComponent.discard(); + state = wrapper.state() as SelfServeComponentState; + isEqual(state.currentValues, defaultValues); + isEqual(state.currentValues, state.baselineValues); + + // resetBaselineValues sets baselineValues to currentValues + wrapper.setState({ baselineValues: updatedValues }); + wrapper.update(); + state = wrapper.state() as SelfServeComponentState; + isEqual(state.baselineValues, updatedValues); + selfServeComponent.resetBaselineValues(); + state = wrapper.state() as SelfServeComponentState; + isEqual(state.baselineValues, defaultValues); + isEqual(state.currentValues, state.baselineValues); + + // clicking refresh calls onRefresh. If component is not updating, it calls initialize() as well + selfServeComponent.onRefreshClicked(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(onRefreshMock).toHaveBeenCalledTimes(2); + expect(initializeMock).toHaveBeenCalledTimes(2); + + selfServeComponent.onSaveButtonClick(); + expect(onSaveMock).toHaveBeenCalledTimes(1); + }); + + it("getResolvedValue", async () => { + const wrapper = shallow(); + await new Promise((resolve) => setTimeout(resolve, 0)); + const selfServeComponent = wrapper.instance() as SelfServeComponent; + + const numberResult = 1; + const numberPromise = async (): Promise => { + return numberResult; + }; + expect(await selfServeComponent.getResolvedValue(numberResult)).toEqual(numberResult); + expect(await selfServeComponent.getResolvedValue(numberPromise)).toEqual(numberResult); + + const stringResult = "result"; + const stringPromise = async (): Promise => { + return stringResult; + }; + expect(await selfServeComponent.getResolvedValue(stringResult)).toEqual(stringResult); + expect(await selfServeComponent.getResolvedValue(stringPromise)).toEqual(stringResult); + }); + + it("message bar and spinner snapshots", async () => { + const newDescriptor = { ...exampleData, onRefresh: onRefreshIsUpdatingMock }; + let wrapper = shallow(); + await new Promise((resolve) => setTimeout(resolve, 0)); + let selfServeComponent = wrapper.instance() as SelfServeComponent; + selfServeComponent.onSaveButtonClick(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(wrapper).toMatchSnapshot(); + + newDescriptor.onRefresh = onRefreshMock; + wrapper = shallow(); + await new Promise((resolve) => setTimeout(resolve, 0)); + selfServeComponent = wrapper.instance() as SelfServeComponent; + selfServeComponent.onSaveButtonClick(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(wrapper).toMatchSnapshot(); + + wrapper.setState({ isInitializing: true }); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); + + wrapper.setState({ compileErrorMessage: "sample error message" }); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); }); }); diff --git a/src/SelfServe/SelfServeComponent.tsx b/src/SelfServe/SelfServeComponent.tsx index 5f4b48728..d4189d144 100644 --- a/src/SelfServe/SelfServeComponent.tsx +++ b/src/SelfServe/SelfServeComponent.tsx @@ -1,62 +1,31 @@ import React from "react"; -import { IStackTokens, PrimaryButton, Spinner, SpinnerSize, Stack } from "office-ui-fabric-react"; import { - ChoiceItem, + CommandBar, + ICommandBarItemProps, + IStackTokens, + MessageBar, + MessageBarType, + Spinner, + SpinnerSize, + Stack, +} from "office-ui-fabric-react"; +import { + AnyDisplay, + Node, InputType, - InputTypeValue, - SmartUiComponent, - UiType, - SmartUiDescriptor, - Info, -} from "../Explorer/Controls/SmartUi/SmartUiComponent"; - -export interface BaseInput { - label: (() => Promise) | string; - dataFieldName: string; - type: InputTypeValue; - onChange?: (currentState: Map, newValue: InputType) => Map; - placeholder?: (() => Promise) | string; - errorMessage?: string; -} - -export interface NumberInput extends BaseInput { - min: (() => Promise) | number; - max: (() => Promise) | number; - step: (() => Promise) | number; - defaultValue?: number; - uiType: UiType; -} - -export interface BooleanInput extends BaseInput { - trueLabel: (() => Promise) | string; - falseLabel: (() => Promise) | string; - defaultValue?: boolean; -} - -export interface StringInput extends BaseInput { - defaultValue?: string; -} - -export interface ChoiceInput extends BaseInput { - choices: (() => Promise) | ChoiceItem[]; - defaultKey?: string; -} - -export interface Node { - id: string; - info?: (() => Promise) | Info; - input?: AnyInput; - children?: Node[]; -} - -export interface SelfServeDescriptor { - root: Node; - initialize?: () => Promise>; - onSubmit?: (currentValues: Map) => Promise; - inputNames?: string[]; -} - -export type AnyInput = NumberInput | BooleanInput | StringInput | ChoiceInput; + RefreshResult, + SelfServeDescriptor, + SelfServeNotification, + SmartUiInput, + DescriptionDisplay, + StringInput, + NumberInput, + BooleanInput, + ChoiceInput, + SelfServeNotificationType, +} from "./SelfServeTypes"; +import { SmartUiComponent, SmartUiDescriptor } from "../Explorer/Controls/SmartUi/SmartUiComponent"; +import { getMessageBarType } from "./SelfServeUtils"; export interface SelfServeComponentProps { descriptor: SelfServeDescriptor; @@ -64,13 +33,18 @@ export interface SelfServeComponentProps { export interface SelfServeComponentState { root: SelfServeDescriptor; - currentValues: Map; - baselineValues: Map; - isRefreshing: boolean; + currentValues: Map; + baselineValues: Map; + isInitializing: boolean; + hasErrors: boolean; + compileErrorMessage: string; + notification: SelfServeNotification; + refreshResult: RefreshResult; } export class SelfServeComponent extends React.Component { componentDidMount(): void { + this.performRefresh(); this.initializeSmartUiComponent(); } @@ -80,62 +54,108 @@ export class SelfServeComponent extends React.Component { + this.setState({ hasErrors }); + }; + private initializeSmartUiComponent = async (): Promise => { - this.setState({ isRefreshing: true }); - await this.initializeSmartUiNode(this.props.descriptor.root); + this.setState({ isInitializing: true }); await this.setDefaults(); - this.setState({ isRefreshing: false }); + const { currentValues, baselineValues } = this.state; + await this.initializeSmartUiNode(this.props.descriptor.root, currentValues, baselineValues); + this.setState({ isInitializing: false, currentValues, baselineValues }); }; private setDefaults = async (): Promise => { - this.setState({ isRefreshing: true }); let { currentValues, baselineValues } = this.state; const initialValues = await this.props.descriptor.initialize(); - for (const key of initialValues.keys()) { - if (this.props.descriptor.inputNames.indexOf(key) === -1) { - this.setState({ isRefreshing: false }); - throw new Error(`${key} is not an input property of this class.`); + this.props.descriptor.inputNames.map((inputName) => { + let initialValue = initialValues.get(inputName); + if (!initialValue) { + initialValue = { value: undefined, hidden: false }; + } + currentValues = currentValues.set(inputName, initialValue); + baselineValues = baselineValues.set(inputName, initialValue); + initialValues.delete(inputName); + }); + + if (initialValues.size > 0) { + const keys = []; + for (const key of initialValues.keys()) { + keys.push(key); } - currentValues = currentValues.set(key, initialValues.get(key)); - baselineValues = baselineValues.set(key, initialValues.get(key)); + this.setState({ + compileErrorMessage: `The following fields have default values set but are not input properties of this class: ${keys.join( + ", " + )}`, + }); } - this.setState({ currentValues, baselineValues, isRefreshing: false }); + this.setState({ currentValues, baselineValues }); + }; + + public resetBaselineValues = (): void => { + const currentValues = this.state.currentValues; + let baselineValues = this.state.baselineValues; + for (const key of currentValues.keys()) { + const currentValue = currentValues.get(key); + baselineValues = baselineValues.set(key, { ...currentValue }); + } + this.setState({ baselineValues }); }; public discard = (): void => { let { currentValues } = this.state; const { baselineValues } = this.state; - for (const key of baselineValues.keys()) { - currentValues = currentValues.set(key, baselineValues.get(key)); + for (const key of currentValues.keys()) { + const baselineValue = baselineValues.get(key); + currentValues = currentValues.set(key, { ...baselineValue }); } this.setState({ currentValues }); }; - private initializeSmartUiNode = async (currentNode: Node): Promise => { + private initializeSmartUiNode = async ( + currentNode: Node, + currentValues: Map, + baselineValues: Map + ): Promise => { currentNode.info = await this.getResolvedValue(currentNode.info); if (currentNode.input) { - currentNode.input = await this.getResolvedInput(currentNode.input); + currentNode.input = await this.getResolvedInput(currentNode.input, currentValues, baselineValues); } - const promises = currentNode.children?.map(async (child: Node) => await this.initializeSmartUiNode(child)); + const promises = currentNode.children?.map( + async (child: Node) => await this.initializeSmartUiNode(child, currentValues, baselineValues) + ); if (promises) { await Promise.all(promises); } }; - private getResolvedInput = async (input: AnyInput): Promise => { + private getResolvedInput = async ( + input: AnyDisplay, + currentValues: Map, + baselineValues: Map + ): Promise => { input.label = await this.getResolvedValue(input.label); input.placeholder = await this.getResolvedValue(input.placeholder); switch (input.type) { case "string": { + if ("description" in input) { + const descriptionDisplay = input as DescriptionDisplay; + descriptionDisplay.description = await this.getResolvedValue(descriptionDisplay.description); + } return input as StringInput; } case "number": { @@ -143,6 +163,16 @@ export class SelfServeComponent extends React.Component { + private onInputChange = (input: AnyDisplay, 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); + const currentInputValue = currentValues.get(dataFieldName); + currentValues.set(dataFieldName, { value: newValue, hidden: currentInputValue?.hidden }); this.setState({ currentValues }); } }; - public render(): JSX.Element { - const containerStackTokens: IStackTokens = { childrenGap: 20 }; - return !this.state.isRefreshing ? ( -
- - + public onSaveButtonClick = (): void => { + const onSavePromise = this.props.descriptor.onSave(this.state.currentValues); + onSavePromise.catch((error) => { + this.setState({ + notification: { + message: `Error: ${error.message}`, + type: SelfServeNotificationType.error, + }, + }); + }); + onSavePromise.then((notification: SelfServeNotification) => { + this.setState({ + notification: { + message: notification.message, + type: notification.type, + }, + }); + this.resetBaselineValues(); + this.onRefreshClicked(); + }); + }; - - { - await this.props.descriptor.onSubmit(this.state.currentValues); - this.setDefaults(); - }} + public isDiscardButtonDisabled = (): boolean => { + for (const key of this.state.currentValues.keys()) { + const currentValue = JSON.stringify(this.state.currentValues.get(key)); + const baselineValue = JSON.stringify(this.state.baselineValues.get(key)); + + if (currentValue !== baselineValue) { + return false; + } + } + return true; + }; + + public isSaveButtonDisabled = (): boolean => { + if (this.state.hasErrors) { + return true; + } + for (const key of this.state.currentValues.keys()) { + const currentValue = JSON.stringify(this.state.currentValues.get(key)); + const baselineValue = JSON.stringify(this.state.baselineValues.get(key)); + + if (currentValue !== baselineValue) { + return false; + } + } + return true; + }; + + private performRefresh = async (): Promise => { + const refreshResult = await this.props.descriptor.onRefresh(); + this.setState({ refreshResult: { ...refreshResult } }); + return refreshResult; + }; + + public onRefreshClicked = async (): Promise => { + this.setState({ isInitializing: true }); + const refreshResult = await this.performRefresh(); + if (!refreshResult.isUpdateInProgress) { + this.initializeSmartUiComponent(); + } + this.setState({ isInitializing: false }); + }; + + private getCommandBarItems = (): ICommandBarItemProps[] => { + return [ + { + key: "save", + text: "Save", + iconProps: { iconName: "Save" }, + split: true, + disabled: this.isSaveButtonDisabled(), + onClick: this.onSaveButtonClick, + }, + { + key: "discard", + text: "Discard", + iconProps: { iconName: "Undo" }, + split: true, + disabled: this.isDiscardButtonDisabled(), + onClick: () => { + this.discard(); + }, + }, + { + key: "refresh", + text: "Refresh", + disabled: this.state.isInitializing, + iconProps: { iconName: "Refresh" }, + split: true, + onClick: () => { + this.onRefreshClicked(); + }, + }, + ]; + }; + + public render(): JSX.Element { + const containerStackTokens: IStackTokens = { childrenGap: 5 }; + if (this.state.compileErrorMessage) { + return {this.state.compileErrorMessage}; + } + return ( +
+ + + {this.state.isInitializing ? ( + - this.discard()} - /> - + ) : ( + <> + {this.state.refreshResult?.isUpdateInProgress && ( + + {this.state.refreshResult.notificationMessage} + + )} + {this.state.notification && ( + this.setState({ notification: undefined })} + > + {this.state.notification.message} + + )} + + + )}
- ) : ( - ); } } diff --git a/src/SelfServe/SelfServeComponentAdapter.tsx b/src/SelfServe/SelfServeComponentAdapter.tsx index 44d4c5fa7..967eea72b 100644 --- a/src/SelfServe/SelfServeComponentAdapter.tsx +++ b/src/SelfServe/SelfServeComponentAdapter.tsx @@ -7,7 +7,8 @@ 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 { SelfServeComponent } from "./SelfServeComponent"; +import { SelfServeDescriptor } from "./SelfServeTypes"; import { SelfServeType } from "./SelfServeUtils"; export class SelfServeComponentAdapter implements ReactAdapter { @@ -28,6 +29,10 @@ export class SelfServeComponentAdapter implements ReactAdapter { const SelfServeExample = await import(/* webpackChunkName: "SelfServeExample" */ "./Example/SelfServeExample"); return new SelfServeExample.default().toSelfServeDescriptor(); } + case SelfServeType.sqlx: { + const SqlX = await import(/* webpackChunkName: "SqlX" */ "./SqlX/SqlX"); + return new SqlX.default().toSelfServeDescriptor(); + } default: return undefined; } diff --git a/src/SelfServe/SelfServeTypes.ts b/src/SelfServe/SelfServeTypes.ts new file mode 100644 index 000000000..93314c867 --- /dev/null +++ b/src/SelfServe/SelfServeTypes.ts @@ -0,0 +1,130 @@ +interface BaseInput { + dataFieldName: string; + errorMessage?: string; + type: InputTypeValue; + label?: (() => Promise) | string; + onChange?: (currentState: Map, newValue: InputType) => Map; + placeholder?: (() => Promise) | string; +} + +export interface NumberInput extends BaseInput { + min: (() => Promise) | number; + max: (() => Promise) | number; + step: (() => Promise) | number; + defaultValue?: number; + uiType: NumberUiType; +} + +export interface BooleanInput extends BaseInput { + trueLabel: (() => Promise) | string; + falseLabel: (() => Promise) | string; + defaultValue?: boolean; +} + +export interface StringInput extends BaseInput { + defaultValue?: string; +} + +export interface ChoiceInput extends BaseInput { + choices: (() => Promise) | ChoiceItem[]; + defaultKey?: string; +} + +export interface DescriptionDisplay extends BaseInput { + description: (() => Promise) | Description; +} + +export interface Node { + id: string; + info?: (() => Promise) | Info; + input?: AnyDisplay; + children?: Node[]; +} + +export interface SelfServeDescriptor { + root: Node; + initialize?: () => Promise>; + onSave?: (currentValues: Map) => Promise; + inputNames?: string[]; + onRefresh?: () => Promise; +} + +export type AnyDisplay = NumberInput | BooleanInput | StringInput | ChoiceInput | DescriptionDisplay; + +export abstract class SelfServeBaseClass { + public abstract initialize: () => Promise>; + public abstract onSave: (currentValues: Map) => Promise; + public abstract onRefresh: () => Promise; + + public toSelfServeDescriptor(): SelfServeDescriptor { + const className = this.constructor.name; + const selfServeDescriptor = Reflect.getMetadata(className, this) as SelfServeDescriptor; + + if (!this.initialize) { + throw new Error(`initialize() was not declared for the class '${className}'`); + } + if (!this.onSave) { + throw new Error(`onSave() was not declared for the class '${className}'`); + } + if (!this.onRefresh) { + throw new Error(`onRefresh() was not declared for the class '${className}'`); + } + if (!selfServeDescriptor?.root) { + throw new Error(`@SmartUi decorator was not declared for the class '${className}'`); + } + + selfServeDescriptor.initialize = this.initialize; + selfServeDescriptor.onSave = this.onSave; + selfServeDescriptor.onRefresh = this.onRefresh; + return selfServeDescriptor; + } +} + +export type InputTypeValue = "number" | "string" | "boolean" | "object"; + +export enum NumberUiType { + Spinner = "Spinner", + Slider = "Slider", +} + +export type ChoiceItem = { label: string; key: string }; + +export type InputType = number | string | boolean | ChoiceItem; + +export interface Info { + message: string; + link?: { + href: string; + text: string; + }; +} + +export interface Description { + text: string; + link?: { + href: string; + text: string; + }; +} + +export interface SmartUiInput { + value: InputType; + hidden?: boolean; + disabled?: boolean; +} + +export enum SelfServeNotificationType { + info = "info", + warning = "warning", + error = "error", +} + +export interface SelfServeNotification { + message: string; + type: SelfServeNotificationType; +} + +export interface RefreshResult { + isUpdateInProgress: boolean; + notificationMessage: string; +} diff --git a/src/SelfServe/SelfServeUtils.test.tsx b/src/SelfServe/SelfServeUtils.test.tsx index 993203480..531eaa26a 100644 --- a/src/SelfServe/SelfServeUtils.test.tsx +++ b/src/SelfServe/SelfServeUtils.test.tsx @@ -1,40 +1,39 @@ -import { - CommonInputTypes, - mapToSmartUiDescriptor, - SelfServeBaseClass, - updateContextWithDecorator, -} from "./SelfServeUtils"; -import { InputType, UiType } from "./../Explorer/Controls/SmartUi/SmartUiComponent"; +import { NumberUiType, RefreshResult, SelfServeBaseClass, SelfServeNotification, SmartUiInput } from "./SelfServeTypes"; +import { DecoratorProperties, mapToSmartUiDescriptor, updateContextWithDecorator } from "./SelfServeUtils"; describe("SelfServeUtils", () => { it("initialize should be declared for self serve classes", () => { class Test extends SelfServeBaseClass { - public onSubmit = async (): Promise => { - return; - }; - public initialize: () => Promise>; + public initialize: () => Promise>; + public onSave: (currentValues: Map) => Promise; + public onRefresh: () => Promise; } expect(() => new Test().toSelfServeDescriptor()).toThrow("initialize() was not declared for the class 'Test'"); }); - it("onSubmit should be declared for self serve classes", () => { + it("onSave should be declared for self serve classes", () => { class Test extends SelfServeBaseClass { - public onSubmit: () => Promise; - public initialize = async (): Promise> => { - return undefined; - }; + public initialize = jest.fn(); + public onSave: () => Promise; + public onRefresh: () => Promise; } - expect(() => new Test().toSelfServeDescriptor()).toThrow("onSubmit() was not declared for the class 'Test'"); + expect(() => new Test().toSelfServeDescriptor()).toThrow("onSave() was not declared for the class 'Test'"); + }); + + it("onRefresh should be declared for self serve classes", () => { + class Test extends SelfServeBaseClass { + public initialize = jest.fn(); + public onSave = jest.fn(); + public onRefresh: () => Promise; + } + expect(() => new Test().toSelfServeDescriptor()).toThrow("onRefresh() 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; - }; + public initialize = jest.fn(); + public onSave = jest.fn(); + public onRefresh = jest.fn(); } expect(() => new Test().toSelfServeDescriptor()).toThrow( "@SmartUi decorator was not declared for the class 'Test'" @@ -42,7 +41,7 @@ describe("SelfServeUtils", () => { }); it("updateContextWithDecorator", () => { - const context = new Map(); + const context = new Map(); updateContextWithDecorator(context, "dbThroughput", "testClass", "max", 1); updateContextWithDecorator(context, "dbThroughput", "testClass", "min", 2); updateContextWithDecorator(context, "collThroughput", "testClass", "max", 5); @@ -52,7 +51,7 @@ describe("SelfServeUtils", () => { }); it("mapToSmartUiDescriptor", () => { - const context: Map = new Map([ + const context: Map = new Map([ [ "dbThroughput", { @@ -63,7 +62,7 @@ describe("SelfServeUtils", () => { min: 1, max: 5, step: 1, - uiType: UiType.Slider, + uiType: NumberUiType.Slider, }, ], [ @@ -76,7 +75,7 @@ describe("SelfServeUtils", () => { min: 1, max: 5, step: 1, - uiType: UiType.Spinner, + uiType: NumberUiType.Spinner, }, ], [ @@ -89,7 +88,7 @@ describe("SelfServeUtils", () => { min: 1, max: 5, step: 1, - uiType: UiType.Spinner, + uiType: NumberUiType.Spinner, errorMessage: "label, truelabel and falselabel are required for boolean input", }, ], diff --git a/src/SelfServe/SelfServeUtils.tsx b/src/SelfServe/SelfServeUtils.tsx index 722a6e2c9..5c30e0b6b 100644 --- a/src/SelfServe/SelfServeUtils.tsx +++ b/src/SelfServe/SelfServeUtils.tsx @@ -1,14 +1,22 @@ +import { MessageBarType } from "office-ui-fabric-react"; import "reflect-metadata"; -import { ChoiceItem, Info, InputTypeValue, InputType } from "../Explorer/Controls/SmartUi/SmartUiComponent"; import { + Node, + AnyDisplay, BooleanInput, ChoiceInput, - SelfServeDescriptor, + ChoiceItem, + Description, + DescriptionDisplay, + Info, + InputType, + InputTypeValue, NumberInput, + SelfServeDescriptor, + SmartUiInput, StringInput, - Node, - AnyInput, -} from "./SelfServeComponent"; + SelfServeNotificationType, +} from "./SelfServeTypes"; export enum SelfServeType { // No self serve type passed, launch explorer @@ -17,33 +25,10 @@ export enum SelfServeType { invalid = "invalid", // Add your self serve types here example = "example", + sqlx = "sqlx", } -export abstract class SelfServeBaseClass { - public abstract onSubmit: (currentValues: Map) => Promise; - public abstract initialize: () => Promise>; - - public toSelfServeDescriptor(): SelfServeDescriptor { - const className = this.constructor.name; - const smartUiDescriptor = Reflect.getMetadata(className, this) as SelfServeDescriptor; - - if (!this.initialize) { - throw new Error(`initialize() was not declared for the class '${className}'`); - } - if (!this.onSubmit) { - throw new Error(`onSubmit() was not declared for the class '${className}'`); - } - if (!smartUiDescriptor?.root) { - throw new Error(`@SmartUi decorator was not declared for the class '${className}'`); - } - - smartUiDescriptor.initialize = this.initialize; - smartUiDescriptor.onSubmit = this.onSubmit; - return smartUiDescriptor; - } -} - -export interface CommonInputTypes { +export interface DecoratorProperties { id: string; info?: (() => Promise) | Info; type?: InputTypeValue; @@ -58,41 +43,43 @@ export interface CommonInputTypes { choices?: (() => Promise) | ChoiceItem[]; uiType?: string; errorMessage?: string; - onChange?: (currentState: Map, newValue: InputType) => Map; - onSubmit?: (currentValues: Map) => Promise; - initialize?: () => Promise>; + description?: (() => Promise) | Description; + onChange?: (currentState: Map, newValue: InputType) => Map; + onSave?: (currentValues: Map) => Promise; + initialize?: () => Promise>; } -const setValue = ( +const setValue = ( name: T, value: K, - fieldObject: CommonInputTypes + fieldObject: DecoratorProperties ): void => { fieldObject[name] = value; }; -const getValue = (name: T, fieldObject: CommonInputTypes): unknown => { +const getValue = (name: T, fieldObject: DecoratorProperties): unknown => { return fieldObject[name]; }; -export const addPropertyToMap = ( +export const addPropertyToMap = ( target: unknown, propertyName: string, className: string, - descriptorName: keyof CommonInputTypes, + descriptorName: keyof DecoratorProperties, descriptorValue: K ): void => { const context = - (Reflect.getMetadata(className, target) as Map) ?? new Map(); + (Reflect.getMetadata(className, target) as Map) ?? + new Map(); updateContextWithDecorator(context, propertyName, className, descriptorName, descriptorValue); Reflect.defineMetadata(className, context, target); }; -export const updateContextWithDecorator = ( - context: Map, +export const updateContextWithDecorator = ( + context: Map, propertyName: string, className: string, - descriptorName: keyof CommonInputTypes, + descriptorName: keyof DecoratorProperties, descriptorValue: K ): void => { if (!(context instanceof Map)) { @@ -112,12 +99,12 @@ export const updateContextWithDecorator = { - const context = Reflect.getMetadata(className, target) as Map; + const context = Reflect.getMetadata(className, target) as Map; const smartUiDescriptor = mapToSmartUiDescriptor(context); Reflect.defineMetadata(className, smartUiDescriptor, target); }; -export const mapToSmartUiDescriptor = (context: Map): SelfServeDescriptor => { +export const mapToSmartUiDescriptor = (context: Map): SelfServeDescriptor => { const root = context.get("root"); context.delete("root"); const inputNames: string[] = []; @@ -140,7 +127,7 @@ export const mapToSmartUiDescriptor = (context: Map): }; const addToDescriptor = ( - context: Map, + context: Map, root: Node, key: string, inputNames: string[] @@ -157,7 +144,7 @@ const addToDescriptor = ( root.children.push(element); }; -const getInput = (value: CommonInputTypes): AnyInput => { +const getInput = (value: DecoratorProperties): AnyDisplay => { switch (value.type) { case "number": if (!value.label || !value.step || !value.uiType || !value.min || !value.max) { @@ -165,6 +152,9 @@ const getInput = (value: CommonInputTypes): AnyInput => { } return value as NumberInput; case "string": + if (value.description) { + return value as DescriptionDisplay; + } if (!value.label) { value.errorMessage = `label is required for string input '${value.id}'.`; } @@ -181,3 +171,14 @@ const getInput = (value: CommonInputTypes): AnyInput => { return value as ChoiceInput; } }; + +export const getMessageBarType = (type: SelfServeNotificationType): MessageBarType => { + switch (type) { + case SelfServeNotificationType.info: + return MessageBarType.info; + case SelfServeNotificationType.warning: + return MessageBarType.warning; + case SelfServeNotificationType.error: + return MessageBarType.error; + } +}; diff --git a/src/SelfServe/SqlX/SqlX.rp.ts b/src/SelfServe/SqlX/SqlX.rp.ts new file mode 100644 index 000000000..bf564a7b4 --- /dev/null +++ b/src/SelfServe/SqlX/SqlX.rp.ts @@ -0,0 +1,33 @@ +import { RefreshResult } from "../SelfServeTypes"; + +export interface DedicatedGatewayResponse { + sku: string; + instances: number; +} + +export const getRegionSpecificMinInstances = async (): Promise => { + // TODO: write RP call to get min number of instances needed for this region + throw new Error("getRegionSpecificMinInstances not implemented"); +}; + +export const getRegionSpecificMaxInstances = async (): Promise => { + // TODO: write RP call to get max number of instances needed for this region + throw new Error("getRegionSpecificMaxInstances not implemented"); +}; + +export const updateDedicatedGatewayProvisioning = async (sku: string, instances: number): Promise => { + // TODO: write RP call to update dedicated gateway provisioning + throw new Error( + `updateDedicatedGatewayProvisioning not implemented. Parameters- sku: ${sku}, instances:${instances}` + ); +}; + +export const initializeDedicatedGatewayProvisioning = async (): Promise => { + // TODO: write RP call to initialize UI for dedicated gateway provisioning + throw new Error("initializeDedicatedGatewayProvisioning not implemented"); +}; + +export const refreshDedicatedGatewayProvisioning = async (): Promise => { + // TODO: write RP call to check if dedicated gateway update has gone through + throw new Error("refreshDedicatedGatewayProvisioning not implemented"); +}; diff --git a/src/SelfServe/SqlX/SqlX.tsx b/src/SelfServe/SqlX/SqlX.tsx new file mode 100644 index 000000000..add077a6f --- /dev/null +++ b/src/SelfServe/SqlX/SqlX.tsx @@ -0,0 +1,97 @@ +import { IsDisplayable, OnChange, Values } from "../Decorators"; +import { + ChoiceItem, + InputType, + NumberUiType, + RefreshResult, + SelfServeBaseClass, + SelfServeNotification, + SmartUiInput, +} from "../SelfServeTypes"; +import { refreshDedicatedGatewayProvisioning } from "./SqlX.rp"; + +const onEnableDedicatedGatewayChange = ( + currentState: Map, + newValue: InputType +): Map => { + const sku = currentState.get("sku"); + const instances = currentState.get("instances"); + const isSkuHidden = newValue === undefined || !(newValue as boolean); + currentState.set("enableDedicatedGateway", { value: newValue }); + currentState.set("sku", { value: sku.value, hidden: isSkuHidden }); + currentState.set("instances", { value: instances.value, hidden: isSkuHidden }); + return currentState; +}; + +const getSkus = async (): Promise => { + // TODO: get SKUs from getRegionSpecificSkus() RP call and return array of {label:..., key:...}. + throw new Error("getSkus not implemented."); +}; + +const getInstancesMin = async (): Promise => { + // TODO: get SKUs from getRegionSpecificSkus() RP call and return array of {label:..., key:...}. + throw new Error("getInstancesMin not implemented."); +}; + +const getInstancesMax = async (): Promise => { + // TODO: get SKUs from getRegionSpecificSkus() RP call and return array of {label:..., key:...}. + throw new Error("getInstancesMax not implemented."); +}; + +const validate = (currentValues: Map): void => { + // TODO: add cusom validation logic to be called before Saving the data. + throw new Error(`validate not implemented. No. of properties to validate: ${currentValues.size}`); +}; + +@IsDisplayable() +export default class SqlX extends SelfServeBaseClass { + public onRefresh = async (): Promise => { + return refreshDedicatedGatewayProvisioning(); + }; + + public onSave = async (currentValues: Map): Promise => { + validate(currentValues); + // TODO: add pre processing logic before calling the updateDedicatedGatewayProvisioning() RP call. + throw new Error(`onSave not implemented. No. of properties to save: ${currentValues.size}`); + }; + + public initialize = async (): Promise> => { + // TODO: get initialization data from initializeDedicatedGatewayProvisioning() RP call. + throw new Error("onSave not implemented"); + }; + + @Values({ + description: { + text: "Provisioning dedicated gateways for SqlX accounts.", + link: { + href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction", + text: "Learn more about dedicated gateway.", + }, + }, + }) + description: string; + + @OnChange(onEnableDedicatedGatewayChange) + @Values({ + label: "Dedicated Gateway", + trueLabel: "Enable", + falseLabel: "Disable", + }) + enableDedicatedGateway: boolean; + + @Values({ + label: "SKUs", + choices: getSkus, + placeholder: "Select SKUs", + }) + sku: ChoiceItem; + + @Values({ + label: "Number of instances", + min: getInstancesMin, + max: getInstancesMax, + step: 1, + uiType: NumberUiType.Spinner, + }) + instances: number; +} diff --git a/src/SelfServe/__snapshots__/SelfServeComponent.test.tsx.snap b/src/SelfServe/__snapshots__/SelfServeComponent.test.tsx.snap index ce478192b..b87f5be11 100644 --- a/src/SelfServe/__snapshots__/SelfServeComponent.test.tsx.snap +++ b/src/SelfServe/__snapshots__/SelfServeComponent.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`SelfServeComponent should render 1`] = ` +exports[`SelfServeComponent message bar and spinner snapshots 1`] = `
+ + + refresh performed successfully + + + submitted successfully + "450", - "analyticalStore" => "false", - "database" => "db2", + "throughput" => Object { + "value": 450, + }, + "analyticalStore" => Object { + "value": false, + }, + "database" => Object { + "value": "db2", + }, } } descriptor={ @@ -36,21 +109,95 @@ exports[`SelfServeComponent should render 1`] = ` "initialize": [MockFunction] { "calls": Array [ Array [], + Array [], + Array [], + Array [], + Array [], ], "results": Array [ Object { "type": "return", "value": Promise {}, }, + Object { + "type": "return", + "value": Promise {}, + }, + Object { + "type": "return", + "value": Promise {}, + }, + Object { + "type": "return", + "value": Promise {}, + }, + Object { + "type": "return", + "value": Promise {}, + }, ], }, "inputNames": Array [ "throughput", - "containerId", "analyticalStore", "database", ], - "onSubmit": [MockFunction], + "onRefresh": [MockFunction] { + "calls": Array [ + Array [], + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + Object { + "type": "return", + "value": Promise {}, + }, + ], + }, + "onSave": [MockFunction] { + "calls": Array [ + Array [ + Map { + "throughput" => Object { + "value": 450, + }, + "analyticalStore" => Object { + "value": false, + }, + "database" => Object { + "value": "db2", + }, + }, + ], + Array [ + Map { + "throughput" => Object { + "value": 450, + }, + "analyticalStore" => Object { + "value": false, + }, + "database" => Object { + "value": "db2", + }, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + Object { + "type": "return", + "value": Promise {}, + }, + ], + }, "root": Object { "children": Array [ Object { @@ -128,41 +275,612 @@ exports[`SelfServeComponent should render 1`] = ` }, } } + disabled={true} + onError={[Function]} + onInputChange={[Function]} + /> + +
+`; + +exports[`SelfServeComponent message bar and spinner snapshots 2`] = ` +
+ + + + submitted successfully + + Object { + "value": 450, + }, + "analyticalStore" => Object { + "value": false, + }, + "database" => Object { + "value": "db2", + }, + } + } + descriptor={ + Object { + "initialize": [MockFunction] { + "calls": Array [ + Array [], + Array [], + Array [], + Array [], + Array [], + Array [], + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + Object { + "type": "return", + "value": Promise {}, + }, + Object { + "type": "return", + "value": Promise {}, + }, + Object { + "type": "return", + "value": Promise {}, + }, + Object { + "type": "return", + "value": Promise {}, + }, + Object { + "type": "return", + "value": Promise {}, + }, + Object { + "type": "return", + "value": Promise {}, + }, + ], + }, + "inputNames": Array [ + "throughput", + "analyticalStore", + "database", + ], + "onRefresh": [MockFunction] { + "calls": Array [ + Array [], + Array [], + Array [], + Array [], + Array [], + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + Object { + "type": "return", + "value": Promise {}, + }, + Object { + "type": "return", + "value": Promise {}, + }, + Object { + "type": "return", + "value": Promise {}, + }, + Object { + "type": "return", + "value": Promise {}, + }, + Object { + "type": "return", + "value": Promise {}, + }, + ], + }, + "onSave": [MockFunction] { + "calls": Array [ + Array [ + Map { + "throughput" => Object { + "value": 450, + }, + "analyticalStore" => Object { + "value": false, + }, + "database" => Object { + "value": "db2", + }, + }, + ], + Array [ + Map { + "throughput" => Object { + "value": 450, + }, + "analyticalStore" => Object { + "value": false, + }, + "database" => Object { + "value": "db2", + }, + }, + ], + Array [ + Map { + "throughput" => Object { + "value": 450, + }, + "analyticalStore" => Object { + "value": false, + }, + "database" => Object { + "value": "db2", + }, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + Object { + "type": "return", + "value": Promise {}, + }, + Object { + "type": "return", + "value": Promise {}, + }, + ], + }, + "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", + }, + }, + } + } + disabled={false} + onError={[Function]} + onInputChange={[Function]} + /> + +
+`; + +exports[`SelfServeComponent message bar and spinner snapshots 3`] = ` +
+ + + + +
+`; + +exports[`SelfServeComponent message bar and spinner snapshots 4`] = ` + + sample error message + +`; + +exports[`SelfServeComponent should render and honor save, discard, refresh actions 1`] = ` +
+ + + Object { + "value": 450, + }, + "analyticalStore" => Object { + "value": false, + }, + "database" => Object { + "value": "db2", + }, + } + } + descriptor={ + Object { + "initialize": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + }, + "inputNames": Array [ + "throughput", + "analyticalStore", + "database", + ], + "onRefresh": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + }, + "onSave": [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", + }, + }, + } + } + disabled={false} + onError={[Function]} onInputChange={[Function]} /> - - - -
`; diff --git a/test/selfServe/selfServeExample.spec.ts b/test/selfServe/selfServeExample.spec.ts index a753d73b9..e50ce4bb3 100644 --- a/test/selfServe/selfServeExample.spec.ts +++ b/test/selfServe/selfServeExample.spec.ts @@ -12,10 +12,27 @@ describe("Self Serve", () => { frame = await getTestExplorerFrame( new Map([[TestExplorerParams.selfServeType, SelfServeType.example]]) ); - await frame.waitForSelector("#regions-dropown-input"); - await frame.waitForSelector("#enableLogging-radioSwitch-input"); - await frame.waitForSelector("#accountName-textBox-input"); + + // id of the display element is in the format {PROPERTY_NAME}-{DISPLAY_NAME}-{DISPLAY_TYPE} + await frame.waitForSelector("#description-text-display"); + + const regions = await frame.waitForSelector("#regions-dropdown-input"); + let disabledLoggingToggle = await frame.$$("#enableLogging-toggle-input[disabled]"); + expect(disabledLoggingToggle).toHaveLength(0); + await regions.click(); + const regionsDropdownElement1 = await frame.waitForSelector("#regions-dropdown-input-list0"); + await regionsDropdownElement1.click(); + disabledLoggingToggle = await frame.$$("#enableLogging-toggle-input[disabled]"); + expect(disabledLoggingToggle).toHaveLength(1); + + await frame.waitForSelector("#accountName-textField-input"); + + const enableDbLevelThroughput = await frame.waitForSelector("#enableDbLevelThroughput-toggle-input"); + const dbThroughput = await frame.$$("#dbThroughput-slider-input"); + expect(dbThroughput).toHaveLength(0); + await enableDbLevelThroughput.click(); await frame.waitForSelector("#dbThroughput-slider-input"); + await frame.waitForSelector("#collectionThroughput-spinner-input"); } catch (error) { // eslint-disable-next-line @typescript-eslint/no-explicit-any