client/app/components/ParameterMappingInput.jsx (563 lines of code) (raw):

/* eslint-disable react/no-multi-comp */ import { isString, extend, each, has, map, includes, findIndex, find, fromPairs, clone, isEmpty } from "lodash"; import React, { Fragment } from "react"; import PropTypes from "prop-types"; import classNames from "classnames"; import Select from "antd/lib/select"; import Table from "antd/lib/table"; import Popover from "antd/lib/popover"; import Button from "antd/lib/button"; import Tag from "antd/lib/tag"; import Input from "antd/lib/input"; import Radio from "antd/lib/radio"; import Form from "antd/lib/form"; import Tooltip from "@/components/Tooltip"; import ParameterValueInput from "@/components/ParameterValueInput"; import { ParameterMappingType } from "@/services/widget"; import { Parameter, cloneParameter } from "@/services/parameters"; import HelpTrigger from "@/components/HelpTrigger"; import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled"; import EditOutlinedIcon from "@ant-design/icons/EditOutlined"; import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined"; import CheckOutlinedIcon from "@ant-design/icons/CheckOutlined"; import "./ParameterMappingInput.less"; export const MappingType = { DashboardAddNew: "dashboard-add-new", DashboardMapToExisting: "dashboard-map-to-existing", WidgetLevel: "widget-level", StaticValue: "static-value", }; export function parameterMappingsToEditableMappings(mappings, parameters, existingParameterNames = []) { return map(mappings, mapping => { const result = extend({}, mapping); const alreadyExists = includes(existingParameterNames, mapping.mapTo); result.param = find(parameters, p => p.name === mapping.name); switch (mapping.type) { case ParameterMappingType.DashboardLevel: result.type = alreadyExists ? MappingType.DashboardMapToExisting : MappingType.DashboardAddNew; result.value = null; break; case ParameterMappingType.StaticValue: result.type = MappingType.StaticValue; result.param = cloneParameter(result.param); result.param.setValue(result.value); break; case ParameterMappingType.WidgetLevel: result.type = MappingType.WidgetLevel; result.value = null; break; // no default } return result; }); } export function editableMappingsToParameterMappings(mappings) { return fromPairs( map( // convert to map mappings, mapping => { const result = extend({}, mapping); switch (mapping.type) { case MappingType.DashboardAddNew: result.type = ParameterMappingType.DashboardLevel; result.value = null; break; case MappingType.DashboardMapToExisting: result.type = ParameterMappingType.DashboardLevel; result.value = null; break; case MappingType.StaticValue: result.type = ParameterMappingType.StaticValue; result.param = cloneParameter(mapping.param); result.param.setValue(result.value); result.value = result.param.value; break; case MappingType.WidgetLevel: result.type = ParameterMappingType.WidgetLevel; result.value = null; break; // no default } delete result.param; return [result.name, result]; } ) ); } export function synchronizeWidgetTitles(sourceMappings, widgets) { const affectedWidgets = []; each(sourceMappings, sourceMapping => { if (sourceMapping.type === ParameterMappingType.DashboardLevel) { each(widgets, widget => { const widgetMappings = widget.options.parameterMappings; each(widgetMappings, widgetMapping => { // check if mapped to the same dashboard-level parameter if ( widgetMapping.type === ParameterMappingType.DashboardLevel && widgetMapping.mapTo === sourceMapping.mapTo ) { // dirty check - update only when needed if (widgetMapping.title !== sourceMapping.title) { widgetMapping.title = sourceMapping.title; affectedWidgets.push(widget); } } }); }); } }); return affectedWidgets; } export class ParameterMappingInput extends React.Component { static propTypes = { mapping: PropTypes.object, // eslint-disable-line react/forbid-prop-types existingParamNames: PropTypes.arrayOf(PropTypes.string), onChange: PropTypes.func, inputError: PropTypes.string, }; static defaultProps = { mapping: {}, existingParamNames: [], onChange: () => {}, inputError: null, }; formItemProps = { labelCol: { span: 5 }, wrapperCol: { span: 16 }, className: "form-item", }; updateSourceType = type => { let { mapping: { mapTo }, } = this.props; const { existingParamNames } = this.props; // if mapped name doesn't already exists // default to first select option if (type === MappingType.DashboardMapToExisting && !includes(existingParamNames, mapTo)) { mapTo = existingParamNames[0]; } this.updateParamMapping({ type, mapTo }); }; updateParamMapping = update => { const { onChange, mapping } = this.props; const newMapping = extend({}, mapping, update); if (newMapping.value !== mapping.value) { newMapping.param = cloneParameter(newMapping.param); newMapping.param.setValue(newMapping.value); } if (has(update, "type")) { if (update.type === MappingType.StaticValue) { newMapping.value = newMapping.param.value; } else { newMapping.value = null; } } onChange(newMapping); }; renderMappingTypeSelector() { const noExisting = isEmpty(this.props.existingParamNames); return ( <Radio.Group value={this.props.mapping.type} onChange={e => this.updateSourceType(e.target.value)}> <Radio className="radio" value={MappingType.DashboardAddNew} data-test="NewDashboardParameterOption"> New dashboard parameter </Radio> <Radio className="radio" value={MappingType.DashboardMapToExisting} disabled={noExisting}> Existing dashboard parameter{" "} {noExisting ? ( <Tooltip title="There are no dashboard parameters corresponding to this data type"> <QuestionCircleFilledIcon /> </Tooltip> ) : null} </Radio> <Radio className="radio" value={MappingType.WidgetLevel} data-test="WidgetParameterOption"> Widget parameter </Radio> <Radio className="radio" value={MappingType.StaticValue} data-test="StaticValueOption"> Static value </Radio> </Radio.Group> ); } renderDashboardAddNew() { const { mapping: { mapTo }, } = this.props; return ( <Input value={mapTo} aria-label="Parameter name (key)" onChange={e => this.updateParamMapping({ mapTo: e.target.value })} /> ); } renderDashboardMapToExisting() { const { mapping, existingParamNames } = this.props; const options = map(existingParamNames, paramName => ({ label: paramName, value: paramName })); return <Select value={mapping.mapTo} onChange={mapTo => this.updateParamMapping({ mapTo })} options={options} />; } renderStaticValue() { const { mapping } = this.props; return ( <ParameterValueInput type={mapping.param.type} value={mapping.param.normalizedValue} enumOptions={mapping.param.enumOptions} queryId={mapping.param.queryId} parameter={mapping.param} onSelect={value => this.updateParamMapping({ value })} /> ); } renderInputBlock() { const { mapping } = this.props; switch (mapping.type) { case MappingType.DashboardAddNew: return ["Key", "Enter a new parameter keyword", this.renderDashboardAddNew()]; case MappingType.DashboardMapToExisting: return ["Key", "Select from a list of existing parameters", this.renderDashboardMapToExisting()]; case MappingType.StaticValue: return ["Value", null, this.renderStaticValue()]; default: return []; } } render() { const { inputError } = this.props; const [label, help, input] = this.renderInputBlock(); return ( <Form layout="horizontal"> <Form.Item label="Source" {...this.formItemProps}> {this.renderMappingTypeSelector()} </Form.Item> <Form.Item style={{ height: 60, visibility: input ? "visible" : "hidden" }} label={label} {...this.formItemProps} validateStatus={inputError ? "error" : ""} help={inputError || help} // empty space so line doesn't collapse > {input} </Form.Item> </Form> ); } } class MappingEditor extends React.Component { static propTypes = { mapping: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types existingParamNames: PropTypes.arrayOf(PropTypes.string).isRequired, onChange: PropTypes.func.isRequired, }; constructor(props) { super(props); this.state = { visible: false, mapping: clone(this.props.mapping), inputError: null, }; } onVisibleChange = visible => { if (visible) this.show(); else this.hide(); }; onChange = mapping => { let inputError = null; if (mapping.type === MappingType.DashboardAddNew) { if (isEmpty(mapping.mapTo)) { inputError = "Keyword must have a value"; } else if (includes(this.props.existingParamNames, mapping.mapTo)) { inputError = "A parameter with this name already exists"; } } this.setState({ mapping, inputError }); }; save = () => { this.props.onChange(this.props.mapping, this.state.mapping); this.hide(); }; show = () => { this.setState({ visible: true, mapping: clone(this.props.mapping), // restore original state }); }; hide = () => { this.setState({ visible: false }); }; renderContent() { const { mapping, inputError } = this.state; return ( <div className="parameter-mapping-editor" data-test="EditParamMappingPopover"> <header> Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" /> </header> <ParameterMappingInput mapping={mapping} existingParamNames={this.props.existingParamNames} onChange={this.onChange} inputError={inputError} /> <footer> <Button onClick={this.hide}>Cancel</Button> <Button onClick={this.save} disabled={!!inputError} type="primary"> OK </Button> </footer> </div> ); } render() { const { visible, mapping } = this.state; return ( <Popover placement="left" trigger="click" content={this.renderContent()} visible={visible} onVisibleChange={this.onVisibleChange}> <Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}> <EditOutlinedIcon /> </Button> </Popover> ); } } class TitleEditor extends React.Component { static propTypes = { existingParams: PropTypes.arrayOf(PropTypes.object), mapping: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types onChange: PropTypes.func.isRequired, }; static defaultProps = { existingParams: [], }; state = { showPopup: false, title: "", // will be set on editing }; onPopupVisibleChange = showPopup => { this.setState({ showPopup, title: showPopup ? this.getMappingTitle() : "", }); }; onEditingTitleChange = event => { this.setState({ title: event.target.value }); }; getMappingTitle() { let { mapping } = this.props; if (isString(mapping.title) && mapping.title !== "") { return mapping.title; } // if mapped to dashboard, find source param and return it's title if (mapping.type === MappingType.DashboardMapToExisting) { const source = find(this.props.existingParams, { name: mapping.mapTo }); if (source) { mapping = source; } } return mapping.title || mapping.param.title; } save = () => { const newMapping = extend({}, this.props.mapping, { title: this.state.title }); this.props.onChange(newMapping); this.hide(); }; hide = () => { this.setState({ showPopup: false }); }; renderPopover() { const { param: { title: paramTitle }, } = this.props.mapping; return ( <div className="parameter-mapping-title-editor"> <Input size="small" value={this.state.title} placeholder={paramTitle} aria-label="Edit parameter title" onChange={this.onEditingTitleChange} onPressEnter={this.save} maxLength={100} autoFocus /> <Button size="small" type="dashed" onClick={this.hide}> <CloseOutlinedIcon /> </Button> <Button size="small" type="dashed" onClick={this.save}> <CheckOutlinedIcon /> </Button> </div> ); } renderEditButton() { const { mapping } = this.props; if (mapping.type === MappingType.StaticValue) { return ( <Tooltip placement="right" title="Titles for static values don't appear in widgets"> {/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */} <span tabIndex={0}> <i className="fa fa-eye-slash" aria-hidden="true" /> </span> </Tooltip> ); } return ( <Popover placement="right" trigger="click" content={this.renderPopover()} visible={this.state.showPopup} onVisibleChange={this.onPopupVisibleChange}> <Button size="small" type="dashed"> <EditOutlinedIcon /> </Button> </Popover> ); } render() { const { mapping } = this.props; // static value are non-editable hence disabled const disabled = mapping.type === MappingType.StaticValue; return ( <div className={classNames("parameter-mapping-title", { disabled })}> <span className="text">{this.getMappingTitle()}</span> {this.renderEditButton()} </div> ); } } export class ParameterMappingListInput extends React.Component { static propTypes = { mappings: PropTypes.arrayOf(PropTypes.object), existingParams: PropTypes.arrayOf(PropTypes.object), onChange: PropTypes.func, }; static defaultProps = { mappings: [], existingParams: [], onChange: () => {}, }; static getStringValue(value) { // null if (!value) { return ""; } // range if (value instanceof Object && "start" in value && "end" in value) { return `${value.start} ~ ${value.end}`; } // just to be safe, array or object if (typeof value === "object") { return map(value, v => this.getStringValue(v)).join(", "); } // rest return value.toString(); } static getDefaultValue(mapping, existingParams) { const { type, mapTo, name } = mapping; let { param } = mapping; // if mapped to another param, swap 'em if (type === MappingType.DashboardMapToExisting && mapTo !== name) { const mappedTo = find(existingParams, { name: mapTo }); if (mappedTo) { // just being safe param = mappedTo; } // static type is different since it's fed param.normalizedValue } else if (type === MappingType.StaticValue) { param = cloneParameter(param).setValue(mapping.value); } let value = Parameter.getExecutionValue(param); // in case of dynamic value display the name instead of value if (param.hasDynamicValue) { value = param.normalizedValue.name; } return this.getStringValue(value); } static getSourceTypeLabel({ type, mapTo }) { switch (type) { case MappingType.DashboardAddNew: case MappingType.DashboardMapToExisting: return ( <Fragment> Dashboard <Tag className="tag">{mapTo}</Tag> </Fragment> ); case MappingType.WidgetLevel: return "Widget parameter"; case MappingType.StaticValue: return "Static value"; default: return ""; // won't happen (typescript-ftw) } } updateParamMapping(oldMapping, newMapping) { const mappings = [...this.props.mappings]; const index = findIndex(mappings, oldMapping); if (index >= 0) { // This should be the only possible case, but need to handle `else` too mappings[index] = newMapping; } else { mappings.push(newMapping); } this.props.onChange(mappings); } render() { const { existingParams } = this.props; // eslint-disable-line react/prop-types const dataSource = this.props.mappings.map(mapping => ({ mapping })); return ( <div className="parameters-mapping-list"> <Table dataSource={dataSource} size="middle" pagination={false} rowKey={(record, idx) => `row${idx}`}> <Table.Column title="Title" dataIndex="mapping" key="title" render={mapping => ( <TitleEditor existingParams={existingParams} mapping={mapping} onChange={newMapping => this.updateParamMapping(mapping, newMapping)} /> )} /> <Table.Column title="Keyword" dataIndex="mapping" key="keyword" className="keyword" render={mapping => <code>{`{{ ${mapping.name} }}`}</code>} /> <Table.Column title="Default Value" dataIndex="mapping" key="value" render={mapping => this.constructor.getDefaultValue(mapping, this.props.existingParams)} /> <Table.Column title="Value Source" dataIndex="mapping" key="source" render={mapping => { const existingParamsNames = existingParams .filter(({ type }) => type === mapping.param.type) // exclude mismatching param types .map(({ name }) => name); // keep names only return ( <Fragment> {this.constructor.getSourceTypeLabel(mapping)}{" "} <MappingEditor mapping={mapping} existingParamNames={existingParamsNames} onChange={(oldMapping, newMapping) => this.updateParamMapping(oldMapping, newMapping)} /> </Fragment> ); }} /> </Table> </div> ); } }