diff --git a/src/Bindings/ReactBindingHandler.ts b/src/Bindings/ReactBindingHandler.ts index 128c16dd4..8ecb51eee 100644 --- a/src/Bindings/ReactBindingHandler.ts +++ b/src/Bindings/ReactBindingHandler.ts @@ -37,7 +37,7 @@ export class Registerer { // If any of the ko observable change inside parameters, trigger a new render. ko.computed(() => ko.toJSON(adapter.parameters)).subscribe(() => - ReactDOM.render(adapter.renderComponent(), element) + ReactDOM.render(adapter.renderComponent(), element) ); // Initial rendering at mount point diff --git a/src/Explorer/Controls/SmartUi/SmartUiComponent.test.tsx b/src/Explorer/Controls/SmartUi/SmartUiComponent.test.tsx index a6d66b631..155cf2dcc 100644 --- a/src/Explorer/Controls/SmartUi/SmartUiComponent.test.tsx +++ b/src/Explorer/Controls/SmartUi/SmartUiComponent.test.tsx @@ -1,25 +1,9 @@ import React from "react"; import { shallow } from "enzyme"; -import { SmartUiComponent, Descriptor, UiType } from "./SmartUiComponent"; +import { SmartUiComponent, SmartUiDescriptor, UiType } from "./SmartUiComponent"; describe("SmartUiComponent", () => { - let initializeCalled = false; - let fetchMaxCalled = false; - - const initializeMock = async () => { - initializeCalled = true; - return new Map(); - }; - const fetchMaxvalue = async () => { - fetchMaxCalled = true; - return 500; - }; - - const exampleData: Descriptor = { - onSubmit: async () => { - return; - }, - initialize: initializeMock, + const exampleData: SmartUiDescriptor = { root: { id: "root", info: { @@ -37,7 +21,7 @@ describe("SmartUiComponent", () => { dataFieldName: "throughput", type: "number", min: 400, - max: fetchMaxvalue, + max: 500, step: 10, defaultValue: 400, uiType: UiType.Spinner @@ -108,14 +92,10 @@ describe("SmartUiComponent", () => { }; it("should render", async () => { - const wrapper = shallow(); + const wrapper = shallow( + + ); await new Promise(resolve => setTimeout(resolve, 0)); expect(wrapper).toMatchSnapshot(); - expect(initializeCalled).toBeTruthy(); - expect(fetchMaxCalled).toBeTruthy(); - - wrapper.setState({ isRefreshing: true }); - wrapper.update(); - expect(wrapper).toMatchSnapshot(); }); }); diff --git a/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx b/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx index dd3143e1c..619c09bbc 100644 --- a/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx +++ b/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx @@ -7,7 +7,7 @@ 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, PrimaryButton, Spinner, SpinnerSize } from "office-ui-fabric-react"; +import { Link, MessageBar, MessageBarType } from "office-ui-fabric-react"; import * as InputUtils from "./InputUtils"; import "./SmartUiComponent.less"; @@ -26,51 +26,10 @@ export enum UiType { Slider = "Slider" } -type numberPromise = () => Promise; -type stringPromise = () => Promise; -type choiceItemPromise = () => Promise; -type infoPromise = () => Promise; - -/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ export type ChoiceItem = { label: string; key: string }; export type InputType = number | string | boolean | ChoiceItem; -export interface BaseInput { - label: (() => Promise) | string; - dataFieldName: string; - type: InputTypeValue; - onChange?: (currentState: Map, newValue: InputType) => Map; - placeholder?: (() => Promise) | string; - errorMessage?: string; -} - -/** - * For now, this only supports integers - */ -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 Info { message: string; link?: { @@ -79,33 +38,63 @@ export interface Info { }; } -export type AnyInput = NumberInput | BooleanInput | StringInput | ChoiceInput; +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?: (() => Promise) | Info; + info?: Info; input?: AnyInput; children?: Node[]; } -export interface Descriptor { +export interface SmartUiDescriptor { root: Node; - initialize?: () => Promise>; - onSubmit?: (currentValues: Map) => Promise; - inputNames?: string[]; } /************************** Component implementation starts here ************************************* */ export interface SmartUiComponentProps { - descriptor: Descriptor; + descriptor: SmartUiDescriptor; + currentValues: Map; + onInputChange: (input: AnyInput, newValue: InputType) => void; } interface SmartUiComponentState { - currentValues: Map; - baselineValues: Map; errors: Map; - isRefreshing: boolean; } export class SmartUiComponent extends React.Component { @@ -115,114 +104,13 @@ export class SmartUiComponent extends React.Component => { - this.setState({ isRefreshing: true }); - await this.initializeNode(this.props.descriptor.root); - await this.setDefaults(); - this.setState({ isRefreshing: false }); - }; - - private setDefaults = async (): Promise => { - this.setState({ isRefreshing: true }); - let { currentValues, baselineValues } = this.state; - - const initialValues = await this.props.descriptor.initialize(); - for (const key of initialValues.keys()) { - - if (this.props.descriptor.inputNames.indexOf(key) === -1) { - this.setState({ isRefreshing: false }); - throw new Error(`${key} is not an input property of this class.`); - } - - currentValues = currentValues.set(key, initialValues.get(key)); - baselineValues = baselineValues.set(key, initialValues.get(key)); - } - this.setState({ currentValues: currentValues, baselineValues: baselineValues, isRefreshing: false }); - }; - - private 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: currentValues }); - }; - - private initializeNode = async (currentNode: Node): Promise => { - if (currentNode.info && currentNode.info instanceof Function) { - currentNode.info = await (currentNode.info as infoPromise)(); - } - - if (currentNode.input) { - currentNode.input = await this.getModifiedInput(currentNode.input); - } - const promises = currentNode.children?.map(async (child: Node) => await this.initializeNode(child)); - if (promises) { - await Promise.all(promises); - } - }; - - private getModifiedInput = async (input: AnyInput): Promise => { - if (input.label instanceof Function) { - input.label = await (input.label as stringPromise)(); - } - - if (input.placeholder instanceof Function) { - input.placeholder = await (input.placeholder as stringPromise)(); - } - - switch (input.type) { - case "string": { - return input as StringInput; - } - case "number": { - const numberInput = input as NumberInput; - if (numberInput.min instanceof Function) { - numberInput.min = await (numberInput.min as numberPromise)(); - } - if (numberInput.max instanceof Function) { - numberInput.max = await (numberInput.max as numberPromise)(); - } - if (numberInput.step instanceof Function) { - numberInput.step = await (numberInput.step as numberPromise)(); - } - return numberInput; - } - case "boolean": { - const booleanInput = input as BooleanInput; - if (booleanInput.trueLabel instanceof Function) { - booleanInput.trueLabel = await (booleanInput.trueLabel as stringPromise)(); - } - if (booleanInput.falseLabel instanceof Function) { - booleanInput.falseLabel = await (booleanInput.falseLabel as stringPromise)(); - } - return booleanInput; - } - default: { - const enumInput = input as ChoiceInput; - if (enumInput.choices instanceof Function) { - enumInput.choices = await (enumInput.choices as choiceItemPromise)(); - } - return enumInput; - } - } - }; - private renderInfo(info: Info): JSX.Element { return ( @@ -236,42 +124,28 @@ export class SmartUiComponent extends React.Component { - 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 }); - } - }; - private renderTextInput(input: StringInput): JSX.Element { - const value = this.state.currentValues.get(input.dataFieldName) as string; + const value = this.props.currentValues.get(input.dataFieldName) as string; return (
-
- this.onInputChange(input, newValue)} - styles={{ - subComponentStyles: { - label: { - root: { - ...SmartUiComponent.labelStyle, - fontWeight: 600 - } + this.props.onInputChange(input, newValue)} + styles={{ + subComponentStyles: { + label: { + root: { + ...SmartUiComponent.labelStyle, + fontWeight: 600 } } - }} - /> -
+ } + }} + />
); } @@ -286,7 +160,7 @@ export class SmartUiComponent extends React.Component this.onValidate(input, newValue, props.min, props.max)} onIncrement={newValue => this.onIncrement(input, newValue, props.step, props.max)} @@ -354,18 +229,20 @@ export class SmartUiComponent extends React.Component this.onInputChange(input, newValue)} - styles={{ - titleLabel: { - ...SmartUiComponent.labelStyle, - fontWeight: 600 - }, - valueLabel: SmartUiComponent.labelStyle - }} - /> +
+ this.props.onInputChange(input, newValue)} + styles={{ + titleLabel: { + ...SmartUiComponent.labelStyle, + fontWeight: 600 + }, + valueLabel: SmartUiComponent.labelStyle + }} + /> +
); } else { return <>Unsupported number UI type {input.uiType}; @@ -373,9 +250,10 @@ export class SmartUiComponent extends React.Component +
{input.label} @@ -384,23 +262,17 @@ export class SmartUiComponent extends React.Component this.onInputChange(input, false) + onSelect: () => this.props.onInputChange(input, false) }, { - label: input.trueLabel as string, + label: input.trueLabel, key: "true", - onSelect: () => this.onInputChange(input, true) + 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} />
); @@ -408,17 +280,15 @@ export class SmartUiComponent extends React.Component this.onInputChange(input, item.key.toString())} - placeholder={placeholder as string} - options={(choices as ChoiceItem[]).map(c => ({ + id={`${input.dataFieldName}-dropown-input`} + label={label} + 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.label }))} @@ -471,28 +341,10 @@ export class SmartUiComponent extends React.Component - - {this.renderNode(this.props.descriptor.root)} - - { - await this.props.descriptor.onSubmit(this.state.currentValues); - this.setDefaults(); - }} - /> - this.discard()} /> - - -
- ) : ( - + 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 d9559ef84..b8efc28e3 100644 --- a/src/Explorer/Controls/SmartUi/__snapshots__/SmartUiComponent.test.tsx.snap +++ b/src/Explorer/Controls/SmartUi/__snapshots__/SmartUiComponent.test.tsx.snap @@ -1,108 +1,103 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`SmartUiComponent should render 1`] = ` -
- + + Start at $24/mo per database + + More Details + + + +
- - - Start at $24/mo per database - + + + + +
+
+ + +
- More Details - - - -
- - -
- -
-
-
-
-
- - - - -
-
- + + +
+
+ - - - Error: - label, truelabel and falselabel are required for boolean input 'throughput3' - - - -
-
- + + Error: + label, truelabel and falselabel are required for boolean input 'throughput3' + + + +
+
+ - -
-
- -
-
-
-
-
-
- - -
-
- - Analytical Store - -
- -
-
-
-
-
- - - +
+ +
+
+
+
+
+ + +
+
+ + Analytical Store + +
+ - - -
-
- + + +
+
- - - + > + + + + +
-
-`; - -exports[`SmartUiComponent should render 2`] = ` - +
`; diff --git a/src/Main.tsx b/src/Main.tsx index f5aabafcc..43f6f2aa7 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -78,8 +78,6 @@ import hdeConnectImage from "../images/HdeConnectCosmosDB.svg"; import refreshImg from "../images/refresh-cosmos.svg"; import arrowLeftImg from "../images/imgarrowlefticon.svg"; import { KOCommentEnd, KOCommentIfStart } from "./koComment"; -import { Spinner, SpinnerSize } from "office-ui-fabric-react"; -import { SelfServeType } from "./SelfServe/SelfServeUtils"; // TODO: Encapsulate and reuse all global variables as environment variables window.authType = AuthType.AAD; diff --git a/src/SelfServe/ClassDecorators.tsx b/src/SelfServe/ClassDecorators.tsx index 73e52fac0..aeb9ad8e2 100644 --- a/src/SelfServe/ClassDecorators.tsx +++ b/src/SelfServe/ClassDecorators.tsx @@ -2,15 +2,13 @@ import { Info } from "../Explorer/Controls/SmartUi/SmartUiComponent"; import { addPropertyToMap, buildSmartUiDescriptor } from "./SelfServeUtils"; export const IsDisplayable = (): ClassDecorator => { - //eslint-disable-next-line @typescript-eslint/ban-types - return (target: Function) => { + return target => { buildSmartUiDescriptor(target.name, target.prototype); }; }; export const ClassInfo = (info: (() => Promise) | Info): ClassDecorator => { - //eslint-disable-next-line @typescript-eslint/ban-types - return (target: Function) => { + return target => { addPropertyToMap(target.prototype, "root", target.name, "info", info); }; }; diff --git a/src/SelfServe/PropertyDecorators.tsx b/src/SelfServe/PropertyDecorators.tsx index a0213e2a2..9733fd360 100644 --- a/src/SelfServe/PropertyDecorators.tsx +++ b/src/SelfServe/PropertyDecorators.tsx @@ -34,15 +34,15 @@ export interface ChoiceInputOptions extends InputOptionsBase { type InputOptions = NumberInputOptions | StringInputOptions | BooleanInputOptions | ChoiceInputOptions; function isNumberInputOptions(inputOptions: InputOptions): inputOptions is NumberInputOptions { - return !!(inputOptions as NumberInputOptions).min; + return "min" in inputOptions; } function isBooleanInputOptions(inputOptions: InputOptions): inputOptions is BooleanInputOptions { - return !!(inputOptions as BooleanInputOptions).trueLabel; + return "trueLabel" in inputOptions; } function isChoiceInputOptions(inputOptions: InputOptions): inputOptions is ChoiceInputOptions { - return !!(inputOptions as ChoiceInputOptions).choices; + return "choices" in inputOptions; } const addToMap = (...decorators: Decorator[]): PropertyDecorator => { @@ -77,32 +77,25 @@ export const PropertyInfo = (info: (() => Promise) | Info): PropertyDecora export const Values = (inputOptions: InputOptions): PropertyDecorator => { if (isNumberInputOptions(inputOptions)) { - const numberInputOptions = inputOptions as NumberInputOptions; return addToMap( - { name: "label", value: numberInputOptions.label }, - { name: "min", value: numberInputOptions.min }, - { name: "max", value: numberInputOptions.max }, - { name: "step", value: numberInputOptions.step }, - { name: "uiType", value: numberInputOptions.uiType } + { 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)) { - const booleanInputOptions = inputOptions as BooleanInputOptions; return addToMap( - { name: "label", value: booleanInputOptions.label }, - { name: "trueLabel", value: booleanInputOptions.trueLabel }, - { name: "falseLabel", value: booleanInputOptions.falseLabel } + { name: "label", value: inputOptions.label }, + { name: "trueLabel", value: inputOptions.trueLabel }, + { name: "falseLabel", value: inputOptions.falseLabel } ); } else if (isChoiceInputOptions(inputOptions)) { - const choiceInputOptions = inputOptions as ChoiceInputOptions; - return addToMap( - { name: "label", value: choiceInputOptions.label }, - { name: "choices", value: choiceInputOptions.choices } - ); + return addToMap({ name: "label", value: inputOptions.label }, { name: "choices", value: inputOptions.choices }); } else { - const stringInputOptions = inputOptions as StringInputOptions; return addToMap( - { name: "label", value: stringInputOptions.label }, - { name: "placeholder", value: stringInputOptions.placeholder } + { name: "label", value: inputOptions.label }, + { name: "placeholder", value: inputOptions.placeholder } ); } }; diff --git a/src/SelfServe/SelfServeComponent.test.tsx b/src/SelfServe/SelfServeComponent.test.tsx new file mode 100644 index 000000000..32cf9ba73 --- /dev/null +++ b/src/SelfServe/SelfServeComponent.test.tsx @@ -0,0 +1,104 @@ +import React from "react"; +import { shallow } from "enzyme"; +import { SelfServeDescriptor, SelfServeComponent, SelfServeComponentState } from "./SelfServeComponent"; +import { InputType, UiType } from "../Explorer/Controls/SmartUi/SmartUiComponent"; + +describe("SelfServeComponent", () => { + const defaultValues = new Map([ + ["throughput", "450"], + ["analyticalStore", "false"], + ["database", "db2"] + ]); + const initializeMock = jest.fn(async () => defaultValues); + const onSubmitMock = jest.fn(async () => { + return; + }); + + const exampleData: SelfServeDescriptor = { + initialize: initializeMock, + onSubmit: onSubmitMock, + inputNames: ["throughput", "containerId", "analyticalStore", "database"], + root: { + id: "root", + info: { + message: "Start at $24/mo per database", + link: { + href: "https://aka.ms/azure-cosmos-db-pricing", + text: "More Details" + } + }, + children: [ + { + id: "throughput", + input: { + label: "Throughput (input)", + dataFieldName: "throughput", + type: "number", + min: 400, + max: 500, + step: 10, + defaultValue: 400, + uiType: UiType.Spinner + } + }, + { + id: "containerId", + input: { + label: "Container id", + dataFieldName: "containerId", + type: "string" + } + }, + { + id: "analyticalStore", + input: { + label: "Analytical Store", + trueLabel: "Enabled", + falseLabel: "Disabled", + defaultValue: true, + dataFieldName: "analyticalStore", + type: "boolean" + } + }, + { + id: "database", + input: { + label: "Database", + dataFieldName: "database", + type: "object", + choices: [ + { label: "Database 1", key: "db1" }, + { label: "Database 2", key: "db2" }, + { label: "Database 3", key: "db3" } + ], + defaultKey: "db2" + } + } + ] + } + }; + + const verifyDefaultsSet = (currentValues: Map): void => { + for (const key of currentValues.keys()) { + if (defaultValues.has(key)) { + expect(defaultValues.get(key)).toEqual(currentValues.get(key)); + } + } + }; + + it("should render", async () => { + const wrapper = shallow(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(wrapper).toMatchSnapshot(); + + // initialize() should be called and defaults should be set when component is mounted + expect(initializeMock).toHaveBeenCalled(); + const state = wrapper.state() as SelfServeComponentState; + verifyDefaultsSet(state.currentValues); + + // onSubmit() must be called when submit button is clicked + const submitButton = wrapper.find("#submitButton"); + submitButton.simulate("click"); + expect(onSubmitMock).toHaveBeenCalled(); + }); +}); diff --git a/src/SelfServe/SelfServeComponent.tsx b/src/SelfServe/SelfServeComponent.tsx new file mode 100644 index 000000000..01464573e --- /dev/null +++ b/src/SelfServe/SelfServeComponent.tsx @@ -0,0 +1,219 @@ +import React from "react"; +import { IStackTokens, PrimaryButton, Spinner, SpinnerSize, Stack } from "office-ui-fabric-react"; +import { + ChoiceItem, + InputType, + InputTypeValue, + SmartUiComponent, + UiType, + SmartUiDescriptor, + Info +} from "../Explorer/Controls/SmartUi/SmartUiComponent"; + +export interface BaseInput { + label: (() => Promise) | string; + dataFieldName: string; + type: InputTypeValue; + onChange?: (currentState: Map, newValue: InputType) => Map; + placeholder?: (() => Promise) | string; + errorMessage?: string; +} + +export interface NumberInput extends BaseInput { + min: (() => Promise) | number; + max: (() => Promise) | number; + step: (() => Promise) | number; + defaultValue?: number; + uiType: UiType; +} + +export interface BooleanInput extends BaseInput { + trueLabel: (() => Promise) | string; + falseLabel: (() => Promise) | string; + defaultValue?: boolean; +} + +export interface StringInput extends BaseInput { + defaultValue?: string; +} + +export interface ChoiceInput extends BaseInput { + choices: (() => Promise) | ChoiceItem[]; + defaultKey?: string; +} + +export interface Node { + id: string; + info?: (() => Promise) | Info; + input?: AnyInput; + children?: Node[]; +} + +export interface SelfServeDescriptor { + root: Node; + initialize?: () => Promise>; + onSubmit?: (currentValues: Map) => Promise; + inputNames?: string[]; +} + +export type AnyInput = NumberInput | BooleanInput | StringInput | ChoiceInput; + +export interface SelfServeComponentProps { + descriptor: SelfServeDescriptor; +} + +export interface SelfServeComponentState { + root: SelfServeDescriptor; + currentValues: Map; + baselineValues: Map; + isRefreshing: boolean; +} + +export class SelfServeComponent extends React.Component { + componentDidMount(): void { + this.initializeSmartUiComponent(); + } + + constructor(props: SelfServeComponentProps) { + super(props); + this.state = { + root: this.props.descriptor, + currentValues: new Map(), + baselineValues: new Map(), + isRefreshing: false + }; + } + + private initializeSmartUiComponent = async (): Promise => { + this.setState({ isRefreshing: true }); + await this.initializeSmartUiNode(this.props.descriptor.root); + await this.setDefaults(); + this.setState({ isRefreshing: false }); + }; + + private setDefaults = async (): Promise => { + this.setState({ isRefreshing: true }); + let { currentValues, baselineValues } = this.state; + + const initialValues = await this.props.descriptor.initialize(); + for (const key of initialValues.keys()) { + if (this.props.descriptor.inputNames.indexOf(key) === -1) { + this.setState({ isRefreshing: false }); + throw new Error(`${key} is not an input property of this class.`); + } + + currentValues = currentValues.set(key, initialValues.get(key)); + baselineValues = baselineValues.set(key, initialValues.get(key)); + } + this.setState({ currentValues: currentValues, baselineValues: baselineValues, isRefreshing: false }); + }; + + public discard = (): void => { + console.log("discarding"); + let { currentValues } = this.state; + const { baselineValues } = this.state; + for (const key of baselineValues.keys()) { + currentValues = currentValues.set(key, baselineValues.get(key)); + } + this.setState({ currentValues: currentValues }); + }; + + private initializeSmartUiNode = async (currentNode: Node): Promise => { + currentNode.info = await this.getResolvedValue(currentNode.info); + + if (currentNode.input) { + currentNode.input = await this.getResolvedInput(currentNode.input); + } + + const promises = currentNode.children?.map(async (child: Node) => await this.initializeSmartUiNode(child)); + if (promises) { + await Promise.all(promises); + } + }; + + private getResolvedInput = async (input: AnyInput): Promise => { + input.label = await this.getResolvedValue(input.label); + input.placeholder = await this.getResolvedValue(input.placeholder); + + switch (input.type) { + case "string": { + return input as StringInput; + } + case "number": { + const numberInput = input as NumberInput; + numberInput.min = await this.getResolvedValue(numberInput.min); + numberInput.max = await this.getResolvedValue(numberInput.max); + numberInput.step = await this.getResolvedValue(numberInput.step); + return numberInput; + } + case "boolean": { + const booleanInput = input as BooleanInput; + booleanInput.trueLabel = await this.getResolvedValue(booleanInput.trueLabel); + booleanInput.falseLabel = await this.getResolvedValue(booleanInput.falseLabel); + return booleanInput; + } + default: { + const choiceInput = input as ChoiceInput; + choiceInput.choices = await this.getResolvedValue(choiceInput.choices); + return choiceInput; + } + } + }; + + public async getResolvedValue(value: T | (() => Promise)): Promise { + if (value instanceof Function) { + return value(); + } + return value; + } + + private onInputChange = (input: AnyInput, newValue: InputType) => { + if (input.onChange) { + const newValues = input.onChange(this.state.currentValues, newValue); + this.setState({ currentValues: newValues }); + } else { + const dataFieldName = input.dataFieldName; + const { currentValues } = this.state; + currentValues.set(dataFieldName, newValue); + this.setState({ currentValues }); + } + }; + + public render(): JSX.Element { + const containerStackTokens: IStackTokens = { childrenGap: 20 }; + return !this.state.isRefreshing ? ( +
+ + + + + { + await this.props.descriptor.onSubmit(this.state.currentValues); + this.setDefaults(); + }} + /> + this.discard()} + /> + + +
+ ) : ( + + ); + } +} diff --git a/src/SelfServe/SelfServeComponentAdapter.tsx b/src/SelfServe/SelfServeComponentAdapter.tsx index 711f23c72..44d4c5fa7 100644 --- a/src/SelfServe/SelfServeComponentAdapter.tsx +++ b/src/SelfServe/SelfServeComponentAdapter.tsx @@ -7,26 +7,26 @@ import * as ko from "knockout"; import * as React from "react"; import { ReactAdapter } from "../Bindings/ReactBindingHandler"; import Explorer from "../Explorer/Explorer"; -import { Descriptor, SmartUiComponent } from "../Explorer/Controls/SmartUi/SmartUiComponent"; +import { SelfServeDescriptor, SelfServeComponent } from "./SelfServeComponent"; import { SelfServeType } from "./SelfServeUtils"; export class SelfServeComponentAdapter implements ReactAdapter { - public parameters: ko.Observable; + public parameters: ko.Observable; public container: Explorer; constructor(container: Explorer) { this.container = container; - this.parameters = ko.observable(undefined) + this.parameters = ko.observable(undefined); this.container.selfServeType.subscribe(() => { this.triggerRender(); }); } - public static getDescriptor = async (selfServeType: SelfServeType): Promise => { + public static getDescriptor = async (selfServeType: SelfServeType): Promise => { switch (selfServeType) { case SelfServeType.example: { const SelfServeExample = await import(/* webpackChunkName: "SelfServeExample" */ "./Example/SelfServeExample"); - return new SelfServeExample.default().toSmartUiDescriptor(); + return new SelfServeExample.default().toSelfServeDescriptor(); } default: return undefined; @@ -35,21 +35,17 @@ export class SelfServeComponentAdapter implements ReactAdapter { public renderComponent(): JSX.Element { if (this.container.selfServeType() === SelfServeType.invalid) { - return

Invalid self serve type!

+ return

Invalid self serve type!

; } - const smartUiDescriptor = this.parameters() - return smartUiDescriptor ? ( - - ) : ( - <> - ); + const smartUiDescriptor = this.parameters(); + return smartUiDescriptor ? : <>; } private triggerRender() { window.requestAnimationFrame(async () => { const selfServeType = this.container.selfServeType(); const smartUiDescriptor = await SelfServeComponentAdapter.getDescriptor(selfServeType); - this.parameters(smartUiDescriptor) + this.parameters(smartUiDescriptor); }); } } diff --git a/src/SelfServe/SelfServeUtils.test.tsx b/src/SelfServe/SelfServeUtils.test.tsx index 685374cf8..b081ba73b 100644 --- a/src/SelfServe/SelfServeUtils.test.tsx +++ b/src/SelfServe/SelfServeUtils.test.tsx @@ -14,7 +14,7 @@ describe("SelfServeUtils", () => { }; public initialize: () => Promise>; } - expect(() => new Test().toSmartUiDescriptor()).toThrow("initialize() was not declared for the class 'Test'"); + expect(() => new Test().toSelfServeDescriptor()).toThrow("initialize() was not declared for the class 'Test'"); }); it("onSubmit should be declared for self serve classes", () => { @@ -24,7 +24,7 @@ describe("SelfServeUtils", () => { return undefined; }; } - expect(() => new Test().toSmartUiDescriptor()).toThrow("onSubmit() was not declared for the class 'Test'"); + expect(() => new Test().toSelfServeDescriptor()).toThrow("onSubmit() was not declared for the class 'Test'"); }); it("@SmartUi decorator must be present for self serve classes", () => { @@ -36,7 +36,9 @@ describe("SelfServeUtils", () => { return undefined; }; } - expect(() => new Test().toSmartUiDescriptor()).toThrow("@SmartUi decorator was not declared for the class 'Test'"); + expect(() => new Test().toSelfServeDescriptor()).toThrow( + "@SmartUi decorator was not declared for the class 'Test'" + ); }); it("updateContextWithDecorator", () => { diff --git a/src/SelfServe/SelfServeUtils.tsx b/src/SelfServe/SelfServeUtils.tsx index 6dcb5ded6..f735ade66 100644 --- a/src/SelfServe/SelfServeUtils.tsx +++ b/src/SelfServe/SelfServeUtils.tsx @@ -1,17 +1,14 @@ import "reflect-metadata"; +import { ChoiceItem, Info, InputTypeValue, InputType } from "../Explorer/Controls/SmartUi/SmartUiComponent"; import { - ChoiceItem, - Node, - Info, - InputTypeValue, - Descriptor, - AnyInput, - NumberInput, - StringInput, BooleanInput, ChoiceInput, - InputType -} from "../Explorer/Controls/SmartUi/SmartUiComponent"; + SelfServeDescriptor, + NumberInput, + StringInput, + Node, + AnyInput +} from "./SelfServeComponent"; export enum SelfServeType { // No self serve type passed, launch explorer @@ -26,9 +23,9 @@ export abstract class SelfServeBaseClass { public abstract onSubmit: (currentValues: Map) => Promise; public abstract initialize: () => Promise>; - public toSmartUiDescriptor(): Descriptor { + public toSelfServeDescriptor(): SelfServeDescriptor { const className = this.constructor.name; - const smartUiDescriptor = Reflect.getMetadata(className, this) as Descriptor; + const smartUiDescriptor = Reflect.getMetadata(className, this) as SelfServeDescriptor; if (!this.initialize) { throw new Error(`initialize() was not declared for the class '${className}'`); @@ -128,12 +125,12 @@ export const buildSmartUiDescriptor = (className: string, target: unknown): void Reflect.defineMetadata(className, smartUiDescriptor, target); }; -export const mapToSmartUiDescriptor = (context: Map): Descriptor => { +export const mapToSmartUiDescriptor = (context: Map): SelfServeDescriptor => { const root = context.get("root"); context.delete("root"); const inputNames: string[] = []; - const smartUiDescriptor: Descriptor = { + const smartUiDescriptor: SelfServeDescriptor = { root: { id: "root", info: root?.info, diff --git a/src/SelfServe/__snapshots__/SelfServeComponent.test.tsx.snap b/src/SelfServe/__snapshots__/SelfServeComponent.test.tsx.snap new file mode 100644 index 000000000..ce478192b --- /dev/null +++ b/src/SelfServe/__snapshots__/SelfServeComponent.test.tsx.snap @@ -0,0 +1,168 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SelfServeComponent should render 1`] = ` +
+ + "450", + "analyticalStore" => "false", + "database" => "db2", + } + } + descriptor={ + Object { + "initialize": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + }, + "inputNames": Array [ + "throughput", + "containerId", + "analyticalStore", + "database", + ], + "onSubmit": [MockFunction], + "root": Object { + "children": Array [ + Object { + "id": "throughput", + "info": undefined, + "input": Object { + "dataFieldName": "throughput", + "defaultValue": 400, + "label": "Throughput (input)", + "max": 500, + "min": 400, + "placeholder": undefined, + "step": 10, + "type": "number", + "uiType": "Spinner", + }, + }, + Object { + "id": "containerId", + "info": undefined, + "input": Object { + "dataFieldName": "containerId", + "label": "Container id", + "placeholder": undefined, + "type": "string", + }, + }, + Object { + "id": "analyticalStore", + "info": undefined, + "input": Object { + "dataFieldName": "analyticalStore", + "defaultValue": true, + "falseLabel": "Disabled", + "label": "Analytical Store", + "placeholder": undefined, + "trueLabel": "Enabled", + "type": "boolean", + }, + }, + Object { + "id": "database", + "info": undefined, + "input": Object { + "choices": Array [ + Object { + "key": "db1", + "label": "Database 1", + }, + Object { + "key": "db2", + "label": "Database 2", + }, + Object { + "key": "db3", + "label": "Database 3", + }, + ], + "dataFieldName": "database", + "defaultKey": "db2", + "label": "Database", + "placeholder": undefined, + "type": "object", + }, + }, + ], + "id": "root", + "info": Object { + "link": Object { + "href": "https://aka.ms/azure-cosmos-db-pricing", + "text": "More Details", + }, + "message": "Start at $24/mo per database", + }, + }, + } + } + onInputChange={[Function]} + /> + + + + + +
+`; diff --git a/test/selfServe/selfServeExample.spec.ts b/test/selfServe/selfServeExample.spec.ts index de2e0ebf3..a753d73b9 100644 --- a/test/selfServe/selfServeExample.spec.ts +++ b/test/selfServe/selfServeExample.spec.ts @@ -12,13 +12,11 @@ describe("Self Serve", () => { frame = await getTestExplorerFrame( new Map([[TestExplorerParams.selfServeType, SelfServeType.example]]) ); - await frame.waitForSelector(".ms-Dropdown"); - const dropdownLabel = await frame.$eval(".ms-Dropdown-label", element => element.textContent); - expect(dropdownLabel).toEqual("Regions"); - await frame.waitForSelector(".radioSwitchComponent"); - await frame.waitForSelector(".ms-TextField"); - await frame.waitForSelector(".ms-Slider "); - await frame.waitForSelector(".ms-spinButton-input"); + 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;