mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-23 02:41:39 +00:00
Added SelfServeComponent
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
104
src/SelfServe/SelfServeComponent.test.tsx
Normal file
104
src/SelfServe/SelfServeComponent.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
219
src/SelfServe/SelfServeComponent.tsx
Normal file
219
src/SelfServe/SelfServeComponent.tsx
Normal 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%" } }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
168
src/SelfServe/__snapshots__/SelfServeComponent.test.tsx.snap
Normal file
168
src/SelfServe/__snapshots__/SelfServeComponent.test.tsx.snap
Normal 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>
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user