Compare commits

..

31 Commits

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

View File

@@ -1,10 +1,10 @@
import React, { useMemo } from "react"; import { first } from "lodash";
import { first, includes } from "lodash"; import React, { useState } from "react";
import Button from "antd/lib/button";
import Menu from "antd/lib/menu"; import Menu from "antd/lib/menu";
import Link from "@/components/Link"; import Link from "@/components/Link";
import HelpTrigger from "@/components/HelpTrigger"; import HelpTrigger from "@/components/HelpTrigger";
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog"; import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
import { useCurrentRoute } from "@/components/ApplicationArea/Router";
import { Auth, currentUser } from "@/services/auth"; import { Auth, currentUser } from "@/services/auth";
import settingsMenu from "@/services/settingsMenu"; import settingsMenu from "@/services/settingsMenu";
import logoUrl from "@/assets/images/redash_icon_small.png"; import logoUrl from "@/assets/images/redash_icon_small.png";
@@ -15,50 +15,29 @@ import AlertOutlinedIcon from "@ant-design/icons/AlertOutlined";
import PlusOutlinedIcon from "@ant-design/icons/PlusOutlined"; import PlusOutlinedIcon from "@ant-design/icons/PlusOutlined";
import QuestionCircleOutlinedIcon from "@ant-design/icons/QuestionCircleOutlined"; import QuestionCircleOutlinedIcon from "@ant-design/icons/QuestionCircleOutlined";
import SettingOutlinedIcon from "@ant-design/icons/SettingOutlined"; import SettingOutlinedIcon from "@ant-design/icons/SettingOutlined";
import MenuUnfoldOutlinedIcon from "@ant-design/icons/MenuUnfoldOutlined";
import MenuFoldOutlinedIcon from "@ant-design/icons/MenuFoldOutlined";
import VersionInfo from "./VersionInfo"; import VersionInfo from "./VersionInfo";
import "./DesktopNavbar.less"; import "./DesktopNavbar.less";
function NavbarSection({ children, ...props }) { function NavbarSection({ inlineCollapsed, children, ...props }) {
return ( return (
<Menu selectable={false} mode="vertical" theme="dark" {...props}> <Menu
selectable={false}
mode={inlineCollapsed ? "inline" : "vertical"}
inlineCollapsed={inlineCollapsed}
theme="dark"
{...props}>
{children} {children}
</Menu> </Menu>
); );
} }
function useNavbarActiveState() {
const currentRoute = useCurrentRoute();
return useMemo(
() => ({
dashboards: includes(
["Dashboards.List", "Dashboards.Favorites", "Dashboards.ViewOrEdit", "Dashboards.LegacyViewOrEdit"],
currentRoute.id
),
queries: includes(
[
"Queries.List",
"Queries.Favorites",
"Queries.Archived",
"Queries.My",
"Queries.View",
"Queries.New",
"Queries.Edit",
],
currentRoute.id
),
dataSources: includes(["DataSources.List"], currentRoute.id),
alerts: includes(["Alerts.List", "Alerts.New", "Alerts.View", "Alerts.Edit"], currentRoute.id),
}),
[currentRoute.id]
);
}
export default function DesktopNavbar() { export default function DesktopNavbar() {
const firstSettingsTab = first(settingsMenu.getAvailableItems()); const [collapsed, setCollapsed] = useState(true);
const activeState = useNavbarActiveState(); const firstSettingsTab = first(settingsMenu.getAvailableItems());
const canCreateQuery = currentUser.hasPermission("create_query"); const canCreateQuery = currentUser.hasPermission("create_query");
const canCreateDashboard = currentUser.hasPermission("create_dashboard"); const canCreateDashboard = currentUser.hasPermission("create_dashboard");
@@ -66,7 +45,7 @@ export default function DesktopNavbar() {
return ( return (
<div className="desktop-navbar"> <div className="desktop-navbar">
<NavbarSection className="desktop-navbar-logo"> <NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-logo">
<div> <div>
<Link href="./"> <Link href="./">
<img src={logoUrl} alt="Redash" /> <img src={logoUrl} alt="Redash" />
@@ -74,43 +53,45 @@ export default function DesktopNavbar() {
</div> </div>
</NavbarSection> </NavbarSection>
<NavbarSection> <NavbarSection inlineCollapsed={collapsed}>
{currentUser.hasPermission("list_dashboards") && ( {currentUser.hasPermission("list_dashboards") && (
<Menu.Item key="dashboards" className={activeState.dashboards ? "navbar-active-item" : null}> <Menu.Item key="dashboards">
<Link href="dashboards"> <Link href="dashboards">
<DesktopOutlinedIcon /> <DesktopOutlinedIcon />
<span className="desktop-navbar-label">Dashboards</span> <span>Dashboards</span>
</Link> </Link>
</Menu.Item> </Menu.Item>
)} )}
{currentUser.hasPermission("view_query") && ( {currentUser.hasPermission("view_query") && (
<Menu.Item key="queries" className={activeState.queries ? "navbar-active-item" : null}> <Menu.Item key="queries">
<Link href="queries"> <Link href="queries">
<CodeOutlinedIcon /> <CodeOutlinedIcon />
<span className="desktop-navbar-label">Queries</span> <span>Queries</span>
</Link> </Link>
</Menu.Item> </Menu.Item>
)} )}
{currentUser.hasPermission("list_alerts") && ( {currentUser.hasPermission("list_alerts") && (
<Menu.Item key="alerts" className={activeState.alerts ? "navbar-active-item" : null}> <Menu.Item key="alerts">
<Link href="alerts"> <Link href="alerts">
<AlertOutlinedIcon /> <AlertOutlinedIcon />
<span className="desktop-navbar-label">Alerts</span> <span>Alerts</span>
</Link> </Link>
</Menu.Item> </Menu.Item>
)} )}
</NavbarSection> </NavbarSection>
<NavbarSection className="desktop-navbar-spacer"> <NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-spacer">
{(canCreateQuery || canCreateDashboard || canCreateAlert) && <Menu.Divider />}
{(canCreateQuery || canCreateDashboard || canCreateAlert) && ( {(canCreateQuery || canCreateDashboard || canCreateAlert) && (
<Menu.SubMenu <Menu.SubMenu
key="create" key="create"
popupClassName="desktop-navbar-submenu" popupClassName="desktop-navbar-submenu"
data-test="CreateButton"
title={ title={
<React.Fragment> <React.Fragment>
<span data-test="CreateButton">
<PlusOutlinedIcon /> <PlusOutlinedIcon />
<span className="desktop-navbar-label">Create</span> <span>Create</span>
</span>
</React.Fragment> </React.Fragment>
}> }>
{canCreateQuery && ( {canCreateQuery && (
@@ -138,30 +119,32 @@ export default function DesktopNavbar() {
)} )}
</NavbarSection> </NavbarSection>
<NavbarSection> <NavbarSection inlineCollapsed={collapsed}>
<Menu.Item key="help"> <Menu.Item key="help">
<HelpTrigger showTooltip={false} type="HOME"> <HelpTrigger showTooltip={false} type="HOME">
<QuestionCircleOutlinedIcon /> <QuestionCircleOutlinedIcon />
<span className="desktop-navbar-label">Help</span> <span>Help</span>
</HelpTrigger> </HelpTrigger>
</Menu.Item> </Menu.Item>
{firstSettingsTab && ( {firstSettingsTab && (
<Menu.Item key="settings" className={activeState.dataSources ? "navbar-active-item" : null}> <Menu.Item key="settings">
<Link href={firstSettingsTab.path} data-test="SettingsLink"> <Link href={firstSettingsTab.path} data-test="SettingsLink">
<SettingOutlinedIcon /> <SettingOutlinedIcon />
<span className="desktop-navbar-label">Settings</span> <span>Settings</span>
</Link> </Link>
</Menu.Item> </Menu.Item>
)} )}
<Menu.Divider />
</NavbarSection> </NavbarSection>
<NavbarSection className="desktop-navbar-profile-menu"> <NavbarSection inlineCollapsed={collapsed} className="desktop-navbar-profile-menu">
<Menu.SubMenu <Menu.SubMenu
key="profile" key="profile"
popupClassName="desktop-navbar-submenu" popupClassName="desktop-navbar-submenu"
title={ title={
<span data-test="ProfileDropdown" className="desktop-navbar-profile-menu-title"> <span data-test="ProfileDropdown" className="desktop-navbar-profile-menu-title">
<img className="profile__image_thumb" src={currentUser.profile_image_url} alt={currentUser.name} /> <img className="profile__image_thumb" src={currentUser.profile_image_url} alt={currentUser.name} />
<span>{currentUser.name}</span>
</span> </span>
}> }>
<Menu.Item key="profile"> <Menu.Item key="profile">
@@ -184,6 +167,10 @@ export default function DesktopNavbar() {
</Menu.Item> </Menu.Item>
</Menu.SubMenu> </Menu.SubMenu>
</NavbarSection> </NavbarSection>
<Button onClick={() => setCollapsed(!collapsed)} className="desktop-navbar-collapse-button">
{collapsed ? <MenuUnfoldOutlinedIcon /> : <MenuFoldOutlinedIcon />}
</Button>
</div> </div>
); );
} }

View File

@@ -1,17 +1,12 @@
@backgroundColor: #001529; @backgroundColor: #001529;
@dividerColor: rgba(255, 255, 255, 0.5); @dividerColor: rgba(255, 255, 255, 0.5);
@textColor: rgba(255, 255, 255, 0.75); @textColor: rgba(255, 255, 255, 0.75);
@brandColor: #ff7964; // Redash logo color
@activeItemColor: @brandColor;
@iconSize: 26px;
.desktop-navbar { .desktop-navbar {
background: @backgroundColor; background: @backgroundColor;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
width: 80px;
overflow: hidden;
&-spacer { &-spacer {
flex: 1 1 auto; flex: 1 1 auto;
@@ -26,6 +21,12 @@
height: 40px; height: 40px;
transition: all 270ms; transition: all 270ms;
} }
&.ant-menu-inline-collapsed {
img {
height: 20px;
}
}
} }
.help-trigger { .help-trigger {
@@ -33,19 +34,26 @@
} }
.ant-menu { .ant-menu {
&:not(.ant-menu-inline-collapsed) {
width: 170px;
}
&.ant-menu-inline-collapsed > .ant-menu-submenu-title span img + span,
&.ant-menu-inline-collapsed > .ant-menu-item i + span {
display: inline-block;
max-width: 0;
opacity: 0;
}
.ant-menu-item-divider {
background: @dividerColor;
}
.ant-menu-item, .ant-menu-item,
.ant-menu-submenu { .ant-menu-submenu {
font-weight: 500; font-weight: 500;
color: @textColor; color: @textColor;
&.navbar-active-item {
box-shadow: inset 3px 0 0 @activeItemColor;
.anticon {
color: @activeItemColor;
}
}
&.ant-menu-submenu-open, &.ant-menu-submenu-open,
&.ant-menu-submenu-active, &.ant-menu-submenu-active,
&:hover, &:hover,
@@ -53,16 +61,6 @@
color: #fff; color: #fff;
} }
.anticon {
font-size: @iconSize;
margin: 0;
}
.desktop-navbar-label {
margin-top: 4px;
font-size: 11px;
}
a, a,
span, span,
.anticon { .anticon {
@@ -73,33 +71,21 @@
.ant-menu-submenu-arrow { .ant-menu-submenu-arrow {
display: none; display: none;
} }
.ant-menu-item,
.ant-menu-submenu {
padding: 0;
height: 60px;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
} }
.ant-menu-submenu-title { .ant-btn.desktop-navbar-collapse-button {
width: 100%; background-color: @backgroundColor;
padding: 0; border: 0;
border-radius: 0;
color: @textColor;
&:hover,
&:active {
color: #fff;
} }
a, &:after {
&.ant-menu-vertical > .ant-menu-submenu > .ant-menu-submenu-title, animation: 0s !important;
.ant-menu-submenu-title {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
line-height: normal;
height: auto;
background: none;
color: inherit;
} }
} }
@@ -113,8 +99,37 @@
.profile__image_thumb { .profile__image_thumb {
margin: 0; margin: 0;
vertical-align: middle; vertical-align: middle;
width: @iconSize; }
height: @iconSize;
.profile__image_thumb + span {
flex: 1 1 auto;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-left: 10px;
vertical-align: middle;
display: inline-block;
// styles from Antd
opacity: 1;
transition: opacity 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
margin-left 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), width 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
}
}
&.ant-menu-inline-collapsed {
.ant-menu-submenu-title {
padding-left: 16px !important;
padding-right: 16px !important;
}
.desktop-navbar-profile-menu-title {
.profile__image_thumb + span {
opacity: 0;
max-width: 0;
margin-left: 0;
}
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,7 +52,7 @@ export default function MenuButton({ doDelete, canEdit, mute, unmute, muted }) {
)} )}
</Menu.Item> </Menu.Item>
<Menu.Item> <Menu.Item>
<a onClick={confirmDelete}>Delete</a> <a onClick={confirmDelete}>Delete Alert</a>
</Menu.Item> </Menu.Item>
</Menu> </Menu>
}> }>

View File

@@ -29,7 +29,7 @@ export function QuerySourceDropdown(props) {
QuerySourceDropdown.propTypes = { QuerySourceDropdown.propTypes = {
dataSources: PropTypes.any, dataSources: PropTypes.any,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), value: PropTypes.string,
disabled: PropTypes.bool, disabled: PropTypes.bool,
loading: PropTypes.bool, loading: PropTypes.bool,
onChange: PropTypes.func, onChange: PropTypes.func,

View File

@@ -15,7 +15,7 @@ export function QuerySourceDropdownItem({ dataSource, children }) {
QuerySourceDropdownItem.propTypes = { QuerySourceDropdownItem.propTypes = {
dataSource: PropTypes.shape({ dataSource: PropTypes.shape({
name: PropTypes.string, name: PropTypes.string,
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), id: PropTypes.string,
type: PropTypes.string, type: PropTypes.string,
}).isRequired, }).isRequired,
children: PropTypes.element, children: PropTypes.element,

View File

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

View File

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

View File

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

View File

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

3357
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -67,9 +67,9 @@
"path-to-regexp": "^3.1.0", "path-to-regexp": "^3.1.0",
"prop-types": "^15.6.1", "prop-types": "^15.6.1",
"query-string": "^6.9.0", "query-string": "^6.9.0",
"react": "^16.14.0", "react": "^16.13.1",
"react-ace": "^9.1.1", "react-ace": "^9.1.1",
"react-dom": "^16.14.0", "react-dom": "^16.13.1",
"react-grid-layout": "^0.18.2", "react-grid-layout": "^0.18.2",
"react-resizable": "^1.10.1", "react-resizable": "^1.10.1",
"react-virtualized": "^9.21.2", "react-virtualized": "^9.21.2",
@@ -89,7 +89,6 @@
"@cypress/code-coverage": "^3.8.1", "@cypress/code-coverage": "^3.8.1",
"@percy/agent": "0.24.3", "@percy/agent": "0.24.3",
"@percy/cypress": "^2.3.2", "@percy/cypress": "^2.3.2",
"@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
"@types/classnames": "^2.2.10", "@types/classnames": "^2.2.10",
"@types/hoist-non-react-statics": "^3.3.1", "@types/hoist-non-react-statics": "^3.3.1",
"@types/lodash": "^4.14.157", "@types/lodash": "^4.14.157",
@@ -138,19 +137,16 @@
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^1.19.1", "prettier": "^1.19.1",
"raw-loader": "^0.5.1", "raw-loader": "^0.5.1",
"react-refresh": "^0.9.0",
"react-test-renderer": "^16.5.2", "react-test-renderer": "^16.5.2",
"request": "^2.88.0", "request": "^2.88.0",
"request-cookies": "^1.1.0", "request-cookies": "^1.1.0",
"style-loader": "^2.0.0",
"ts-migrate": "^0.1.10",
"typescript": "^3.9.6", "typescript": "^3.9.6",
"url-loader": "^1.1.2", "url-loader": "^1.1.2",
"webpack": "^4.44.2", "webpack": "^4.20.2",
"webpack-build-notifier": "^0.1.30", "webpack-build-notifier": "^0.1.30",
"webpack-bundle-analyzer": "^2.11.1", "webpack-bundle-analyzer": "^2.11.1",
"webpack-cli": "^3.1.2", "webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.11.0", "webpack-dev-server": "^3.1.9",
"webpack-manifest-plugin": "^2.0.4" "webpack-manifest-plugin": "^2.0.4"
}, },
"optionalDependencies": { "optionalDependencies": {

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ const CopyWebpackPlugin = require("copy-webpack-plugin");
const LessPluginAutoPrefix = require("less-plugin-autoprefix"); const LessPluginAutoPrefix = require("less-plugin-autoprefix");
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer") const BundleAnalyzerPlugin = require("webpack-bundle-analyzer")
.BundleAnalyzerPlugin; .BundleAnalyzerPlugin;
const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin"); const fs = require("fs");
const path = require("path"); const path = require("path");
@@ -30,9 +30,6 @@ function optionalRequire(module, defaultReturn = undefined) {
const CONFIG = optionalRequire("./scripts/config", {}); const CONFIG = optionalRequire("./scripts/config", {});
const isProduction = process.env.NODE_ENV === "production"; const isProduction = process.env.NODE_ENV === "production";
const isDevelopment = !isProduction;
const isHotReloadingEnabled =
isDevelopment && process.env.HOT_RELOAD === "true";
const redashBackend = process.env.REDASH_BACKEND || "http://localhost:5000"; const redashBackend = process.env.REDASH_BACKEND || "http://localhost:5000";
const baseHref = CONFIG.baseHref || "/"; const baseHref = CONFIG.baseHref || "/";
@@ -48,8 +45,7 @@ const extensionPath = path.join(__dirname, extensionsRelativePath);
// Function to apply configuration overrides (see scripts/README) // Function to apply configuration overrides (see scripts/README)
function maybeApplyOverrides(config) { function maybeApplyOverrides(config) {
const overridesLocation = const overridesLocation = process.env.REDASH_WEBPACK_OVERRIDES || "./scripts/webpack/overrides";
process.env.REDASH_WEBPACK_OVERRIDES || "./scripts/webpack/overrides";
const applyOverrides = optionalRequire(overridesLocation); const applyOverrides = optionalRequire(overridesLocation);
if (!applyOverrides) { if (!applyOverrides) {
return config; return config;
@@ -101,7 +97,6 @@ const config = {
filename: "multi_org.html", filename: "multi_org.html",
excludeChunks: ["server"] excludeChunks: ["server"]
}), }),
isProduction &&
new MiniCssExtractPlugin({ new MiniCssExtractPlugin({
filename: "[name].[chunkhash].css" filename: "[name].[chunkhash].css"
}), }),
@@ -115,9 +110,8 @@ const config = {
{ from: "client/app/unsupportedRedirect.js" }, { from: "client/app/unsupportedRedirect.js" },
{ from: "client/app/assets/css/*.css", to: "styles/", flatten: true }, { from: "client/app/assets/css/*.css", to: "styles/", flatten: true },
{ from: "client/app/assets/fonts", to: "fonts/" } { from: "client/app/assets/fonts", to: "fonts/" }
]), ])
isHotReloadingEnabled && new ReactRefreshWebpackPlugin({ overlay: false }) ],
].filter(Boolean),
optimization: { optimization: {
splitChunks: { splitChunks: {
chunks: chunk => { chunks: chunk => {
@@ -130,17 +124,7 @@ const config = {
{ {
test: /\.(t|j)sx?$/, test: /\.(t|j)sx?$/,
exclude: /node_modules/, exclude: /node_modules/,
use: [ use: ["babel-loader", "eslint-loader"]
{
loader: require.resolve("babel-loader"),
options: {
plugins: [
isHotReloadingEnabled && require.resolve("react-refresh/babel")
].filter(Boolean)
}
},
require.resolve("eslint-loader")
]
}, },
{ {
test: /\.html$/, test: /\.html$/,
@@ -155,7 +139,7 @@ const config = {
test: /\.css$/, test: /\.css$/,
use: [ use: [
{ {
loader: isProduction ? MiniCssExtractPlugin.loader : "style-loader" loader: MiniCssExtractPlugin.loader
}, },
{ {
loader: "css-loader", loader: "css-loader",
@@ -169,12 +153,12 @@ const config = {
test: /\.less$/, test: /\.less$/,
use: [ use: [
{ {
loader: isProduction ? MiniCssExtractPlugin.loader : "style-loader" loader: MiniCssExtractPlugin.loader
}, },
{ {
loader: "css-loader", loader: "css-loader",
options: { options: {
minimize: isProduction minimize: process.env.NODE_ENV === "production"
} }
}, },
{ {
@@ -274,8 +258,7 @@ const config = {
stats: { stats: {
modules: false, modules: false,
chunkModules: false chunkModules: false
}, }
hot: isHotReloadingEnabled
}, },
performance: { performance: {
hints: false hints: false