diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 2dd7327ca..c3332ea92 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -444,6 +444,17 @@ export class KeyCodes { public static Tab: number = 9; } +// Normalized per: https://www.w3.org/TR/uievents-key/#named-key-attribute-values +export class NormalizedEventKey { + public static readonly Space = " "; + public static readonly Enter = "Enter"; + public static readonly Escape = "Escape"; + public static readonly UpArrow = "ArrowUp"; + public static readonly DownArrow = "ArrowDown"; + public static readonly LeftArrow = "ArrowLeft"; + public static readonly RightArrow = "ArrowRight"; +} + export class TryCosmosExperience { public static extendUrl: string = "https://trycosmosdb.azure.com/api/resource/extendportal?userId={0}"; public static deleteUrl: string = "https://trycosmosdb.azure.com/api/resource/deleteportal?userId={0}"; diff --git a/src/Explorer/Controls/RadioSwitchComponent/RadioSwitchComponent.less b/src/Explorer/Controls/RadioSwitchComponent/RadioSwitchComponent.less new file mode 100644 index 000000000..6156e39fc --- /dev/null +++ b/src/Explorer/Controls/RadioSwitchComponent/RadioSwitchComponent.less @@ -0,0 +1,16 @@ +@import "../../../../less/Common/Constants.less"; + +.radioSwitchComponent { + cursor: pointer; + display: flex; + + &>span:nth-child(n+2) { + margin-left: 10px; + } + + .caption { + color: @BaseDark; + padding-left: @SmallSpace; + vertical-align: top; + } +} \ No newline at end of file diff --git a/src/Explorer/Controls/RadioSwitchComponent/RadioSwitchComponent.tsx b/src/Explorer/Controls/RadioSwitchComponent/RadioSwitchComponent.tsx new file mode 100644 index 000000000..c58d6472a --- /dev/null +++ b/src/Explorer/Controls/RadioSwitchComponent/RadioSwitchComponent.tsx @@ -0,0 +1,51 @@ +/** + * Horizontal switch component + */ + +import * as React from "react"; +import "./RadioSwitchComponent.less"; +import { Icon } from "office-ui-fabric-react/lib/Icon"; +import { NormalizedEventKey } from "../../../Common/Constants"; + +export interface Choice { + key: string; + onSelect: () => void; + label: string; +} + +export interface RadioSwitchComponentProps { + choices: Choice[]; + selectedKey: string; + onSelectionKeyChange?: (newValue: string) => void; +} + +export class RadioSwitchComponent extends React.Component { + public render(): JSX.Element { + return ( +
+ {this.props.choices.map((choice: Choice) => ( + this.onSelect(choice)} + onKeyPress={event => this.onKeyPress(event, choice)} + > + + {choice.label} + + ))} +
+ ); + } + + private onSelect(choice: Choice): void { + this.props.onSelectionKeyChange && this.props.onSelectionKeyChange(choice.key); + choice.onSelect(); + } + + private onKeyPress(event: React.KeyboardEvent, choice: Choice): void { + if (event.key === NormalizedEventKey.Enter || event.key === NormalizedEventKey.Space) { + this.onSelect(choice); + } + } +} diff --git a/src/Explorer/Controls/SmartUi/InputUtils.ts b/src/Explorer/Controls/SmartUi/InputUtils.ts new file mode 100644 index 000000000..469726878 --- /dev/null +++ b/src/Explorer/Controls/SmartUi/InputUtils.ts @@ -0,0 +1,35 @@ +/* Utilities for validation */ + +export const onValidateValueChange = (newValue: string, minValue?: number, maxValue?: number): number => { + let numericValue = parseInt(newValue); + if (!isNaN(numericValue) && isFinite(numericValue)) { + if (minValue !== undefined && numericValue < minValue) { + numericValue = minValue; + } + if (maxValue !== undefined && numericValue > maxValue) { + numericValue = maxValue; + } + + return Math.floor(numericValue); + } + + return undefined; +}; + +export const onIncrementValue = (newValue: string, step: number, max?: number): number => { + const numericValue = parseInt(newValue); + if (!isNaN(numericValue) && isFinite(numericValue)) { + const newValue = numericValue + step; + return max !== undefined ? Math.min(max, newValue) : newValue; + } + return undefined; +}; + +export const onDecrementValue = (newValue: string, step: number, min?: number): number => { + const numericValue = parseInt(newValue); + if (!isNaN(numericValue) && isFinite(numericValue)) { + const newValue = numericValue - step; + return min !== undefined ? Math.max(min, newValue) : newValue; + } + return undefined; +}; diff --git a/src/Explorer/Controls/SmartUi/SmartUiComponent.less b/src/Explorer/Controls/SmartUi/SmartUiComponent.less new file mode 100644 index 000000000..336343a16 --- /dev/null +++ b/src/Explorer/Controls/SmartUi/SmartUiComponent.less @@ -0,0 +1,14 @@ +@import "../../../../less/Common/Constants.less"; + +.widgetRendererContainer { + text-align: left; + + .inputLabelContainer { + margin-bottom: 4px; + + .inputLabel { + color: #393939; + font-weight: 600; + } + } +} \ No newline at end of file diff --git a/src/Explorer/Controls/SmartUi/SmartUiComponent.test.tsx b/src/Explorer/Controls/SmartUi/SmartUiComponent.test.tsx new file mode 100644 index 000000000..b455a59fa --- /dev/null +++ b/src/Explorer/Controls/SmartUi/SmartUiComponent.test.tsx @@ -0,0 +1,88 @@ +import React from "react"; +import { shallow } from "enzyme"; +import { SmartUiComponent, Descriptor, InputType } from "./SmartUiComponent"; + +describe("SmartUiComponent", () => { + const exampleData: Descriptor = { + 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, + inputType: "spin" + } + }, + { + id: "throughput2", + input: { + label: "Throughput (Slider)", + dataFieldName: "throughput2", + type: "number", + min: 400, + max: 500, + step: 10, + defaultValue: 400, + inputType: "slider" + } + }, + { + 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: "enum", + choices: [ + { label: "Database 1", key: "db1", value: "database1" }, + { label: "Database 2", key: "db2", value: "database2" }, + { label: "Database 3", key: "db3", value: "database3" } + ], + defaultKey: "db2" + } + } + ] + } + }; + + const exampleCallbacks = (newValues: Map): void => { + console.log("New values:", newValues); + }; + + it("should render", () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx b/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx new file mode 100644 index 000000000..bb218c7a3 --- /dev/null +++ b/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx @@ -0,0 +1,335 @@ +import * as React from "react"; +import { Position } from "office-ui-fabric-react/lib/utilities/positioning"; +import { Slider } from "office-ui-fabric-react/lib/Slider"; +import { SpinButton } from "office-ui-fabric-react/lib/SpinButton"; +import { Dropdown, IDropdownOption } from "office-ui-fabric-react/lib/Dropdown"; +import { TextField } from "office-ui-fabric-react/lib/TextField"; +import { Text } from "office-ui-fabric-react/lib/Text"; +import { InputType } from "../../Tables/Constants"; +import { RadioSwitchComponent } from "../RadioSwitchComponent/RadioSwitchComponent"; +import { Stack, IStackTokens } from "office-ui-fabric-react/lib/Stack"; +import { Link, MessageBar, MessageBarType } from "office-ui-fabric-react"; + +import * as InputUtils from "./InputUtils"; +import "./SmartUiComponent.less"; + +/** + * Generic UX renderer + * It takes: + * - a JSON object as data + * - a Map of callbacks + * - a descriptor of the UX. + */ + +export type InputTypeValue = "number" | "string" | "boolean" | "enum"; + +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +export type EnumItem = { label: string; key: string; value: any }; + +export type InputType = number | string | boolean | EnumItem; + +interface BaseInput { + label: string; + dataFieldName: string; + type: InputTypeValue; + placeholder?: string; +} + +/** + * For now, this only supports integers + */ +export interface NumberInput extends BaseInput { + min?: number; + max?: number; + step: number; + defaultValue: number; + inputType: "spin" | "slider"; +} + +export interface BooleanInput extends BaseInput { + trueLabel: string; + falseLabel: string; + defaultValue: boolean; +} + +export interface StringInput extends BaseInput { + defaultValue?: string; +} + +export interface EnumInput extends BaseInput { + choices: EnumItem[]; + defaultKey: string; +} + +export interface Info { + message: string; + link?: { + href: string; + text: string; + }; +} + +export type AnyInput = NumberInput | BooleanInput | StringInput | EnumInput; + +export interface Node { + id: string; + info?: Info; + input?: AnyInput; + children?: Node[]; +} + +export interface Descriptor { + root: Node; +} + +/************************** Component implementation starts here ************************************* */ + +export interface SmartUiComponentProps { + descriptor: Descriptor; + onChange: (newValues: Map) => void; +} + +interface SmartUiComponentState { + currentValues: Map; + errors: Map; +} + +export class SmartUiComponent extends React.Component { + private static readonly labelStyle = { + color: "#393939", + fontFamily: "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif", + fontSize: 12 + }; + + constructor(props: SmartUiComponentProps) { + super(props); + this.state = { + currentValues: new Map(), + errors: new Map() + }; + } + + private renderInfo(info: Info): JSX.Element { + return ( + + {info.message} + + {info.link.text} + + + ); + } + + private onInputChange = (newValue: string | number | boolean, dataFieldName: string) => { + const { currentValues } = this.state; + currentValues.set(dataFieldName, newValue); + this.setState({ currentValues }, () => this.props.onChange(this.state.currentValues)); + }; + + private renderStringInput(input: StringInput): JSX.Element { + return ( +
+
+ this.onInputChange(newValue, input.dataFieldName)} + styles={{ + subComponentStyles: { + label: { + root: { + ...SmartUiComponent.labelStyle, + fontWeight: 600 + } + } + } + }} + /> +
+
+ ); + } + + private clearError(dataFieldName: string): void { + const { errors } = this.state; + errors.delete(dataFieldName); + this.setState({ errors }); + } + + private onValidate = (value: string, min: number, max: number, dataFieldName: string): string => { + const newValue = InputUtils.onValidateValueChange(value, min, max); + if (newValue) { + this.onInputChange(newValue, dataFieldName); + this.clearError(dataFieldName); + return newValue.toString(); + } else { + const { errors } = this.state; + errors.set(dataFieldName, `Invalid value ${value}: must be between ${min} and ${max}`); + this.setState({ errors }); + } + return undefined; + }; + + private onIncrement = (value: string, step: number, max: number, dataFieldName: string): string => { + const newValue = InputUtils.onIncrementValue(value, step, max); + if (newValue) { + this.onInputChange(newValue, dataFieldName); + this.clearError(dataFieldName); + return newValue.toString(); + } + return undefined; + }; + + private onDecrement = (value: string, step: number, min: number, dataFieldName: string): string => { + const newValue = InputUtils.onDecrementValue(value, step, min); + if (newValue) { + this.onInputChange(newValue, dataFieldName); + this.clearError(dataFieldName); + return newValue.toString(); + } + return undefined; + }; + + private renderNumberInput(input: NumberInput): JSX.Element { + const { label, min, max, defaultValue, dataFieldName, step } = input; + const props = { label, min, max, ariaLabel: label, step }; + + if (input.inputType === "spin") { + return ( +
+ this.onValidate(newValue, min, max, dataFieldName)} + onIncrement={newValue => this.onIncrement(newValue, step, max, dataFieldName)} + onDecrement={newValue => this.onDecrement(newValue, step, min, dataFieldName)} + labelPosition={Position.top} + styles={{ + label: { + ...SmartUiComponent.labelStyle, + fontWeight: 600 + } + }} + /> + {this.state.errors.has(dataFieldName) && ( + Error: {this.state.errors.get(dataFieldName)} + )} +
+ ); + } else if (input.inputType === "slider") { + return ( + this.onInputChange(newValue, dataFieldName)} + styles={{ + titleLabel: { + ...SmartUiComponent.labelStyle, + fontWeight: 600 + }, + valueLabel: SmartUiComponent.labelStyle + }} + /> + ); + } else { + return <>Unsupported number input type {input.inputType}; + } + } + + private renderBooleanInput(input: BooleanInput): JSX.Element { + const { dataFieldName } = input; + return ( +
+
+ + {input.label} + +
+ this.onInputChange(false, dataFieldName) + }, + { + label: input.trueLabel, + key: "true", + onSelect: () => this.onInputChange(true, dataFieldName) + } + ]} + selectedKey={ + (this.state.currentValues.has(dataFieldName) + ? (this.state.currentValues.get(dataFieldName) as boolean) + : input.defaultValue) + ? "true" + : "false" + } + /> +
+ ); + } + + private renderEnumInput(input: EnumInput): JSX.Element { + const { label, defaultKey, dataFieldName, choices, placeholder } = input; + return ( + this.onInputChange(item.key.toString(), dataFieldName)} + placeholder={placeholder} + options={choices.map(c => ({ + key: c.key, + text: c.value + }))} + styles={{ + label: { + ...SmartUiComponent.labelStyle, + fontWeight: 600 + }, + dropdown: SmartUiComponent.labelStyle + }} + /> + ); + } + + private renderInput(input: AnyInput): JSX.Element { + switch (input.type) { + case "string": + return this.renderStringInput(input as StringInput); + case "number": + return this.renderNumberInput(input as NumberInput); + case "boolean": + return this.renderBooleanInput(input as BooleanInput); + case "enum": + return this.renderEnumInput(input as EnumInput); + default: + throw new Error(`Unknown input type: ${input.type}`); + } + } + + private renderNode(node: Node): JSX.Element { + const containerStackTokens: IStackTokens = { childrenGap: 10 }; + + return ( + + {node.info && this.renderInfo(node.info)} + {node.input && this.renderInput(node.input)} + {node.children && node.children.map(child =>
{this.renderNode(child)}
)} +
+ ); + } + + render(): JSX.Element { + return <>{this.renderNode(this.props.descriptor.root)}; + } +} diff --git a/src/Explorer/Controls/SmartUi/__snapshots__/SmartUiComponent.test.tsx.snap b/src/Explorer/Controls/SmartUi/__snapshots__/SmartUiComponent.test.tsx.snap new file mode 100644 index 000000000..1e7c4d261 --- /dev/null +++ b/src/Explorer/Controls/SmartUi/__snapshots__/SmartUiComponent.test.tsx.snap @@ -0,0 +1,240 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SmartUiComponent should render 1`] = ` + + + + Start at $24/mo per database + + More Details + + +
+ +
+ +
+
+
+
+ + + +
+
+ +
+
+ +
+
+
+
+
+ +
+
+ + Analytical Store + +
+ +
+
+
+
+ + + +
+
+
+`;