Compare commits

...

31 Commits

Author SHA1 Message Date
Gabriel Dutra
19343a0520 Clear QueryBasedParameterInput 2020-11-23 19:18:35 -03:00
Gabriel Dutra
c1ed8848f0 Merge branch 'master' into query-based-dropdown--parameters 2020-11-23 16:42:06 -03:00
Gabriel Dutra
b40070d7f5 Use LabeledValues for parameterized queries 2020-11-23 16:40:18 -03:00
Gabriel Dutra
bd9ce68f68 Don't filter out values when param has search 2020-11-20 15:14:17 -03:00
Gabriel Dutra
0c0b62ae1a Remove searchTerm from structure 2020-11-20 15:13:47 -03:00
Gabriel Dutra
08bcdf77d0 Mock query instead of query_has_parameters 2020-11-12 09:11:54 -03:00
Gabriel Dutra
aa2064b1ab Fix other dropdown_values usages to use query obj 2020-11-11 13:58:01 -03:00
Gabriel Dutra
d0a787cab1 Make NoResultFound invalid parameters 2020-11-10 22:10:47 -03:00
Gabriel Dutra
a741341938 Oops 2020-11-10 20:46:51 -03:00
Gabriel Dutra
53385fa24b Merge branch 'master' into query-based-dropdown--parameters 2020-11-10 15:19:43 -03:00
Gabriel Dutra
f396c96457 Merge branch 'master' into query-based-dropdown--parameters 2020-02-25 07:49:36 -03:00
Gabriel Dutra
8bfcbf21e3 Remove redundant import 2020-02-24 11:44:28 -03:00
Gabriel Dutra
8a1640c4e7 Separate InputPopover component 2020-02-24 11:44:18 -03:00
Gabriel Dutra
a37e7f93dc Add is_safe test for queries with params 2020-02-22 15:47:29 -03:00
Gabriel Dutra
cc34e781d3 Small updates
- Change searchTerm separator
- Add cy.wait
2020-02-22 15:23:43 -03:00
Gabriel Dutra
6aa0ea715e Invert tooltip messages order 2020-02-22 14:08:19 -03:00
Gabriel Dutra
6c27619671 Make Parameter Mapping required in UI 2020-02-21 23:00:26 -03:00
Gabriel Dutra
6eeb3b3eb2 Separate UI components 2020-02-21 15:49:23 -03:00
Gabriel Dutra
d40edb81c2 Fix backend tests 2020-02-21 14:18:59 -03:00
Gabriel Dutra
f128b4b85f Only allow search for Text Parameters 2020-02-21 13:36:06 -03:00
Gabriel Dutra
264fb5798d Merge branch 'master' into query-based-dropdown--parameters 2020-02-21 13:31:49 -03:00
Gabriel Dutra
90023ac435 Make sure Table updates correctly 2020-02-21 11:03:37 -03:00
Gabriel Dutra
df755fbc17 Add try except for NoResultFound 2020-02-21 09:40:52 -03:00
Gabriel Dutra
e555642844 Add is_safe check for parameterized query based 2020-02-21 09:27:12 -03:00
Gabriel Dutra
bdd7b146ae Change stored mapping attributes 2020-02-20 21:59:19 -03:00
Gabriel Dutra
b7478defec Don't validade query params with params 2020-02-20 19:29:43 -03:00
Gabriel Dutra
bb0d7830c9 Fixes + temp remove validation for Query param 2020-02-20 18:49:29 -03:00
Gabriel Dutra
137aa22dd4 Parameter Mapping UI (2/2) 2020-02-18 17:55:27 -03:00
Gabriel Dutra
9cf396599a Parameter Mapping UI (1/2) 2020-02-17 23:32:10 -03:00
Gabriel Dutra
b70f0fa921 Iterate over backend verification 2020-02-16 13:39:05 -03:00
Gabriel Dutra
5e3613d6cb Start experiements with a 'search' parameter 2020-02-13 16:29:50 -03:00
17 changed files with 680 additions and 153 deletions

View File

@@ -1,5 +1,5 @@
import { includes, words, capitalize, clone, isNull } from "lodash";
import React, { useState, useEffect } from "react";
import { includes, words, capitalize, clone, isNull, map, get, find } from "lodash";
import React, { useState, useEffect, useRef, useMemo } from "react";
import PropTypes from "prop-types";
import Checkbox from "antd/lib/checkbox";
import Modal from "antd/lib/modal";
@@ -11,6 +11,8 @@ import Divider from "antd/lib/divider";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import QuerySelector from "@/components/QuerySelector";
import { Query } from "@/services/query";
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
import QueryBasedParameterMappingTable from "./query-based-parameter/QueryBasedParameterMappingTable";
const { Option } = Select;
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
@@ -69,17 +71,27 @@ NameInput.propTypes = {
function EditParameterSettingsDialog(props) {
const [param, setParam] = useState(clone(props.parameter));
const [isNameValid, setIsNameValid] = useState(true);
const [initialQuery, setInitialQuery] = useState();
const [paramQuery, setParamQuery] = useState();
const mappingParameters = useMemo(
() =>
map(paramQuery && paramQuery.getParametersDefs(), mappingParam => ({
mappingParam,
existingMapping: get(param.parameterMapping, mappingParam.name, {
mappingType: QueryBasedParameterMappingType.UNDEFINED,
}),
})),
[param.parameterMapping, paramQuery]
);
const isNew = !props.parameter.name;
// fetch query by id
const initialQueryId = useRef(props.parameter.queryId);
useEffect(() => {
const queryId = props.parameter.queryId;
if (queryId) {
Query.get({ id: queryId }).then(setInitialQuery);
if (initialQueryId.current) {
Query.get({ id: initialQueryId.current }).then(setParamQuery);
}
}, [props.parameter.queryId]);
}, []);
function isFulfilled() {
// name
@@ -93,8 +105,14 @@ function EditParameterSettingsDialog(props) {
}
// query
if (param.type === "query" && !param.queryId) {
return false;
if (param.type === "query") {
if (!param.queryId) {
return false;
}
if (find(mappingParameters, { existingMapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED } })) {
return false;
}
}
return true;
@@ -187,14 +205,28 @@ function EditParameterSettingsDialog(props) {
</Form.Item>
)}
{param.type === "query" && (
<Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}>
<Form.Item label="Query" help="Select query to load dropdown values from" required {...formItemProps}>
<QuerySelector
selectedQuery={initialQuery}
onChange={q => setParam({ ...param, queryId: q && q.id })}
selectedQuery={paramQuery}
onChange={q => {
if (q) {
setParamQuery(q);
setParam({ ...param, queryId: q.id, parameterMapping: {} });
}
}}
type="select"
/>
</Form.Item>
)}
{param.type === "query" && paramQuery && paramQuery.hasParameters() && (
<Form.Item className="m-t-15 m-b-5" label="Parameters" required {...formItemProps}>
<QueryBasedParameterMappingTable
param={param}
mappingParameters={mappingParameters}
onChangeParam={setParam}
/>
</Form.Item>
)}
{(param.type === "enum" || param.type === "query") && (
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
<Checkbox

View File

@@ -0,0 +1,134 @@
import React, { useState, useEffect, useRef, useReducer } from "react";
import PropTypes from "prop-types";
import { values } from "lodash";
import Button from "antd/lib/button";
import Tooltip from "antd/lib/tooltip";
import Radio from "antd/lib/radio";
import Typography from "antd/lib/typography/Typography";
import ParameterValueInput from "@/components/ParameterValueInput";
import InputPopover from "@/components/InputPopover";
import Form from "antd/lib/form";
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
const { Text } = Typography;
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
export default function QueryBasedParameterMappingEditor({ parameter, mapping, searchAvailable, onChange }) {
const [showPopover, setShowPopover] = useState(false);
const [newMapping, setNewMapping] = useReducer((prevState, updates) => ({ ...prevState, ...updates }), mapping);
const newMappingRef = useRef(newMapping);
useEffect(() => {
if (
mapping.mappingType !== newMappingRef.current.mappingType ||
mapping.staticValue !== newMappingRef.current.staticValue
) {
setNewMapping(mapping);
}
}, [mapping]);
const parameterRef = useRef(parameter);
useEffect(() => {
parameterRef.current.setValue(mapping.staticValue);
}, [mapping.staticValue]);
const onCancel = () => {
setNewMapping(mapping);
setShowPopover(false);
};
const onOk = () => {
onChange(newMapping);
setShowPopover(false);
};
let currentState = <Text type="secondary">Pick a type</Text>;
if (mapping.mappingType === QueryBasedParameterMappingType.DROPDOWN_SEARCH) {
currentState = "Dropdown Search";
} else if (mapping.mappingType === QueryBasedParameterMappingType.STATIC) {
currentState = `Value: ${mapping.staticValue}`;
}
return (
<>
{currentState}
<InputPopover
placement="left"
trigger="click"
header="Edit Parameter Source"
okButtonProps={{
disabled: newMapping.mappingType === QueryBasedParameterMappingType.STATIC && parameter.isEmpty,
}}
onOk={onOk}
onCancel={onCancel}
content={
<Form>
<Form.Item className="m-b-15" label="Source" {...formItemProps}>
<Radio.Group
value={newMapping.mappingType}
onChange={({ target }) => setNewMapping({ mappingType: target.value })}>
<Radio
className="radio"
value={QueryBasedParameterMappingType.DROPDOWN_SEARCH}
disabled={!searchAvailable || parameter.type !== "text"}>
Dropdown Search{" "}
{(!searchAvailable || parameter.type !== "text") && (
<Tooltip
title={
parameter.type !== "text"
? "Dropdown Search is only available for Text Parameters"
: "There is already a parameter mapped with the Dropdown Search type."
}>
<QuestionCircleFilledIcon />
</Tooltip>
)}
</Radio>
<Radio className="radio" value={QueryBasedParameterMappingType.STATIC}>
Static Value
</Radio>
</Radio.Group>
</Form.Item>
{newMapping.mappingType === QueryBasedParameterMappingType.STATIC && (
<Form.Item label="Value" required {...formItemProps}>
<ParameterValueInput
type={parameter.type}
value={parameter.normalizedValue}
enumOptions={parameter.enumOptions}
queryId={parameter.queryId}
parameter={parameter}
onSelect={value => {
parameter.setValue(value);
setNewMapping({ staticValue: parameter.getExecutionValue({ joinListValues: true }) });
}}
/>
</Form.Item>
)}
</Form>
}
visible={showPopover}
onVisibleChange={setShowPopover}>
<Button className="m-l-5" size="small" type="dashed">
<EditOutlinedIcon />
</Button>
</InputPopover>
</>
);
}
QueryBasedParameterMappingEditor.propTypes = {
parameter: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
mapping: PropTypes.shape({
mappingType: PropTypes.oneOf(values(QueryBasedParameterMappingType)),
staticValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
}),
searchAvailable: PropTypes.bool,
onChange: PropTypes.func,
};
QueryBasedParameterMappingEditor.defaultProps = {
mapping: { mappingType: QueryBasedParameterMappingType.UNDEFINED, staticValue: undefined },
searchAvailable: false,
onChange: () => {},
};

View File

@@ -0,0 +1,56 @@
import React from "react";
import { findKey } from "lodash";
import PropTypes from "prop-types";
import Table from "antd/lib/table";
import { QueryBasedParameterMappingType } from "@/services/parameters/QueryBasedDropdownParameter";
import QueryBasedParameterMappingEditor from "./QueryBasedParameterMappingEditor";
export default function QueryBasedParameterMappingTable({ param, mappingParameters, onChangeParam }) {
return (
<Table
dataSource={mappingParameters}
size="middle"
pagination={false}
rowKey={({ mappingParam }) => `param${mappingParam.name}`}>
<Table.Column title="Title" key="title" render={({ mappingParam }) => mappingParam.getTitle()} />
<Table.Column
title="Keyword"
key="keyword"
className="keyword"
render={({ mappingParam }) => <code>{`{{ ${mappingParam.name} }}`}</code>}
/>
<Table.Column
title="Value Source"
key="source"
render={({ mappingParam, existingMapping }) => (
<QueryBasedParameterMappingEditor
parameter={mappingParam.setValue(existingMapping.staticValue)}
mapping={existingMapping}
searchAvailable={
!findKey(param.parameterMapping, {
mappingType: QueryBasedParameterMappingType.DROPDOWN_SEARCH,
}) || existingMapping.mappingType === QueryBasedParameterMappingType.DROPDOWN_SEARCH
}
onChange={mapping =>
onChangeParam({
...param,
parameterMapping: { ...param.parameterMapping, [mappingParam.name]: mapping },
})
}
/>
)}
/>
</Table>
);
}
QueryBasedParameterMappingTable.propTypes = {
param: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
mappingParameters: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
onChangeParam: PropTypes.func,
};
QueryBasedParameterMappingTable.defaultProps = {
mappingParameters: [],
onChangeParam: () => {},
};

View File

@@ -0,0 +1,57 @@
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import Popover from "antd/lib/popover";
import "./index.less";
export default function InputPopover({
header,
content,
children,
okButtonProps,
cancelButtonProps,
onCancel,
onOk,
...props
}) {
return (
<Popover
{...props}
content={
<div className="input-popover-content" data-test="InputPopoverContent">
{header && <header>{header}</header>}
{content}
<footer>
<Button onClick={onCancel} {...cancelButtonProps}>
Cancel
</Button>
<Button onClick={onOk} type="primary" {...okButtonProps}>
OK
</Button>
</footer>
</div>
}>
{children}
</Popover>
);
}
InputPopover.propTypes = {
header: PropTypes.node,
content: PropTypes.node,
children: PropTypes.node,
okButtonProps: PropTypes.object,
cancelButtonProps: PropTypes.object,
onOk: PropTypes.func,
onCancel: PropTypes.func,
};
InputPopover.defaultProps = {
header: null,
children: null,
okButtonProps: null,
cancelButtonProps: null,
onOk: () => {},
onCancel: () => {},
};

View File

@@ -0,0 +1,37 @@
@import "~antd/lib/modal/style/index"; // for ant @vars
.input-popover-content {
width: 390px;
.radio {
display: block;
height: 30px;
line-height: 30px;
}
.form-item {
margin-bottom: 10px;
}
header {
padding: 0 16px 10px;
margin: 0 -16px 20px;
border-bottom: @border-width-base @border-style-base @border-color-split;
font-size: @font-size-lg;
font-weight: 500;
color: @heading-color;
display: flex;
justify-content: space-between;
}
footer {
border-top: @border-width-base @border-style-base @border-color-split;
padding: 10px 16px 0;
margin: 0 -16px;
text-align: right;
button {
margin-left: 8px;
}
}
}

View File

@@ -17,6 +17,7 @@ import ParameterValueInput from "@/components/ParameterValueInput";
import { ParameterMappingType } from "@/services/widget";
import { Parameter, cloneParameter } from "@/services/parameters";
import HelpTrigger from "@/components/HelpTrigger";
import InputPopover from "@/components/InputPopover";
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
@@ -313,43 +314,34 @@ class MappingEditor extends React.Component {
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;
const { visible, mapping, inputError } = this.state;
return (
<Popover
<InputPopover
placement="left"
trigger="click"
content={this.renderContent()}
header={
<>
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
</>
}
content={
<ParameterMappingInput
mapping={mapping}
existingParamNames={this.props.existingParamNames}
onChange={this.onChange}
inputError={inputError}
/>
}
onOk={this.save}
onCancel={this.hide}
okButtonProps={{ disabled: !!inputError }}
visible={visible}
onVisibleChange={this.onVisibleChange}>
<Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}>
<EditOutlinedIcon />
</Button>
</Popover>
</InputPopover>
);
}
}

View File

@@ -1,4 +1,4 @@
@import '~antd/lib/modal/style/index'; // for ant @vars
@import "~antd/lib/modal/style/index"; // for ant @vars
.parameters-mapping-list {
.keyword {
@@ -22,48 +22,13 @@
}
}
.parameter-mapping-editor {
width: 390px;
.radio {
display: block;
height: 30px;
line-height: 30px;
}
.form-item {
margin-bottom: 10px;
}
header {
padding: 0 16px 10px;
margin: 0 -16px 20px;
border-bottom: @border-width-base @border-style-base @border-color-split;
font-size: @font-size-lg;
font-weight: 500;
color: @heading-color;
display: flex;
justify-content: space-between;
}
footer {
border-top: @border-width-base @border-style-base @border-color-split;
padding: 10px 16px 0;
margin: 0 -16px;
text-align: right;
button {
margin-left: 8px;
}
}
}
.parameter-mapping-title {
.text {
margin-right: 3px;
}
&.disabled, .fa {
&.disabled,
.fa {
color: #a4a4a4;
}

View File

@@ -21,7 +21,7 @@
.@{ant-prefix}-input-number,
.@{ant-prefix}-select-selector,
.@{ant-prefix}-picker {
background-color: @input-dirty;
background-color: @input-dirty !important;
}
}
}

View File

@@ -7,7 +7,6 @@ import { Parameter, createParameter } from "@/services/parameters";
import ParameterApplyButton from "@/components/ParameterApplyButton";
import ParameterValueInput from "@/components/ParameterValueInput";
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
import { toHuman } from "@/lib/utils";
import "./Parameters.less";
@@ -121,7 +120,7 @@ export default class Parameters extends React.Component {
return (
<div key={param.name} className="di-block" data-test={`ParameterName-${param.name}`}>
<div className="parameter-heading">
<label>{param.title || toHuman(param.name)}</label>
<label>{param.getTitle()}</label>
{editable && (
<button
className="btn btn-default btn-xs m-l-5"

View File

@@ -1,8 +1,19 @@
import { find, isArray, get, first, map, intersection, isEqual, isEmpty } from "lodash";
import { find, isArray, get, first, map, intersection, isEqual, isEmpty, trim, debounce, isNil } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
const SEARCH_DEBOUNCE_TIME = 300;
function filterValuesThatAreNotInOptions(value, options) {
if (isArray(value)) {
const optionValues = map(options, option => option.value);
return intersection(value, optionValues);
}
const found = find(options, option => option.value === value) !== undefined;
return found ? value : get(first(options), "value");
}
export default class QueryBasedParameterInput extends React.Component {
static propTypes = {
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
@@ -28,6 +39,7 @@ export default class QueryBasedParameterInput extends React.Component {
options: [],
value: null,
loading: false,
currentSearchTerm: null,
};
}
@@ -36,9 +48,10 @@ export default class QueryBasedParameterInput extends React.Component {
}
componentDidUpdate(prevProps) {
if (this.props.queryId !== prevProps.queryId) {
if (this.props.queryId !== prevProps.queryId || this.props.parameter !== prevProps.parameter) {
this._loadOptions(this.props.queryId);
}
if (this.props.value !== prevProps.value) {
this.setValue(this.props.value);
}
@@ -46,54 +59,85 @@ export default class QueryBasedParameterInput extends React.Component {
setValue(value) {
const { options } = this.state;
if (this.props.mode === "multiple") {
const { mode, parameter } = this.props;
if (mode === "multiple") {
if (isNil(value)) {
value = [];
}
value = isArray(value) ? value : [value];
const optionValues = map(options, option => option.value);
const validValues = intersection(value, optionValues);
this.setState({ value: validValues });
return validValues;
}
const found = find(options, option => option.value === this.props.value) !== undefined;
value = found ? value : get(first(options), "value");
// parameters with search don't have options available, so we trust what we get
if (!parameter.searchFunction) {
value = filterValuesThatAreNotInOptions(value, options);
}
this.setState({ value });
return value;
}
updateOptions(options) {
this.setState({ options, loading: false }, () => {
const updatedValue = this.setValue(this.props.value);
if (!isEqual(updatedValue, this.props.value)) {
this.props.onSelect(updatedValue);
}
});
}
async _loadOptions(queryId) {
if (queryId && queryId !== this.state.queryId) {
this.setState({ loading: true });
const options = await this.props.parameter.loadDropdownValues();
const options = await this.props.parameter.loadDropdownValues(this.state.currentSearchTerm);
// stale queryId check
if (this.props.queryId === queryId) {
this.setState({ options, loading: false }, () => {
const updatedValue = this.setValue(this.props.value);
if (!isEqual(updatedValue, this.props.value)) {
this.props.onSelect(updatedValue);
}
});
this.updateOptions(options);
}
}
}
searchFunction = debounce(searchTerm => {
const { parameter } = this.props;
if (parameter.searchFunction && trim(searchTerm)) {
this.setState({ loading: true, currentSearchTerm: searchTerm });
parameter.searchFunction(searchTerm).then(options => {
if (this.state.currentSearchTerm === searchTerm) {
this.updateOptions(options);
}
});
}
}, SEARCH_DEBOUNCE_TIME);
render() {
const { className, mode, onSelect, queryId, value, ...otherProps } = this.props;
const { parameter, className, mode, onSelect, queryId, value, ...otherProps } = this.props;
const { loading, options } = this.state;
const selectProps = { ...otherProps };
if (parameter.searchColumn) {
selectProps.filterOption = false;
selectProps.onSearch = this.searchFunction;
selectProps.onChange = value => onSelect(parameter.normalizeValue(value));
selectProps.notFoundContent = null;
selectProps.labelInValue = true;
}
return (
<span>
<SelectWithVirtualScroll
className={className}
disabled={loading}
disabled={!parameter.searchFunction && loading}
loading={loading}
mode={mode}
value={this.state.value}
value={this.state.value || undefined}
onChange={onSelect}
options={map(options, ({ value, name }) => ({ label: String(name), value }))}
options={options}
optionFilterProp="children"
showSearch
showArrow
notFoundContent={isEmpty(options) ? "No options available" : null}
{...otherProps}
{...selectProps}
/>
</span>
);

View File

@@ -1,4 +1,5 @@
import { isNull, isObject, isFunction, isUndefined, isEqual, has, omit, isArray, each } from "lodash";
import { toHuman } from "@/lib/utils";
class Parameter {
constructor(parameter, parentQueryId) {
@@ -44,6 +45,10 @@ class Parameter {
return this.$$value;
}
getTitle() {
return this.title || toHuman(this.name);
}
isEmptyValue(value) {
return isNull(this.normalizeValue(value));
}

View File

@@ -1,15 +1,70 @@
import { isNull, isUndefined, isArray, isEmpty, get, map, join, has } from "lodash";
import {
isNull,
isUndefined,
isArray,
isEmpty,
get,
map,
join,
has,
toString,
findKey,
mapValues,
pickBy,
filter,
omit,
} from "lodash";
import { Query } from "@/services/query";
import QueryResult from "@/services/query-result";
import Parameter from "./Parameter";
function mapQueryResultToDropdownOptions(options) {
return map(options, ({ label, name, value }) => ({ label: label || name, value: toString(value) }));
}
export const QueryBasedParameterMappingType = {
DROPDOWN_SEARCH: "search",
STATIC: "static",
UNDEFINED: "undefined",
};
function extractOptionLabelsFromValues(values) {
if (!isArray(values)) {
values = [values];
}
const optionLabels = {};
values.forEach(val => {
if (has(val, "label") && has(val, "value")) {
optionLabels[val.value] = val.label;
}
});
return optionLabels;
}
class QueryBasedDropdownParameter extends Parameter {
constructor(parameter, parentQueryId) {
super(parameter, parentQueryId);
this.queryId = parameter.queryId;
this.multiValuesOptions = parameter.multiValuesOptions;
this.parameterMapping = parameter.parameterMapping;
this.$$optionLabels = extractOptionLabelsFromValues(parameter.value);
this.setValue(parameter.value);
}
get searchColumn() {
return findKey(this.parameterMapping, { mappingType: QueryBasedParameterMappingType.DROPDOWN_SEARCH });
}
get staticParams() {
const staticParams = pickBy(
this.parameterMapping,
mapping => mapping.mappingType === QueryBasedParameterMappingType.STATIC
);
return mapValues(staticParams, value => value.staticValue);
}
normalizeValue(value) {
if (isUndefined(value) || isNull(value) || (isArray(value) && isEmpty(value))) {
return null;
@@ -20,24 +75,48 @@ class QueryBasedDropdownParameter extends Parameter {
} else {
value = isArray(value) ? value[0] : value;
}
if (this.searchColumn) {
value = this._getLabeledValue(value);
}
return value;
}
setValue(value) {
if (this.searchColumn) {
value = this._getLabeledValue(value);
}
return super.setValue(value);
}
getExecutionValue(extra = {}) {
const { joinListValues } = extra;
if (joinListValues && isArray(this.value)) {
const separator = get(this.multiValuesOptions, "separator", ",");
const prefix = get(this.multiValuesOptions, "prefix", "");
const suffix = get(this.multiValuesOptions, "suffix", "");
const parameterValues = map(this.value, v => `${prefix}${v}${suffix}`);
return join(parameterValues, separator);
let executionValue = this.value;
if (isArray(executionValue)) {
executionValue = map(executionValue, value => get(value, "value", value));
if (joinListValues) {
const separator = get(this.multiValuesOptions, "separator", ",");
const prefix = get(this.multiValuesOptions, "prefix", "");
const suffix = get(this.multiValuesOptions, "suffix", "");
const parameterValues = map(executionValue, v => `${prefix}${v}${suffix}`);
executionValue = join(parameterValues, separator);
}
return executionValue;
}
return this.value;
executionValue = get(executionValue, "value", executionValue);
return executionValue;
}
toUrlParams() {
const prefix = this.urlPrefix;
if (this.searchColumn) {
return;
}
let urlParam = this.value;
if (this.multiValuesOptions && isArray(this.value)) {
urlParam = JSON.stringify(this.value);
@@ -51,28 +130,80 @@ class QueryBasedDropdownParameter extends Parameter {
fromUrlParams(query) {
const prefix = this.urlPrefix;
const key = `${prefix}${this.name}`;
if (this.searchColumn) {
return;
}
if (has(query, key)) {
const queryKey = query[key];
if (this.multiValuesOptions) {
try {
const valueFromJson = JSON.parse(query[key]);
this.setValue(isArray(valueFromJson) ? valueFromJson : query[key]);
const valueFromJson = JSON.parse(queryKey);
this.setValue(isArray(valueFromJson) ? valueFromJson : queryKey);
} catch (e) {
this.setValue(query[key]);
this.setValue(queryKey);
}
} else {
this.setValue(query[key]);
this.setValue(queryKey);
}
}
}
loadDropdownValues() {
if (this.parentQueryId) {
return Query.associatedDropdown({ queryId: this.parentQueryId, dropdownQueryId: this.queryId }).catch(() =>
Promise.resolve([])
);
}
_saveLabeledValuesFromOptions(options) {
this.$$optionLabels = { ...this.$$optionLabels, ...extractOptionLabelsFromValues(options) };
return options;
}
return Query.asDropdown({ id: this.queryId }).catch(Promise.resolve([]));
_getLabeledValue(value) {
const getSingleLabeledValue = value => {
value = get(value, "value", value);
if (!(value in this.$$optionLabels)) {
return null;
}
return { value, label: this.$$optionLabels[value] };
};
if (isArray(value)) {
value = map(value, getSingleLabeledValue);
return filter(value); // remove values without label
}
return getSingleLabeledValue(value);
}
loadDropdownValues(initialSearchTerm = null) {
return Query.get({ id: this.queryId })
.then(query => {
const queryHasParameters = query.hasParameters();
if (queryHasParameters && this.searchColumn) {
this.searchFunction = searchTerm =>
QueryResult.getByQueryId(query.id, { ...this.staticParams, [this.searchColumn]: searchTerm }, -1)
.toPromise()
.then(result => get(result, "query_result.data.rows"))
.then(mapQueryResultToDropdownOptions)
.then(options => this._saveLabeledValuesFromOptions(options))
.catch(() => Promise.resolve([]));
return initialSearchTerm ? this.searchFunction(initialSearchTerm) : Promise.resolve([]);
} else {
this.searchFunction = null;
}
if (queryHasParameters) {
return QueryResult.getByQueryId(query.id, { ...this.staticParams }, -1)
.toPromise()
.then(result => get(result, "query_result.data.rows"));
} else if (this.parentQueryId) {
return Query.associatedDropdown({ queryId: this.parentQueryId, dropdownQueryId: this.queryId });
}
return Query.asDropdown({ id: this.queryId });
})
.then(mapQueryResultToDropdownOptions)
.catch(() => Promise.resolve([]));
}
toSaveableObject() {
const saveableObject = super.toSaveableObject();
return omit(saveableObject, ["$$optionLabels"]);
}
}

View File

@@ -31,6 +31,18 @@ describe("QueryBasedDropdownParameter", () => {
});
});
describe("getExecutionValue", () => {
test("returns value when stored value doesn't contain its label", () => {
param.setValue("test");
expect(param.getExecutionValue()).toBe("test");
});
test("returns value from object when stored value contains its label", () => {
param.setValue({ label: "Test Label", value: "test" });
expect(param.getExecutionValue()).toBe("test");
});
});
describe("Multi-valued", () => {
beforeAll(() => {
multiValuesOptions = { prefix: '"', suffix: '"', separator: "," };
@@ -44,6 +56,19 @@ describe("QueryBasedDropdownParameter", () => {
});
describe("getExecutionValue", () => {
test("returns value when stored value doesn't contain its label", () => {
param.setValue(["test1", "test2"]);
expect(param.getExecutionValue()).toEqual(["test1", "test2"]);
});
test("returns value from object when stored value contains its label", () => {
param.setValue([
{ label: "Test Label 1", value: "test1" },
{ label: "Test Label 2", value: "test2" },
]);
expect(param.getExecutionValue()).toEqual(["test1", "test2"]);
});
test("joins values when joinListValues is truthy", () => {
param.setValue(["value1", "value3"]);
const executionValue = param.getExecutionValue({ joinListValues: true });

View File

@@ -37,7 +37,7 @@ describe("Parameter Mapping", () => {
};
const saveMappingOptions = () => {
cy.getByTestId("EditParamMappingPopover").within(() => {
cy.getByTestId("InputPopoverContent").within(() => {
cy.contains("button", "OK").click();
});
@@ -90,7 +90,7 @@ describe("Parameter Mapping", () => {
cy.getByTestId("StaticValueOption").click();
cy.getByTestId("EditParamMappingPopover").within(() => {
cy.getByTestId("InputPopoverContent").within(() => {
cy.getByTestId("ParameterValueInput")
.find("input")
.type("{selectall}StaticValue");

View File

@@ -209,7 +209,7 @@ class QueryResultDropdownResource(BaseResource):
)
require_access(query.data_source, current_user, view_only)
try:
return dropdown_values(query_id, self.current_org)
return dropdown_values(query, self.current_org)
except QueryDetachedFromDataSourceError as e:
abort(400, message=str(e))
@@ -224,13 +224,14 @@ class QueryDropdownsResource(BaseResource):
related_queries_ids = [
p["queryId"] for p in query.parameters if p["type"] == "query"
]
dropdown_query = get_object_or_404(
models.Query.get_by_id_and_org, dropdown_query_id, self.current_org
)
if int(dropdown_query_id) not in related_queries_ids:
dropdown_query = get_object_or_404(
models.Query.get_by_id_and_org, dropdown_query_id, self.current_org
)
require_access(dropdown_query.data_source, current_user, view_only)
return dropdown_values(dropdown_query_id, self.current_org)
return dropdown_values(dropdown_query, self.current_org)
class QueryResultResource(BaseResource):

View File

@@ -5,6 +5,7 @@ from redash.utils import mustache_render, json_loads
from redash.permissions import require_access, view_only
from funcy import distinct
from dateutil.parser import parse
from redash import models
def _pluck_name_and_value(default_column, row):
@@ -15,22 +16,18 @@ def _pluck_name_and_value(default_column, row):
return {"name": row[name_column], "value": str(row[value_column])}
def _load_result(query_id, org):
from redash import models
query = models.Query.get_by_id_and_org(query_id, org)
def _load_result(query, org):
if query.data_source:
query_result = models.QueryResult.get_by_id_and_org(
query.latest_query_data_id, org
)
return query_result.data
else:
raise QueryDetachedFromDataSourceError(query_id)
raise QueryDetachedFromDataSourceError(query.id)
def dropdown_values(query_id, org):
data = _load_result(query_id, org)
def dropdown_values(query, org):
data = _load_result(query, org)
first_column = data["columns"][0]["name"]
pluck = partial(_pluck_name_and_value, first_column)
return list(map(pluck, data["rows"]))
@@ -155,6 +152,12 @@ class ParameterizedQuery(object):
query_id = definition.get("queryId")
allow_multiple_values = isinstance(definition.get("multiValuesOptions"), dict)
if definition["type"] == "query":
try:
query = models.Query.get_by_id_and_org(query_id, self.org)
except (models.NoResultFound):
return False
if isinstance(enum_options, str):
enum_options = enum_options.split("\n")
@@ -166,9 +169,11 @@ class ParameterizedQuery(object):
),
"query": lambda value: _is_value_within_options(
value,
[v["value"] for v in dropdown_values(query_id, self.org)],
[v["value"] for v in dropdown_values(query, self.org)],
allow_multiple_values,
),
)
if not query.parameters
else True,
"date": _is_date,
"datetime-local": _is_date,
"datetime-with-seconds": _is_date,
@@ -183,8 +188,18 @@ class ParameterizedQuery(object):
@property
def is_safe(self):
text_parameters = [param for param in self.schema if param["type"] == "text"]
return not any(text_parameters)
for param in self.schema:
if param["type"] == "text":
return False
if param["type"] == "query":
try:
query = models.Query.get_by_id_and_org(
param.get("queryId"), self.org
)
return not query.parameters
except (models.NoResultFound):
return True
return True
@property
def missing_params(self):

View File

@@ -166,7 +166,13 @@ class TestParameterizedQuery(TestCase):
"redash.models.parameterized_query.dropdown_values",
return_value=[{"value": "1"}],
)
def test_validation_accepts_integer_values_for_dropdowns(self, _):
@patch(
"redash.models.Query.get_by_id_and_org",
return_value=namedtuple("Query", ["parameters"])(parameters=[]),
)
def test_validation_accepts_integer_values_for_dropdowns(
self, dpd_values, dpd_query
):
schema = [{"name": "bar", "type": "query", "queryId": 1}]
query = ParameterizedQuery("foo {{bar}}", schema)
@@ -175,7 +181,11 @@ class TestParameterizedQuery(TestCase):
self.assertEqual("foo 1", query.text)
@patch("redash.models.parameterized_query.dropdown_values")
def test_raises_on_invalid_query_parameters(self, _):
@patch(
"redash.models.Query.get_by_id_and_org",
return_value=namedtuple("Query", ["parameters"])(parameters=[]),
)
def test_raises_on_invalid_query_parameters(self, dpd_values, dpd_query):
schema = [{"name": "bar", "type": "query", "queryId": 1}]
query = ParameterizedQuery("foo", schema)
@@ -186,7 +196,11 @@ class TestParameterizedQuery(TestCase):
"redash.models.parameterized_query.dropdown_values",
return_value=[{"value": "baz"}],
)
def test_raises_on_unlisted_query_value_parameters(self, _):
@patch(
"redash.models.Query.get_by_id_and_org",
return_value=namedtuple("Query", ["parameters"])(parameters=[]),
)
def test_raises_on_unlisted_query_value_parameters(self, dpd_values, dpd_query):
schema = [{"name": "bar", "type": "query", "queryId": 1}]
query = ParameterizedQuery("foo", schema)
@@ -197,7 +211,11 @@ class TestParameterizedQuery(TestCase):
"redash.models.parameterized_query.dropdown_values",
return_value=[{"value": "baz"}],
)
def test_validates_query_parameters(self, _):
@patch(
"redash.models.Query.get_by_id_and_org",
return_value=namedtuple("Query", ["parameters"])(parameters=[]),
)
def test_validates_query_parameters(self, dpd_values, dpd_query):
schema = [{"name": "bar", "type": "query", "queryId": 1}]
query = ParameterizedQuery("foo {{bar}}", schema)
@@ -235,6 +253,26 @@ class TestParameterizedQuery(TestCase):
self.assertFalse(query.is_safe)
@patch(
"redash.models.Query.get_by_id_and_org",
return_value=namedtuple("Query", ["parameters"])(parameters=[{"name": "test"}]),
)
def test_is_not_safe_if_expecting_parameterized_query_based_parameter(self, _):
schema = [{"name": "bar", "type": "query"}]
query = ParameterizedQuery("foo", schema)
self.assertFalse(query.is_safe)
@patch(
"redash.models.Query.get_by_id_and_org",
return_value=namedtuple("Query", ["parameters"])(parameters=[]),
)
def test_is_safe_if_expecting_query_based_parameter(self, _):
schema = [{"name": "bar", "type": "query"}]
query = ParameterizedQuery("foo", schema)
self.assertTrue(query.is_safe)
def test_is_safe_if_not_expecting_text_parameter(self):
schema = [{"name": "bar", "type": "number"}]
query = ParameterizedQuery("foo", schema)
@@ -255,7 +293,7 @@ class TestParameterizedQuery(TestCase):
},
)
def test_dropdown_values_prefers_name_and_value_columns(self, _):
values = dropdown_values(1, None)
values = dropdown_values(None, None)
self.assertEqual(values, [{"name": "John", "value": "John Doe"}])
@patch(
@@ -266,7 +304,7 @@ class TestParameterizedQuery(TestCase):
},
)
def test_dropdown_values_compromises_for_first_column(self, _):
values = dropdown_values(1, None)
values = dropdown_values(None, None)
self.assertEqual(values, [{"name": 5, "value": "5"}])
@patch(
@@ -277,13 +315,9 @@ class TestParameterizedQuery(TestCase):
},
)
def test_dropdown_supports_upper_cased_columns(self, _):
values = dropdown_values(1, None)
values = dropdown_values(None, None)
self.assertEqual(values, [{"name": 5, "value": "5"}])
@patch(
"redash.models.Query.get_by_id_and_org",
return_value=namedtuple("Query", "data_source")(None),
)
def test_dropdown_values_raises_when_query_is_detached_from_data_source(self, _):
def test_dropdown_values_raises_when_query_is_detached_from_data_source(self):
with pytest.raises(QueryDetachedFromDataSourceError):
dropdown_values(1, None)
dropdown_values(namedtuple("Query", ["id", "data_source"])(None, None), None)