Compare commits

...

6 Commits

Author SHA1 Message Date
restyled-io[bot]
9b3f31bdce Restyle Parameter feedback - #1 Server errors (#6226)
* Restyled by autopep8

* Restyled by black

* Restyled by clang-format

* Restyled by isort

* Restyled by prettier

* Restyled by reorder-python-imports

* Restyled by whitespace

* Restyled by yapf

---------

Co-authored-by: Restyled.io <commits@restyled.io>
2023-07-21 14:40:48 -05:00
Ran Byron
13e5500718 Parameter feedback - #2 Client errors in query page (#4319)
* Parameter feedback - #2 Client errors in query page

* Added cypress test

* Fixed percy screenshot

* Safer touched change

* Parameter feedback - #3 Added in Widgets (#4320)

* Parameter feedback - #3 Added in Widgets

* Added cypress tests

* Making sure widget-level param is selected

* Parameter feedback - #4 Added in Dashboard params (#4321)

* Parameter feedback - #4 Added in Dashboard params

* Added cypress test

* Moved to service

* Parameter feedback - #5 Unsaved indication (#4322)

* Parameter feedback - #5 Unsaved indication

* Added ANGULAR_REMOVE_ME

* Added cypress test

* Fixed percy screenshot

* Some code improvements

* Parameter input feedback - #6 Better value normalization (#4327)
2023-07-21 14:36:53 -05:00
Ran Byron
5213b524b4 Sorting param names in error msgs 2019-10-31 08:41:05 +02:00
Ran Byron
e20b2b5dd3 pep8 2019-10-29 22:08:58 +02:00
Ran Byron
ac77587335 Added unit tests 2019-10-29 22:01:41 +02:00
Ran Byron
c553f006d9 Parameter input feedback - server only 2019-10-29 22:01:33 +02:00
21 changed files with 2198 additions and 1110 deletions

View File

@@ -1,23 +1,22 @@
import { includes, words, capitalize, clone, isNull } from "lodash";
import { includes, words, capitalize, clone, isNull } from 'lodash'; import React, { useState, useEffect } from "react";
import React, { useState, useEffect } from 'react'; import PropTypes from "prop-types";
import PropTypes from 'prop-types'; import Checkbox from "antd/lib/checkbox";
import Checkbox from 'antd/lib/checkbox'; import Modal from "antd/lib/modal";
import Modal from 'antd/lib/modal'; import Form from "antd/lib/form";
import Form from 'antd/lib/form'; import Button from "antd/lib/button";
import Button from 'antd/lib/button'; import Select from "antd/lib/select";
import Select from 'antd/lib/select'; import Input from "antd/lib/input";
import Input from 'antd/lib/input'; import Divider from "antd/lib/divider";
import Divider from 'antd/lib/divider'; import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper'; import { QuerySelector } from "@/components/QuerySelector";
import { QuerySelector } from '@/components/QuerySelector'; import { Query } from "@/services/query";
import { Query } from '@/services/query';
const { Option } = Select; const { Option } = Select;
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } }; const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
function getDefaultTitle(text) { function getDefaultTitle(text) {
return capitalize(words(text).join(' ')); // humanize return capitalize(words(text).join(" ")); // humanize
} }
function isTypeDateRange(type) { function isTypeDateRange(type) {
@@ -26,28 +25,28 @@ function isTypeDateRange(type) {
function joinExampleList(multiValuesOptions) { function joinExampleList(multiValuesOptions) {
const { prefix, suffix } = multiValuesOptions; const { prefix, suffix } = multiValuesOptions;
return ['value1', 'value2', 'value3'] return ["value1", "value2", "value3"]
.map(value => `${prefix}${value}${suffix}`) .map((value) => `${prefix}${value}${suffix}`)
.join(','); .join(",");
} }
function NameInput({ name, type, onChange, existingNames, setValidation }) { function NameInput({ name, type, onChange, existingNames, setValidation }) {
let helpText = ''; let helpText = "";
let validateStatus = ''; let validateStatus = "";
if (!name) { if (!name) {
helpText = 'Choose a keyword for this parameter'; helpText = "Choose a keyword for this parameter";
setValidation(false); setValidation(false);
} else if (includes(existingNames, name)) { } else if (includes(existingNames, name)) {
helpText = 'Parameter with this name already exists'; helpText = "Parameter with this name already exists";
setValidation(false); setValidation(false);
validateStatus = 'error'; validateStatus = "error";
} else { } else {
if (isTypeDateRange(type)) { if (isTypeDateRange(type)) {
helpText = ( helpText = (
<React.Fragment> <React.Fragment>
Appears in query as {' '} Appears in query as{" "}
<code style={{ display: 'inline-block', color: 'inherit' }}> <code style={{ display: "inline-block", color: "inherit" }}>
{`{{${name}.start}} {{${name}.end}}`} {`{{${name}.start}} {{${name}.end}}`}
</code> </code>
</React.Fragment> </React.Fragment>
@@ -64,7 +63,7 @@ function NameInput({ name, type, onChange, existingNames, setValidation }) {
validateStatus={validateStatus} validateStatus={validateStatus}
{...formItemProps} {...formItemProps}
> >
<Input onChange={e => onChange(e.target.value)} autoFocus /> <Input onChange={(e) => onChange(e.target.value)} autoFocus />
</Form.Item> </Form.Item>
); );
} }
@@ -101,12 +100,12 @@ function EditParameterSettingsDialog(props) {
} }
// title // title
if (param.title === '') { if (param.title === "") {
return false; return false;
} }
// query // query
if (param.type === 'query' && !param.queryId) { if (param.type === "query" && !param.queryId) {
return false; return false;
} }
@@ -129,21 +128,29 @@ function EditParameterSettingsDialog(props) {
return ( return (
<Modal <Modal
{...props.dialog.props} {...props.dialog.props}
title={isNew ? 'Add Parameter' : param.name} title={isNew ? "Add Parameter" : param.name}
width={600} width={600}
footer={[( footer={[
<Button key="cancel" onClick={props.dialog.dismiss}>Cancel</Button> <Button key="cancel" onClick={props.dialog.dismiss}>
), ( Cancel
<Button key="submit" htmlType="submit" disabled={!isFulfilled()} type="primary" form="paramForm" data-test="SaveParameterSettings"> </Button>,
{isNew ? 'Add Parameter' : 'OK'} <Button
</Button> key="submit"
)]} htmlType="submit"
disabled={!isFulfilled()}
type="primary"
form="paramForm"
data-test="SaveParameterSettings"
>
{isNew ? "Add Parameter" : "OK"}
</Button>,
]}
> >
<Form layout="horizontal" onSubmit={onConfirm} id="paramForm"> <Form layout="horizontal" onSubmit={onConfirm} id="paramForm">
{isNew && ( {isNew && (
<NameInput <NameInput
name={param.name} name={param.name}
onChange={name => setParam({ ...param, name })} onChange={(name) => setParam({ ...param, name })}
setValidation={setIsNameValid} setValidation={setIsNameValid}
existingNames={props.existingParams} existingNames={props.existingParams}
type={param.type} type={param.type}
@@ -151,88 +158,141 @@ function EditParameterSettingsDialog(props) {
)} )}
<Form.Item label="Title" {...formItemProps}> <Form.Item label="Title" {...formItemProps}>
<Input <Input
value={isNull(param.title) ? getDefaultTitle(param.name) : param.title} value={
onChange={e => setParam({ ...param, title: e.target.value })} isNull(param.title) ? getDefaultTitle(param.name) : param.title
}
onChange={(e) => setParam({ ...param, title: e.target.value })}
data-test="ParameterTitleInput" data-test="ParameterTitleInput"
/> />
</Form.Item> </Form.Item>
<Form.Item label="Type" {...formItemProps}> <Form.Item label="Type" {...formItemProps}>
<Select value={param.type} onChange={type => setParam({ ...param, type })} data-test="ParameterTypeSelect"> <Select
<Option value="text" data-test="TextParameterTypeOption">Text</Option> value={param.type}
<Option value="number" data-test="NumberParameterTypeOption">Number</Option> onChange={(type) => setParam({ ...param, type })}
data-test="ParameterTypeSelect"
>
<Option value="text" data-test="TextParameterTypeOption">
Text
</Option>
<Option value="number" data-test="NumberParameterTypeOption">
Number
</Option>
<Option value="enum">Dropdown List</Option> <Option value="enum">Dropdown List</Option>
<Option value="query">Query Based Dropdown List</Option> <Option value="query">Query Based Dropdown List</Option>
<Option disabled key="dv1"> <Option disabled key="dv1">
<Divider className="select-option-divider" /> <Divider className="select-option-divider" />
</Option> </Option>
<Option value="date" data-test="DateParameterTypeOption">Date</Option> <Option value="date" data-test="DateParameterTypeOption">
<Option value="datetime-local" data-test="DateTimeParameterTypeOption">Date and Time</Option> Date
<Option value="datetime-with-seconds">Date and Time (with seconds)</Option> </Option>
<Option
value="datetime-local"
data-test="DateTimeParameterTypeOption"
>
Date and Time
</Option>
<Option value="datetime-with-seconds">
Date and Time (with seconds)
</Option>
<Option disabled key="dv2"> <Option disabled key="dv2">
<Divider className="select-option-divider" /> <Divider className="select-option-divider" />
</Option> </Option>
<Option value="date-range" data-test="DateRangeParameterTypeOption">Date Range</Option> <Option value="date-range" data-test="DateRangeParameterTypeOption">
Date Range
</Option>
<Option value="datetime-range">Date and Time Range</Option> <Option value="datetime-range">Date and Time Range</Option>
<Option value="datetime-range-with-seconds">Date and Time Range (with seconds)</Option> <Option value="datetime-range-with-seconds">
Date and Time Range (with seconds)
</Option>
</Select> </Select>
</Form.Item> </Form.Item>
{param.type === 'enum' && ( {param.type === "enum" && (
<Form.Item label="Values" help="Dropdown list values (newline delimited)" {...formItemProps}> <Form.Item
label="Values"
help="Dropdown list values (newline delimited)"
{...formItemProps}
>
<Input.TextArea <Input.TextArea
data-test="EnumTextArea"
rows={3} rows={3}
value={param.enumOptions} value={param.enumOptions}
onChange={e => setParam({ ...param, enumOptions: e.target.value })} onChange={(e) =>
setParam({ ...param, enumOptions: e.target.value })
}
/> />
</Form.Item> </Form.Item>
)} )}
{param.type === 'query' && ( {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"
{...formItemProps}
>
<QuerySelector <QuerySelector
selectedQuery={initialQuery} selectedQuery={initialQuery}
onChange={q => setParam({ ...param, queryId: q && q.id })} onChange={(q) => setParam({ ...param, queryId: q && q.id })}
type="select" type="select"
/> />
</Form.Item> </Form.Item>
)} )}
{(param.type === 'enum' || param.type === 'query') && ( {(param.type === "enum" || param.type === "query") && (
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}> <Form.Item
className="m-b-0"
label=" "
colon={false}
{...formItemProps}
>
<Checkbox <Checkbox
defaultChecked={!!param.multiValuesOptions} defaultChecked={!!param.multiValuesOptions}
onChange={e => setParam({ ...param, onChange={(e) =>
multiValuesOptions: e.target.checked ? { setParam({
prefix: '', ...param,
suffix: '', multiValuesOptions: e.target.checked
separator: ',', ? {
} : null })} prefix: "",
suffix: "",
separator: ",",
}
: null,
})
}
data-test="AllowMultipleValuesCheckbox" data-test="AllowMultipleValuesCheckbox"
> >
Allow multiple values Allow multiple values
</Checkbox> </Checkbox>
</Form.Item> </Form.Item>
)} )}
{(param.type === 'enum' || param.type === 'query') && param.multiValuesOptions && ( {(param.type === "enum" || param.type === "query") &&
param.multiValuesOptions && (
<Form.Item <Form.Item
label="Quotation" label="Quotation"
help={( help={
<React.Fragment> <React.Fragment>
Placed in query as: <code>{joinExampleList(param.multiValuesOptions)}</code> Placed in query as:{" "}
<code>{joinExampleList(param.multiValuesOptions)}</code>
</React.Fragment> </React.Fragment>
)} }
{...formItemProps} {...formItemProps}
> >
<Select <Select
value={param.multiValuesOptions.prefix} value={param.multiValuesOptions.prefix}
onChange={quoteOption => setParam({ ...param, onChange={(quoteOption) =>
setParam({
...param,
multiValuesOptions: { multiValuesOptions: {
...param.multiValuesOptions, ...param.multiValuesOptions,
prefix: quoteOption, prefix: quoteOption,
suffix: quoteOption, suffix: quoteOption,
} })} },
})
}
data-test="QuotationSelect" data-test="QuotationSelect"
> >
<Option value="">None (default)</Option> <Option value="">None (default)</Option>
<Option value="'">Single Quotation Mark</Option> <Option value="'">Single Quotation Mark</Option>
<Option value={'"'} data-test="DoubleQuotationMarkOption">Double Quotation Mark</Option> <Option value={'"'} data-test="DoubleQuotationMarkOption">
Double Quotation Mark
</Option>
</Select> </Select>
</Form.Item> </Form.Item>
)} )}

View File

@@ -1,21 +1,21 @@
import React from 'react'; import React from "react";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import Select from 'antd/lib/select'; import Select from "antd/lib/select";
import Input from 'antd/lib/input'; import Input from "antd/lib/input";
import InputNumber from 'antd/lib/input-number'; import InputNumber from "antd/lib/input-number";
import DateParameter from '@/components/dynamic-parameters/DateParameter'; import DateParameter from "@/components/dynamic-parameters/DateParameter";
import DateRangeParameter from '@/components/dynamic-parameters/DateRangeParameter'; import DateRangeParameter from "@/components/dynamic-parameters/DateRangeParameter";
import { isEqual } from 'lodash'; import { isEqual, trim } from "lodash";
import { QueryBasedParameterInput } from './QueryBasedParameterInput'; import { QueryBasedParameterInput } from "./QueryBasedParameterInput";
import './ParameterValueInput.less'; import "./ParameterValueInput.less";
const { Option } = Select; const { Option } = Select;
const multipleValuesProps = { const multipleValuesProps = {
maxTagCount: 3, maxTagCount: 3,
maxTagTextLength: 10, maxTagTextLength: 10,
maxTagPlaceholder: num => `+${num.length} more`, maxTagPlaceholder: (num) => `+${num.length} more`,
}; };
class ParameterValueInput extends React.Component { class ParameterValueInput extends React.Component {
@@ -30,19 +30,21 @@ class ParameterValueInput extends React.Component {
}; };
static defaultProps = { static defaultProps = {
type: 'text', type: "text",
value: null, value: null,
enumOptions: '', enumOptions: "",
queryId: null, queryId: null,
parameter: null, parameter: null,
onSelect: () => {}, onSelect: () => {},
className: '', className: "",
}; };
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
value: props.parameter.hasPendingValue ? props.parameter.pendingValue : props.value, value: props.parameter.hasPendingValue
? props.parameter.pendingValue
: props.value,
isDirty: props.parameter.hasPendingValue, isDirty: props.parameter.hasPendingValue,
}; };
} }
@@ -56,13 +58,13 @@ class ParameterValueInput extends React.Component {
isDirty: parameter.hasPendingValue, isDirty: parameter.hasPendingValue,
}); });
} }
} };
onSelect = (value) => { onSelect = (value) => {
const isDirty = !isEqual(value, this.props.value); const isDirty = !isEqual(trim(value), trim(this.props.value));
this.setState({ value, isDirty }); this.setState({ value, isDirty });
this.props.onSelect(value, isDirty); this.props.onSelect(value, isDirty);
} };
renderDateParameter() { renderDateParameter() {
const { type, parameter } = this.props; const { type, parameter } = this.props;
@@ -95,13 +97,14 @@ class ParameterValueInput extends React.Component {
renderEnumInput() { renderEnumInput() {
const { enumOptions, parameter } = this.props; const { enumOptions, parameter } = this.props;
const { value } = this.state; const { value } = this.state;
const enumOptionsArray = enumOptions.split('\n').filter(v => v !== ''); const enumOptionsArray = enumOptions.split("\n").filter((v) => v !== "");
// Antd Select doesn't handle null in multiple mode // Antd Select doesn't handle null in multiple mode
const normalize = val => (parameter.multiValuesOptions && val === null ? [] : val); const normalize = (val) =>
parameter.multiValuesOptions && val === null ? [] : val;
return ( return (
<Select <Select
className={this.props.className} className={this.props.className}
mode={parameter.multiValuesOptions ? 'multiple' : 'default'} mode={parameter.multiValuesOptions ? "multiple" : "default"}
optionFilterProp="children" optionFilterProp="children"
disabled={enumOptionsArray.length === 0} disabled={enumOptionsArray.length === 0}
value={normalize(value)} value={normalize(value)}
@@ -113,7 +116,11 @@ class ParameterValueInput extends React.Component {
notFoundContent={null} notFoundContent={null}
{...multipleValuesProps} {...multipleValuesProps}
> >
{enumOptionsArray.map(option => (<Option key={option} value={option}>{ option }</Option>))} {enumOptionsArray.map((option) => (
<Option key={option} value={option}>
{option}
</Option>
))}
</Select> </Select>
); );
} }
@@ -124,7 +131,7 @@ class ParameterValueInput extends React.Component {
return ( return (
<QueryBasedParameterInput <QueryBasedParameterInput
className={this.props.className} className={this.props.className}
mode={parameter.multiValuesOptions ? 'multiple' : 'default'} mode={parameter.multiValuesOptions ? "multiple" : "default"}
optionFilterProp="children" optionFilterProp="children"
parameter={parameter} parameter={parameter}
value={value} value={value}
@@ -140,13 +147,11 @@ class ParameterValueInput extends React.Component {
const { className } = this.props; const { className } = this.props;
const { value } = this.state; const { value } = this.state;
const normalize = val => (isNaN(val) ? undefined : val);
return ( return (
<InputNumber <InputNumber
className={className} className={className}
value={normalize(value)} value={value}
onChange={val => this.onSelect(normalize(val))} onChange={(val) => this.onSelect(val)}
/> />
); );
} }
@@ -160,7 +165,7 @@ class ParameterValueInput extends React.Component {
className={className} className={className}
value={value} value={value}
data-test="TextParamInput" data-test="TextParamInput"
onChange={e => this.onSelect(e.target.value)} onChange={(e) => this.onSelect(e.target.value)}
/> />
); );
} }
@@ -168,16 +173,22 @@ class ParameterValueInput extends React.Component {
renderInput() { renderInput() {
const { type } = this.props; const { type } = this.props;
switch (type) { switch (type) {
case 'datetime-with-seconds': case "datetime-with-seconds":
case 'datetime-local': case "datetime-local":
case 'date': return this.renderDateParameter(); case "date":
case 'datetime-range-with-seconds': return this.renderDateParameter();
case 'datetime-range': case "datetime-range-with-seconds":
case 'date-range': return this.renderDateRangeParameter(); case "datetime-range":
case 'enum': return this.renderEnumInput(); case "date-range":
case 'query': return this.renderQueryBasedInput(); return this.renderDateRangeParameter();
case 'number': return this.renderNumberInput(); case "enum":
default: return this.renderTextInput(); return this.renderEnumInput();
case "query":
return this.renderQueryBasedInput();
case "number":
return this.renderNumberInput();
default:
return this.renderTextInput();
} }
} }
@@ -185,7 +196,11 @@ class ParameterValueInput extends React.Component {
const { isDirty } = this.state; const { isDirty } = this.state;
return ( return (
<div className="parameter-input" data-dirty={isDirty || null} data-test="ParameterValueInput"> <div
className="parameter-input"
data-dirty={isDirty || null}
data-test="ParameterValueInput"
>
{this.renderInput()} {this.renderInput()}
</div> </div>
); );

View File

@@ -1,23 +1,31 @@
import React from 'react'; import React from "react";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import { size, filter, forEach, extend } from 'lodash'; import { size, filter, forEach, extend, get, includes } from "lodash";
import { react2angular } from 'react2angular'; import { react2angular } from "react2angular";
import { SortableContainer, SortableElement, DragHandle } from '@/components/sortable'; import {
import { $location } from '@/services/ng'; SortableContainer,
import { Parameter } from '@/services/parameters'; SortableElement,
import ParameterApplyButton from '@/components/ParameterApplyButton'; DragHandle,
import ParameterValueInput from '@/components/ParameterValueInput'; } from "@/components/sortable";
import EditParameterSettingsDialog from './EditParameterSettingsDialog'; import { $location } from "@/services/ng";
import { toHuman } from '@/filters'; import { Parameter } from "@/services/parameters";
import ParameterApplyButton from "@/components/ParameterApplyButton";
import ParameterValueInput from "@/components/ParameterValueInput";
import Form from "antd/lib/form";
import Tooltip from "antd/lib/tooltip";
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
import { toHuman } from "@/filters";
import './Parameters.less'; import "./Parameters.less";
function updateUrl(parameters) { function updateUrl(parameters) {
const params = extend({}, $location.search()); const params = extend({}, $location.search());
parameters.forEach((param) => { parameters.forEach((param) => {
extend(params, param.toUrlParams()); extend(params, param.toUrlParams());
}); });
Object.keys(params).forEach(key => params[key] == null && delete params[key]); Object.keys(params).forEach(
(key) => params[key] == null && delete params[key]
);
$location.search(params); $location.search(params);
} }
@@ -29,6 +37,10 @@ export class Parameters extends React.Component {
onValuesChange: PropTypes.func, onValuesChange: PropTypes.func,
onPendingValuesChange: PropTypes.func, onPendingValuesChange: PropTypes.func,
onParametersEdit: PropTypes.func, onParametersEdit: PropTypes.func,
queryResultErrorData: PropTypes.shape({
parameters: PropTypes.objectOf(PropTypes.string),
}),
unsavedParameters: PropTypes.arrayOf(PropTypes.string),
}; };
static defaultProps = { static defaultProps = {
@@ -38,25 +50,36 @@ export class Parameters extends React.Component {
onValuesChange: () => {}, onValuesChange: () => {},
onPendingValuesChange: () => {}, onPendingValuesChange: () => {},
onParametersEdit: () => {}, onParametersEdit: () => {},
queryResultErrorData: {},
unsavedParameters: null,
}; };
constructor(props) { constructor(props) {
super(props); super(props);
const { parameters } = props; const { parameters } = props;
this.state = { parameters }; this.state = {
parameters,
touched: {},
};
if (!props.disableUrlUpdate) { if (!props.disableUrlUpdate) {
updateUrl(parameters); updateUrl(parameters);
} }
} }
componentDidUpdate = (prevProps) => { componentDidUpdate = (prevProps) => {
const { parameters, disableUrlUpdate } = this.props; const { parameters, disableUrlUpdate, queryResultErrorData } = this.props;
if (prevProps.parameters !== parameters) { if (prevProps.parameters !== parameters) {
this.setState({ parameters }); this.setState({ parameters });
if (!disableUrlUpdate) { if (!disableUrlUpdate) {
updateUrl(parameters); updateUrl(parameters);
} }
} }
// reset touched flags on new error data
if (prevProps.queryResultErrorData !== queryResultErrorData) {
this.setState({ touched: {} });
}
}; };
handleKeyDown = (e) => { handleKeyDown = (e) => {
@@ -69,14 +92,15 @@ export class Parameters extends React.Component {
setPendingValue = (param, value, isDirty) => { setPendingValue = (param, value, isDirty) => {
const { onPendingValuesChange } = this.props; const { onPendingValuesChange } = this.props;
this.setState(({ parameters }) => { this.setState(({ parameters, touched }) => {
if (isDirty) { if (isDirty) {
param.setPendingValue(value); param.setPendingValue(value);
touched = { ...touched, [param.name]: true };
} else { } else {
param.clearPendingValue(); param.clearPendingValue();
} }
onPendingValuesChange(); onPendingValuesChange();
return { parameters }; return { parameters, touched };
}); });
}; };
@@ -94,8 +118,10 @@ export class Parameters extends React.Component {
applyChanges = () => { applyChanges = () => {
const { onValuesChange, disableUrlUpdate } = this.props; const { onValuesChange, disableUrlUpdate } = this.props;
this.setState(({ parameters }) => { this.setState(({ parameters }) => {
const parametersWithPendingValues = parameters.filter(p => p.hasPendingValue); const parametersWithPendingValues = parameters.filter(
forEach(parameters, p => p.applyPendingValue()); (p) => p.hasPendingValue
);
forEach(parameters, (p) => p.applyPendingValue());
if (!disableUrlUpdate) { if (!disableUrlUpdate) {
updateUrl(parameters); updateUrl(parameters);
} }
@@ -106,20 +132,53 @@ export class Parameters extends React.Component {
showParameterSettings = (parameter, index) => { showParameterSettings = (parameter, index) => {
const { onParametersEdit } = this.props; const { onParametersEdit } = this.props;
EditParameterSettingsDialog EditParameterSettingsDialog.showModal({ parameter }).result.then(
.showModal({ parameter }) (updated) => {
.result.then((updated) => { this.setState(({ parameters, touched }) => {
this.setState(({ parameters }) => { touched = { ...touched, [parameter.name]: true };
const updatedParameter = extend(parameter, updated); const updatedParameter = extend(parameter, updated);
parameters[index] = Parameter.create(updatedParameter, updatedParameter.parentQueryId); parameters[index] = Parameter.create(
updatedParameter,
updatedParameter.parentQueryId
);
onParametersEdit(); onParametersEdit();
return { parameters }; return { parameters, touched };
});
}); });
}
);
};
getParameterFeedback = (param) => {
// error msg
const { queryResultErrorData } = this.props;
const error = get(queryResultErrorData, ["parameters", param.name], false);
if (error) {
const feedback = <Tooltip title={error}>{error}</Tooltip>;
return [feedback, "error"];
}
// unsaved
const { unsavedParameters } = this.props;
if (includes(unsavedParameters, param.name)) {
const feedback = (
<>
Unsaved{" "}
<Tooltip title='Click the "Save" button to preserve this parameter.'>
<i className="fa fa-question-circle" />
</Tooltip>
</>
);
return [feedback, "warning"];
}
return [];
}; };
renderParameter(param, index) { renderParameter(param, index) {
const { editable } = this.props; const { editable } = this.props;
const touched = this.state.touched[param.name];
const [feedback, status] = this.getParameterFeedback(param);
return ( return (
<div <div
key={param.name} key={param.name}
@@ -139,14 +198,21 @@ export class Parameters extends React.Component {
</button> </button>
)} )}
</div> </div>
<Form.Item
validateStatus={touched ? "" : status}
help={feedback || null}
>
<ParameterValueInput <ParameterValueInput
type={param.type} type={param.type}
value={param.normalizedValue} value={param.normalizedValue}
parameter={param} parameter={param}
enumOptions={param.enumOptions} enumOptions={param.enumOptions}
queryId={param.queryId} queryId={param.queryId}
onSelect={(value, isDirty) => this.setPendingValue(param, value, isDirty)} onSelect={(value, isDirty) =>
this.setPendingValue(param, value, isDirty)
}
/> />
</Form.Item>
</div> </div>
); );
} }
@@ -154,7 +220,7 @@ export class Parameters extends React.Component {
render() { render() {
const { parameters } = this.state; const { parameters } = this.state;
const { editable } = this.props; const { editable } = this.props;
const dirtyParamCount = size(filter(parameters, 'hasPendingValue')); const dirtyParamCount = size(filter(parameters, "hasPendingValue"));
return ( return (
<SortableContainer <SortableContainer
disabled={!editable} disabled={!editable}
@@ -165,26 +231,31 @@ export class Parameters extends React.Component {
updateBeforeSortStart={this.onBeforeSortStart} updateBeforeSortStart={this.onBeforeSortStart}
onSortEnd={this.moveParameter} onSortEnd={this.moveParameter}
containerProps={{ containerProps={{
className: 'parameter-container', className: "parameter-container",
onKeyDown: dirtyParamCount ? this.handleKeyDown : null, onKeyDown: dirtyParamCount ? this.handleKeyDown : null,
}} }}
> >
{parameters.map((param, index) => ( {parameters.map((param, index) => (
<SortableElement key={param.name} index={index}> <SortableElement key={param.name} index={index}>
<div className="parameter-block" data-editable={editable || null}> <div className="parameter-block" data-editable={editable || null}>
{editable && <DragHandle data-test={`DragHandle-${param.name}`} />} {editable && (
<DragHandle data-test={`DragHandle-${param.name}`} />
)}
{this.renderParameter(param, index)} {this.renderParameter(param, index)}
</div> </div>
</SortableElement> </SortableElement>
))} ))}
<ParameterApplyButton onClick={this.applyChanges} paramCount={dirtyParamCount} /> <ParameterApplyButton
onClick={this.applyChanges}
paramCount={dirtyParamCount}
/>
</SortableContainer> </SortableContainer>
); );
} }
} }
export default function init(ngModule) { export default function init(ngModule) {
ngModule.component('parameters', react2angular(Parameters)); ngModule.component("parameters", react2angular(Parameters));
} }
init.init = true; init.init = true;

View File

@@ -3,7 +3,7 @@
.parameter-block { .parameter-block {
display: inline-block; display: inline-block;
background: white; background: white;
padding: 0 12px 6px 0; padding: 0 12px 17px 0;
vertical-align: top; vertical-align: top;
z-index: 1; z-index: 1;
@@ -15,12 +15,31 @@
.parameter-container.sortable-container & { .parameter-container.sortable-container & {
margin: 4px 0 0 4px; margin: 4px 0 0 4px;
padding: 3px 6px 6px; padding: 3px 6px 19px;
} }
&.parameter-dragged { &.parameter-dragged {
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15); box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
} }
.ant-form-item {
margin-bottom: 0 !important;
}
.ant-form-explain {
position: absolute;
left: 0;
right: 0;
bottom: -20px;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ant-form-item-control {
line-height: normal;
}
} }
.parameter-heading { .parameter-heading {

View File

@@ -1,61 +1,82 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import { compact, isEmpty, invoke } from 'lodash'; import { compact, isEmpty, invoke } from "lodash";
import { markdown } from 'markdown'; import { markdown } from "markdown";
import cx from 'classnames'; import cx from "classnames";
import Menu from 'antd/lib/menu'; import Menu from "antd/lib/menu";
import { currentUser } from '@/services/auth'; import { currentUser } from "@/services/auth";
import recordEvent from '@/services/recordEvent'; import recordEvent from "@/services/recordEvent";
import { formatDateTime } from '@/filters/datetime'; import { formatDateTime } from "@/filters/datetime";
import HtmlContent from '@/components/HtmlContent'; import HtmlContent from "@/components/HtmlContent";
import { Parameters } from '@/components/Parameters'; import { Parameters } from "@/components/Parameters";
import { TimeAgo } from '@/components/TimeAgo'; import { TimeAgo } from "@/components/TimeAgo";
import { Timer } from '@/components/Timer'; import { Timer } from "@/components/Timer";
import { Moment } from '@/components/proptypes'; import { Moment } from "@/components/proptypes";
import QueryLink from '@/components/QueryLink'; import QueryLink from "@/components/QueryLink";
import { FiltersType } from '@/components/Filters'; import { FiltersType } from "@/components/Filters";
import ExpandedWidgetDialog from '@/components/dashboards/ExpandedWidgetDialog'; import ExpandedWidgetDialog from "@/components/dashboards/ExpandedWidgetDialog";
import EditParameterMappingsDialog from '@/components/dashboards/EditParameterMappingsDialog'; import EditParameterMappingsDialog from "@/components/dashboards/EditParameterMappingsDialog";
import { VisualizationRenderer } from '@/visualizations/VisualizationRenderer'; import { VisualizationRenderer } from "@/visualizations/VisualizationRenderer";
import Widget from './Widget'; import Widget from "./Widget";
function visualizationWidgetMenuOptions({ widget, canEditDashboard, onParametersEdit }) { function visualizationWidgetMenuOptions({
const canViewQuery = currentUser.hasPermission('view_query'); widget,
const canEditParameters = canEditDashboard && !isEmpty(invoke(widget, 'query.getParametersDefs')); canEditDashboard,
onParametersEdit,
}) {
const canViewQuery = currentUser.hasPermission("view_query");
const canEditParameters =
canEditDashboard && !isEmpty(invoke(widget, "query.getParametersDefs"));
const widgetQueryResult = widget.getQueryResult(); const widgetQueryResult = widget.getQueryResult();
const isQueryResultEmpty = !widgetQueryResult || !widgetQueryResult.isEmpty || widgetQueryResult.isEmpty(); const isQueryResultEmpty =
!widgetQueryResult ||
!widgetQueryResult.isEmpty ||
widgetQueryResult.isEmpty();
const downloadLink = fileType => widgetQueryResult.getLink(widget.getQuery().id, fileType); const downloadLink = (fileType) =>
const downloadName = fileType => widgetQueryResult.getName(widget.getQuery().name, fileType); widgetQueryResult.getLink(widget.getQuery().id, fileType);
const downloadName = (fileType) =>
widgetQueryResult.getName(widget.getQuery().name, fileType);
return compact([ return compact([
<Menu.Item key="download_csv" disabled={isQueryResultEmpty}> <Menu.Item key="download_csv" disabled={isQueryResultEmpty}>
{!isQueryResultEmpty ? ( {!isQueryResultEmpty ? (
<a href={downloadLink('csv')} download={downloadName('csv')} target="_self"> <a
href={downloadLink("csv")}
download={downloadName("csv")}
target="_self"
>
Download as CSV File Download as CSV File
</a> </a>
) : 'Download as CSV File'} ) : (
"Download as CSV File"
)}
</Menu.Item>, </Menu.Item>,
<Menu.Item key="download_excel" disabled={isQueryResultEmpty}> <Menu.Item key="download_excel" disabled={isQueryResultEmpty}>
{!isQueryResultEmpty ? ( {!isQueryResultEmpty ? (
<a href={downloadLink('xlsx')} download={downloadName('xlsx')} target="_self"> <a
href={downloadLink("xlsx")}
download={downloadName("xlsx")}
target="_self"
>
Download as Excel File Download as Excel File
</a> </a>
) : 'Download as Excel File'} ) : (
"Download as Excel File"
)}
</Menu.Item>, </Menu.Item>,
((canViewQuery || canEditParameters) && <Menu.Divider key="divider" />), (canViewQuery || canEditParameters) && <Menu.Divider key="divider" />,
canViewQuery && ( canViewQuery && (
<Menu.Item key="view_query"> <Menu.Item key="view_query">
<a href={widget.getQuery().getUrl(true, widget.visualization.id)}>View Query</a> <a href={widget.getQuery().getUrl(true, widget.visualization.id)}>
View Query
</a>
</Menu.Item> </Menu.Item>
), ),
(canEditParameters && ( canEditParameters && (
<Menu.Item <Menu.Item key="edit_parameters" onClick={onParametersEdit}>
key="edit_parameters"
onClick={onParametersEdit}
>
Edit Parameters Edit Parameters
</Menu.Item> </Menu.Item>
)), ),
]); ]);
} }
@@ -73,8 +94,15 @@ function RefreshIndicator({ refreshStartedAt }) {
RefreshIndicator.propTypes = { refreshStartedAt: Moment }; RefreshIndicator.propTypes = { refreshStartedAt: Moment };
RefreshIndicator.defaultProps = { refreshStartedAt: null }; RefreshIndicator.defaultProps = { refreshStartedAt: null };
function VisualizationWidgetHeader({ widget, refreshStartedAt, parameters, onParametersUpdate }) { function VisualizationWidgetHeader({
const canViewQuery = currentUser.hasPermission('view_query'); widget,
refreshStartedAt,
parameters,
onParametersUpdate,
}) {
const canViewQuery = currentUser.hasPermission("view_query");
const queryResult = widget.getQueryResult();
const errorData = queryResult && queryResult.getErrorData();
return ( return (
<> <>
@@ -82,16 +110,24 @@ function VisualizationWidgetHeader({ widget, refreshStartedAt, parameters, onPar
<div className="t-header widget clearfix"> <div className="t-header widget clearfix">
<div className="th-title"> <div className="th-title">
<p> <p>
<QueryLink query={widget.getQuery()} visualization={widget.visualization} readOnly={!canViewQuery} /> <QueryLink
query={widget.getQuery()}
visualization={widget.visualization}
readOnly={!canViewQuery}
/>
</p> </p>
<HtmlContent className="text-muted markdown query--description"> <HtmlContent className="text-muted markdown query--description">
{markdown.toHTML(widget.getQuery().description || '')} {markdown.toHTML(widget.getQuery().description || "")}
</HtmlContent> </HtmlContent>
</div> </div>
</div> </div>
{!isEmpty(parameters) && ( {!isEmpty(parameters) && (
<div className="m-b-10"> <div className="m-b-5">
<Parameters parameters={parameters} onValuesChange={onParametersUpdate} /> <Parameters
parameters={parameters}
queryResultErrorData={errorData}
onValuesChange={onParametersUpdate}
/>
</div> </div>
)} )}
</> </>
@@ -113,7 +149,7 @@ VisualizationWidgetHeader.defaultProps = {
function VisualizationWidgetFooter({ widget, isPublic, onRefresh, onExpand }) { function VisualizationWidgetFooter({ widget, isPublic, onRefresh, onExpand }) {
const widgetQueryResult = widget.getQueryResult(); const widgetQueryResult = widget.getQueryResult();
const updatedAt = invoke(widgetQueryResult, 'getUpdatedAt'); const updatedAt = invoke(widgetQueryResult, "getUpdatedAt");
const [refreshClickButtonId, setRefreshClickButtonId] = useState(); const [refreshClickButtonId, setRefreshClickButtonId] = useState();
const refreshWidget = (buttonId) => { const refreshWidget = (buttonId) => {
@@ -126,22 +162,27 @@ function VisualizationWidgetFooter({ widget, isPublic, onRefresh, onExpand }) {
return ( return (
<> <>
<span> <span>
{(!isPublic && !!widgetQueryResult) && ( {!isPublic && !!widgetQueryResult && (
<a <a
className="refresh-button hidden-print btn btn-sm btn-default btn-transparent" className="refresh-button hidden-print btn btn-sm btn-default btn-transparent"
onClick={() => refreshWidget(1)} onClick={() => refreshWidget(1)}
data-test="RefreshButton" data-test="RefreshButton"
> >
<i className={cx('zmdi zmdi-refresh', { 'zmdi-hc-spin': refreshClickButtonId === 1 })} />{' '} <i
className={cx("zmdi zmdi-refresh", {
"zmdi-hc-spin": refreshClickButtonId === 1,
})}
/>{" "}
<TimeAgo date={updatedAt} /> <TimeAgo date={updatedAt} />
</a> </a>
)} )}
<span className="visible-print"> <span className="visible-print">
<i className="zmdi zmdi-time-restore" />{' '}{formatDateTime(updatedAt)} <i className="zmdi zmdi-time-restore" /> {formatDateTime(updatedAt)}
</span> </span>
{isPublic && ( {isPublic && (
<span className="small hidden-print"> <span className="small hidden-print">
<i className="zmdi zmdi-time-restore" />{' '}<TimeAgo date={updatedAt} /> <i className="zmdi zmdi-time-restore" />{" "}
<TimeAgo date={updatedAt} />
</span> </span>
)} )}
</span> </span>
@@ -151,7 +192,11 @@ function VisualizationWidgetFooter({ widget, isPublic, onRefresh, onExpand }) {
className="btn btn-sm btn-default hidden-print btn-transparent btn__refresh" className="btn btn-sm btn-default hidden-print btn-transparent btn__refresh"
onClick={() => refreshWidget(2)} onClick={() => refreshWidget(2)}
> >
<i className={cx('zmdi zmdi-refresh', { 'zmdi-hc-spin': refreshClickButtonId === 2 })} /> <i
className={cx("zmdi zmdi-refresh", {
"zmdi-hc-spin": refreshClickButtonId === 2,
})}
/>
</a> </a>
)} )}
<a <a
@@ -204,8 +249,12 @@ class VisualizationWidget extends React.Component {
componentDidMount() { componentDidMount() {
const { widget, onLoad } = this.props; const { widget, onLoad } = this.props;
recordEvent('view', 'query', widget.visualization.query.id, { dashboard: true }); recordEvent("view", "query", widget.visualization.query.id, {
recordEvent('view', 'visualization', widget.visualization.id, { dashboard: true }); dashboard: true,
});
recordEvent("view", "visualization", widget.visualization.id, {
dashboard: true,
});
onLoad(); onLoad();
} }
@@ -214,7 +263,8 @@ class VisualizationWidget extends React.Component {
}; };
editParameterMappings = () => { editParameterMappings = () => {
const { widget, dashboard, onRefresh, onParameterMappingsChange } = this.props; const { widget, dashboard, onRefresh, onParameterMappingsChange } =
this.props;
EditParameterMappingsDialog.showModal({ EditParameterMappingsDialog.showModal({
dashboard, dashboard,
widget, widget,
@@ -233,17 +283,18 @@ class VisualizationWidget extends React.Component {
const widgetQueryResult = widget.getQueryResult(); const widgetQueryResult = widget.getQueryResult();
const widgetStatus = widgetQueryResult && widgetQueryResult.getStatus(); const widgetStatus = widgetQueryResult && widgetQueryResult.getStatus();
switch (widgetStatus) { switch (widgetStatus) {
case 'failed': case "failed":
return ( return (
<div className="body-row-auto scrollbox"> <div className="body-row-auto scrollbox">
{widgetQueryResult.getError() && ( {widgetQueryResult.getError() && (
<div className="alert alert-danger m-5"> <div className="alert alert-danger m-5">
Error running query: <strong>{widgetQueryResult.getError()}</strong> Error running query:{" "}
<strong>{widgetQueryResult.getError()}</strong>
</div> </div>
)} )}
</div> </div>
); );
case 'done': case "done":
return ( return (
<div className="body-row-auto scrollbox"> <div className="body-row-auto scrollbox">
<VisualizationRenderer <VisualizationRenderer
@@ -269,32 +320,35 @@ class VisualizationWidget extends React.Component {
const { widget, isPublic, canEdit, onRefresh } = this.props; const { widget, isPublic, canEdit, onRefresh } = this.props;
const { localParameters } = this.state; const { localParameters } = this.state;
const widgetQueryResult = widget.getQueryResult(); const widgetQueryResult = widget.getQueryResult();
const isRefreshing = widget.loading && !!(widgetQueryResult && widgetQueryResult.getStatus()); const isRefreshing =
widget.loading && !!(widgetQueryResult && widgetQueryResult.getStatus());
return ( return (
<Widget <Widget
{...this.props} {...this.props}
className="widget-visualization" className="widget-visualization"
menuOptions={visualizationWidgetMenuOptions({ widget, menuOptions={visualizationWidgetMenuOptions({
widget,
canEditDashboard: canEdit, canEditDashboard: canEdit,
onParametersEdit: this.editParameterMappings })} onParametersEdit: this.editParameterMappings,
header={( })}
header={
<VisualizationWidgetHeader <VisualizationWidgetHeader
widget={widget} widget={widget}
refreshStartedAt={isRefreshing ? widget.refreshStartedAt : null} refreshStartedAt={isRefreshing ? widget.refreshStartedAt : null}
parameters={localParameters} parameters={localParameters}
onParametersUpdate={onRefresh} onParametersUpdate={onRefresh}
/> />
)} }
footer={( footer={
<VisualizationWidgetFooter <VisualizationWidgetFooter
widget={widget} widget={widget}
isPublic={isPublic} isPublic={isPublic}
onRefresh={onRefresh} onRefresh={onRefresh}
onExpand={this.expandWidget} onExpand={this.expandWidget}
/> />
)} }
tileProps={{ 'data-refreshing': isRefreshing }} tileProps={{ "data-refreshing": isRefreshing }}
> >
{this.renderVisualization()} {this.renderVisualization()}
</Widget> </Widget>

View File

@@ -13,7 +13,7 @@
<div class="col-md-12 query__vis"> <div class="col-md-12 query__vis">
<div class="p-t-15 p-b-10" ng-if="$ctrl.query.hasParameters() && !$ctrl.hideParametersUI"> <div class="p-t-15 p-b-10" ng-if="$ctrl.query.hasParameters() && !$ctrl.hideParametersUI">
<parameters parameters="$ctrl.query.getParametersDefs()" on-values-change="$ctrl.refreshQueryResults"></parameters> <parameters parameters="$ctrl.query.getParametersDefs()" query-result-error-data="$ctrl.errorData" on-values-change="$ctrl.refreshQueryResults"></parameters>
</div> </div>
<div ng-if="$ctrl.error"> <div ng-if="$ctrl.error">

View File

@@ -1,19 +1,21 @@
import { find } from 'lodash'; import logoUrl from "@/assets/images/redash_icon_small.png";
import moment from 'moment'; import { find } from "lodash";
import logoUrl from '@/assets/images/redash_icon_small.png'; import moment from "moment";
import template from './visualization-embed.html';
import template from "./visualization-embed.html";
const VisualizationEmbed = { const VisualizationEmbed = {
template, template,
bindings: { bindings: {
query: '<', query: "<",
}, },
controller($routeParams) { controller($routeParams) {
'ngInject'; "ngInject";
this.refreshQueryResults = () => { this.refreshQueryResults = () => {
this.loading = true; this.loading = true;
this.error = null; this.error = null;
this.errorData = {};
this.refreshStartedAt = moment(); this.refreshStartedAt = moment();
this.query this.query
.getQueryResultPromise() .getQueryResultPromise()
@@ -24,11 +26,15 @@ const VisualizationEmbed = {
.catch((error) => { .catch((error) => {
this.loading = false; this.loading = false;
this.error = error.getError(); this.error = error.getError();
this.errorData = error.getErrorData();
}); });
}; };
const visualizationId = parseInt($routeParams.visualizationId, 10); const visualizationId = parseInt($routeParams.visualizationId, 10);
this.visualization = find(this.query.visualizations, visualization => visualization.id === visualizationId); this.visualization = find(
this.query.visualizations,
(visualization) => visualization.id === visualizationId
);
this.showQueryDescription = $routeParams.showDescription; this.showQueryDescription = $routeParams.showDescription;
this.logoUrl = logoUrl; this.logoUrl = logoUrl;
this.apiKey = $routeParams.api_key; this.apiKey = $routeParams.api_key;
@@ -37,14 +43,14 @@ const VisualizationEmbed = {
this.hideHeader = $routeParams.hide_header !== undefined; this.hideHeader = $routeParams.hide_header !== undefined;
this.hideQueryLink = $routeParams.hide_link !== undefined; this.hideQueryLink = $routeParams.hide_link !== undefined;
document.querySelector('body').classList.add('headless'); document.querySelector("body").classList.add("headless");
this.refreshQueryResults(); this.refreshQueryResults();
}, },
}; };
export default function init(ngModule) { export default function init(ngModule) {
ngModule.component('visualizationEmbed', VisualizationEmbed); ngModule.component("visualizationEmbed", VisualizationEmbed);
function loadSession($route, Auth) { function loadSession($route, Auth) {
const apiKey = $route.current.params.api_key; const apiKey = $route.current.params.api_key;
@@ -53,19 +59,25 @@ export default function init(ngModule) {
} }
function loadQuery($route, Auth, Query) { function loadQuery($route, Auth, Query) {
'ngInject'; "ngInject";
return loadSession($route, Auth).then(() => Query.get({ id: $route.current.params.queryId }).$promise); return loadSession($route, Auth).then(
() => Query.get({ id: $route.current.params.queryId }).$promise
);
} }
ngModule.config(($routeProvider) => { ngModule.config(($routeProvider) => {
$routeProvider.when('/embed/query/:queryId/visualization/:visualizationId', { $routeProvider.when(
"/embed/query/:queryId/visualization/:visualizationId",
{
resolve: { resolve: {
query: loadQuery, query: loadQuery,
}, },
reloadOnSearch: false, reloadOnSearch: false,
template: '<visualization-embed query="$resolve.query"></visualization-embed>', template:
}); '<visualization-embed query="$resolve.query"></visualization-embed>',
}
);
}); });
} }

View File

@@ -93,8 +93,8 @@
</label> </label>
</div> </div>
<div class="m-b-10 p-15 bg-white tiled" ng-if="$ctrl.globalParameters.length > 0" data-test="DashboardParameters"> <div class="m-b-10 p-t-15 p-l-15 p-r-15 p-b-5 bg-white tiled" ng-if="$ctrl.globalParameters.length > 0" data-test="DashboardParameters">
<parameters parameters="$ctrl.globalParameters" on-values-change="$ctrl.refreshDashboard"></parameters> <parameters parameters="$ctrl.globalParameters" query-result-error-data="$ctrl.dashboard.getQueryResultsErrorData()" on-values-change="$ctrl.refreshDashboard"></parameters>
</div> </div>
<div class="m-b-10 p-15 bg-white tiled" ng-if="$ctrl.filters | notEmpty"> <div class="m-b-10 p-15 bg-white tiled" ng-if="$ctrl.filters | notEmpty">

View File

@@ -1,8 +1,8 @@
<div class="container p-t-10 p-b-20" ng-if="$ctrl.dashboard"> <div class="container p-t-10 p-b-20" ng-if="$ctrl.dashboard">
<page-header title="$ctrl.dashboard.name"></page-header> <page-header title="$ctrl.dashboard.name"></page-header>
<div class="m-b-10 p-15 bg-white tiled" ng-if="$ctrl.globalParameters.length > 0"> <div class="m-b-10 p-t-15 p-l-15 p-r-15 p-b-5 bg-white tiled" ng-if="$ctrl.globalParameters.length > 0">
<parameters parameters="$ctrl.globalParameters" on-values-change="$ctrl.refreshDashboard"></parameters> <parameters parameters="$ctrl.globalParameters" query-result-error-data="$ctrl.dashboard.getQueryResultsErrorData()" on-values-change="$ctrl.refreshDashboard"></parameters>
</div> </div>
<div class="m-b-5"> <div class="m-b-5">

View File

@@ -199,8 +199,8 @@
<section class="flex-fill p-relative t-body query-visualizations-wrapper"> <section class="flex-fill p-relative t-body query-visualizations-wrapper">
<div class="d-flex flex-column p-b-15 p-absolute static-position__mobile" style="left: 0; top: 0; right: 0; bottom: 0;"> <div class="d-flex flex-column p-b-15 p-absolute static-position__mobile" style="left: 0; top: 0; right: 0; bottom: 0;">
<div class="p-t-15 p-b-5" ng-if="query.hasParameters()"> <div class="p-t-15 p-b-5" ng-if="query.hasParameters()">
<parameters parameters="query.getParametersDefs()" editable="sourceMode && canEdit" disable-url-update="query.isNew()" <parameters parameters="query.getParametersDefs()" query-result-error-data="queryResult.getErrorData()" editable="sourceMode && canEdit" disable-url-update="query.isNew()"
on-values-change="executeQuery" on-pending-values-change="applyParametersChanges" on-parameters-edit="onParametersUpdated"></parameters> on-values-change="executeQuery" on-pending-values-change="applyParametersChanges" on-parameters-edit="onParametersUpdated" unsaved-parameters="getUnsavedParameters()"></parameters>
</div> </div>
<!-- Query Execution Status --> <!-- Query Execution Status -->

View File

@@ -1,6 +1,7 @@
import { map, debounce } from 'lodash'; import { debounce, isEmpty, isEqual, map } from "lodash";
import template from './query.html';
import EditParameterSettingsDialog from '@/components/EditParameterSettingsDialog'; import template from "./query.html";
import EditParameterSettingsDialog from "@/components/EditParameterSettingsDialog";
function QuerySourceCtrl( function QuerySourceCtrl(
Events, Events,
@@ -10,12 +11,12 @@ function QuerySourceCtrl(
$uibModal, $uibModal,
currentUser, currentUser,
KeyboardShortcuts, KeyboardShortcuts,
$rootScope, $rootScope
) { ) {
// extends QueryViewCtrl // extends QueryViewCtrl
$controller('QueryViewCtrl', { $scope }); $controller("QueryViewCtrl", { $scope });
Events.record('view_source', 'query', $scope.query.id); Events.record("view_source", "query", $scope.query.id);
const isNewQuery = !$scope.query.id; const isNewQuery = !$scope.query.id;
let queryText = $scope.query.query; let queryText = $scope.query.query;
@@ -27,35 +28,36 @@ function QuerySourceCtrl(
$scope.modKey = KeyboardShortcuts.modKey; $scope.modKey = KeyboardShortcuts.modKey;
// @override // @override
Object.defineProperty($scope, 'showDataset', { Object.defineProperty($scope, "showDataset", {
get() { get() {
return $scope.queryResult && $scope.queryResult.getStatus() === 'done'; return $scope.queryResult && $scope.queryResult.getStatus() === "done";
}, },
}); });
const shortcuts = { const shortcuts = {
'mod+s': function save() { "mod+s": function save() {
if ($scope.canEdit) { if ($scope.canEdit) {
$scope.saveQuery(); $scope.saveQuery();
} }
}, },
'mod+p': () => { "mod+p": () => {
$scope.addNewParameter(); $scope.addNewParameter();
}, },
}; };
KeyboardShortcuts.bind(shortcuts); KeyboardShortcuts.bind(shortcuts);
$scope.$on('$destroy', () => { $scope.$on("$destroy", () => {
KeyboardShortcuts.unbind(shortcuts); KeyboardShortcuts.unbind(shortcuts);
}); });
$scope.canForkQuery = () => currentUser.hasPermission('edit_query') && !$scope.dataSource.view_only; $scope.canForkQuery = () =>
currentUser.hasPermission("edit_query") && !$scope.dataSource.view_only;
$scope.updateQuery = debounce( $scope.updateQuery = debounce((newQueryText) =>
newQueryText => $scope.$apply(() => { $scope.$apply(() => {
$scope.query.query = newQueryText; $scope.query.query = newQueryText;
}), })
); );
// @override // @override
@@ -78,20 +80,22 @@ function QuerySourceCtrl(
}; };
$scope.addNewParameter = () => { $scope.addNewParameter = () => {
EditParameterSettingsDialog EditParameterSettingsDialog.showModal({
.showModal({
parameter: { parameter: {
title: null, title: null,
name: '', name: "",
type: 'text', type: "text",
value: null, value: null,
}, },
existingParams: map($scope.query.getParameters().get(), p => p.name), existingParams: map($scope.query.getParameters().get(), (p) => p.name),
}) }).result.then((param) => {
.result.then((param) => {
param = $scope.query.getParameters().add(param); param = $scope.query.getParameters().add(param);
$rootScope.$broadcast('query-editor.command', 'paste', param.toQueryTextFragment()); $rootScope.$broadcast(
$rootScope.$broadcast('query-editor.command', 'focus'); "query-editor.command",
"paste",
param.toQueryTextFragment()
);
$rootScope.$broadcast("query-editor.command", "focus");
}); });
}; };
@@ -103,44 +107,62 @@ function QuerySourceCtrl(
} }
}; };
$scope.listenForEditorCommand = f => $scope.$on('query-editor.command', f); $scope.listenForEditorCommand = (f) => $scope.$on("query-editor.command", f);
$scope.listenForResize = f => $scope.$parent.$on('angular-resizable.resizing', f); $scope.listenForResize = (f) =>
$scope.$parent.$on("angular-resizable.resizing", f);
$scope.$watch('query.query', (newQueryText) => { $scope.$watch("query.query", (newQueryText) => {
$scope.isDirty = newQueryText !== queryText; $scope.isDirty = newQueryText !== queryText;
}); });
$scope.unsavedParameters = null;
$scope.getUnsavedParameters = () => {
if (!$scope.isDirty || !queryText) {
return null;
}
const unsavedParameters =
$scope.query.$parameters.getUnsavedParameters(queryText);
if (isEmpty(unsavedParameters)) {
return null;
}
// avoiding Angular infdig (ANGULAR_REMOVE_ME)
if (!isEqual(unsavedParameters, $scope.unsavedParameters)) {
$scope.unsavedParameters = unsavedParameters;
}
return $scope.unsavedParameters;
};
} }
export default function init(ngModule) { export default function init(ngModule) {
ngModule.controller('QuerySourceCtrl', QuerySourceCtrl); ngModule.controller("QuerySourceCtrl", QuerySourceCtrl);
return { return {
'/queries/new': { "/queries/new": {
template, template,
layout: 'fixed', layout: "fixed",
controller: 'QuerySourceCtrl', controller: "QuerySourceCtrl",
reloadOnSearch: false, reloadOnSearch: false,
resolve: { resolve: {
query: function newQuery(Query) { query: function newQuery(Query) {
'ngInject'; "ngInject";
return Query.newQuery(); return Query.newQuery();
}, },
dataSources(DataSource) { dataSources(DataSource) {
'ngInject'; "ngInject";
return DataSource.query().$promise; return DataSource.query().$promise;
}, },
}, },
}, },
'/queries/:queryId/source': { "/queries/:queryId/source": {
template, template,
layout: 'fixed', layout: "fixed",
controller: 'QuerySourceCtrl', controller: "QuerySourceCtrl",
reloadOnSearch: false, reloadOnSearch: false,
resolve: { resolve: {
query: (Query, $route) => { query: (Query, $route) => {
'ngInject'; "ngInject";
return Query.get({ id: $route.current.params.queryId }).$promise; return Query.get({ id: $route.current.params.queryId }).$promise;
}, },

View File

@@ -1,6 +1,7 @@
import _ from 'lodash'; import dashboardGridOptions from "@/config/dashboard-grid-options";
import dashboardGridOptions from '@/config/dashboard-grid-options'; import _ from "lodash";
import { Widget } from './widget';
import { Widget } from "./widget";
export let Dashboard = null; // eslint-disable-line import/no-mutable-exports export let Dashboard = null; // eslint-disable-line import/no-mutable-exports
@@ -25,7 +26,10 @@ export function collectDashboardFilters(dashboard, queryResults, urlParams) {
if (!_.has(filters, queryFilter.name)) { if (!_.has(filters, queryFilter.name)) {
filters[filter.name] = filter; filters[filter.name] = filter;
} else { } else {
filters[filter.name].values = _.union(filters[filter.name].values, filter.values); filters[filter.name].values = _.union(
filters[filter.name].values,
filter.values
);
} }
}); });
}); });
@@ -36,15 +40,15 @@ export function collectDashboardFilters(dashboard, queryResults, urlParams) {
function prepareWidgetsForDashboard(widgets) { function prepareWidgetsForDashboard(widgets) {
// Default height for auto-height widgets. // Default height for auto-height widgets.
// Compute biggest widget size and choose between it and some magic number. // Compute biggest widget size and choose between it and some magic number.
// This value should be big enough so auto-height widgets will not overlap other ones. // This value should be big enough so auto-height widgets will not overlap
// other ones.
const defaultWidgetSizeY = const defaultWidgetSizeY =
Math.max( Math.max(
_ _.chain(widgets)
.chain(widgets) .map((w) => w.options.position.sizeY)
.map(w => w.options.position.sizeY)
.max() .max()
.value(), .value(),
20, 20
) + 5; ) + 5;
// Fix layout: // Fix layout:
@@ -52,14 +56,16 @@ function prepareWidgetsForDashboard(widgets) {
// 2. update position of widgets in each row - place it right below // 2. update position of widgets in each row - place it right below
// biggest widget from previous row // biggest widget from previous row
_.chain(widgets) _.chain(widgets)
.sortBy(widget => widget.options.position.row) .sortBy((widget) => widget.options.position.row)
.groupBy(widget => widget.options.position.row) .groupBy((widget) => widget.options.position.row)
.reduce((row, widgetsAtRow) => { .reduce((row, widgetsAtRow) => {
let height = 1; let height = 1;
_.each(widgetsAtRow, (widget) => { _.each(widgetsAtRow, (widget) => {
height = Math.max( height = Math.max(
height, height,
widget.options.position.autoHeight ? defaultWidgetSizeY : widget.options.position.sizeY, widget.options.position.autoHeight
? defaultWidgetSizeY
: widget.options.position.sizeY
); );
widget.options.position.row = row; widget.options.position.row = row;
if (widget.options.position.sizeY < 1) { if (widget.options.position.sizeY < 1) {
@@ -71,18 +77,20 @@ function prepareWidgetsForDashboard(widgets) {
.value(); .value();
// Sort widgets by updated column and row value // Sort widgets by updated column and row value
widgets = _.sortBy(widgets, widget => widget.options.position.col); widgets = _.sortBy(widgets, (widget) => widget.options.position.col);
widgets = _.sortBy(widgets, widget => widget.options.position.row); widgets = _.sortBy(widgets, (widget) => widget.options.position.row);
return widgets; return widgets;
} }
function calculateNewWidgetPosition(existingWidgets, newWidget) { function calculateNewWidgetPosition(existingWidgets, newWidget) {
const width = _.extend({ sizeX: dashboardGridOptions.defaultSizeX }, _.extend({}, newWidget.options).position).sizeX; const width = _.extend(
{ sizeX: dashboardGridOptions.defaultSizeX },
_.extend({}, newWidget.options).position
).sizeX;
// Find first free row for each column // Find first free row for each column
const bottomLine = _ const bottomLine = _.chain(existingWidgets)
.chain(existingWidgets)
.map((w) => { .map((w) => {
const options = _.extend({}, w.options); const options = _.extend({}, w.options);
const position = _.extend({ row: 0, sizeY: 0 }, options.position); const position = _.extend({ row: 0, sizeY: 0 }, options.position);
@@ -108,24 +116,24 @@ function calculateNewWidgetPosition(existingWidgets, newWidget) {
// Go through columns, pick them by count necessary to hold new block, // Go through columns, pick them by count necessary to hold new block,
// and calculate bottom-most free row per group. // and calculate bottom-most free row per group.
// Choose group with the top-most free row (comparing to other groups) // Choose group with the top-most free row (comparing to other groups)
return _ return _.chain(_.range(0, dashboardGridOptions.columns - width + 1))
.chain(_.range(0, dashboardGridOptions.columns - width + 1)) .map((col) => ({
.map(col => ({
col, col,
row: _ row: _.chain(bottomLine)
.chain(bottomLine)
.slice(col, col + width) .slice(col, col + width)
.max() .max()
.value(), .value(),
})) }))
.sortBy('row') .sortBy("row")
.first() .first()
.value(); .value();
} }
function DashboardService($resource, $http, $location, currentUser) { function DashboardService($resource, $http, $location, currentUser) {
function prepareDashboardWidgets(widgets) { function prepareDashboardWidgets(widgets) {
return prepareWidgetsForDashboard(_.map(widgets, widget => new Widget(widget))); return prepareWidgetsForDashboard(
_.map(widgets, (widget) => new Widget(widget))
);
} }
function transformSingle(dashboard) { function transformSingle(dashboard) {
@@ -145,36 +153,36 @@ function DashboardService($resource, $http, $location, currentUser) {
}); });
const resource = $resource( const resource = $resource(
'api/dashboards/:slug', "api/dashboards/:slug",
{ slug: '@slug' }, { slug: "@slug" },
{ {
get: { method: 'GET', transformResponse: transform }, get: { method: "GET", transformResponse: transform },
save: { method: 'POST', transformResponse: transform }, save: { method: "POST", transformResponse: transform },
query: { method: 'GET', isArray: false, transformResponse: transform }, query: { method: "GET", isArray: false, transformResponse: transform },
recent: { recent: {
method: 'get', method: "get",
isArray: true, isArray: true,
url: 'api/dashboards/recent', url: "api/dashboards/recent",
transformResponse: transform, transformResponse: transform,
}, },
favorites: { favorites: {
method: 'get', method: "get",
isArray: false, isArray: false,
url: 'api/dashboards/favorites', url: "api/dashboards/favorites",
}, },
favorite: { favorite: {
method: 'post', method: "post",
isArray: false, isArray: false,
url: 'api/dashboards/:slug/favorite', url: "api/dashboards/:slug/favorite",
transformRequest: [() => ''], // body not needed transformRequest: [() => ""], // body not needed
}, },
unfavorite: { unfavorite: {
method: 'delete', method: "delete",
isArray: false, isArray: false,
url: 'api/dashboards/:slug/favorite', url: "api/dashboards/:slug/favorite",
transformRequest: [() => ''], // body not needed transformRequest: [() => ""], // body not needed
},
}, },
}
); );
resource.prototype.canEdit = function canEdit() { resource.prototype.canEdit = function canEdit() {
@@ -199,7 +207,8 @@ function DashboardService($resource, $http, $location, currentUser) {
if (!globalParams[mapping.mapTo]) { if (!globalParams[mapping.mapTo]) {
globalParams[mapping.mapTo] = param.clone(); globalParams[mapping.mapTo] = param.clone();
globalParams[mapping.mapTo].name = mapping.mapTo; globalParams[mapping.mapTo].name = mapping.mapTo;
globalParams[mapping.mapTo].title = mapping.title || param.title; globalParams[mapping.mapTo].title =
mapping.title || param.title;
globalParams[mapping.mapTo].locals = []; globalParams[mapping.mapTo].locals = [];
} }
@@ -209,13 +218,18 @@ function DashboardService($resource, $http, $location, currentUser) {
}); });
} }
}); });
return _.values(_.each(globalParams, (param) => { return _.values(
_.each(globalParams, (param) => {
param.setValue(param.value); // apply global param value to all locals param.setValue(param.value); // apply global param value to all locals
param.fromUrlParams(queryParams); // try to initialize from url (may do nothing) param.fromUrlParams(queryParams); // try to initialize from url (may do nothing)
})); })
);
}; };
resource.prototype.addWidget = function addWidget(textOrVisualization, options = {}) { resource.prototype.addWidget = function addWidget(
textOrVisualization,
options = {}
) {
const props = { const props = {
dashboard_id: this.id, dashboard_id: this.id,
options: { options: {
@@ -223,7 +237,7 @@ function DashboardService($resource, $http, $location, currentUser) {
isHidden: false, isHidden: false,
position: {}, position: {},
}, },
text: '', text: "",
visualization_id: null, visualization_id: null,
visualization: null, visualization: null,
}; };
@@ -249,14 +263,51 @@ function DashboardService($resource, $http, $location, currentUser) {
}); });
}; };
let currentQueryResultsErrorData; // swap for useMemo ANGULAR_REMOVE_ME
resource.prototype.getQueryResultsErrorData =
function getQueryResultsErrorData() {
const dashboardErrors = _.map(this.widgets, (widget) => {
// get result
const result = widget.getQueryResult();
if (!result) {
return null;
}
// get error data
const errorData = result.getErrorData();
if (_.isEmpty(errorData)) {
return null;
}
// dashboard params only
const localParamNames = _.map(
widget.getLocalParameters(),
(p) => p.name
);
const filtered = _.omit(errorData.parameters, localParamNames);
return filtered;
});
const merged = _.assign({}, ...dashboardErrors);
const errorData = _.isEmpty(merged) ? null : { parameters: merged };
// avoiding Angular infdig (ANGULAR_REMOVE_ME)
if (!_.isEqual(currentQueryResultsErrorData, errorData)) {
currentQueryResultsErrorData = errorData;
}
return currentQueryResultsErrorData;
};
return resource; return resource;
} }
export default function init(ngModule) { export default function init(ngModule) {
ngModule.factory('Dashboard', DashboardService); ngModule.factory("Dashboard", DashboardService);
ngModule.run(($injector) => { ngModule.run(($injector) => {
Dashboard = $injector.get('Dashboard'); Dashboard = $injector.get("Dashboard");
}); });
} }

View File

@@ -1,5 +1,6 @@
import { toNumber, isNull } from 'lodash'; import { toNumber, trim } from "lodash";
import { Parameter } from '.';
import { Parameter } from ".";
class NumberParameter extends Parameter { class NumberParameter extends Parameter {
constructor(parameter, parentQueryId) { constructor(parameter, parentQueryId) {
@@ -9,11 +10,11 @@ class NumberParameter extends Parameter {
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
normalizeValue(value) { normalizeValue(value) {
if (isNull(value)) { if (!trim(value)) {
return null; return null;
} }
const normalizedValue = toNumber(value); const normalizedValue = toNumber(value);
return !isNaN(normalizedValue) ? normalizedValue : null; return !isNaN(normalizedValue) ? normalizedValue : value;
} }
} }

View File

@@ -1,5 +1,6 @@
import { toString, isEmpty } from 'lodash'; import { isEmpty, toString, trim } from "lodash";
import { Parameter } from '.';
import { Parameter } from ".";
class TextParameter extends Parameter { class TextParameter extends Parameter {
constructor(parameter, parentQueryId) { constructor(parameter, parentQueryId) {
@@ -15,6 +16,13 @@ class TextParameter extends Parameter {
} }
return normalizedValue; return normalizedValue;
} }
getExecutionValue() {
if (!trim(this.value)) {
return null;
}
return this.value;
}
} }
export default TextParameter; export default TextParameter;

View File

@@ -1,26 +1,21 @@
import { Parameter } from '..'; import { Parameter } from "..";
describe('NumberParameter', () => { describe("NumberParameter", () => {
let param; let param;
beforeEach(() => { beforeEach(() => {
param = Parameter.create({ name: 'param', title: 'Param', type: 'number' }); param = Parameter.create({ name: "param", title: "Param", type: "number" });
}); });
describe('normalizeValue', () => { describe("normalizeValue", () => {
test('converts Strings', () => { test("converts Strings", () => {
const normalizedValue = param.normalizeValue('15'); const normalizedValue = param.normalizeValue("15");
expect(normalizedValue).toBe(15); expect(normalizedValue).toBe(15);
}); });
test('converts Numbers', () => { test("converts Numbers", () => {
const normalizedValue = param.normalizeValue(42); const normalizedValue = param.normalizeValue(42);
expect(normalizedValue).toBe(42); expect(normalizedValue).toBe(42);
}); });
test('returns null when not possible to convert to number', () => {
const normalizedValue = param.normalizeValue('notanumber');
expect(normalizedValue).toBeNull();
});
}); });
}); });

View File

@@ -1,22 +1,30 @@
import debug from 'debug'; import debug from "debug";
import moment from 'moment'; import {
import { uniqBy, each, isNumber, isString, includes, extend, forOwn } from 'lodash'; each,
extend,
forOwn,
includes,
isNumber,
isString,
uniqBy,
} from "lodash";
import moment from "moment";
const logger = debug('redash:services:QueryResult'); const logger = debug("redash:services:QueryResult");
const filterTypes = ['filter', 'multi-filter', 'multiFilter']; const filterTypes = ["filter", "multi-filter", "multiFilter"];
function getColumnNameWithoutType(column) { function getColumnNameWithoutType(column) {
let typeSplit; let typeSplit;
if (column.indexOf('::') !== -1) { if (column.indexOf("::") !== -1) {
typeSplit = '::'; typeSplit = "::";
} else if (column.indexOf('__') !== -1) { } else if (column.indexOf("__") !== -1) {
typeSplit = '__'; typeSplit = "__";
} else { } else {
return column; return column;
} }
const parts = column.split(typeSplit); const parts = column.split(typeSplit);
if (parts[0] === '' && parts.length === 2) { if (parts[0] === "" && parts.length === 2) {
return parts[1]; return parts[1];
} }
@@ -32,31 +40,45 @@ export function getColumnCleanName(column) {
} }
function getColumnFriendlyName(column) { function getColumnFriendlyName(column) {
return getColumnNameWithoutType(column).replace(/(?:^|\s)\S/g, a => a.toUpperCase()); return getColumnNameWithoutType(column).replace(/(?:^|\s)\S/g, (a) =>
a.toUpperCase()
);
} }
function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) { function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) {
const QueryResultResource = $resource('api/query_results/:id', { id: '@id' }, { post: { method: 'POST' } }); const QueryResultResource = $resource(
const QueryResultByQueryIdResource = $resource('api/queries/:queryId/results/:id.json', { queryId: '@queryId', id: '@id' }); "api/query_results/:id",
const Job = $resource('api/jobs/:id', { id: '@id' }); { id: "@id" },
const JobWithApiKey = $resource('api/queries/:queryId/jobs/:id', { queryId: '@queryId', id: '@id' }); { post: { method: "POST" } }
);
const QueryResultByQueryIdResource = $resource(
"api/queries/:queryId/results/:id.json",
{ queryId: "@queryId", id: "@id" }
);
const Job = $resource("api/jobs/:id", { id: "@id" });
const JobWithApiKey = $resource("api/queries/:queryId/jobs/:id", {
queryId: "@queryId",
id: "@id",
});
const statuses = { const statuses = {
1: 'waiting', 1: "waiting",
2: 'processing', 2: "processing",
3: 'done', 3: "done",
4: 'failed', 4: "failed",
}; };
function handleErrorResponse(queryResult, response) { function handleErrorResponse(queryResult, response) {
if (response.status === 403) { if (response.status === 403) {
queryResult.update(response.data); queryResult.update(response.data);
} else if (response.status === 400 && 'job' in response.data) { } else if (response.status === 400 && "job" in response.data) {
queryResult.update(response.data); queryResult.update(response.data);
} else { } else {
logger('Unknown error', response); logger("Unknown error", response);
queryResult.update({ queryResult.update({
job: { job: {
error: response.data.message || 'unknown error occurred. Please try again later.', error:
response.data.message ||
"unknown error occurred. Please try again later.",
status: 4, status: 4,
}, },
}); });
@@ -68,7 +90,7 @@ function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) {
this.deferred = $q.defer(); this.deferred = $q.defer();
this.job = {}; this.job = {};
this.query_result = {}; this.query_result = {};
this.status = 'waiting'; this.status = "waiting";
this.updatedAt = moment(); this.updatedAt = moment();
@@ -83,34 +105,35 @@ function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) {
update(props) { update(props) {
extend(this, props); extend(this, props);
if ('query_result' in props) { if ("query_result" in props) {
this.status = 'done'; this.status = "done";
const columnTypes = {}; const columnTypes = {};
// TODO: we should stop manipulating incoming data, and switch to relaying // TODO: we should stop manipulating incoming data, and switch to
// on the column type set by the backend. This logic is prone to errors, // relaying on the column type set by the backend. This logic is prone
// and better be removed. Kept for now, for backward compatability. // to errors, and better be removed. Kept for now, for backward
// compatability.
each(this.query_result.data.rows, (row) => { each(this.query_result.data.rows, (row) => {
forOwn(row, (v, k) => { forOwn(row, (v, k) => {
let newType = null; let newType = null;
if (isNumber(v)) { if (isNumber(v)) {
newType = 'float'; newType = "float";
} else if (isString(v) && v.match(/^\d{4}-\d{2}-\d{2}T/)) { } else if (isString(v) && v.match(/^\d{4}-\d{2}-\d{2}T/)) {
row[k] = moment.utc(v); row[k] = moment.utc(v);
newType = 'datetime'; newType = "datetime";
} else if (isString(v) && v.match(/^\d{4}-\d{2}-\d{2}$/)) { } else if (isString(v) && v.match(/^\d{4}-\d{2}-\d{2}$/)) {
row[k] = moment.utc(v); row[k] = moment.utc(v);
newType = 'date'; newType = "date";
} else if (typeof v === 'object' && v !== null) { } else if (typeof v === "object" && v !== null) {
row[k] = JSON.stringify(v); row[k] = JSON.stringify(v);
} else { } else {
newType = 'string'; newType = "string";
} }
if (newType !== null) { if (newType !== null) {
if (columnTypes[k] !== undefined && columnTypes[k] !== newType) { if (columnTypes[k] !== undefined && columnTypes[k] !== newType) {
columnTypes[k] = 'string'; columnTypes[k] = "string";
} else { } else {
columnTypes[k] = newType; columnTypes[k] = newType;
} }
@@ -119,9 +142,9 @@ function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) {
}); });
each(this.query_result.data.columns, (column) => { each(this.query_result.data.columns, (column) => {
column.name = '' + column.name; column.name = "" + column.name;
if (columnTypes[column.name]) { if (columnTypes[column.name]) {
if (column.type == null || column.type === 'string') { if (column.type == null || column.type === "string") {
column.type = columnTypes[column.name]; column.type = columnTypes[column.name];
} }
} }
@@ -129,10 +152,12 @@ function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) {
this.deferred.resolve(this); this.deferred.resolve(this);
} else if (this.job.status === 3) { } else if (this.job.status === 3) {
this.status = 'processing'; this.status = "processing";
} else if (this.job.status === 4) { } else if (this.job.status === 4) {
this.status = statuses[this.job.status]; this.status = statuses[this.job.status];
this.deferred.reject(new QueryResultError(this.job.error)); this.deferred.reject(
new QueryResultError(this.job.error, this.job.error_data)
);
} else { } else {
this.status = undefined; this.status = undefined;
} }
@@ -140,7 +165,7 @@ function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) {
getId() { getId() {
let id = null; let id = null;
if ('query_result' in this) { if ("query_result" in this) {
id = this.query_result.id; id = this.query_result.id;
} }
return id; return id;
@@ -152,22 +177,30 @@ function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) {
getStatus() { getStatus() {
if (this.isLoadingResult) { if (this.isLoadingResult) {
return 'loading-result'; return "loading-result";
} }
return this.status || statuses[this.job.status]; return this.status || statuses[this.job.status];
} }
getError() { getError() {
// TODO: move this logic to the server... // TODO: move this logic to the server...
if (this.job.error === 'None') { if (this.job.error === "None") {
return undefined; return undefined;
} }
return this.job.error; return this.job.error;
} }
getErrorData() {
return this.job.error_data || undefined;
}
getLog() { getLog() {
if (!this.query_result.data || !this.query_result.data.log || this.query_result.data.log.length === 0) { if (
!this.query_result.data ||
!this.query_result.data.log ||
this.query_result.data.log.length === 0
) {
return null; return null;
} }
@@ -175,7 +208,11 @@ function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) {
} }
getUpdatedAt() { getUpdatedAt() {
return this.query_result.retrieved_at || this.job.updated_at * 1000.0 || this.updatedAt; return (
this.query_result.retrieved_at ||
this.job.updated_at * 1000.0 ||
this.updatedAt
);
} }
getRuntime() { getRuntime() {
@@ -208,18 +245,18 @@ function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) {
getColumnNames() { getColumnNames() {
if (this.columnNames === undefined && this.query_result.data) { if (this.columnNames === undefined && this.query_result.data) {
this.columnNames = this.query_result.data.columns.map(v => v.name); this.columnNames = this.query_result.data.columns.map((v) => v.name);
} }
return this.columnNames; return this.columnNames;
} }
getColumnCleanNames() { getColumnCleanNames() {
return this.getColumnNames().map(col => getColumnCleanName(col)); return this.getColumnNames().map((col) => getColumnCleanName(col));
} }
getColumnFriendlyNames() { getColumnFriendlyNames() {
return this.getColumnNames().map(col => getColumnFriendlyName(col)); return this.getColumnNames().map((col) => getColumnFriendlyName(col));
} }
getFilters() { getFilters() {
@@ -231,7 +268,7 @@ function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) {
this.getColumns().forEach((col) => { this.getColumns().forEach((col) => {
const name = col.name; const name = col.name;
const type = name.split('::')[1] || name.split('__')[1]; const type = name.split("::")[1] || name.split("__")[1];
if (includes(filterTypes, type)) { if (includes(filterTypes, type)) {
// filter found // filter found
const filter = { const filter = {
@@ -239,7 +276,7 @@ function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) {
friendlyName: getColumnFriendlyName(name), friendlyName: getColumnFriendlyName(name),
column: col, column: col,
values: [], values: [],
multiple: type === 'multiFilter' || type === 'multi-filter', multiple: type === "multiFilter" || type === "multi-filter",
}; };
filters.push(filter); filters.push(filter);
} }
@@ -289,17 +326,26 @@ function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) {
// Error handler // Error handler
queryResult.isLoadingResult = false; queryResult.isLoadingResult = false;
handleErrorResponse(queryResult, error); handleErrorResponse(queryResult, error);
}, }
); );
return queryResult; return queryResult;
} }
loadLatestCachedResult(queryId, parameters) { loadLatestCachedResult(queryId, parameters) {
$resource('api/queries/:id/results', { id: '@queryId' }, { post: { method: 'POST' } }) $resource(
.post({ queryId, parameters }, "api/queries/:id/results",
(response) => { this.update(response); }, { id: "@queryId" },
(error) => { handleErrorResponse(this, error); }); { post: { method: "POST" } }
).post(
{ queryId, parameters },
(response) => {
this.update(response);
},
(error) => {
handleErrorResponse(this, error);
}
);
} }
loadResult(tryCount) { loadResult(tryCount) {
@@ -316,10 +362,11 @@ function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) {
} }
if (tryCount > 3) { if (tryCount > 3) {
logger('Connection error while trying to load result', error); logger("Connection error while trying to load result", error);
this.update({ this.update({
job: { job: {
error: 'failed communicating with server. Please check your Internet connection and try again.', error:
"failed communicating with server. Please check your Internet connection and try again.",
status: 4, status: 4,
}, },
}); });
@@ -329,25 +376,32 @@ function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) {
this.loadResult(tryCount + 1); this.loadResult(tryCount + 1);
}, 1000 * Math.pow(2, tryCount)); }, 1000 * Math.pow(2, tryCount));
} }
}, }
); );
} }
refreshStatus(query, parameters, tryNumber = 1) { refreshStatus(query, parameters, tryNumber = 1) {
const resource = Auth.isAuthenticated() ? Job : JobWithApiKey; const resource = Auth.isAuthenticated() ? Job : JobWithApiKey;
const loadResult = () => (Auth.isAuthenticated() const loadResult = () =>
Auth.isAuthenticated()
? this.loadResult() ? this.loadResult()
: this.loadLatestCachedResult(query, parameters)); : this.loadLatestCachedResult(query, parameters);
const params = Auth.isAuthenticated() ? { id: this.job.id } : { queryId: query, id: this.job.id }; const params = Auth.isAuthenticated()
? { id: this.job.id }
: { queryId: query, id: this.job.id };
resource.get( resource.get(
params, params,
(jobResponse) => { (jobResponse) => {
this.update(jobResponse); this.update(jobResponse);
if (this.getStatus() === 'processing' && this.job.query_result_id && this.job.query_result_id !== 'None') { if (
this.getStatus() === "processing" &&
this.job.query_result_id &&
this.job.query_result_id !== "None"
) {
loadResult(); loadResult();
} else if (this.getStatus() !== 'failed') { } else if (this.getStatus() !== "failed") {
const waitTime = tryNumber > 10 ? 3000 : 500; const waitTime = tryNumber > 10 ? 3000 : 500;
$timeout(() => { $timeout(() => {
this.refreshStatus(query, parameters, tryNumber + 1); this.refreshStatus(query, parameters, tryNumber + 1);
@@ -355,15 +409,17 @@ function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) {
} }
}, },
(error) => { (error) => {
logger('Connection error', error); logger("Connection error", error);
// TODO: use QueryResultError, or better yet: exception/reject of promise. // TODO: use QueryResultError, or better yet: exception/reject of
// promise.
this.update({ this.update({
job: { job: {
error: 'failed communicating with server. Please check your Internet connection and try again.', error:
"failed communicating with server. Please check your Internet connection and try again.",
status: 4, status: 4,
}, },
}); });
}, }
); );
} }
@@ -376,13 +432,20 @@ function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) {
} }
getName(queryName, fileType) { getName(queryName, fileType) {
return `${queryName.replace(/ /g, '_') + moment(this.getUpdatedAt()).format('_YYYY_MM_DD')}.${fileType}`; return `${
queryName.replace(/ /g, "_") +
moment(this.getUpdatedAt()).format("_YYYY_MM_DD")
}.${fileType}`;
} }
static getByQueryId(id, parameters, maxAge) { static getByQueryId(id, parameters, maxAge) {
const queryResult = new QueryResult(); const queryResult = new QueryResult();
$resource('api/queries/:id/results', { id: '@id' }, { post: { method: 'POST' } }).post( $resource(
"api/queries/:id/results",
{ id: "@id" },
{ post: { method: "POST" } }
).post(
{ {
id, id,
parameters, parameters,
@@ -391,13 +454,13 @@ function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) {
(response) => { (response) => {
queryResult.update(response); queryResult.update(response);
if ('job' in response) { if ("job" in response) {
queryResult.refreshStatus(id, parameters); queryResult.refreshStatus(id, parameters);
} }
}, },
(error) => { (error) => {
handleErrorResponse(queryResult, error); handleErrorResponse(queryResult, error);
}, }
); );
return queryResult; return queryResult;
@@ -422,13 +485,13 @@ function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) {
(response) => { (response) => {
queryResult.update(response); queryResult.update(response);
if ('job' in response) { if ("job" in response) {
queryResult.refreshStatus(query, parameters); queryResult.refreshStatus(query, parameters);
} }
}, },
(error) => { (error) => {
handleErrorResponse(queryResult, error); handleErrorResponse(queryResult, error);
}, }
); );
return queryResult; return queryResult;
@@ -439,7 +502,7 @@ function QueryResultService($resource, $timeout, $q, QueryResultError, Auth) {
} }
export default function init(ngModule) { export default function init(ngModule) {
ngModule.factory('QueryResult', QueryResultService); ngModule.factory("QueryResult", QueryResultService);
} }
init.init = true; init.init = true;

View File

@@ -1,26 +1,37 @@
import moment from 'moment'; import debug from "debug";
import debug from 'debug';
import Mustache from 'mustache';
import { import {
zipObject, isEmpty, map, filter, includes, union, each,
uniq, has, identity, extend, each, some, extend,
} from 'lodash'; has,
identity,
includes,
isEmpty,
isNil,
map,
reject,
some,
union,
uniq,
zipObject,
} from "lodash";
import moment from "moment";
import Mustache from "mustache";
import { Parameter } from './parameters'; import { Parameter } from "./parameters";
Mustache.escape = identity; // do not html-escape values Mustache.escape = identity; // do not html-escape values
export let Query = null; // eslint-disable-line import/no-mutable-exports export let Query = null; // eslint-disable-line import/no-mutable-exports
const logger = debug('redash:services:query'); const logger = debug("redash:services:query");
function collectParams(parts) { function collectParams(parts) {
let parameters = []; let parameters = [];
parts.forEach((part) => { parts.forEach((part) => {
if (part[0] === 'name' || part[0] === '&') { if (part[0] === "name" || part[0] === "&") {
parameters.push(part[1].split('.')[0]); parameters.push(part[1].split(".")[0]);
} else if (part[0] === '#') { } else if (part[0] === "#") {
parameters = union(parameters, collectParams(part[4])); parameters = union(parameters, collectParams(part[4]));
} }
}); });
@@ -35,16 +46,16 @@ class Parameters {
this.initFromQueryString(queryString); this.initFromQueryString(queryString);
} }
parseQuery() { parseQuery(queryText = this.query.query) {
const fallback = () => map(this.query.options.parameters, i => i.name); const fallback = () => map(this.query.options.parameters, (i) => i.name);
let parameters = []; let parameters = [];
if (this.query.query !== undefined) { if (!isNil(queryText)) {
try { try {
const parts = Mustache.parse(this.query.query); const parts = Mustache.parse(queryText);
parameters = uniq(collectParams(parts)); parameters = uniq(collectParams(parts));
} catch (e) { } catch (e) {
logger('Failed parsing parameters: ', e); logger("Failed parsing parameters: ", e);
// Return current parameters so we don't reset the list // Return current parameters so we don't reset the list
parameters = fallback(); parameters = fallback();
} }
@@ -61,7 +72,9 @@ class Parameters {
} }
this.cachedQueryText = this.query.query; this.cachedQueryText = this.query.query;
const parameterNames = update ? this.parseQuery() : map(this.query.options.parameters, p => p.name); const parameterNames = update
? this.parseQuery()
: map(this.query.options.parameters, (p) => p.name);
this.query.options.parameters = this.query.options.parameters || []; this.query.options.parameters = this.query.options.parameters || [];
@@ -72,20 +85,25 @@ class Parameters {
parameterNames.forEach((param) => { parameterNames.forEach((param) => {
if (!has(parametersMap, param)) { if (!has(parametersMap, param)) {
this.query.options.parameters.push(Parameter.create({ this.query.options.parameters.push(
Parameter.create({
title: param, title: param,
name: param, name: param,
type: 'text', type: "text",
value: null, value: null,
global: false, global: false,
})); })
);
} }
}); });
const parameterExists = p => includes(parameterNames, p.name); const parameterExists = (p) => includes(parameterNames, p.name);
const parameters = this.query.options.parameters; const parameters = this.query.options.parameters;
this.query.options.parameters = parameters.filter(parameterExists) this.query.options.parameters = parameters
.map(p => (p instanceof Parameter ? p : Parameter.create(p, this.query.id))); .filter(parameterExists)
.map((p) =>
p instanceof Parameter ? p : Parameter.create(p, this.query.id)
);
} }
initFromQueryString(query) { initFromQueryString(query) {
@@ -100,52 +118,61 @@ class Parameters {
} }
add(parameterDef) { add(parameterDef) {
this.query.options.parameters = this.query.options.parameters this.query.options.parameters = this.query.options.parameters.filter(
.filter(p => p.name !== parameterDef.name); (p) => p.name !== parameterDef.name
);
const param = Parameter.create(parameterDef); const param = Parameter.create(parameterDef);
this.query.options.parameters.push(param); this.query.options.parameters.push(param);
return param; return param;
} }
getMissing() {
return map(filter(this.get(), p => p.isEmpty), i => i.title);
}
isRequired() { isRequired() {
return !isEmpty(this.get()); return !isEmpty(this.get());
} }
getExecutionValues(extra = {}) { getExecutionValues(extra = {}) {
const params = this.get(); const params = this.get();
return zipObject(map(params, i => i.name), map(params, i => i.getExecutionValue(extra))); return zipObject(
map(params, (i) => i.name),
map(params, (i) => i.getExecutionValue(extra))
);
} }
hasPendingValues() { hasPendingValues() {
return some(this.get(), p => p.hasPendingValue); return some(this.get(), (p) => p.hasPendingValue);
} }
applyPendingValues() { applyPendingValues() {
each(this.get(), p => p.applyPendingValue()); each(this.get(), (p) => p.applyPendingValue());
}
getUnsavedParameters(queryText) {
const savedParameters = this.parseQuery(queryText);
return reject(this.get(), (p) => includes(savedParameters, p.name)).map(
(p) => p.name
);
} }
toUrlParams() { toUrlParams() {
if (this.get().length === 0) { if (this.get().length === 0) {
return ''; return "";
} }
const params = Object.assign(...this.get().map(p => p.toUrlParams())); const params = Object.assign(...this.get().map((p) => p.toUrlParams()));
Object.keys(params).forEach(key => params[key] == null && delete params[key]); Object.keys(params).forEach(
return Object (key) => params[key] == null && delete params[key]
.keys(params) );
.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`) return Object.keys(params)
.join('&'); .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
.join("&");
} }
} }
function QueryResultErrorFactory($q) { function QueryResultErrorFactory($q) {
class QueryResultError { class QueryResultError {
constructor(errorMessage) { constructor(errorMessage, errorData = {}) {
this.errorMessage = errorMessage; this.errorMessage = errorMessage;
this.errorData = errorData;
this.updatedAt = moment.utc(); this.updatedAt = moment.utc();
} }
@@ -157,13 +184,17 @@ function QueryResultErrorFactory($q) {
return this.errorMessage; return this.errorMessage;
} }
getErrorData() {
return this.errorData || undefined;
}
toPromise() { toPromise() {
return $q.reject(this); return $q.reject(this);
} }
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
getStatus() { getStatus() {
return 'failed'; return "failed";
} }
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
@@ -187,75 +218,75 @@ function QueryResource(
$q, $q,
currentUser, currentUser,
QueryResultError, QueryResultError,
QueryResult, QueryResult
) { ) {
const QueryService = $resource( const QueryService = $resource(
'api/queries/:id', "api/queries/:id",
{ id: '@id' }, { id: "@id" },
{ {
recent: { recent: {
method: 'get', method: "get",
isArray: true, isArray: true,
url: 'api/queries/recent', url: "api/queries/recent",
}, },
archive: { archive: {
method: 'get', method: "get",
isArray: false, isArray: false,
url: 'api/queries/archive', url: "api/queries/archive",
}, },
query: { query: {
isArray: false, isArray: false,
}, },
myQueries: { myQueries: {
method: 'get', method: "get",
isArray: false, isArray: false,
url: 'api/queries/my', url: "api/queries/my",
}, },
fork: { fork: {
method: 'post', method: "post",
isArray: false, isArray: false,
url: 'api/queries/:id/fork', url: "api/queries/:id/fork",
params: { id: '@id' }, params: { id: "@id" },
}, },
resultById: { resultById: {
method: 'get', method: "get",
isArray: false, isArray: false,
url: 'api/queries/:id/results.json', url: "api/queries/:id/results.json",
}, },
asDropdown: { asDropdown: {
method: 'get', method: "get",
isArray: true, isArray: true,
url: 'api/queries/:id/dropdown', url: "api/queries/:id/dropdown",
}, },
associatedDropdown: { associatedDropdown: {
method: 'get', method: "get",
isArray: true, isArray: true,
url: 'api/queries/:queryId/dropdowns/:dropdownQueryId', url: "api/queries/:queryId/dropdowns/:dropdownQueryId",
}, },
favorites: { favorites: {
method: 'get', method: "get",
isArray: false, isArray: false,
url: 'api/queries/favorites', url: "api/queries/favorites",
}, },
favorite: { favorite: {
method: 'post', method: "post",
isArray: false, isArray: false,
url: 'api/queries/:id/favorite', url: "api/queries/:id/favorite",
transformRequest: [() => ''], // body not needed transformRequest: [() => ""], // body not needed
}, },
unfavorite: { unfavorite: {
method: 'delete', method: "delete",
isArray: false, isArray: false,
url: 'api/queries/:id/favorite', url: "api/queries/:id/favorite",
transformRequest: [() => ''], // body not needed transformRequest: [() => ""], // body not needed
},
}, },
}
); );
QueryService.newQuery = function newQuery() { QueryService.newQuery = function newQuery() {
return new QueryService({ return new QueryService({
query: '', query: "",
name: 'New Query', name: "New Query",
schedule: null, schedule: null,
user: currentUser, user: currentUser,
options: {}, options: {},
@@ -263,17 +294,21 @@ function QueryResource(
}; };
QueryService.format = function formatQuery(syntax, query) { QueryService.format = function formatQuery(syntax, query) {
if (syntax === 'json') { if (syntax === "json") {
try { try {
const formatted = JSON.stringify(JSON.parse(query), ' ', 4); const formatted = JSON.stringify(JSON.parse(query), " ", 4);
return $q.resolve(formatted); return $q.resolve(formatted);
} catch (err) { } catch (err) {
return $q.reject(String(err)); return $q.reject(String(err));
} }
} else if (syntax === 'sql') { } else if (syntax === "sql") {
return $http.post('api/queries/format', { query }).then(response => response.data.query); return $http
.post("api/queries/format", { query })
.then((response) => response.data.query);
} else { } else {
return $q.reject('Query formatting is not supported for your data source syntax.'); return $q.reject(
"Query formatting is not supported for your data source syntax."
);
} }
}; };
@@ -290,13 +325,8 @@ function QueryResource(
}; };
QueryService.prototype.scheduleInLocalTime = function scheduleInLocalTime() { QueryService.prototype.scheduleInLocalTime = function scheduleInLocalTime() {
const parts = this.schedule.split(':'); const parts = this.schedule.split(":");
return moment return moment.utc().hour(parts[0]).minute(parts[1]).local().format("HH:mm");
.utc()
.hour(parts[0])
.minute(parts[1])
.local()
.format('HH:mm');
}; };
QueryService.prototype.hasResult = function hasResult() { QueryService.prototype.hasResult = function hasResult() {
@@ -311,28 +341,13 @@ function QueryResource(
return this.getParametersDefs().length > 0; return this.getParametersDefs().length > 0;
}; };
QueryService.prototype.prepareQueryResultExecution = function prepareQueryResultExecution(execute, maxAge) { QueryService.prototype.prepareQueryResultExecution =
function prepareQueryResultExecution(execute, maxAge) {
const parameters = this.getParameters(); const parameters = this.getParameters();
const missingParams = parameters.getMissing();
if (missingParams.length > 0) {
let paramsWord = 'parameter';
let valuesWord = 'value';
if (missingParams.length > 1) {
paramsWord = 'parameters';
valuesWord = 'values';
}
return new QueryResult({
job: {
error: `missing ${valuesWord} for ${missingParams.join(', ')} ${paramsWord}.`,
status: 4,
},
});
}
if (parameters.isRequired()) { if (parameters.isRequired()) {
// Need to clear latest results, to make sure we don't use results for different params. // Need to clear latest results, to make sure we don't use results for
// different params.
this.latest_query_data = null; this.latest_query_data = null;
this.latest_query_data_id = null; this.latest_query_data_id = null;
} }
@@ -345,7 +360,10 @@ function QueryResource(
} }
} else if (this.latest_query_data_id && maxAge !== 0) { } else if (this.latest_query_data_id && maxAge !== 0) {
if (!this.queryResult) { if (!this.queryResult) {
this.queryResult = QueryResult.getById(this.id, this.latest_query_data_id); this.queryResult = QueryResult.getById(
this.id,
this.latest_query_data_id
);
} }
} else { } else {
this.queryResult = execute(); this.queryResult = execute();
@@ -355,18 +373,35 @@ function QueryResource(
}; };
QueryService.prototype.getQueryResult = function getQueryResult(maxAge) { QueryService.prototype.getQueryResult = function getQueryResult(maxAge) {
const execute = () => QueryResult.getByQueryId(this.id, this.getParameters().getExecutionValues(), maxAge); const execute = () =>
QueryResult.getByQueryId(
this.id,
this.getParameters().getExecutionValues(),
maxAge
);
return this.prepareQueryResultExecution(execute, maxAge); return this.prepareQueryResultExecution(execute, maxAge);
}; };
QueryService.prototype.getQueryResultByText = function getQueryResultByText(maxAge, selectedQueryText) { QueryService.prototype.getQueryResultByText = function getQueryResultByText(
maxAge,
selectedQueryText
) {
const queryText = selectedQueryText || this.query; const queryText = selectedQueryText || this.query;
if (!queryText) { if (!queryText) {
return new QueryResultError("Can't execute empty query."); return new QueryResultError("Can't execute empty query.");
} }
const parameters = this.getParameters().getExecutionValues({ joinListValues: true }); const parameters = this.getParameters().getExecutionValues({
const execute = () => QueryResult.get(this.data_source_id, queryText, parameters, maxAge, this.id); joinListValues: true,
});
const execute = () =>
QueryResult.get(
this.data_source_id,
queryText,
parameters,
maxAge,
this.id
);
return this.prepareQueryResultExecution(execute, maxAge); return this.prepareQueryResultExecution(execute, maxAge);
}; };
@@ -374,7 +409,7 @@ function QueryResource(
let url = `queries/${this.id}`; let url = `queries/${this.id}`;
if (source) { if (source) {
url += '/source'; url += "/source";
} }
let params = {}; let params = {};
@@ -383,10 +418,16 @@ function QueryResource(
extend(params, param.toUrlParams()); extend(params, param.toUrlParams());
}); });
} }
Object.keys(params).forEach(key => params[key] == null && delete params[key]); Object.keys(params).forEach(
params = map(params, (value, name) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`).join('&'); (key) => params[key] == null && delete params[key]
);
params = map(
params,
(value, name) =>
`${encodeURIComponent(name)}=${encodeURIComponent(value)}`
).join("&");
if (params !== '') { if (params !== "") {
url += `?${params}`; url += `?${params}`;
} }
@@ -397,7 +438,8 @@ function QueryResource(
return url; return url;
}; };
QueryService.prototype.getQueryResultPromise = function getQueryResultPromise() { QueryService.prototype.getQueryResultPromise =
function getQueryResultPromise() {
return this.getQueryResult().toPromise(); return this.getQueryResult().toPromise();
}; };
@@ -409,7 +451,9 @@ function QueryResource(
return this.$parameters; return this.$parameters;
}; };
QueryService.prototype.getParametersDefs = function getParametersDefs(update = true) { QueryService.prototype.getParametersDefs = function getParametersDefs(
update = true
) {
return this.getParameters().get(update); return this.getParameters().get(update);
}; };
@@ -417,11 +461,11 @@ function QueryResource(
} }
export default function init(ngModule) { export default function init(ngModule) {
ngModule.factory('QueryResultError', QueryResultErrorFactory); ngModule.factory("QueryResultError", QueryResultErrorFactory);
ngModule.factory('Query', QueryResource); ngModule.factory("Query", QueryResource);
ngModule.run(($injector) => { ngModule.run(($injector) => {
Query = $injector.get('Query'); Query = $injector.get("Query");
}); });
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,86 +1,146 @@
import logging
import time import time
from flask import make_response, request from flask import make_response
from flask import request
from flask_login import current_user from flask_login import current_user
from flask_restful import abort from flask_restful import abort
from redash import models, settings
from redash.handlers.base import BaseResource, get_object_or_404, record_event from redash import models
from redash.permissions import (has_access, not_view_only, require_access, from redash import settings
require_permission, view_only) from redash.handlers.base import BaseResource
from redash.handlers.base import get_object_or_404
from redash.handlers.base import record_event
from redash.models.parameterized_query import dropdown_values
from redash.models.parameterized_query import InvalidParameterError
from redash.models.parameterized_query import ParameterizedQuery
from redash.models.parameterized_query import QueryDetachedFromDataSourceError
from redash.permissions import has_access
from redash.permissions import not_view_only
from redash.permissions import require_access
from redash.permissions import require_permission
from redash.permissions import view_only
from redash.serializers import serialize_query_result
from redash.serializers import serialize_query_result_to_csv
from redash.serializers import serialize_query_result_to_xlsx
from redash.tasks import QueryTask from redash.tasks import QueryTask
from redash.tasks.queries import enqueue_query from redash.tasks.queries import enqueue_query
from redash.utils import (collect_parameters_from_request, gen_query_hash, json_dumps, utcnow, to_filename) from redash.utils import collect_parameters_from_request
from redash.models.parameterized_query import (ParameterizedQuery, InvalidParameterError, from redash.utils import gen_query_hash
QueryDetachedFromDataSourceError, dropdown_values) from redash.utils import json_dumps
from redash.serializers import serialize_query_result, serialize_query_result_to_csv, serialize_query_result_to_xlsx from redash.utils import to_filename
from redash.utils import utcnow
def error_response(message, http_status=400): def error_response(message, data=None, http_status=400):
return {'job': {'status': 4, 'error': message}}, http_status return {
"job": {
"status": 4,
"error": message,
"error_data": data
}
}, http_status
error_messages = { error_messages = {
'unsafe_when_shared': error_response('This query contains potentially unsafe parameters and cannot be executed on a shared dashboard or an embedded visualization.', 403), "unsafe_when_shared":
'unsafe_on_view_only': error_response('This query contains potentially unsafe parameters and cannot be executed with read-only access to this data source.', 403), error_response(
'no_permission': error_response('You do not have permission to run queries with this data source.', 403), "This query contains potentially unsafe parameters and cannot be executed on a shared dashboard or an embedded visualization.",
'select_data_source': error_response('Please select data source to run this query.', 401) None,
403,
),
"unsafe_on_view_only":
error_response(
"This query contains potentially unsafe parameters and cannot be executed with read-only access to this data source.",
None,
403,
),
"no_permission":
error_response(
"You do not have permission to run queries with this data source.",
None, 403),
"select_data_source":
error_response("Please select data source to run this query.", None, 401),
} }
def run_query(query, parameters, data_source, query_id, max_age=0): def run_query(query, parameters, data_source, query_id, max_age=0):
if data_source.paused: if data_source.paused:
if data_source.pause_reason: if data_source.pause_reason:
message = '{} is paused ({}). Please try later.'.format(data_source.name, data_source.pause_reason) message = "{} is paused ({}). Please try later.".format(
data_source.name, data_source.pause_reason)
else: else:
message = '{} is paused. Please try later.'.format(data_source.name) message = "{} is paused. Please try later.".format(
data_source.name)
return error_response(message) return error_response(message)
try: try:
query.apply(parameters) query.apply(parameters)
except (InvalidParameterError, QueryDetachedFromDataSourceError) as e: except QueryDetachedFromDataSourceError as e:
abort(400, message=e.message) abort(400, message=e.message)
except InvalidParameterError as e:
return error_response(e.message, {"parameters": e.parameter_errors})
if query.missing_params: missing_params_error = query.missing_params_error
return error_response('Missing parameter value for: {}'.format(", ".join(query.missing_params))) if missing_params_error:
message, parameter_errors = missing_params_error
return error_response(message, {"parameters": parameter_errors})
if max_age == 0: if max_age == 0:
query_result = None query_result = None
else: else:
query_result = models.QueryResult.get_latest(data_source, query.text, max_age) query_result = models.QueryResult.get_latest(data_source, query.text,
max_age)
record_event(current_user.org, current_user, { record_event(
'action': 'execute_query', current_user.org,
'cache': 'hit' if query_result else 'miss', current_user,
'object_id': data_source.id, {
'object_type': 'data_source', "action": "execute_query",
'query': query.text, "cache": "hit" if query_result else "miss",
'query_id': query_id, "object_id": data_source.id,
'parameters': parameters "object_type": "data_source",
}) "query": query.text,
"query_id": query_id,
"parameters": parameters,
},
)
if query_result: if query_result:
return {'query_result': serialize_query_result(query_result, current_user.is_api_user())} return {
"query_result":
serialize_query_result(query_result, current_user.is_api_user())
}
else: else:
job = enqueue_query(query.text, data_source, current_user.id, current_user.is_api_user(), metadata={ job = enqueue_query(
"Username": repr(current_user) if current_user.is_api_user() else current_user.email, query.text,
"Query ID": query_id data_source,
}) current_user.id,
return {'job': job.to_dict()} current_user.is_api_user(),
metadata={
"Username":
repr(current_user)
if current_user.is_api_user() else current_user.email,
"Query ID":
query_id,
},
)
return {"job": job.to_dict()}
def get_download_filename(query_result, query, filetype): def get_download_filename(query_result, query, filetype):
retrieved_at = query_result.retrieved_at.strftime("%Y_%m_%d") retrieved_at = query_result.retrieved_at.strftime("%Y_%m_%d")
if query: if query:
filename = to_filename(query.name) if query.name != '' else str(query.id) filename = to_filename(query.name) if query.name != "" else str(
query.id)
else: else:
filename = str(query_result.id) filename = str(query_result.id)
return "{}_{}.{}".format(filename, retrieved_at, filetype) return "{}_{}.{}".format(filename, retrieved_at, filetype)
class QueryResultListResource(BaseResource): class QueryResultListResource(BaseResource):
@require_permission('execute_query')
@require_permission("execute_query")
def post(self): def post(self):
""" """
Execute a query (or retrieve recent results). Execute a query (or retrieve recent results).
@@ -96,35 +156,40 @@ class QueryResultListResource(BaseResource):
""" """
params = request.get_json(force=True) params = request.get_json(force=True)
query = params['query'] query = params["query"]
max_age = params.get('max_age', -1) max_age = params.get("max_age", -1)
# max_age might have the value of None, in which case calling int(None) will fail # max_age might have the value of None, in which case calling int(None) will fail
if max_age is None: if max_age is None:
max_age = -1 max_age = -1
max_age = int(max_age) max_age = int(max_age)
query_id = params.get('query_id', 'adhoc') query_id = params.get("query_id", "adhoc")
parameters = params.get('parameters', collect_parameters_from_request(request.args)) parameters = params.get("parameters",
collect_parameters_from_request(request.args))
parameterized_query = ParameterizedQuery(query, org=self.current_org) parameterized_query = ParameterizedQuery(query, org=self.current_org)
data_source_id = params.get('data_source_id') data_source_id = params.get("data_source_id")
if data_source_id: if data_source_id:
data_source = models.DataSource.get_by_id_and_org(params.get('data_source_id'), self.current_org) data_source = models.DataSource.get_by_id_and_org(
params.get("data_source_id"), self.current_org)
else: else:
return error_messages['select_data_source'] return error_messages["select_data_source"]
if not has_access(data_source, self.current_user, not_view_only): if not has_access(data_source, self.current_user, not_view_only):
return error_messages['no_permission'] return error_messages["no_permission"]
return run_query(parameterized_query, parameters, data_source, query_id, max_age) return run_query(parameterized_query, parameters, data_source,
query_id, max_age)
ONE_YEAR = 60 * 60 * 24 * 365.25 ONE_YEAR = 60 * 60 * 24 * 365.25
class QueryResultDropdownResource(BaseResource): class QueryResultDropdownResource(BaseResource):
def get(self, query_id): def get(self, query_id):
query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org) query = get_object_or_404(models.Query.get_by_id_and_org, query_id,
self.current_org)
require_access(query.data_source, current_user, view_only) require_access(query.data_source, current_user, view_only)
try: try:
return dropdown_values(query_id, self.current_org) return dropdown_values(query_id, self.current_org)
@@ -133,42 +198,52 @@ class QueryResultDropdownResource(BaseResource):
class QueryDropdownsResource(BaseResource): class QueryDropdownsResource(BaseResource):
def get(self, query_id, dropdown_query_id): def get(self, query_id, dropdown_query_id):
query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org) query = get_object_or_404(models.Query.get_by_id_and_org, query_id,
self.current_org)
require_access(query, current_user, view_only) require_access(query, current_user, view_only)
related_queries_ids = [p['queryId'] for p in query.parameters if p['type'] == 'query'] related_queries_ids = [
p["queryId"] for p in query.parameters if p["type"] == "query"
]
if int(dropdown_query_id) not in related_queries_ids: 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) 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) require_access(dropdown_query.data_source, current_user, view_only)
return dropdown_values(dropdown_query_id, self.current_org) return dropdown_values(dropdown_query_id, self.current_org)
class QueryResultResource(BaseResource): class QueryResultResource(BaseResource):
@staticmethod @staticmethod
def add_cors_headers(headers): def add_cors_headers(headers):
if 'Origin' in request.headers: if "Origin" in request.headers:
origin = request.headers['Origin'] origin = request.headers["Origin"]
if set(['*', origin]) & settings.ACCESS_CONTROL_ALLOW_ORIGIN: if set(["*", origin]) & settings.ACCESS_CONTROL_ALLOW_ORIGIN:
headers['Access-Control-Allow-Origin'] = origin headers["Access-Control-Allow-Origin"] = origin
headers['Access-Control-Allow-Credentials'] = str(settings.ACCESS_CONTROL_ALLOW_CREDENTIALS).lower() headers["Access-Control-Allow-Credentials"] = str(
settings.ACCESS_CONTROL_ALLOW_CREDENTIALS).lower()
@require_permission('view_query') @require_permission("view_query")
def options(self, query_id=None, query_result_id=None, filetype='json'): def options(self, query_id=None, query_result_id=None, filetype="json"):
headers = {} headers = {}
self.add_cors_headers(headers) self.add_cors_headers(headers)
if settings.ACCESS_CONTROL_REQUEST_METHOD: if settings.ACCESS_CONTROL_REQUEST_METHOD:
headers['Access-Control-Request-Method'] = settings.ACCESS_CONTROL_REQUEST_METHOD headers[
"Access-Control-Request-Method"] = settings.ACCESS_CONTROL_REQUEST_METHOD
if settings.ACCESS_CONTROL_ALLOW_HEADERS: if settings.ACCESS_CONTROL_ALLOW_HEADERS:
headers['Access-Control-Allow-Headers'] = settings.ACCESS_CONTROL_ALLOW_HEADERS headers[
"Access-Control-Allow-Headers"] = settings.ACCESS_CONTROL_ALLOW_HEADERS
return make_response("", 200, headers) return make_response("", 200, headers)
@require_permission('view_query') @require_permission("view_query")
def post(self, query_id): def post(self, query_id):
""" """
Execute a saved query. Execute a saved query.
@@ -181,31 +256,39 @@ class QueryResultResource(BaseResource):
always execute. always execute.
""" """
params = request.get_json(force=True, silent=True) or {} params = request.get_json(force=True, silent=True) or {}
parameter_values = params.get('parameters', {}) parameter_values = params.get("parameters", {})
max_age = params.get('max_age', -1) max_age = params.get("max_age", -1)
# max_age might have the value of None, in which case calling int(None) will fail # max_age might have the value of None, in which case calling int(None) will fail
if max_age is None: if max_age is None:
max_age = -1 max_age = -1
max_age = int(max_age) max_age = int(max_age)
query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org) query = get_object_or_404(models.Query.get_by_id_and_org, query_id,
self.current_org)
allow_executing_with_view_only_permissions = query.parameterized.is_safe allow_executing_with_view_only_permissions = query.parameterized.is_safe
if has_access(query, self.current_user, allow_executing_with_view_only_permissions): if has_access(query, self.current_user,
return run_query(query.parameterized, parameter_values, query.data_source, query_id, max_age) allow_executing_with_view_only_permissions):
return run_query(
query.parameterized,
parameter_values,
query.data_source,
query_id,
max_age,
)
else: else:
if not query.parameterized.is_safe: if not query.parameterized.is_safe:
if current_user.is_api_user(): if current_user.is_api_user():
return error_messages['unsafe_when_shared'] return error_messages["unsafe_when_shared"]
else: else:
return error_messages['unsafe_on_view_only'] return error_messages["unsafe_on_view_only"]
else: else:
return error_messages['no_permission'] return error_messages["no_permission"]
@require_permission('view_query') @require_permission("view_query")
def get(self, query_id=None, query_result_id=None, filetype='json'): def get(self, query_id=None, query_result_id=None, filetype="json"):
""" """
Retrieve query results. Retrieve query results.
@@ -228,52 +311,61 @@ class QueryResultResource(BaseResource):
should_cache = query_result_id is not None should_cache = query_result_id is not None
parameter_values = collect_parameters_from_request(request.args) parameter_values = collect_parameters_from_request(request.args)
max_age = int(request.args.get('maxAge', 0)) max_age = int(request.args.get("maxAge", 0))
query_result = None query_result = None
query = None query = None
if query_result_id: if query_result_id:
query_result = get_object_or_404(models.QueryResult.get_by_id_and_org, query_result_id, self.current_org) query_result = get_object_or_404(
models.QueryResult.get_by_id_and_org, query_result_id,
if query_id is not None:
query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org)
if query_result is None and query is not None and query.latest_query_data_id is not None:
query_result = get_object_or_404(models.QueryResult.get_by_id_and_org,
query.latest_query_data_id,
self.current_org) self.current_org)
if query is not None and query_result is not None and self.current_user.is_api_user(): if query_id is not None:
query = get_object_or_404(models.Query.get_by_id_and_org, query_id,
self.current_org)
if (query_result is None and query is not None
and query.latest_query_data_id is not None):
query_result = get_object_or_404(
models.QueryResult.get_by_id_and_org,
query.latest_query_data_id,
self.current_org,
)
if (query is not None and query_result is not None
and self.current_user.is_api_user()):
if query.query_hash != query_result.query_hash: if query.query_hash != query_result.query_hash:
abort(404, message='No cached result found for this query.') abort(404,
message="No cached result found for this query.")
if query_result: if query_result:
require_access(query_result.data_source, self.current_user, view_only) require_access(query_result.data_source, self.current_user,
view_only)
if isinstance(self.current_user, models.ApiUser): if isinstance(self.current_user, models.ApiUser):
event = { event = {
'user_id': None, "user_id": None,
'org_id': self.current_org.id, "org_id": self.current_org.id,
'action': 'api_get', "action": "api_get",
'api_key': self.current_user.name, "api_key": self.current_user.name,
'file_type': filetype, "file_type": filetype,
'user_agent': request.user_agent.string, "user_agent": request.user_agent.string,
'ip': request.remote_addr "ip": request.remote_addr,
} }
if query_id: if query_id:
event['object_type'] = 'query' event["object_type"] = "query"
event['object_id'] = query_id event["object_id"] = query_id
else: else:
event['object_type'] = 'query_result' event["object_type"] = "query_result"
event['object_id'] = query_result_id event["object_id"] = query_result_id
self.record_event(event) self.record_event(event)
if filetype == 'json': if filetype == "json":
response = self.make_json_response(query_result) response = self.make_json_response(query_result)
elif filetype == 'xlsx': elif filetype == "xlsx":
response = self.make_excel_response(query_result) response = self.make_excel_response(query_result)
else: else:
response = self.make_csv_response(query_result) response = self.make_csv_response(query_result)
@@ -282,43 +374,49 @@ class QueryResultResource(BaseResource):
self.add_cors_headers(response.headers) self.add_cors_headers(response.headers)
if should_cache: if should_cache:
response.headers.add_header('Cache-Control', 'private,max-age=%d' % ONE_YEAR) response.headers.add_header("Cache-Control",
"private,max-age=%d" % ONE_YEAR)
filename = get_download_filename(query_result, query, filetype) filename = get_download_filename(query_result, query, filetype)
response.headers.add_header( response.headers.add_header(
"Content-Disposition", "Content-Disposition",
'attachment; filename="{}"'.format(filename) 'attachment; filename="{}"'.format(filename))
)
return response return response
else: else:
abort(404, message='No cached result found for this query.') abort(404, message="No cached result found for this query.")
def make_json_response(self, query_result): def make_json_response(self, query_result):
data = json_dumps({'query_result': query_result.to_dict()}) data = json_dumps({"query_result": query_result.to_dict()})
headers = {'Content-Type': "application/json"} headers = {"Content-Type": "application/json"}
return make_response(data, 200, headers) return make_response(data, 200, headers)
@staticmethod @staticmethod
def make_csv_response(query_result): def make_csv_response(query_result):
headers = {'Content-Type': "text/csv; charset=UTF-8"} headers = {"Content-Type": "text/csv; charset=UTF-8"}
return make_response(serialize_query_result_to_csv(query_result), 200, headers) return make_response(serialize_query_result_to_csv(query_result), 200,
headers)
@staticmethod @staticmethod
def make_excel_response(query_result): def make_excel_response(query_result):
headers = {'Content-Type': "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"} headers = {
return make_response(serialize_query_result_to_xlsx(query_result), 200, headers) "Content-Type":
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
}
return make_response(serialize_query_result_to_xlsx(query_result), 200,
headers)
class JobResource(BaseResource): class JobResource(BaseResource):
def get(self, job_id, query_id=None): def get(self, job_id, query_id=None):
""" """
Retrieve info about a running query job. Retrieve info about a running query job.
""" """
job = QueryTask(job_id=job_id) job = QueryTask(job_id=job_id)
return {'job': job.to_dict()} return {"job": job.to_dict()}
def delete(self, job_id): def delete(self, job_id):
""" """

View File

@@ -1,12 +1,18 @@
import pystache
from functools import partial from functools import partial
from numbers import Number from numbers import Number
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 six import string_types, text_type import pystache
from dateutil.parser import parse
from funcy import compact
from funcy import distinct
from funcy import lpluck
from six import string_types
from six import text_type
from redash.permissions import require_access
from redash.permissions import view_only
from redash.utils import json_loads
from redash.utils import mustache_render
def _pluck_name_and_value(default_column, row): def _pluck_name_and_value(default_column, row):
@@ -23,7 +29,8 @@ def _load_result(query_id, org):
query = models.Query.get_by_id_and_org(query_id, org) query = models.Query.get_by_id_and_org(query_id, org)
if query.data_source: if query.data_source:
query_result = models.QueryResult.get_by_id_and_org(query.latest_query_data_id, org) query_result = models.QueryResult.get_by_id_and_org(
query.latest_query_data_id, org)
return query_result.data return query_result.data
else: else:
raise QueryDetachedFromDataSourceError(query_id) raise QueryDetachedFromDataSourceError(query_id)
@@ -38,14 +45,17 @@ def dropdown_values(query_id, org):
def join_parameter_list_values(parameters, schema): def join_parameter_list_values(parameters, schema):
updated_parameters = {} updated_parameters = {}
for (key, value) in parameters.items(): for key, value in parameters.items():
if isinstance(value, list): if isinstance(value, list):
definition = next((definition for definition in schema if definition["name"] == key), {}) definition = next(
multi_values_options = definition.get('multiValuesOptions', {}) (definition
separator = str(multi_values_options.get('separator', ',')) for definition in schema if definition["name"] == key), {})
prefix = str(multi_values_options.get('prefix', '')) multi_values_options = definition.get("multiValuesOptions", {})
suffix = str(multi_values_options.get('suffix', '')) separator = str(multi_values_options.get("separator", ","))
updated_parameters[key] = separator.join([prefix + v + suffix for v in value]) prefix = str(multi_values_options.get("prefix", ""))
suffix = str(multi_values_options.get("suffix", ""))
updated_parameters[key] = separator.join(
[prefix + v + suffix for v in value])
else: else:
updated_parameters[key] = value updated_parameters[key] = value
return updated_parameters return updated_parameters
@@ -74,7 +84,7 @@ def _parameter_names(parameter_values):
for key, value in parameter_values.items(): for key, value in parameter_values.items():
if isinstance(value, dict): if isinstance(value, dict):
for inner_key in value.keys(): for inner_key in value.keys():
names.append('{}.{}'.format(key, inner_key)) names.append("{}.{}".format(key, inner_key))
else: else:
names.append(key) names.append(key)
@@ -107,13 +117,26 @@ def _is_date_range(obj):
return False return False
def _is_date_range_type(type):
return type in [
"date-range", "datetime-range", "datetime-range-with-seconds"
]
def _is_tag_in_template(name, template):
tags = _collect_query_parameters(template)
return name in tags
def _is_value_within_options(value, dropdown_options, allow_list=False): def _is_value_within_options(value, dropdown_options, allow_list=False):
if isinstance(value, list): if isinstance(value, list):
return allow_list and set(map(text_type, value)).issubset(set(dropdown_options)) return allow_list and set(map(text_type, value)).issubset(
set(dropdown_options))
return text_type(value) in dropdown_options return text_type(value) in dropdown_options
class ParameterizedQuery(object): class ParameterizedQuery(object):
def __init__(self, template, schema=None, org=None): def __init__(self, template, schema=None, org=None):
self.schema = schema or [] self.schema = schema or []
self.org = org self.org = org
@@ -122,76 +145,164 @@ class ParameterizedQuery(object):
self.parameters = {} self.parameters = {}
def apply(self, parameters): def apply(self, parameters):
invalid_parameter_names = [key for (key, value) in parameters.items() if not self._valid(key, value)] # filter out params not defined in schema
if invalid_parameter_names: if self.schema:
raise InvalidParameterError(invalid_parameter_names) names_with_definition = lpluck("name", self.schema)
parameters = {
k: v
for (k, v) in parameters.items() if k in names_with_definition
}
invalid_parameters = compact(
{k: self._invalid_message(k, v)
for (k, v) in parameters.items()})
if invalid_parameters:
raise InvalidParameterError(invalid_parameters)
else: else:
self.parameters.update(parameters) self.parameters.update(parameters)
self.query = mustache_render(self.template, join_parameter_list_values(parameters, self.schema)) self.query = mustache_render(
self.template,
join_parameter_list_values(parameters, self.schema))
return self return self
def _valid(self, name, value): def _invalid_message(self, name, value):
if not self.schema: if value is None:
return True return "Required parameter"
definition = next((definition for definition in self.schema if definition["name"] == name), None) # skip if no schema
if not self.schema:
return None
definition = next(
(definition
for definition in self.schema if definition["name"] == name),
None,
)
if not definition: if not definition:
return False return "Parameter no longer exists in query."
enum_options = definition.get('enumOptions') enum_options = definition.get("enumOptions")
query_id = definition.get('queryId') query_id = definition.get("queryId")
allow_multiple_values = isinstance(definition.get('multiValuesOptions'), dict) allow_multiple_values = isinstance(
definition.get("multiValuesOptions"), dict)
if isinstance(enum_options, string_types): if isinstance(enum_options, string_types):
enum_options = enum_options.split('\n') enum_options = enum_options.split("\n")
validators = { value_validators = {
"text": lambda value: isinstance(value, string_types), "text":
"number": _is_number, lambda value: isinstance(value, string_types),
"enum": lambda value: _is_value_within_options(value, "number":
enum_options, _is_number,
"enum":
lambda value: _is_value_within_options(value, enum_options,
allow_multiple_values), allow_multiple_values),
"query": lambda value: _is_value_within_options(value, "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_id, self.org)],
allow_multiple_values), allow_multiple_values,
"date": _is_date, ),
"datetime-local": _is_date, "date":
"datetime-with-seconds": _is_date, _is_date,
"date-range": _is_date_range, "datetime-local":
"datetime-range": _is_date_range, _is_date,
"datetime-range-with-seconds": _is_date_range, "datetime-with-seconds":
_is_date,
"date-range":
_is_date_range,
"datetime-range":
_is_date_range,
"datetime-range-with-seconds":
_is_date_range,
} }
validate = validators.get(definition["type"], lambda x: False) validate_value = value_validators.get(definition["type"],
lambda x: False)
return validate(value) if not validate_value(value):
return "Invalid value"
tag_error_msg = self._validate_tag(name, definition["type"])
if tag_error_msg is not None:
return tag_error_msg
return None
def _validate_tag(self, name, type):
error_msg = "{{{{ {0} }}}} not found in query"
if _is_date_range_type(type):
start_tag = "{}.start".format(name)
if not _is_tag_in_template(start_tag, self.template):
return error_msg.format(start_tag)
end_tag = "{}.end".format(name)
if not _is_tag_in_template(end_tag, self.template):
return error_msg.format(end_tag)
elif not _is_tag_in_template(name, self.template):
return error_msg.format(name)
return None
@property @property
def is_safe(self): def is_safe(self):
text_parameters = [param for param in self.schema if param["type"] == "text"] text_parameters = [
param for param in self.schema if param["type"] == "text"
]
return not any(text_parameters) return not any(text_parameters)
@property @property
def missing_params(self): def missing_params(self):
query_parameters = set(_collect_query_parameters(self.template)) query_parameters = _collect_query_parameters(self.template)
return set(query_parameters) - set(_parameter_names(self.parameters)) return set(query_parameters) - set(_parameter_names(self.parameters))
@property
def missing_params_error(self):
missing_params = self.missing_params
if not missing_params:
return None
parameter_names = ", ".join('"{}"'.format(name)
for name in sorted(missing_params))
if len(missing_params) > 1:
message = "Parameters {} are missing.".format(parameter_names)
else:
message = "Parameter {} is missing.".format(parameter_names)
parameter_errors = {
name: "Missing parameter"
for name in missing_params
}
return message, parameter_errors
@property @property
def text(self): def text(self):
return self.query return self.query
class InvalidParameterError(Exception): class InvalidParameterError(Exception):
def __init__(self, parameters):
parameter_names = ", ".join(parameters) def __init__(self, parameter_errors):
message = "The following parameter values are incompatible with their definitions: {}".format(parameter_names) parameter_names = ", ".join(
super(InvalidParameterError, self).__init__(message) '"{}"'.format(name) for name in sorted(parameter_errors.keys()))
if len(parameter_errors) > 1:
message = "Parameters {} are invalid.".format(parameter_names)
else:
message = "Parameter {} is invalid.".format(parameter_names)
self.message = message
self.parameter_errors = parameter_errors
super().__init__(message, parameter_errors)
class QueryDetachedFromDataSourceError(Exception): class QueryDetachedFromDataSourceError(Exception):
def __init__(self, query_id): def __init__(self, query_id):
self.query_id = query_id self.query_id = query_id
super(QueryDetachedFromDataSourceError, self).__init__( self.message = "This query is detached from any data source. Please select a different query."
"This query is detached from any data source. Please select a different query.")
super().__init__(self.message)

View File

@@ -1,56 +1,178 @@
from unittest import TestCase
from mock import patch
from collections import namedtuple from collections import namedtuple
import pytest from unittest import TestCase
from redash.models.parameterized_query import ParameterizedQuery, InvalidParameterError, QueryDetachedFromDataSourceError, dropdown_values import pytest
from mock import patch
from redash.models.parameterized_query import dropdown_values
from redash.models.parameterized_query import InvalidParameterError
from redash.models.parameterized_query import ParameterizedQuery
from redash.models.parameterized_query import QueryDetachedFromDataSourceError
class TestParameterizedQuery(TestCase): class TestParameterizedQuery(TestCase):
def test_returns_empty_list_for_regular_query(self): def test_returns_empty_list_for_regular_query(self):
query = ParameterizedQuery("SELECT 1") query = ParameterizedQuery("SELECT 1")
self.assertEqual(set([]), query.missing_params) self.assertEqual(set([]), query.missing_params)
def test_finds_all_params_when_missing(self): def test_finds_all_params_when_missing(self):
query = ParameterizedQuery("SELECT {{param}} FROM {{table}}") query = ParameterizedQuery("SELECT {{param}} FROM {{table}}")
self.assertEqual(set(['param', 'table']), query.missing_params) self.assertEqual(set(["param", "table"]), query.missing_params)
def test_finds_all_params(self): def test_finds_all_params(self):
query = ParameterizedQuery("SELECT {{param}} FROM {{table}}").apply({ query = ParameterizedQuery("SELECT {{param}} FROM {{table}}").apply({
'param': 'value', "param":
'table': 'value' "value",
"table":
"value"
}) })
self.assertEqual(set([]), query.missing_params) self.assertEqual(set([]), query.missing_params)
def test_deduplicates_params(self): def test_deduplicates_params(self):
query = ParameterizedQuery("SELECT {{param}}, {{param}} FROM {{table}}").apply({ query = ParameterizedQuery(
'param': 'value', "SELECT {{param}}, {{param}} FROM {{table}}").apply({
'table': 'value' "param":
"value",
"table":
"value"
}) })
self.assertEqual(set([]), query.missing_params) self.assertEqual(set([]), query.missing_params)
def test_handles_nested_params(self): def test_handles_nested_params(self):
query = ParameterizedQuery("SELECT {{param}}, {{param}} FROM {{table}} -- {{#test}} {{nested_param}} {{/test}}").apply({ query = ParameterizedQuery(
'param': 'value', "SELECT {{param}}, {{param}} FROM {{table}} -- {{#test}} {{nested_param}} {{/test}}"
'table': 'value' ).apply({
"param": "value",
"table": "value"
}) })
self.assertEqual(set(['test', 'nested_param']), query.missing_params) self.assertEqual(set(["test", "nested_param"]), query.missing_params)
def test_handles_objects(self): def test_handles_objects(self):
query = ParameterizedQuery("SELECT * FROM USERS WHERE created_at between '{{ created_at.start }}' and '{{ created_at.end }}'").apply({ query = ParameterizedQuery(
'created_at': { "SELECT * FROM USERS WHERE created_at between '{{ created_at.start }}' and '{{ created_at.end }}'"
'start': 1, ).apply({"created_at": {
'end': 2 "start": 1,
} "end": 2
}) }})
self.assertEqual(set([]), query.missing_params) self.assertEqual(set([]), query.missing_params)
def test_raises_on_parameters_not_in_schema(self): def test_single_invalid_parameter_exception(self):
query = ParameterizedQuery("foo")
with pytest.raises(InvalidParameterError) as excinfo:
query.apply({"bar": None})
message, parameter_errors = excinfo.value.args
self.assertEquals(message, 'Parameter "bar" is invalid.')
self.assertEquals(len(parameter_errors), 1)
def test_multiple_invalid_parameter_exception(self):
query = ParameterizedQuery("foo")
with pytest.raises(InvalidParameterError) as excinfo:
query.apply({"bar": None, "baz": None})
message, parameter_errors = excinfo.value.args
self.assertEquals(message, 'Parameters "bar", "baz" are invalid.')
self.assertEquals(len(parameter_errors), 2)
def test_invalid_parameter_error_messages(self):
schema = [
{
"name": "bar",
"type": "text"
},
{
"name": "baz",
"type": "text"
},
{
"name": "foo",
"type": "text"
},
{
"name": "spam",
"type": "date-range"
},
{
"name": "ham",
"type": "date-range"
},
{
"name": "eggs",
"type": "number"
},
]
parameters = {
"bar": None,
"baz": 7,
"foo": "text",
"spam": {
"start": "2000-01-01 12:00:00",
"end": "2000-12-31 12:00:00"
},
"ham": {
"start": "2000-01-01 12:00:00",
"end": "2000-12-31 12:00:00"
},
"eggs": 42,
}
query = ParameterizedQuery(
"foo {{ spam }} {{ ham.start}} {{ eggs.start }}", schema)
with pytest.raises(InvalidParameterError) as excinfo:
query.apply(parameters)
_, parameter_errors = excinfo.value.args
self.assertEquals(
parameter_errors,
{
"bar": "Required parameter",
"baz": "Invalid value",
"foo": "{{ foo }} not found in query",
"spam": "{{ spam.start }} not found in query",
"ham": "{{ ham.end }} not found in query",
"eggs": "{{ eggs }} not found in query",
},
)
def test_single_missing_parameter_error(self):
query = ParameterizedQuery("foo {{ bar }}")
message, parameter_errors = query.missing_params_error
self.assertEquals(message, 'Parameter "bar" is missing.')
self.assertEquals(len(parameter_errors), 1)
def test_multiple_missing_parameter_error(self):
query = ParameterizedQuery("foo {{ bar }} {{ baz }}")
message, parameter_errors = query.missing_params_error
self.assertEquals(message, 'Parameters "bar", "baz" are missing.')
self.assertEquals(len(parameter_errors), 2)
def test_missing_parameter_error_message(self):
query = ParameterizedQuery("foo {{ bar }}")
_, parameter_errors = query.missing_params_error
self.assertEquals(parameter_errors, {"bar": "Missing parameter"})
def test_ignores_parameters_not_in_schema(self):
schema = [{"name": "bar", "type": "text"}]
query = ParameterizedQuery("foo {{ bar }}", schema)
with pytest.raises(InvalidParameterError) as excinfo:
query.apply({"qux": 7, "bar": 7})
_, parameter_errors = excinfo.value.args
self.assertTrue("bar" in parameter_errors)
self.assertFalse("qux" in parameter_errors)
def test_passes_on_parameters_not_in_schema(self):
schema = [{"name": "bar", "type": "text"}] schema = [{"name": "bar", "type": "text"}]
query = ParameterizedQuery("foo", schema) query = ParameterizedQuery("foo", schema)
with pytest.raises(InvalidParameterError): try:
query.apply({"qux": 7}) query.apply({"qux": None})
except InvalidParameterError:
pytest.fail("Unexpected InvalidParameterError")
def test_raises_on_invalid_text_parameters(self): def test_raises_on_invalid_text_parameters(self):
schema = [{"name": "bar", "type": "text"}] schema = [{"name": "bar", "type": "text"}]
@@ -113,14 +235,22 @@ class TestParameterizedQuery(TestCase):
self.assertEqual("foo 2000-01-01 12:00:00", query.text) self.assertEqual("foo 2000-01-01 12:00:00", query.text)
def test_raises_on_invalid_enum_parameters(self): def test_raises_on_invalid_enum_parameters(self):
schema = [{"name": "bar", "type": "enum", "enumOptions": ["baz", "qux"]}] schema = [{
"name": "bar",
"type": "enum",
"enumOptions": ["baz", "qux"]
}]
query = ParameterizedQuery("foo", schema) query = ParameterizedQuery("foo", schema)
with pytest.raises(InvalidParameterError): with pytest.raises(InvalidParameterError):
query.apply({"bar": 7}) query.apply({"bar": 7})
def test_raises_on_unlisted_enum_value_parameters(self): def test_raises_on_unlisted_enum_value_parameters(self):
schema = [{"name": "bar", "type": "enum", "enumOptions": ["baz", "qux"]}] schema = [{
"name": "bar",
"type": "enum",
"enumOptions": ["baz", "qux"]
}]
query = ParameterizedQuery("foo", schema) query = ParameterizedQuery("foo", schema)
with pytest.raises(InvalidParameterError): with pytest.raises(InvalidParameterError):
@@ -131,7 +261,11 @@ class TestParameterizedQuery(TestCase):
"name": "bar", "name": "bar",
"type": "enum", "type": "enum",
"enumOptions": ["baz", "qux"], "enumOptions": ["baz", "qux"],
"multiValuesOptions": {"separator": ",", "prefix": "", "suffix": ""} "multiValuesOptions": {
"separator": ",",
"prefix": "",
"suffix": ""
},
}] }]
query = ParameterizedQuery("foo", schema) query = ParameterizedQuery("foo", schema)
@@ -139,7 +273,11 @@ class TestParameterizedQuery(TestCase):
query.apply({"bar": ["shlomo", "baz"]}) query.apply({"bar": ["shlomo", "baz"]})
def test_validates_enum_parameters(self): def test_validates_enum_parameters(self):
schema = [{"name": "bar", "type": "enum", "enumOptions": ["baz", "qux"]}] schema = [{
"name": "bar",
"type": "enum",
"enumOptions": ["baz", "qux"]
}]
query = ParameterizedQuery("foo {{bar}}", schema) query = ParameterizedQuery("foo {{bar}}", schema)
query.apply({"bar": "baz"}) query.apply({"bar": "baz"})
@@ -151,7 +289,11 @@ class TestParameterizedQuery(TestCase):
"name": "bar", "name": "bar",
"type": "enum", "type": "enum",
"enumOptions": ["baz", "qux"], "enumOptions": ["baz", "qux"],
"multiValuesOptions": {"separator": ",", "prefix": "'", "suffix": "'"} "multiValuesOptions": {
"separator": ",",
"prefix": "'",
"suffix": "'"
},
}] }]
query = ParameterizedQuery("foo {{bar}}", schema) query = ParameterizedQuery("foo {{bar}}", schema)
@@ -159,7 +301,12 @@ class TestParameterizedQuery(TestCase):
self.assertEqual("foo 'qux','baz'", query.text) self.assertEqual("foo 'qux','baz'", query.text)
@patch('redash.models.parameterized_query.dropdown_values', return_value=[{"value": "1"}]) @patch(
"redash.models.parameterized_query.dropdown_values",
return_value=[{
"value": "1"
}],
)
def test_validation_accepts_integer_values_for_dropdowns(self, _): def test_validation_accepts_integer_values_for_dropdowns(self, _):
schema = [{"name": "bar", "type": "query", "queryId": 1}] schema = [{"name": "bar", "type": "query", "queryId": 1}]
query = ParameterizedQuery("foo {{bar}}", schema) query = ParameterizedQuery("foo {{bar}}", schema)
@@ -168,7 +315,7 @@ class TestParameterizedQuery(TestCase):
self.assertEqual("foo 1", query.text) self.assertEqual("foo 1", query.text)
@patch('redash.models.parameterized_query.dropdown_values') @patch("redash.models.parameterized_query.dropdown_values")
def test_raises_on_invalid_query_parameters(self, _): def test_raises_on_invalid_query_parameters(self, _):
schema = [{"name": "bar", "type": "query", "queryId": 1}] schema = [{"name": "bar", "type": "query", "queryId": 1}]
query = ParameterizedQuery("foo", schema) query = ParameterizedQuery("foo", schema)
@@ -176,7 +323,12 @@ class TestParameterizedQuery(TestCase):
with pytest.raises(InvalidParameterError): with pytest.raises(InvalidParameterError):
query.apply({"bar": 7}) query.apply({"bar": 7})
@patch('redash.models.parameterized_query.dropdown_values', return_value=[{"value": "baz"}]) @patch(
"redash.models.parameterized_query.dropdown_values",
return_value=[{
"value": "baz"
}],
)
def test_raises_on_unlisted_query_value_parameters(self, _): def test_raises_on_unlisted_query_value_parameters(self, _):
schema = [{"name": "bar", "type": "query", "queryId": 1}] schema = [{"name": "bar", "type": "query", "queryId": 1}]
query = ParameterizedQuery("foo", schema) query = ParameterizedQuery("foo", schema)
@@ -184,7 +336,12 @@ class TestParameterizedQuery(TestCase):
with pytest.raises(InvalidParameterError): with pytest.raises(InvalidParameterError):
query.apply({"bar": "shlomo"}) query.apply({"bar": "shlomo"})
@patch('redash.models.parameterized_query.dropdown_values', return_value=[{"value": "baz"}]) @patch(
"redash.models.parameterized_query.dropdown_values",
return_value=[{
"value": "baz"
}],
)
def test_validates_query_parameters(self, _): def test_validates_query_parameters(self, _):
schema = [{"name": "bar", "type": "query", "queryId": 1}] schema = [{"name": "bar", "type": "query", "queryId": 1}]
query = ParameterizedQuery("foo {{bar}}", schema) query = ParameterizedQuery("foo {{bar}}", schema)
@@ -204,9 +361,15 @@ class TestParameterizedQuery(TestCase):
schema = [{"name": "bar", "type": "date-range"}] schema = [{"name": "bar", "type": "date-range"}]
query = ParameterizedQuery("foo {{bar.start}} {{bar.end}}", schema) query = ParameterizedQuery("foo {{bar.start}} {{bar.end}}", schema)
query.apply({"bar": {"start": "2000-01-01 12:00:00", "end": "2000-12-31 12:00:00"}}) query.apply({
"bar": {
"start": "2000-01-01 12:00:00",
"end": "2000-12-31 12:00:00"
}
})
self.assertEqual("foo 2000-01-01 12:00:00 2000-12-31 12:00:00", query.text) self.assertEqual("foo 2000-01-01 12:00:00 2000-12-31 12:00:00",
query.text)
def test_raises_on_unexpected_param_types(self): def test_raises_on_unexpected_param_types(self):
schema = [{"name": "bar", "type": "burrito"}] schema = [{"name": "bar", "type": "burrito"}]
@@ -233,28 +396,74 @@ class TestParameterizedQuery(TestCase):
self.assertTrue(query.is_safe) self.assertTrue(query.is_safe)
@patch('redash.models.parameterized_query._load_result', return_value={ @patch(
"columns": [{"name": "id"}, {"name": "Name"}, {"name": "Value"}], "redash.models.parameterized_query._load_result",
"rows": [{"id": 5, "Name": "John", "Value": "John Doe"}]}) return_value={
"columns": [{
"name": "id"
}, {
"name": "Name"
}, {
"name": "Value"
}],
"rows": [{
"id": 5,
"Name": "John",
"Value": "John Doe"
}],
},
)
def test_dropdown_values_prefers_name_and_value_columns(self, _): def test_dropdown_values_prefers_name_and_value_columns(self, _):
values = dropdown_values(1, None) values = dropdown_values(1, None)
self.assertEqual(values, [{"name": "John", "value": "John Doe"}]) self.assertEqual(values, [{"name": "John", "value": "John Doe"}])
@patch('redash.models.parameterized_query._load_result', return_value={ @patch(
"columns": [{"name": "id"}, {"name": "fish"}, {"name": "poultry"}], "redash.models.parameterized_query._load_result",
"rows": [{"fish": "Clown", "id": 5, "poultry": "Hen"}]}) return_value={
"columns": [{
"name": "id"
}, {
"name": "fish"
}, {
"name": "poultry"
}],
"rows": [{
"fish": "Clown",
"id": 5,
"poultry": "Hen"
}],
},
)
def test_dropdown_values_compromises_for_first_column(self, _): def test_dropdown_values_compromises_for_first_column(self, _):
values = dropdown_values(1, None) values = dropdown_values(1, None)
self.assertEqual(values, [{"name": 5, "value": "5"}]) self.assertEqual(values, [{"name": 5, "value": "5"}])
@patch('redash.models.parameterized_query._load_result', return_value={ @patch(
"columns": [{"name": "ID"}, {"name": "fish"}, {"name": "poultry"}], "redash.models.parameterized_query._load_result",
"rows": [{"fish": "Clown", "ID": 5, "poultry": "Hen"}]}) return_value={
"columns": [{
"name": "ID"
}, {
"name": "fish"
}, {
"name": "poultry"
}],
"rows": [{
"fish": "Clown",
"ID": 5,
"poultry": "Hen"
}],
},
)
def test_dropdown_supports_upper_cased_columns(self, _): def test_dropdown_supports_upper_cased_columns(self, _):
values = dropdown_values(1, None) values = dropdown_values(1, None)
self.assertEqual(values, [{"name": 5, "value": "5"}]) self.assertEqual(values, [{"name": 5, "value": "5"}])
@patch('redash.models.Query.get_by_id_and_org', return_value=namedtuple('Query', 'data_source')(None)) @patch(
def test_dropdown_values_raises_when_query_is_detached_from_data_source(self, _): "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, _):
with pytest.raises(QueryDetachedFromDataSourceError): with pytest.raises(QueryDetachedFromDataSourceError):
dropdown_values(1, None) dropdown_values(1, None)