import { Dropdown, IDropdownOption, IStackTokens, Label, Link, MessageBar, MessageBarType, Position, Slider, SpinButton, Stack, Text, TextField, Toggle, } from "@fluentui/react"; import * as React from "react"; import { ChoiceItem, Description, DescriptionType, Info, InputType, InputTypeValue, NumberUiType, SmartUiInput, } from "../../../SelfServe/SelfServeTypes"; import { ToolTipLabelComponent } from "../Settings/SettingsSubComponents/ToolTipLabelComponent"; 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. */ interface BaseDisplay { labelTKey: string; dataFieldName: string; errorMessage?: string; type: InputTypeValue; } interface BaseInput extends BaseDisplay { placeholderTKey?: string; } /** * For now, this only supports integers */ interface NumberInput extends BaseInput { min: number; max: number; step: number; defaultValue?: number; uiType: NumberUiType; } interface BooleanInput extends BaseInput { trueLabelTKey: string; falseLabelTKey: string; defaultValue?: boolean; } interface StringInput extends BaseInput { defaultValue?: string; } interface ChoiceInput extends BaseInput { choices: ChoiceItem[]; defaultKey?: string; } interface DescriptionDisplay extends BaseDisplay { description?: Description; isDynamicDescription?: boolean; } type AnyDisplay = NumberInput | BooleanInput | StringInput | ChoiceInput | DescriptionDisplay; interface Node { id: string; info?: Info; input?: AnyDisplay; children?: Node[]; } export interface SmartUiDescriptor { root: Node; } /************************** Component implementation starts here ************************************* */ export interface SmartUiComponentProps { descriptor: SmartUiDescriptor; currentValues: Map; onInputChange: (input: AnyDisplay, newValue: InputType) => void; onError: (hasError: boolean) => void; disabled: boolean; getTranslation: (messageKey: string, namespace?: string) => string; } interface SmartUiComponentState { errors: Map; } export class SmartUiComponent extends React.Component { private shouldCheckErrors = true; private static readonly labelStyle = { color: "#393939", fontFamily: "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif", fontSize: 12, }; componentDidUpdate(): void { if (!this.shouldCheckErrors) { this.shouldCheckErrors = true; return; } this.props.onError(this.state.errors.size > 0); this.shouldCheckErrors = false; } constructor(props: SmartUiComponentProps) { super(props); this.state = { errors: new Map(), }; } private renderInfo(info: Info): JSX.Element { return ( info && ( {this.props.getTranslation(info.messageTKey)}{" "} {info.link && ( {this.props.getTranslation(info.link.textTKey)} )} ) ); } private renderTextInput(input: StringInput, labelId: string, labelElement: JSX.Element): JSX.Element { const value = this.props.currentValues.get(input.dataFieldName)?.value as string; const disabled = this.props.disabled || this.props.currentValues.get(input.dataFieldName)?.disabled; return ( {labelElement} this.props.onInputChange(input, newValue)} styles={{ root: { width: 400 }, }} /> ); } private renderDescription(input: DescriptionDisplay, labelId: string, labelElement: JSX.Element): JSX.Element { const dataFieldName = input.dataFieldName; const description = input.description || (this.props.currentValues.get(dataFieldName)?.value as Description); if (!description) { if (!input.isDynamicDescription) { return this.renderError("Description is not provided."); } // If input is a dynamic description and description is not available, empty element is rendered return <>; } const descriptionElement = ( {labelElement} {this.props.getTranslation(description.textTKey)}{" "} {description.link && ( {this.props.getTranslation(description.link.textTKey)} )} ); if (description.type === DescriptionType.Text) { return descriptionElement; } const messageBarType = description.type === DescriptionType.InfoMessageBar ? MessageBarType.info : MessageBarType.warning; return {descriptionElement}; } private clearError(dataFieldName: string): void { const { errors } = this.state; errors.delete(dataFieldName); this.setState({ errors }); } private onValidate = (input: NumberInput, value: string, min: number, max: number): string => { const newValue = InputUtils.onValidateValueChange(value, min, max); const dataFieldName = input.dataFieldName; if (newValue) { this.props.onInputChange(input, newValue); this.clearError(dataFieldName); return newValue.toString(); } else { const { errors } = this.state; errors.set(dataFieldName, `Invalid value '${value}'. It must be between ${min} and ${max}`); this.setState({ errors }); } return undefined; }; private onIncrement = (input: NumberInput, value: string, step: number, max: number): string => { const newValue = InputUtils.onIncrementValue(value, step, max); const dataFieldName = input.dataFieldName; if (newValue) { this.props.onInputChange(input, newValue); this.clearError(dataFieldName); return newValue.toString(); } return undefined; }; private onDecrement = (input: NumberInput, value: string, step: number, min: number): string => { const newValue = InputUtils.onDecrementValue(value, step, min); const dataFieldName = input.dataFieldName; if (newValue) { this.props.onInputChange(input, newValue); this.clearError(dataFieldName); return newValue.toString(); } return undefined; }; private renderNumberInput(input: NumberInput, labelId: string, labelElement: JSX.Element): JSX.Element { const { labelTKey, min, max, dataFieldName, step } = input; const props = { min: min, max: max, ariaLabel: this.props.getTranslation(labelTKey), step: step, }; const value = this.props.currentValues.get(dataFieldName)?.value as number; const disabled = this.props.disabled || this.props.currentValues.get(dataFieldName)?.disabled; if (input.uiType === NumberUiType.Spinner) { return ( {labelElement} 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} aria-labelledby={labelId} disabled={disabled} /> {this.state.errors.has(dataFieldName) && ( Error: {this.state.errors.get(dataFieldName)} )} ); } else if (input.uiType === NumberUiType.Slider) { return ( {labelElement}
this.props.onInputChange(input, newValue)} styles={{ root: { width: 400 }, valueLabel: SmartUiComponent.labelStyle, }} />
); } else { return <>Unsupported number UI type {input.uiType}; } } private renderBooleanInput(input: BooleanInput, labelId: string, labelElement: JSX.Element): JSX.Element { const value = this.props.currentValues.get(input.dataFieldName)?.value as boolean; const disabled = this.props.disabled || this.props.currentValues.get(input.dataFieldName)?.disabled; return ( {labelElement} this.props.onInputChange(input, checked)} styles={{ root: { width: 400 } }} /> ); } private renderChoiceInput(input: ChoiceInput, labelId: string, labelElement: JSX.Element): JSX.Element { const { defaultKey, dataFieldName, choices, placeholderTKey } = input; const value = this.props.currentValues.get(dataFieldName)?.value as string; const disabled = this.props.disabled || this.props.currentValues.get(dataFieldName)?.disabled; let selectedKey = value ? value : defaultKey; if (!selectedKey) { selectedKey = ""; } return ( {labelElement} this.props.onInputChange(input, item.key.toString())} placeholder={this.props.getTranslation(placeholderTKey)} disabled={disabled} // Removed dropdownWidth="auto" as dropdown accept only number options={choices.map((c) => ({ key: c.key, text: this.props.getTranslation(c.labelTKey), }))} styles={{ root: { width: 400 }, dropdown: SmartUiComponent.labelStyle, }} /> ); } private renderError(errorMessage: string): JSX.Element { return Error: {errorMessage}; } private renderElement(input: AnyDisplay, info: Info): JSX.Element { if (input.errorMessage) { return this.renderError(input.errorMessage); } const inputHidden = this.props.currentValues.get(input.dataFieldName)?.hidden; if (inputHidden) { return <>; } const labelId = `${input.dataFieldName}-label`; const labelElement: JSX.Element = input.labelTKey && ( ); return {this.renderControl(input, labelId, labelElement)}; } private renderControl(input: AnyDisplay, labelId: string, labelElement: JSX.Element): JSX.Element { switch (input.type) { case "string": if ("description" in input || "isDynamicDescription" in input) { return this.renderDescription(input as DescriptionDisplay, labelId, labelElement); } return this.renderTextInput(input as StringInput, labelId, labelElement); case "number": return this.renderNumberInput(input as NumberInput, labelId, labelElement); case "boolean": return this.renderBooleanInput(input as BooleanInput, labelId, labelElement); case "object": return this.renderChoiceInput(input as ChoiceInput, labelId, labelElement); default: throw new Error(`Unknown input type: ${input.type}`); } } private renderNode(node: Node): JSX.Element { const containerStackTokens: IStackTokens = { childrenGap: 10 }; return ( {node.input && this.renderElement(node.input, node.info as Info)} {node.children && node.children.map((child) =>
{this.renderNode(child)}
)}
); } render(): JSX.Element { return this.renderNode(this.props.descriptor.root); } }