mirror of
https://github.com/getredash/redash.git
synced 2026-03-23 13:00:10 -04:00
* Start draft for new Parameter structure * Add the rest of the methods * EnumParameter * QueryBasedDropdownParameter * DateParameter * DateRangeParameter * Update Parameter usage on code * Merge dynamicValue into normalizedValue * Add updateLocals and omit unwanted props * Allow null NumberParameter and omit parentQueryId * Rename parameter getValue to getExecutionValue * Update $$value to normalizedValue + omit on save * Add a few comments * Remove ngModel property from Parameter * Use value directly in DateRangeParameter * Use simpler separator for DateRange url param * Add backward compatibility * Use normalizeValue null value for isEmpty * Start creating jest tests * Add more tests * Normalize null value for multi mode in Enum * Use saved value for param isEmpty
654 lines
18 KiB
JavaScript
654 lines
18 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 Icon from 'antd/lib/icon';
|
|
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 'antd/lib/tooltip';
|
|
import ParameterValueInput from '@/components/ParameterValueInput';
|
|
import { ParameterMappingType } from '@/services/widget';
|
|
import { Parameter } from '@/services/parameters';
|
|
import HelpTrigger from '@/components/HelpTrigger';
|
|
|
|
import './ParameterMappingInput.less';
|
|
|
|
const { Option } = Select;
|
|
|
|
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 = result.param.clone();
|
|
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 = mapping.param.clone();
|
|
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 = newMapping.param.clone();
|
|
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">
|
|
<Icon type="question-circle" theme="filled" />
|
|
</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}
|
|
onChange={e => this.updateParamMapping({ mapTo: e.target.value })}
|
|
/>
|
|
);
|
|
}
|
|
|
|
renderDashboardMapToExisting() {
|
|
const { mapping, existingParamNames } = this.props;
|
|
|
|
return (
|
|
<Select
|
|
value={mapping.mapTo}
|
|
onChange={mapTo => this.updateParamMapping({ mapTo })}
|
|
dropdownMatchSelectWidth={false}
|
|
>
|
|
{map(existingParamNames, name => (
|
|
<Option value={name} key={name}>{ name }</Option>
|
|
))}
|
|
</Select>
|
|
);
|
|
}
|
|
|
|
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={`EditParamMappingButon-${mapping.param.name}`}>
|
|
<Icon type="edit" />
|
|
</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}
|
|
onChange={this.onEditingTitleChange}
|
|
onPressEnter={this.save}
|
|
maxLength={100}
|
|
autoFocus
|
|
/>
|
|
<Button size="small" type="dashed" onClick={this.hide}>
|
|
<Icon type="close" />
|
|
</Button>
|
|
<Button size="small" type="dashed" onClick={this.save}>
|
|
<Icon type="check" />
|
|
</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">
|
|
<i className="fa fa-eye-slash" />
|
|
</Tooltip>
|
|
);
|
|
}
|
|
return (
|
|
<Popover
|
|
placement="right"
|
|
trigger="click"
|
|
content={this.renderPopover()}
|
|
visible={this.state.showPopup}
|
|
onVisibleChange={this.onPopupVisibleChange}
|
|
>
|
|
<Button size="small" type="dashed">
|
|
<Icon type="edit" />
|
|
</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 = param.clone().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>
|
|
);
|
|
}
|
|
}
|