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 { 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 * 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" | "object"; export enum UiType { Spinner = "Spinner", Slider = "Slider" } type numberPromise = () => Promise; type stringPromise = () => Promise; type choiceItemPromise = () => Promise; type infoPromise = () => Promise; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ export type ChoiceItem = { label: string; key: string }; export type InputType = number | string | boolean | ChoiceItem; export interface BaseInput { label: (() => Promise) | string; dataFieldName: string; type: InputTypeValue; onChange?: (currentState: Map, newValue: InputType) => Map; placeholder?: (() => Promise) | string; errorMessage?: string; } /** * For now, this only supports integers */ export interface NumberInput extends BaseInput { min: (() => Promise) | number; max: (() => Promise) | number; step: (() => Promise) | number; defaultValue?: number; uiType: UiType; } export interface BooleanInput extends BaseInput { trueLabel: (() => Promise) | string; falseLabel: (() => Promise) | string; defaultValue?: boolean; } export interface StringInput extends BaseInput { defaultValue?: string; } export interface ChoiceInput extends BaseInput { choices: (() => Promise) | ChoiceItem[]; defaultKey?: string; } export interface Info { message: string; link?: { href: string; text: string; }; } export type AnyInput = NumberInput | BooleanInput | StringInput | ChoiceInput; export interface Node { id: string; info?: (() => Promise) | Info; input?: AnyInput; children?: Node[]; } export interface Descriptor { root: Node; initialize?: () => Promise>; onSubmit?: (currentValues: Map) => Promise; inputNames?: string[]; } /************************** Component implementation starts here ************************************* */ export interface SmartUiComponentProps { descriptor: Descriptor; } interface SmartUiComponentState { currentValues: Map; baselineValues: Map; errors: Map; isRefreshing: boolean; } 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 }; componentDidMount(): void { this.initializeSmartUiComponent(); } constructor(props: SmartUiComponentProps) { super(props); this.state = { baselineValues: new Map(), currentValues: new Map(), errors: new Map(), isRefreshing: false }; } private initializeSmartUiComponent = async (): Promise => { this.setState({ isRefreshing: true }); await this.initializeNode(this.props.descriptor.root); await this.setDefaults(); this.setState({ isRefreshing: false }); }; private setDefaults = async (): Promise => { this.setState({ isRefreshing: true }); let { currentValues, baselineValues } = this.state; const initialValues = await this.props.descriptor.initialize(); for (const key of initialValues.keys()) { if (this.props.descriptor.inputNames.indexOf(key) === -1) { this.setState({ isRefreshing: false }); throw new Error(`${key} is not an input property of this class.`); } currentValues = currentValues.set(key, initialValues.get(key)); baselineValues = baselineValues.set(key, initialValues.get(key)); } this.setState({ currentValues: currentValues, baselineValues: baselineValues, isRefreshing: false }); }; private discard = (): void => { let { currentValues } = this.state; const { baselineValues } = this.state; for (const key of baselineValues.keys()) { currentValues = currentValues.set(key, baselineValues.get(key)); } this.setState({ currentValues: currentValues }); }; private initializeNode = async (currentNode: Node): Promise => { if (currentNode.info && currentNode.info instanceof Function) { currentNode.info = await (currentNode.info as infoPromise)(); } if (currentNode.input) { currentNode.input = await this.getModifiedInput(currentNode.input); } const promises = currentNode.children?.map(async (child: Node) => await this.initializeNode(child)); if (promises) { await Promise.all(promises); } }; private getModifiedInput = async (input: AnyInput): Promise => { if (input.label instanceof Function) { input.label = await (input.label as stringPromise)(); } if (input.placeholder instanceof Function) { input.placeholder = await (input.placeholder as stringPromise)(); } switch (input.type) { case "string": { return input as StringInput; } case "number": { const numberInput = input as NumberInput; if (numberInput.min instanceof Function) { numberInput.min = await (numberInput.min as numberPromise)(); } if (numberInput.max instanceof Function) { numberInput.max = await (numberInput.max as numberPromise)(); } if (numberInput.step instanceof Function) { numberInput.step = await (numberInput.step as numberPromise)(); } return numberInput; } case "boolean": { const booleanInput = input as BooleanInput; if (booleanInput.trueLabel instanceof Function) { booleanInput.trueLabel = await (booleanInput.trueLabel as stringPromise)(); } if (booleanInput.falseLabel instanceof Function) { booleanInput.falseLabel = await (booleanInput.falseLabel as stringPromise)(); } return booleanInput; } default: { const enumInput = input as ChoiceInput; if (enumInput.choices instanceof Function) { enumInput.choices = await (enumInput.choices as choiceItemPromise)(); } return enumInput; } } }; private renderInfo(info: Info): JSX.Element { return ( {info.message} {info.link && ( {info.link.text} )} ); } 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; return (
this.onInputChange(input, newValue)} 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 = (input: AnyInput, value: string, min: number, max: number): string => { const newValue = InputUtils.onValidateValueChange(value, min, max); const dataFieldName = input.dataFieldName; if (newValue) { this.onInputChange(input, newValue); 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 = (input: AnyInput, value: string, step: number, max: number): string => { const newValue = InputUtils.onIncrementValue(value, step, max); const dataFieldName = input.dataFieldName; if (newValue) { this.onInputChange(input, newValue); this.clearError(dataFieldName); return newValue.toString(); } return undefined; }; private onDecrement = (input: AnyInput, value: string, step: number, min: number): string => { const newValue = InputUtils.onDecrementValue(value, step, min); const dataFieldName = input.dataFieldName; if (newValue) { this.onInputChange(input, newValue); this.clearError(dataFieldName); return newValue.toString(); } return undefined; }; 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 }; const value = this.state.currentValues.get(dataFieldName) as number; if (input.uiType === UiType.Spinner) { return ( <> this.onValidate(input, newValue, props.min, props.max)} onIncrement={newValue => this.onIncrement(input, newValue, props.step, props.max)} onDecrement={newValue => this.onDecrement(input, newValue, props.step, props.min)} labelPosition={Position.top} styles={{ label: { ...SmartUiComponent.labelStyle, fontWeight: 600 } }} /> {this.state.errors.has(dataFieldName) && ( Error: {this.state.errors.get(dataFieldName)} )} ); } else if (input.uiType === UiType.Slider) { return ( this.onInputChange(input, newValue)} styles={{ titleLabel: { ...SmartUiComponent.labelStyle, fontWeight: 600 }, valueLabel: SmartUiComponent.labelStyle }} /> ); } else { return <>Unsupported number UI type {input.uiType}; } } private renderBooleanInput(input: BooleanInput): JSX.Element { const { dataFieldName } = input; return (
{input.label}
this.onInputChange(input, false) }, { label: input.trueLabel as string, key: "true", onSelect: () => this.onInputChange(input, true) } ]} selectedKey={ (this.state.currentValues.has(dataFieldName) ? (this.state.currentValues.get(dataFieldName) as boolean) : input.defaultValue) ? "true" : "false" } />
); } private renderChoiceInput(input: ChoiceInput): JSX.Element { const { label, defaultKey: defaultKey, dataFieldName, choices, placeholder } = input; return ( this.onInputChange(input, item.key.toString())} placeholder={placeholder as string} options={(choices as ChoiceItem[]).map(c => ({ key: c.key, text: c.label }))} styles={{ label: { ...SmartUiComponent.labelStyle, fontWeight: 600 }, dropdown: SmartUiComponent.labelStyle }} /> ); } private renderError(input: AnyInput): JSX.Element { return Error: {input.errorMessage}; } private renderInput(input: AnyInput): JSX.Element { if (input.errorMessage) { return this.renderError(input); } switch (input.type) { case "string": return this.renderTextInput(input as StringInput); case "number": return this.renderNumberInput(input as NumberInput); case "boolean": return this.renderBooleanInput(input as BooleanInput); case "object": return this.renderChoiceInput(input as ChoiceInput); default: throw new Error(`Unknown input type: ${input.type}`); } } private renderNode(node: Node): JSX.Element { const containerStackTokens: IStackTokens = { childrenGap: 15 }; return ( {node.info && this.renderInfo(node.info as Info)} {node.input && this.renderInput(node.input)} {node.children && node.children.map(child =>
{this.renderNode(child)}
)}
); } render(): JSX.Element { const containerStackTokens: IStackTokens = { childrenGap: 20 }; return !this.state.isRefreshing ? (
{this.renderNode(this.props.descriptor.root)} { await this.props.descriptor.onSubmit(this.state.currentValues); this.setDefaults(); }} /> this.discard()} />
) : ( ); } }