mirror of
https://github.com/getredash/redash.git
synced 2025-12-19 17:37:19 -05:00
Compare commits
31 Commits
24.03.0-de
...
query-base
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19343a0520 | ||
|
|
c1ed8848f0 | ||
|
|
b40070d7f5 | ||
|
|
bd9ce68f68 | ||
|
|
0c0b62ae1a | ||
|
|
08bcdf77d0 | ||
|
|
aa2064b1ab | ||
|
|
d0a787cab1 | ||
|
|
a741341938 | ||
|
|
53385fa24b | ||
|
|
f396c96457 | ||
|
|
8bfcbf21e3 | ||
|
|
8a1640c4e7 | ||
|
|
a37e7f93dc | ||
|
|
cc34e781d3 | ||
|
|
6aa0ea715e | ||
|
|
6c27619671 | ||
|
|
6eeb3b3eb2 | ||
|
|
d40edb81c2 | ||
|
|
f128b4b85f | ||
|
|
264fb5798d | ||
|
|
90023ac435 | ||
|
|
df755fbc17 | ||
|
|
e555642844 | ||
|
|
bdd7b146ae | ||
|
|
b7478defec | ||
|
|
bb0d7830c9 | ||
|
|
137aa22dd4 | ||
|
|
9cf396599a | ||
|
|
b70f0fa921 | ||
|
|
5e3613d6cb |
@@ -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,10 +105,16 @@ function EditParameterSettingsDialog(props) {
|
||||
}
|
||||
|
||||
// query
|
||||
if (param.type === "query" && !param.queryId) {
|
||||
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
|
||||
@@ -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: () => {},
|
||||
};
|
||||
@@ -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: () => {},
|
||||
};
|
||||
57
client/app/components/InputPopover/index.jsx
Normal file
57
client/app/components/InputPopover/index.jsx
Normal 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: () => {},
|
||||
};
|
||||
37
client/app/components/InputPopover/index.less
Normal file
37
client/app/components/InputPopover/index.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
render() {
|
||||
const { visible, mapping, inputError } = this.state;
|
||||
return (
|
||||
<div className="parameter-mapping-editor" data-test="EditParamMappingPopover">
|
||||
<header>
|
||||
<InputPopover
|
||||
placement="left"
|
||||
trigger="click"
|
||||
header={
|
||||
<>
|
||||
Edit Source and Value <HelpTrigger type="VALUE_SOURCE_OPTIONS" />
|
||||
</header>
|
||||
</>
|
||||
}
|
||||
content={
|
||||
<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()}
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
.@{ant-prefix}-input-number,
|
||||
.@{ant-prefix}-select-selector,
|
||||
.@{ant-prefix}-picker {
|
||||
background-color: @input-dirty;
|
||||
background-color: @input-dirty !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,26 +59,26 @@ export default class QueryBasedParameterInput extends React.Component {
|
||||
|
||||
setValue(value) {
|
||||
const { options } = this.state;
|
||||
if (this.props.mode === "multiple") {
|
||||
value = isArray(value) ? value : [value];
|
||||
const optionValues = map(options, option => option.value);
|
||||
const validValues = intersection(value, optionValues);
|
||||
this.setState({ value: validValues });
|
||||
return validValues;
|
||||
const { mode, parameter } = this.props;
|
||||
|
||||
if (mode === "multiple") {
|
||||
if (isNil(value)) {
|
||||
value = [];
|
||||
}
|
||||
const found = find(options, option => option.value === this.props.value) !== undefined;
|
||||
value = found ? value : get(first(options), "value");
|
||||
|
||||
value = isArray(value) ? value : [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;
|
||||
}
|
||||
|
||||
async _loadOptions(queryId) {
|
||||
if (queryId && queryId !== this.state.queryId) {
|
||||
this.setState({ loading: true });
|
||||
const options = await this.props.parameter.loadDropdownValues();
|
||||
|
||||
// stale queryId check
|
||||
if (this.props.queryId === queryId) {
|
||||
updateOptions(options) {
|
||||
this.setState({ options, loading: false }, () => {
|
||||
const updatedValue = this.setValue(this.props.value);
|
||||
if (!isEqual(updatedValue, this.props.value)) {
|
||||
@@ -73,27 +86,58 @@ export default class QueryBasedParameterInput extends React.Component {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async _loadOptions(queryId) {
|
||||
if (queryId && queryId !== this.state.queryId) {
|
||||
this.setState({ loading: true });
|
||||
const options = await this.props.parameter.loadDropdownValues(this.state.currentSearchTerm);
|
||||
|
||||
// stale queryId check
|
||||
if (this.props.queryId === queryId) {
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
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(this.value, v => `${prefix}${v}${suffix}`);
|
||||
return join(parameterValues, separator);
|
||||
const parameterValues = map(executionValue, v => `${prefix}${v}${suffix}`);
|
||||
executionValue = join(parameterValues, separator);
|
||||
}
|
||||
return this.value;
|
||||
return executionValue;
|
||||
}
|
||||
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
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
|
||||
)
|
||||
|
||||
if int(dropdown_query_id) not in related_queries_ids:
|
||||
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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user