Initial implementation of a generic UI component (#61)

* Add generic component

* Add validation. Rename to widgetRenderer

* Remove test code from splash screen

* Clean up infobox

* Fix styling/layout

* Move test code into unit test

* Replace <input> and <labe> by <TextField> and <Text> respectively. Fix style.

* Replace InfoBoxComponent with UI fabric MessageBar. Fix styling for TextField

* Use MessageBar for error message

* Rename WdigetRendererComponent to SmartUiComponent
This commit is contained in:
Laurent Nguyen 2020-07-06 17:16:43 +02:00 committed by GitHub
parent 3f34936acd
commit 27024ef75c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 790 additions and 0 deletions

View File

@ -444,6 +444,17 @@ export class KeyCodes {
public static Tab: number = 9; 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 { export class TryCosmosExperience {
public static extendUrl: string = "https://trycosmosdb.azure.com/api/resource/extendportal?userId={0}"; 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}"; public static deleteUrl: string = "https://trycosmosdb.azure.com/api/resource/deleteportal?userId={0}";

View File

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

View File

@ -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<RadioSwitchComponentProps> {
public render(): JSX.Element {
return (
<div className="radioSwitchComponent">
{this.props.choices.map((choice: Choice) => (
<span
tabIndex={0}
key={choice.key}
onClick={() => this.onSelect(choice)}
onKeyPress={event => this.onKeyPress(event, choice)}
>
<Icon iconName={this.props.selectedKey === choice.key ? "RadioBtnOn" : "RadioBtnOff"} />
<span className="caption">{choice.label}</span>
</span>
))}
</div>
);
}
private onSelect(choice: Choice): void {
this.props.onSelectionKeyChange && this.props.onSelectionKeyChange(choice.key);
choice.onSelect();
}
private onKeyPress(event: React.KeyboardEvent<HTMLSpanElement>, choice: Choice): void {
if (event.key === NormalizedEventKey.Enter || event.key === NormalizedEventKey.Space) {
this.onSelect(choice);
}
}
}

View File

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

View File

@ -0,0 +1,14 @@
@import "../../../../less/Common/Constants.less";
.widgetRendererContainer {
text-align: left;
.inputLabelContainer {
margin-bottom: 4px;
.inputLabel {
color: #393939;
font-weight: 600;
}
}
}

View File

@ -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<string, InputType>): void => {
console.log("New values:", newValues);
};
it("should render", () => {
const wrapper = shallow(<SmartUiComponent descriptor={exampleData} onChange={exampleCallbacks} />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -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<string, InputType>) => void;
}
interface SmartUiComponentState {
currentValues: Map<string, InputType>;
errors: Map<string, string>;
}
export class SmartUiComponent extends React.Component<SmartUiComponentProps, SmartUiComponentState> {
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 (
<MessageBar>
{info.message}
<Link href={info.link.href} target="_blank">
{info.link.text}
</Link>
</MessageBar>
);
}
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 (
<div className="stringInputContainer">
<div>
<TextField
id={`${input.dataFieldName}-input`}
label={input.label}
type="text"
value={input.defaultValue}
placeholder={input.placeholder}
onChange={(_, newValue) => this.onInputChange(newValue, input.dataFieldName)}
styles={{
subComponentStyles: {
label: {
root: {
...SmartUiComponent.labelStyle,
fontWeight: 600
}
}
}
}}
/>
</div>
</div>
);
}
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 (
<div>
<SpinButton
{...props}
defaultValue={defaultValue.toString()}
onValidate={newValue => 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) && (
<MessageBar messageBarType={MessageBarType.error}>Error: {this.state.errors.get(dataFieldName)}</MessageBar>
)}
</div>
);
} else if (input.inputType === "slider") {
return (
<Slider
// showValue={true}
// valueFormat={}
{...props}
defaultValue={defaultValue}
onChange={newValue => 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 (
<div>
<div className="inputLabelContainer">
<Text variant="small" nowrap className="inputLabel">
{input.label}
</Text>
</div>
<RadioSwitchComponent
choices={[
{
label: input.falseLabel,
key: "false",
onSelect: () => 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"
}
/>
</div>
);
}
private renderEnumInput(input: EnumInput): JSX.Element {
const { label, defaultKey, dataFieldName, choices, placeholder } = input;
return (
<Dropdown
label={label}
selectedKey={
this.state.currentValues.has(dataFieldName)
? (this.state.currentValues.get(dataFieldName) as string)
: defaultKey
}
onChange={(_, item: IDropdownOption) => 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 (
<Stack tokens={containerStackTokens} className="widgetRendererContainer">
{node.info && this.renderInfo(node.info)}
{node.input && this.renderInput(node.input)}
{node.children && node.children.map(child => <div key={child.id}>{this.renderNode(child)}</div>)}
</Stack>
);
}
render(): JSX.Element {
return <>{this.renderNode(this.props.descriptor.root)}</>;
}
}

View File

@ -0,0 +1,240 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SmartUiComponent should render 1`] = `
<Fragment>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StyledMessageBarBase>
Start at $24/mo per database
<StyledLinkBase
href="https://aka.ms/azure-cosmos-db-pricing"
target="_blank"
>
More Details
</StyledLinkBase>
</StyledMessageBarBase>
<div
key="throughput"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<div>
<CustomizedSpinButton
ariaLabel="Throughput (input)"
decrementButtonIcon={
Object {
"iconName": "ChevronDownSmall",
}
}
defaultValue="400"
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>
</Stack>
</div>
<div
key="throughput2"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StyledSliderBase
ariaLabel="Throughput (Slider)"
defaultValue={400}
label="Throughput (Slider)"
max={500}
min={400}
onChange={[Function]}
step={10}
styles={
Object {
"titleLabel": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
},
"valueLabel": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
},
}
}
/>
</Stack>
</div>
<div
key="containerId"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<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>
</Stack>
</div>
<div
key="analyticalStore"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<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>
</Stack>
</div>
<div
key="database"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StyledWithResponsiveMode
label="Database"
onChange={[Function]}
options={
Array [
Object {
"key": "db1",
"text": "database1",
},
Object {
"key": "db2",
"text": "database2",
},
Object {
"key": "db3",
"text": "database3",
},
]
}
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,
},
}
}
/>
</Stack>
</div>
</Stack>
</Fragment>
`;