mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-20 01:11:25 +00:00
* Added support for multi line descriptions * test snapshot change * addresse pr comments
414 lines
13 KiB
TypeScript
414 lines
13 KiB
TypeScript
import {
|
|
Dropdown,
|
|
IDropdownOption,
|
|
IStackTokens,
|
|
Label,
|
|
Link,
|
|
MessageBar,
|
|
MessageBarType,
|
|
Position,
|
|
Slider,
|
|
SpinButton,
|
|
Stack,
|
|
Text,
|
|
TextField,
|
|
Toggle,
|
|
} from "@fluentui/react";
|
|
import { TFunction } from "i18next";
|
|
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<string, SmartUiInput>;
|
|
onInputChange: (input: AnyDisplay, newValue: InputType) => void;
|
|
onError: (hasError: boolean) => void;
|
|
disabled: boolean;
|
|
getTranslation: TFunction;
|
|
}
|
|
|
|
interface SmartUiComponentState {
|
|
errors: Map<string, string>;
|
|
}
|
|
|
|
export class SmartUiComponent extends React.Component<SmartUiComponentProps, SmartUiComponentState> {
|
|
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 && (
|
|
<Text>
|
|
{this.props.getTranslation(info.messageTKey)}{" "}
|
|
{info.link && (
|
|
<Link href={info.link.href} target="_blank">
|
|
{this.props.getTranslation(info.link.textTKey)}
|
|
</Link>
|
|
)}
|
|
</Text>
|
|
)
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Stack>
|
|
{labelElement}
|
|
<TextField
|
|
id={`${input.dataFieldName}-textField-input`}
|
|
aria-labelledby={labelId}
|
|
type="text"
|
|
value={value || ""}
|
|
placeholder={this.props.getTranslation(input.placeholderTKey)}
|
|
disabled={disabled}
|
|
onChange={(_, newValue) => this.props.onInputChange(input, newValue)}
|
|
styles={{
|
|
root: { width: 400 },
|
|
}}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
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 = (
|
|
<Stack>
|
|
{labelElement}
|
|
<Text id={`${dataFieldName}-text-display`} aria-labelledby={labelId} style={{ whiteSpace: "pre-line" }}>
|
|
{this.props.getTranslation(description.textTKey)}{" "}
|
|
{description.link && (
|
|
<Link target="_blank" href={description.link.href}>
|
|
{this.props.getTranslation(description.link.textTKey)}
|
|
</Link>
|
|
)}
|
|
</Text>
|
|
</Stack>
|
|
);
|
|
|
|
if (description.type === DescriptionType.Text) {
|
|
return descriptionElement;
|
|
}
|
|
const messageBarType =
|
|
description.type === DescriptionType.InfoMessageBar ? MessageBarType.info : MessageBarType.warning;
|
|
return <MessageBar messageBarType={messageBarType}>{descriptionElement}</MessageBar>;
|
|
}
|
|
|
|
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 (
|
|
<Stack>
|
|
{labelElement}
|
|
<Stack styles={{ root: { width: 400 } }} tokens={{ childrenGap: 2 }}>
|
|
<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)}
|
|
onDecrement={(newValue) => this.onDecrement(input, newValue, props.step, props.min)}
|
|
labelPosition={Position.top}
|
|
aria-labelledby={labelId}
|
|
disabled={disabled}
|
|
/>
|
|
{this.state.errors.has(dataFieldName) && (
|
|
<MessageBar messageBarType={MessageBarType.error}>
|
|
Error: {this.state.errors.get(dataFieldName)}
|
|
</MessageBar>
|
|
)}
|
|
</Stack>
|
|
</Stack>
|
|
);
|
|
} else if (input.uiType === NumberUiType.Slider) {
|
|
return (
|
|
<Stack>
|
|
{labelElement}
|
|
<div id={`${input.dataFieldName}-slider-input`}>
|
|
<Slider
|
|
{...props}
|
|
value={value}
|
|
disabled={disabled}
|
|
onChange={(newValue) => this.props.onInputChange(input, newValue)}
|
|
styles={{
|
|
root: { width: 400 },
|
|
valueLabel: SmartUiComponent.labelStyle,
|
|
}}
|
|
/>
|
|
</div>
|
|
</Stack>
|
|
);
|
|
} 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 (
|
|
<Stack>
|
|
{labelElement}
|
|
<Toggle
|
|
id={`${input.dataFieldName}-toggle-input`}
|
|
aria-labelledby={labelId}
|
|
checked={value || false}
|
|
onText={this.props.getTranslation(input.trueLabelTKey)}
|
|
offText={this.props.getTranslation(input.falseLabelTKey)}
|
|
disabled={disabled}
|
|
onChange={(event, checked: boolean) => this.props.onInputChange(input, checked)}
|
|
styles={{ root: { width: 400 } }}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Stack>
|
|
{labelElement}
|
|
<Dropdown
|
|
id={`${input.dataFieldName}-dropdown-input`}
|
|
aria-labelledby={labelId}
|
|
selectedKey={selectedKey}
|
|
onChange={(_, item: IDropdownOption) => 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,
|
|
}}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
private renderError(errorMessage: string): JSX.Element {
|
|
return <MessageBar messageBarType={MessageBarType.error}>Error: {errorMessage}</MessageBar>;
|
|
}
|
|
|
|
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 && (
|
|
<Label id={labelId}>
|
|
<ToolTipLabelComponent
|
|
label={this.props.getTranslation(input.labelTKey)}
|
|
toolTipElement={this.renderInfo(info)}
|
|
/>
|
|
</Label>
|
|
);
|
|
|
|
return <Stack>{this.renderControl(input, labelId, labelElement)}</Stack>;
|
|
}
|
|
|
|
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 (
|
|
<Stack tokens={containerStackTokens} className="widgetRendererContainer">
|
|
<Stack.Item>{node.input && this.renderElement(node.input, node.info as Info)}</Stack.Item>
|
|
{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);
|
|
}
|
|
}
|