mirror of
https://github.com/getredash/redash.git
synced 2025-12-19 17:37:19 -05:00
Co-authored-by: Ezra Odio <eodio@starfishstorage.com> Co-authored-by: Restyled.io <commits@restyled.io>
636 lines
19 KiB
JavaScript
636 lines
19 KiB
JavaScript
/* 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 })}
|
|
regex={mapping.param.regex}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
}
|