Added SelfServeComponent

This commit is contained in:
Srinath Narayanan
2021-01-15 12:34:08 -08:00
parent 2ec2a891b4
commit d17508cc27
14 changed files with 879 additions and 625 deletions

View File

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

View File

@@ -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(<SmartUiComponent descriptor={exampleData} />);
const wrapper = shallow(
<SmartUiComponent descriptor={exampleData} currentValues={new Map()} onInputChange={undefined} />
);
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();
});
});

View File

@@ -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<number>;
type stringPromise = () => Promise<string>;
type choiceItemPromise = () => Promise<ChoiceItem[]>;
type infoPromise = () => Promise<Info>;
/* 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>) | string;
dataFieldName: string;
type: InputTypeValue;
onChange?: (currentState: Map<string, InputType>, newValue: InputType) => Map<string, InputType>;
placeholder?: (() => Promise<string>) | string;
errorMessage?: string;
}
/**
* For now, this only supports integers
*/
export interface NumberInput extends BaseInput {
min: (() => Promise<number>) | number;
max: (() => Promise<number>) | number;
step: (() => Promise<number>) | number;
defaultValue?: number;
uiType: UiType;
}
export interface BooleanInput extends BaseInput {
trueLabel: (() => Promise<string>) | string;
falseLabel: (() => Promise<string>) | string;
defaultValue?: boolean;
}
export interface StringInput extends BaseInput {
defaultValue?: string;
}
export interface ChoiceInput extends BaseInput {
choices: (() => Promise<ChoiceItem[]>) | ChoiceItem[];
defaultKey?: string;
}
export interface 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?: Info;
input?: AnyInput;
children?: Node[];
}
export interface Descriptor {
export interface SmartUiDescriptor {
root: Node;
initialize?: () => Promise<Map<string, InputType>>;
onSubmit?: (currentValues: Map<string, InputType>) => Promise<void>;
inputNames?: string[];
}
/************************** Component implementation starts here ************************************* */
export interface SmartUiComponentProps {
descriptor: Descriptor;
descriptor: SmartUiDescriptor;
currentValues: Map<string, InputType>;
onInputChange: (input: AnyInput, newValue: InputType) => void;
}
interface SmartUiComponentState {
currentValues: Map<string, InputType>;
baselineValues: Map<string, InputType>;
errors: Map<string, string>;
isRefreshing: boolean;
}
export class SmartUiComponent extends React.Component<SmartUiComponentProps, SmartUiComponentState> {
@@ -115,114 +104,13 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
fontSize: 12
};
componentDidMount(): void {
this.initializeSmartUiComponent();
}
constructor(props: SmartUiComponentProps) {
super(props);
this.state = {
baselineValues: new Map(),
currentValues: new Map(),
errors: new Map(),
isRefreshing: false
errors: new Map()
};
}
private initializeSmartUiComponent = async (): Promise<void> => {
this.setState({ isRefreshing: true });
await this.initializeNode(this.props.descriptor.root);
await this.setDefaults();
this.setState({ isRefreshing: false });
};
private setDefaults = async (): Promise<void> => {
this.setState({ isRefreshing: true });
let { currentValues, baselineValues } = this.state;
const initialValues = await this.props.descriptor.initialize();
for (const key of initialValues.keys()) {
if (this.props.descriptor.inputNames.indexOf(key) === -1) {
this.setState({ isRefreshing: false });
throw new Error(`${key} is not an input property of this class.`);
}
currentValues = currentValues.set(key, initialValues.get(key));
baselineValues = baselineValues.set(key, initialValues.get(key));
}
this.setState({ currentValues: 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<void> => {
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<AnyInput> => {
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 (
<MessageBar>
@@ -236,42 +124,28 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
);
}
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 });
}
};
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 (
<div className="stringInputContainer">
<div>
<TextField
id={`${input.dataFieldName}-input`}
label={input.label as string}
type="text"
value={value}
placeholder={input.placeholder as string}
onChange={(_, newValue) => this.onInputChange(input, newValue)}
styles={{
subComponentStyles: {
label: {
root: {
...SmartUiComponent.labelStyle,
fontWeight: 600
}
<TextField
id={`${input.dataFieldName}-textBox-input`}
label={input.label}
type="text"
value={value}
placeholder={input.placeholder}
onChange={(_, newValue) => this.props.onInputChange(input, newValue)}
styles={{
subComponentStyles: {
label: {
root: {
...SmartUiComponent.labelStyle,
fontWeight: 600
}
}
}}
/>
</div>
}
}}
/>
</div>
);
}
@@ -286,7 +160,7 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
const newValue = InputUtils.onValidateValueChange(value, min, max);
const dataFieldName = input.dataFieldName;
if (newValue) {
this.onInputChange(input, newValue);
this.props.onInputChange(input, newValue);
this.clearError(dataFieldName);
return newValue.toString();
} else {
@@ -301,7 +175,7 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
const newValue = InputUtils.onIncrementValue(value, step, max);
const dataFieldName = input.dataFieldName;
if (newValue) {
this.onInputChange(input, newValue);
this.props.onInputChange(input, newValue);
this.clearError(dataFieldName);
return newValue.toString();
}
@@ -312,7 +186,7 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
const newValue = InputUtils.onDecrementValue(value, step, min);
const dataFieldName = input.dataFieldName;
if (newValue) {
this.onInputChange(input, newValue);
this.props.onInputChange(input, newValue);
this.clearError(dataFieldName);
return newValue.toString();
}
@@ -322,19 +196,20 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
private renderNumberInput(input: NumberInput): JSX.Element {
const { label, min, max, dataFieldName, step } = input;
const props = {
label: label as string,
min: min as number,
max: max as number,
ariaLabel: label as string,
step: step as number
label: label,
min: min,
max: max,
ariaLabel: label,
step: step
};
const value = this.state.currentValues.get(dataFieldName) as number;
const value = this.props.currentValues.get(dataFieldName) as number;
if (input.uiType === UiType.Spinner) {
return (
<>
<SpinButton
{...props}
id={`${input.dataFieldName}-spinner-input`}
value={value?.toString()}
onValidate={newValue => this.onValidate(input, newValue, props.min, props.max)}
onIncrement={newValue => this.onIncrement(input, newValue, props.step, props.max)}
@@ -354,18 +229,20 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
);
} else if (input.uiType === UiType.Slider) {
return (
<Slider
{...props}
value={value}
onChange={newValue => this.onInputChange(input, newValue)}
styles={{
titleLabel: {
...SmartUiComponent.labelStyle,
fontWeight: 600
},
valueLabel: SmartUiComponent.labelStyle
}}
/>
<div id={`${input.dataFieldName}-slider-input`}>
<Slider
{...props}
value={value}
onChange={newValue => this.props.onInputChange(input, newValue)}
styles={{
titleLabel: {
...SmartUiComponent.labelStyle,
fontWeight: 600
},
valueLabel: SmartUiComponent.labelStyle
}}
/>
</div>
);
} else {
return <>Unsupported number UI type {input.uiType}</>;
@@ -373,9 +250,10 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
}
private renderBooleanInput(input: BooleanInput): JSX.Element {
const { dataFieldName } = input;
const value = this.props.currentValues.get(input.dataFieldName) as boolean;
const selectedKey = value || input.defaultValue ? "true" : "false";
return (
<div>
<div id={`${input.dataFieldName}-radioSwitch-input`}>
<div className="inputLabelContainer">
<Text variant="small" nowrap className="inputLabel">
{input.label}
@@ -384,23 +262,17 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
<RadioSwitchComponent
choices={[
{
label: input.falseLabel as string,
label: input.falseLabel,
key: "false",
onSelect: () => 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}
/>
</div>
);
@@ -408,17 +280,15 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
private renderChoiceInput(input: ChoiceInput): JSX.Element {
const { label, defaultKey: defaultKey, dataFieldName, choices, placeholder } = input;
const value = this.props.currentValues.get(dataFieldName) as string;
return (
<Dropdown
label={label as string}
selectedKey={
this.state.currentValues.has(dataFieldName)
? (this.state.currentValues.get(dataFieldName) as string)
: (defaultKey as string)
}
onChange={(_, item: IDropdownOption) => 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<SmartUiComponentProps, Sma
render(): JSX.Element {
const containerStackTokens: IStackTokens = { childrenGap: 20 };
return !this.state.isRefreshing ? (
<div style={{ overflowX: "auto" }}>
<Stack tokens={containerStackTokens} styles={{ root: { width: 400, padding: 10 } }}>
{this.renderNode(this.props.descriptor.root)}
<Stack horizontal tokens={{ childrenGap: 10 }}>
<PrimaryButton
styles={{ root: { width: 100 } }}
text="submit"
onClick={async () => {
await this.props.descriptor.onSubmit(this.state.currentValues);
this.setDefaults();
}}
/>
<PrimaryButton styles={{ root: { width: 100 } }} text="discard" onClick={() => this.discard()} />
</Stack>
</Stack>
</div>
) : (
<Spinner
size={SpinnerSize.large}
styles={{ root: { textAlign: "center", justifyContent: "center", width: "100%", height: "100%" } }}
/>
return (
<Stack tokens={containerStackTokens} styles={{ root: { width: 400, padding: 10 } }}>
{this.renderNode(this.props.descriptor.root)}
</Stack>
);
}
}

View File

@@ -1,108 +1,103 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SmartUiComponent should render 1`] = `
<div
style={
<Stack
styles={
Object {
"overflowX": "auto",
"root": Object {
"padding": 10,
"width": 400,
},
}
}
tokens={
Object {
"childrenGap": 20,
}
}
>
<Stack
styles={
Object {
"root": Object {
"padding": 10,
"width": 400,
},
}
}
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 20,
"childrenGap": 15,
}
}
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
}
<StackItem>
<StyledMessageBarBase>
Start at $24/mo per database
<StyledLinkBase
href="https://aka.ms/azure-cosmos-db-pricing"
target="_blank"
>
More Details
</StyledLinkBase>
</StyledMessageBarBase>
</StackItem>
<div
key="throughput"
>
<StackItem>
<StyledMessageBarBase>
Start at $24/mo per database
<StyledLinkBase
href="https://aka.ms/azure-cosmos-db-pricing"
target="_blank"
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
}
>
<StackItem>
<CustomizedSpinButton
ariaLabel="Throughput (input)"
decrementButtonIcon={
Object {
"iconName": "ChevronDownSmall",
}
}
disabled={false}
id="throughput-spinner-input"
incrementButtonIcon={
Object {
"iconName": "ChevronUpSmall",
}
}
label="Throughput (input)"
labelPosition={0}
max={500}
min={400}
onDecrement={[Function]}
onIncrement={[Function]}
onValidate={[Function]}
step={10}
styles={
Object {
"label": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
},
}
}
/>
</StackItem>
</Stack>
</div>
<div
key="throughput2"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
}
>
<StackItem>
<div
id="throughput2-slider-input"
>
More Details
</StyledLinkBase>
</StyledMessageBarBase>
</StackItem>
<div
key="throughput"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
}
>
<StackItem>
<div>
<CustomizedSpinButton
ariaLabel="Throughput (input)"
decrementButtonIcon={
Object {
"iconName": "ChevronDownSmall",
}
}
disabled={false}
incrementButtonIcon={
Object {
"iconName": "ChevronUpSmall",
}
}
label="Throughput (input)"
labelPosition={0}
max={500}
min={400}
onDecrement={[Function]}
onIncrement={[Function]}
onValidate={[Function]}
step={10}
styles={
Object {
"label": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
},
}
}
/>
</div>
</StackItem>
</Stack>
</div>
<div
key="throughput2"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
}
>
<StackItem>
<StyledSliderBase
ariaLabel="Throughput (Slider)"
label="Throughput (Slider)"
@@ -126,215 +121,169 @@ exports[`SmartUiComponent should render 1`] = `
}
}
/>
</StackItem>
</Stack>
</div>
<div
key="throughput3"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
</div>
</StackItem>
</Stack>
</div>
<div
key="throughput3"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
>
<StackItem>
<StyledMessageBarBase
messageBarType={1}
>
Error:
label, truelabel and falselabel are required for boolean input 'throughput3'
</StyledMessageBarBase>
</StackItem>
</Stack>
</div>
<div
key="containerId"
}
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
<StackItem>
<StyledMessageBarBase
messageBarType={1}
>
Error:
label, truelabel and falselabel are required for boolean input 'throughput3'
</StyledMessageBarBase>
</StackItem>
</Stack>
</div>
<div
key="containerId"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
>
<StackItem>
<div
className="stringInputContainer"
>
<div>
<StyledTextFieldBase
id="containerId-input"
label="Container id"
onChange={[Function]}
styles={
Object {
"subComponentStyles": Object {
"label": Object {
"root": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
},
},
},
}
}
type="text"
/>
</div>
</div>
</StackItem>
</Stack>
</div>
<div
key="analyticalStore"
}
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
}
>
<StackItem>
<div>
<div
className="inputLabelContainer"
>
<Text
className="inputLabel"
nowrap={true}
variant="small"
>
Analytical Store
</Text>
</div>
<RadioSwitchComponent
choices={
Array [
Object {
"key": "false",
"label": "Disabled",
"onSelect": [Function],
},
Object {
"key": "true",
"label": "Enabled",
"onSelect": [Function],
},
]
}
selectedKey="true"
/>
</div>
</StackItem>
</Stack>
</div>
<div
key="database"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
}
>
<StackItem>
<StyledWithResponsiveMode
label="Database"
<StackItem>
<div
className="stringInputContainer"
>
<StyledTextFieldBase
id="containerId-textBox-input"
label="Container id"
onChange={[Function]}
options={
styles={
Object {
"subComponentStyles": Object {
"label": Object {
"root": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
},
},
},
}
}
type="text"
/>
</div>
</StackItem>
</Stack>
</div>
<div
key="analyticalStore"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
}
>
<StackItem>
<div
id="analyticalStore-radioSwitch-input"
>
<div
className="inputLabelContainer"
>
<Text
className="inputLabel"
nowrap={true}
variant="small"
>
Analytical Store
</Text>
</div>
<RadioSwitchComponent
choices={
Array [
Object {
"key": "db1",
"text": "Database 1",
"key": "false",
"label": "Disabled",
"onSelect": [Function],
},
Object {
"key": "db2",
"text": "Database 2",
},
Object {
"key": "db3",
"text": "Database 3",
"key": "true",
"label": "Enabled",
"onSelect": [Function],
},
]
}
selectedKey="db2"
styles={
Object {
"dropdown": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
},
"label": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
},
}
}
selectedKey="true"
/>
</StackItem>
</Stack>
</div>
</Stack>
<Stack
horizontal={true}
tokens={
Object {
"childrenGap": 10,
}
}
</div>
</StackItem>
</Stack>
</div>
<div
key="database"
>
<CustomizedPrimaryButton
onClick={[Function]}
styles={
<Stack
className="widgetRendererContainer"
tokens={
Object {
"root": Object {
"width": 100,
},
"childrenGap": 15,
}
}
text="submit"
/>
<CustomizedPrimaryButton
onClick={[Function]}
styles={
Object {
"root": Object {
"width": 100,
},
}
}
text="discard"
/>
</Stack>
>
<StackItem>
<StyledWithResponsiveMode
id="database-dropown-input"
label="Database"
onChange={[Function]}
options={
Array [
Object {
"key": "db1",
"text": "Database 1",
},
Object {
"key": "db2",
"text": "Database 2",
},
Object {
"key": "db3",
"text": "Database 3",
},
]
}
selectedKey="db2"
styles={
Object {
"dropdown": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
},
"label": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
},
}
}
/>
</StackItem>
</Stack>
</div>
</Stack>
</div>
`;
exports[`SmartUiComponent should render 2`] = `
<StyledSpinnerBase
size={3}
styles={
Object {
"root": Object {
"height": "100%",
"justifyContent": "center",
"textAlign": "center",
"width": "100%",
},
}
}
/>
</Stack>
`;

View File

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

View File

@@ -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>) | Info): ClassDecorator => {
//eslint-disable-next-line @typescript-eslint/ban-types
return (target: Function) => {
return target => {
addPropertyToMap(target.prototype, "root", target.name, "info", info);
};
};

View File

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

View File

@@ -0,0 +1,104 @@
import React from "react";
import { shallow } from "enzyme";
import { SelfServeDescriptor, SelfServeComponent, SelfServeComponentState } from "./SelfServeComponent";
import { InputType, UiType } from "../Explorer/Controls/SmartUi/SmartUiComponent";
describe("SelfServeComponent", () => {
const defaultValues = new Map<string, InputType>([
["throughput", "450"],
["analyticalStore", "false"],
["database", "db2"]
]);
const initializeMock = jest.fn(async () => defaultValues);
const onSubmitMock = jest.fn(async () => {
return;
});
const exampleData: SelfServeDescriptor = {
initialize: initializeMock,
onSubmit: onSubmitMock,
inputNames: ["throughput", "containerId", "analyticalStore", "database"],
root: {
id: "root",
info: {
message: "Start at $24/mo per database",
link: {
href: "https://aka.ms/azure-cosmos-db-pricing",
text: "More Details"
}
},
children: [
{
id: "throughput",
input: {
label: "Throughput (input)",
dataFieldName: "throughput",
type: "number",
min: 400,
max: 500,
step: 10,
defaultValue: 400,
uiType: UiType.Spinner
}
},
{
id: "containerId",
input: {
label: "Container id",
dataFieldName: "containerId",
type: "string"
}
},
{
id: "analyticalStore",
input: {
label: "Analytical Store",
trueLabel: "Enabled",
falseLabel: "Disabled",
defaultValue: true,
dataFieldName: "analyticalStore",
type: "boolean"
}
},
{
id: "database",
input: {
label: "Database",
dataFieldName: "database",
type: "object",
choices: [
{ label: "Database 1", key: "db1" },
{ label: "Database 2", key: "db2" },
{ label: "Database 3", key: "db3" }
],
defaultKey: "db2"
}
}
]
}
};
const verifyDefaultsSet = (currentValues: Map<string, InputType>): void => {
for (const key of currentValues.keys()) {
if (defaultValues.has(key)) {
expect(defaultValues.get(key)).toEqual(currentValues.get(key));
}
}
};
it("should render", async () => {
const wrapper = shallow(<SelfServeComponent descriptor={exampleData} />);
await new Promise(resolve => setTimeout(resolve, 0));
expect(wrapper).toMatchSnapshot();
// initialize() should be called and defaults should be set when component is mounted
expect(initializeMock).toHaveBeenCalled();
const state = wrapper.state() as SelfServeComponentState;
verifyDefaultsSet(state.currentValues);
// onSubmit() must be called when submit button is clicked
const submitButton = wrapper.find("#submitButton");
submitButton.simulate("click");
expect(onSubmitMock).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,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>) | string;
dataFieldName: string;
type: InputTypeValue;
onChange?: (currentState: Map<string, InputType>, newValue: InputType) => Map<string, InputType>;
placeholder?: (() => Promise<string>) | string;
errorMessage?: string;
}
export interface NumberInput extends BaseInput {
min: (() => Promise<number>) | number;
max: (() => Promise<number>) | number;
step: (() => Promise<number>) | number;
defaultValue?: number;
uiType: UiType;
}
export interface BooleanInput extends BaseInput {
trueLabel: (() => Promise<string>) | string;
falseLabel: (() => Promise<string>) | string;
defaultValue?: boolean;
}
export interface StringInput extends BaseInput {
defaultValue?: string;
}
export interface ChoiceInput extends BaseInput {
choices: (() => Promise<ChoiceItem[]>) | ChoiceItem[];
defaultKey?: string;
}
export interface Node {
id: string;
info?: (() => Promise<Info>) | Info;
input?: AnyInput;
children?: Node[];
}
export interface SelfServeDescriptor {
root: Node;
initialize?: () => Promise<Map<string, InputType>>;
onSubmit?: (currentValues: Map<string, InputType>) => Promise<void>;
inputNames?: string[];
}
export type AnyInput = NumberInput | BooleanInput | StringInput | ChoiceInput;
export interface SelfServeComponentProps {
descriptor: SelfServeDescriptor;
}
export interface SelfServeComponentState {
root: SelfServeDescriptor;
currentValues: Map<string, InputType>;
baselineValues: Map<string, InputType>;
isRefreshing: boolean;
}
export class SelfServeComponent extends React.Component<SelfServeComponentProps, SelfServeComponentState> {
componentDidMount(): void {
this.initializeSmartUiComponent();
}
constructor(props: SelfServeComponentProps) {
super(props);
this.state = {
root: this.props.descriptor,
currentValues: new Map(),
baselineValues: new Map(),
isRefreshing: false
};
}
private initializeSmartUiComponent = async (): Promise<void> => {
this.setState({ isRefreshing: true });
await this.initializeSmartUiNode(this.props.descriptor.root);
await this.setDefaults();
this.setState({ isRefreshing: false });
};
private setDefaults = async (): Promise<void> => {
this.setState({ isRefreshing: true });
let { currentValues, baselineValues } = this.state;
const initialValues = await this.props.descriptor.initialize();
for (const key of initialValues.keys()) {
if (this.props.descriptor.inputNames.indexOf(key) === -1) {
this.setState({ isRefreshing: false });
throw new Error(`${key} is not an input property of this class.`);
}
currentValues = currentValues.set(key, initialValues.get(key));
baselineValues = baselineValues.set(key, initialValues.get(key));
}
this.setState({ currentValues: 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<void> => {
currentNode.info = await this.getResolvedValue(currentNode.info);
if (currentNode.input) {
currentNode.input = await this.getResolvedInput(currentNode.input);
}
const promises = currentNode.children?.map(async (child: Node) => await this.initializeSmartUiNode(child));
if (promises) {
await Promise.all(promises);
}
};
private getResolvedInput = async (input: AnyInput): Promise<AnyInput> => {
input.label = await this.getResolvedValue(input.label);
input.placeholder = await this.getResolvedValue(input.placeholder);
switch (input.type) {
case "string": {
return input as StringInput;
}
case "number": {
const numberInput = input as NumberInput;
numberInput.min = await this.getResolvedValue(numberInput.min);
numberInput.max = await this.getResolvedValue(numberInput.max);
numberInput.step = await this.getResolvedValue(numberInput.step);
return numberInput;
}
case "boolean": {
const booleanInput = input as BooleanInput;
booleanInput.trueLabel = await this.getResolvedValue(booleanInput.trueLabel);
booleanInput.falseLabel = await this.getResolvedValue(booleanInput.falseLabel);
return booleanInput;
}
default: {
const choiceInput = input as ChoiceInput;
choiceInput.choices = await this.getResolvedValue(choiceInput.choices);
return choiceInput;
}
}
};
public async getResolvedValue<T>(value: T | (() => Promise<T>)): Promise<T> {
if (value instanceof Function) {
return value();
}
return value;
}
private onInputChange = (input: AnyInput, newValue: InputType) => {
if (input.onChange) {
const newValues = input.onChange(this.state.currentValues, newValue);
this.setState({ currentValues: newValues });
} else {
const dataFieldName = input.dataFieldName;
const { currentValues } = this.state;
currentValues.set(dataFieldName, newValue);
this.setState({ currentValues });
}
};
public render(): JSX.Element {
const containerStackTokens: IStackTokens = { childrenGap: 20 };
return !this.state.isRefreshing ? (
<div style={{ overflowX: "auto" }}>
<Stack tokens={containerStackTokens} styles={{ root: { width: 400, padding: 10 } }}>
<SmartUiComponent
descriptor={this.state.root as SmartUiDescriptor}
currentValues={this.state.currentValues}
onInputChange={this.onInputChange}
/>
<Stack horizontal tokens={{ childrenGap: 10 }}>
<PrimaryButton
id="submitButton"
styles={{ root: { width: 100 } }}
text="submit"
onClick={async () => {
await this.props.descriptor.onSubmit(this.state.currentValues);
this.setDefaults();
}}
/>
<PrimaryButton
id="discardButton"
styles={{ root: { width: 100 } }}
text="discard"
onClick={() => this.discard()}
/>
</Stack>
</Stack>
</div>
) : (
<Spinner
size={SpinnerSize.large}
styles={{ root: { textAlign: "center", justifyContent: "center", width: "100%", height: "100%" } }}
/>
);
}
}

View File

@@ -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<Descriptor>;
public parameters: ko.Observable<SelfServeDescriptor>;
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<Descriptor> => {
public static getDescriptor = async (selfServeType: SelfServeType): Promise<SelfServeDescriptor> => {
switch (selfServeType) {
case SelfServeType.example: {
const SelfServeExample = await import(/* webpackChunkName: "SelfServeExample" */ "./Example/SelfServeExample");
return new SelfServeExample.default().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 <h1>Invalid self serve type!</h1>
return <h1>Invalid self serve type!</h1>;
}
const smartUiDescriptor = this.parameters()
return smartUiDescriptor ? (
<SmartUiComponent descriptor={smartUiDescriptor} />
) : (
<></>
);
const smartUiDescriptor = this.parameters();
return smartUiDescriptor ? <SelfServeComponent descriptor={smartUiDescriptor} /> : <></>;
}
private triggerRender() {
window.requestAnimationFrame(async () => {
const selfServeType = this.container.selfServeType();
const smartUiDescriptor = await SelfServeComponentAdapter.getDescriptor(selfServeType);
this.parameters(smartUiDescriptor)
this.parameters(smartUiDescriptor);
});
}
}

View File

@@ -14,7 +14,7 @@ describe("SelfServeUtils", () => {
};
public initialize: () => Promise<Map<string, InputType>>;
}
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", () => {

View File

@@ -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<string, InputType>) => Promise<void>;
public abstract initialize: () => Promise<Map<string, InputType>>;
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<string, CommonInputTypes>): Descriptor => {
export const mapToSmartUiDescriptor = (context: Map<string, CommonInputTypes>): SelfServeDescriptor => {
const root = context.get("root");
context.delete("root");
const inputNames: string[] = [];
const smartUiDescriptor: Descriptor = {
const smartUiDescriptor: SelfServeDescriptor = {
root: {
id: "root",
info: root?.info,

View File

@@ -0,0 +1,168 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SelfServeComponent should render 1`] = `
<div
style={
Object {
"overflowX": "auto",
}
}
>
<Stack
styles={
Object {
"root": Object {
"padding": 10,
"width": 400,
},
}
}
tokens={
Object {
"childrenGap": 20,
}
}
>
<SmartUiComponent
currentValues={
Map {
"throughput" => "450",
"analyticalStore" => "false",
"database" => "db2",
}
}
descriptor={
Object {
"initialize": [MockFunction] {
"calls": Array [
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
],
},
"inputNames": Array [
"throughput",
"containerId",
"analyticalStore",
"database",
],
"onSubmit": [MockFunction],
"root": Object {
"children": Array [
Object {
"id": "throughput",
"info": undefined,
"input": Object {
"dataFieldName": "throughput",
"defaultValue": 400,
"label": "Throughput (input)",
"max": 500,
"min": 400,
"placeholder": undefined,
"step": 10,
"type": "number",
"uiType": "Spinner",
},
},
Object {
"id": "containerId",
"info": undefined,
"input": Object {
"dataFieldName": "containerId",
"label": "Container id",
"placeholder": undefined,
"type": "string",
},
},
Object {
"id": "analyticalStore",
"info": undefined,
"input": Object {
"dataFieldName": "analyticalStore",
"defaultValue": true,
"falseLabel": "Disabled",
"label": "Analytical Store",
"placeholder": undefined,
"trueLabel": "Enabled",
"type": "boolean",
},
},
Object {
"id": "database",
"info": undefined,
"input": Object {
"choices": Array [
Object {
"key": "db1",
"label": "Database 1",
},
Object {
"key": "db2",
"label": "Database 2",
},
Object {
"key": "db3",
"label": "Database 3",
},
],
"dataFieldName": "database",
"defaultKey": "db2",
"label": "Database",
"placeholder": undefined,
"type": "object",
},
},
],
"id": "root",
"info": Object {
"link": Object {
"href": "https://aka.ms/azure-cosmos-db-pricing",
"text": "More Details",
},
"message": "Start at $24/mo per database",
},
},
}
}
onInputChange={[Function]}
/>
<Stack
horizontal={true}
tokens={
Object {
"childrenGap": 10,
}
}
>
<CustomizedPrimaryButton
id="submitButton"
onClick={[Function]}
styles={
Object {
"root": Object {
"width": 100,
},
}
}
text="submit"
/>
<CustomizedPrimaryButton
id="discardButton"
onClick={[Function]}
styles={
Object {
"root": Object {
"width": 100,
},
}
}
text="discard"
/>
</Stack>
</Stack>
</div>
`;

View File

@@ -12,13 +12,11 @@ describe("Self Serve", () => {
frame = await getTestExplorerFrame(
new Map<string, string>([[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;